# Implementation

Shipping the kit into rj-dtc `apps/admin` (Next.js 15, Tailwind 3.4, shadcn/ui new-york): the generated drop-in files, fonts, server and client components, the kit.js behaviours in React, every gotcha found during the build, and the handoff checklist.

*Generated from the kit by `phase-4/src/build_patterns_impl.py`; the HTML pages under `docs/implementation/` carry the live specimens and copy buttons.*

## Contents

- [Shipping files](#shipping-files)
- [tailwind.config.ts (Tailwind 3.4)](#tailwind)
- [globals.css: the shadcn variable block](#globals-css)
- [components.json](#components-json)
- [Fonts with next/font](#fonts)
- [Server and client components](#server-client)
- [kit.js behaviours in React](#behaviours)
- [Gotchas](#gotchas)
- [Handoff checklist](#checklist)

<a id="shipping-files"></a>

## Shipping files

Everything the front-end needs to restyle `apps/admin` is generated from `tokens.css` into `phase-4/implementation-files/`. Copy the tree over `apps/admin/`, then follow the pages in this section.

<a id="shipping-files-files"></a>

### The files

| File (in apps/admin) | What it is | Replaces |
|---|---|---|
| `src/app/tokens.css` | The kit tokens, as-is: colour roles, spacing, radius, shadows, motion, z-index, type scale, reduced motion | nothing (new) |
| `src/app/globals.css` | Tailwind directives + the shadcn colour block (light) + font variables + base styles | the zinc / Inter globals.css |
| `tailwind.config.ts` | Tailwind 3.4 theme mapped onto the tokens, keyframes, token-driven transition utilities | the current config |
| `components.json` | shadcn new-york, RSC, CSS variables, Lucide | the current file (baseColor zinc → slate) |
| `src/app/fonts.ts` | next/font: Archivo, IBM Plex Sans, IBM Plex Mono | Inter + JetBrains Mono from Google |
| `src/app/layout.tsx` | Root layout: CSS order, font variables, token classes, Toaster; Clerk unchanged | the current layout |
| `src/lib/kit-motion.ts` | Durations and delays React props need (Sonner, Tooltip, HoverCard) | nothing (new) |
| `later/tailwind-v4-theme.css` | The v4 `@theme` equivalent, for when the app moves to Tailwind 4. Do not ship today. | — |

> **Rule.** Never edit the generated files by hand. Change `phase-2/kit/tokens.css`, then run `python3 projects/pina/brands/brand-005-rj-dtc-admin/phase-4/src/build_patterns_impl.py` and copy again.

<a id="shipping-files-order"></a>

### Drop-in order

1. Copy `implementation-files/` over `apps/admin/` (it mirrors the folder layout).
2. Add the dependencies the restyle needs: `pnpm --filter rj-admin add sonner @tanstack/react-table vaul react-hook-form @hookform/resolvers`.
3. Delete the old font `@import` and the `#3b82f6` focus rule: the new globals.css already has neither.
4. Run `pnpm --filter rj-admin type-check` and `pnpm --filter rj-admin build`.
5. Restyle the 13 existing `components/ui/*` files to the kit specs, then add the new primitives ([server and client](#server-client)).
6. Work the [gotchas](#gotchas) and the [handoff checklist](#checklist).

<a id="shipping-files-source"></a>

### Where the values come from

`tokens.css` is the only source. The generator parses it at build time, so the config, the globals block, `tokens.json` (W3C Design Tokens) and every token table in these docs carry the same values. Hex values come from the authored hex in each token's comment.

`regenerate`

```bash
python3 projects/pina/brands/brand-005-rj-dtc-admin/phase-4/src/build_patterns_impl.py
```

<a id="tailwind"></a>

## tailwind.config.ts (Tailwind 3.4)

A drop-in Tailwind 3.4 config. Every value points at a CSS variable from `tokens.css`, so the kit stays the one source of truth at runtime and opacity modifiers like `bg-success/soft` work.

<a id="tailwind-map"></a>

### What maps to what

| Tailwind | From | Example class |
|---|---|---|
| `colors` | every colour role as `hsl(var(--x) / <alpha-value>)`; sidebar and chart blocks; `scrim` | `bg-primary hover:bg-primary-hover`, `bg-success/soft text-success` |
| `fontFamily` | `sans` = Plex Sans, `title` = Archivo, `mono` = Plex Mono | `font-title` |
| `fontSize` | every `--text-*` with its `--lh-*` / `--tr-*` where the kit has one | `text-page`, `text-body`, `text-micro` |
| `spacing` | Tailwind's 4px scale already matches `--s-*`; the generator adds what is missing (`18`) plus named sizes | `h-control`, `w-sheet-md`, `h-topbar` |
| `borderRadius` | shadcn `lg/md/sm` plus the kit names | `rounded-control`, `rounded-card` |
| `boxShadow` | every `--e-*` plus `focus` | `shadow-pop`, `focus-visible:shadow-focus` |
| `zIndex` | the `--z-*` scale | `z-sheet`, `z-toast` |
| `transitionTimingFunction` | `standard`, `enter`, `exit` (renamed: Tailwind's `ease-in` means the opposite) | `ease-enter` |
| `animation` | keyframes for Radix overlays, on the motion tokens | `data-[state=open]:animate-sheet-in` |
| plugin | `.transition-control`, `.transition-surface`, `.transition-lift`, `.press`, `.lift`, `.font-title` | `transition-control press` |

> **Note.** Keep the default `screens`, but do not use breakpoints to collapse a desktop layout into a phone one. The phone is a separate composition ([phone patterns](../patterns/phone/)).

<a id="tailwind-file"></a>

### The file

`tailwind.config.ts`

```ts
// GENERATED from phase-2/kit/tokens.css by phase-4/src/build_patterns_impl.py. Do not edit by hand:
// change tokens.css and regenerate. Drop-in for rj-dtc apps/admin (Tailwind 3.4, shadcn/ui new-york).
// Every value points at a CSS variable from tokens.css (shipped as src/app/tokens.css), so the kit stays the
// one source of truth at runtime. Colour roles are bare HSL triples, so opacity modifiers work: bg-success/soft, bg-primary/40.
import type { Config } from "tailwindcss";
import plugin from "tailwindcss/plugin";
import tailwindcssAnimate from "tailwindcss-animate";

const c = (name: string) => `hsl(var(--${name}) / <alpha-value>)`;

const config: Config = {
  // Dark is not designed. The class strategy stays so a stray `dark:` utility compiles but never activates.
  darkMode: ["class"],
  content: [
    "./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/components/**/*.{js,ts,jsx,tsx,mdx}",
    "./src/app/**/*.{js,ts,jsx,tsx,mdx}",
  ],
  theme: {
    extend: {
      // Archivo (titles, buttons, menu, labels, table headers) is NEVER set wide: no font-stretch above 100%,
      // no wdth axis, no positive tracking. font-sans is the reading face (IBM Plex Sans).
      fontFamily: {
        sans: "var(--font-ui)",
        title: "var(--font-title)",
        mono: "var(--font-code)",
      },
      fontSize: {
        "display": ["var(--text-display)", { lineHeight: "var(--lh-display)", letterSpacing: "var(--tr-display)" }],  // 40px
        "page": ["var(--text-page)", { lineHeight: "var(--lh-page)", letterSpacing: "var(--tr-page)" }],  // 26px
        "section": ["var(--text-section)", { lineHeight: "var(--lh-section)", letterSpacing: "var(--tr-section)" }],  // 16px
        "card": ["var(--text-card)", { lineHeight: "var(--lh-card)" }],  // 13.5px
        "body": ["var(--text-body)", { lineHeight: "var(--lh-body)" }],  // 13px
        "read": ["var(--text-read)", { lineHeight: "var(--lh-read)" }],  // 15px
        "label": "var(--text-label)",  // 13px
        "small": "var(--text-small)",  // 12px
        "kpi": "var(--text-kpi)",  // 28px
        "hero": "var(--text-hero)",  // 30px
        "step-m": "var(--text-step-m)",  // 17px
        "input-m": "var(--text-input-m)",  // 16px
        "micro": "var(--text-micro)",  // 11.5px
        "code": "var(--text-code)",  // 12px
        "btn": "var(--text-btn)",  // 13.5px
        "btn-sm": "var(--text-btn-sm)",  // 12.5px
        "btn-lg": "var(--text-btn-lg)",  // 15px
        "8": "var(--text-8)",  // 8px
        "8-5": "var(--text-8-5)",  // 8.5px
        "9": "var(--text-9)",  // 9px
        "9-5": "var(--text-9-5)",  // 9.5px
        "10": "var(--text-10)",  // 10px
        "10-5": "var(--text-10-5)",  // 10.5px
        "11": "var(--text-11)",  // 11px
        "14": "var(--text-14)",  // 14px
        "18": "var(--text-18)",  // 18px
        "19": "var(--text-19)",  // 19px
        "20": "var(--text-20)",  // 20px
        "22": "var(--text-22)",  // 22px
        "24": "var(--text-24)",  // 24px
        "44": "var(--text-44)",  // 44px
        "52": "var(--text-52)",  // 52px
      },
      colors: {
        "background": c("background"),
        "foreground": c("foreground"),
        "card": { "DEFAULT": c("card"), "foreground": c("card-foreground") },
        "popover": { "DEFAULT": c("popover"), "foreground": c("popover-foreground") },
        "primary": { "DEFAULT": c("primary"), "foreground": c("primary-foreground"), "hover": c("primary-hover") },
        "secondary": { "DEFAULT": c("secondary"), "foreground": c("secondary-foreground"), "border": c("secondary-border") },
        "muted": { "DEFAULT": c("muted"), "foreground": c("muted-foreground") },
        "accent": { "DEFAULT": c("accent"), "foreground": c("accent-foreground") },
        "destructive": { "DEFAULT": c("destructive"), "foreground": c("destructive-foreground") },
        "border": c("border"),
        "input": { "DEFAULT": c("input"), "hover": c("input-hover") },
        "secondary-border": { "hover": c("secondary-border-hover") },
        "ring": c("ring"),
        "success": { "DEFAULT": c("success"), "foreground": c("success-foreground") },
        "warning": { "DEFAULT": c("warning"), "foreground": c("warning-foreground") },
        "info": { "DEFAULT": c("info"), "foreground": c("info-foreground") },
        "neutral": { "DEFAULT": c("neutral"), "foreground": c("neutral-foreground") },
        "draft": c("draft"),
        "available": c("available"),
        "retired": c("retired"),
        "locked": c("locked"),
        "not-wired": c("not-wired"),
        "canvas": c("canvas"),
        sidebar: { "DEFAULT": c("sidebar"), "foreground": c("sidebar-foreground"), "primary": c("sidebar-primary"), "primary-foreground": c("sidebar-primary-foreground"), "accent": c("sidebar-accent"), "accent-foreground": c("sidebar-accent-foreground"), "border": c("sidebar-border"), "ring": c("sidebar-ring") },
        chart: { "1": c("chart-1"), "2": c("chart-2"), "3": c("chart-3"), "4": c("chart-4"), "5": c("chart-5"), "muted": c("chart-muted") },
        scrim: "var(--scrim)",
        "sticky-edge": "var(--sticky-edge)",
      },
      spacing: {
        "18": "var(--s-18)",  // 72px (not in the Tailwind default scale)
        "control": "var(--control-h)",  // 40px
        "control-sm": "var(--control-h-sm)",  // 34px
        "control-xs": "var(--control-h-xs)",  // 28px
        "control-m": "var(--control-h-m)",  // 44px
        "control-m-lg": "var(--control-h-m-lg)",  // 48px
        "row": "var(--row)",  // 44px
        "row-th": "var(--row-th)",  // 36px
        "row-compact": "var(--row-compact)",  // 36px
        "topbar": "var(--topbar-h)",  // 56px
        "sidebar": "var(--sidebar-w)",  // 232px
        "sheet": "var(--sheet-w)",  // 404px
        "sheet-md": "var(--sheet-w-md)",  // 532px
        "sheet-lg": "var(--sheet-w-lg)",  // 856px
        "dialog": "var(--dialog-w)",  // 480px
        "help": "var(--help-w)",  // 380px
        "chip": "var(--chip-h)",  // 22px
        "chip-lg": "var(--chip-h-lg)",  // 26px
        "pbar": "var(--pbar-h)",  // 52px
        "tabbar": "var(--tabbar-h)",  // 62px
        "tab": "var(--tab-h)",  // 40px
        "tab-m": "var(--tab-h-m)",  // 44px
        "kv-label": "var(--kv-label)",  // 120px
        "kv-label-m": "var(--kv-label-m)",  // 104px
        "li": "var(--li-min-h)",  // 72px
      },
      // Status soft fill: bg-success/soft = the status colour at --status-soft-alpha (.12).
      opacity: {
        soft: "var(--status-soft-alpha)",
      },
      maxWidth: {
        content: "var(--content-max)",  // 1280px
      },
      borderRadius: {
        lg: "var(--radius)",  // 0.375rem = 6px (shadcn)
        md: "calc(var(--radius) - 2px)",  // 4px
        sm: "calc(var(--radius) - 4px)",  // 2px
        "chip": "var(--r-chip)",  // 4px
        "control": "var(--r-control)",  // 6px
        "card": "var(--r-card)",  // 8px
        "frame": "var(--r-frame)",  // 15px
        "pill": "var(--r-pill)",  // 999px
        "media": "var(--r-media)",  // 4px
        "card-inner": "var(--r-card-inner)",  // calc(var(--r-card) - 1px)
      },
      boxShadow: {
        "1": "var(--e-1)",
        "btn": "var(--e-btn)",
        "btn-2": "var(--e-btn-2)",
        "frame": "var(--e-frame)",
        "pop": "var(--e-pop)",
        "toast": "var(--e-toast)",
        "sheet": "var(--e-sheet)",
        "dialog": "var(--e-dialog)",
        "phonsheet": "var(--e-phone-sheet)",
        "card-hover": "var(--e-card-hover)",
        "rail": "var(--e-rail)",
        "rail-warning": "var(--e-rail-warning)",
        "rail-strong": "var(--e-rail-strong)",
        "rail-strong-info": "var(--e-rail-strong-info)",
        "rail-strong-warning": "var(--e-rail-strong-warning)",
        "rail-strong-destructive": "var(--e-rail-strong-destructive)",
        "row-focus": "var(--e-row-focus)",
        "row-focus-first": "var(--e-row-focus-first)",
        "sidebar-edge": "var(--e-sidebar-edge)",
        "sidebar-item-on": "var(--e-sidebar-item-on)",
        "brand-mark": "var(--e-brand-mark)",
        focus: "var(--focus-ring)",
      },
      zIndex: {
        "sticky": "var(--z-sticky)",  // 3
        "pop": "var(--z-pop)",  // 20
        "tip": "var(--z-tip)",  // 25
        "scrim": "var(--z-scrim)",  // 30
        "sheet": "var(--z-sheet)",  // 31
        "guard-scrim": "var(--z-guard-scrim)",  // 40
        "guard": "var(--z-guard)",  // 41
        "toast": "var(--z-toast)",  // 50
      },
      // Named enter / exit, not in / out: Tailwind's own ease-in / ease-out mean the opposite curves.
      transitionTimingFunction: {
        "standard": "var(--ease-standard)",  // cubic-bezier(.2,0,0,1)
        "enter": "var(--ease-in)",  // cubic-bezier(.22,1,.36,1)
        "exit": "var(--ease-out)",  // cubic-bezier(.4,0,1,1)
      },
      // Literal ms for utilities such as duration-hover. Prefer the transition-* utilities from the plugin
      // below: they read the motion tokens, so prefers-reduced-motion collapses them to 1ms.
      transitionDuration: {
        "hover": "120ms",
        "press": "80ms",
        "focus": "140ms",
        "pop-in": "160ms",
        "pop-out": "110ms",
        "sheet-in": "260ms",
        "sheet-out": "180ms",
        "toast-in": "200ms",
        "toast-out": "140ms",
        "spin": "800ms",
        "spin-reduced": "2400ms",
        "flash": "900ms",
        "shake": "360ms",
        "rotate": "1200ms",
        "indeterminate": "1500ms",
        "loadbar": "1400ms",
        "caret": "1100ms",
        "handoff": "200ms",
        "handoff-sheet": "300ms",
      },
      keyframes: {
        "accordion-down": { from: { height: "0" }, to: { height: "var(--radix-accordion-content-height)" } },
        "accordion-up": { from: { height: "var(--radix-accordion-content-height)" }, to: { height: "0" } },
        "k-pop-in": { from: { opacity: "0", transform: "translateY(var(--pop-shift)) scale(.985)" }, to: { opacity: "1", transform: "none" } },
        "k-pop-out": { from: { opacity: "1", transform: "none" }, to: { opacity: "0", transform: "translateY(var(--pop-shift)) scale(.985)" } },
        "k-sheet-in": { from: { transform: "translateX(104%)" }, to: { transform: "none" } },
        "k-sheet-out": { from: { transform: "none" }, to: { transform: "translateX(104%)" } },
        "k-bottom-in": { from: { transform: "translateY(104%)" }, to: { transform: "none" } },
        "k-bottom-out": { from: { transform: "none" }, to: { transform: "translateY(104%)" } },
        // Dialog content is centred with translate(-50%,-50%); the keyframes carry it, or the animation drops it.
        "k-dialog-in": { from: { opacity: "0", transform: "translate(-50%,-48%) scale(.98)" }, to: { opacity: "1", transform: "translate(-50%,-50%)" } },
        "k-dialog-out": { from: { opacity: "1", transform: "translate(-50%,-50%)" }, to: { opacity: "0", transform: "translate(-50%,-48%) scale(.98)" } },
        "k-fade-in": { from: { opacity: "0" }, to: { opacity: "1" } },
        "k-fade-out": { from: { opacity: "1" }, to: { opacity: "0" } },
        "k-skel": { from: { opacity: ".55" }, to: { opacity: "1" } },
      },
      // Radix waits for animationend before unmounting, so overlays need keyframe animations, not transitions.
      animation: {
        "accordion-down": "accordion-down var(--motion-pop-in)",
        "accordion-up": "accordion-up var(--motion-pop-out)",
        "pop-in": "k-pop-in var(--motion-pop-in) both",
        "pop-out": "k-pop-out var(--motion-pop-out) both",
        "sheet-in": "k-sheet-in var(--motion-sheet-in) both",
        "sheet-out": "k-sheet-out var(--motion-sheet-out) both",
        "bottom-in": "k-bottom-in var(--motion-sheet-in) both",
        "bottom-out": "k-bottom-out var(--motion-sheet-out) both",
        "dialog-in": "k-dialog-in var(--motion-sheet-in) both",
        "dialog-out": "k-dialog-out var(--motion-pop-out) both",
        "scrim-in": "k-fade-in var(--motion-sheet-in) both",
        "scrim-out": "k-fade-out var(--motion-sheet-out) both",
        skel: "k-skel var(--motion-skeleton)",
      },
    },
  },
  plugins: [
    tailwindcssAnimate,
    // Token-driven transitions, copied from the kit's base.css so the timing matches the approved board.
    plugin(({ addUtilities }) => {
      addUtilities({
        ".transition-control": {
          transition:
            "background-color var(--motion-hover), border-color var(--motion-hover), color var(--motion-hover), " +
            "box-shadow var(--motion-focus), transform var(--motion-press), filter var(--motion-hover)",
        },
        ".transition-surface": { transition: "background-color var(--motion-hover), color var(--motion-hover)" },
        ".transition-lift": { transition: "transform var(--motion-hover), box-shadow var(--motion-hover), border-color var(--motion-hover)" },
        ".press": { "&:active": { transform: "translateY(var(--press-shift))" } },
        ".lift": { "&:hover": { transform: "translateY(var(--lift-shift))", boxShadow: "var(--e-card-hover)" } },
        ".font-title": { fontStretch: "var(--font-stretch-title)", fontVariationSettings: "normal" },
      });
    }),
  ],
};

export default config;
```

<a id="tailwind-v4"></a>

### Later: Tailwind v4

When `apps/admin` moves to Tailwind 4, the theme moves into CSS. The generated equivalent is in `implementation-files/later/tailwind-v4-theme.css`; `tokens.css` stays as it is.

`later/tailwind-v4-theme.css`

```css
/* LATER, not for today: the Tailwind v4 equivalent of tailwind.config.ts, for when apps/admin moves to v4.
   GENERATED by phase-4/src/build_patterns_impl.py. v4 reads the theme from CSS; tokens.css stays as-is. */
@import "tailwindcss";
@import "./tokens.css";

@theme inline {
  --color-background: hsl(var(--background));
  --color-foreground: hsl(var(--foreground));
  --color-card: hsl(var(--card));
  --color-card-foreground: hsl(var(--card-foreground));
  --color-popover: hsl(var(--popover));
  --color-popover-foreground: hsl(var(--popover-foreground));
  --color-primary: hsl(var(--primary));
  --color-primary-foreground: hsl(var(--primary-foreground));
  --color-primary-hover: hsl(var(--primary-hover));
  --color-secondary: hsl(var(--secondary));
  --color-secondary-foreground: hsl(var(--secondary-foreground));
  --color-muted: hsl(var(--muted));
  --color-muted-foreground: hsl(var(--muted-foreground));
  --color-accent: hsl(var(--accent));
  --color-accent-foreground: hsl(var(--accent-foreground));
  --color-destructive: hsl(var(--destructive));
  --color-destructive-foreground: hsl(var(--destructive-foreground));
  --color-border: hsl(var(--border));
  --color-input: hsl(var(--input));
  --color-input-hover: hsl(var(--input-hover));
  --color-secondary-border: hsl(var(--secondary-border));
  --color-secondary-border-hover: hsl(var(--secondary-border-hover));
  --color-ring: hsl(var(--ring));
  --color-success: hsl(var(--success));
  --color-success-foreground: hsl(var(--success-foreground));
  --color-warning: hsl(var(--warning));
  --color-warning-foreground: hsl(var(--warning-foreground));
  --color-info: hsl(var(--info));
  --color-info-foreground: hsl(var(--info-foreground));
  --color-neutral: hsl(var(--neutral));
  --color-neutral-foreground: hsl(var(--neutral-foreground));
  --color-draft: hsl(var(--draft));
  --color-available: hsl(var(--available));
  --color-retired: hsl(var(--retired));
  --color-locked: hsl(var(--locked));
  --color-not-wired: hsl(var(--not-wired));
  --color-sidebar: hsl(var(--sidebar));
  --color-sidebar-background: hsl(var(--sidebar-background));
  --color-sidebar-foreground: hsl(var(--sidebar-foreground));
  --color-sidebar-primary: hsl(var(--sidebar-primary));
  --color-sidebar-primary-foreground: hsl(var(--sidebar-primary-foreground));
  --color-sidebar-accent: hsl(var(--sidebar-accent));
  --color-sidebar-accent-foreground: hsl(var(--sidebar-accent-foreground));
  --color-sidebar-border: hsl(var(--sidebar-border));
  --color-sidebar-ring: hsl(var(--sidebar-ring));
  --color-chart-1: hsl(var(--chart-1));
  --color-chart-2: hsl(var(--chart-2));
  --color-chart-3: hsl(var(--chart-3));
  --color-chart-4: hsl(var(--chart-4));
  --color-chart-5: hsl(var(--chart-5));
  --color-chart-muted: hsl(var(--chart-muted));
  --color-canvas: hsl(var(--canvas));
  --color-scrim: var(--scrim);

  --font-sans: var(--font-ui);
  --font-title: var(--font-title);
  --font-mono: var(--font-code);

  --text-display: var(--text-display);
  --text-page: var(--text-page);
  --text-section: var(--text-section);
  --text-card: var(--text-card);
  --text-body: var(--text-body);
  --text-read: var(--text-read);
  --text-label: var(--text-label);
  --text-small: var(--text-small);
  --text-kpi: var(--text-kpi);
  --text-hero: var(--text-hero);
  --text-step-m: var(--text-step-m);
  --text-input-m: var(--text-input-m);
  --text-micro: var(--text-micro);
  --text-code: var(--text-code);
  --text-btn: var(--text-btn);
  --text-btn-sm: var(--text-btn-sm);
  --text-btn-lg: var(--text-btn-lg);
  --text-8: var(--text-8);
  --text-8-5: var(--text-8-5);
  --text-9: var(--text-9);
  --text-9-5: var(--text-9-5);
  --text-10: var(--text-10);
  --text-10-5: var(--text-10-5);
  --text-11: var(--text-11);
  --text-14: var(--text-14);
  --text-18: var(--text-18);
  --text-19: var(--text-19);
  --text-20: var(--text-20);
  --text-22: var(--text-22);
  --text-24: var(--text-24);
  --text-44: var(--text-44);
  --text-52: var(--text-52);

  --radius-chip: var(--r-chip);
  --radius-control: var(--r-control);
  --radius-card: var(--r-card);
  --radius-frame: var(--r-frame);
  --radius-pill: var(--r-pill);
  --radius-media: var(--r-media);
  --radius-card-inner: var(--r-card-inner);

  --shadow-1: var(--e-1);
  --shadow-btn: var(--e-btn);
  --shadow-btn-2: var(--e-btn-2);
  --shadow-frame: var(--e-frame);
  --shadow-pop: var(--e-pop);
  --shadow-toast: var(--e-toast);
  --shadow-sheet: var(--e-sheet);
  --shadow-dialog: var(--e-dialog);
  --shadow-phone-sheet: var(--e-phone-sheet);
  --shadow-card-hover: var(--e-card-hover);
  --shadow-rail: var(--e-rail);
  --shadow-rail-warning: var(--e-rail-warning);
  --shadow-rail-strong: var(--e-rail-strong);
  --shadow-rail-strong-info: var(--e-rail-strong-info);
  --shadow-rail-strong-warning: var(--e-rail-strong-warning);
  --shadow-rail-strong-destructive: var(--e-rail-strong-destructive);
  --shadow-row-focus: var(--e-row-focus);
  --shadow-row-focus-first: var(--e-row-focus-first);
  --shadow-sidebar-edge: var(--e-sidebar-edge);
  --shadow-sidebar-item-on: var(--e-sidebar-item-on);
  --shadow-brand-mark: var(--e-brand-mark);
  --shadow-focus: var(--focus-ring);

  --ease-standard: var(--ease-standard);
  --ease-enter: var(--ease-in);
  --ease-exit: var(--ease-out);
}
```

<a id="globals-css"></a>

## globals.css: the shadcn variable block

The shadcn colour roles, generated from `tokens.css`, in the `globals.css` the shadcn CLI expects, plus the font variables and a small base layer.

<a id="globals-css-notes"></a>

### Read this first

- **Load order**: `import "./tokens.css"` then `import "./globals.css"` in `layout.tsx`. globals.css must come second so its font variables win.
- **Light only.** Dark is not designed, so there is no `.dark` block. `darkMode: ["class"]` stays in the config so a stray `dark:` class compiles and never activates.
- The colour roles repeat the values in `tokens.css` one for one. Both come from one generator, so they cannot disagree; keep them generated.
- `--brand-progress-*` stay for `brand-page.tsx`, mapped onto kit roles, until that page moves to tokens.
- The old blue focus outline and the raw scrollbar colour are gone; the focus ring comes from `--ring`.

<a id="globals-css-roles"></a>

### Colour roles

| Token | Value | Hex | Note |
|---|---|---|---|
| `--background` | `0 0% 100%` | `#FFFFFF` | page surface (clean white canvas) |
| `--foreground` | `222 43% 10%` | `#0F1626` | body text, headings |
| `--card` | `0 0% 100%` | `#FFFFFF` |  |
| `--popover` | `0 0% 100%` | `#FFFFFF` | menus, sheets, dialogs, toasts |
| `--primary` | `220 49% 11%` | `#0E1729` | RJ midnight navy (--rj-ink): every primary action |
| `--primary-foreground` | `60 17% 98%` | `#FAFAF8` |  |
| `--primary-hover` | `223 42% 18%` | `#1A2540` | --rj-ink-hover |
| `--secondary` | `0 0% 100%` | `#FFFFFF` | outlined secondary button ground |
| `--secondary-foreground` | `220 49% 11%` | `#0E1729` |  |
| `--muted` | `225 22% 96%` | `#F4F5F8` | table header, wells, hover |
| `--muted-foreground` | `221 17% 38%` | `#505A70` | quiet text (>= 5.9:1 on white) |
| `--accent` | `224 38% 94%` | `#EBEEF6` | selection, active filter |
| `--accent-foreground` | `220 49% 11%` | `#0E1729` |  |
| `--destructive` | `353 53% 42%` | `#A2323F` |  |
| `--border` | `220 20% 91%` | `#E4E7ED` | hairlines |
| `--input` | `219 14% 59%` | `#8892A5` | control borders: 3.1:1 on white (WCAG 1.4.11); was #C6CCD7 at 1.6:1 |
| `--input-hover` | `223 17% 45%` | `#5F6A86` | field / checkbox / select border on hover |
| `--secondary-border` | `219 14% 79%` | `#C2C7D1` | outlined secondary button edge (board 3, text-labelled so exempt from 3:1) |
| `--ring` | `221 45% 45%` | `#3F5FA6` | focus |
| `--radius` | `0.375rem` |  | 6px  (shadcn --radius) |

<a id="globals-css-file"></a>

### The file

`src/app/globals.css`

```css
/* GENERATED from phase-2/kit/tokens.css by phase-4/src/build_patterns_impl.py. Do not edit by hand.
   Drop-in replacement for rj-dtc apps/admin/src/app/globals.css (Tailwind 3.4, shadcn/ui new-york).

   Load order (src/app/layout.tsx):  import "./tokens.css";  import "./globals.css";
   tokens.css ships AS-IS and holds every non-colour value (spacing, radius, shadows, motion, z-index, type scale).
   The shadcn colour roles below repeat its values one for one so the shadcn CLI finds them here; both files come
   from one generator, so they cannot disagree. LIGHT ONLY: dark is not designed, so there is no .dark block. */

@tailwind base;
@tailwind components;
@tailwind utilities;

@layer base {
  :root {
    color-scheme: light;

    /* shadcn core roles */
    --background: 0 0% 100%;  /* #FFFFFF */
    --foreground: 222 43% 10%;  /* #0F1626 */
    --card: 0 0% 100%;  /* #FFFFFF */
    --card-foreground: 222 43% 10%;  /* #0F1626 */
    --popover: 0 0% 100%;  /* #FFFFFF */
    --popover-foreground: 222 43% 10%;  /* #0F1626 */
    --primary: 220 49% 11%;  /* #0E1729 */
    --primary-foreground: 60 17% 98%;  /* #FAFAF8 */
    --primary-hover: 223 42% 18%;  /* #1A2540 */
    --secondary: 0 0% 100%;  /* #FFFFFF */
    --secondary-foreground: 220 49% 11%;  /* #0E1729 */
    --muted: 225 22% 96%;  /* #F4F5F8 */
    --muted-foreground: 221 17% 38%;  /* #505A70 */
    --accent: 224 38% 94%;  /* #EBEEF6 */
    --accent-foreground: 220 49% 11%;  /* #0E1729 */
    --destructive: 353 53% 42%;  /* #A2323F */
    --destructive-foreground: 0 0% 100%;  /* #FFFFFF */
    --border: 220 20% 91%;  /* #E4E7ED */
    --input: 219 14% 59%;  /* #8892A5 */
    --input-hover: 223 17% 45%;  /* #5F6A86 */
    --secondary-border: 219 14% 79%;  /* #C2C7D1 */
    --secondary-border-hover: 220 10% 70%;  /* #ABB0BA */
    --ring: 221 45% 45%;  /* #3F5FA6 */
    --radius: 0.375rem;

    /* status roles */
    --success: 158 51% 26%;  /* #21654C */
    --success-foreground: 0 0% 100%;  /* #FFFFFF */
    --warning: 36 79% 29%;  /* #83550F */
    --warning-foreground: 0 0% 100%;  /* #FFFFFF */
    --info: 218 49% 39%;  /* #335794 */
    --info-foreground: 0 0% 100%;  /* #FFFFFF */
    --neutral: 222 13% 39%;  /* #575F72 */
    --neutral-foreground: 0 0% 100%;  /* #FFFFFF */
    --draft: var(--info);  /* #335794 */
    --available: var(--success);  /* #21654C */
    --retired: var(--neutral);  /* #575F72 */
    --locked: var(--neutral);  /* #575F72 */
    --not-wired: var(--warning);  /* #83550F */

    /* sidebar */
    --sidebar: 220 49% 11%;  /* #0E1729 */
    --sidebar-background: var(--sidebar);  /* #0E1729 */
    --sidebar-foreground: 222 19% 82%;  /* #C9CEDA */
    --sidebar-primary: 221 54% 75%;  /* #9FB4E2 */
    --sidebar-primary-foreground: 220 49% 11%;  /* #0E172A */
    --sidebar-accent: 222 43% 19%;  /* #1B2744 */
    --sidebar-accent-foreground: 60 17% 98%;  /* #FAFAF8 */
    --sidebar-border: 222 33% 22%;  /* #253049 */
    --sidebar-ring: 221 54% 75%;  /* #9DB3E2 */

    /* charts */
    --chart-1: 219 61% 47%;  /* #2F62C0 */
    --chart-2: 166 76% 36%;  /* #16A07F */
    --chart-3: 20 70% 53%;  /* #DB6B35 */
    --chart-4: 229 62% 68%;  /* #7C8FE0 */
    --chart-5: 334 58% 58%;  /* #D2588C */
    --chart-muted: 221 25% 58%;  /* #7A8BB0 */

    /* presentation */
    --canvas: 214 24% 94%;  /* #EDF0F4 */

    /* fonts: next/font hashes the family names, so the token stacks must point at the next/font variables
       (src/app/fonts.ts). The literal 'Archivo' / 'IBM Plex Sans' names in tokens.css never match on their own. */
    --font-title: var(--font-archivo), system-ui, sans-serif;
    --font-voice: var(--font-title);
    --font-ui: var(--font-plex-sans), system-ui, sans-serif;
    --font-code: var(--font-plex-mono), ui-monospace, monospace;

    /* legacy: brand-page.tsx still reads these three; mapped onto kit roles until it moves to tokens */
    --brand-progress-bg: var(--accent);
    --brand-progress-border: var(--secondary-border);
    --brand-progress-fg: var(--primary);
  }

  * {
    @apply border-border;
  }

  html {
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
  }

  body {
    @apply bg-background text-foreground font-sans text-body antialiased;
    font-feature-settings: "cv11" 1;
  }

  /* Archivo voice: normal width only (Frank, 2026-10-11) */
  h1, h2, h3, h4, th, label, button, .font-title {
    font-stretch: var(--font-stretch-title);
    font-variation-settings: normal;
  }

  code, kbd, samp, pre {
    font-family: var(--font-code);
    letter-spacing: 0;
  }

  /* one focus ring, from the token (replaces the old 2px #3b82f6 outline on buttons) */
  :focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
  }

  /* phone composition: iOS zooms a focused field under 16px */
  [data-composition="phone"] input:not([type="checkbox"]):not([type="radio"]):not([type="range"]),
  [data-composition="phone"] textarea,
  [data-composition="phone"] select {
    font-size: var(--text-input-m);
  }
}
```

<a id="globals-css-tokens"></a>

### tokens.css (ships as-is)

`src/app/tokens.css`

```css
/* =====================================================================================
   RJ DTC Admin · Round 2 component kit · tokens.css  (FROZEN 2026-10-11 · integration pass 2026-10-11 · v3 named literals 2026-10-12, see KIT-CHANGELOG.md)
   Direction: Round 1c board 3 "Fine Worsted · Satin Lapel", picked by Frank 2026-10-11,
   with his one amendment: Archivo at its NORMAL width (no font-stretch, wdth 100, no
   widening letter-spacing) on titles, buttons and the menu.
   Source of every value: phase-1/r3/src/directions_r3.py (SATIN_LAPEL) on top of
   phase-1/src/base.css + phase-1/r2/src/directions_r2.py (Fine Worsted skin).

   Colour roles are shadcn-style bare HSL triples ("H S% L%"), so they map 1:1 to a
   shadcn/ui globals.css and are used as  hsl(var(--x))  or  hsl(var(--x) / .12).
   Hex in the trailing comment is the authored value. Change nothing here inside a family
   page: if a family needs a new token, raise it in CONTRACT.md § Raising a token.
   ===================================================================================== */

:root {
  color-scheme: light;

  /* ---------- shadcn core roles ---------- */
  --background: 0 0% 100%;                 /* #FFFFFF  page surface (clean white canvas) */
  --foreground: 222 43% 10%;               /* #0F1626  body text, headings */
  --card: 0 0% 100%;                       /* #FFFFFF */
  --card-foreground: 222 43% 10%;          /* #0F1626 */
  --popover: 0 0% 100%;                    /* #FFFFFF  menus, sheets, dialogs, toasts */
  --popover-foreground: 222 43% 10%;       /* #0F1626 */
  --primary: 220 49% 11%;                  /* #0E1729  RJ midnight navy (--rj-ink): every primary action */
  --primary-foreground: 60 17% 98%;        /* #FAFAF8 */
  --primary-hover: 223 42% 18%;            /* #1A2540  --rj-ink-hover */
  --secondary: 0 0% 100%;                  /* #FFFFFF  outlined secondary button ground */
  --secondary-foreground: 220 49% 11%;     /* #0E1729 */
  --muted: 225 22% 96%;                    /* #F4F5F8  table header, wells, hover */
  --muted-foreground: 221 17% 38%;         /* #505A70  quiet text (>= 5.9:1 on white) */
  --accent: 224 38% 94%;                   /* #EBEEF6  selection, active filter */
  --accent-foreground: 220 49% 11%;        /* #0E1729 */
  --destructive: 353 53% 42%;              /* #A2323F */
  --destructive-foreground: 0 0% 100%;     /* #FFFFFF */
  --border: 220 20% 91%;                   /* #E4E7ED  hairlines */
  --input: 219 14% 59%;                    /* #8892A5  control borders: 3.1:1 on white (WCAG 1.4.11); was #C6CCD7 at 1.6:1 */
  --input-hover: 223 17% 45%;              /* #5F6A86  field / checkbox / select border on hover */
  --secondary-border: 219 14% 79%;         /* #C2C7D1  outlined secondary button edge (board 3, text-labelled so exempt from 3:1) */
  --secondary-border-hover: 220 10% 70%;   /* #ABB0BA */
  --ring: 221 45% 45%;                     /* #3F5FA6  focus */
  --radius: 0.375rem;                      /* 6px  (shadcn --radius) */

  /* ---------- status roles (each has a -foreground for text ON a solid fill) ---------- */
  --success: 158 51% 26%;                  /* #21654C */
  --success-foreground: 0 0% 100%;
  --warning: 36 79% 29%;                   /* #83550F  also the STAGING badge */
  --warning-foreground: 0 0% 100%;
  --info: 218 49% 39%;                     /* #335794 */
  --info-foreground: 0 0% 100%;
  --neutral: 222 13% 39%;                  /* #575F72 */
  --neutral-foreground: 0 0% 100%;
  /* catalogue lifecycle — always use these names, never the generic role, in product UI */
  --draft: var(--info);                    /* Draft     = info blue */
  --available: var(--success);             /* Available = green */
  --retired: var(--neutral);               /* Retired   = slate */
  --locked: var(--neutral);                /* "locked" banner + lock chip */
  --not-wired: var(--warning);             /* "not wired" banner (feature exists, backend not connected) */
  /* soft fills: status at 12% on white; text stays the status colour (all >= 4.5:1) */
  --status-soft-alpha: .12;

  /* ---------- sidebar (shadcn sidebar block) ---------- */
  --sidebar: 220 49% 11%;                  /* #0E1729 */
  --sidebar-background: var(--sidebar);    /* older shadcn name */
  --sidebar-foreground: 222 19% 82%;       /* #C9CEDA  --rj-mist */
  --sidebar-primary: 221 54% 75%;          /* #9FB4E2  active-item rail + icon tint */
  --sidebar-primary-foreground: 220 49% 11%;
  --sidebar-accent: 222 43% 19%;           /* #1B2744 */
  --sidebar-accent-foreground: 60 17% 98%; /* #FAFAF8 */
  --sidebar-border: 222 33% 22%;           /* #253049 */
  --sidebar-ring: 221 54% 75%;
  /* the satin sheen: about a third of Silk Facing's glow, sidebar only, never on buttons */
  --sidebar-sheen: linear-gradient(180deg, #131D33 0%, #0E1729 52%, #0C1425 100%);
  --sidebar-sheen-light: radial-gradient(closest-side, rgba(130,160,220,.11), transparent);

  /* ---------- charts (shadcn --chart-1..5) ----------
     Categorical order, assigned in this order and never cycled. Validated with the dataviz
     validator on white: lightness band, chroma floor, adjacent CVD >= 10.6, normal-vision >= 15.9,
     all >= 3:1; slots 1-3 also pass --pairs all. None of them is a status colour.
     A single series uses --chart-1; a comparison / previous period uses --chart-muted (dotted). */
  --chart-1: 219 61% 47%;                  /* #2F62C0  blue */
  --chart-2: 166 76% 36%;                  /* #16A07F  teal */
  --chart-3: 20 70% 53%;                   /* #DB6B35  coral */
  --chart-4: 229 62% 68%;                  /* #7C8FE0  periwinkle */
  --chart-5: 334 58% 58%;                  /* #D2588C  rose */
  --chart-muted: 221 25% 58%;              /* #7A8BB0  steel: previous period, reference lines, never a category */

  /* ---------- presentation (kit pages only, not product UI) ---------- */
  --canvas: 214 24% 94%;                   /* #EDF0F4  page behind specimen cards */
  --studio: radial-gradient(120% 90% at 50% 18%, #f7f8fa 0%, #e3e7ee 80%); /* garment photo ground */

  /* ---------- type ---------- */
  --font-title: 'Archivo', system-ui, sans-serif;     /* titles, buttons, menu, labels, table headers */
  --font-voice: var(--font-title);
  --font-ui: 'IBM Plex Sans', system-ui, sans-serif;  /* reading text, tables, prices */
  --font-code: 'IBM Plex Mono', ui-monospace, monospace; /* cloth codes, handles, versions, ids */
  --font-stretch-title: 100%;              /* Frank 2026-10-11: Archivo is NEVER set wide */
  --title-w: 600;
  /* scale: size / line-height / tracking (tracking is never positive on Archivo) */
  --text-display: 40px;  --lh-display: 1.05; --tr-display: -.02em;   /* kit page H1 only */
  --text-page: 26px;     --lh-page: 1.15;    --tr-page: -.015em;     /* page title in the app */
  --text-section: 16px;  --lh-section: 1.25; --tr-section: -.005em;  /* sheet / dialog / panel title */
  --text-card: 13.5px;   --lh-card: 1.3;                              /* card title, panel h3 */
  --text-body: 13px;     --lh-body: 1.45;                             /* default UI text (Plex) */
  --text-read: 15px;     --lh-read: 1.55;                             /* help drawer, long copy */
  --text-label: 13px;                                                /* form label (Archivo 600) */
  --text-small: 12px;                                                /* meta, table secondary */
  --text-kpi: 28px;      --text-hero: 30px;                          /* KPI value / chart headline figure (raised by data-display) */
  --text-step-m: 17px;                                               /* phone compact step title (raised by wizard-stepper) */
  --text-input-m: 16px;                                              /* phone text fields: iOS never zooms at >= 16px */
  --text-micro: 11.5px;                                              /* chips, counts, th */
  --text-code: 12px;
  --text-btn: 13.5px;    --text-btn-sm: 12.5px;  --text-btn-lg: 15px;
  --w-regular: 400; --w-medium: 500; --w-semibold: 600; --w-bold: 700;

  /* ---------- spacing: 4px grid ---------- */
  --s-0: 0; --s-1: 4px; --s-2: 8px; --s-3: 12px; --s-4: 16px; --s-5: 20px; --s-6: 24px;
  --s-7: 28px; --s-8: 32px; --s-10: 40px; --s-12: 48px; --s-16: 64px; --s-18: 72px;

  /* ---------- sizes ---------- */
  --control-h: 40px;      /* button, input, select (desktop) */
  --control-h-sm: 34px;   /* small button, row actions */
  --control-h-m: 44px;    /* phone minimum tap target */
  --control-h-m-lg: 48px; /* phone primary / sheet buttons */
  --row: 44px;            /* table row */
  --row-th: 36px;         /* table header */
  --topbar-h: 56px;
  --sidebar-w: 232px;
  --sheet-w: 404px;
  --dialog-w: 480px;
  --help-w: 380px;
  --sheet-w-md: 532px;    /* medium side sheet: detail views (raised by overlays) */
  --sheet-w-lg: 856px;    /* large side sheet: create key, long forms (raised by overlays) */
  --row-compact: 36px;    /* compact table density (raised by data-table) */
  --control-h-xs: 28px;   /* inline buttons inside photo slots, compact pills (raised by form-controls) */
  --chip-h: 22px;
  --chip-h-lg: 26px;      /* header-size chip (raised by status-feedback) */
  --pbar-h: 52px;         /* phone top bar (raised by specimens) */
  --tabbar-h: 62px;       /* phone tab bar incl. padding (raised by specimens) */
  --content-max: 1280px;  /* page content stops growing past this (raised by specimens; build lane) */

  /* ---------- radius ---------- */
  --r-chip: 4px;                           /* square chips */
  --r-control: 6px;                        /* buttons, inputs, menu items, filter chips */
  --r-card: 8px;                           /* cards, table wrap, popovers (= radius + 2) */
  --r-frame: 15px;                         /* panels, app frame, specimen cards */
  --r-pill: 999px;                         /* switch, avatar */
  --r-media: 4px;                          /* photos inside cards */
  --r-card-inner: calc(var(--r-card) - 1px); /* photo corners inside a 1px-bordered card (raised by cards-gallery) */

  /* ---------- elevation ---------- */
  --e-0: none;
  --e-1: 0 1px 2px rgba(14,23,41,.04);                                   /* card */
  --e-btn: 0 1px 2px rgba(14,23,41,.20);                                 /* primary button */
  --e-btn-2: 0 1px 1px rgba(14,23,41,.05);                               /* secondary button */
  --e-frame: 0 0 0 1px rgba(14,23,41,.05), 0 16px 36px -24px rgba(14,23,41,.20); /* app frame */
  --e-pop: 0 12px 32px -10px rgba(14,23,41,.25);                         /* dropdown, combobox */
  --e-toast: 0 12px 28px -14px rgba(14,23,41,.35);
  --e-sheet: -24px 0 60px -20px rgba(14,23,41,.35);                      /* side sheet, help drawer */
  --e-dialog: 0 24px 64px -20px rgba(14,23,41,.40);
  --e-phone-sheet: 0 -20px 50px -20px rgba(14,23,41,.50);                /* phone bottom sheet */
  --e-card-hover: 0 10px 24px -14px rgba(14,23,41,.28);                 /* lifted card / kanban card (raised by cards-gallery + data-display) */
  --sticky-edge: hsl(var(--foreground) / .09);                           /* frozen-column edge shade (raised by data-table) */
  --scrim: rgba(10,14,24,.38);
  --focus-ring: 0 0 0 1px hsl(var(--ring)), 0 0 0 4px hsl(var(--ring) / .14);

  /* ---------- motion: crisp set (Satin Lapel), approved as-is ---------- */
  --ease-standard: cubic-bezier(.2,0,0,1);
  --ease-in: cubic-bezier(.22,1,.36,1);    /* entering: soft landing */
  --ease-out: cubic-bezier(.4,0,1,1);      /* leaving */
  --motion-hover: 120ms var(--ease-standard);
  --motion-press: 80ms var(--ease-standard);
  --motion-focus: 140ms var(--ease-standard);
  --motion-pop-in: 160ms var(--ease-in);
  --motion-pop-out: 110ms var(--ease-out);
  --motion-sheet-in: 260ms var(--ease-in);
  --motion-sheet-out: 180ms var(--ease-out);
  --motion-toast-in: 200ms var(--ease-in);
  --motion-toast-out: 140ms var(--ease-out);
  --toast-dwell: 2600ms;
  --toast-dwell-undo: 5200ms; /* an Undo toast stays twice as long (raised by status-feedback) */
  --toast-gap: 8px;           /* gap between stacked toasts (raised by status-feedback) */
  --tooltip-delay: 400ms;     /* hover delay before a tooltip; keyboard focus shows it at once (raised by overlays + page-help) */
  --hovercard-delay: 500ms;   /* (raised by overlays) */
  --motion-skeleton: 1.6s var(--ease-standard) infinite alternate; /* skeleton pulse (raised by data-table + cards-gallery) */
  --press-shift: 1px;      /* button settles 1px on press */
  --pop-shift: -4px;       /* dropdown drops 4px */
  --toast-shift: 8px;      /* toast rises 8px */
  --lift-shift: -2px;      /* card hover lift (raised by cards-gallery) */

  /* ---------- stacking (raised by overlays: a guard dialog sits above an open sheet) ---------- */
  --z-sticky: 3; --z-pop: 20; --z-tip: 25; --z-scrim: 30; --z-sheet: 31; --z-guard-scrim: 40; --z-guard: 41; --z-toast: 50;

  /* ---------- kit v2 page primitives (Round 4 promotion, see KIT-CHANGELOG.md § v2) ---------- */
  --tab-h: 40px;           /* page / section tab strip (.tabs) */
  --tab-h-m: 44px;         /* phone tab strip, thumb height */
  --kv-label: 120px;       /* key/value aside label column (.k-kv) */
  --kv-label-m: 104px;
  --li-min-h: 72px;        /* phone list row without a thumbnail (.k-li) */
  --actbar-blur: 8px;      /* phone action bar / phone save bar glass */

  /* ---------- kit v3 · icon sizes (Lucide on a 24px grid, 1.75 stroke; see KIT-CHANGELOG.md § v3) ---------- */
  --icon-12: 12px;         /* surface literal (svg width=12) */
  --icon-13: 13px;         /* surface literal (svg width=13) */
  --icon-14: 14px;         /* contract size inside chips, menus and small buttons */
  --icon-15: 15px;         /* surface literal (svg width=15), the most used off-contract size */
  --icon-16: 16px;         /* contract default */
  --icon-18: 18px;         /* surface literal (svg width=18) */
  --icon-19: 19px;         /* surface literal (svg width=19) */
  --icon-20: 20px;         /* contract size in empty states */
  --icon-22: 22px;         /* surface literal (svg width=22) */
  --icon-28: 28px;         /* surface literal (svg width=28) */

  /* ---------- kit v3 · type literals (off the main scale; named so every use can be found) ---------- */
  --text-8: 8px;           /* initials in the empty garment placeholder (.dt-ph.none) */
  --text-8-5: 8.5px;       /* initials in the empty garment placeholder (.dt-ph.none) */
  --text-9: 9px;           /* initials in the large empty garment placeholder (.dt-ph.lg.none) */
  --text-9-5: 9.5px;       /* small mono badge */
  --text-10: 10px;         /* tab bar badge, tiny avatar */
  --text-10-5: 10.5px;     /* STAGING / environment badge, tab bar label, filter counts */
  --text-11: 11px;         /* kbd, chip count, step mark */
  --text-14: 14px;         /* phone lede, phone list title */
  --text-18: 18px;         /* phone sheet and ticket title */
  --text-19: 19px;         /* surface-only literal */
  --text-20: 20px;         /* ticket head, mini error page title */
  --text-22: 22px;         /* stacked KPI value, error page title */
  --text-24: 24px;         /* phone page title */
  --text-44: 44px;         /* surface-only literal */
  --text-52: 52px;         /* surface-only literal */

  /* ---------- kit v3 · motion literals (animations and hand-off delays; reduced motion is handled per rule) ---------- */
  --ease-enter: var(--ease-in);   /* clearer name: the ENTERING curve (decelerates into place). --ease-in stays for compatibility */
  --ease-exit: var(--ease-out);   /* clearer name: the LEAVING curve (accelerates away). --ease-out stays for compatibility */
  --ease-linear: linear;          /* spinners and rotations only */
  --ease-blink: steps(1);         /* text caret blink */
  --motion-spin: .8s;             /* spinner turn */
  --motion-spin-reduced: 2.4s;    /* spinner turn under prefers-reduced-motion */
  --motion-flash: 900ms;          /* row / card "just changed" flash */
  --motion-shake: 360ms;          /* shake animation on the surfaces (pg-shake) */
  --motion-rotate: 1.2s;          /* rotation animation on the surfaces (wb-rot) */
  --motion-indeterminate: 1.5s;   /* indeterminate progress bar sweep */
  --motion-loadbar: 1.4s;         /* top load bar sweep */
  --motion-caret: 1.1s;           /* command palette caret blink */
  --motion-handoff: .2s;          /* visibility hand-off after a menu, tooltip or dialog closes */
  --motion-handoff-sheet: .3s;    /* visibility hand-off after a side sheet closes */
  --skeleton-stagger: .12s;       /* delay step between skeleton rows */

  /* ---------- kit v3 · glass blur ---------- */
  --blur-glass: 10px;      /* desktop top bar glass */
  --blur-soft: 6px;        /* glass blur written on the surfaces */

  /* ---------- kit v3 · selection rails and row focus (box-shadow) ---------- */
  --e-rail: inset 2px 0 0 hsl(var(--primary));                      /* 2px primary rail: selected row (.fc-cp-tbl) */
  --e-rail-warning: inset 2px 0 0 hsl(var(--warning));              /* 2px warning rail (surfaces) */
  --e-rail-strong: inset 3px 0 0 hsl(var(--primary));               /* 3px primary rail (surfaces) */
  --e-rail-strong-info: inset 3px 0 0 hsl(var(--info));
  --e-rail-strong-warning: inset 3px 0 0 hsl(var(--warning));       /* stale card */
  --e-rail-strong-destructive: inset 3px 0 0 hsl(var(--destructive)); /* blocked card */
  --e-row-focus: inset 0 1px 0 hsl(var(--ring)),inset 0 -1px 0 hsl(var(--ring));                          /* keyboard-focused table row cell */
  --e-row-focus-first: inset 2px 0 0 hsl(var(--ring)),inset 0 1px 0 hsl(var(--ring)),inset 0 -1px 0 hsl(var(--ring)); /* its first cell */

  /* ---------- kit v3 · sidebar light and brand mark (exact board values, sidebar only, never on buttons) ---------- */
  --e-sidebar-edge: inset -1px 0 0 rgba(201,206,218,.07);          /* the sidebar's inner right edge */
  --sidebar-item-on: rgba(255,255,255,.07);                        /* active menu item ground */
  --e-sidebar-item-on: inset 0 0 0 1px rgba(201,206,218,.09),0 6px 18px -12px rgba(110,140,210,.22); /* active menu item edge + soft glow */
  --sidebar-item-on-icon: #B7C7EA;                                 /* active menu item icon */
  --brand-mark: linear-gradient(145deg,#EEF1F8,#CBD5EA);           /* RJ brand mark tile */
  --brand-mark-foreground: #0E1729;                                /* RJ initials on the brand mark */
  --e-brand-mark: 0 0 12px -4px rgba(150,175,225,.22);             /* brand mark glow */

  /* ---------- viewports (dedicated compositions, never a responsive collapse) ---------- */
  --vp-desktop: 1440px;
  --vp-phone: 390px;
}

/* Reduced motion: every move becomes instant. */
@media (prefers-reduced-motion: reduce) {
  :root {
    --motion-hover: 1ms linear; --motion-press: 1ms linear; --motion-focus: 1ms linear;
    --motion-pop-in: 1ms linear; --motion-pop-out: 1ms linear;
    --motion-sheet-in: 1ms linear; --motion-sheet-out: 1ms linear;
    --motion-toast-in: 1ms linear; --motion-toast-out: 1ms linear;
    --press-shift: 0px; --pop-shift: 0px; --toast-shift: 0px; --lift-shift: 0px;
    --tooltip-delay: 0ms; --hovercard-delay: 0ms; --motion-skeleton: none;
  }
}
```

<a id="components-json"></a>

## components.json

The shadcn CLI configuration for `apps/admin`. It keeps the style, aliases and icon library already in use; only the base colour moves from zinc to slate.

<a id="components-json-file"></a>

### The file

`components.json`

```json
{
  "$schema": "https://ui.shadcn.com/schema.json",
  "style": "new-york",
  "rsc": true,
  "tsx": true,
  "tailwind": {
    "config": "tailwind.config.ts",
    "css": "src/app/globals.css",
    "baseColor": "slate",
    "cssVariables": true,
    "prefix": ""
  },
  "aliases": {
    "components": "@/components",
    "utils": "@/lib/utils",
    "ui": "@/components/ui",
    "lib": "@/lib",
    "hooks": "@/hooks"
  },
  "iconLibrary": "lucide"
}
```

<a id="components-json-cli"></a>

### Using the CLI safely

- `baseColor` only matters when the CLI writes colours (init, and components such as sidebar or chart). Slate is the nearest preset to the kit's navy-tinted neutrals.
- After every `shadcn add`, diff `globals.css` and `tailwind.config.ts`: `sidebar` and `chart` inject their own `--sidebar-*` / `--chart-*` values. Restore the generated files.
- Check that an added component uses Tailwind 3 syntax. Newer CLIs default to Tailwind v4 output; if you see `@theme` or OKLCH colours, pin a CLI from the 2.x line.
- The shadcn `sonner` component reads the theme from `next-themes`; pass `theme="light"` and drop that import.

`add the new primitives`

```bash
pnpm --filter rj-admin dlx shadcn@2 add select tabs sheet drawer dropdown-menu popover tooltip hover-card toggle-group radio-group textarea \
  alert-dialog command breadcrumb sidebar table pagination avatar scroll-area collapsible calendar chart sonner
```

<a id="fonts"></a>

## Fonts with next/font

Three faces, self-hosted by `next/font`: Archivo for titles, buttons, the menu, labels and table headers; IBM Plex Sans for reading text, tables and prices; IBM Plex Mono for codes, handles, versions and ids. They replace Inter and JetBrains Mono.

<a id="fonts-faces"></a>

### The faces

| Face | Token | Use | Weights |
|---|---|---|---|
| Archivo | `--font-title` (also `--font-voice`) | Titles, buttons, menu, labels, table headers, chips | 400 to 700 (`--w-*`) |
| IBM Plex Sans | `--font-ui` | Reading text, tables, prices (tabular numbers) | 400, 500, 600 |
| IBM Plex Mono | `--font-code` | Cloth codes, handles, versions, ids | 400, 500 |

> **Rule.** Archivo is never set wide (Frank, 2026-10-11): no `axes: ["wdth"]`, no `font-stretch` above 100%, no positive letter-spacing. Tracking on Archivo is zero or negative only.

| Token | Value | Hex | Note |
|---|---|---|---|
| `--font-title` | `'Archivo', system-ui, sans-serif` |  | titles, buttons, menu, labels, table headers |
| `--font-ui` | `'IBM Plex Sans', system-ui, sans-serif` |  | reading text, tables, prices |
| `--font-code` | `'IBM Plex Mono', ui-monospace, monospace` |  | cloth codes, handles, versions, ids |
| `--font-stretch-title` | `100%` |  | Frank 2026-10-11: Archivo is NEVER set wide |
| `--title-w` | `600` |  |  |
| `--w-regular` | `400` |  |  |
| `--w-medium` | `500` |  |  |
| `--w-semibold` | `600` |  |  |
| `--w-bold` | `700` |  |  |

<a id="fonts-file"></a>

### fonts.ts

`src/app/fonts.ts`

```ts
// GENERATED by phase-4/src/build_patterns_impl.py. src/app/fonts.ts for rj-dtc apps/admin.
// Replaces Inter + JetBrains Mono. Self-hosted by next/font: no Google Fonts request at runtime.
// Weights: Archivo from tokens.css (--w-*), Plex from the kit's base.css font import.
import { Archivo, IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google";

// Archivo is NEVER set wide (Frank, 2026-10-11): do not pass axes: ["wdth"], never use font-stretch above 100%.
export const archivo = Archivo({
  subsets: ["latin"],
  weight: ["400", "500", "600", "700"],
  variable: "--font-archivo",
  display: "swap",
});

export const plexSans = IBM_Plex_Sans({
  subsets: ["latin"],
  weight: ["400", "500", "600"],
  variable: "--font-plex-sans",
  display: "swap",
});

export const plexMono = IBM_Plex_Mono({
  subsets: ["latin"],
  weight: ["400", "500"],
  variable: "--font-plex-mono",
  display: "swap",
});

/** Put on <html>: defines --font-archivo / --font-plex-sans / --font-plex-mono, which globals.css maps onto the tokens. */
export const fontVariables = `${archivo.variable} ${plexSans.variable} ${plexMono.variable}`;
```

<a id="fonts-wire"></a>

### Wiring

- `layout.tsx` puts `fontVariables` on `<html>`, which defines `--font-archivo`, `--font-plex-sans` and `--font-plex-mono`.
- `globals.css` points the token stacks at them: next/font hashes the family names, so the literal `'Archivo'` in tokens.css would never match on its own.
- Money and figures use `tabular-nums` (`font-variant-numeric`).
- Do not ship the kit's `base.css` font `@import`: that would download the same fonts again from Google.

`src/app/layout.tsx`

```tsx
// GENERATED by phase-4/src/build_patterns_impl.py. src/app/layout.tsx for rj-dtc apps/admin.
// Same Clerk behaviour as origin/main (provider only when the publishable key is set); what changes is
// the CSS order (tokens.css before globals.css), the next/font variables on <html>, and token classes on <body>
// in place of bg-white / text-zinc-900.
import type { Metadata } from "next";
import { ClerkProvider } from "@clerk/nextjs";
import { Toaster } from "@/components/ui/sonner";
import { fontVariables } from "./fonts";
import "./tokens.css";
import "./globals.css";

export const metadata: Metadata = {
  title: "RJ DTC Admin",
  description: "RJ MTM | DTC Operator Shell — internal admin panel for all 25 brands",
};

const clerkConfigured = Boolean(process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY);
const signInUrl = process.env.NEXT_PUBLIC_CLERK_SIGN_IN_URL ?? "/sign-in";

export default function RootLayout({ children }: { children: React.ReactNode }) {
  const body = (
    <html lang="en" className={fontVariables}>
      <body className="bg-background text-foreground font-sans text-body antialiased">
        {children}
        <Toaster />
      </body>
    </html>
  );

  if (!clerkConfigured) return body;

  return <ClerkProvider signInUrl={signInUrl}>{body}</ClerkProvider>;
}
```

<a id="fonts-check"></a>

### Check it

In the browser, any Archivo element must compute `font-stretch: 100%`, and the computed `font-family` must start with the next/font family, not the fallback.

`devtools`

```js
getComputedStyle(document.querySelector("h1")).fontStretch   // "100%"
getComputedStyle(document.querySelector("h1")).fontFamily    // "__Archivo_…", …
```

<a id="server-client"></a>

## Server and client components

Pages and layouts stay server components that load data; interactive parts are small client islands. `apps/admin` has 13 shadcn primitives today; the kit needs more.

<a id="server-client-rule"></a>

### The rule

- Pages, layouts and anything that reads data are **server** components. They pass plain data down.
- A component needs `"use client"` when it holds state, uses an effect or a Radix primitive, or handles events.
- Keep the client boundary low: a table page is a server page wrapping one client `DataTable`, not a client page.
- Formatting money and dates happens on the server where possible, so both compositions show the same strings.

<a id="server-client-map"></a>

### Kit to shadcn, with status in apps/admin

| Kit | Build as | In apps/admin today | Runs as |
|---|---|---|---|
| `.btn`, `.icon-btn` | `Button` (restyle the cva to the kit variants) | exists | server-safe |
| `.chip`, `.dot` | `Badge` + status variants, `StatusDot` | exists (Badge) | server |
| card surfaces | `Card` | exists | server |
| `.sf-alert` (info, success, warning, danger, locked, not wired) | `Alert` + variants | exists | server |
| `.skel` | `Skeleton` | exists | server |
| `.field`, `.input` | `Input` | exists | server-safe |
| `.lbl` | `Label` | exists | client (Radix) |
| `.cb` | `Checkbox` | exists | client |
| `.switch` | `Switch` | exists | client |
| `.k-dialog` | `Dialog` | exists | client |
| progress | `Progress` (+ indeterminate variant) | exists | client |
| separators | `Separator` | exists | client |
| `<details>` groups | `Accordion` | exists | client |
| confirm | `AlertDialog` | new | client |
| `.k-sheet` | `Sheet` | new | client |
| `.k-sheet.k-bottom` | `Drawer` (vaul) | new | client |
| `.menu`, `.mi` | `DropdownMenu` | new (Radix package installed) | client |
| `.pop` | `Popover` | new (Radix package installed) | client |
| `.tt`, `.tip` | `Tooltip` | new | client |
| hover card | `HoverCard` | new | client |
| `.seg` | `ToggleGroup` (single = radios) | new | client |
| radio | `RadioGroup` | new | client |
| textarea with counter | `Textarea` + counter | new | client |
| select | `Select` | new | client |
| combobox, picker, ⌘K | `Command` (cmdk installed) + `Popover` / `CommandDialog` | new | client |
| `.tabs` (in-page) | `Tabs` | new | client |
| route tabs | `<Link aria-current="page">` | — | server |
| `.crumbs` | `Breadcrumb` | new | server |
| `.sidebar` | `Sidebar` | new | client |
| `dt-*` | `Table` + `@tanstack/react-table` `DataTable` | new | client |
| pager | `Pagination` | new | server (links) |
| `.avatar` | `Avatar` | new | client |
| scroll areas | `ScrollArea` | new | client |
| `fc-cal` | `Calendar` | new | client |
| `dd-*` charts | `Chart` (Recharts) | new | client |
| `.toast` | `Sonner` (`Toaster` in the root layout) | new | client |
| `.spin` | `Spinner` | custom | server |
| `.k-savebar` | `SaveBar` | custom | client |
| `.k-actbar` | `ActionBar` | custom | client |
| `.k-rec` | `RecordHeader` | custom | server |
| `.k-kv` | `KeyValueList` | custom | server |
| `.k-li` | `ListRow` | custom | server |
| `.help-q` + drawer | `HelpTrigger` + `PageHelp` | custom | client |
| KPI tile | `KpiTile` (Card) | custom | server |
| stepper | `Stepper` | custom | client |
| `fc-money`, price lines | `MoneyInput`, `PriceLines` | custom | client |
| tag input | `TagInput` | custom | client |
| photo uploader with order | `PhotoUploader` (@dnd-kit installed) | custom | client |
| kanban | `Kanban` (no drag on orders, on purpose) | custom | client |
| `.staging`, `.env-*` | `EnvBadge` | custom | server |

> **Note.** In apps/admin today: accordion, alert, badge, button, card, checkbox, dialog, input, label, progress, separator, skeleton, switch (13). Seven of them carry "use client": accordion, checkbox, dialog, label, progress, separator, switch. Read at `origin/main` fc7ff3e8.

<a id="server-client-button"></a>

### Example: restyled Button

`components/ui/button.tsx (variants)`

```tsx
const buttonVariants = cva(
  "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-control border border-transparent font-title " +
  "text-btn font-semibold transition-control press focus-visible:outline-none focus-visible:shadow-focus " +
  "disabled:pointer-events-none disabled:opacity-45 disabled:shadow-none [&_svg]:size-4 [&_svg]:shrink-0",
  {
    variants: {
      variant: {
        primary:        "bg-primary text-primary-foreground shadow-btn hover:bg-primary-hover",
        secondary:      "bg-secondary text-secondary-foreground border-secondary-border shadow-btn-2 hover:bg-muted hover:border-secondary-border-hover",
        tertiary:       "bg-transparent text-foreground px-3 hover:bg-muted",
        ghost:          "bg-transparent text-muted-foreground hover:bg-muted",
        danger:         "bg-destructive text-destructive-foreground hover:brightness-[.92]",
        "danger-quiet": "bg-transparent text-destructive border-destructive/40 hover:bg-destructive/[.07]",
      },
      size: {
        default: "h-control px-4",
        sm:      "h-control-sm px-3 text-btn-sm",
        lg:      "h-control-m-lg px-5 text-btn-lg",
        icon:    "size-9",
      },
    },
    defaultVariants: { variant: "primary", size: "default" },
  },
);
// Phone composition: wrap in data-composition="phone" and use size="lg" for the action bar (48px), never under 44px.
```

<a id="behaviours"></a>

## kit.js behaviours in React

`kit.js` is a demo layer for the static prototypes. Do not port it: Radix already does focus trapping, focus return, arrow keys and Escape. This page maps each hook to what replaces it.

<a id="behaviours-map"></a>

### Hook by hook

| kit.js hook | What it does in the prototype | In React |
|---|---|---|
| `[data-k-pop]` + `[data-k-trigger]` | Toggles `.open` and `aria-expanded` on a dropdown or popover | `DropdownMenu` / `Popover` / `Select`; Radix sets `aria-expanded` and `data-state` |
| clicks inside `.k-pop` | Stay open (filter forms, nested inputs) | `Popover` stays open by default; use it for filter panels |
| `[data-k-option]`, `[data-k-close-pop]` | Pick a value / close, and let the click bubble | `DropdownMenuItem onSelect`, `SelectItem`; no delegated listeners |
| arrow keys, Escape | Move through items; close the top layer and return focus | Built into Radix menus, selects, dialogs |
| `[data-k-stage]` + `[data-k-open]` / `[data-k-close]` / scrim | Opens and closes a sheet or dialog | `Sheet` / `Dialog` / `Drawer` with `open` + `onOpenChange` |
| focus in, Tab trap, focus return | Moves focus into the layer, traps Tab, restores to the opener | Radix `FocusScope` inside Dialog does all three |
| `[data-k-autofocus]` | Which field gets focus first | `onOpenAutoFocus={(e) => { e.preventDefault(); ref.current?.focus(); }}` |
| `[data-k-focus-scope]` | The same trap for a family-owned stage | Any Radix Dialog; or `FocusScope` from `@radix-ui/react-focus-scope` |
| `[data-k-toggle="id"]` | Opens a stage from outside, carrying `aria-expanded` | Lift `open` into state; give the outside trigger `aria-expanded={open}` and `aria-controls` |
| `.k-guard` | A dialog above an open sheet | Nested `AlertDialog`; open it from `onInteractOutside` / `onEscapeKeyDown` when the form is dirty, and `preventDefault()` the close |
| `[data-k-toast]` + `[data-k-fire]` + `[data-k-undo]` | Shows a toast for the dwell, longer with Undo | Sonner `toast()` with `duration` from `kit-motion.ts` and `action` |
| `.seg` | Moves the selection, sets `aria-checked` / `aria-pressed` | `ToggleGroup type="single"` (radio semantics) |
| `.switch[role=switch]` | Flips `aria-checked` unless a handler already acted or opened a confirm | Controlled `Switch`; for confirm-first, open the AlertDialog in `onCheckedChange` and set `checked` only on confirm |
| `.tabs [role=tab]` | Selects the tab, shows its `aria-controls` panel, arrow keys | `Tabs` (Radix); route tabs are links with `aria-current="page"` |
| `[data-k-form]` + `.k-savebar` + `[data-k-discard]` | Any input marks the bar dirty; Discard clears it | react-hook-form `formState.isDirty` drives `SaveBar`; Discard calls `reset()`; add a `beforeunload` guard while dirty |
| `window.Kit.scan(root)` | Wires markup rendered after load | Not needed in React |
| tooltips (`.tt`) | Hover waits `--tooltip-delay`; focus shows at once | `TooltipProvider delayDuration={TOOLTIP_DELAY_MS}`; Radix opens on focus without delay |
| hover card | Waits `--hovercard-delay` | `HoverCard openDelay={HOVERCARD_DELAY_MS}` |
| reduced motion | Every token duration becomes 1ms | Free for anything using the token animations and `transition-*` utilities; use `reducedDelay()` for JS delays |

<a id="behaviours-constants"></a>

### kit-motion.ts

The few numbers React props need, generated from `tokens.css`.

`src/lib/kit-motion.ts`

```ts
// GENERATED from phase-2/kit/tokens.css by phase-4/src/build_patterns_impl.py. src/lib/kit-motion.ts.
// Numbers that React props need (Sonner, Radix Tooltip / HoverCard) and CSS cannot hand them.
// Under prefers-reduced-motion the CSS durations collapse to 1ms; the delays below drop to 0 via reducedDelay().

/** --toast-dwell: 2600ms */
export const TOAST_DWELL_MS = 2600;
/** --toast-dwell-undo: 5200ms */
export const TOAST_DWELL_UNDO_MS = 5200;
/** --toast-gap: 8px */
export const TOAST_GAP_PX = 8;
/** --tooltip-delay: 400ms */
export const TOOLTIP_DELAY_MS = 400;
/** --hovercard-delay: 500ms */
export const HOVERCARD_DELAY_MS = 500;

/** Tooltip / hover-card delay that honours reduced motion (tokens.css sets both delays to 0ms there). */
export function reducedDelay(ms: number): number {
  if (typeof window === "undefined") return ms;
  return window.matchMedia("(prefers-reduced-motion: reduce)").matches ? 0 : ms;
}
```

| Token | Value | Hex | Note |
|---|---|---|---|
| `--toast-dwell` | `2600ms` |  |  |
| `--toast-dwell-undo` | `5200ms` |  | an Undo toast stays twice as long (raised by status-feedback) |
| `--toast-gap` | `8px` |  | gap between stacked toasts (raised by status-feedback) |
| `--tooltip-delay` | `400ms` |  | hover delay before a tooltip; keyboard focus shows it at once (raised by overlays + page-help) |
| `--hovercard-delay` | `500ms` |  | (raised by overlays) |
| `--motion-pop-in` | `160ms var(--ease-in)` |  |  |
| `--motion-pop-out` | `110ms var(--ease-out)` |  |  |
| `--motion-sheet-in` | `260ms var(--ease-in)` |  |  |
| `--motion-sheet-out` | `180ms var(--ease-out)` |  |  |
| `--ease-standard` | `cubic-bezier(.2,0,0,1)` |  |  |
| `--ease-in` | `cubic-bezier(.22,1,.36,1)` |  | entering: soft landing |
| `--ease-out` | `cubic-bezier(.4,0,1,1)` |  | leaving |

<a id="behaviours-sheet"></a>

### Example: a guarded quick-edit sheet

`quick-edit-sheet.tsx`

```tsx
"use client";
export function QuickEditSheet({ product, open, onOpenChange }: QuickEditProps) {
  const form = useForm<QuickEdit>({ resolver: zodResolver(quickEditSchema), defaultValues: toForm(product) });
  const [guard, setGuard] = useState(false);
  const priceRef = useRef<HTMLInputElement>(null);
  const block = (e: Event) => { if (form.formState.isDirty) { e.preventDefault(); setGuard(true); } };

  return (
    <Sheet open={open} onOpenChange={onOpenChange}>
      <SheetContent
        className="w-sheet data-[state=open]:animate-sheet-in data-[state=closed]:animate-sheet-out"
        onOpenAutoFocus={(e) => { e.preventDefault(); priceRef.current?.focus(); }}
        onInteractOutside={block}
        onEscapeKeyDown={block}
      >
        {/* header · scrolling body · footer: Cancel / Save changes */}
        <AlertDialog open={guard} onOpenChange={setGuard}>
          {/* "Discard your changes?" · Discard changes / Keep editing */}
        </AlertDialog>
      </SheetContent>
    </Sheet>
  );
}
```

<a id="gotchas"></a>

## Gotchas

58 problems the designers and the kit passes hit, plus what reading `apps/admin` at `origin/main` turned up. Each one says what happens and what to do in the Next.js build.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [orders](../../../phase-3/pages/orders/index.html), [support-thread](../../../phase-3/pages/support-thread/index.html), [website-menu](../../../phase-3/pages/website-menu/index.html), [product-prices](../../../phase-3/pages/product-prices/index.html)

<a id="gotchas-list"></a>

### The list

| # | Area | What happens | Do this in apps/admin | Found in |
|---|---|---|---|---|
| 1 | Layout | **`overflow:hidden` on the app frame kills `position:sticky`** An ancestor with `overflow:hidden` becomes the scroll container, so the sticky top bar, toolbar, save bar and phone action bar never stick. | Use `overflow:clip` (or no overflow) on every shell wrapper: frame, main, phone. Never `overflow-hidden` above a sticky element. | KIT-CHANGELOG v2-1; raised by every page team |
| 2 | Layout | **A card with `overflow:hidden` breaks a sticky table header** The price grid header stopped sticking inside a rounded card. | Clip the card with `overflow:clip`, or round the table wrapper instead of clipping it. | product-prices NOTES |
| 3 | Layout | **The sticky offset under a sticky toolbar is not a fixed number** Pages measured the toolbar at run time (`--gs-bar-h`, `--od-bar-h`) so the table header sits under it. | Set the table header `top` from a CSS variable written by a ResizeObserver on the toolbar; the top bar part is `--topbar-h`. | global-store NOTES 4; orders NOTES 1 |
| 4 | Layout | **The sidebar must be sticky and full height, and its children must not shrink** On short windows the Jump to button squashed. | Sidebar `sticky top-0 h-dvh`, every child `shrink-0`, the menu list the only scrolling part. | pricing, design-pricing, variants NOTES 2 |
| 5 | Layout | **The bulk-selection pill was `position:absolute`** It scrolled away with the table. | Make it `fixed`, centred on the content column (offset by `--sidebar-w`), not on the window. | global-store NOTES 3; products NOTES |
| 6 | Layout | **Overlays need a full-viewport layer, not the specimen stage** The kit `.k-stage` is `position:relative` for specimen cards; every page rebuilt a fixed layer (`.gs-layer`, `.pg-layer`, `.hq-layer` …). | Radix portals Sheet, Dialog, Popover and Toast to `<body>`, which is the same thing. Do not port `.k-stage`. | KIT-CHANGELOG v2-6; every page lane |
| 7 | Layout | **The help drawer opens below the top bar, beside the sidebar** So the "?" stays visible and works as the toggle. | Non-modal Sheet (`modal={false}`) positioned `top: var(--topbar-h)`, `left: var(--sidebar-w)` on desktop. | global-store NOTES 5; KIT-CHANGELOG 25 |
| 8 | Layout | **The content frame needs the viewport height** The CMS embed was a fixed-height specimen. | Height `calc(100dvh - var(--topbar-h))`. | content-frame NOTES 1 |
| 9 | Layout | **Fixed-height wizard specimen** The guided editor in the kit is a 680px specimen; pages built their own full-height grid. | Build the editor as a full-height layout with a scrolling editor column and the action bar pinned inside it. | global-store-editor NOTES 1; settings-brand-new NOTES |
| 10 | Layout | **Off-grid literals in the shell** `.page` 22px, `.ph` 18px, `.p-sec` 18px, sidebar 14px / 10px and `.sb-item` 10px are kept so board 3 renders as approved. | Snap them to the 4px grid in the build (24 / 16 / 16 / 12 / 8) and compare a screenshot against the approved page. | KIT-CHANGELOG Open |
| 11 | Layout | **Three tokens are declared but not applied in the kit chrome** `--pbar-h`, `--tabbar-h` and `--content-max` exist in tokens.css; the kit pages hard-code the bar heights. | Use the tokens in the app: `h-pbar`, `h-tabbar`, `max-w-content`. | KIT-CHANGELOG Open |
| 12 | Behaviour | **A popover closed on any inner click** Filter forms and nested inputs inside a menu could not be used. | Filter panels are Radix `Popover` (stays open); action lists are `DropdownMenu` (closes on select). | KIT-CHANGELOG 45 |
| 13 | Behaviour | **Menu-item clicks were swallowed** `[data-k-close-pop]` stopped propagation, so page listeners never fired (website-menu listened in the capture phase). | Use `onSelect` on each `DropdownMenuItem`; no delegated listeners. | website-menu NOTES 3; KIT-CHANGELOG v2-16 |
| 14 | Behaviour | **Markup rendered after load was not wired** kit.js bound listeners once, so HQ pages invented `data-hq-open`. | Irrelevant in React: do not port kit.js. | HQ NOTES 4; KIT-CHANGELOG v2-15 |
| 15 | Behaviour | **Switches were not wired by the kit** Six campaign pages toggled `aria-checked` themselves. | Controlled Radix `Switch`. When a switch needs a confirm, keep `checked` unchanged until the confirm resolves. | campaigns NOTES 3; KIT-CHANGELOG v2-17 |
| 16 | Behaviour | **Tabs were a segmented control** `.seg` is a control, not navigation; pages reused it and the data-table saved-views strip as tabs. | In-page panels: Radix `Tabs`. Route sub-navigation (Inbox · Templates · Categories): `<Link>` with `aria-current="page"`. | analytics, cx, hq, su, pricing NOTES; KIT-CHANGELOG v2-7 |
| 17 | Behaviour | **No step-navigation hook** create-product and global-store-editor each re-implemented go-to-step. | One `Stepper` client component; the step lives in the URL (`?step=design`) so Back works. | global-store-editor NOTES 3; create-product NOTES 1 |
| 18 | Behaviour | **No page-state primitive** Prototype pages pre-render every state and toggle `[hidden]`. | Real state comes from data; the `?state=` switcher is prototype chrome. | campaigns NOTES 4; dashboard NOTES 9 |
| 19 | Behaviour | **Radix exit animations need keyframes** The kit animates with transitions; Radix `Presence` unmounts on `animationend`, so a transition-only close snaps shut. | Use the generated `animate-sheet-in/out`, `animate-dialog-in/out`, `animate-pop-in/out`, `animate-scrim-in/out`. | recon: Radix Presence |
| 20 | Behaviour | **Dialog keyframes must carry the centring transform** Content centred with `translate(-50%,-50%)` loses it during an animation that sets `transform`. | The generated `k-dialog-in/out` keyframes include the translate (same values as the kit `.k-dialog`). | base.css .k-dialog |
| 21 | Behaviour | **Default delays and durations differ from the kit** Radix Tooltip waits 700ms by default; Sonner shows a toast for 4000ms. | `delayDuration={TOOLTIP_DELAY_MS}` (400), HoverCard `openDelay` 500, Sonner `duration` 2600 and 5200 with Undo, all from `kit-motion.ts`. | tokens.css; recon |
| 22 | Behaviour | **Toast stacking was computed from heights** The kit stack used inline transforms from JS. | Sonner stacks for you; set `gap={8}` (`--toast-gap`) and `visibleToasts={3}`. | status-feedback NOTES 7 |
| 23 | Behaviour | **A phone toast with an action wraps into a tall empty box** The action row dropped under the text. | On the phone: action inline at the right, or tap-to-dismiss with no action row. | global-store-editor NOTES 10 |
| 24 | Behaviour | **Pops inside a scrolling table were clipped** super-brands fell back to a native select because the kit pop was absolutely positioned inside the scroller. | Radix Select and DropdownMenu portal out of the scroller, so the kit select can be used again. | super-brands NOTES |
| 25 | Behaviour | **Long phone selects** Support used the desktop dropdown on the phone. | Long lists on the phone open a bottom sheet (vaul `Drawer`) with a searchable list. | support-thread NOTES 9 |
| 26 | Type | **Archivo must never be wide** Frank, 2026-10-11: no `font-stretch` above 100%, no `wdth` axis, no positive tracking on titles, buttons, menu, labels, table headers. | next/font without `axes: ["wdth"]`; never `tracking-wide` on `font-title`; check computed `font-stretch: 100%`. | CONTRACT §4 |
| 27 | Type | **next/font hashes the family names** tokens.css names `'Archivo'` and `'IBM Plex Sans'` literally; next/font registers a hashed family, so the literal never matches and you get the fallback. | The generated globals.css points `--font-title`, `--font-ui` and `--font-code` at the next/font variables. | recon |
| 28 | Type | **Do not ship the Google Fonts `@import`** base.css imports the three fonts from Google; with next/font that is a second download and a third-party request. | Ship tokens.css only; never base.css into the app. | base.css; recon |
| 29 | Type | **Mono is for codes, not names** `.dt-t2` is Plex Mono (a handle); variant names and add-on lines are words and were reset to Plex Sans locally. | Mono only for cloth codes, handles, versions and ids. | design-pricing NOTES 5; orders NOTES 4; order-detail NOTES 1 |
| 30 | Type | **Long ids overflow tiles and asides** Mono metric names and request ids ran out of phone KPI tiles and the key/value aside. | `min-w-0` on the flex/grid child plus `overflow-wrap:anywhere` (as `.k-kv`). | orders-retry-erp NOTES 2; order-envelope NOTES 2 |
| 31 | Type | **iOS zooms fields under 16px** A focused 13px field zooms the whole page on iPhone. | Phone composition: every text field at `--text-input-m` (16px). The generated globals.css does it for `[data-composition="phone"]`. | KIT-CHANGELOG 41 |
| 32 | Colour | **2,053 hard-coded `zinc-*` utilities in 146 files** They will not pick up a single token; the old zinc look survives the token swap. | Move them to semantic classes (`text-muted-foreground`, `border-border`, `bg-muted`) and add a lint rule that rejects palette classes. | recon: apps/admin at origin/main |
| 33 | Colour | **The old globals.css forces a blue focus outline** `button:focus-visible{outline:2px solid #3b82f6}` overrides the token focus ring, and the scrollbar is a raw `#d4d4d8`. | The generated globals.css replaces both with the `--ring` outline. | recon |
| 34 | Colour | **`layout.tsx` hard-codes `bg-white text-zinc-900`** The body ignores the tokens. | `bg-background text-foreground font-sans text-body` (generated layout.tsx). | recon |
| 35 | Colour | **Opacity modifiers need `<alpha-value>`** With `hsl(var(--success))` in the config, an opacity modifier on a token colour does nothing, and `/12` is not on Tailwind's opacity scale. | The generated config defines every colour as `hsl(var(--x) / <alpha-value>)` and adds the `soft` step (`--status-soft-alpha`): `bg-success/soft`. | recon |
| 36 | Colour | **No hex in components** The kit counts a hex value, a raw radius or a raw shadow as a bug. | Lint for `#[0-9a-f]{3,8}` and arbitrary `[…px]` radius / shadow values in `src/components`. | CONTRACT §4 |
| 37 | Colour | **The canvas colour is for kit pages only** `--canvas` and `--studio` sit behind specimen cards and garment photos. | The product background is `--background` (white). `--studio` only behind garment photos. | tokens.css |
| 38 | shadcn | **The radius scale shrinks** `--radius` goes from 0.5rem to 0.375rem, so `rounded-lg/md/sm` become 6 / 4 / 2px. Kit controls are 6px, cards 8px. | Buttons and inputs `rounded-control`, cards and popovers `rounded-card`, chips `rounded-chip`, panels `rounded-frame`. | recon |
| 39 | shadcn | **Default sizes are not the kit's** shadcn Button is `h-9` (36px), `sm` 32px, `lg` 40px; the kit is 40 / 34 / 48px, and 44px minimum on the phone. | Restyle the `buttonVariants` cva to `h-control`, `h-control-sm`, `h-control-m-lg` and the kit variants (primary, secondary, tertiary, ghost, danger, danger-quiet). | recon: components/ui/button.tsx |
| 40 | shadcn | **Overlay scrim and z-index** shadcn Dialog and Sheet use `bg-black/80` and `z-50` for everything. | Scrim `bg-scrim`; z from the kit scale: scrim 30, sheet 31, guard 40 / 41, toast 50. | tokens.css z-scale; KIT-CHANGELOG 7 |
| 41 | shadcn | **The CLI writes its own colours** `shadcn add sidebar` and `chart` inject `--sidebar-*` and `--chart-*` values into globals.css. | After each add, regenerate or restore globals.css from this kit and diff it. | recon |
| 42 | shadcn | **Sonner arrives with next-themes** The shadcn `sonner` component reads the theme from `next-themes`. | Light only: pass `theme="light"` and drop the `useTheme` call. | recon |
| 43 | shadcn | **Tailwind ease names collide** The kit `--ease-in` is the entering curve, the opposite of Tailwind's `ease-in`. | Use `ease-enter` / `ease-exit` / `ease-standard` from the generated config. | tokens.css |
| 44 | shadcn | **Check the CLI output is Tailwind 3** Newer shadcn CLIs default to Tailwind v4 output (`@theme`, OKLCH colours). | If an added component carries v4-only syntax, pin a CLI from the 2.x line and re-add. | recon |
| 45 | Components | **Scoped link resets** A page rule `.x a{color:inherit}` stripped a primary link button's text colour. | Never reset anchor colour globally; render link buttons with `<Button asChild><Link/></Button>`. | order-detail NOTES 3; KIT-CHANGELOG v2-14 |
| 46 | Components | **One phone list row, not three** `.cg-li`, `.dt-li`, `.k-li` and three page-local rows drifted apart. | One `ListRow` with an optional thumbnail and a trailing slot. | KIT-CHANGELOG Open; v2-12 |
| 47 | Components | **Two indeterminate progress bars** `sh-load` and `sf-slide` are duplicates. | One `Progress` with an `indeterminate` variant. | KIT-CHANGELOG Open |
| 48 | Components | **A KPI tile is a link only when it navigates** The kit KPI is `a.dd-kpi`; on the retry queue it is not a link. | Render `<Link>` only with an href; otherwise a `div` with `cursor-default`. | orders-retry-erp NOTES 1 |
| 49 | Components | **KPI tiles on the phone** Five tiles in two rows were too tall. | Three per row, 20px value. | customers lane NOTES 6 |
| 50 | Components | **Charts are drawn at a fixed width** The kit line chart is drawn for a 1300px frame; in a two-thirds column the axis text shrank. | Recharts `ResponsiveContainer`: measure the column, never scale the SVG text. | analytics NOTES 8 |
| 51 | Components | **Description lists draw a rule under the last row** `.dd-dl` had a bottom border on the last item. | `last:border-0`. | website NOTES 3 |
| 52 | Components | **Disclosure and accordion** `<details>` has no kit style; two pages built their own. | shadcn `Accordion` / `Collapsible`. | releases NOTES 2; settings-brand-new NOTES |
| 53 | Components | **Kanban columns can be unmapped** The kit kanban assumes every column is wired. | Column state "Not mapped": `.chip.unmapped` + dashed block. | orders-kanban NOTES 1 |
| 54 | Components | **Dots needed draft and neutral** website-menu used an inline `background:hsl(var(--draft))`. | `.dot` now has every status; map dots from the same status table as chips. | website-menu NOTES 6; KIT v2-13 |
| 55 | Navigation | **Off-menu pages have no menu item** `/fit-review`, `/tailors`, `/appointments` are URL-only, so nothing highlights. | A "Not in the menu" sidebar group showing the current page (proposed). | customers lane NOTES 4 |
| 56 | Navigation | **Support staff tab bar** The brand phone tab bar is Home · Orders · Products · Menu; support staff live in Support. | Proposed: swap Products for Support for the support role (needs a yes). | support NOTES 3 |
| 57 | Process | **Prototype chrome is not product UI** The Preview switcher, `?state=`, `?open=`, `?tab=`, the `_kit/` mirror and `data.js` are for review. | Do not build them. Read real data; rebuild interactions with Radix. | PAGE-MAP §6 |
| 58 | Process | **Status words drifted between families** "Synced", "Not started", "Pending operator", red "Refunded", green "Shipped". | One `status.ts` map from the status vocabulary page; every chip and dot reads it. | KIT-CHANGELOG cross-family |

<a id="gotchas-sources"></a>

### Where they came from

- `phase-3/pages/*/NOTES.md`: the CONTRACT GAPS and Kit gaps sections of all 64 page folders.
- `phase-2/kit/KIT-CHANGELOG.md`: the integration pass (v1), the Round 4 promotion (v2) and both Open lists.
- rj-dtc `apps/admin` read at `origin/main` (fc7ff3e8, 2026-10-11): `tailwind.config.ts`, `globals.css`, `components.json`, `layout.tsx`, `components/ui/*`.

<a id="checklist"></a>

## Handoff checklist

Tick through this in order. The per-URL integration list is `PAGE-MAP.md` (73 rows); the decisions that need Frank are in `PROPOSALS.md`.

Seen on: [global-store](../../../phase-3/pages/global-store/index.html), [global-store-editor](../../../phase-3/pages/global-store-editor/index.html), [dashboard](../../../phase-3/pages/dashboard/index.html), [orders](../../../phase-3/pages/orders/index.html)

<a id="checklist-foundation"></a>

### 1 · Foundation

- [ ] Copy `implementation-files/` over `apps/admin/`; add sonner, @tanstack/react-table, vaul, react-hook-form, @hookform/resolvers.
- [ ] `type-check` and `build` pass; the app renders in Archivo / Plex with the navy primary.
- [ ] No Inter, no JetBrains Mono, no Google Fonts request in the network tab.
- [ ] Computed `font-stretch` is `100%` on every Archivo element.
- [ ] Focus ring is the `--ring` ring everywhere; no blue outline left.
- [ ] Lint rule rejects hex values and `zinc-*` / `slate-*` palette classes in `src/components` and `src/app`.

<a id="checklist-primitives"></a>

### 2 · Primitives

- [ ] Restyle the 13 existing `components/ui/*` files to the kit (heights, radius names, variants, states).
- [ ] Add the new primitives listed in [server and client](#server-client).
- [ ] Build the custom pieces: SaveBar, ActionBar, RecordHeader, KeyValueList, ListRow, PageHelp, Stepper, MoneyInput, StatusBadge.
- [ ] One `status.ts` map from the [status vocabulary](../patterns/status-vocabulary/); every chip and dot reads it.
- [ ] Check each primitive against its component page and the kit specimen, in every state: default, hover, focus, disabled, error, loading, empty.

<a id="checklist-shell"></a>

### 3 · Shell

- [ ] HQ and brand shells, place switcher, ⌘K Jump to, STAGING badge, account menu, the "?" help drawer.
- [ ] Phone: place bar, tab bar, Menu bottom sheet, action bar; composition picked server-side at the same URL.
- [ ] Sticky top bar, toolbar and save bar actually stick on a long page (no `overflow:hidden` above them).

<a id="checklist-surfaces"></a>

### 4 · Surfaces

- [ ] Follow the build order in `PAGE-MAP.md` §9: Global Store and its editor, then the brand product pages, then dashboard and orders, then website.
- [ ] For each URL in `PAGE-MAP.md`, build the desktop and the phone composition and compare against `/pages/<slug>/` and `/pages/<slug>/m/` at 1440 and 390.
- [ ] Build the states the page's preview switcher walks (`?state=`), from real data, not the switcher.
- [ ] Ship the PROPOSALS rows tagged **UI**; hold **BE** rows for their backend and **PD** rows for a decision.
- [ ] Do not build prototype chrome: the preview switcher, `?state=` / `?open=` / `?tab=` URLs, `_kit/`, `data.js`.

<a id="checklist-decisions"></a>

### 5 · Decisions before build

These change what gets built. Settle them with Frank before the affected pages (PROPOSALS, "Decisions for Frank"):

1. Campaign money: pounds or dollars?
2. Campaign status: settable, or derived from the dates?
3. Product editor: Design before Price?
4. Who clears a refund, and from where?
5. ERP production sub-steps, and the ERP status words.
6. Rules C-1 and C-2: working or locked at launch?
7. Wire the buttons that do nothing today, or keep them inert?
8. The three bulk actions: yes?
9. Dashboard "Next step" ranking.
10. The one primary per page: are the picks right?

<a id="checklist-verify"></a>

### 6 · Verify

- [ ] No horizontal scroll at 390; every tap target at least 44px; phone text fields at 16px.
- [ ] Keyboard: every overlay traps focus, Escape closes the top layer, focus returns to the opener.
- [ ] Reduced motion: overlays and toasts appear without movement.
- [ ] Contrast: status chips at least 4.5:1, control edges at least 3:1.
- [ ] Counts agree across each page (menu badge, chips, list).
- [ ] Run the [gotchas](#gotchas) list once more against the finished app.

<a id="checklist-links"></a>

### Links

- [PAGE-MAP.md](../../../PAGE-MAP.md): one row per admin URL, with the surface, the shell, the kit components and the states designed.
- [PROPOSALS.md](../../../PROPOSALS.md): everything the designs add beyond today, tagged UI / BE / PD, with the decisions for Frank.
- Approved surfaces: `/pages/` on the ladder site (desktop) and `/pages/m/` (phone).
- Kit source: `phase-2/kit/` (tokens.css, base.css, kit.js, CONTRACT.md, KIT-CHANGELOG.md).
