Fullstack visual system

July 16, 2026 · View on GitHub

Audience: product UI, Tailwind migration, and theme work.
Implementation: input.css (tokens + base) and src/ui/classes.rs (utilities).
Theme control: src/app/theme.rs + html[data-theme="light|dark|system"].

This brief is the source of truth for surface hierarchy. Prefer semantic tokens (bg-canvas, bg-surface, …) over raw greys so light/dark and future accent work stay one place.


1. Principles

  1. Content-first — chrome is quiet; primary content and forms lead.
  2. Semantic surfaces — name roles (canvas, sidebar, surface), not paint chips.
  3. One pure-white layer (light) — cards, inputs, and modals may be pure white; the page field must not.
  4. Subtle contrast over decoration — elevation comes from stacked neutrals, soft borders, and light shadows—not heavy chrome.
  5. Accent is late-bound — neutrals are neutral; brand accent can land later without rewriting every component.
  6. Theme is explicit — light / dark / system via data-theme; utilities use the dark: variant wired in input.css.

2. Light elevation (zinc-25 pattern)

Tailwind greys jump white → 50. 50 is already dingy as a full-bleed page background; pure white as canvas is stark—cards disappear into the page.

Insert a −25 step (Porzio / ChatGPT “super-slightly-off-white”):

RoleSemantic tokenTargetFeel
Page / main field--bg-canvasbg-canvaszinc-25 oklch(99.2% 0 0)Light and airy
Shell rail--bg-sidebarbg-sidebarzinc-50 oklch(98.5% 0 0)Quiet frame
Cards, inputs, modals--bg-surface / --bg-elevatedpure white #ffffffCrisp, elevated
Inset wells only--bg-surface-subtle~oklch(97.5% 0 0)Nested chips, segmented controls

Stack (light)

┌─────────────────────────────────────────────┐
│  canvas (zinc-25)                           │
│  ┌──────────┐  ┌──────────────────────────┐ │
│  │ sidebar  │  │  surface (white card)    │ │
│  │ (zinc-50)│  │  inputs / tiles / modals │ │
│  └──────────┘  └──────────────────────────┘ │
└─────────────────────────────────────────────┘

Rules (light)

  • Do put large regions on bg-canvas.
  • Do put floating UI on bg-surface / bg-elevated.
  • Do use bg-sidebar only for the rail (or match canvas if a product wants a flat field).
  • Don’t paint the whole app bg-white or raw zinc-50 — flat or dingy.
  • Don’t use surface-subtle as a full-bleed page background.
  • Optional utility: --color-zinc-25 / bg-zinc-25 for one-offs; prefer bg-canvas in product UI.

Reference values (light)

Canonical values live in input.css under :root / html[data-theme="light"]:

TokenValue
--bg-canvasoklch(99.2% 0 0)
--bg-sidebaroklch(98.5% 0 0)
--bg-surface#ffffff
--bg-elevated#ffffff
--bg-surface-subtleoklch(97.5% 0 0)
--bg-surface-hoveroklch(96% 0 0)
--bg-surface-activeoklch(94.5% 0 0)
--border-subtleoklch(92% 0 0)
--border-strongoklch(86% 0 0)
--code-bgoklch(98% 0 0)
--shadow-soft0 8px 24px rgba(0, 0, 0, 0.08)

3. Dark elevation

Same roles, inverted values (already in input.css):

RoleDark intent
CanvasMid room (#212121) — not pure black
SidebarSlightly deeper chrome (#171717)
Surface / elevatedRaised panel (#2f2f2f)
Surface-subtleInset, not full-bleed

Do not force light zinc-25 into dark. Dark contrast is “lighter panel on darker room,” not off-white.


4. Semantic token map

Bridged in @theme inline so Tailwind utilities stay aligned with CSS variables:

Utility prefixCSS variableTypical use
bg-canvas--bg-canvashtml/body, shell, auth page, main field
bg-sidebar--bg-sidebarWorkspace / settings rail
bg-surface--bg-surfaceCards, panels, inputs, board tiles
bg-elevated--bg-elevatedPopovers, sticky chips, “above surface”
bg-surface-subtle--bg-surface-subtleInset wells, inactive segmented segments
bg-surface-hover / activehover/activeInteractive rows and buttons
text-primarytertiarytext tokensHierarchy
border-subtle / strongborder tokensDividers and control edges
bg-accent / text-dangerstatus tokensActions and feedback

Implementation constants: src/ui/classes.rs (e.g. AUTH_PAGE = canvas, AUTH_CARD = surface).


5. Component layering checklist

SurfaceToken
App shell / main columnbg-canvas
Sidebarbg-sidebar
Auth page backdropbg-canvas
Auth card, account panels, settings cardsbg-surface
Board tiles, modals, dialogsbg-surface (+ soft shadow where needed)
Inputs / textareas / secondary buttonsbg-surface
Segmented controls, code wells, empty inset regionsbg-surface-subtle
MFA QR platepure white (bg-white) — scan contrast, intentional exception

6. Anti-patterns

AvoidWhy
Full-bleed bg-white / pure white canvas in lightStark; cards don’t float (no hierarchy)
Full-bleed zinc-50 / heavy grey pageDingy; “no contrast” next to white controls
Hard-coded #fff / Tailwind grey ramps in markupBreaks dark mode and future token retunes
Using surface-subtle as the app backgroundToo heavy; reserved for nested UI
Painting modals with --bg-inverse as scrimIn dark mode becomes a milky fog — use overlay scrim tokens

7. Theme modes

data-themeBehavior
lightLight token block
darkDark token block
system (or unset)Follows prefers-color-scheme

Toggle lives in the workspace shell foot (ThemeToggle). FOUC script in the document head seeds data-theme from localStorage (app-theme).


8. Loading states (skeletons)

Prefer skeleton placeholders over plain "Loading members…" text for async page/section content. Implementation: src/ui/skeleton.rs + SKEL_* in classes.rs.

Rules

  1. Semantic grey onlybg-surface-subtle + animate-pulse (via SKEL_BONE*). No hard-coded #e8e8ed / zinc ramps for new loaders.
  2. Layout-preserving — mirror title, panel, table, or form shape so content does not jump when data arrives.
  3. Accessible — root aria-busy="true" and a specific aria-label (e.g. "Loading members"); bones aria-hidden.
  4. Compose — use primitives (SkeletonBone, SkeletonRow, SkeletonStack, SkeletonCircle, SkeletonText, SkeletonPanel) for custom layouts; use recipes (FormSkeleton, TableSkeleton, ListSkeleton, SettingsPageSkeleton, CardGridSkeleton, PageHeaderSkeleton) when they fit.
  5. Not for buttons — keep control pending copy ("Saving…", "Loading demos…") on CTAs; skeletons are for section/page body loads.

Example

use crate::ui::{SettingsPageSkeleton, SettingsSkeletonVariant, TableSkeleton};

// Settings form body
view! {
  <Show when=move || data.get().is_none()>
    <SettingsPageSkeleton label="Loading workspace" variant=SettingsSkeletonVariant::Form />
  </Show>
}

// Custom members toolbar + table
view! {
  <TableSkeleton rows=6 cols=3 with_avatar=true label="Loading members" />
}

9. Workspace chrome persistence (islands)

Sidebar org switcher, account menu, and theme toggle must not re-flash on every soft navigation. Implementation notes for authors:

  1. Enable HydrationScripts { islands: true, islands_router: true }.
  2. Mark stable regions: #workspace-content, #workspace-primary-nav, #workspace-topbar-title (swapped) vs #workspace-chrome-foot (persisted).
  3. Install initWorkspaceChromePersist() from WorkspaceSidebarControls so in-app hops only replace content/nav/title and leave chrome islands mounted.
  4. Cache session + org memberships (workspace-chrome-v1) for cold remounts; use data-nav + mark_active_nav for flyout focus (islands have no Router).

Full guide (technique, anti-patterns, verification):

10. Change process

  1. Edit tokens in examples/fullstack-app/input.css (this brief stays in sync).
  2. Dual-sync product files to the CLI template when required
    (scripts/sync_fullstack_template.shinput.css is on the allowlist).
  3. Prefer class constants in src/ui/classes.rs over ad-hoc utilities in islands.
  4. Rebuild Tailwind via the normal Spin/Cargo frontend pipeline; verify light and dark.
  5. After chrome/nav changes, dual-sync the template and re-check soft-nav probes (same leptos-island node identity for foot menus across hops).

When this document and input.css disagree, update both in the same change.


11. Credits / inspiration

  • ChatGPT-style neutral product chrome (content-first, soft fields).
  • Caleb Porzio — zinc-25 Tailwind note: add a −25 grey so the page field is airy while pure-white cards still contrast.
  • Industry baseline for overlays: dark scrim (not inverse-as-fog), ~40–60% black.