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-flushfor zero padding wrappers around tables..xl-glass— chrome:--xl-glass-strongfill +backdrop-filter: blur(24px) saturate(140%). A hairline edge is drawn with box-shadow, notborder, 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-rolledborder border-slate-300 … focus:ring-2strings..xl-btn— unchanged API; radius moves to 10 px.secondarybecomes a translucent well instead of an outlined white box;ghost,subtle,primary,dangerkeep 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 inlib/colors.tsand page-local maps carryxl-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, replaceshover: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, noshadow-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 onhtmlonly; puttingoverflow-x: hiddenonbodytoo 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 belowlgso 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.
- Hairline softening — token rename, everywhere:
border-slate-100|200→border-hairline,dark:border-slate-700|800→dark:border-hairline-dark, same fordivide-*. Leaves structure intact, drops contrast of every remaining divider to 8 %. - Card lift — a className that contains a card signature
(
rounded-*+border+border-hairline+ (bg-white|dark:bg-slate-800|900)) loses those tokens plus anyshadow-*and gainsxl-surface. Padding and layout classes are untouched. - Input lift —
<input|select|textareaclassName strings loseborder*,rounded-*,bg-white,dark:bg-slate-*,focus:*,dark:focus:*,px-*/py-*and gainxl-input. - 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-*. - Selected-state rings —
ring-1 ring-inset ring-<tone>-*on selected/active items become a filled well tint (bg-<tone>-500/10). - Tinted callouts —
bg-<tone>-50 border border-<tone>-200 dark:border-<tone>-800 dark:bg-<tone>-900/20becomebg-<tone>-500/8 dark:bg-<tone>-400/10with no border. - Table header bands —
bg-slate-50 dark:bg-slate-800|900(/N)on<thead>becomexl-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 --noEmitandnpx vite buildin both apps.vitest runin 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. Zeropageerror. - Deploy with
scripts/deploy.sh; confirm bundle hash + footer SHA.