diff --git a/.changeset/design-system-foundation.md b/.changeset/design-system-foundation.md new file mode 100644 index 00000000..67eb5f6d --- /dev/null +++ b/.changeset/design-system-foundation.md @@ -0,0 +1,34 @@ +--- +"@paddock/web": minor +--- + +Design system foundation: Tailwind v4, semantic OKLCH tokens, and an enforced contrast floor. + +The UI had no design document and no token layer — colour was addressed by +palette step in 1017 places, with 722 hand-written `dark:` pairs and 200 +arbitrary `text-[Npx]` values. One ramp had been tuned against a dark canvas and +reused unchanged against a light one, so light mode failed WCAG AA at its +most-used tokens. + +- **Light mode now passes AA.** Help text and field labels went 3.75:1 → 6.71:1, + muted text and placeholders 2.81:1 → 4.90:1, and the primary button's white + label 4.17:1 → 5.53:1. Light and dark ramps are now derived separately in + OKLCH; the mid-steps lose the high-chroma tan cast that made light mode read + muddy. +- **Contrast is enforced, not asserted.** A new test parses the real stylesheet + and fails the build if any text-on-surface pair drops below 4.5:1 (3:1 for + control boundaries) in either mode, or if a colour falls outside the sRGB + gamut. +- **Shared UI primitives** (`Button`, `Card`, `Section`, `EmptyState`, `Field`, + `Input`, `Toggle`, `Chip`, `Callout`, `Dialog`, `Menu`). Dialogs now trap and + restore focus, and menus support arrow-key navigation — neither worked before. +- Chat messages no longer animate in (a 250 ms fade-with-translate on a + 100+/day event), and `prefers-reduced-motion` is honoured throughout. +- Config no longer reflows while you type: a field's width was recomputed from + its live value on every keystroke, so crossing 38 characters jumped it to full + width and re-packed every field after it. +- Per-instance branding (`PADDOCK_BRAND_ACCENT`) is unchanged and covered by a + test. + +`docs/DESIGN.md` documents the system, and Tailwind is configured in CSS — +`tailwind.config.js` and `postcss.config.js` are gone. diff --git a/.changeset/runtime-themes-and-accent-picker.md b/.changeset/runtime-themes-and-accent-picker.md new file mode 100644 index 00000000..6facc9d1 --- /dev/null +++ b/.changeset/runtime-themes-and-accent-picker.md @@ -0,0 +1,43 @@ +--- +"@paddock/web": minor +--- + +Four runtime themes and a colour picker, in Config → Appearance. + +The design system shipped one palette. This adds a theme switcher and three +further themes on top of the neutral base, plus an accent picker — all +per-browser, applied instantly, no save and no restart. + +- **Four themes.** `foundation` (the neutral base), `parchment` (a 90s RPG menu + — wine chrome, brass fittings, corner brackets, an old-style serif), + `terminal` (green phosphor and ANSI in the dark; greenbar and ribbon ink in + the light) and `scifi` (deep-space ground and luminous cyan). Both light and + dark are designed for each, not inverted from one another. +- **Pick any colour for the accent.** The picker takes a colour and nothing + else: the theme supplies its own saturation and a target contrast, and the + *lightness is solved* to hit it. An unreadable accent is therefore + inexpressible rather than merely warned about — it is not possible to choose + one that fails AA in either mode. Optionally the same colour can tint the + page ground (None / A little / More). No colour theory is exposed anywhere in + the UI. +- **Per-instance branding still composes.** With no colour picked, the solver + reads the hue of whatever `PADDOCK_BRAND_ACCENT` produced, so an operator's + brand colour is re-solved against the active theme instead of being used at + whatever lightness it happened to have. +- **Every theme is contrast-guarded.** The build-time guard previously read only + the base palette, so a theme could ship uncertified. It now walks the theme + registry and applies the full contract to each one in both modes, and fails + both ways — a theme registered with no stylesheet, or a stylesheet no one + registered. + +**Fixed: hovering the primary button made its label harder to read.** In dark +mode the fill lightened on hover, taking its white label from 5.53:1 to +**4.17:1** — below AA, on hover, on the most-clicked control in the app. Hover +now raises contrast in both modes. The guard had never paired a foreground with +a hover fill, which is why this was invisible; that pairing is now asserted, and +adding it immediately caught a second instance in `parchment` (6.31:1 → 5.20:1) +where the correct direction is the opposite one, because its fill carries dark +ink rather than white. + +The "Appearance" entry in the sidebar is gone — it was a second door onto the +Config section that already holds these settings. diff --git a/CLAUDE.md b/CLAUDE.md index 6ae3f259..698ba6bc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -92,6 +92,39 @@ derived, not read); nothing may ever log or serialise a declared server's values puts the whole `mcp_servers` record in one `--mcp-config` argv element, so an `env` value or an `Authorization` header is visible in `/proc//cmdline` to the same user. +## UI conventions + +**Read [`docs/DESIGN.md`](docs/DESIGN.md) before touching anything visual.** It is +the repo's only document about how Paddock *looks* (every other `DESIGN-*.md` is +architecture, and unlike the rest of `docs/` it is current, not the stale fork). +It covers the token architecture, the type/space/radius/elevation/motion scales, +the shared primitives, and — aimed squarely at a coding agent — a "Reject this" +section and a step-by-step "How to add a direction". + +The five rules it exists to protect, all enforced by +`packages/web/src/styles/tokens.test.ts`, which fails the build: + +- **Colour lives only in `packages/web/src/styles/tokens.css`**, as semantic + tokens (`--surface-raised`, `--text-muted`, `--danger-soft`) declared twice — + `:root` for light, `.dark` for dark, ramps derived **separately** in OKLCH. +- **Never a literal hex, `rgb()` or raw palette step in component code.** Write + `text-fg-muted`, not `text-paddock-500` or `text-[#8f7c54]`. The one exception + is `src/lib/brand.ts`. +- **Never a `dark:` variant for a colour** — the token swaps itself. Never an + arbitrary `text-[Npx]` — use a rung (`text-3xs` … `text-3xl`). Never a bare + `outline` focus ring (use `box-shadow`), never `transition-all`. +- **Reach for a primitive** from `packages/web/src/components/ui/` (`Button`, + `Card`, `Section`, `EmptyState`, `Field`, `Input`, `Toggle`, `Chip`, + `Callout`, `Dialog`, `Menu`) before hand-rolling markup. Structural changes go + in the primitive, where one edit reaches every call site. +- **`--accent` / `--accent-600` / `--accent-700` are the branding seam** (#34): + space-separated sRGB channels a running server overwrites for + `PADDOCK_BRAND_ACCENT`. Keep that format and keep every other accent token + derived from them. + +Styling is **Tailwind v4** — configuration is CSS (`packages/web/src/index.css`), +there is no `tailwind.config.js` and no PostCSS config; do not reintroduce them. + ## Dev conventions Full guide: [`CONTRIBUTING.md`](CONTRIBUTING.md); run modes: [`DEV.md`](DEV.md). diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 00000000..9d897055 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,573 @@ +# Paddock — visual design system + +This is the repo's **only** design document about how Paddock *looks*. Every +other `DESIGN-*.md` here is architecture. Until this file existed, the visual +language lived entirely in `packages/web/tailwind.config.js` and a 494-line +`index.css`, which is how the app arrived at 1017 raw palette-step uses, 722 +hand-written `dark:` variants, 200 arbitrary `text-[Npx]` values, zero shared +React primitives, and a light mode that failed WCAG AA at its four most-used +tokens. + +It describes a **system**, not a look. **Four themes ship**, switchable at +runtime from Config → Appearance, and they are deliberately far apart: + +| theme | it is | file | +|---|---|---| +| `foundation` | the neutral base — warm ground, terracotta accent. Also the default when no theme is chosen, because its values *are* `tokens.css`. | `styles/theme-foundation.css` | +| `parchment` | a 90s RPG menu — wine chrome, brass fittings, corner brackets, an old-style serif. | `styles/theme-parchment.css` | +| `terminal` | green phosphor and ANSI in the dark; greenbar and ribbon ink in the light. | `styles/theme-terminal.css` | +| `scifi` | deep-space ground and luminous cyan — hairlines, telemetry, glow. | `styles/theme-scifi.css` | + +The design run that produced this system explored four more — `instrument`, +`phosphor`, `vellum`, `register`. They were **cut**, on the grounds that they +differed from each other mostly in palette and type, so shipping all four bought +variety no one could name. Their branches are still on the remote; what survives +here is the token contract they were the proof of. + +Foundation is a refactor, not an aesthetic statement: it exists to prove the +system works and to fix the measured defects. The other three are the statement. + +**If you are an AI coding agent working on Paddock's UI, read "Reject this" +(§7) before you write a line of CSS.** It is aimed at you specifically. + +--- + +## 1. The architecture in one paragraph + +Colour lives in exactly one file — `packages/web/src/styles/tokens.css` — as +**semantic** custom properties, declared twice: once under `:root` (light) and +once under `.dark`. `packages/web/src/index.css` maps those onto Tailwind +utilities through `@theme inline`, so `bg-surface-raised` resolves to +`var(--surface-raised)` and re-themes itself when the class on `` flips. +Components name meanings (`text-fg-muted`, `bg-danger-soft`), never colours. +Shared React primitives in `packages/web/src/components/ui/` own the structural +decisions. **A theme is therefore a set of token values plus a typography +choice** — not a rewrite of components — and it ships as one +`styles/theme-.css` that overrides the foundation behind a `[data-theme]` +selector, switchable at runtime with no reload. + +``` +styles/tokens.css ← the foundation palette + the base every theme falls through to. +styles/theme-.css ← one per theme. This is the file a new theme adds. +index.css ← @theme mapping + scales + the few class-level primitives. +components/ui/ ← structure. Shared by every theme; a theme should not edit it. +components/, routes/ ← consume utilities. A theme should not need to open these. +``` + +The one qualification, because someone will otherwise infer the stronger claim: +**"a theme is one file of values" is true for three of the four.** Parchment +needed real texture and ornament — properties, not values — and took a +deliberate exemption to add gated CSS. See §8, Step 3. + +## 2. Token architecture and naming rules + +### The rules + +1. **A token is named for what it means, not what it is.** `--danger-soft`, not + `--rose-50`. If you cannot say what a token *means* in the product, it is a + palette step and does not belong here. +2. **Semantic tokens only at the point of use.** A component may write + `text-fg-muted`. It may not write `text-paddock-500`, `text-[#8f7c54]`, or + `dark:text-paddock-400`. +3. **The mode swap happens once**, at `:root` / `.dark` in `tokens.css`. A + `dark:` variant in a component is a bug unless the rule genuinely differs per + mode *beyond colour* (there are almost no such cases). +4. **Author in OKLCH.** Lightness steps are then perceptually even and chroma is + independent of lightness, which is what lets the two modes be tuned + separately. +5. **Derive the light and dark ramps separately.** The pre-token palette tuned + one ramp against a dark canvas and reused it unchanged against a light one. + That single decision produced every contrast failure listed in §4. +6. **Tint borders, shadows and muted text toward the background hue.** Neutral + grey on a hued surface reads as dirt. + +### The token set + +| group | tokens | means | +|---|---|---| +| surface | `--surface` `--surface-raised` `--surface-sunken` | the page canvas · anything floating above it (card, menu, dialog, composer) · anything reading as a well (input, code block, track) | +| surface, interactive | `--surface-hover` `--surface-active` `--surface-selected` | fills laid over any of the three above | +| scrim | `--overlay` | modal / drawer / lightbox backdrop | +| text | `--text` `--text-muted` `--text-subtle` | primary body & headings · secondary (labels, help, metadata) · tertiary (placeholder, timestamp, disabled, empty-state copy) | +| text on fill | `--text-on-solid` | anything sitting on a saturated colour | +| border | `--border-subtle` `--border` `--border-strong` | divider inside a container · container edge · control boundary, hover edge, focus-adjacent | +| accent | `--accent` `--accent-600` `--accent-700` | **the brandable seam — see §3** | +| accent, derived | `--accent-color` `--accent-solid` `--accent-solid-hover` `--accent-fg` `--accent-text` `--accent-soft` `--accent-border` | | +| status × 5 | `---solid` `---fg` `---text` `---soft` `---border` | see below | +| effects | `--shadow-color` `--shadow-ambient` `--shadow-direct` `--scroll-thumb` | | + +### The five domain hues + +Paddock's UI encodes exactly five colour meanings. They were previously spelled +in seven Tailwind families (`emerald`, `rose`, `red`, `amber`, `sky`, `violet`, +`zinc`) across four mutually-incompatible chip recipes. + +| token | was | means | +|---|---|---| +| `success` | emerald | added · completed · enabled · connected · adopted · a string literal | +| `warn` | amber | caution · pending · modified · paused · dirty worktree · starred · context pressure · a schedule-provenance chat | +| `danger` | rose **and** red | error · failed · deleted · destructive action · stderr | +| `info` | sky | informational · in progress · background task · repo-backed · a hook-provenance chat | +| `lineage` | violet | **derivation and structure**: a renamed file, a diff hunk header, a spawned chat, trigger type = schedule | + +`lineage` is the one that needs explaining. It is not a severity — it marks +"this thing came from that thing". Keep it distinct from `info`. + +Each hue carries the same five slots, so they are interchangeable at a call +site: `-solid` (saturated fill: dots, destructive button), `-fg` (foreground on +that fill), `-text` (the hue *as text*, on a neutral surface or on its own +`-soft`), `-soft` (tinted background: chips, banners, diff lines), `-border` +(edge of a `-soft` banner). + +`zinc` had one use — `StatusPill`'s `abandoned` — and is not a sixth hue. Inert +things use the neutral tokens. + +## 3. The brandable accent seam (issue #34) + +`--accent`, `--accent-600` and `--accent-700` are **space-separated sRGB +channels**, not OKLCH: + +```css +--accent: 194 96 60; /* #c2603c */ +``` + +A running server rewrites exactly those three by injecting +`` before `` +for `PADDOCK_BRAND_ACCENT` (`packages/server/src/brand.ts`, which derives the +600/700 shades by darkening so an operator picks one colour). + +Therefore: + +- Do not rename those three, and do not convert them to `oklch()`. +- Every other accent-flavoured token must **derive** from them (`rgb(var(--accent-600))`, + `color-mix(in oklab, rgb(var(--accent)) 74%, white)`), or branding silently + stops applying to whatever you hard-coded. `tokens.test.ts` asserts this by + substituting a fake brand colour and checking every derived token moves. +- **Known limitation:** contrast is verified for the *default* accent. An + operator who sets a pale `PADDOCK_BRAND_ACCENT` can drive white-on-accent + below 4.5:1, and nothing at build time can catch it. A future fix is + `contrast-color()` once it ships, or computing the foreground server-side in + `brand.ts`. + +## 4. Contrast is enforced, not asserted + +`packages/web/src/styles/tokens.test.ts` **parses the real `tokens.css`** — +resolving `oklch()`, `rgb(var(--x))`, `color-mix(in oklab, …)` and `var()` chains +through `src/lib/color.ts` — and fails the build if any pairing drops below AA. +It checks, in **both** modes: + +- every one of `--text` / `--text-muted` / `--text-subtle` / `--accent-text` on + every one of the six surfaces, at **4.5:1**; +- each hue's `-text` on `surface`, `surface-raised`, `surface-sunken` and its own + `-soft`, at 4.5:1; +- each hue's `-fg` on its `-solid`, and `--accent-fg` on `--accent-solid`; +- `--border-strong` at **3:1** on every surface (WCAG 1.4.11, non-text); +- every `oklch()` token inside the sRGB gamut (an out-of-gamut value clips + silently in the browser and quietly loses the contrast you thought you had); +- the brand seam described in §3. + +This is the thing that makes several competing visual directions *trustworthy +rather than four opinions*. If you change a token and this fails, it tells you +the exact pair, both hex values, and the ratio. + +What it fixed, measured on `main`: + +| token | uses | on `main` | now (light / dark) | +|---|---|---|---| +| help text, `.field-label` (`paddock-500`) | 96 | **3.75:1** light | 6.71 / 6.62 | +| muted + placeholder (`paddock-400`) | 163 | **2.81:1** light | 4.90 / 4.87 | +| primary button, white on accent | 40 | **4.17:1** both | 5.53 / 5.53 | + +The primary-button fix is why the button fill is `--accent-600` rather than the +raw brand accent: the accent stays the brand colour everywhere it does not carry +text. + +### The limit of this guarantee: it reads tokens, and the screen shows pixels + +`tokens.test.ts` (and `themes.test.ts`, which applies the same contract to every +runtime theme) resolves **declared token values**. If anything composites on top +of a surface — a blend mode, a texture tile, a translucent overlay — the colour +that reaches the eye is not the colour the guard measured, and the guard has no +way to know. It does not report a smaller margin. **It reports a number that is +not on screen.** + +This is not hypothetical. The `parchment` theme laid a texture tile over its +chrome with `background-blend-mode: soft-light`. `soft-light` is asymmetric on a +dark backdrop — below a base of about 0.25 it lifts far more than an equally +distant darker sample lowers, and it lifts the low channels of a saturated colour +proportionally most. The result: + +| | value | +|---|---| +| intended chrome (token) | L **0.332** | +| actually painted | `#7c3d49`, L **0.444** | + +A 0.11 lift, plus desaturation, across the nav rail and the header slab — the +largest saturated area on screen. Every chrome assertion passed. A human said "it +looks pink" twice and was told the token value twice; the first measurement of an +actual pixel settled it in about ninety seconds. + +**So, for any theme that composites over a surface, satisfy _one_ of these two.** +They are alternatives, not a sequence — the second is strictly better because it +lets a theme have real texture instead of a whisper of one: + +**(a) Keep the composite negligible.** Painted pixel within **ΔL 0.01** of the +token, verified by sampling the rendered page. Then the existing token-based +guards remain true and nothing else is needed. + +**(b) Model the composite in the guard, and assert against the worst painted +pixel.** `theme-parchment.test.ts` is the worked example: every surface there is +`background-color` + a `multiply`-blended noise tile, so the real background is +the token times some factor in `[TEXTURE_MIN, 1]`. The guard multiplies the +background down by `TEXTURE_MIN` before measuring contrast — light mode only, +because `multiply` can only darken and a darker background under *light* text +only raises the ratio, so the flat token is already the worst case in dark mode +and on the chrome. + +Route (b) carries one obligation that is easy to forget: **`TEXTURE_MIN` is a +hand-maintained mirror of the tile's own range, and nothing enforces the +coupling.** Change the tile's `feFuncR` slope/intercept or its opacity and the +constant silently stops describing the CSS — the same failure as before, one +level up. So re-measure it when the tile changes. Measured on the shipping +theme: the darkest real ground pixel is **0.959** of the token against a +`TEXTURE_MIN` of **0.93**, i.e. conservative with room to spare — and the painted +ground is ΔL **0.019** from its token, which is why route (a) alone would not +pass and route (b) is what makes the theme legal. + +Whichever route: **prefer `multiply` to `soft-light` or `overlay`.** `multiply` +can only darken, so the error is bounded and points in a known direction. + +**How to sample.** Screenshot, decode, take the **median** of a patch, convert to +OKLab L. Use the median rather than the mean, and check the patch is bare surface +first: a patch that clips a glyph shows a min/max spread of ~116/255 where clean +ground is ~6, and its minimum is antialiasing rather than texture. `ffmpeg` is on +the box and decodes PNG directly: + +```sh +ffmpeg -v error -i shot.png -vf "crop=8:8:X:Y" -f rawvideo -pix_fmt rgb24 - | od -An -tu1 -N3 +``` + +**And when a human's report and your instrument disagree, check the instrument.** + +Today only `parchment` uses a blend mode. `scifi` and `terminal` use alpha and +gradients, which the rendered-node audit already handles. Note that route (b) +currently lives in parchment's own test file — the shared `themes.test.ts` still +reads flat tokens, so a fifth theme that composites inherits **no** protection +until it writes its own. That is the next piece of work on the guard. + +## 5. The scales + +### Type + +Tailwind's `xs`(12) `sm`(14) `base`(16) `lg`(18) `xl`(20) `2xl`(24) `3xl`(30) +rungs are kept **unchanged** — the app's body size is `text-sm`, and redefining +it would resize the entire product. Three rungs are added, for the sizes the app +was faking with 200 arbitrary values across 9 distinct pixel sizes: + +| rung | px | for | +|---|---|---| +| `text-3xs` | 10 | dense metadata, counts, letter badges | +| `text-2xs` | 11 | chips, pills, section labels — the single most-used size | +| `text-md` | 15 | document prose (the file viewer), one notch above UI text | + +11.5px collapsed to 11, 12.5px to 12, 13px to 14, 17px to 18, 28px to 30. None +of those differences was doing any work. + +Also: `font-variant-numeric: tabular-nums` (the `tabular` utility) anywhere +numbers are compared column-to-column — token counts, durations, timestamps, the +Config screen. `text-wrap: balance` is applied to `h1`–`h4` in base. Use `…` not +`...`, curly quotes, and non-breaking spaces in `10 MB` / `⌘ K`. + +### Space + +Tailwind v4's dynamic spacing, `--spacing: 0.25rem`. Everything is a multiple of +4px; there is no bespoke scale and none is wanted. Gutters run 2/3/4/6, section +rhythm 6/8/9. + +### Radius — concentric + +A child's radius is **one rung below its parent's**, so nested corners stay +parallel rather than pooling: + +``` +dialog / card rounded-2xl (16) + panel rounded-xl (12) + control rounded-lg (8) + chip rounded-md (6) + badge rounded-sm (4) +``` + +`--radius-*` are real custom properties — emitted on `:root` by the plain +(non-`inline`) `@theme` block in `index.css` — so a squarer theme sets them all +to `0` in its own `[data-theme]` block and the whole app follows. Same for +`--text-*`, `--shadow-*`, `--ease-*` and `--duration-*`. + +### Elevation — layered + +Never one flat `shadow-sm`. Every rung is **ambient + direct**: a tight, +higher-opacity shadow for the contact edge plus a wide, softer one for the +occlusion. `--shadow-color` is warm-tinted in light mode and neutral black in +dark; `--shadow-ambient` / `--shadow-direct` set the two opacities. + +Crisp edges come from a **semi-transparent border plus a shadow**, not an opaque +border. + +### Motion — frequency decides whether to animate at all + +| frequency | example | rule | +|---|---|---| +| 100+/day | keyboard actions, command palette, **a chat message arriving** | **no animation** | +| occasional | modals, drawers, toasts | standard | + +Durations: `motion-fast` 120 ms (feedback) · `motion-base` 180 ms (state change) +· `motion-slow` 300 ms (layout). **Exit faster than entrance** — long feedback +reads as latency. + +Animate `transform` and `opacity` only. Never `top`/`left`/`width`/`height`. +Never `transition: all`. `prefers-reduced-motion` collapses every transition to +0.01 ms and stops every keyframe animation, in base. + +The previous `fade-in` was 250 ms *with a 4px translate* and fired on every +arriving chat message — precisely the case that should not animate. It is now +opacity-only, 120 ms, and reserved for occasional chrome. + +## 6. The primitives + +`packages/web/src/components/ui/`. Import from the folder: +`import { Button, Card, Field } from "../components/ui"`. + +| primitive | file | use when | +|---|---|---| +| `Button` | `Button.tsx` | any button. `variant`: primary · ghost · subtle · danger · link. `size`: sm · md · icon-sm · icon-md. `loading` + `loadingLabel` replaces the `{saving ? "Saving…" : "Save"}` idiom that appeared 13 times. | +| `Card` | `Surfaces.tsx` | a raised container. `interactive` opts into the hover treatment — the old `.card` baked a hover lift into all eight uses, six of which were not clickable. | +| `Section` | `Surfaces.tsx` | a titled group. `variant`: `card` (settings-style) · `rule` (long-form config) · `bare`. Replaces two incompatible local `Section`s plus five inlined variants. | +| `EmptyState` | `Surfaces.tsx` | **any** empty slot. `variant`: `inline` (quiet, inside an already-titled section) · `panel` (the full invitation, with an icon and a CTA). | +| `Input` `Textarea` `Select` | `Form.tsx` | one shared control style so the three cannot drift apart. | +| `Field` | `Form.tsx` | label + control + help/error, correctly associated via `htmlFor` and `aria-describedby`. | +| `Label` `Hint` | `Form.tsx` | when `Field` is too much structure. | +| `Toggle` `Checkbox` | `Form.tsx` | a switch-styled checkbox (still a real checkbox) and a plain one, at **one** size. | +| `Chip` `StatusDot` `Callout` | `Chip.tsx` | small inline labels. `tone` takes a *meaning* (`success`/`warn`/`danger`/`info`/`lineage`/`accent`/`neutral`). `Callout` is the tinted notice that had been copy-pasted into 13 files. | +| `Dialog` | `Overlay.tsx` | every modal. Handles Escape, backdrop dismissal, **focus trap and focus restoration** — none of the eight ad-hoc overlays did either. | +| `Menu` `MenuItem` | `Overlay.tsx` | a dropdown. Handles outside-click, Escape and arrow-key roving, which the two ad-hoc menus claimed via `role="menu"` but never implemented. | + +Primitives are **styled by token only**. That is the contract that lets a +direction restyle the whole app without opening a route file. + +### Structure & content rules + +- **Repeated patterns, then a deliberate break.** Four identical sections is + monotony. +- **An empty state is an invitation to act**: headline, one line of explanation, + a primary action. Positive framing. No dead ends — always offer a next step or + a recovery. +- **Design the dense state, not just the empty one.** This is a tool for someone + with 7 projects and thousands of chats. +- Hit targets ≥ 24 px (≥ 44 px touch). Flex children that can truncate need + `min-w-0`. +- Focus rings via **`box-shadow`, not `outline`** — `outline` ignores + `border-radius` and paints a rectangle around a rounded control. Use the + `focus-ring` utility or `focus:ring-*`. +- Increase contrast on `:hover` / `:active` / `:focus`, never decrease it. +- Hover-only affordances must be behind the **`can-hover:`** variant. Touch + browsers (iOS Safari especially) apply a *sticky* `:hover` after a tap that + persists until the next tap elsewhere, so an ungated hover reveal gets stuck + on. This is not theoretical; it is why the variant exists. + +## 7. Reject this + +*Written for a future coding agent. These are the specific failure modes, and +the first three are documented defaults you will produce unless you decide not +to.* + +**The three default-AI-aesthetic clusters. Avoid all three.** + +1. A **warm cream background** near `#F4F1EA` with a high-contrast serif display + face and a **terracotta** accent. Paddock's pre-token palette was, by + coincidence, exactly this: canvas `#f7f6f1` is 7/255 from that hex and the + accent is terracotta. `foundation` still sits near it, on purpose, because + the token migration had to be a refactor. **A new theme must move away from + it** — `parchment`, `terminal` and `scifi` all do. +2. A **near-black background** with a single bright acid-green or vermilion + accent. `terminal` is a deliberate, granted exception: Ed asked for that + aesthetic by name. A waiver is not a licence to be lazy — it went to real + reference (VT220/3270 phosphor, ANSI 16, DOS TUI chrome), and **contrast was + never waived**. Do not assume you have the same waiver. +3. A **broadsheet layout**: hairline rules, zero border-radius, dense newspaper + columns. + +**Local rules, all machine-enforced by `styles/tokens.test.ts` and +`styles/themes.test.ts`:** + +- **Never a literal hex, `rgb()` or palette step in component code.** Not + `#c2603c`, not `text-paddock-500`, not `bg-emerald-100`. The single exception + is `src/lib/brand.ts`, which mirrors the server's default accent and is the + branding seam. This rule is the only thing that stops 1017 raw uses + re-accumulating. +- **Never an arbitrary `text-[Npx]`.** Use a rung of the scale. If no rung fits, + the answer is almost always that the design is wrong, not that the scale is. +- **Never a bare `outline` focus ring.** `box-shadow`. +- **Never `transition: all` / `transition-all`.** Name the properties. +- **Never a `dark:` variant for a colour.** The token swaps itself. If you are + writing one, you have hard-coded a colour somewhere. + +**And the judgement call the tests cannot make:** + +> Plan a compact token system, then **critique the plan before building** — if +> any part of it reads like the generic default you would produce for any +> similar page, revise that part. Spend boldness on **one signature element** +> and keep everything around it quiet and disciplined. Then, before you ship, +> take one thing off. + +Anthropic's guidance explicitly advises **against Inter**. `foundation` keeps +it because `foundation` is a refactor; a new theme has no such excuse. The other +three each ship their own face. + +## 8. How to add a theme + +*Written for someone with no context, because that is literally what happens +next.* + +**One new file, three registry lines.** Nothing else — and in particular you do +**not** edit `tokens.css`, which is the foundation's own palette and is shared +by every theme as the layer they fall through to. + +### Step 1 — `packages/web/src/styles/theme-.css` + +Copy `theme-foundation.css` and work from it. Three blocks, and the selectors +are load-bearing: + +```css +[data-theme=""] { /* fonts — the same in both modes */ } +[data-theme=""]:not(.dark) { /* light colours */ } +[data-theme=""].dark { /* dark colours */ } +``` + +The two colour blocks are `(0,2,0)`, so they outrank `tokens.css`'s `:root` and +`.dark` — and because they are mutually exclusive rather than source-ordered, +light and dark keep working *inside* your theme. Do not prefix them with `html`: +unprefixed, each block is a self-contained scrap of the theme that works on any +element, which is what lets the picker render a live swatch of a theme it is not +currently in (`
` is a real, correctly +cascaded piece of Terminal). + +You only have to declare what you **change**. Anything you omit falls through to +`tokens.css`, which is a feature: a partial theme is legal and still fully +checked. Do not rename keys, and do not add keys — a colour with no token is +either a missing meaning (add it to `tokens.css`, both modes, and to §2) or a +component that should have used an existing one. + +Author in OKLCH. Derive the two modes **separately**: pick your surfaces first, +then find the text lightness that clears 4.5:1 against the *darkest* surface +that text can legally sit on (in light mode that is `--surface-sunken`, because +`--text-subtle` is the placeholder colour and a placeholder is real text). + +Keep `--accent` / `--accent-600` / `--accent-700` as space-separated sRGB +channels and keep every other accent token derived from them (§3) — that is what +keeps both the brand seam and the runtime hue picker working on your theme. + +**Get the hover direction from your own polarity, not from the mode.** +`--accent-solid-hover` must raise the contrast of the label the fill carries. If +`--accent-fg` is white, that means a *darker* fill; if your fill carries dark +ink (Parchment's struck-brass coin does, in both modes) it means a *lighter* +one. Both mistakes have shipped here. The guard now checks the pairing. + +The non-colour scales are overridable from your theme block too, because +`index.css` emits them as real custom properties on `:root`. A squarer theme +adds: + +```css +--radius-lg: 0; --radius-xl: 0; --radius-2xl: 0; +``` + +Same for `--text-*`, `--shadow-*`, `--ease-*`, `--duration-*`. + +**Fonts** go in the same block: + +```css +[data-theme=""] { + --font-sans: "YourFace", system-ui, sans-serif; + --font-display: "YourDisplayFace", serif; /* applied to h1–h4; defaults to sans */ + --font-mono: "YourMono", ui-monospace, monospace; + --font-prose: var(--font-sans); /* long-form reading face */ +} +``` + +A new face must be **OFL or otherwise permissively licensed, self-hosted as a +committed woff2 subset** in `packages/web/public/fonts/`, with an `@font-face` +rule — either at the top of your theme file (Parchment and Terminal do this) or +in the `@layer base` block of `index.css` (Sci-Fi does). Both work; there is no +rule, only a leftover of the order the themes were written in. No runtime Google +Fonts — the LAN this runs on works offline. + +`@font-face` is **lazy**: a face downloads only when text is actually rendered +in it, so declaring every theme's faces costs nothing until one is selected. The +exception is ``, which fetches unconditionally — which is +why `index.html` emits it for the *active* theme from its pre-paint script +rather than hard-coding one face. + +### Step 2 — the three registry lines + +| file | line | +|---|---| +| `src/index.css` | `@import "./styles/theme-.css";` | +| `src/lib/appearance.ts` | an entry in `THEMES` — `id`, `label`, `blurb` | +| `index.html` | your primary face in the `PRIMARY` preload map | + +`styles/themes.test.ts` walks the `THEMES` registry and fails **both** ways: a +theme registered with no CSS behind it, and a theme block in CSS that nobody +registered. So a half-finished registration is caught rather than silently +rendering as the foundation palette and looking merely disappointing. + +### Step 3 — decoration, only if the palette genuinely cannot carry your idea + +A theme is tokens and fonts. That is true for three of the four shipped here — +and it was **not** enough for Parchment, which needed texture, `border-image` +ornament frames and corner brackets to read as a 90s game menu at all. Those +are properties, not values, and no token can express one. + +So the boundary is: **tokens carry a palette; a distinctive *look* needs gated +CSS.** If you take that exemption, every rule must be gated on your own +`[data-theme=""]` — an ungated rule silently redecorates every other theme. +Check `::before` / `::after` `content` specifically; a grep for background +images and blend modes does not see pseudo-element ornament, which is how a +containment check passed here while missing 45 selectors. + +And read §4's second half before you composite anything. A blend mode moves the +painted pixel away from its token, and the contrast guard reads tokens. + +### Step 4 — verify + +```bash +cd packages/web +env -u NODE_ENV npx vitest run src/styles/ # MUST be green +env -u NODE_ENV npx vite build # ~12s +``` + +The contrast guard names the exact failing pair, both resolved hex values, and +the ratio. An out-of-gamut `oklch()` is reported separately — fix it by lowering +chroma, not lightness. + +Then **look at it**, in both modes, on a dense transcript rather than on Home. +Two of the defects found in this system's history were invisible to 600 green +assertions and obvious to an eye: an accent that was readable but not the colour +anyone asked for, and ornament that ate a row on a 34px tool-call plate. + +### What you do NOT do + +- Do not edit `tokens.css` to make your theme work. That is the foundation's + palette and the base every other theme inherits; changing it changes all four. +- Do not add a Tailwind colour to `@theme` that is not backed by a token. +- Do not reintroduce `tailwind.config.js`. This is Tailwind v4; configuration is + CSS. +- Do not edit `src/lib/color.ts` or the guards to make a value pass. The + threshold is the floor, not a suggestion. + +--- + +## Appendix — sources + +The craft floor in §5–§7 is lifted from Anthropic's `frontend-design` skill +(the three default-aesthetic clusters, the token-plan critique, the +one-signature-element rule, the advice against Inter), Vercel's web-interface +guidelines, Rauno Freiberg's interaction guidelines, and Emil Kowalski on +motion. Where they conflicted, the stricter rule won. diff --git a/docs/demo/paddock-demo.gif b/docs/demo/paddock-demo.gif index bb8f3f4d..a3e284f4 100644 Binary files a/docs/demo/paddock-demo.gif and b/docs/demo/paddock-demo.gif differ diff --git a/package-lock.json b/package-lock.json index ce877dde..8bb4b094 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,19 +28,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@alloc/quick-lru": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", - "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -2454,6 +2441,278 @@ "license": "MIT", "peer": true }, + "node_modules/@tailwindcss/node": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.3.tgz", + "integrity": "sha512-/T8IKEsf9VTU6tLjgC7+sv2mOPtQxzE2jMw7u4Tt40Tx+QSZxpzh95/H6cMKoja9XuW7iMdLJYBB0o9G1CaAgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.24.1", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.3.tgz", + "integrity": "sha512-krXjAikiaFSPaK/FkAQT5UTx3VormQaiZ5hBFlJZ9UFQGB/rwg1MZIhHAG9smMQRTdyJxP6Qt5MwMtdyU5FWrA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-arm64": "4.3.3", + "@tailwindcss/oxide-darwin-x64": "4.3.3", + "@tailwindcss/oxide-freebsd-x64": "4.3.3", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.3", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.3", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.3", + "@tailwindcss/oxide-linux-x64-musl": "4.3.3", + "@tailwindcss/oxide-wasm32-wasi": "4.3.3", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.3", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.3" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.3.tgz", + "integrity": "sha512-Y85A2gmPSkl5Ve5qR86GL4HT509cFqQh1aes9p3sSkyTPwt0Pppf3GkwGe4JPACcRYjgJIEhQgM6dBClnr0NYw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.3.tgz", + "integrity": "sha512-BiaWatpBcERQFDlOjRDpIVXuFK5PJez5SA4JMg6VYZdBYU+qKfV/vqjcIs+IYmtitf1xYQZTwXvU/8y4lfZUGw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.3.tgz", + "integrity": "sha512-fAeUqfV5ndhxRwai8cXGzdLvul9utWOmeTkv69unv4ZXixjn61Z+p9lCWdwOwA3TYboG3BwdVuN/RDjhBRl0mw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.3.tgz", + "integrity": "sha512-iyf5bV6+wnAlflVeEy7R25dupxTNECZN5QMI0qNT6eT+EgaGdZcKhGkr5SdoaWiLJ3spLqIY9VCeSGrwmtg4kw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.3.tgz", + "integrity": "sha512-aAYUprJAJQWWbRrPvtjdroZ56Md+JM8pMiopS6xGEwDfLhqj+2ver2p4nU4Mb3CRqcMmNBjo8KkUgcxhkzVQGQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.3.tgz", + "integrity": "sha512-nDxldcEENOxZRzC2uu9jrutZdAAQtb+8WWDCSnWL1zvBk1+FN+x6MtDViPB5AJMfttVCUhehGWus3XBPgatM/w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.3.tgz", + "integrity": "sha512-Md44bD6veX/PC5iyF8cDVnw4HBIANZepRZZ7a8DQOvkfo5WUBwcp6iAuCUz23u+4SUkhJlD3eL7hNdW8ezd/kA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.3.tgz", + "integrity": "sha512-tx7us1muwOKAKWao2v/GaafFeQboE6aj88vC6ziN2NCGcRm8gWUhwjzg+YdVB1e4boAtdtma4L43onunI6NS4w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.3.tgz", + "integrity": "sha512-SJxX60smvHgasZoBy11dX6YRjXJFovwWBoedhbQPOBzgFWBHGB+TVPWB9BxzR7TTxU8FQZAI2AyiNCMzFm8Img==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.3.tgz", + "integrity": "sha512-jx1+rPhY/5Ympkktd656HBWEBLxP7dH06losBLjjf5vgCODXvi9KhtftWcMIwTFIDqBr7cRnQkdLnAG+IOlGvQ==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.3.tgz", + "integrity": "sha512-3rc292Ca2ceK6Ulcc/bAVnTs/3nDtoPhyEKlgPv+yQJQi/JS/AMJlqzxvlDacL1nekbrcf6bTqp/jV4qgnPxNQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.3.tgz", + "integrity": "sha512-yJ0pwIVc/nYeGoV02WtsN8KYyLQv7kyI2wDnkezyJlGGjkd4QLwDGAwl47YpPJeuI0M0ObaXGSPjvWDPeTPggw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.3.tgz", + "integrity": "sha512-yYU8cogLeSh/ms2jh8Fj7jaba/EWa7Ja6GoUqYZaraEuCI5YS6ms6ObZgjjedm+jm6XZjdNRWBpPP6Z86oOxcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.3", + "@tailwindcss/oxide": "4.3.3", + "tailwindcss": "4.3.3" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, "node_modules/@testing-library/dom": { "version": "10.4.1", "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", @@ -3269,34 +3528,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/any-promise": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", - "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", - "dev": true, - "license": "MIT" - }, - "node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", - "dev": true, - "license": "ISC", - "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/arg": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", - "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", - "dev": true, - "license": "MIT" - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -3463,19 +3694,6 @@ "node": ">=4" } }, - "node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -3681,16 +3899,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/camelcase-css": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", - "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/caniuse-lite": { "version": "1.0.30001799", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", @@ -3796,44 +4004,6 @@ "node": ">= 16" } }, - "node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", - "dev": true, - "license": "MIT", - "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" - }, - "engines": { - "node": ">= 8.10.0" - }, - "funding": { - "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/chokidar/node_modules/glob-parent": { - "version": "5.1.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", - "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/chownr": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", @@ -3895,16 +4065,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/commander": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", - "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -4025,23 +4185,10 @@ "dev": true, "license": "MIT" }, - "node_modules/cssesc": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", - "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", - "dev": true, - "license": "MIT", - "bin": { - "cssesc": "bin/cssesc" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/cssstyle": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", - "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", + "node_modules/cssstyle": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/cssstyle/-/cssstyle-4.6.0.tgz", + "integrity": "sha512-2z+rWdzbbSZv6/rhtvzvqeZQHrBaqgogqt85sqFNbabZOuFbCVFb8kPeEtZjiKkbrm395irpNKiYeFeLiQnFPg==", "dev": true, "license": "MIT", "dependencies": { @@ -4694,6 +4841,16 @@ "node": ">=8" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devlop": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", @@ -4707,13 +4864,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/didyoumean": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", - "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/dir-glob": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-3.0.1.tgz", @@ -4727,13 +4877,6 @@ "node": ">=8" } }, - "node_modules/dlv": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", - "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", - "dev": true, - "license": "MIT" - }, "node_modules/docker-modem": { "version": "5.0.7", "resolved": "https://registry.npmjs.org/docker-modem/-/docker-modem-5.0.7.tgz", @@ -4867,6 +5010,20 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.24.5.tgz", + "integrity": "sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/enquirer": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/enquirer/-/enquirer-2.4.1.tgz", @@ -5569,19 +5726,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/glob-parent": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", - "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", - "dev": true, - "license": "ISC", - "dependencies": { - "is-glob": "^4.0.3" - }, - "engines": { - "node": ">=10.13.0" - } - }, "node_modules/glob/node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -5988,35 +6132,6 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dev": true, - "license": "MIT", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/is-decimal": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", @@ -6227,13 +6342,13 @@ } }, "node_modules/jiti": { - "version": "1.21.7", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", - "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", "dev": true, "license": "MIT", "bin": { - "jiti": "bin/jiti.js" + "jiti": "lib/jiti-cli.mjs" } }, "node_modules/jose": { @@ -6413,22 +6528,266 @@ "integrity": "sha512-8h2oVEZNktL4BH2JCOI90iD1yXwL6iNW7KcCKT2QZgQJR2vbqDsldCTPRU9NifTCqHZci57XvQQ15YTu+sTYPg==", "license": "MIT" }, - "node_modules/lilconfig": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz", - "integrity": "sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==", + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", "dev": true, - "license": "MIT", + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, "engines": { - "node": ">=10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/lines-and-columns": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", - "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], "dev": true, - "license": "MIT" + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/locate-path": { "version": "5.0.0", @@ -7625,18 +7984,6 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, - "node_modules/mz": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", - "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0", - "object-assign": "^4.0.1", - "thenify-all": "^1.0.0" - } - }, "node_modules/nan": { "version": "2.28.0", "resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz", @@ -7742,16 +8089,6 @@ "nodejs-whisper": "dist/downloadModel.js" } }, - "node_modules/normalize-path": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", - "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/normalize-range": { "version": "0.1.2", "resolved": "https://registry.npmjs.org/normalize-range/-/normalize-range-0.1.2.tgz", @@ -7806,16 +8143,6 @@ "node": ">=0.10.0" } }, - "node_modules/object-hash": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", - "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -8036,13 +8363,6 @@ "node": ">=8" } }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, "node_modules/path-scurry": { "version": "1.11.1", "resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-1.11.1.tgz", @@ -8122,26 +8442,6 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, - "node_modules/pify": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", - "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/pirates": { - "version": "4.0.7", - "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", - "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 6" - } - }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -8243,139 +8543,6 @@ "node": "^10 || ^12 || >=14" } }, - "node_modules/postcss-import": { - "version": "15.1.0", - "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", - "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", - "dev": true, - "license": "MIT", - "dependencies": { - "postcss-value-parser": "^4.0.0", - "read-cache": "^1.0.0", - "resolve": "^1.1.7" - }, - "engines": { - "node": ">=14.0.0" - }, - "peerDependencies": { - "postcss": "^8.0.0" - } - }, - "node_modules/postcss-js": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", - "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "camelcase-css": "^2.0.1" - }, - "engines": { - "node": "^12 || ^14 || >= 16" - }, - "peerDependencies": { - "postcss": "^8.4.21" - } - }, - "node_modules/postcss-load-config": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-4.0.2.tgz", - "integrity": "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "lilconfig": "^3.0.0", - "yaml": "^2.3.4" - }, - "engines": { - "node": ">= 14" - }, - "peerDependencies": { - "postcss": ">=8.0.9", - "ts-node": ">=9.0.0" - }, - "peerDependenciesMeta": { - "postcss": { - "optional": true - }, - "ts-node": { - "optional": true - } - } - }, - "node_modules/postcss-load-config/node_modules/lilconfig": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", - "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=14" - }, - "funding": { - "url": "https://github.com/sponsors/antonk52" - } - }, - "node_modules/postcss-nested": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", - "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "postcss-selector-parser": "^6.1.1" - }, - "engines": { - "node": ">=12.0" - }, - "peerDependencies": { - "postcss": "^8.2.14" - } - }, - "node_modules/postcss-selector-parser": { - "version": "6.1.4", - "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.4.tgz", - "integrity": "sha512-bIoJLOmjCO1S9XdY/DcnR5hJxvrDir1PbGChrzXG3vw0/FOliy/fA3dmdhQ441kah4gKv+TwckGzex6wNS5cnQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "cssesc": "^3.0.0", - "util-deprecate": "^1.0.2" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/postcss-value-parser": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", @@ -8708,16 +8875,6 @@ "react-dom": ">=16.8" } }, - "node_modules/read-cache": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", - "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", - "dev": true, - "license": "MIT", - "dependencies": { - "pify": "^2.3.0" - } - }, "node_modules/read-yaml-file": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-yaml-file/-/read-yaml-file-1.1.0.tgz", @@ -8782,19 +8939,6 @@ "node": ">= 6" } }, - "node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dev": true, - "license": "MIT", - "dependencies": { - "picomatch": "^2.2.1" - }, - "engines": { - "node": ">=8.10.0" - } - }, "node_modules/readline-sync": { "version": "1.4.10", "resolved": "https://registry.npmjs.org/readline-sync/-/readline-sync-1.4.10.tgz", @@ -8903,28 +9047,6 @@ "node": ">=0.10.0" } }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -9673,29 +9795,6 @@ "integrity": "sha512-5Z9ZpRzfuH6l/UAvCPAPUo3665Nk2wLaZU3x+TLHKVzIz33+sbJqbtrYoC3KD4/uVOr2Zp+L0LySezP9OHV9yA==", "license": "MIT" }, - "node_modules/sucrase": { - "version": "3.35.1", - "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", - "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.2", - "commander": "^4.0.0", - "lines-and-columns": "^1.1.6", - "mz": "^2.7.0", - "pirates": "^4.0.1", - "tinyglobby": "^0.2.11", - "ts-interface-checker": "^0.1.9" - }, - "bin": { - "sucrase": "bin/sucrase", - "sucrase-node": "bin/sucrase-node" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, "node_modules/supports-color": { "version": "7.2.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", @@ -9709,19 +9808,6 @@ "node": ">=8" } }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -9730,41 +9816,24 @@ "license": "MIT" }, "node_modules/tailwindcss": { - "version": "3.4.13", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.13.tgz", - "integrity": "sha512-KqjHOJKogOUt5Bs752ykCeiwvi0fKVkr5oqsFNt/8px/tA8scFPIlkygsf6jXrfCqGHz7VflA6+yytWuM+XhFw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@alloc/quick-lru": "^5.2.0", - "arg": "^5.0.2", - "chokidar": "^3.5.3", - "didyoumean": "^1.2.2", - "dlv": "^1.1.3", - "fast-glob": "^3.3.0", - "glob-parent": "^6.0.2", - "is-glob": "^4.0.3", - "jiti": "^1.21.0", - "lilconfig": "^2.1.0", - "micromatch": "^4.0.5", - "normalize-path": "^3.0.0", - "object-hash": "^3.0.0", - "picocolors": "^1.0.0", - "postcss": "^8.4.23", - "postcss-import": "^15.1.0", - "postcss-js": "^4.0.1", - "postcss-load-config": "^4.0.1", - "postcss-nested": "^6.0.1", - "postcss-selector-parser": "^6.0.11", - "resolve": "^1.22.2", - "sucrase": "^3.32.0" - }, - "bin": { - "tailwind": "lib/cli.js", - "tailwindcss": "lib/cli.js" - }, + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.3.tgz", + "integrity": "sha512-gOhV3P7ufE62QDGg1zVaTgCR+EtPv92k2nIhVcVKcLmxT1sUBsQGhnZj175j+MqRt4zLF7ic+sCYjfhxMxj7YQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", "engines": { - "node": ">=14.0.0" + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/tar-fs": { @@ -9823,29 +9892,6 @@ "node": ">=18" } }, - "node_modules/thenify": { - "version": "3.3.1", - "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", - "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", - "dev": true, - "license": "MIT", - "dependencies": { - "any-promise": "^1.0.0" - } - }, - "node_modules/thenify-all": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", - "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", - "dev": true, - "license": "MIT", - "dependencies": { - "thenify": ">= 3.1.0 < 4" - }, - "engines": { - "node": ">=0.8" - } - }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -9862,54 +9908,6 @@ "node": ">=18" } }, - "node_modules/tinyglobby": { - "version": "0.2.17", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", - "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "dev": true, - "license": "MIT", - "dependencies": { - "fdir": "^6.5.0", - "picomatch": "^4.0.4" - }, - "engines": { - "node": ">=12.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/SuperchupuDev" - } - }, - "node_modules/tinyglobby/node_modules/fdir": { - "version": "6.5.0", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", - "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12.0.0" - }, - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, - "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", - "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/tinypool": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", @@ -10044,13 +10042,6 @@ "node": ">=6.10" } }, - "node_modules/ts-interface-checker": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", - "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", - "dev": true, - "license": "Apache-2.0" - }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -11735,6 +11726,7 @@ "remark-gfm": "^4.0.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -11745,7 +11737,7 @@ "autoprefixer": "10.4.20", "jsdom": "^25.0.1", "postcss": "8.4.47", - "tailwindcss": "3.4.13", + "tailwindcss": "^4.3.3", "typescript": "5.5.4", "vite": "5.4.6", "vitest": "^2.1.9" diff --git a/packages/web/index.html b/packages/web/index.html index bf67e27e..41831e6e 100644 --- a/packages/web/index.html +++ b/packages/web/index.html @@ -2,17 +2,41 @@ - + @@ -44,9 +68,34 @@ - - + +
diff --git a/packages/web/package.json b/packages/web/package.json index 0f66baa7..00d73907 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -23,6 +23,7 @@ "remark-gfm": "^4.0.0" }, "devDependencies": { + "@tailwindcss/vite": "^4.3.3", "@testing-library/jest-dom": "^6.9.1", "@testing-library/react": "^16.3.2", "@testing-library/user-event": "^14.6.1", @@ -30,10 +31,8 @@ "@types/react-dom": "18.3.0", "@vitejs/plugin-react": "4.3.1", "@vitest/coverage-v8": "^2.1.9", - "autoprefixer": "10.4.20", "jsdom": "^25.0.1", - "postcss": "8.4.47", - "tailwindcss": "3.4.13", + "tailwindcss": "^4.3.3", "typescript": "5.5.4", "vite": "5.4.6", "vitest": "^2.1.9" diff --git a/packages/web/postcss.config.js b/packages/web/postcss.config.js deleted file mode 100644 index 2aa7205d..00000000 --- a/packages/web/postcss.config.js +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/packages/web/public/fonts/alegreya-latin-italic.woff2 b/packages/web/public/fonts/alegreya-latin-italic.woff2 new file mode 100644 index 00000000..5396c78f Binary files /dev/null and b/packages/web/public/fonts/alegreya-latin-italic.woff2 differ diff --git a/packages/web/public/fonts/alegreya-latin.woff2 b/packages/web/public/fonts/alegreya-latin.woff2 new file mode 100644 index 00000000..94d58158 Binary files /dev/null and b/packages/web/public/fonts/alegreya-latin.woff2 differ diff --git a/packages/web/public/fonts/alegreya-sc-latin-500.woff2 b/packages/web/public/fonts/alegreya-sc-latin-500.woff2 new file mode 100644 index 00000000..bbeb8939 Binary files /dev/null and b/packages/web/public/fonts/alegreya-sc-latin-500.woff2 differ diff --git a/packages/web/public/fonts/alegreya-sc-latin-700.woff2 b/packages/web/public/fonts/alegreya-sc-latin-700.woff2 new file mode 100644 index 00000000..8efe2075 Binary files /dev/null and b/packages/web/public/fonts/alegreya-sc-latin-700.woff2 differ diff --git a/packages/web/public/fonts/courier-prime-latin-400.woff2 b/packages/web/public/fonts/courier-prime-latin-400.woff2 new file mode 100644 index 00000000..42dadfed Binary files /dev/null and b/packages/web/public/fonts/courier-prime-latin-400.woff2 differ diff --git a/packages/web/public/fonts/courier-prime-latin-700.woff2 b/packages/web/public/fonts/courier-prime-latin-700.woff2 new file mode 100644 index 00000000..2d36acc0 Binary files /dev/null and b/packages/web/public/fonts/courier-prime-latin-700.woff2 differ diff --git a/packages/web/public/fonts/courier-prime-latin-italic.woff2 b/packages/web/public/fonts/courier-prime-latin-italic.woff2 new file mode 100644 index 00000000..da0d87c2 Binary files /dev/null and b/packages/web/public/fonts/courier-prime-latin-italic.woff2 differ diff --git a/packages/web/public/fonts/iosevka-terminal-400.woff2 b/packages/web/public/fonts/iosevka-terminal-400.woff2 new file mode 100644 index 00000000..51df6bac Binary files /dev/null and b/packages/web/public/fonts/iosevka-terminal-400.woff2 differ diff --git a/packages/web/public/fonts/iosevka-terminal-700.woff2 b/packages/web/public/fonts/iosevka-terminal-700.woff2 new file mode 100644 index 00000000..d7cb2067 Binary files /dev/null and b/packages/web/public/fonts/iosevka-terminal-700.woff2 differ diff --git a/packages/web/public/fonts/martian-mono-latin.woff2 b/packages/web/public/fonts/martian-mono-latin.woff2 new file mode 100644 index 00000000..39768937 Binary files /dev/null and b/packages/web/public/fonts/martian-mono-latin.woff2 differ diff --git a/packages/web/public/fonts/saira-latin.woff2 b/packages/web/public/fonts/saira-latin.woff2 new file mode 100644 index 00000000..55bfc728 Binary files /dev/null and b/packages/web/public/fonts/saira-latin.woff2 differ diff --git a/packages/web/src/components/AdoptChatsModal.tsx b/packages/web/src/components/AdoptChatsModal.tsx index dd0ebc6e..914f4761 100644 --- a/packages/web/src/components/AdoptChatsModal.tsx +++ b/packages/web/src/components/AdoptChatsModal.tsx @@ -98,14 +98,14 @@ export function AdoptChatsModal({ return (
!busy && onClose()} >
e.stopPropagation()} >
@@ -117,13 +117,13 @@ export function AdoptChatsModal({ type="button" onClick={onClose} disabled={busy} - className="rounded-lg p-1 text-paddock-400 hover:bg-paddock-100 hover:text-paddock-600 dark:hover:bg-paddock-800" + className="rounded-lg p-1 text-fg-subtle hover:bg-surface-hover hover:text-fg-muted" aria-label="Close" >
-

+

These Claude Code sessions were run in a terminal and aren't visible here yet. Adopting them lists them here — your own ~/.claude history is never moved or deleted. @@ -131,7 +131,7 @@ export function AdoptChatsModal({

{sources.length === 0 && ( -

Nothing left to adopt.

+

Nothing left to adopt.

)} {sources.map((source) => { const ids = source.sessions.map((c) => c.sessionId); @@ -141,13 +141,13 @@ export function AdoptChatsModal({
{/* The origin path, in full. A user who does not recognise a source is exactly who this dialog exists for. */} - + {source.sourceCwd} @@ -155,7 +155,7 @@ export function AdoptChatsModal({
    {source.sessions.map((candidate) => (
  • -
-
- +
+ {count} of {allIds.length} selected diff --git a/packages/web/src/components/AppShell.test.tsx b/packages/web/src/components/AppShell.test.tsx index 1aa890ac..f612b0e4 100644 --- a/packages/web/src/components/AppShell.test.tsx +++ b/packages/web/src/components/AppShell.test.tsx @@ -575,10 +575,10 @@ describe("AppShell: navigation", () => { // `end` on the NavLink: without it every route matches `/` as a prefix and // the Home item reads as active on every page in the app. const { unmount } = renderShell("/"); - expect(screen.getByRole("link", { name: "Home" }).className).toMatch(/bg-paddock-200/); + expect(screen.getByRole("link", { name: "Home" }).className).toMatch(/bg-surface-selected/); unmount(); renderShell("/projects/alpha/chat"); - expect(screen.getByRole("link", { name: "Home" }).className).not.toMatch(/bg-paddock-200/); + expect(screen.getByRole("link", { name: "Home" }).className).not.toMatch(/bg-surface-selected/); }); it("a project nav link routes to that project", () => { diff --git a/packages/web/src/components/AppShell.tsx b/packages/web/src/components/AppShell.tsx index fac292aa..156873d7 100644 --- a/packages/web/src/components/AppShell.tsx +++ b/packages/web/src/components/AppShell.tsx @@ -226,12 +226,12 @@ export function AppShell() { const rootBadge = badges.get(ROOT_KEY); return ( -
+
{/* Mobile top bar — hidden on lg+, where the sidebar is always present. Also dropped on project routes, which host their own single-row header (with an inline hamburger) so the two rows collapse into one (#372). */} {!routeOwnsMobileHeader && ( -
+
- {brand.name} + {brand.name}
)} @@ -250,7 +250,7 @@ export function AppShell() { {/* Drawer backdrop (mobile only, when open). */} {navOpen && ( ); } @@ -442,12 +443,12 @@ export function AppShell() { /** * The instance logo chip (issue #34). Renders the configured logo as an * when it's a URL/path, otherwise as an inline glyph/emoji. The accent-colored - * chip background comes from the runtime `--accent` CSS variable via `bg-accent`. + * chip background comes from the runtime `--accent` CSS variable via `bg-accent-solid`. */ function BrandLogo({ brand, className = "" }: { brand: ReturnType; className?: string }) { return ( {logoIsImage(brand.logo) ? ( @@ -462,7 +463,7 @@ function BrandLogo({ brand, className = "" }: { brand: ReturnType - + Loading…
); @@ -480,9 +481,7 @@ function ProjectNavLink({ project: p, badge }: { project: Project; badge?: Proje to={`/projects/${p.slug}`} className={({ isActive }) => `group mb-0.5 flex flex-col gap-1 rounded-lg px-2.5 py-2 text-sm transition-colors ${ - isActive - ? "bg-paddock-200/80 dark:bg-paddock-800" - : "hover:bg-paddock-200/50 dark:hover:bg-paddock-800/50" + isActive ? "bg-surface-selected" : "hover:bg-surface-hover" }` } > @@ -491,7 +490,7 @@ function ProjectNavLink({ project: p, badge }: { project: Project; badge?: Proje {p.name} @@ -503,7 +502,7 @@ function ProjectNavLink({ project: p, badge }: { project: Project; badge?: Proje ))} {p.domain.length > 2 && ( - +{p.domain.length - 2} + +{p.domain.length - 2} )} )} @@ -531,20 +530,20 @@ function ProjectBadges({ badge, className = "" }: { badge?: ProjectBadge; classN {inflight > 0 && ( )} {unread > 0 && ( diff --git a/packages/web/src/components/AppearancePanel.tsx b/packages/web/src/components/AppearancePanel.tsx new file mode 100644 index 00000000..44836516 --- /dev/null +++ b/packages/web/src/components/AppearancePanel.tsx @@ -0,0 +1,570 @@ +/** + * The appearance picker: theme, accent colour, ground tint. + * + * ── The one hard constraint on this file ───────────────────────────────────── + * It must never expose colour theory. No "hue", no "chroma", no "lightness", + * no "OKLCH", no required hex entry. You pick a colour the way a person picks a + * colour — off a strip, or by name — and it works. Every number in here is + * either a preview you look at or a diagnostic clearly marked as one. + * + * That is not a stylistic preference; it is the feature. The maths exists so + * that the interface can be this small. If a user has to understand anything to + * use this, the solver has failed no matter how correct it is. + * + * ── Where it lives ─────────────────────────────────────────────────────────── + * A section at the top of `/config`, which is where every other setting lives. + * An earlier cut put it in a `Dialog` and then in a floating dock; both were + * answers to the same mistake, which was covering the app you are recolouring. + * The config page solves it outright: the picker previews itself against a + * real, token-dense screen, and the page's width lets the whole control be one + * ~370px band instead of a tall column. + * + * It is NOT one of that page's config fields — see `APPEARANCE_GROUP` in + * InstanceConfigForm for why that distinction is kept visible. + * + * ── What is real ───────────────────────────────────────────────────────────── + * Every swatch on the strip is painted with the colour that position actually + * produces — `accentSwatches()` runs the same solve the app will run. The + * theme cards are real scraps of their theme, cascaded by the same + * `[data-theme]` blocks the app uses, not hand-picked preview hexes. + */ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; +import { + accentSwatches, + applyAccent, + clearAccent, + readAccentContext, + type AccentContext, + type AccentReport, +} from "../lib/accent"; +import { + APPEARANCE_EVENT, + NAMED_HUES, + THEMES, + TINTS, + readAppearance, + saveAppearance, + type Appearance, + type ThemeId, +} from "../lib/appearance"; +import { Button, Chip, StatusDot, cx } from "./ui"; + +/** Hues to paint the strip at. 5-degree steps: finer than the eye separates at + * this width, coarse enough that the whole strip is one cheap solve. */ +const STRIP = Array.from({ length: 72 }, (_, i) => i * 5); + +const BY_HUE = [...NAMED_HUES].sort((a, b) => a.hue - b.hue); +const arc = (a: number, b: number) => Math.min(Math.abs(a - b), 360 - Math.abs(a - b)); + +/** + * Say where on the strip you are, in words. + * + * Reads out continuously while you scrub, which is why it names the *bracketing + * pair* rather than falling back to "Custom" between the named colours — a + * label that blinks between "Teal" and "Custom" as you drag tells you nothing + * and makes the control feel unreliable. "Between Teal and Sky" is both true + * and the only vocabulary this thing is allowed: no degrees, no hex. + */ +function describeHue(hue: number): string { + const near = BY_HUE.reduce((best, n) => (arc(n.hue, hue) < arc(best.hue, hue) ? n : best)); + if (arc(near.hue, hue) <= 8) return near.name; + // The pair this hue sits between, wrapping past the end of the list. + const after = BY_HUE.find((n) => n.hue > hue) ?? BY_HUE[0]; + const before = BY_HUE[(BY_HUE.indexOf(after) - 1 + BY_HUE.length) % BY_HUE.length]; + return `between ${before.name} and ${after.name}`; +} + +/** + * A live scrap of a theme: its canvas, a card on it, its accent button, and two + * bars of body text. Small enough to scan five of at once, complete enough that + * the choice is informed — the ground/card relationship and the accent are the + * three things that actually differ between these directions. + * + * `data-theme` + `dark` go on this element rather than on ``, which the + * generated blocks support on purpose (they are not `html`-prefixed). + */ +function ThemeSwatch({ + theme, + dark, + hue, + className, +}: { + theme: ThemeId; + dark: boolean; + /** The picked hue, or null to show the theme's own accent. */ + hue: number | null; + className?: string; +}) { + const ref = useRef(null); + + /** + * Solve the picked colour for THIS theme, on this element. + * + * `applyAccent` takes any element, not just ``, and reads its computed + * tokens — so pointing it at a preview gives that preview the same solve the + * app would run if you switched to it. Five previews at one hue therefore + * show five genuinely different accents, because each theme contributes its + * own chroma and its own contrast targets. Showing every preview the theme's + * shipped accent while the app wears a different one would be the picker + * lying about the thing it exists to preview. + */ + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + if (hue === null) clearAccent(el); + else applyAccent(hue, el); + }, [theme, dark, hue]); + + return ( +
+
+
+
+
+
+
+
+
+
+
+ ); +} + +/** + * The spectrum: a draggable slider, not a row of swatch buttons. + * + * Two things make this more than a styling change. + * + * **It is painted with the real solved colours.** The gradient stops are + * `accentSwatches()` output — the same solve the app runs — so every position + * on the track is the colour that position produces. Where the solve has to + * give ground (chroma pulled in near the sRGB boundary, lightness moved around + * a hue whose luminance runs high) the track shows it rather than promising a + * vividness the app will not deliver. The last stop repeats the first, because + * hue wraps and a gradient does not. + * + * **Dragging applies on every frame, but only commits on release.** A live + * apply is a full solve plus a repair pass, and the repair pass reads computed + * styles — a forced style recalc. Doing that per `pointermove` (which can fire + * several times a frame) would be visibly rough, so moves are coalesced into + * one `requestAnimationFrame`. Persisting is separate and deliberately deferred + * to `pointerup`: writing localStorage and re-rendering the five theme previews + * on every frame of a drag is a lot of work to throw away, and the previews + * flickering as you scrub is worse than them settling once you stop. + * + * During the drag the thumb and the label are moved by writing to the DOM + * directly rather than through state, so a scrub costs zero React renders. + */ +function AccentStrip({ + hexes, + hue, + active, + onCommit, +}: { + hexes: string[]; + hue: number; + /** Whether a colour has been picked (vs "theme's own"). */ + active: boolean; + onCommit: (hue: number) => void; +}) { + const trackRef = useRef(null); + const thumbRef = useRef(null); + const labelRef = useRef(null); + const frame = useRef(0); + const pending = useRef(null); + const dragging = useRef(false); + // Captured once per drag; see `readAccentContext`. + const context = useRef(null); + + const gradient = useMemo(() => { + if (hexes.length === 0) return undefined; + const stops = hexes.map((hex, i) => `${hex} ${((i / hexes.length) * 100).toFixed(2)}%`); + stops.push(`${hexes[0]} 100%`); + return `linear-gradient(to right, ${stops.join(", ")})`; + }, [hexes]); + + const paint = useCallback((h: number) => { + if (thumbRef.current) thumbRef.current.style.left = `${(h / 360) * 100}%`; + if (labelRef.current) labelRef.current.textContent = describeHue(h); + }, []); + + // Keep the thumb honest when the hue changes from anywhere else — a named + // swatch, "theme's own", a theme switch that re-solves the same hue. + useEffect(() => { + if (!dragging.current) paint(hue); + }, [hue, paint]); + + const hueAt = (clientX: number): number => { + const rect = trackRef.current?.getBoundingClientRect(); + if (!rect || rect.width === 0) return 0; + const f = Math.min(1, Math.max(0, (clientX - rect.left) / rect.width)); + return Math.round(f * 360) % 360; + }; + + const preview = useCallback( + (h: number) => { + pending.current = h; + if (frame.current) return; + frame.current = requestAnimationFrame(() => { + frame.current = 0; + const next = pending.current; + pending.current = null; + if (next === null) return; + paint(next); + applyAccent(next, document.documentElement, { + context: context.current ?? undefined, + live: true, + }); + }); + }, + [paint], + ); + + useEffect(() => () => cancelAnimationFrame(frame.current), []); + + const finish = (h: number) => { + dragging.current = false; + context.current = null; + cancelAnimationFrame(frame.current); + frame.current = 0; + pending.current = null; + onCommit(h); + }; + + return ( + <> +
+ Drag along the strip + +
+
{ + e.currentTarget.setPointerCapture(e.pointerId); + dragging.current = true; + context.current = readAccentContext(); + preview(hueAt(e.clientX)); + }} + onPointerMove={(e) => { + if (dragging.current) preview(hueAt(e.clientX)); + }} + onPointerUp={(e) => { + if (dragging.current) finish(hueAt(e.clientX)); + }} + onPointerCancel={() => { + if (dragging.current) finish(hue); + }} + onKeyDown={(e) => { + const step = e.key === "PageUp" || e.key === "PageDown" ? 15 : e.shiftKey ? 10 : 2; + let next: number | null = null; + if (e.key === "ArrowLeft" || e.key === "ArrowDown" || e.key === "PageDown") { + next = (hue - step + 360) % 360; + } else if (e.key === "ArrowRight" || e.key === "ArrowUp" || e.key === "PageUp") { + next = (hue + step) % 360; + } else if (e.key === "Home") next = 0; + else if (e.key === "End") next = 359; + if (next === null) return; + e.preventDefault(); + onCommit(next); + }} + style={{ backgroundImage: gradient, touchAction: "none" }} + className={cx( + "relative mt-1 h-8 cursor-pointer rounded-lg border border-edge shadow-xs select-none", + "focus-visible:focus-ring", + )} + > +
+
+ + ); +} + +export interface AppearancePanelProps { + className?: string; +} + +export function AppearancePanel({ className }: AppearancePanelProps) { + const [appearance, setAppearance] = useState(readAppearance); + const [report, setReport] = useState(null); + const dark = + typeof document !== "undefined" && document.documentElement.classList.contains("dark"); + + /** + * Apply a partial change. + * + * The current appearance is held in a ref as well as in state because two + * changes inside one task — pick a theme, then pick a colour — would both + * read the same pre-render `appearance` object, and the second would silently + * revert the first. React has not re-rendered in between; the ref has. + */ + const current = useRef(appearance); + const commit = useCallback((patch: Partial) => { + const next = { ...current.current, ...patch }; + current.current = next; + setAppearance(next); + setReport(saveAppearance(next)); + }, []); + + // The solve itself runs at boot and on every light/dark flip (see + // `initAppearance`); this only listens, so the readout and the strip follow a + // mode change made from the button next door. + useEffect(() => { + const onApplied = (e: Event) => setReport((e as CustomEvent).detail); + window.addEventListener(APPEARANCE_EVENT, onApplied); + setReport(saveAppearance(readAppearance())); + return () => window.removeEventListener(APPEARANCE_EVENT, onApplied); + }, []); + + // The strip is re-solved per theme and per mode — the same hue is a different + // colour on a dark canvas, and the strip should show that rather than lie. + const strip = useMemo( + () => accentSwatches(STRIP), + // eslint-disable-next-line react-hooks/exhaustive-deps -- re-solve when the + // rendered theme or mode changes; `report` is the signal that it did. + [appearance.theme, dark, report?.mode], + ); + + const activeHue = report?.hue ?? 0; + const isCustom = appearance.hue !== null; + + return ( +
+ {/* ------------------------------------------------------------ theme -- */} +
+ + Theme + + {/* Two across when narrow, four when there is room — which is all four + themes in one row, so the set is comparable at a glance rather than + wrapped. The layout is the only difference between the two; it is + what keeps the section short on a page and the dock short in a + column. */} +
+ {THEMES.map((t) => { + const selected = appearance.theme === t.id; + return ( + + ); + })} +
+
+ + {/* Wide, the colour strip and the tint sit side by side rather than + stacked: the strip wants length and the tint wants almost none, so + stacking them is what makes a short section tall for no reason. */} +
+ {/* --------------------------------------------------------- colour -- */} +
+ + Accent colour + +

+ Pick any colour. The theme adjusts it so buttons and labels stay readable in both light + and dark. +

+ + commit({ hue })} + /> + + {/* …and the same thing by name, because most of the time you know + which colour you want and do not want to hunt for it. */} +
+ + {NAMED_HUES.map((n) => { + const active = isCustom && Math.abs(activeHue - n.hue) < 2.5; + return ( + + ); + })} +
+
+ + {/* ----------------------------------------------------------- tint -- */} +
+ + Tint the background + +

+ Colours the whole page a little, not just the buttons. Useful for telling two Paddock + instances apart at a glance. +

+
+ {TINTS.map((t) => ( + + ))} +
+
+
+ + {/* ---------------------------------------------------------- preview -- */} + +
+ ); +} + +/** + * The accent, on the actual controls that wear it. + * + * This exists because of a mistake worth recording. The picker moved onto + * `/config` to stop it covering the app — and `/config` turns out to paint only + * **seven** elements with `--accent-solid`, the largest of which is the Save + * button, which is disabled and therefore muted whenever the form is clean. So + * changing the accent was genuinely, correctly applying and genuinely looking + * like nothing had happened. A control you cannot see the effect of is broken + * however correct its output. + * + * These are the real primitives (`Button`, `Chip`, `StatusDot`) with the real + * tokens, not a painted mock — which is the point: if the accent breaks a + * button, it breaks here too. The hover fill is shown as a plain swatch rather + * than a button, because you cannot force `:hover` and the hover step is + * exactly where the contrast inversion lived. + */ +function AccentPreview() { + return ( +
+

Preview

+
+ + + + + accent chip + + + running + + + + hovered + + + + + + + status hues (theme’s, not yours) + +
+
+ ); +} diff --git a/packages/web/src/components/ChangesPane.tsx b/packages/web/src/components/ChangesPane.tsx index ccea8b9b..043466c2 100644 --- a/packages/web/src/components/ChangesPane.tsx +++ b/packages/web/src/components/ChangesPane.tsx @@ -33,18 +33,18 @@ function statusBadge(file: GitFileChange): { label: string; cls: string } { const code = file.untracked ? "??" : (file.status || "").trim(); const head = code[0]?.toUpperCase() ?? ""; if (file.untracked || head === "?") - return { label: "??", cls: "bg-paddock-200/70 text-paddock-600 dark:bg-paddock-800 dark:text-paddock-300" }; + return { label: "??", cls: "bg-surface-active text-fg-muted" }; if (head === "A") - return { label: "A", cls: "bg-emerald-100 text-emerald-700 dark:bg-emerald-950/50 dark:text-emerald-400" }; + return { label: "A", cls: "bg-success-soft text-success" }; if (head === "D") - return { label: "D", cls: "bg-rose-100 text-rose-700 dark:bg-rose-950/50 dark:text-rose-400" }; + return { label: "D", cls: "bg-danger-soft text-danger" }; if (head === "R") - return { label: "R", cls: "bg-violet-100 text-violet-700 dark:bg-violet-950/50 dark:text-violet-400" }; + return { label: "R", cls: "bg-lineage-soft text-lineage" }; if (head === "M") - return { label: "M", cls: "bg-amber-100 text-amber-700 dark:bg-amber-950/50 dark:text-amber-400" }; + return { label: "M", cls: "bg-warn-soft text-warn" }; return { label: head || code || "•", - cls: "bg-paddock-200/70 text-paddock-600 dark:bg-paddock-800 dark:text-paddock-300", + cls: "bg-surface-active text-fg-muted", }; } @@ -195,18 +195,18 @@ export function ChangesPane({ return (
{/* Header: branch + uncommitted count + push, with the GitHub affordance. */} -
- - +
+ + {status.branch ?? "—"} {status.clean ? ( - + clean ) : ( - + {uncommitted} uncommitted )} @@ -229,7 +229,7 @@ export function ChangesPane({ {pushing ? "Pushing…" : "Push"} {hasRemote && ahead > 0 && ( - ↑{ahead} + ↑{ahead} )}
@@ -238,13 +238,13 @@ export function ChangesPane({ {(busyErr || note) && (
{busyErr && ( -
+
{busyErr}
)} {!busyErr && note && ( -

{note}

+

{note}

)}
)} @@ -253,11 +253,11 @@ export function ChangesPane({ {/* Left (top on mobile): changed-files list. Stacks above the diff on a phone — the list is height-capped so the diff still gets real estate; on lg+ it's the familiar fixed-width left column. */} -
+
Changed files {files.length > 0 && ( - + {selectedCount}/{files.length} selected @@ -274,7 +274,7 @@ export function ChangesPane({
{files.length === 0 ? ( -

+

No uncommitted changes. Files you author surface here for a checkpoint.

) : ( @@ -292,13 +292,13 @@ export function ChangesPane({
{/* Commit box. */} -
+