Skip to content

feat: Tailwind v4 + FlyonUI admin UI theme#615

Merged
andris9 merged 35 commits into
masterfrom
feature/tailwind-flyonui-theme
Jul 16, 2026
Merged

feat: Tailwind v4 + FlyonUI admin UI theme#615
andris9 merged 35 commits into
masterfrom
feature/tailwind-flyonui-theme

Conversation

@andris9

@andris9 andris9 commented Jul 16, 2026

Copy link
Copy Markdown
Collaborator

Complete reskin of the admin web UI from Bootstrap 4 + SB-Admin-2 + jQuery to Tailwind v4 + FlyonUI, converted page by page with per-page Playwright coverage.

Highlights

  • Central component library in views/partials/ui/ (buttons, badges, modals, tooltips, form fields, stat cards, copy/secret fields, state badges, pagination, menus) backed by static/js/ui.js
  • Brand theme (indigo #4C5CC5 / coral #F8485E, PT Sans) with light + dark modes and a topbar toggle; the embedded ee-client message browser follows the admin theme (ee-client 1.5.0)
  • jQuery fully retired: the bootstrap-compat shim, hand-vendored jQuery and the unused npm dependency are gone; SSE state badges and TLS labels run on FlyonUI tooltips with plain DOM repaints
  • ClipboardJS replaced by a small central handler; FlyonUI plugins (tabs, overlays, dropdowns, accordion, HSTogglePassword) instead of hand-rolled widgets; native datalist autocomplete
  • Open source license listing rebuilt: full license texts, self-contained page, stricter SPDX gate
  • ~125k lines of dead vendored assets deleted across the branch

Testing

  • E2E suite (Playwright, 34 tests across 4 specs): admin shell, per-page smoke and interaction tests, live SSE repaint proof, theme sync, clipboard both paths, TLS provisioning gate
  • Unit and integration tiers unchanged and green in CI

🤖 Generated with Claude Code

https://claude.ai/code/session_01RwJF5u6yxWQHvE8LapWPB3

andris9 added 30 commits July 15, 2026 16:06
… [skip ci]

Phase 0 of the admin UI reskin (Bootstrap 4/SB-Admin-2 -> Tailwind v4 +
FlyonUI, SSR + jQuery retained):

- devDependencies (exact pins, all MIT): tailwindcss, @tailwindcss/cli,
  flyonui, @iconify/tailwind4, @iconify-json/tabler. No charting library
  (apexcharts >= 5.16 has a restrictive dual license and breaks the
  license gate).
- static/css/src/app.tailwind.css: build source with EmailEngine brand
  themes - light (primary #4C5CC5 / secondary+accent #F8485E from the
  logo sheet) and dark (lightened variants) - plus the custom component
  styles ported from app.css/callout.css/emailengine.css, converted to
  FlyonUI theme tokens so both themes work.
- static/css/src/bootstrap-compat.css: un-layered compatibility bridge
  that keeps unconverted Bootstrap markup rendering after the Bootstrap
  bundles are removed (grid, BS-scale spacing, display/flex utils,
  forms, legacy component gaps, Preflight restorations). Shrinks as
  views convert; deleted at the end of the migration.
- PT Sans self-hosted (OFL-1.1): static/fonts/pt-sans/*.woff2 +
  pt-sans-font.css, following the nunito pattern.
- npm run build:css compiles static/css/flyonui.css (committed, bundled
  by pkg); flyonui.js vendored to static/js/ via copy-static-files.sh
  like ace/ee-client.
- licenses regenerated; gate passes with the new dev-only deps.

No template references the new assets yet - no runtime change.

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Phase 1 of the admin UI reskin. The global chrome is now FlyonUI-native
while every unconverted page keeps working through a two-part
compatibility bridge:

- views/layout/app.hbs rewritten: fixed drawer sidebar (FlyonUI
  accordion menu), sticky topbar with search, system-alerts dropdown,
  light/dark theme toggle (localStorage + prefers-color-scheme
  fallback, pre-paint init via views/partials/theme_init.hbs), user
  dropdown, FlyonUI-native tooltips, license/auth warning stat-cards,
  overlay trial modal, footer.
- Central component library started: views/partials/ui/{btn, badge,
  stat-card, modal, page-header, menu-item, menu-section}.hbs -
  parameterized, theme-token-based partials that converted views
  compose from; side_menu.hbs is built from menu-item/menu-section.
- static/js/ui.js (new): showToast (FlyonUI markup), uiDismissFade,
  flash-alert wiring (alerts.hbs is markup-only now), uiModal
  (HSOverlay), uiAutoInit, theme toggle. Loaded by all four layouts.
- static/js/bootstrap-compat.js (new): jQuery Bootstrap shim covering
  the plugin calls and data-toggle/data-dismiss markup that unconverted
  inline scripts rely on (modal incl. shown/hidden.bs.modal events,
  alert incl. closed.bs.alert, collapse with data-parent accordion,
  tabs, dropdowns, tooltip/popover bubbles). Stamps legacy dropdown
  menus so bootstrap-compat.css can scope its styling; single
  delegated ancestor walk per click; tracked open-dropdown/tooltip
  state instead of document-wide scans.
- app.js: showToast moved to ui.js; the /admin/changes EventSource is
  now gated on body[data-sse-changes] so the login/public layouts stop
  opening an authenticated SSE stream that errored on the console.
- workers/api.js: eeColor handlebars helper - single translation point
  from Bootstrap-era server color names (danger) to FlyonUI (error);
  fixes the previously unstyled bg-danger system-alert icon circles.
- bootstrap-compat.css slimmed after a census sweep: dead SB-Admin
  rules dropped, Tailwind-identical color utilities removed, collision
  list documented (spacing 3/4/5, .shadow, .container), legacy
  dropdown styling scoped to shim-stamped menus.
- e2e: test/e2e/pages-admin.spec.js - per-page smoke suite for the new
  theme (shell render, accordion, dropdowns, theme toggle persistence,
  toast, login redirect; all assert zero console errors). Shared
  ADMIN_PASSWORD now exported from helpers/bootstrap.js.

Verified: lint, format, licenses gate, 1394 unit tests, 6 shell e2e
tests, Playwright screenshots light+dark with zero console errors, and
compat smoke checks (tooltips/modals/collapse) on six unconverted
pages. Security review: no findings.

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
…with datalists

Phase 2 of the admin UI reskin:

- All 8 section header partials (accounts/gateways/tokens/templates/
  webhooks/security/oauth-config/document-store) now compose from
  ui/page-header (new icon slot, additive class contract, caller-owned
  margins). oauth_tabs and the document-store tab strip converted to
  FlyonUI tabs; the deprecation notice uses ui/stat-card.
- add_account_modal rewritten as a ui/modal overlay with ui/form-field
  and ui/btn; focus/reset behavior moved to FlyonUI open.overlay/
  close.overlay events; the two trigger sites (dashboard, accounts
  index) switched from data-toggle to data-overlay.
- bootstrap-autocomplete removed: all four sites now use native
  datalist suggestions (smtp + imap-proxy listen addresses,
  accounts/edit sent-mail folder paths, gateway well-known-service
  picker incl. host/port/secure autofill on input). Script tag dropped
  from the layout; the $.fn.button version stub removed from the shim.
- ui/form-field partial added (label + control + hint + error,
  is-invalid contract, optional built-in datalist via options param).
- Bootstrap neutral-gray "secondary" bridged in compat CSS
  (btn-secondary/badge-secondary -> neutral) so legacy Cancel buttons
  and Disabled/Paused state badges do not turn brand-coral; converted
  views use variant="neutral" (documented in ui/btn and the collision
  list, which also now spells out the *-[1rem] spacing rule).

Deviation from the phase plan: the four large single-consumer form
partials (oauth_form, webhook_form, template_form, gateway_form) move
to their page batches where per-page e2e verification happens.

Verified: datalist suggestions + gateway autofill, modal overlay events
(focus, clear-on-close, Escape), converted headers/tabs on live pages
with zero console errors; lint, format, 1394 unit tests, 6 shell e2e
tests. Security review: no findings.

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Dashboard batch (per-page pipeline):

- views/dashboard.hbs rewritten: warning/welcome cards and all stat
  tiles compose from ui/stat-card; the three BullMQ queue tiles share a
  view-local queueCard inline partial (the old third copy had already
  drifted into broken markup); counters iterate with a |counter| block
  param so the title="" anti-fallthrough hash cannot mask their fields;
  responsive grid replaces the Bootstrap row/col layout; the software
  versions panel is a native details/summary with a rotating chevron
  (webkit marker hidden for older Safari).
- New ui/tooltip partial (FlyonUI hover tooltip, [--placement:*]
  variants safelisted) replaces ~15 legacy data-toggle tooltips.
- lib/ui-routes/dashboard-routes.js now emits full iconify classes for
  counter icons (safelisted in app.tailwind.css since lib/ is not
  scanned; coupling documented on both sides).
- e2e: dashboard test added to pages-admin.spec.js (stat cards,
  FlyonUI tooltip on hover, versions panel toggle, zero console
  errors).

Verified: light+dark screenshots with all icons/titles/accents, FlyonUI
tooltips working, details toggle, 7 e2e tests green, lint/format/unit
(1394) green. Combined cleanup+security review applied (queue-card
extraction, producer-side safelist comment, summary affordance);
security: no findings.

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Accounts batch (per-page pipeline; register/* pages move to Phase 4
with their public layout):

- accounts/index.hbs: FlyonUI table with row-overlay links (interactive
  badge/tooltip cells lifted above the overlay so hover popovers and
  clicks coexist; duplicate overlay anchors removed from the tab
  order), ui/page-header with action slot, ui/pagination + ui/breadcrumb
  partials (new).
- accounts/account.hbs: toolbar of tooltipped soft buttons (Tabler
  icons), FlyonUI dropdowns for logs and the gateway delivery test, all
  five modals on ui/modal, scripts rewired from jQuery Bootstrap events
  to FlyonUI open.overlay/close.overlay, .test-send-trigger click
  delegation replaces the Bootstrap relatedTarget contract, export
  wizard polling modernized (hidden class, FlyonUI badge colors),
  ee-dl description-list component replaces dl.row grids.
- accounts/edit.hbs: composed from ui/form-field and the new
  ui/checkbox partial; datalist via the shared list param; password
  placeholders via the new `when` helper.
- accounts/browse.hbs: ee-client confirm/alert dialogs on ui/modal +
  uiModal with once-only close.overlay resolution (fixes the inherited
  listener accumulation).
- test_send.hbs: client-side rendered SPF/DKIM/DMARC results as native
  details panels; repetitive badge chains collapsed via browser-side
  statusColor/statusIcon helpers (icons safelisted).
- ui/tooltip: inline-partial body fallback removed - it resolved
  unreliably through nested partial chains and crashed fresh-instance
  renders with "The partial tooltipBody could not be found"; composed
  bodies now use the new `concat` handlebars helper. HTML entities in
  partial hash strings replaced with plain characters (hash params are
  escaped on output, so entities rendered literally).
- Security hardening from review: IMAP ID support-url is only rendered
  as a link when it passes an https?:// check in account-routes.js (the
  value is attacker-controllable by a hostile IMAP server, and the old
  template quoting had silently broken the link).
- route-helpers.js: composed type.label for type-icon tooltips.
- e2e: four accounts-page tests added (list + add-account overlay
  focus/reset contract, detail toolbar/modals, edit form, ee-client
  browse init); account-dependent tests skip on standalone runs.

Verified: all pages light+dark with zero console errors against a live
account (tooltips, modals, dropdowns, export modal open.overlay flow,
delivery-test trigger, datalist, breadcrumbs), 8 e2e tests green,
lint/format/licenses, 1394 unit tests. Review applied (S1 + C1-C7);
security: one finding, fixed (support-url).

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Gateways batch (per-page pipeline):

- gateways/index.hbs: FlyonUI table with row-overlay links, ui/page-header
  action slot, ui/pagination, info stat-card.
- gateways/gateway.hbs: tooltipped toolbar, ee-dl description list,
  delete confirmation on ui/modal; the state badge data-account now uses
  the gateway id (was the whole object, so the SSE selector could never
  match).
- gateways/new.hbs + edit.hbs: composed from ui/form-field via the
  reworked gateway_form partial (well-known-service picker keeps its
  basicAutoComplete hook for the datalist autofill), card-header actions
  dropdown (Test Connection) converted to a FlyonUI dropdown; verified
  live against Ethereal ("Connection successful" toast).
- e2e: full gateway create/view/delete flow test; the suite now logs in
  once per run via a storageState captured in beforeAll - per-test
  logins were tripping the server's login rate limiter as the suite
  grew (manual contexts must pass storageState: undefined because
  Playwright fixture options are inherited). Create-redirect wait
  anchored to the detail URL to avoid racing the form POST.

Verified: all four pages with a live gateway (row navigation, tooltips,
delete modal, actions dropdown, real SMTP test connection), zero console
errors; 9 e2e tests green in 14s; lint/format; 1394 unit tests. Review
applied (e2e redirect race, badge selector); security: clean.

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Tokens + templates batches (per-page pipeline):

- tokens/index.hbs: list with tooltipped token-id + per-row delete
  buttons wiring the confirmation modal via dataset, usage panel on the
  new ui/details partial.
- tokens/new.hbs: scope checkbox list (or-else-all hooks preserved),
  one-time token reveal on ui/modal with the NEW static=true mode -
  review caught that the conversion had dropped the old static
  backdrop, letting a stray Escape destroy the one-time secret;
  ui/modal now supports [--overlay-backdrop:static] +
  data-overlay-keyboard="false". Loader swapped to the spinner idiom.
- templates/{index,new,edit,template}.hbs + template_form.hbs: FlyonUI
  data-tabs drive the ACE editor panes (replacing Bootstrap tabs),
  send-test modal with open.overlay reset + gated send button,
  Handlebars-payload panel on ui/details, delete modals, ui/form-field
  composition, breadcrumbs.
- SYSTEMIC FIX uncovered by the e2e run: block-form ui/* partials
  leaked their own hash params into nested partial calls inside their
  blocks (every button inside a ui/modal inherited the modal id -
  duplicate DOM ids on all converted modals; the dashboard welcome
  button silently inherited the stat-card's variant). All eight
  block-form partials now neutralize their own param names when
  invoking @partial-block. Page sweep confirms zero duplicate ids
  (except the pre-existing layout/page #crumb overlap) and zero console
  errors across all converted pages.
- New ui/details partial replaces the four hand-rolled details/summary
  collapsible cards (dashboard, templates, tokens, template payload).
- e2e: tokens list/delete-modal test (reusing the createApiToken
  bootstrap helper; deterministic opacity-based overlay settle wait)
  and a templates create/tabs/send-test/delete flow test.

Verified: tokens + templates pages against live data (FlyonUI tabs with
ACE, reveal flow incl. redirect, delete modals, spinner states), page
sweep for duplicate ids/console errors, 11 e2e tests green, lint/
format, 1394 unit tests. Reviews applied (S1 static backdrop, C1-C5).

Claude-Session: https://claude.ai/code/session_01N4gy7CNzkic59TJZU1X9Yu
Webhooks batch of the admin UI reskin (4 pages + partials), each page
investigated, converted, and verified per the page pipeline:

- views/webhooks/index.hbs: page header, info stat-card, stretched-link
  table rows with ui/badge status (Failing/Enabled/Disabled), pagination.
- views/webhooks/webhook.hbs: toolbar with ui/tooltip buttons, ee-dl
  definition list, FlyonUI data-tabs for the fn/map/error-log panes
  (ACE previews untouched), delete confirmation via ui/modal; the error
  log pane now only renders when an error log exists; legacy
  clear-alert-btn alert kept for the Phase 5 app.js cluster.
- views/webhooks/{new,edit}.hbs: breadcrumbs + ui/btn submits.
- views/partials/webhook_form.hbs: ui/form-field, ui/checkbox,
  ui/details for custom headers, editor chrome via .code-editor +
  .editor-embed-* component classes.
- views/partials/webhooks_editor_functions.hbs: setPayloadModal on
  ui/modal + uiModal.open; the filter evaluation result now toggles
  semantic text-error instead of compat-bridged text-danger and drops
  the initial text-info placeholder (layered text-error sorts before
  text-info, so the leftover class would win the cascade).
- NEW views/partials/ui/tooltip-rich.hbs: central FlyonUI tooltip with
  a rich HTML body (block = body, trigger icon param); replaces the old
  data-html editor-scope popover and covers the upcoming popover-heavy
  config batches. editor_scope_info.hbs is now a thin call into it.
- ui/badge: render numeric 0 labels (if includeZero=true) instead of
  failing on the absent @partial-block; custom-header badges use the
  badge block form.
- Duplicate #crumb ids swept from all converted pages (the app layout
  already renders the #crumb element that scripts read; form inputs
  keep name="crumb", standalone JS-only inputs removed.

Verified with Playwright against a live instance in light + dark:
create/edit/delete round-trip, live filter/map evaluation via the
worker, payload modal with predefined payloads, scope tooltips, editor
fullscreen toggle, webhook preview send, zero console errors and no
duplicate DOM ids. New e2e test in pages-admin.spec.js covers the
create -> evaluate -> tabs -> delete flow; full spec green. Cleanup +
security review agents ran on the batch (all findings applied; no new
vulnerabilities found).
EOF
)
Config-core batch of the admin UI reskin (network, logging, license,
webhooks, smtp, imap-proxy, ai, service, service-preview), each page
investigated, converted, and verified per the page pipeline:

- NEW ui/setting-ref partial: the floated [settingKey] swagger reference
  shown on nearly every config field (link, plain env-var, suffix, and
  class modes); used across all converted config pages.
- ui/page-header: badgeLabel/badgeVariant slot for status badges next to
  the title (license, config/webhooks; smtp/imap-proxy join in Phase 5
  when their SSE badges retarget).
- ui/checkbox: disabled, labelTitle, dataReverse params; ui/details:
  summarySmall suffix; ui/btn: external param (target/rel/referrerpolicy
  for external links; also backported to the templates index doc link);
  ui/tooltip-rich: label (text trigger) alternative to icon.
- network: browser-compiled address_list.hbs converted (FlyonUI markup
  only - it cannot use server partials), delete dropdown replaced with a
  delegated trash button that survives client-side re-renders,
  uiAutoInit() after list redraws, scan button de-bootstrapped (and
  given type="button" - the original submitted the settings form).
- smtp/imap-proxy: join-based password reveal, FlyonUI tabs for the
  integration examples, TLS cert modal on ui/modal with hidden-class
  state toggling; fixed the original's shown.bs.modal handler
  accumulation (duplicate cert fetches on reopen); dropped dead
  NODEMAILER/PHPMAILER replaces from the imap-proxy example renderer.
- ai: privacy popover on ui/tooltip-rich, test-filter/test-prompt modals
  on ui/modal, flask/spinner icon swap in iconify classes, evaluation
  script drops the text-info placeholder before adding text-error (the
  layered text-error sorts before text-info in the compiled CSS).
- service: language-override collapse as a native details element,
  header-template preview image on ui/tooltip-rich, queue-clean and
  model-reload spinner buttons standardized on ui/btn + icon class
  hooks, duplicate section ids deduped (imap_settings/script_settings).
- layout/app.hbs: the global #crumb input now renders unconditionally -
  it sat inside the trialPossible conditional, so on licensed instances
  every fetch-based admin action would have lost its CSRF crumb source
  once the page-local duplicates were removed (security-review catch).
- Legacy contracts kept for Phase 5: SSE state badges (smtp/imap-proxy
  headers), clear-alert-btn alerts (config/webhooks, ai), the
  shim-backed tls-label tooltip repaint.

Verified with Playwright against a live instance in light + dark: IP
rescan round-trip with client-side Handlebars re-render + delegated
delete modal, or-else-all gating in both directions, integration-example
tabs + live re-render, cert-label repaint path, AI live filter
evaluation + payload/test-prompt modals, service preview window, queue
cleanup toasts; zero console errors, no duplicate DOM ids. Seven new
e2e tests in pages-admin.spec.js; full spec green. Cleanup + security
review agents ran on the batch (findings applied; the crumb regression
above was the one security-relevant catch).
Config/oauth batch of the admin UI reskin (apps index, subscriptions,
new, edit, app detail + oauth_form/scope_info partials), each page
investigated, converted, and verified per the page pipeline:

- index/subscriptions: FlyonUI dropdown for the create-app provider
  menu, stretched-link table rows, ui/badge statuses, ui/pagination;
  status badges with dynamic titles keep native title tooltips lifted
  above the row overlay.
- oauth_form (1445 lines): provider info cards on ui/stat-card, fields
  on ui/form-field block form, base-scope radio tables with FlyonUI
  radios, account-type helper as a joined button group, Additional and
  Disabled scopes as ui/details (the scope-preset JS now toggles the
  details open property instead of the Bootstrap collapse show class);
  the disabled-scopes textarea renamed to skipScopesList, fixing a
  pre-existing duplicate id with the collapse container the JS actually
  targeted. The auth-method switcher stays custom JS (deliberately not
  data-tabs) with FlyonUI tab styling and hidden-class sections.
- new/edit: breadcrumbs via ui/breadcrumb; the ~110-line credential
  file-loader + auth-method script that was duplicated verbatim across
  both pages now lives once in oauth_form.hbs next to the markup it
  targets.
- app detail: toolbar on the established tooltip+soft-button idiom,
  ee-dl with a joined copy-to-clipboard control, verify-setup modal
  ported to ui/modal with open.overlay auto-run, iconify status icons
  in the step renderer (esc() wrapping preserved on all server-supplied
  fields), pub/sub badges composed from ui/tooltip + ui/badge.
- views/config/oauth.hbs converted too but found to be ORPHANED (no
  route renders it; the /admin/config/oauth-legacy paths are gone) -
  flagged for deletion in the Phase 5 cleanup.

Verified with Playwright against a live instance in light + dark for
all five provider variants: base-scope gating, account-type presets
filling and expanding/collapsing the scope sections, authority/tenant
gating, auth-method tab switching, create -> detail -> verify-setup
(auto-run renders steps + verdict) -> edit prefill -> delete round
trip; zero console errors, no duplicate DOM ids. New e2e test in
pages-admin.spec.js; full spec green (20 passed). Cleanup + security
review agents ran on the batch (findings applied; no new
vulnerabilities found).
Document-store batch of the admin UI reskin (settings, chat, mappings
index/new, pre-processing). Deprecated feature, so this is a straight
conversion on the established idioms - no redesign:

- settings/chat/pre-processing: enable-strips with setting-refs and
  status pills, ui/form-field blocks, test-connection and try-chat
  buttons on ui/btn with iconify spinner toggles, tryChatModal and
  setPayloadModal on ui/modal (open.overlay reset listener replaces
  show.bs.modal), chat response rendering keeps its textContent-only
  sinks, hidden-class state toggling replaces d-none throughout.
- chat enable label composes ui/tooltip (ElasticSearch version note)
  and ui/tooltip-rich (document-store-disabled warning) triggers.
- mappings: ui/stat-card intro, searchable badges on ui/badge,
  add-mapping confirmation on ui/modal, index-status popover on
  ui/tooltip-rich, breadcrumb via ui/breadcrumb.
- pre-processing: same evaluation-script conversion as ai.hbs and the
  webhooks editor (text-error rename with the text-info placeholder
  removed - compiled-CSS order trap), editor grid on sm:col-span-*.
- playwright.config.js: the e2e server now boots with
  EENGINE_DOCUMENT_STORE_ENABLED=true so the gated pages render and can
  be smoke-tested; the gate-on/setting-off state reuses the tested
  setting-off runtime paths.

Verified with Playwright against a live gate-enabled instance in light
+ dark: test-connection toast, try-chat round trip (error path +
spinner restore), mapping confirmation modal + rich tooltip, live
filter evaluation and payload modal in pre-processing; zero console
errors, no duplicate DOM ids. New e2e test covers all four page types;
full spec green (21 passed). Note: after a failed chat request the
overlay no longer closes on Escape because the disabled/re-enabled
submit button drops focus to body and FlyonUI needs focus inside the
overlay (Bootstrap's focus trap masked this) - Close/backdrop work,
documented in the e2e test. Combined quality + security review agent
ran on the batch: clean, no findings to apply.
Internals batch of the admin UI reskin (system threads + thread
accounts), both pages investigated, converted, and verified per the
page pipeline:

- index: thread table with unresponsive rows on bg-error/10,
  configuration and error popovers on ui/tooltip-rich, health badges on
  ui/badge with the route now emitting FlyonUI variants (error/neutral
  instead of compat-bridged danger/secondary), snapshot/restart circle
  buttons wrapped in ui/tooltip (the main-thread placeholder keeps its
  invisible slot for column alignment), kill/snapshot confirmation
  modals on ui/modal with the row buttons filling the hidden thread
  inputs before uiModal.open.
- thread: ui/breadcrumb, stretched-link account rows, the provider
  type icon on the accounts-index ui/tooltip idiom (the legacy
  title/data-toggle attributes were unreachable under the row overlay),
  SSE state badge kept byte-identical to accounts/index.hbs including
  the z-lifted wrapping link, ui/pagination.
- swagger and arena wrappers audited: pure embeds with no Bootstrap
  markup, nothing to convert. views/index.hbs and oauth-scope-error.hbs
  moved to the Phase 4 batch - they render under the still-unconverted
  main/public layouts.

Verified with Playwright against a live instance in light + dark:
modals fill and open per row, config/error tooltips, thread detail
with breadcrumb + state badge + type tooltip; zero console errors, no
duplicate DOM ids. New e2e test covers both pages with a three-branch
thread-detail fallback (accounts link, empty IMAP worker, or the
non-IMAP redirect guard) because late in a full-suite run the e2e
instance stops listing its IMAP/webhooks/submit threads (pre-existing
environment behaviour, unrelated to the theme). Full spec green
(22 passed). Combined quality + security review agent ran on the
batch; all findings applied (type-tooltip idiom, route badge variants,
w-32, thread-page e2e coverage), security clean.
Phase 4 of the admin UI reskin: the three remaining layouts (login,
public, main) and every page that renders under them, plus the two
app-layout account pages:

- layouts: theme_init + PT Sans + flyonui.css/flyonui.js +
  bootstrap-compat.js + ui.js replace the Bootstrap/SB-Admin stacks;
  the login/public shells keep their split-card and centered-card
  structure on a brand primary-to-secondary gradient; the
  .bg-login-page-image rule is ported into app.tailwind.css (it had
  been left behind in the old app.css). The main layout footer comment
  no longer embeds the license key - that layout serves
  unauthenticated pages (/ and /license.html), so the key was visible
  in public page source (carried over from the old theme;
  security-review catch).
- account/login + account/totp: pill inputs, FlyonUI divider/buttons;
  the pages keep their own #crumb (the login layout renders no global
  one; login-passkey.js reads it by id).
- account/security: status grid rows, five modals on ui/modal, passkey
  register flow (passkey-register.js now opens through uiModal), Okta
  and OIDC instruction tables restyled.
- account/password, legal, upgrade, index, oauth-scope-error, error,
  unsubscribe (#resubscribeModal id and resubscribe-link contract
  preserved; uiModal.open replaces the jQuery call), license (eulaText
  wrapper).
- accounts/register/*: hosted-form pages converted with all e2e ids
  intact (#imap_*/#smtp_*, #submit-settings-btn-down/#submit-wo-testing
  split dropdown now on the FlyonUI join+dropdown pattern); the
  verify-connection JS swaps iconify classes and btn-primary <->
  btn-neutral (btn-secondary is off-limits in converted views); the
  connection-error list renders into an ee-dl; the appPassword XSS fix,
  DOM-API-only error rendering, and the single-use-nonce double-submit
  guard are preserved verbatim.
- i18n: every {{_ ...}} msgid byte-identical to master (verified by
  extraction diff) so existing translations keep working.

Verified with Playwright against a live instance: fresh-context login
page render (light + dark), full hosted-form walk with a live Ethereal
IMAP/SMTP verify round trip (spinner -> check icon -> submit enable ->
split dropdown -> skip-verification -> redirect page), all main/public
pages, security modals; zero console errors. Two new e2e tests
(main-layout public pages; security modals + password form) and the
full four-spec e2e suite is green (30 passed) - including login,
hosted-form, and unsubscribe flows that now run through the converted
layouts. Cleanup + security review agents ran on the phase (findings
applied; no security regressions).
Phase 5 of the admin UI reskin - the last Bootstrap remnants are
retargeted or deleted:

- State badges: every emitter (route-helpers formatAccountData and
  formatServerState, the app.js SSE maps, the gateway label builders in
  admin-entities-routes, the internals fallback, the cert labels in
  workers/api.js) now emits FlyonUI variants (danger->error,
  secondary/light->neutral); badge-pill markup became rounded-full and
  the SSE class-swap exception for it was dropped. Flash alert types
  map through the eeColor helper at the template boundary, so the 140+
  request.flash call sites keep their semantic names.
- Icons: account-type/provider icons (route-helpers, oauth2-apps) and
  the systemAlerts list (workers/api.js) emit full iconify classes,
  rendered as spans; showToast maps its legacy icon names to iconify
  classes; the SSE spinner and pending-form submit spinner inject
  iconify classes; every server-emitted class is safelisted in
  app.tailwind.css (lib/ and workers/ are not scanned).
- clear-alert-btn error alerts: app.js binds the close button directly
  (removes the alert + clears the stored error server-side); the
  closed.bs.alert contract is gone.
- bootstrap-compat.js rewritten as a residual tooltip/popover-only shim
  for the SSE state badges and TLS cert labels; it also gains hover
  delegation for data-trigger="hover" popovers - the migration-era shim
  only opened popovers on click (and preventDefault()ed the badge's
  wrapping link), so badge hover popovers never actually worked; the
  unsanitized data-html popover path is deleted (textContent only now).
- bootstrap-compat.css rewritten to just the shim bubbles + the .close
  button; the entire Bootstrap bridge (grid, spacing, forms,
  components) is gone. A systematic sweep found one live orphan
  (font-weight-bold on the Subconnections heading -> font-bold).
- Deleted: bootstrap-4.6.2-dist, sb-admin-2 css/js, fontawesome-free
  (~16MB), nunito fonts, jquery-easing, bootstrap-autocomplete,
  moment-with-locales, popper, the stray jquery 3.4.1 slim copy,
  static/icons (~330 svg files, showToast-only), app.css, callout.css,
  emailengine.css, and the orphaned views/config/oauth.hbs (no route
  renders it). Font Awesome links removed from all four layouts.
- startbootstrap-sb-admin-2 removed from dependencies and .ncurc.js;
  static/licenses.html regenerated without it. Dead card-subtitle
  class swept; main layout footer keeps the version-only comment.

Verified with Playwright against a live instance: retargeted state
badges render and update live through an SSE reconnect round trip,
toast/systemAlert/type/provider icons all render as iconify spans with
Font Awesome fully unloaded (zero fa elements, zero console errors),
gateway/tls badges, hover popover delegation. Full four-spec e2e suite
green (30 passed) and the unit tier passes. Combined quality +
security review agent ran on the phase: three missed retargets found
and fixed (gateway error labels, internals Unknown fallback, the
font-weight-bold orphan); security clean - the shim rewrite removes an
unsanitized innerHTML path.
Final-gate pass over the complete migration branch. Two independent
whole-branch review agents (quality + security) audited the full
master...HEAD diff; the security review found no regressions (and the
branch nets out safer: identical raw-HTML surface to master, all POST
forms CSRF-covered, CVE-carrying vendored JS removed, remote-controlled
support-url now validated). Quality findings applied:

- ui/page-header: the badgeLabel slot leaked the partial's own icon and
  class hash params into the nested ui/badge call - the license and
  config/webhooks title badges rendered a spurious icon and margin;
  the nested call now neutralizes them.
- badge-neutral: FlyonUI ships no such variant, so the neutral state
  badges (disabled/paused/unknown) only looked right because the base
  .badge shares the tokens; app.tailwind.css now defines the variant
  explicitly so the SSE class swaps stay meaningful.
- static/index.html deleted: an unlinked old-theme page that referenced
  six stylesheets this branch removed - it was publicly served and
  rendered broken.
- Enable-strip alignment: config/imap-proxy and config/webhooks now use
  the same bordered feature-toggle strip as their smtp/ai/document-store
  siblings.
- ui/setting-ref adopted at the two remaining byte-identical hand-rolled
  reference blocks (accounts/edit, document-store pre-processing);
  stale bridge note in ui/btn docs reworded.
- CLAUDE.md gains the promised Admin UI Theme section: build pipeline
  (build:css + committed artifacts), brand tokens, the ui/ partial
  library conventions (hash-param neutralization, safelist trap,
  entity-in-hash-string trap), iconify-only icons, the residual
  bootstrap-compat shim contract, and the no-prettier-on-hbs rule.

Deliberately not changed (reviewed and accepted): documented-but-unused
partial params kept as API surface, referrerpolicy spelling variance on
_blank links (rel=noreferrer already governs), address_list.hbs
hand-rolled tooltip (browser-compiled template cannot use partials),
the 4-level breadcrumb in templates/edit.hbs, and the pre-existing
orphan candidates in static/ (vendor/jquery, front/, spare logos).

Final gates all green: full npm test (1394 unit + 140 integration, 0
failures), complete four-spec e2e suite (30 passed), licenses gate
exits 0, compiled CSS/JS artifacts byte-identical to fresh builds.
FlyonUI's Tabs script toggles a plain `active` class, but its stylesheet
only styles `.tab-active`, so nothing matched `.tab.active` and every tab
strip rendered the selected tab identically to the unselected ones. The
panes switched correctly - only the highlight was missing.

Use FlyonUI's `active-tab:` variant (it compiles to `[data-tab].active`)
to apply `tab-active` on the data-tabs strips. The OAuth auth-method
strip is driven by its own page script rather than data-tabs, so the
variant cannot reach it; that script and its markup now toggle
`tab-active` directly.

The e2e tests clicked these tabs and asserted pane visibility, which is
why they stayed green through the bug. Add expectSelectedTab(), which
compares the computed color against the theme's resolved --color-primary
(theme-agnostic) and parks the pointer first, since hover also paints a
tab primary and would mask a regression. Each assertion was verified to
fail on the unfixed markup. The OAuth strip had no tab coverage at all,
so it gets some.
…dler

The vendored clipboard.min.js (v2.0.0, 2017) served three admin pages
through the .copy-btn init in app.js. Replace it with a delegated click
handler in ui.js: data-copy-target names the element to copy, the async
Clipboard API does the write, and installs served over plain HTTP (not a
secure context) fall back to select() + execCommand. Successful copies
flash the button icon to a check mark - ClipboardJS gave no feedback at
all. Delegation also means buttons inside dynamically injected markup
work without re-binding, which the load-time ClipboardJS init did not.

The three near-identical input+button blocks collapse into a new
ui/copy-field partial (small/join/class/inputClass variants cover the
existing sites), so the id/data-copy-target pairing is correct by
construction. The vendored library and its script tags are removed from
all three layouts; login/public never had a copy button to begin with.

E2e covers both write paths: the templates detail page exercises the
real Clipboard API (grantPermissions + readText + icon feedback), a
synthetic fixture with navigator.clipboard stubbed out exercises the
execCommand fallback, and the oauth app page covers the join-variant
partial wiring.
Three config pages (SMTP, IMAP proxy, service) each carried a 10-line
inline script that swapped the password input's type attribute. FlyonUI's
HSTogglePassword plugin does this declaratively via data-toggle-password,
so the scripts go away, and the button now swaps between eye/eye-off
icons through the vendor password-active: variant (shipped by the
strong-password plugin's variants.css, which flyonui/variants.css already
imports - toggling adds `active` on the button and the variant compiles
to a descendant selector).

The input+button join group was near-identical at the three sites, so it
becomes a ui/secret-field partial: the id/name/reveal-target triple and
the data-lpignore/autocomplete boilerplate are now correct by
construction. The conditional-on-value placeholder wrappers were
redundant (placeholders only display while the input is empty) and are
dropped.

E2e: a shared expectPasswordToggle helper asserts the full round-trip on
all three pages - type flips to text with the eye-off icon shown, then
back. The old imap-proxy assertion only checked the one-way type flip.
The TLS status badges on the SMTP and IMAP proxy config pages were the
last data-toggle="tooltip" consumers of the bootstrap-compat shim, and
their paintCertData() repaint carried the only inline jQuery calls left
in views/ (the $().tooltip('dispose'/'enable') dance). Wrap the badge in
the standard ui/tooltip partial instead; the repaint now just writes the
status text into the tooltip body, since HSTooltip reads content live and
needs no re-init.

paintCertData() itself was byte-identical on both pages, takes no page
context, and this change would have grown both copies - it moves to
ui.js and the page scripts call the shared version.

E2e (expectTlsLabelTooltip, both pages): the tooltip body carries the
certificate status text in the exact structure paintCertData() targets,
and hovering the badge opens the FlyonUI tooltip.
The last legacy contract from the Bootstrap migration was the jQuery
tooltip/popover shim serving the SSE state badges. Those badges (five
account/gateway/internals views plus the SMTP and IMAP proxy page
headers) now render through a new ui/state-badge partial: a FlyonUI
tooltip carries the connection error, hidden while there is no error.
The SSE handlers repaint through plain DOM writes - a shared
repaintStateBadge() swaps the badge class/spinner/text and toggles the
tooltip body, replacing the $().popover() enable/disable dance (the two
updaters had carried byte-identical loop bodies; now there is one).

The page-header badges gain a working error tooltip: they were SSE
targets but never carried the popover markup the shim triggered on, so
their error bubbles could never actually show.

With no consumers left: bootstrap-compat.js, bootstrap-compat.css (the
.close rules move into app.tailwind.css) and the hand-vendored jQuery
3.6.0 are deleted, the script tags are gone from all four layouts, and
the unreferenced jquery 4.0.0 npm dependency + static/vendor/jquery
copies are removed (license listing regenerated). CLAUDE.md now
documents the admin UI as jQuery-free.

E2e: an account with deliberately failing IMAP auth drives its badge
into the error state live on the accounts list (SSE repaint), asserting
the badge class/text swap, the tooltip body text, unhiding, and the
hover bubble. Runs last in the spec - a mid-deletion account transiently
404s the internals thread pages, so cleanup polls the account away.
…[skip ci]

Evaluated migrating the oauth_form auth-method strip to HSTabs: each
method shows two section blocks interleaved with fields shared by both
methods, and HSTabs maps one toggle to exactly one content element, so
the migration would need field duplication or a form reshuffle. The
25-line script stays; the comment now records the reasoning.
The same 24-line .toggle-fullscreen block (click toggles .full-screen-div
on the editor container, Escape exits, editor resized and refocused on
both transitions) was copy-pasted in six page scripts. It moves to ui.js
as uiEditorFullscreen(editors); the pages pass the id->ace-instance Map
they already maintain, which also filters out the styling-only
.toggle-fullscreen links that have no editor attached.

E2e: the templates form test runs the fullscreen round-trip through the
shared helper at one representative call site.
The build-a-datalist-and-attach-inputs block (the replacement for the
old bootstrap-autocomplete plugin) was duplicated on four pages with
small variations. It moves to ui.js as uiDatalist(id, values, inputs);
the pages keep only what genuinely differs: the smtp/imap-proxy pages
pass their interface-address lists, accounts/edit references the list id
from markup, and the gateway form maps its "key / description" labels
and keeps the page-local listener that autofills host/port/secure when a
well-known service is picked.

E2e: the gateway test types "gmail" into the name field and asserts the
host autofill (datalist + listener end to end); the smtp and imap-proxy
tests assert the listen-address inputs are wired to a populated
suggestion list.
Three defects reported on the editor blocks:

- The toolbar's title cell ("Filter function" etc.) was styled identically
  to the clickable action cells. Title cells get a new editor-embed-title
  class (tinted background, semibold), and the action links render in the
  primary color with hover underline - Tailwind's preflight had stripped
  the anchor styling Bootstrap used to provide, leaving everything
  identical black text.

- Toggle fullscreen collapsed the editor to a 2px strip. ACE injects its
  own un-layered .ace_editor{position:relative} stylesheet at runtime,
  which beats the @layer components .full-screen-div rule, so the fixed
  positioning never applied. The rule moves out of the layer with
  !important on the geometry (comment documents the cascade reasoning
  and the layered-!important footgun).

- The scope-info hover rendered as unreadable ghost text. ui/tooltip-rich
  put block content (a ul) inside the inline tooltip-body span, and an
  inline box fragments its background around block children - the bubble
  background painted as two tiny blobs while the light bubble text sat
  bare over the white editor. The body is now display:block.

E2e assertions are effect-based, since the class-presence fullscreen
check stayed green through the bug: the fullscreen rect must cover the
viewport, the tooltip body must paint a non-transparent background that
encloses the list, and the title cell background must differ from the
action cells.
Initiating TLS certificate generation on the SMTP or IMAP proxy config
pages without a usable Service URL showed "Setting up TLS certificate
for false". getServiceDomain() returns a sentinel when the URL is
unset, an IP, localhost or a .local/.lan name - and the templates
interpolated it raw, so data-domain rendered as the string "false",
which is truthy and sailed through the page script's provisioning
guard.

The sentinel becomes an empty string (interpolates to nothing; every
consumer only ever truthiness-checks it, verified across all callers),
the data-domain attribute and the modal's domain text render only when
a domain exists, and the TLS checkbox is disabled without one - except
when TLS is already enabled, so it can still be switched off after the
Service URL was changed to something unusable. The helper text under
the checkbox already explains how to set the domain.

Server-side this was never exploitable: the provisioning endpoint takes
no domain from the client and the ACME call was already guarded on the
settings-derived hostname - the UI just pretended otherwise.

E2e: clears serviceUrl through the settings API, asserts the checkbox
is disabled with no data-domain on both config pages, restores the
setting in a finally block, and asserts both pages are operable again.
The stat/notice cards still carried SB-Admin-2's signature look - a
colored start-edge stripe (border-s-4) with a bold uppercase colored
label - which read as Bootstrap inside the FlyonUI theme. The variant
color now lives in a tinted rounded-field icon container (the reference
dashboard's avatar-placeholder pattern) on the plain card shell every
other card uses.

The partial keeps its API and both roles: with a title the icon+title
form a notice-card header row; without one the icon anchors to the top
of the content row (items-start, so it does not float mid-height next
to tall notice bodies). The dashboard's inline KPI labels drop the
uppercase colored treatment for a quiet muted caption with a semibold
value, which also unifies the two label styles that page had drifted
into. The orphaned border-s-{variant} safelist is removed and
bg-{variant}/10 is safelisted for the assembled tint classes.

The sidebar brand block padding shrinks from py-12 to py-5, in line
with the FlyonUI reference sidebar header rhythm.

E2e: the dashboard test asserts the new idiom (tinted icon container
present, no edge stripes anywhere) instead of the old stripe class.
Four visual defects plus a verification, from manual testing feedback:

- Tooltips clipped under the sidebar. FlyonUI emits .tooltip-content
  (z-20) in @layer utilities, so the components-layer z-60 override was
  dead CSS - utilities wins the layer order regardless of specificity.
  The override moves un-layered (like the ACE fullscreen rule) and now
  computes to 60, above the drawer (z-40) and topbar (z-30), below
  modals and toasts. Verified with a browser probe: the bubble paints
  over the sidebar and elementFromPoint inside the overlap hits the
  bubble.

- Hovering an errorless state badge showed an empty dark bubble.
  HSTooltip's show() removes the hidden class from the content - the
  exact class the empty state was gated with, so the first hover
  destroyed the gate. The gate is now ee-tooltip-empty on the wrapper
  (display:none on the content), which HSTooltip does not manage; the
  SSE repaint toggles it in setStateTooltip().

- All badges are square-cornered now: the pill param is removed from
  ui/badge and ui/state-badge along with every caller, and the
  hardcoded badge rounded-full copies in accounts/account.hbs (markup
  and both JS classNames) and test_send.hbs are swept.

- rounded-full -> rounded-field on the icon containers (system-alert
  level icon, topbar avatar). The auth pages' pill-shaped inputs and
  buttons keep their deliberate style - they are neither badges nor
  icons.

- The "System" section label is removed from the sidebar menu, and
  ui/tooltip renders its block bare when the text is empty, so a
  missing tooltip source can no longer produce an empty bubble.

SSE verification: the state-badge e2e now installs a MutationObserver
(class/children only - the tooltip teardown mutates style attributes
asynchronously and must not count) and forces a reconnect through the
API; the badge must repaint with no navigation. Live account state
streaming works in the new theme. A second e2e covers the case found
in manual testing: hovering an errorless badge shows nothing.
The service and AI config pages set enableBasicAutocompletion,
enableSnippets and enableLiveAutocompletion on their editors but never
loaded ext-language_tools.js, so ACE logged 'misspelled option' console
warnings for every editor and silently ignored the options - editor
autocompletion never actually worked on those two pages. The webhook
and document-store editor pages already load the extension; these two
now match. Verified in a browser that the warnings are gone on all
four ACE pages.
@postalsys/ee-client 1.5.0 brings the host-controlled dark mode API
(darkMode/showDarkModeToggle options, setDarkMode method) used by the
admin message browser theme sync.
The ee-client webmail embed managed its own light/dark mode with a
builtin toggle, independent of the admin topbar switch. With ee-client
1.5.0 the host controls the mode: the embed initializes from the
resolved admin theme, hides its own toggle (showDarkModeToggle: false),
and follows changes live - a MutationObserver on the root element's
data-theme attribute (stamped by the topbar toggle) plus a
prefers-color-scheme listener for installs that follow the system
scheme drive client.setDarkMode().

ui.js exposes the previously-private theme resolution as
uiEffectiveTheme() so embeds can read the effective choice (the
data-theme attribute alone is absent when following the system).

E2e (browse page test): the embed renders no dark mode toggle of its
own, and clicking the topbar theme switch flips the client's dark mode
live, then back.
andris9 added 5 commits July 16, 2026 15:02
Adopts the license generation approach from KEKA: list-generate.js now
walks npm ls --omit=dev itself (the license-checker dependency and its
tree are removed), collects each package's declared license, homepage
and full license text, tags build-embed packages (tailwind/flyonui/
iconify) and hand-vendored assets (PT Sans, highlight.js) as assets,
and renders a self-contained static/licenses.html - card per package
with expandable license text, light and dark, no more Bootstrap CSS
from a CDN.

The SPDX allow-list gate stays and got stricter: licenses are read
from declared metadata only (no license-file guessing), all violations
are reported before the non-zero exit, and the two packages the old
tool silently guessed for are pinned in a documented LICENSE_OVERRIDES
map. javascript: cannot ride in on a hostile homepage field - link
schemes are allow-listed. Byte-identical license texts render once and
other packages reference them, keeping the page at two thirds of its
inline size.
swagger-ui ships no dark styles at all - its stylesheet contains zero
prefers-color-scheme rules and assumes a light page. What looked like
automatic dark mode support was the dark admin theme's page background
and color-scheme bleeding through swagger-ui's transparent panels,
which rendered the reference unreadable. There is no dark theme to tie
the admin toggle to, so per the fallback: the swagger container is
pinned to a light data-theme subtree (white card, color-scheme light)
while the admin chrome around it stays themed.

E2e: with the dark theme forced, the reference island computes a white
background and light color-scheme while the page itself is dark.
The gateways page notice faked a bullet list with literal asterisks
and <br> tags; it renders as a proper list-disc ul now, matching the
other pages' list styling.
FlyonUI's tooltip-content uses width:max-content, so long tooltip texts
(e.g. the gateway form's direct-TLS explanation) rendered as one
viewport-wide line. The bubble is capped at 20rem in the un-layered
tooltip overrides, and the body becomes display:block with balanced
wrapping - the body span is inline in FlyonUI's markup, and an inline
box fragments its background into a separate dark box per wrapped line.

E2e asserts the gateway form's long tooltip stays within the cap.
The Security (TLS) card, the certificate provisioning modal and the
provisioning script were byte-for-byte duplicated (modulo the field
name) between the SMTP and IMAP proxy config pages - verified by
normalization diff before extraction. They live in three partials now
(tls_security_card / tls_security_modal / tls_security_js),
parameterized by the setting field name; the modal stays outside the
settings form so its Close button cannot submit it. Both pages keep
provisioning through the same endpoint deliberately - the certificate
belongs to the service domain, not to either server. 162 duplicated
lines gone from each page; all element ids are preserved, so the
page scripts, paintCertData() and the existing e2e coverage of both
pages hold unchanged.
@socket-security

Copy link
Copy Markdown

@andris9
andris9 merged commit d648da1 into master Jul 16, 2026
13 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant