Skip to content

Design system (House Style)

Single reference for reskinning Settings / Admin / Auth / public Vue pages. All facts below are extracted verbatim from the current sources — no guessing. Import paths use the @/ alias = resources/js/.

Source files:

Themes

Four themes ship: system (matches the OS), light ("House Style", the paper look), dark ("Dim & cozy"), and desktop ("Windows 95"). The active theme is the data-theme attribute on <html>; every token below is redefined per theme so a theme swap recolors the whole UI live.

ThemeToggle.vue is the switcher: it applies the choice optimistically (data-theme on <html>), writes the theme cookie + localStorage key imaginook.theme, and PATCHes /settings/theme when signed in. It is already placed in ConsoleLayout.vue's header — do not re-add it inside console pages; add it to auth/public layouts that lack the console chrome.

Two structural facts hold across all four themes:

  • Radius. Every primitive applies :style="{ borderRadius: 'var(--radius)' }" internally, so corners are 3px in light/dark and 0px in desktop. You never set radius yourself on a primitive.
  • Bevels. ui-raised / ui-sunken are the desktop-theme Win-95 bevel hooks (inert off desktop). Primitives carry them where needed; hand-rolled panels must add them.

Nothing in the kit is ever tilted, in any theme.

Accessibility

The semantic text and border tokens are WCAG-AA tuned per theme — the contrast between --content / --content-muted / --edge and their backgrounds is checked for AA against each theme's actual surface colors, not once globally. The deterministic contrast check lives in the dev tooling, so a token that drifts below AA fails locally rather than shipping.

A few rules keep the palette honest:

  • The fixed brand hues (teal / gold / coral / charcoal) are ground colors only — they back the logo and the title bars, never AA-critical body text.
  • Title-bar ink follows the bar: the system and studio title bars use dark ink (#1c1c1a) on their lighter grounds, while social uses white on coral.
  • There is a dedicated --warning-text token: gold is a fill-only color (it fails AA as text), so warning text uses --warning-text rather than the gold fill.
  • The light theme focus ring is charcoal, deliberately distinct from the teal accent fill so the ring never visually collides with an accent-filled control.
  • The --cm-* tokens are the CodeMirror editor colors and are not part of this AA pass.

Design tokens

Tokens are CSS custom properties on :root / [data-theme=...]; Tailwind utilities reference them via @theme inline (so every theme swap recolors live). Always reach for the token utility, never a literal hex or raw brand color — except inside NookMark.vue (the fixed brand mark).

Canonical token utilities (use these)

PurposeUtility class(es)Backing token
Page groundbg-surface--surface (paper / dark #1b2127 / desktop teal)
Window/card bodybg-surface-raised--surface-raised (white / #232c33 / silver #c3c7cb)
Wells, insets, tracks, table headersbg-surface-sunken--surface-sunken (--warm-100 / #141a1f / white)
Primary texttext-content--content (ink / #f3efe6 / ink)
Secondary texttext-content-muted--content-muted (--warm-600 / #b4babc / #3f474c)
Hints, placeholders, timestampstext-content-subtle--content-subtle (--warm-400 / #8b9298 / #6c7378)
2px OS-window borderborder-edge--edge (charcoal / #3a444c / charcoal)
Soft divider/borderborder-edge-soft, divide-edge-soft--edge-soft
Accent (teal) surfacebg-accent + text-accent-contrast--accent / --accent-contrast
Accent text/icontext-accent--accent
Accent hover/activebg-accent-strong--accent-strong
Danger (coral)text-danger, border-danger, bg-danger, bg-danger/10--danger
Warning (gold)text-warning, bg-warning--warning
Title-bar groundsbg-chrome-system / bg-chrome-studio / bg-chrome-social--chrome-* (use via <Window variant>, rarely raw)

Tints & alpha

Token utilities accept Tailwind alpha: bg-accent/5, bg-accent/10, bg-accent/15, bg-danger/10, border-edge/40, bg-surface/85. Used for selection/hover rows and soft callouts.

Brand-hue utilities (kept literal — narrow uses only)

bg-gold / text-gold / border-gold--brand-gold; bg-red / text-white--brand-coral; bg-teal / text-teal--brand-teal. These are wired in @theme inline to the fixed brand hues. Prefer the semantic tokens (accent / warning / danger) for UI; use brand-hue utilities only where a primitive does (e.g. Badge gold/coral). Do NOT write raw hex like #1f9b90.

Legacy aliases (themed — safe but discouraged)

bg-paper--surface, text-ink--content. They theme correctly because they're repointed, but the swap rules below replace them with the explicit bg-surface / text-content names for clarity.

Type

  • font-mono & font-sans → Space Mono. Label voice = font-mono text-xs font-bold uppercase tracking-wider (or tracking-wide).
  • font-display → Archivo Black. Headlines/wordmark: font-display text-2xl tracking-tight (h1), font-display text-lg (h2/section).

Shape / shadow / radius

  • border-2 border-edge = the canonical 2px OS border.
  • Radius: apply :style="{ borderRadius: 'var(--radius)' }" on any raw element that needs the rounded corner (primitives already do this). Token utility rounded-window (--radius-window) also exists. Never hardcode rounded-md etc.
  • shadow-window = the hard offset shadow 3px 3px 0 0 var(--win-shadow).
  • ui-raised / ui-sunken = the desktop-theme Win-95 bevel hooks (inert off desktop). Put ui-raised on raised panels/cards you hand-roll, ui-sunken on wells; pair with border-2 border-edge + shadow-window.
  • .dither = radial-dot halftone — text-free surfaces ONLY (progress fills, avatar tiles, divider bands). Never under text.

Component utility classes (from @layer components)

Still defined and themed; primitives are preferred, but these exist for plain markup: .input, .label, .err, .btn, .link, .tool, .tool-danger, .dither. The desktop theme rewrites .btn / .tool / .tool-danger / .input bevels automatically via the unlayered [data-theme='desktop'] rules.

Focus / motion (free, do not override)

:focus-visibleoutline: 2px solid var(--focus) globally. Reduced-motion media query neutralizes animations/transitions. Don't add custom focus rings or remove outlines.

Components / UI primitives

Every primitive applies :style="{ borderRadius: 'var(--radius)' }" internally (so 3px in light/dark, 0px desktop) and carries ui-raised / ui-sunken where bevels are needed. You do not add radius or bevels yourself — the primitive handles all three themes. Nothing in the kit is ever tilted.

Window — Window.vue

The flat OS-window container. Renders a <section> with ui-raised border-2 border-edge bg-surface-raised text-content shadow-window, a colored title <header> (2px bottom border), and a p-4 body.

PropTypeDefaultAccepted values
titleString''any text (rendered mono/xs/bold/uppercase/tracking-wider in default title slot)
variantString'system'system (teal bar, #1c1c1a ink) · studio (gold bar, #1c1c1a ink) · social (coral bar, white ink)
controlsBooleantrueshow the decorative – × chrome squares (aria-hidden; purely decorative)

Title-bar color comes from bg-chrome-system / bg-chrome-studio / bg-chrome-social. Pick the variant by domain: system = settings/account/security/general, studio = code/site building, social = profiles/comments/community.

Slots:

  • default — body content (inside p-4)
  • title — replaces the default mono title span (use for custom title markup)
  • actions — right side of the title bar, left of the – × controls (put buttons/badges/links here)
vue
<Window variant="system" title="Profile">
  <template #actions>
    <Badge variant="gold">beta</Badge>
  </template>
  <!-- form / content here -->
</Window>

Button — Button.vue

Flat House Style button. Renders an Inertia <Link> when href is set, otherwise a <button>. Base classes: inline-flex items-center justify-center gap-2 px-4 py-2 text-sm font-bold transition active:translate-x-px active:translate-y-px disabled:cursor-not-allowed disabled:opacity-60.

PropTypeDefaultAccepted values / notes
variantString'accent'accent (teal CTA: bg-accent text-accent-contrast, hover bg-accent-strong) · neutral (toolbar control: bg-surface-raised text-content, hover bg-surface-sunken) · danger (border-danger bg-surface-raised text-danger, hover bg-danger/10) · ghost (borderless transparent, hover bg-surface-sunken)
hrefStringnullwhen set → renders Inertia <Link>; else <button>
typeString'button'only applied to <button> (ignored when href set). Set type="submit" for form submits
blockBooleanfalseadds w-full

Slot: default — label (icons/text; gap-2 handles spacing). Pass Inertia attrs (method, as, :data) through via v-bind/attrs only when it renders a <Link> — note the component forwards only href; for method="post" logout-style links use a raw <Link> instead (see ConsoleLayout.vue).

vue
<Button variant="accent" type="submit" :disabled="form.processing">Save changes</Button>
<Button variant="neutral" href="/settings/profile">Cancel</Button>
<Button variant="danger" @click="confirmDelete">Delete account</Button>
<Button variant="ghost" block>Show more</Button>

Badge — Badge.vue

Small flat tag — mono, uppercase, letter-spaced. Base: inline-flex items-center gap-1 border px-1.5 py-0.5 font-mono text-[10px] font-bold uppercase leading-none tracking-wider. Used for "beta", roles, counts.

PropTypeDefaultAccepted values
variantString'neutral'neutral (bg-surface-raised text-content) · accent (bg-accent text-accent-contrast) · gold (bg-gold text-[#1c1c1a]) · coral (bg-red text-white) · muted (border-edge-soft bg-transparent text-content-muted)

Slot: default — label text.

vue
<Badge variant="gold">beta</Badge>
<Badge variant="accent">admin</Badge>
<Badge variant="muted">3 items</Badge>

StatusPill — StatusPill.vue

Status indicator: a small colored dot + label in a flat neutral pill. Base: inline-flex items-center gap-1.5 border border-edge-soft bg-surface-raised px-2 py-0.5 font-mono text-[11px] font-bold uppercase tracking-wide text-content-muted.

PropTypeDefaultAccepted values
toneString'idle'ok (dot bg-accent) · warn (dot bg-warning) · danger (dot bg-danger) · idle (dot bg-content-subtle)
labelString''used when no slot content given

Slot: default — overrides label. Tone drives only the dot color; the pill body is always neutral.

vue
<StatusPill tone="ok">live</StatusPill>
<StatusPill tone="warn">pending</StatusPill>
<StatusPill :tone="user.banned ? 'danger' : 'ok'">{{ user.banned ? 'banned' : 'active' }}</StatusPill>

Stamp — Stamp.vue

"Rubber stamp" label — thick (2px) bordered, uppercase mono, wide tracking (tracking-[0.2em]). The retro "UNDER CONSTRUCTION" / "BETA" voice. Base: inline-flex items-center border-2 px-2 py-1 font-mono text-xs font-bold uppercase tracking-[0.2em]. Flat and straight — never tilted.

PropTypeDefaultAccepted values
toneString'edge'edge (border-edge text-content) · accent (border-accent text-accent) · danger (border-danger text-danger) · gold (border-gold text-gold)

Slot: default — stamp text. Primary use: centerpiece of an empty state.

vue
<Stamp tone="gold">Under Construction</Stamp>
<Stamp tone="danger">Account Suspended</Stamp>

ThemeToggle — ThemeToggle.vue

Self-contained theme switcher dropdown (system / light / dark / desktop). Applies the choice optimistically (data-theme on <html>), writes the theme cookie + localStorage key imaginook.theme, and PATCHes /settings/theme when signed in.

  • No props, no slots. Drop it in. Already placed in ConsoleLayout.vue header — do not re-add inside console pages. Use it in auth/public layouts that lack a ConsoleLayout chrome.
  • Options array: system ("Match your OS"), light ("House Style"), dark ("Dim & cozy"), desktop ("Windows 95").
vue
<ThemeToggle />

Marquee — Marquee.vue

CSS scrolling ticker (two seamless copies, translateX -50%). Pauses on hover; frozen under reduced-motion. Used in the Taskbar.

PropTypeDefaultNotes
durationNumber24seconds for one full loop

Slot: default — the scrolling content (rendered twice; second copy is aria-hidden). Style the text via classes on the component (mono/uppercase as in Taskbar).

vue
<Marquee :duration="28" class="font-mono text-[11px] uppercase tracking-wider text-content-muted">
  ✦ news ✦ updates ✦
</Marquee>

Taskbar — Taskbar.vue

The charcoal footer: Start lozenge (the StartMenu launcher), coral squiggle, Marquee ticker, live clock. Solid #2f3942 in every theme (brand chrome, never dithered). No props, no slots. Already rendered by ConsoleLayout.vue's footer — only add it to bespoke layouts.

vue
<Taskbar />

StartMenu — StartMenu.vue

The taskbar's Start lozenge → a Win-95 Start menu: a branded vertical strip + the app/system launcher entries (from desktop/shortcuts.js) plus Log out. Opens upward from the taskbar. Click-away + Esc to close; arrow keys / Home / End move focus. Themed via tokens, so it's a launcher in every theme. Entries are the existing full-page routes — no window manager. No props, no slots (lives inside <Taskbar>).

DesktopIcon — DesktopIcon.vue

A Lite-desktop shortcut: glyph tile + label. Single-click selects (highlight), double-click / Enter / Space opens (navigates to href). Keyboard operable. Labels use text-content (near-ink — the AA-legible choice on the teal desktop ground; never muted/accent there).

PropTypeDefaultNotes
labelStringrequired; shown under the glyph, clamped to 2 lines
glyphString'▦'emoji / char (decorative, aria-hidden)
hrefStringnullroute to open
methodString'get'Inertia visit method
selectedBooleanfalsecontrolled highlight (parent owns single selection)

Emits select (single click) and open (double-click / key). Used by the desktop surface.

vue
<DesktopIcon
    v-for="icon in desktopIcons"
    :key="icon.key"
    :label="icon.label" :glyph="icon.glyph" :href="icon.href"
    :selected="selectedIcon === icon.key"
    @select="selectedIcon = icon.key"
/>

NookMark — NookMark.vue

The brand mark SVG (isometric "nook"). Palette is FIXED literal brand hues (teal #1f9b90, gold #e3b341, coral #c1554f, charcoal #2f3942 strokes, white doorway) — pixel-identical across all themes by design. This is the one place literal brand hex is correct; do not "tokenize" it.

PropTypeDefaultNotes
sizeNumber|String32width & height in px
titleString'Imaginook'accessible label (<title> + aria-label)

No slots.

vue
<NookMark :size="56" />
<NookMark :size="32" title="Imaginook home" />

Page-structure recipes

Page wrapper

Console-area pages wrap everything in ConsoleLayout.vue (provides header nav, ThemeToggle, avatar, Taskbar). Pass title (sets <Head> + nav title).

vue
<ConsoleLayout title="Profile">
  <!-- page content -->
</ConsoleLayout>

Auth/public pages without ConsoleLayout must supply their own <Head>, and include <ThemeToggle />

  • brand chrome (NookMark, font-display wordmark) themselves.

Page header block (h1 + subtitle + primary action)

Pattern from Dashboard.vue / FileManager.vue — flex row, headline left, primary <Button> right:

vue
<div class="mb-8 flex flex-wrap items-end justify-between gap-4">
  <div>
    <h1 class="font-display text-2xl tracking-tight">Profile</h1>
    <p class="mt-1 font-mono text-sm uppercase tracking-wider text-content-muted">
      Your account details.
    </p>
  </div>
  <Button href="/settings/security">Security</Button>
</div>

Section heading (between windows)

vue
<h2 class="mb-3 mt-8 font-display text-lg">Your sites</h2>

Card / panel = <Window>

Choose variant by domain (system/studio/social). Body content sits in the p-4 default slot. Use #actions for in-bar controls.

vue
<Window variant="system" title="Email address">
  <!-- form fields -->
</Window>

Grid of windows (Dashboard): class="grid gap-4 sm:grid-cols-2".

A panel WITHOUT a title bar (e.g. FileManager side asides/upload panel): hand-roll with the canonical raw-panel recipe —

vue
<aside class="ui-raised border-2 border-edge bg-surface-raised p-4 shadow-window"
       :style="{ borderRadius: 'var(--radius)' }"> … </aside>

Forms (labels, inputs, errors, actions)

Label voice = .label; field = .input; error = .err (or the danger box). Submit via <Button type="submit">.

vue
<form @submit.prevent="form.post('/settings/profile')" class="space-y-4">
  <div>
    <label for="name" class="label">Display name</label>
    <input id="name" v-model="form.name" class="input" type="text" />
    <p v-if="form.errors.name" class="err">{{ form.errors.name }}</p>
  </div>

  <div class="flex items-center gap-2">
    <Button type="submit" :disabled="form.processing">Save changes</Button>
    <Button variant="neutral" href="/dashboard">Cancel</Button>
  </div>
</form>

Inline create/edit row (FileManager): class="input max-w-xs" next to <Button variant="neutral">. Page-level error banner:

vue
<p v-if="error" class="mb-3 border-2 border-danger bg-danger/10 px-3 py-2 text-sm font-bold text-danger"
   :style="{ borderRadius: 'var(--radius)' }">{{ error }}</p>

Tables (the canonical FileManager pattern)

Table lives inside a raised section. Header row = mono uppercase on bg-surface-sunken; rows divided by divide-edge-soft; hover/selection use accent tints.

vue
<section class="ui-raised overflow-hidden border-2 border-edge bg-surface-raised shadow-window"
         :style="{ borderRadius: 'var(--radius)' }">
  <table class="w-full text-left text-sm">
    <thead class="bg-surface-sunken font-mono text-xs uppercase tracking-wider text-content-subtle">
      <tr>
        <th class="px-3 py-2">Name</th>
        <th class="px-3 py-2 text-right">Size</th>
        <th class="px-3 py-2"></th>
      </tr>
    </thead>
    <tbody class="divide-y divide-edge-soft">
      <tr class="cursor-pointer hover:bg-accent/5" :class="isSel ? 'bg-accent/10' : ''">
        <td class="px-3 py-2">{{ row.name }}</td>
        <td class="px-3 py-2 text-right text-content-muted">{{ row.size }}</td>
        <td class="px-3 py-2 text-right">
          <button type="button" class="text-xs link">edit</button>
        </td>
      </tr>
    </tbody>
  </table>
</section>

Conventions: cell padding px-3 py-2; secondary cell text text-content-muted, subtle/timestamp text-content-subtle; numeric cols text-right (Dashboard also uses tabular-nums for figures); action links use class="text-xs link". Loading row: <td colspan=... class="px-3 py-6 text-center text-content-subtle">Loading…</td>.

Empty states

Two patterns:

(a) Window-framed empty state (Dashboard "No sites yet") — NookMark + Stamp + copy + CTA, centered:

vue
<Window variant="system" title="No sites yet">
  <div class="flex flex-col items-center gap-4 py-8 text-center">
    <NookMark :size="56" />
    <Stamp tone="gold">Under Construction</Stamp>
    <p class="max-w-md font-mono text-sm text-content-muted">No data yet.</p>
    <Button href="/sites/new">Get started</Button>
  </div>
</Window>

(b) In-table empty row (FileManager): <td colspan="N" class="px-3 py-10 text-center text-content-subtle">This folder is empty. …</td>.

Status & tags

  • Live/active/pending/banned → <StatusPill :tone="…">.
  • Roles, "beta", counts → <Badge :variant="…">.
  • Big retro callout → <Stamp :tone="…">.

Progress bars / fills (the ONE place dither belongs)

Track = sunken bordered box; fill = .dither colored by state. Width via inline style. No text on the fill.

vue
<div class="h-3 overflow-hidden border-2 border-edge bg-surface-sunken"
     :style="{ borderRadius: 'var(--radius)' }">
  <div class="dither h-full transition-all"
       :class="exceeded ? 'bg-danger' : near ? 'bg-warning' : 'bg-accent'"
       :style="{ width: pct + '%' }" />
</div>

State→color mapping used across the app: exceeded/error → bg-danger / text-danger; near/warn → bg-warning / text-warning; ok → bg-accent / text-accent.

Avatar / initials tile (ConsoleLayout pattern)

vue
<span class="grid size-9 place-items-center border-2 border-edge bg-accent/15 font-mono text-sm font-bold text-accent"
      :style="{ borderRadius: 'var(--radius)' }">{{ initials }}</span>

Underline-on-active, mono uppercase (ConsoleLayout):

vue
<Link class="cursor-pointer border-b-2 pb-0.5 transition"
  :class="active ? 'border-accent text-accent' : 'border-transparent text-content-muted hover:text-accent'"
  :aria-current="active ? 'page' : undefined">{{ label }}</Link>
  • Inline link: class="link" (+ text-xs font-bold for small).
  • Breadcrumb button: class="px-1.5 py-0.5 hover:bg-surface-sunken hover:text-accent" + radius style; separators <span class="text-content-subtle">/</span> in a font-mono text-xs uppercase tracking-wider text-content-muted nav.

Lite desktop (Desktop theme only)

In the Desktop theme the dashboard swaps to a desktop surfaceDesktopIcon shortcuts on the teal wallpaper — while Light/Dark keep the card dashboard. The swap is CSS-gated, no JS theme detection, no FOUC: the server stamps data-theme on <html>, and app.css toggles two wrapper classes. Below sm the surface stays hidden and the card layout shows (touch fallback).

vue
<section class="desktop-surface" aria-label="Desktop"> … <DesktopIcon …/> … </section>
<div class="console-cards"> …the normal card dashboard… </div>
css
.desktop-surface { display: none; }
@media (min-width: 640px) {
  [data-theme='desktop'] .desktop-surface { display: block; }
  [data-theme='desktop'] .console-cards  { display: none; }
}

Shortcuts come from desktop/shortcuts.js: appShortcuts(page) (the consistent app/system set, shared with the Start menu) and siteShortcuts(sites) (one per active site). Per-site entries resolve against the primarySite shared prop. No window manager — opening an icon navigates to the existing full-page route (the draggable webtop is the post-launch v1.1 epic).

Migration / swap rules

Goal: replace ad-hoc .btn / .tool / .input classes and literal colors with primitives + token utilities, keeping all three themes correct. Where a primitive exists, prefer it over the @layer components class.

#Before (ad-hoc / literal)After (primitive / token)
1<button class="btn">Save</button><Button variant="accent" type="submit">Save</Button>
2<button class="tool">Edit</button><Button variant="neutral">Edit</Button>
3<button class="tool-danger">Delete</button><Button variant="danger">Delete</Button>
4<a class="btn" href="/x">Go</a><Button href="/x">Go</Button> (renders Inertia Link)
5class="rounded-lg bg-white border ..." card<Window variant="system" title="..."> (or, if no title bar wanted, a raw panel: class="ui-raised border-2 border-edge bg-surface-raised shadow-window p-4" + :style="{ borderRadius: 'var(--radius)' }")
6bg-whitebg-surface-raised (cards/windows) or bg-surface (page ground)
7text-ink / text-black / text-[#1c1c1a]text-content
8text-gray-500 / muted greystext-content-muted; for hints/timestamps text-content-subtle
9bg-gray-100 / well backgroundsbg-surface-sunken
10border border-gray-300 / any literal borderborder-2 border-edge (or border-edge-soft / divide-edge-soft for soft dividers)
11text-[#1f9b90] / raw teal / text-teal for UItext-accent; bg-accent + text-accent-contrast for filled
12hover :hover:bg-teal-700hover:bg-accent-strong
13text-red-600 / coral hex for errorstext-danger; error box border-2 border-danger bg-danger/10 text-danger
14text-yellow-600 / gold hex warningstext-warning (or bg-warning for fills)
15rounded, rounded-md, rounded-xldrop it; add :style="{ borderRadius: 'var(--radius)' }" (or rounded-window)
16shadow, shadow-md, custom drop shadowshadow-window
17<span class="badge">beta</span> / hand-rolled tag<Badge variant="gold">beta</Badge>
18hand-rolled status dot + text<StatusPill tone="ok">live</StatusPill>
19"coming soon" / placeholder banner, rotated stamps<Stamp tone="gold">…</Stamp> (never rotate-* / skew-*)
20class="font-bold text-xl" headingsh1 font-display text-2xl tracking-tight; h2 font-display text-lg
21inline label <label class="text-sm">.label class or font-mono text-xs font-bold uppercase tracking-wider text-content-muted
22raw <input class="border p-2">class="input" (or class="input max-w-xs"); never literal border/bg
23<a class="text-blue-600 underline">class="link" (= text-accent + hover underline)
24any rotate-2 / -rotate-1 / skew-*remove entirely — nothing is ever tilted
25.dither / halftone applied under textmove it to a text-free element only (progress fill, tile)
26hardcoded panel needing bevels in desktopadd ui-raised (panels/cards) or ui-sunken (wells); primitives already include this

Notes:

  • .btn is w-full by default in CSS; <Button> is NOT (block prop for full width). Don't blindly assume width parity — add block where the old .btn filled its row.
  • .tool carries cursor-pointer; <Button> uses default button cursor (fine).
  • For Inertia method links (e.g. method="post" as="button" logout), keep a raw <Link> styled with token utilities — <Button> only forwards href.

Locked design rules

Enforce these on every reskinned page:

  1. Palette is ONLY teal/coral/gold/charcoal + neutrals via tokens. No literal hex, no bg-white, no text-blue-*, no new colors. (NookMark's fixed hues are the sole exception.)
  2. Type = Space Mono (font-mono, uppercase letter-spaced labels) + Archivo Black (font-display headlines).
  3. Flat OS-window look: border-2 border-edge, var(--radius), colored title bar via <Window variant>, shadow-window. Buttons/pills/stamps are flat.
  4. Never tilt anything — no rotate-* / skew-*.
  5. .dither only on text-free surfaces (progress fills, tiles, bands).
  6. Prefer primitives over hand-rolled markup so light/dark/desktop (Win-95 bevels via ui-raised / ui-sunken) all render correctly; when hand-rolling a panel add ui-raised / ui-sunken.
  7. Do not touch security invariants (preview sandbox, CSP, PathGuard, EditableFileGuard, inert serving, encryptCookies except theme) — these are server-side; reskin stays in markup/classes.

AGPL-3.0 · built in the open.