Skip to content

Liquid-glass restyle for the admin console and operator app

Status: approved direction from the owner ("both apps read as AI slop — background gradients, outlines everywhere; make it liquid glass, minimal"). This document fixes the visual language so the two frontends can be restyled together and stay in parity afterwards.

Diagnosis

Both SPAs share the same tell-tale construction: every panel is a white box with a 1 px slate-200 outline on a grey canvas, the canvas carries a radial gradient in the corner, the admin rail is a three-stop vertical gradient, and every interactive control adds its own focus:ring-2 on top of the global focus rule. Counted before the restyle:

Pattern admin (frontend/) operator (frontend-app/)
border utilities 1374 374
explicit border-<color>-N 921 235
ring-* utilities 344 209
radius variants in use 10 8
gradient sites 12 0

Outlines do all the work that tone, spacing, and depth should do. The result is a wireframe that happens to be filled in.

Direction

Frosted, layered, quiet. Surfaces are translucent slabs sitting on a flat canvas. Nothing is outlined; edges come from a one-pixel specular highlight along the top of each slab and a soft, wide, low-alpha shadow beneath it. Chrome that content scrolls under (header, rails, bottom nav, sticky toolbars, menus, dialogs) blurs what is behind it. Cards do not blur — on a flat canvas a blur is invisible and only costs compositing — they are simply translucent.

Everything else is subtraction: one radius scale, one accent, one focus voice, hairlines at 8 % alpha where a divider is structurally needed and nothing where it is not.

Pretendard stays. It was chosen for Korean/Latin metric parity and is not a styling decision.

Tokens (both index.css, byte-identical)

--xl-canvas            light #f4f6f8      dark #0c0d0f
--xl-glass             rgb(255 255 255 / .62)   dark rgb(255 255 255 / .045)
--xl-glass-strong      rgb(255 255 255 / .78)   dark rgb(20 21 23 / .84)   (chrome + dialogs)
--xl-glass-highlight   rgb(255 255 255 / .75)   dark rgb(255 255 255 / .07)
--xl-hairline          rgb(11 24 35 / .08)      dark rgb(255 255 255 / .08)
--xl-well              rgb(11 24 35 / .045)     dark rgb(255 255 255 / .06)  (inputs, subtle buttons)
--xl-well-hover        rgb(11 24 35 / .07)      dark rgb(255 255 255 / .10)
--xl-focus             sky-600                  dark sky-300

Tailwind colour aliases so utilities can name them: border-hairline, divide-hairline, bg-well, bg-glass.

Dark mode is graphite, not navy. The steel-blue slate ramp is the light theme's structure colour; inside .dark the --color-slate-* variables are re-pointed to a neutral grey scale (#0c0d0f … #f2f3f4), so every dark: utility, the chart theme, the map theme, the PWA theme colour, and the select chevron follow without markup changes. Sky stays the single accent hue in both themes.

Radius scale: chip 6 px (Hangul rhythm, unchanged), control 10 px, surface 16 px, dialog 20 px. Nothing else.

Shadows: --shadow-glass (surface) and --shadow-float (menus, dialogs, toasts). Both are wide, low-alpha, and colour-tinted to the canvas so they read as ambient occlusion, not drop shadows.

Primitives (@layer components)

  • .xl-surface — the card. Translucent fill, 16 px radius, top highlight, ambient shadow, no border. Optional .xl-surface-flush for zero padding wrappers around tables.
  • .xl-glass — chrome: --xl-glass-strong fill + backdrop-filter: blur(24px) saturate(140%). A hairline edge is drawn with box-shadow, not border, so it never affects layout and can be per-side.
  • .xl-float — menus, popovers, command palette: .xl-glass + float shadow + 16 px radius.
  • .xl-dialog — modal panel: .xl-glass + 20 px radius + float shadow.
  • .xl-input — text inputs, selects, textareas. Filled well, no outline, 10 px radius, transparent 1 px border reserved so focus does not shift layout. Focus: accent border + 3 px soft halo at 18 % alpha. Replaces the ~60 hand-rolled border border-slate-300 … focus:ring-2 strings.
  • .xl-btn — unchanged API; radius moves to 10 px. secondary becomes a translucent well instead of an outlined white box; ghost, subtle, primary, danger keep their roles.
  • .xl-chip / .xl-tag — chips are text, not boxes. A status or severity chip is a 6 px dot (CSS ::before, currentColor) plus the label in the tone colour; a neutral tag (counts, codes, capability labels, roles) is muted text. Status maps in lib/colors.ts and page-local maps carry xl-chip + a text colour only. Selected states of toggle/range buttons are not chips: they take a 12 % tone tint (bg-<tone>-500/12). Keyboard hints are plain <kbd> text.
  • .xl-nav-item / .xl-nav-item-active — rail links. Active = filled well
  • semibold, never a ring.
  • .xl-thead — table header: eyebrow type, hairline below, no band fill.
  • .xl-row-hover — 2.5 % alpha hover, replaces hover:bg-slate-50.

Focus: the global :focus-visible outline is the only keyboard-focus voice. Per-element focus:outline-none focus:ring-* / focus-visible:ring-* strings are deleted, not replaced. .xl-input and .xl-btn opt out of the outline and draw the halo instead.

Shell

  • Canvas: flat --xl-canvas. The radial gradient and the admin rail gradient are removed.
  • Admin rail: no longer a dark gradient. Same glass rail as the operator app in both themes so the two products share one shell. Brand mark + section eyebrows carry the identity.
  • Header: .xl-glass, hairline bottom edge via shadow, no shadow-sm.
  • Mobile bottom nav: .xl-glass, hairline top edge. Active item keeps the 2 px top accent (colour-independent marker, WCAG 1.4.1).
  • Stickiness: rails and headers are position: sticky. The horizontal overflow guard lives on html only; putting overflow-x: hidden on body too makes body a scroll container and silently breaks sticky. On phones the admin header pins under the fixed 3.5 rem top bar and its identity block is hidden below lg so it stays one row. The operator drawer scrim/panel sit at z-60/z-70, above the bottom nav.
  • Dialog backdrop: slate-950/30 + 4 px blur.

Mechanical passes (codemod, both apps)

Run in this order; each is idempotent.

  1. Hairline softening — token rename, everywhere: border-slate-100|200 → border-hairline, dark:border-slate-700|800 → dark:border-hairline-dark, same for divide-*. Leaves structure intact, drops contrast of every remaining divider to 8 %.
  2. Card lift — a className that contains a card signature (rounded-* + border + border-hairline + (bg-white | dark:bg-slate-800|900)) loses those tokens plus any shadow-* and gains xl-surface. Padding and layout classes are untouched.
  3. Input lift — <input|select|textarea className strings lose border*, rounded-*, bg-white, dark:bg-slate-*, focus:*, dark:focus:*, px-*/py-* and gain xl-input.
  4. Focus ring purge — delete focus:outline-none, focus-visible:outline-none, focus:ring-*, focus-visible:ring-*, dark:focus:ring-*, dark:focus-visible:ring-*, focus:border-*.
  5. Selected-state rings — ring-1 ring-inset ring-<tone>-* on selected/active items become a filled well tint (bg-<tone>-500/10).
  6. Tinted callouts — bg-<tone>-50 border border-<tone>-200 dark:border-<tone>-800 dark:bg-<tone>-900/20 become bg-<tone>-500/8 dark:bg-<tone>-400/10 with no border.
  7. Table header bands — bg-slate-50 dark:bg-slate-800|900(/N) on <thead> become xl-thead.

Hand-edited: both shells, Modal, ConfirmDialog, Button, EmptyState, Toast/ToastContainer, CommandPalette, RowActionsMenu, Tooltip, SkeletonCard, Skeleton, both login pages, ScrollFadeContainer.

Left alone: chart internals (uPlot, Recharts, Konva colours and grid strokes), icon pucks and the health-hero stat tiles (tinted on purpose), and the 44 px tap-target floors.

Verification

  • npx tsc -b --noEmit and npx vite build in both apps.
  • vitest run in both apps; class-string assertions in tests are updated to the new primitives, not deleted.
  • Playwright against the local dev server (which proxies /api/ to production) for both apps at 375×812, 768×1024, 1280×800, light and dark: login, home/dashboard, a list page, a detail page, a modal. Zero pageerror.
  • Deploy with scripts/deploy.sh; confirm bundle hash + footer SHA.