From 1d6ef346206715f959f4ddc9e968b9658ac00b1f Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Thu, 8 Oct 2026 08:06:38 +0800 Subject: [PATCH 1/5] docs: design-system manifest v1 + token sheet (DSX-0) Decisive token and block-chrome contract for the per-block-family redesign tasks: type ramp, motion scale + application table, radius roles, a single placeholder alpha, callout/code colour tokens, z-index rules, editor vs data palette boundary, formula-driven gutter geometry, KitFrame adoption and motion/colour/z lint guards. The token sheet is a self-contained HTML sign-off artifact rendering every decided value in light and dark. Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/design/design-system-manifest-2026-10.md | 446 ++++++++++++++ docs/design/token-sheet-2026-10.html | 572 ++++++++++++++++++ 2 files changed, 1018 insertions(+) create mode 100644 docs/design/design-system-manifest-2026-10.md create mode 100644 docs/design/token-sheet-2026-10.html diff --git a/docs/design/design-system-manifest-2026-10.md b/docs/design/design-system-manifest-2026-10.md new file mode 100644 index 000000000..aa2e62b9d --- /dev/null +++ b/docs/design/design-system-manifest-2026-10.md @@ -0,0 +1,446 @@ +# Design-system manifest v1 — tokens + block chrome (DSX-0, 2026-10) + +Status: **decisive contract, awaiting owner sign-off on the token sheet** +([`token-sheet-2026-10.html`](./token-sheet-2026-10.html) — open it in a browser; no build step). +This document is the authoritative contract for the per-block-family redesign tasks +that follow. A family task may not invent a token, a duration, a radius or a colour +literal; if it needs one, it amends this manifest first. + +Format and precedent: [`spacing-manifest-2026-08.md`](./spacing-manifest-2026-08.md) +(SPC-1, spacing + control heights — still in force and not restated here). Input: +the colour-consistency manifest (OB-375, `git show 15ee286a:docs/design/colour-consistency-manifest-2026-07.md`) +— its data palette (`packages/sdk/src/dataColors.ts`) and sidebar model are in force and +are not re-decided here. + +All file:line references are against `main` at `190c5504`, in `packages/ui/src/` unless +noted. Every contrast ratio below was computed (WCAG 2.1 relative luminance; text bar +4.5:1, non-text 3:1), not eyeballed. + +Benchmark: decisions name **the reference editor** where its behaviour informed the +call. Matching it is not a goal in itself; each row states our own reason. + +--- + +## 0. Global rules (apply to every section) + +| # | Rule | +|---|---| +| G1 | **Export parity.** Core block visuals stay plain CSS in `index.css` (comment at :932-935). Every token a core block reads is a CSS custom property declared in the plain `:root` / `.dark` layer (:215-350). `@theme` may *alias* a token for Tailwind utilities; core block CSS never depends on a Tailwind-generated variable or utility. Nothing here prescribes Tailwind for core block visuals. | +| G2 | **Static exports inline light values.** `export/toHtml.ts` cannot read live variables; every token it needs is mirrored as a light-mode literal *derived from the same table in this manifest*, with a unit test asserting equality (pattern: `COLOR_EXPORT_HEX`, `dataColors` export inlining). | +| G3 | **Token namespaces.** Editor/content tokens: `--obe-*`. Motion durations: `--motion-*`. Easings: existing `--ease-*`. Radius: existing `--radius-*` (+ one new step). z-index: existing `--z-index-*`. No other new prefixes. | +| G4 | **Mode pairs.** Any colour token that is not derived from a theme variable (`--foreground`, `--muted`, `--ring`, …) declares both a `:root` and a `.dark` value. A literal colour with no dark value is a defect. | +| G5 | **Sequencing.** A foundation change lands first (declare tokens §1-§5, global retunes §2.4, lint guards §10 at `warn`); family tasks then migrate their own selectors. Guards flip to `error` when their baseline reaches zero. | + +--- + +## 1. Type scale + +### 1.1 Tokens (declared in `:root`, editor section) + +Sizes are multiples of one root variable, so a scaling context (presentation mode, +`.ob-present-full .ob-slide` at :1706) overrides `--obe-font-size` only and the whole +ramp follows. Leadings are unitless. + +| Token | Expression | Resolved | Leading token | Line box | Weight | Space above | +|---|---|---:|---|---:|---:|---:| +| `--obe-font-size` (body) | `16px` | 16px | `--obe-leading: 1.5` | **24px** | 400 | — | +| `--obe-title-size` (page title) | `calc(var(--obe-font-size) * 2.5)` | 40px | `--obe-title-leading: 1.2` | 48px | 700 | — | +| `--obe-h1-size` | `calc(var(--obe-font-size) * 1.875)` | 30px | `--obe-h1-leading: 1.3` | 39px | 600 | `--obe-h1-space: 2rem` (32px) | +| `--obe-h2-size` | `calc(var(--obe-font-size) * 1.5)` | 24px | `--obe-h2-leading: 1.3` | 31.2px | 600 | `--obe-h2-space: 1.5rem` (24px) | +| `--obe-h3-size` | `calc(var(--obe-font-size) * 1.25)` | 20px | `--obe-h3-leading: 1.3` | 26px | 600 | `--obe-h3-space: 1rem` (16px) | +| `--obe-code-size` (code block) | `calc(var(--obe-font-size) * 0.875)` | 14px | `--obe-code-leading: 1.5` | 21px | 400 | — | +| `--obe-code-inline` (inline code) | `0.875em` | 0.875 × parent | inherits | — | 400 | — | +| `--obe-small-size` | `calc(var(--obe-font-size) * 0.875)` | 14px | `--obe-small-leading: 1.43` | 20px | 400 / 500 | — | +| `--obe-caption-size` | `calc(var(--obe-font-size) * 0.75)` | 12px | `--obe-caption-leading: 1.33` | 16px | 500 / 600 | — | + +Also: `--obe-line: calc(var(--obe-font-size) * var(--obe-leading))` (the body line box, 24px), +`--obe-heading-weight: 600`, `--obe-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace` +(replaces the three literal mono stacks, e.g. :1148, :1853). Uppercase eyebrows (caption +size) use `letter-spacing: 0.04em`. + +### 1.2 Decisions vs today + +| Item | Today | Decision | Reason | +|---|---|---|---| +| Body | `15px / 1.65` (:962-963) = 24.75px line | **16px / 1.5** = 24px line | Reading size matches the reference editor (16px/1.5); the line box stays ~24px, so vertical rhythm is preserved and the paragraph line box now equals the 24px gutter button exactly (§8.2). | +| Headings | `1.7 / 1.35 / 1.12rem` (:1143-1145), weight 650, margins 12/8/4px | **30 / 24 / 20px**, weight **600**, space above **32 / 24 / 16px** | A 1.875 / 1.5 / 1.25 ratio ramp (reference editor uses the same ratios). Weight 650 renders as 700 on non-variable system fonts (Segoe UI, Roboto), so 600 is the only weight that looks the same cross-platform. Larger space above restores section separation the 4px/8px margins lost. | +| Units | `rem` (ignores the 15px root) | multiples of `--obe-font-size` | One knob scales the ramp; gutter geometry (§8.2) can compute line boxes in absolute units. | +| Page title | `text-[2.5rem] font-bold leading-tight` (`screens/pageChrome.tsx:162`) | `--obe-title-size` 40px / 1.2 / 700 | Value unchanged; tokenized. | +| Secondary text zoo | 141 `font-size` declarations, ~30 distinct literals (0.65–0.95rem, 10–15px) | Every in-block secondary size maps to **small** (literal ≥ 0.82rem) or **caption** (literal < 0.82rem) | Two steps are enough for chrome inside a block (labels, captions, cells, chips). | + +Out of scope: app-chrome type (`text-[11px]` ×74, `text-[10px]` ×31 in TSX) — flagged for +a later pass; this manifest governs the document surface. + +--- + +## 2. Motion scale + +### 2.1 Tokens + +Durations are plain `:root` variables (Tailwind v4 has no duration theme namespace; +TSX consumes them as `duration-(--motion-base)`). Easings keep their existing `@theme` +names (:157-159) and are mirrored into `:root` per G1. + +| Token | Value | Role | +|---|---:|---| +| `--motion-fast` | **120ms** | Hover/press chrome, reveals, all exits, tooltips | +| `--motion-base` | **180ms** | Menus/popovers entrance, toggles, knobs, value changes | +| `--motion-slow` | **240ms** | Dialogs, drawers, full-surface transitions (slides) | +| `--ease-out-soft` | `cubic-bezier(0.32, 0.72, 0, 1)` | Default for everything that enters or changes | +| `--ease-in-out-soft` | `cubic-bezier(0.65, 0, 0.35, 1)` | Exits and ambient loops | +| `--ease-spring` | `cubic-bezier(0.34, 1.56, 0.64, 1)` | **Switch knobs only** (kit switch, `components/ui/switch`). Banned in the editor and on overlays. | + +`--default-transition-duration` (:155) is retuned **180ms → 120ms** (`= --motion-fast`): +the 230+ bare `transition*` utilities in TSX are overwhelmingly hover colour changes +(`transition-colors` ×156), which belong to the fast tier. 120ms is also the dominant +literal in `index.css` today (~30 of 44), so most CSS migration is a no-op rename. + +### 2.2 Application table + +| Interaction class | Duration | Easing | Animated properties | Today (examples) | +|---|---|---|---|---| +| Hover chrome: bg/colour/border on hover, gutter reveal, image/kit tool reveal | `--motion-fast` | `--ease-out-soft` | `color, background-color, border-color, opacity` | `120ms ease` :1032, `0.12s ease` :1301/:1337/:1384, `150ms` :3225 | +| Press (`:active`) | **0** (instant) | — | — | mixed | +| Tooltip (Radix + `.obe-kit-tip`) | enter/exit `--motion-fast` | out-soft / in-out-soft | **opacity only** (no zoom) | `zoom-in-95` in `components/ui/tooltip.tsx` | +| Menu / popover / select / context menu / slash menu / inline toolbar — **enter** | `--motion-base` | `--ease-out-soft` | opacity 0→1, scale 0.98→1, translate 2px from the anchor side | slash :2119-2141 (the model recipe); Radix `zoom-in-95`, no token duration | +| Same — **exit** | `--motion-fast` | `--ease-in-out-soft` | opacity only | `zoom-out-95` | +| Dialog / command palette / lightbox — enter | `--motion-slow` | `--ease-out-soft` | panel opacity + scale 0.98→1; scrim opacity | `components/ui/dialog.tsx:61` (180ms, zoom 95) | +| Same — exit | `--motion-fast` | `--ease-in-out-soft` | opacity | — | +| Drawer (narrow sidebar) | `--motion-slow` in / `--motion-fast` out | out-soft / in-out-soft | `transform` | Tailwind `duration-300`/`200` | +| **Block insert / delete / convert** (Enter, paste, slash convert, remote CRDT insert) | **none** | — | — | none today — keep | +| Toggle / collapse (accordion, toggle blocks, tabs container, tree disclosure) | chevron: `--motion-base` | `--ease-out-soft` | chevron `transform: rotate`; **content shows/hides instantly** | `0.15s ease` :791; Radix accordion `0.2s` :115-116 | +| Switch knob / kit toggle | `--motion-base` | `--ease-spring` (knob), out-soft (track colour) | `transform`, `background-color` | `150ms` :3237/:3225 | +| Value change (progress fill, meter) | `--motion-base` | `--ease-out-soft` | `width` / `transform` | `200ms ease` :3701 | +| Drag: drop indicator, source dim, drop | **none** (indicator tracks the pointer; no settle animation) | — | — | none — keep | +| Presentation slide enter | `--motion-slow` | `--ease-out-soft` | opacity + `translateY(16px)` → 0 | `340ms cubic-bezier(0.2,0.7,0.2,1)`, 18px :1705-1710 | + +**Block entrance: none.** Typing is the animation — a block must be caret-ready on the +frame Enter lands, and an entrance replayed on every paste of 200 blocks or every +remote insert is noise (the reference editor also inserts blocks with no entrance). + +**Attention signals are exempt from the scale** (they are timers, not feel): locate flash +1.2s (:2204), anchor flash 1.8s (:2218), flow pulse 0.7s (:2197), remote-cursor label +dwell 2.8s (:3606), skeleton shimmer 1.6s (:442). They keep their durations but must use +an `--ease-*` token instead of `ease-out`/`ease`. Adding a new one requires a row here. + +### 2.3 Reduced motion + +| Rule | Decision | +|---|---| +| Mechanism | The global kill switch at :410-419 (all durations → 0.01ms, iteration-count 1) is the **single** mechanism. State changes become instant; nothing else is needed. | +| Per-component `@media (prefers-reduced-motion: reduce)` | Allowed **only** to set a non-animated end state the kill switch can't infer (e.g. `.obe-rcursor-label { opacity: 1 }` :3613). Blocks that only say `animation: none` (e.g. :2141) are redundant — delete. | +| `no-preference` gating | Allowed for scroll-coupled attention effects (anchor flash :2216-2219 pattern). | +| JS motion | Smooth `scrollIntoView`, drag auto-scroll easing and any rAF animation must check `matchMedia('(prefers-reduced-motion: reduce)')` and fall back to `auto`/instant. | + +### 2.4 Global retunes (foundation change) + +1. `--default-transition-duration: 120ms` (§2.1). +2. One shared Radix overlay class constant (entrance/exit per §2.2) replaces the + per-file `zoom-in-95` strings in `components/ui/{dropdown-menu,context-menu,popover,select,tooltip,dialog}.tsx`. +3. `--animate-accordion-*` (:115-116) → `--motion-base` + `--ease-out-soft`. + +--- + +## 3. Radius scale + +| Step | Token | Value | Surfaces (exhaustive by role) | +|---|---|---:|---| +| none | `0` | 0 | Table cells, dividers, full-bleed media, the rail side of quote/notes shapes | +| sm | `--radius-sm` | 4px | **Inline marks** (inline code, mention, highlight runs, `@cell` token); **items inside a menu/list**; row hover/selection wash; marquee; table grips; small tool chips inside an overlay (image size chips) | +| md | `--radius-md` | 6px | **Standalone controls**: gutter buttons, toolbar buttons, icon buttons, inputs/selects/buttons inside blocks and forms, callout icon button, tooltips, drag ghost | +| lg | `--radius-lg` | 8px | **Block surfaces** (callout, code block, image frame + img, image placeholder, group, kit cards, embeds, artifact frames) and **floating menus** (popover, dropdown, select, context, slash, inline toolbar) | +| xl | `--radius-xl` **(new)** = `calc(var(--radius) + 4px)` | 12px | Dialogs, command palette, lightbox frame, presentation slide frame | +| pill | `9999px` (TSX `rounded-full`) | — | Tags/chips, switches, progress track + fill, status lamps; `50%` for true circles (avatars, presence dots) | +| page | `--ob-page-radius` | ≥10px | Notebook sheets only (window-concentric, :282) | + +Rules: + +- **Concentric nesting:** inner radius = outer radius − padding, floored at sm. A menu + (`p-1`, 4px) with sm items is therefore lg (4 + 4). Today's slash menu (6px container, + 7px items) violates this. +- `999px` normalizes to `9999px`. Literal `4px/6px/8px` become their tokens; any other + literal (`7px`, the toolbar's calculated 9px, `10px`) moves to the step its *role* + dictates, not the nearest number. +- Baseline at `190c5504`: 126 `border-radius` declarations, 42 tokened, 84 carrying a + `px` literal (31× `8px`, 21× `6px`, 8× `4px`, 17× `999px`, 3× `9999px`, …). + +--- + +## 4. Placeholder + +**One alpha: `0.6`.** Token: `--obe-placeholder: hsl(var(--muted-foreground) / 0.6)`. + +| Site | Today | After | +|---|---:|---:| +| `.obe-text:empty::before` (:1137-1141) | 0.45 | 0.6 | +| `[data-placeholder]:empty::before` (:671-674) | 0.7 | 0.6 | +| `.obe-image-alt/caption::placeholder` (:1362-1363) | 0.55 | 0.6 | +| `.block-*[data-placeholder]` (:732-739) | 0.6 | deleted (dead CSS, §11) | + +Computed contrast vs page: 0.45 = 1.85 (light) / 2.19 (dark) — effectively invisible +in dark mode; **0.6 = 2.35 / 2.87** (`#aaa9a6` / `#686868`); 0.7 = 2.80 / 3.41 competes +with real secondary text. 0.6 is quiet-but-findable in both modes. + +Scope boundary: **in-document hints** use `--obe-placeholder`. **Form-field placeholders** +in app chrome keep the existing opaque, AA-audited `--placeholder-foreground` (:230) — +a different role (a field label substitute, not a hint), not a second alpha. + +--- + +## 5. Colour tokens: callout tints + code syntax + +### 5.1 Editor palette hoisted to variables (no value change) + +The `.obe-fg-*` / `.obe-bg-*` / `.obe-hl-*` classes (:2299-2360) are re-expressed as +`--obe-fg-`, `--obe-bg-`, `--obe-hl-` (9 tokens × 3 roles, `:root` + `.dark`), +values **verbatim**; the classes read the variables. This lets callouts (and any family) +reference the palette by name. + +### 5.2 Callout tints + +Decision: **variant tints are the editor palette's block-background tints**; the default +(info) variant is the theme neutral. No border (the reference editor's callout is a +borderless tinted panel; borders double up with block-bg colours). + +| Token | Maps to | Light | Dark | `--foreground` on it (L / D) | +|---|---|---|---|---:| +| `--obe-callout-info` | `hsl(var(--muted))` | `#f6f5f4` | `#2f2f2f` | 11.86 / 8.75 | +| `--obe-callout-warn` | `var(--obe-bg-yellow)` | `hsl(45 90% 52% / 0.15)` ≈ `#fdf5dc` | `hsl(45 80% 55% / 0.18)` ≈ `#453d24` | 11.84 / 7.10 | +| `--obe-callout-success` | `var(--obe-bg-green)` | `hsl(140 55% 45% / 0.13)` ≈ `#e5f5ea` | `hsl(140 50% 50% / 0.18)` ≈ `#273e2e` | 11.41 / 7.62 | +| `--obe-callout-danger` | `var(--obe-bg-red)` | `hsl(0 72% 55% / 0.12)` ≈ `#fbe7e7` | `hsl(0 65% 55% / 0.2)` ≈ `#452828` | 10.90 / 8.68 | + +Replaces: `.obe-callout` `muted / 0.55` (`#fafaf9`, 1.04:1 vs page — barely visible), +and the literal `hsl(40 90% 55% / .12)` / `hsl(140 60% 45% / .1)` / `hsl(0 75% 55% / .09)` +(:1215-1217), which had **no dark values**. Export (`export/toHtml.ts:1400-1408`) +inlines the light column and drops its blue info tint and borders (see §11, finding F1). + +### 5.3 Code syntax tokens + +Hue-aligned to the editor palette (purple / green / orange / blue), light values +darkened until they pass 4.5:1 on the code-block surface. Surface: +`--obe-code-bg: hsl(var(--muted) / 0.6)` (`#f9f9f8` / `#2a2a2a`, unchanged), +`--obe-code-border: hsl(var(--border) / 0.7)` (unchanged). + +| Token | Light | on code bg | Dark | on code bg | Today light (ratio) | +|---|---|---:|---|---:|---| +| `--obe-code-com` (italic) | `hsl(var(--muted-foreground))` | 4.75 | same | 4.92 | same | +| `--obe-code-kw` | `hsl(265 55% 48%)` `#6f37be` | 6.73 | `hsl(265 65% 73%)` `#b38de7` | 5.43 | `265 60% 46%` (7.30) | +| `--obe-code-str` | `hsl(140 50% 30%)` `#267340` | 5.55 | `hsl(140 50% 60%)` `#66cc88` | 7.26 | `140 46% 32%` (5.20) | +| `--obe-code-num` | `hsl(28 80% 38%)` `#ae5c13` | 4.60 | `hsl(28 85% 62%)` `#f0994c` | 6.43 | `28 78% 42%` (**3.91 — fails**) | +| `--obe-code-lit` | `hsl(210 72% 42%)` `#1e6bb8` | 5.18 | `hsl(210 75% 67%)` `#6cabea` | 5.94 | `210 72% 45%` (4.63) | + +Dark values are exactly the editor palette's dark `fg` values for those hues, so dark +code reads as the same family as coloured prose. + +--- + +## 6. z-index usage rules + +The scale (:131-145) is complete; this section only governs use. + +| # | Rule | +|---|---| +| Z1 | Every `z-index` in CSS is `var(--z-index-*)`; every TSX z utility is a named tier (`z-menu`, `z-sticky`, …). No numerals, no `z-[n]`. Baseline: 1 CSS literal (`.obe-col-divider` `z-index: 4` :3880 → `--z-index-pane-overlay`, value-preserving); **43** TSX numeric uses (`z-50` ×17, `z-10` ×13, `z-[1]` ×5, `z-20` ×3, …). | +| Z2 | **Local tiers 1–6** (`raised` … `presence`) are only meaningful inside a block's own stacking context. Block chrome uses only local tiers. | +| Z3 | Anything that must escape the page (menus, popovers, tooltips, dialogs) **portals** and uses a shared tier ≥ 40. A block never raises a local element to a shared tier to "win". | +| Z4 | Same-tier overlap is resolved by **portal/DOM order** (later mounts on top), never by inventing a tier. Tooltips share `menu` (50). | +| Z5 | New tiers require a manifest amendment and an entry in the comment block at :121-130. | +| Z6 | Do not create stacking contexts on `.obe-editor-pane` / `.obe-editor-wrap` (`transform`, `filter`, `contain`) — it breaks fixed popups (:937-942). | + +--- + +## 7. Editor palette vs data palette boundary + +| | Editor palette | Data palette | +|---|---|---| +| Source | `blockeditor/colors.ts` (`COLOR_TOKENS`, 9) + `--obe-fg/bg/hl-*` | `sdk/src/dataColors.ts` (12 tokens × pastel/vivid/muted) + `lib/dataColorVars.ts` (`--data-*`) | +| Meaning | **Authored emphasis** stored in the document | **Encodes data**: category, series, status | +| Chosen by | The author (persisted per block/run) | The viewer's appearance preference | +| Consumers | Text colour, highlight, block bg/fg, **callout tints**, **code syntax hues** | Select/tag chips, chart series, status lamps, swatch dots, kanban/board headers | + +**Rule:** if the colour carries meaning *about data*, it reads `--data-*`; if it is +emphasis an author placed on prose or a block, it reads `--obe-*`. Never cross: +chips never use `obe-bg-*`; prose never reads `--data-*`. Single-value meters and +controls (progress fill, switch on, slider track) are **neither** — they use `--primary`. +Shared hue *names* stay aligned (one mental model), values do not. + +--- + +## 8. Block-chrome contract + +### 8.1 Chrome tokens + +| Token | Value | Replaces | +|---|---|---| +| `--obe-gutter-btn` | `24px` (= `--height-control-xs`) | `1.5rem` :1079-1080 | +| `--obe-gutter-gap` | `4px` | :1029 | +| `--obe-gutter-clear` | `4px` (gutter → text) | implicit | +| `--obe-gutter-room` | `calc(2 * var(--obe-gutter-btn) + var(--obe-gutter-gap) + var(--obe-gutter-clear))` = **56px** | `3.4rem` (54.4px) :944, :1023 | +| `--obe-chrome-ink` | `hsl(var(--muted-foreground) / 0.8)` — 3.36 / 4.02 vs page (≥ 3:1 non-text) | `/ 0.7` (2.80 light — fails 3:1) :1084 | +| `--obe-chrome-ink-hover` | `hsl(var(--foreground))` | same | +| `--obe-chrome-hover` | `var(--hover)` | `hsl(var(--accent))` :1090 (two competing recipes today) | +| `--obe-chrome-active` | `var(--hover-strong)` | — (menu-open state had none) | +| `--obe-select-wash` | `:root` `hsl(var(--ring) / 0.14)` (`#e0eefa`), `.dark` `hsl(var(--ring) / 0.2)` (`#2b3c49`) | `accent / 0.55` + `0 0 0 2px ring / 0.35` :983-986; marquee fill :994; cell selection `/0.16` :1999 | +| `--obe-drop` | `hsl(var(--primary))`, drawn **2px solid** (3.81 / 6.11 vs page) | `2px dashed` :1113, :1121 (tables already solid :2111-2114) | +| `--obe-drag-dim` | `0.4` | table `0.45` :2116; block rows none | +| `--obe-focus-ring` / `--obe-focus-offset` | `2px solid hsl(var(--ring))` / `2px` | offset `1px` :2450 vs `2px` :2108, :3949 | + +### 8.2 Gutter geometry — one rule replaces the per-type offsets + +The hand-tuned `top` values at :1063-1071 are replaced by a single formula on +`.obe-gutter`; each block family **declares two variables on its own row** from its +**own layout tokens**: + +``` +.obe-gutter { + top: calc(var(--obe-lead-offset, 0px) + + (var(--obe-lead-line, var(--obe-line)) - var(--obe-gutter-btn)) / 2); +} +/* in the family's own section, e.g. headings: */ +.obe-row[data-block-type='heading'][data-block-level='1'] { + --obe-lead-offset: var(--obe-h1-space); + --obe-lead-line: calc(var(--obe-h1-size) * var(--obe-h1-leading)); +} +``` + +- `--obe-lead-offset` = distance from the row's top edge to the top of the first line box + (margins + border + padding + any header row above the first line). +- `--obe-lead-line` = the first line's box height. +- Both are expressions over tokens the block already uses for its own layout. **A bare + literal in either is a review failure.** The gutter rule contains **no** per-type selector. + +| Family | `--obe-lead-offset` | `--obe-lead-line` | Resolved `top` (new ramp) | Formula on today's ramp vs hand-tuned | +|---|---|---|---:|---| +| paragraph / list / todo / quote | `0` | `--obe-line` (24px) | **0** | 0 vs 0 | +| h1 | `--obe-h1-space` | h1 size × leading (39px) | **39.5px** | 17.0 vs 18.4px | +| h2 | `--obe-h2-space` | 31.2px | **27.6px** | 10.0 vs 11.2px | +| h3 | `--obe-h3-space` | 26px | **17px** | 4.1 vs 6.4px | +| callout | callout `padding-top` (12px) | `--obe-line` | **12px** | — vs 13.6px | +| code | border + block padding + actions-row height + its gap | code size × leading (21px) | family computes | — vs 48px | +| table (with grips) | the family's grip offset (may be negative) | grip height | family computes | — vs −4px | + +(The "today" column shows the formula agrees with the hand-tuned values within 2.3px — +evidence the hand tuning was approximating this rule.) + +Horizontal: full gutter `left: calc(-1 * var(--obe-gutter-room))`. **Handle-only** +gutter (narrow pane `@container (max-width: 47.8rem)` :1054-1057 **and** nested columns +:1037-1040) is one variant: `left: calc(-1 * var(--obe-gutter-btn))` = −24px (replaces +`-1.5rem` and `-1.45rem`). Touch (`pointer: coarse`) and read-only keep hiding the gutter. + +### 8.3 States + +| State | Trigger | Visual (exact) | Motion | +|---|---|---|---| +| Rest | — | gutter `opacity: 0; pointer-events: none`; **row has no background** | — | +| Row hover / focus-within | `.obe-row:hover`, `:focus-within`, `.obe-gutter:focus-within` | gutter `opacity: 1; pointer-events: auto`. Row still has no background (reference editor: hover reveals the handle, never tints the block). | `--motion-fast` opacity | +| Gutter button hover | `:hover` | bg `--obe-chrome-hover`, ink `--obe-chrome-ink-hover`, radius `--radius-md` | `--motion-fast` | +| Gutter button active | handle menu open (`aria-expanded="true"` / `[data-state='open']`) | bg `--obe-chrome-active`, gutter pinned visible | instant | +| Keyboard focus (chrome) | `:focus-visible` | `outline: var(--obe-focus-ring); outline-offset: var(--obe-focus-offset)`; gutter pinned visible (:2454 pattern) | instant | +| Text focus | caret in block | **no row decoration** — the caret is the indicator | — | +| Selected | block in `editor.selection` | row bg `--obe-select-wash`, radius `--radius-sm`, **no outline / box-shadow** (stacked 2px rings double up between adjacent rows) | instant | +| Marquee | rubber-band drag | fill `--obe-select-wash`, `1px solid hsl(var(--ring) / 0.65)`, radius sm, `--z-index-local-overlay` | none | +| Drag source | rows being dragged | `opacity: var(--obe-drag-dim)`. **Never `pointer-events: none` on a draggable.** | instant | +| Drop indicator | `.obe-drop-{above,below,left,right}` | `::after`, `2px solid var(--obe-drop)`, `--z-index-drop-indicator`, absolutely positioned (no layout shift); above/below span the row, left/right span its height | none | +| Drag ghost | multi-block drag image | `--primary` / `--primary-foreground`, radius md, `box-shadow: var(--shadow-menu)` (replaces literal `0 2px 8px …` :1013) | — | + +Application chrome (Tailwind) keeps `--ring-control` / `--ring-field` (:293-294); +editor chrome (plain CSS) uses the outline recipe above. One ring colour (`--ring`), two +idioms by layer. + +--- + +## 9. KitFrame adoption + +`blockeditor/kit/KitFrame.tsx:318` owns the custom-block shell: root `obe-kit obe-kit-{kind}`, +header (label + description), the settings gear (`KitSettings`), name/description fields, +and the wide toggle. + +**Rule: every block registered through `kit/index.ts` and every app-level custom block +with a settings gear renders through `KitFrame`.** Core blocks (text, heading, list, +todo, quote, callout, code, divider, image, table, columns, group, database embed) +**never** use it — they are plain-CSS export-parity blocks. + +| Block | File | Today | Required | +|---|---|---|---| +| number, text input, select, checkbox, … | `kit/inputs.tsx` | KitFrame | — | +| cards/choice, tags, … | `kit/inputs2.tsx` | KitFrame | — | +| slider | `reactiveBlocks.tsx:65` | KitFrame | — | +| form | `FormBlockView.tsx:181` | KitFrame (`symbol={false}`) | — | +| meeting | `MeetingBlockView.tsx:115` | KitFrame | — | +| **statuslight** | `kit/cards.tsx:35` | hand-rolled root + `KitSettings` | **adopt**: `symbol={false}`, header = label | +| **tooltipcard** | `kit/cards.tsx:84` | hand-rolled | **adopt**: `symbol={false}`, `hideHeader` (the term labels itself) | +| **linkcard** | `kit/cards.tsx:126` | hand-rolled | **adopt**: `symbol={false}`, `hideHeader` (the card labels itself) | +| **progressbar** | `kit/progress.tsx:36` | hand-rolled | **adopt**: `symbol={false}`, header = label; value readout stays in `control` | +| **kitchart** | `kit/charts.tsx:1522` | hand-rolled `
` + own caption | **adopt**: KitFrame gains a figure-root option so its header renders as the `figcaption` (accessible name preserved) | + +Acceptance: `grep -n "KitSettings" blockeditor/kit/*.tsx` matches only `KitFrame.tsx` +(today it also matches `cards.tsx`, `progress.tsx`, `charts.tsx`). + +--- + +## 10. Lint-guard spec + +Today only spacing and radius are guarded (`packages/ui/stylelint.config.mjs`; +`eslint-rules/no-arbitrary-spacing.mjs`, `no-hover-geometry.mjs`). Each new guard below +ships with positive/negative controls in `scripts/assert-spacing-stylelint.mjs` (or a +sibling) / a `*.test.mjs` like the existing rules, starts at **warn** with the baseline +recorded, and flips to **error** when the baseline is zero. Disables require a +description (`reportDescriptionlessDisables` is already on). Custom-property declarations +(`--*`) are never matched, so token blocks are unaffected. + +### 10.1 stylelint (`src/index.css`) + +| Guard | Rule | Allowed | Rejected | Baseline | +|---|---|---|---|---:| +| Motion durations | `declaration-property-value-disallowed-list` on `/^(transition|animation)(-duration|-delay)?$/` | `var(--motion-*)`, `0s` | `/\b\d*\.?\d+m?s\b/` (any literal time) | 44 | +| Easings | same, on `transition`, `animation`, `*-timing-function` | `var(--ease-*)`, `linear`, `steps()` | `/\bease(-in|-out|-in-out)?\b/`, `/cubic-bezier\(/` | ~40 | +| Colour literals | `declaration-property-value-disallowed-list` on `color, background, background-color, border*, outline*, fill, stroke, box-shadow, caret-color, text-decoration*, accent-color` | `hsl(var(--…) [/ a])`, `var(--…)`, `transparent`, `currentColor`, `inherit`, system colours (`Highlight`) | `/#[0-9a-f]{3,8}\b/i`, `/\b(?:hsla?|rgba?)\(\s*[0-9.]/i` | 99 | +| z-index | `declaration-property-value-allowed-list` `z-index` | `var(--z-index-*)`, `0`, `auto`, `-1` | numerals | 1 | +| Radius (tightened) | update `radiusAtom` | `var(--radius-*)`, `var(--ob-page-radius)`, `0`, `50%`, `9999px` | remove the `4px/6px/8px/999px/calc(var(--radius) - 2px)` allowances | 84 | +| Font size (editor) | `declaration-property-value-allowed-list` `font-size` in `.obe-*` | `var(--obe-*-size)`, `var(--obe-code-inline)`, `inherit`, `em` ≤ 1 | other literals | ~120 | +| Reduced-motion blocks | assert script counts `prefers-reduced-motion: reduce` occurrences | the global block + listed end-state exceptions (§2.3) | any other | 4 | + +### 10.2 ESLint (TSX, `packages/ui/src`, `packages/web/src`) + +| Rule (new file in `eslint-rules/`) | Flags | Allowed | Baseline | +|---|---|---|---:| +| `tailwind/no-arbitrary-motion` | `duration-`, `duration-[…]`, `delay-`, `ease-(in|out|in-out|linear)`, `ease-[…]`, `animate-[…]`, raw `zoom-in-95`/`zoom-out-95` outside the shared overlay constant | `duration-(--motion-*)`, `ease-out-soft`, `ease-in-out-soft`, `ease-spring` (switch files only), bare `transition*` (reads the retuned default) | 13 durations + 3 easings + 15 files with raw zoom | +| `tailwind/no-palette-color` | Tailwind palette literals `(bg|text|border|ring|fill|stroke|outline|from|via|to|decoration|caret|accent)-(slate|gray|…|rose)-\d{2,3}`, arbitrary colour brackets `-[#…]`/`-[rgb(…)]`/`-[hsl(…)]`, and hex/rgb literals in JSX `style={{…}}` colour keys | semantic utilities (`bg-muted`, `text-muted-foreground`, …), `var(--data-*)` via `dataColorVars` helpers, `var(--obe-*)` | 208 palette utilities | +| `tailwind/no-raw-z` | `z-`, `z-[…]` | named tiers (`z-menu`, `z-overlay`, …) | 43 | + +--- + +## 11. Findings recorded while auditing (not decisions — handed to the owning family) + +| # | Finding | Owner | +|---|---|---| +| F1 | **Export bug:** the editor stores callout variant `warn` (`BlockEditor.tsx:2518`), but `export/toHtml.ts:1402/1406` selects `data-variant=warning` — warn callouts export with the **blue info tint**. Fix with §5.2 export mirroring. | callout family | +| F2 | **Dead CSS:** `index.css:727-930` (`.block-callout/-accordion/-divider/-button/-toc`) has no TS/TSX consumer in any package. Delete (removes a fourth placeholder alpha and literal callout hues). | foundation | +| F3 | **Editor palette contrast:** `.obe-fg-orange` (4.08) and `.obe-fg-yellow` (3.92) fail 4.5:1 as text on the light page; `COLOR_EXPORT_HEX` (`colors.ts:35`) has drifted from the CSS values (e.g. gray `#6b7280` vs `hsl(0 0% 45%)` `#737373`). §5.1 hoists values verbatim; the fix belongs to a palette audit (open question 3). | foundation / palette | +| F4 | `.obe-gutter` comment (:1024) references "`.obe-row`'s top padding" that no longer exists (`padding: 0` :981). Removed by §8.2. | chrome family | + +--- + +## 12. Family checklist (every redesign task) + +1. Only tokens from this manifest (+ SPC-1 spacing/heights); amend here before adding one. +2. Declare `--obe-lead-offset` / `--obe-lead-line` on the family's own row selector (§8.2). +3. Motion per §2.2; no block entrance; reduced motion via the global switch only. +4. Radius by role (§3); concentric nesting. +5. Colours: editor vs data boundary (§7); every non-derived colour has a dark value (G4). +6. Kit blocks through `KitFrame` (§9). +7. Exports: mirror light values with an equality test (G2); re-run the stylelint/ESLint + baselines and report the delta. + +## 13. Open questions (recommended default in bold) + +1. **Body 15 → 16px and heading space 12/8/4 → 32/24/16px** is the largest visible change + in this manifest (line box stays ~24px). *Recommend: accept* — it is the single biggest + contributor to the benchmark's readability; the alternative (keep 15px, adopt only the + heading ratios) is a one-token change if the owner prefers density. +2. **Charts adopt KitFrame via a figure-root option** (§9) rather than staying exempt. + *Recommend: adopt* — the gear, wide toggle and header behaviour are otherwise duplicated + in `charts.tsx`; the figure root keeps `figcaption` semantics. +3. **Editor-palette contrast + export drift (F3)** are recorded, not fixed, here. + *Recommend: spin out a short palette-audit task before the text-family redesign*, so + callout tints (§5.2) and text colours change once, together. diff --git a/docs/design/token-sheet-2026-10.html b/docs/design/token-sheet-2026-10.html new file mode 100644 index 000000000..49374b825 --- /dev/null +++ b/docs/design/token-sheet-2026-10.html @@ -0,0 +1,572 @@ + + + + + +OpenBook token sheet — DSX-0 (2026-10) + + + + +
+
+

OpenBook token sheet

+

DSX-0 · design-system manifest v1 · 2026-10 · visual sign-off for docs/design/design-system-manifest-2026-10.md

+ +
+ + +
+

Type ramp §1

+

All sizes are multiples of --obe-font-size: 16px. The tinted bands under the body sample are the 24px line box (--obe-line), which equals the gutter button height.

+
--obe-title-size40 / 48 · 700
Quarterly planning
page title (unchanged value)
+
--obe-h1-size30 / 39 · 600 · space 32
Heading one
was 27.2px · 650 · space 12 changed
+
--obe-h2-size24 / 31.2 · 600 · space 24
Heading two
was 21.6px · 650 · space 8 changed
+
--obe-h3-size20 / 26 · 600 · space 16
Heading three
was 17.9px · 650 · space 4 changed
+
--obe-font-size16 / 24 · 400
Body text sits on a 24px line box. Inline code like seriesColor(i) is 0.875em of its parent, so it scales inside headings too. A second line shows the rhythm.
was 15px / 1.65 (24.75px) changed
+
--obe-code-size14 / 21 · mono
const total = rows.reduce((a, r) => a + r.value, 0);
was 0.85rem / 1.55
+
--obe-small-size14 / 20
Image captions, kit labels, table cells, chip text.
absorbs literals ≥ 0.82rem
+
--obe-caption-size12 / 16 · 600 · +0.04em
Speaker note · Contents
absorbs literals < 0.82rem
+
+ + +
+

Motion §2

+

Three durations × three easings. Each dot travels for exactly its token's duration, then holds. Spring is shown for completeness. It is allowed on switch knobs only.

+

Your OS has reduce motion on, so animations are paused here, as the product does via its global kill switch (§2.3).

+
+
--ease-out-soft (default)
--ease-in-out-soft (exits, loops)
--ease-spring (knobs only)
+
--motion-fast120ms · hover, reveal, exits, tooltips
+
+
--motion-base180ms · menus in, toggles, values
+
+
--motion-slow240ms · dialogs, drawers, slides
+
+
+ +
+
+

Hover chrome

+
fast · out-soft · colour/bg · press = 0
+ ⋮⋮ Hover me + and me +

Real hover. Press is instant.

+
+
+

Menu / popover

+
in: base · out-soft · fade + scale .98 + 2px
out: fast · in-out-soft · fade only
+ +
+
+

Dialog

+
in: slow · out-soft · fade + scale .98
out: fast · in-out-soft · radius xl
+
+
Delete page?
This can be undone from Trash.
+
+
+

Toggle / collapse

+
chevron: base · out-soft · content: instant
+
› Meeting notes
+
Content shows on the frame. Only the chevron moves.
+
+
+

Switch knob

+
base · knob spring · track out-soft
+
+

The only sanctioned use of --ease-spring.

+
+
+

Block insert

+
none: caret-ready on the frame Enter lands
+
First paragraph
+
Second (Enter)
+
Third (Enter)
+
Pasted block
+
+
+
+
+

Presentation slide

+
slow · out-soft · 16px rise
+
Slide 3: Results
+
+
+

No motion

+
drag + drop
+

Drop indicators track the pointer with no transition; the drag source dims instantly to 0.4; nothing settles after drop. Attention signals (locate flash 1.2s, anchor flash 1.8s, flow pulse 0.7s, cursor-label dwell 2.8s, skeleton 1.6s) are timers, so the scale does not apply to them. They still use an --ease-* token.

+
+
+
+ + +
+

Radius §3

+

Assigned by surface role, not nearest number. Nesting is concentric: inner = outer − padding, never below sm.

+
+
none · 0table cells, dividers, rail side of quotes
+
sm · 4pxinline marks, menu items, selection wash, marquee, grips
+
md · 6pxstandalone controls: gutter + toolbar buttons, inputs, tooltips
+
lg · 8pxblock surfaces + floating menus (4 item + 4 pad)
+
xl · 12px newdialogs, command palette, lightbox, slides
+
pill · 9999pxchips, switches, progress, lamps (50% for circles)
+
+
+ + +
+

Placeholder §4

+

One alpha: --obe-placeholder: hsl(var(--muted-foreground) / 0.6). Rejected alphas shown for comparison.

+ + + + + +
AlphaLightDarkRatio vs page (L / D)
0.45Type '/' for commandsType '/' for commands1.85 / 2.19.obe-text today
0.62.35 / 2.87decided
0.7Type '/' for commandsType '/' for commands2.80 / 3.41[data-placeholder] today
+
+ + +
+

Callout tints + code syntax §5

+

Callout variants reuse the editor palette's block-background tints (info = theme neutral). They have no border, and every value has a dark pair. Syntax hues align with the editor palette, and every light value passes 4.5:1 on the code surface.

+ + + + + + + + + + + +
TokenLightDarkRatio (L / D)
--obe-callout-info--muted #f6f5f4#2f2f2ffg 11.86 / 8.75
--obe-callout-warnbg-yellow ≈ #fdf5dc≈ #453d24fg 11.84 / 7.10
--obe-callout-successbg-green ≈ #e5f5ea≈ #273e2efg 11.41 / 7.62
--obe-callout-dangerbg-red ≈ #fbe7e7≈ #452828fg 10.90 / 8.68
--obe-code-kw265 55% 48%265 65% 73%6.73 / 5.43
--obe-code-str140 50% 30%140 50% 60%5.55 / 7.26
--obe-code-num was 3.9128 80% 38%28 85% 62%4.60 / 6.43
--obe-code-lit210 72% 42%210 75% 67%5.18 / 5.94
--obe-code-com--muted-foreground--muted-foreground4.75 / 4.92
+ +
+
+
Light
+
💡
Info uses the theme neutral and follows the accent's grey temperature.
+
⚠️
Warn reads the editor palette's yellow block tint.
+
✅
Success reads the green block tint.
+
🚫
Danger reads the red block tint.
+
// running total of the visible rows +const limit = 42; +export function total(rows, label = 'sum') { + if (!rows) return null; + return rows.length > limit ? true : 0.5; +}
+
+
+
Dark
+
💡
Info uses the theme neutral and follows the accent's grey temperature.
+
⚠️
Warn reads the editor palette's yellow block tint.
+
✅
Success reads the green block tint.
+
🚫
Danger reads the red block tint.
+
// running total of the visible rows +const limit = 42; +export function total(rows, label = 'sum') { + if (!rows) return null; + return rows.length > limit ? true : 0.5; +}
+
+
+
+ + +
+

z-index ladder §6

+

The scale is unchanged. Shaded rows are local tiers, which only have meaning inside a block's own stacking context. Same-tier overlap resolves by portal order. Never add a tier to win an overlap.

+
+
100
toast
notifications
+
60
lightbox
image lightbox (reserved)
+
55
nested-menu
submenus above their z-50 parent
+
50
menu
menus, popovers, dialogs, tooltips, slash, inline toolbar
+
45
drawer
narrow sidebar
+
40
overlay
navigation scrims
+
30
raised-chrome
timeline-local only
+
20
inline-hint
.obe-kit-tip
+
10
sticky
sticky chrome
+
6
presence
remote cursors
+
5
local-overlay
marquee, chart tooltip, nested gutter
+
4
pane-overlay
column divider (was literal 4)
+
3
drop-indicator
drop lines
+
2
control
in-block controls
+
1
raised
in-block raise
+
+
+ + +
+

Editor palette vs data palette §7

+

Editor palette = authored emphasis stored in the document (text, highlight, block bg, callouts, syntax hues). Data palette = encodes data (chips, series, status) and follows the viewer's scheme. Never cross. Meters and controls use --primary.

+
+
+
Editor palette · light
+
+ fg + AaAaAaAaAaAaAaAaAa + bg + + hl + ababababababababab +
+

gray · brown · orange · yellow · green · blue · purple · pink · red. Values are verbatim from index.css (hoisted to variables). Orange and yellow text fall below 4.5:1 (finding F3).

+
+
+
Editor palette · dark
+
+ fg + AaAaAaAaAaAaAaAaAa + bg + + hl + ababababababababab +
+
+
+
+
Data palette · pastel fills (default scheme, mode-invariant): owned by sdk/src/dataColors.ts, not re-decided here
+
+ fill + +
+

Series order: blue · orange · green · red · purple · cyan · yellow · teal · pink · indigo · brown · gray.

+
+
+ + +
+

Block chrome §8

+

The gutter is positioned by one formula, top = lead-offset + (lead-line − 24px) / 2, using variables each family declares from its own tokens. No per-type tuning. The dashed tick marks where each gutter is centred. Hover the gutter buttons for live states.

+
+
+
Light
+
+
+⋮⋮
Heading one
+
+⋮⋮
Paragraph, with the handle menu open (active).
+
A selected block: wash only, radius sm, no ring.
+
Adjacent selection stays one continuous wash.
+
+⋮⋮
Heading two
+
Drag source (0.4) above a 2px solid drop line
+
+⋮⋮
Heading three
+
+⋮⋮
💡
Callout: lead-offset = its 12px padding.
+
+
+
+
+
Dark
+
+
+⋮⋮
Heading one
+
+⋮⋮
Paragraph, with the handle menu open (active).
+
A selected block: wash only, radius sm, no ring.
+
Adjacent selection stays one continuous wash.
+
+⋮⋮
Heading two
+
Drag source (0.4) above a 2px solid drop line
+
+⋮⋮
Heading three
+
+⋮⋮
💡
Callout: lead-offset = its 12px padding.
+
+
+
+
+ + + + + + + + + +
TokenValueUse
--obe-gutter-btn / -gap / -clear24px / 4px / 4pxgutter room = 2×24 + 4 + 4 = 56px; handle-only (narrow, nested) left = −24px
--obe-chrome-inkmuted-foreground / 0.8gutter icons at rest: 3.36 / 4.02 (was 0.7: 2.80, failed 3:1)
--obe-chrome-hover / -active--hover / --hover-strongbutton hover / menu-open
--obe-select-washring / 0.14 · dark 0.2block selection, marquee fill, table cell selection
--obe-drop--primary, 2px solidall drop indicators (blocks + tables)
--obe-focus-ring2px solid ring · offset 2pxeditor chrome :focus-visible; app chrome keeps --ring-control
--obe-drag-dim0.4drag source; never pointer-events: none
+
+
+ + From 87c58c03cf5cf65b8d33330eb5fdc728e280d73b Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Fri, 9 Oct 2026 09:06:05 +0800 Subject: [PATCH 2/5] feat(ui): establish design foundation tokens and overlay motion --- .../__tests__/gutterStyles.test.ts | 2 +- .../ui/src/components/ui/context-menu.tsx | 8 +- packages/ui/src/components/ui/dialog.tsx | 9 +- .../ui/src/components/ui/dropdown-menu.tsx | 8 +- .../ui/src/components/ui/overlay-motion.ts | 8 + packages/ui/src/components/ui/popover.tsx | 4 +- packages/ui/src/components/ui/select.tsx | 8 +- packages/ui/src/components/ui/switch.tsx | 4 +- packages/ui/src/components/ui/tooltip.tsx | 4 +- .../export/__tests__/foundationTokens.test.ts | 49 ++ packages/ui/src/export/toHtml.ts | 25 +- packages/ui/src/index.css | 795 ++++++++---------- 12 files changed, 435 insertions(+), 489 deletions(-) create mode 100644 packages/ui/src/components/ui/overlay-motion.ts create mode 100644 packages/ui/src/export/__tests__/foundationTokens.test.ts diff --git a/packages/ui/src/blockeditor/__tests__/gutterStyles.test.ts b/packages/ui/src/blockeditor/__tests__/gutterStyles.test.ts index 1ce6c5b17..fe93753ae 100644 --- a/packages/ui/src/blockeditor/__tests__/gutterStyles.test.ts +++ b/packages/ui/src/blockeditor/__tests__/gutterStyles.test.ts @@ -115,7 +115,7 @@ describe('column resize styles', () => { it('keeps a raised, touch-safe one-rem hit zone with a two-pixel hover rule', () => { const divider = ruleBody('.obe-col-divider'); - expect(divider).toMatch(/z-index:\s*4/); + expect(divider).toMatch(/z-index:\s*var\(--z-index-pane-overlay\)/); expect(divider).toMatch(/width:\s*1rem/); expect(divider).toMatch(/touch-action:\s*none/); expect(ruleBody('.obe-col-divider::after')).toMatch(/width:\s*2px/); diff --git a/packages/ui/src/components/ui/context-menu.tsx b/packages/ui/src/components/ui/context-menu.tsx index 9c20cf239..d4327cb71 100644 --- a/packages/ui/src/components/ui/context-menu.tsx +++ b/packages/ui/src/components/ui/context-menu.tsx @@ -1,3 +1,4 @@ +import { OVERLAY_MOTION } from "./overlay-motion" import * as React from "react" import * as ContextMenuPrimitive from "@radix-ui/react-context-menu" import { CheckIcon, ChevronRightIcon } from "@radix-ui/react-icons" @@ -49,7 +50,8 @@ const ContextMenuSubContent = React.forwardRef< , DialogContentProps @@ -58,7 +60,8 @@ const DialogContent = React.forwardRef< className={cn( // `min-w-0 [&>*]:min-w-0` removes the panel/direct-child auto minimums so long children cannot widen it past max-w-*; deep content still needs min-w-0/truncate/break-*. // The body may scroll; floating content is portaled so it is not clipped. A future direct child's min-w-* loses to `[&>*]:min-w-0` by utility order, not intent. - "relative grid max-h-[calc(100vh-4rem)] min-w-0 w-full gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-overlay duration-[var(--default-transition-duration)] ease-[var(--ease-out-soft)] [&>*]:min-w-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95", + "relative grid max-h-[calc(100vh-4rem)] min-w-0 w-full gap-4 overflow-y-auto rounded-xl border bg-background p-6 shadow-overlay [&>*]:min-w-0", + OVERLAY_MOTION.dialog, dialogContentSizes[size], className )} diff --git a/packages/ui/src/components/ui/dropdown-menu.tsx b/packages/ui/src/components/ui/dropdown-menu.tsx index 2d7a8b399..9a0b2f4d6 100644 --- a/packages/ui/src/components/ui/dropdown-menu.tsx +++ b/packages/ui/src/components/ui/dropdown-menu.tsx @@ -1,3 +1,4 @@ +import { OVERLAY_MOTION } from "./overlay-motion" import * as React from "react" import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu" import { @@ -57,7 +58,8 @@ const DropdownMenuSubContent = React.forwardRef< ref={ref} collisionPadding={collisionPadding} className={cn( - "z-50 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:pointer-events-none data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 min-w-32 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-overlay", + OVERLAY_MOTION.menu, density === "compact" ? "p-0.5" : "p-1", className )} @@ -84,8 +86,8 @@ const DropdownMenuContent = React.forwardRef< // the space below the trigger — the unified page menu (CM-2) can run // past the fold, and overflow-hidden would strand its last items (e.g. // "Move to trash") off-screen and unclickable. - "z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),calc(100vh-20px))] min-w-32 overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-popover text-popover-foreground shadow-overlay", - "data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:pointer-events-none data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 max-h-[min(var(--radix-dropdown-menu-content-available-height),calc(100vh-20px))] min-w-32 overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-popover text-popover-foreground shadow-overlay", + OVERLAY_MOTION.menu, density === "compact" ? "p-0.5" : "p-1", className )} diff --git a/packages/ui/src/components/ui/overlay-motion.ts b/packages/ui/src/components/ui/overlay-motion.ts new file mode 100644 index 000000000..8c93f68e4 --- /dev/null +++ b/packages/ui/src/components/ui/overlay-motion.ts @@ -0,0 +1,8 @@ +// DSX §2: exits fade only; menus enter 2px from their anchor at scale .98. +const fade = 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=closed]:pointer-events-none data-[state=closed]:duration-(--motion-fast) data-[state=open]:ease-out-soft data-[state=closed]:ease-in-out-soft'; +export const OVERLAY_MOTION = { + menu: `${fade} data-[state=open]:duration-(--motion-base) data-[state=open]:zoom-in-[0.98] data-[state=open]:data-[side=bottom]:slide-in-from-top-0.5 data-[state=open]:data-[side=left]:slide-in-from-right-0.5 data-[state=open]:data-[side=right]:slide-in-from-left-0.5 data-[state=open]:data-[side=top]:slide-in-from-bottom-0.5`, + dialog: `${fade} data-[state=open]:duration-(--motion-slow) data-[state=open]:zoom-in-[0.98]`, + scrim: `${fade} data-[state=open]:duration-(--motion-slow)`, + tooltip: `${fade} data-[state=open]:duration-(--motion-fast)`, +}; diff --git a/packages/ui/src/components/ui/popover.tsx b/packages/ui/src/components/ui/popover.tsx index 397eff074..3aec88906 100644 --- a/packages/ui/src/components/ui/popover.tsx +++ b/packages/ui/src/components/ui/popover.tsx @@ -1,3 +1,4 @@ +import { OVERLAY_MOTION } from "./overlay-motion" import * as React from "react" import * as PopoverPrimitive from "@radix-ui/react-popover" @@ -24,7 +25,8 @@ const PopoverContent = React.forwardRef< // these vars when avoidCollisions is on — the default) and scroll when // the content is taller than the viewport, so a popover opened near an // edge never spills off-screen. - "z-50 max-h-[var(--radix-popover-content-available-height)] w-72 max-w-[calc(100vw-1rem)] overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-popover p-4 text-popover-foreground shadow-overlay outline-hidden data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 max-h-[var(--radix-popover-content-available-height)] w-72 max-w-[calc(100vw-1rem)] overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-popover p-4 text-popover-foreground shadow-overlay outline-hidden", + OVERLAY_MOTION.menu, className )} {...props} diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index 31a9466f4..becc10264 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -1,3 +1,4 @@ +import { OVERLAY_MOTION } from "./overlay-motion" import * as React from "react" import {Check, ChevronDown} from "lucide-react" import * as PopoverPrimitive from "@radix-ui/react-popover" @@ -198,11 +199,8 @@ const Select = React.forwardRef(function Select( onKeyDown={onKeyDown} className={cn( "z-50 max-h-[var(--radix-popover-content-available-height)] min-w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)]", - "overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-popover p-1 text-popover-foreground shadow-overlay outline-hidden", - // Entrance only — no exit animation, so the content (and its dismissable - // layer) unmounts immediately on close rather than lingering and - // interfering with an enclosing popover. - "data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95", + "overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-popover p-1 text-popover-foreground shadow-overlay outline-hidden", + OVERLAY_MOTION.menu, )} > {items.length === 0 && ( diff --git a/packages/ui/src/components/ui/switch.tsx b/packages/ui/src/components/ui/switch.tsx index f84b5382e..ae50c267c 100644 --- a/packages/ui/src/components/ui/switch.tsx +++ b/packages/ui/src/components/ui/switch.tsx @@ -9,7 +9,7 @@ const Switch = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/packages/ui/src/components/ui/tooltip.tsx b/packages/ui/src/components/ui/tooltip.tsx index 922dff6bf..94d0db191 100644 --- a/packages/ui/src/components/ui/tooltip.tsx +++ b/packages/ui/src/components/ui/tooltip.tsx @@ -1,3 +1,4 @@ +import { OVERLAY_MOTION } from "./overlay-motion" import * as React from "react" import * as TooltipPrimitive from "@radix-ui/react-tooltip" @@ -18,7 +19,8 @@ const TooltipContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lift animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2", + "z-50 overflow-hidden rounded-md border bg-popover px-3 py-1.5 text-xs text-popover-foreground shadow-lift", + OVERLAY_MOTION.tooltip, className )} {...props} diff --git a/packages/ui/src/export/__tests__/foundationTokens.test.ts b/packages/ui/src/export/__tests__/foundationTokens.test.ts new file mode 100644 index 000000000..bf159f4a3 --- /dev/null +++ b/packages/ui/src/export/__tests__/foundationTokens.test.ts @@ -0,0 +1,49 @@ +import {describe, expect, it} from 'vitest'; +// @ts-expect-error -- node:fs is available to Vitest, not the browser package. +import {readFileSync} from 'node:fs'; + +const css: string = readFileSync('src/index.css', 'utf8'); +const exporter: string = readFileSync('src/export/toHtml.ts', 'utf8'); +const root = css.slice(css.indexOf(' :root {'), css.indexOf(' .dark {')); +function token(name: string): string { + const match = root.match(new RegExp(`--${name}: ([^;]+);`)); + expect(match, name).not.toBeNull(); + return match![1]; +} + +describe('DSX foundation export mirrors', () => { + it('keeps the static type ramp equal to the plain CSS tokens', () => { + const body = parseFloat(token('obe-font-size')); + expect(exporter).toContain(`font: ${body}px/${token('obe-leading')} `); + for (const [name, selector] of [['h1', 'h1'], ['h2', 'h2'], ['h3', 'h3'], ['title', 'h1.doc-title']] as const) { + const ratio = Number(token(`obe-${name}-size`).match(/\* ([\d.]+)/)![1]); + expect(exporter).toContain(`${selector} { font-size: ${body * ratio}px;`); + if (name !== 'title') expect(exporter).toContain(`margin-top: ${token(`obe-${name}-space`)};`); + } + expect(exporter).toContain(`font-weight: ${token('obe-heading-weight')}; line-height: ${token('obe-h1-leading')};`); + expect(exporter).toContain(`font-family: ${token('obe-font-mono')}; font-size: ${token('obe-code-inline')};`); + expect(exporter).toContain(`pre code { font-size: ${body * 0.875}px; line-height: ${token('obe-code-leading')}; }`); + }); + + it('mirrors borderless callout tints and supports authored warn plus legacy warning', () => { + expect(exporter).toContain(`.callout { background: hsl(${token('muted')}); }`); + for (const [variant, hue] of [['warn', 'yellow'], ['success', 'green'], ['danger', 'red']]) { + expect(token(`obe-callout-${variant}`)).toBe(`var(--obe-bg-${hue})`); + expect(exporter).toContain(`background: ${token(`obe-bg-${hue}`)}; }`); + } + expect(exporter).toContain('.callout[data-variant=warn], .callout[data-variant=warning]'); + expect(exporter).toContain('padding: 12px; border-radius: 8px; border: none; }'); + }); + + it('declares every palette role and syntax hue in both modes', () => { + const dark = css.slice(css.indexOf(' .dark {'), css.indexOf('\n html,')); + for (const role of ['fg', 'bg', 'hl']) { + for (const hue of ['gray', 'brown', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink', 'red']) { + expect(token(`obe-${role}-${hue}`)).toMatch(/^hsl\(/); + expect(dark).toContain(`--obe-${role}-${hue}: hsl(`); + } + } + for (const hue of ['kw', 'str', 'num', 'lit']) expect(dark).toContain(`--obe-code-${hue}: hsl(`); + expect(token('obe-code-num')).toBe('hsl(28 80% 38%)'); + }); +}); diff --git a/packages/ui/src/export/toHtml.ts b/packages/ui/src/export/toHtml.ts index 26f31fdb5..1211d5a36 100644 --- a/packages/ui/src/export/toHtml.ts +++ b/packages/ui/src/export/toHtml.ts @@ -1269,19 +1269,24 @@ const SCHEME_DUAL = ` const stylesFor = (scheme: DataColorScheme): string => ` * { box-sizing: border-box; } -body { margin: 0; background: #fff; color: #1a1a1a; font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } +body { margin: 0; background: #fff; color: #1a1a1a; font: 16px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } main { max-width: 720px; margin: 0 auto; padding: 48px 24px 120px; } section.page[hidden] { display: none; } .ob-nav { position: sticky; top: 0; z-index: 10; padding: 8px 24px; backdrop-filter: blur(8px); background: rgba(127,127,127,.06); border-bottom: 1px solid rgba(127,127,127,.18); } .ob-nav button { font: inherit; font-size: .9rem; cursor: pointer; border: 1px solid rgba(127,127,127,.3); background: transparent; color: inherit; border-radius: 6px; padding: 4px 12px; } .ob-nav button:hover { background: rgba(127,127,127,.12); } -h1.doc-title { font-size: 2.4rem; font-weight: 800; letter-spacing: -.02em; margin: 0 0 1.2rem; } -h1,h2,h3,h4 { font-weight: 700; line-height: 1.25; margin: 1.6em 0 .4em; } +h1.doc-title { font-size: 40px; line-height: 1.2; font-weight: 700; letter-spacing: -.02em; margin: 0 0 1.2rem; } +/* DSX §1 light mirrors; foundationTokens.test.ts checks equality with index.css. */ +h1,h2,h3,h4 { font-weight: 600; line-height: 1.3; margin: 0 0 .4em; } +h1 { font-size: 30px; margin-top: 2rem; } +h2 { font-size: 24px; margin-top: 1.5rem; } +h3 { font-size: 20px; margin-top: 1rem; } p { margin: .6em 0; } ul,ol { margin: .4em 0; padding-left: 1.4em; } blockquote { margin: 1em 0; padding: .2em 0 .2em 1em; border-left: 3px solid currentColor; opacity: .85; font-style: italic; } pre { background: rgba(127,127,127,.12); padding: 12px 14px; border-radius: 8px; overflow-x: auto; } -code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .9em; } +code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.875em; } +pre code { font-size: 14px; line-height: 1.5; } /* Highlight tints are always light pastels (in both themes), so the marked text must stay dark. An inherited colour would be light-on-light (unreadable) in dark mode. */ mark { background: #fde68a; color: #1c1917; padding: 0 .1em; border-radius: 2px; } @@ -1397,15 +1402,15 @@ table.db-table a.db-row { display: inline-flex; align-items: center; gap: 6px; c table.db-table a.db-row:hover { text-decoration: underline; } .db-empty { opacity: .6; font-size: .9em; } .tag { display: inline-block; padding: 1px 8px; border-radius: 999px; font-size: .82em; margin: 1px 2px 1px 0; } -.callout { display: flex; gap: 8px; margin: 8px 0; padding: 12px; border-radius: 8px; border: 1px solid; } +.callout { display: flex; gap: 8px; margin: 8px 0; padding: 12px; border-radius: 8px; border: none; } .callout::before { content: "💡"; } -.callout[data-variant=warning]::before { content: "⚠️"; } +.callout[data-variant=warn]::before, .callout[data-variant=warning]::before { content: "⚠️"; } .callout[data-variant=success]::before { content: "✅"; } .callout[data-variant=danger]::before { content: "🛑"; } -.callout { background: rgba(59,130,246,.10); border-color: rgba(59,130,246,.35); } -.callout[data-variant=warning] { background: rgba(245,158,11,.12); border-color: rgba(245,158,11,.4); } -.callout[data-variant=success] { background: rgba(34,197,94,.12); border-color: rgba(34,197,94,.4); } -.callout[data-variant=danger] { background: rgba(239,68,68,.12); border-color: rgba(239,68,68,.4); } +.callout { background: hsl(40 9% 96%); } +.callout[data-variant=warn], .callout[data-variant=warning] { background: hsl(45 90% 52% / 0.15); } +.callout[data-variant=success] { background: hsl(140 55% 45% / 0.13); } +.callout[data-variant=danger] { background: hsl(0 72% 55% / 0.12); } .callout__body { flex: 1; } .accordion { margin: 8px 0; border: 1px solid rgba(127,127,127,.25); border-radius: 8px; padding: 4px 12px; } .accordion summary { cursor: pointer; font-weight: 600; padding: 4px 0; } diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index f81e78cab..35e12a97d 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -108,12 +108,13 @@ --height-control-md: 32px; --height-control-lg: 36px; + --radius-xl: calc(var(--radius) + 4px); --radius-lg: var(--radius); --radius-md: calc(var(--radius) - 2px); --radius-sm: calc(var(--radius) - 4px); - --animate-accordion-down: accordion-down 0.2s ease-out; - --animate-accordion-up: accordion-up 0.2s ease-out; + --animate-accordion-down: accordion-down var(--motion-base) var(--ease-out-soft); + --animate-accordion-up: accordion-up var(--motion-base) var(--ease-out-soft); --transition-property-height: height; --transition-property-spacing: margin, padding; @@ -152,7 +153,7 @@ touching call sites. Named eases are exposed as `ease-out-soft` / `ease-spring` utilities for entrances and emphasis. Static end-states are unchanged, so visual snapshots are unaffected; only the in-between motion is refined. */ - --default-transition-duration: 180ms; + --default-transition-duration: 120ms; --default-transition-timing-function: cubic-bezier(0.32, 0.72, 0, 1); --ease-out-soft: cubic-bezier(0.32, 0.72, 0, 1); --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); @@ -213,6 +214,95 @@ } :root { + /* DSX foundation: plain CSS tokens also travel with the standalone viewer. */ + --motion-fast: 120ms; + --motion-base: 180ms; + --motion-slow: 240ms; + --ease-out-soft: cubic-bezier(0.32, 0.72, 0, 1); + --ease-in-out-soft: cubic-bezier(0.65, 0, 0.35, 1); + --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); + --radius-sm: calc(var(--radius) - 4px); + --radius-md: calc(var(--radius) - 2px); + --radius-lg: var(--radius); + --radius-xl: calc(var(--radius) + 4px); + --obe-font-size: 16px; + --obe-leading: 1.5; + --obe-line: calc(var(--obe-font-size) * var(--obe-leading)); + --obe-title-size: calc(var(--obe-font-size) * 2.5); + --obe-title-leading: 1.2; + --obe-heading-weight: 600; + --obe-code-size: calc(var(--obe-font-size) * 0.875); + --obe-code-leading: 1.5; + --obe-code-inline: 0.875em; + --obe-small-size: calc(var(--obe-font-size) * 0.875); + --obe-small-leading: 1.43; + --obe-caption-size: calc(var(--obe-font-size) * 0.75); + --obe-caption-leading: 1.33; + --obe-font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + --obe-placeholder: hsl(var(--muted-foreground) / 0.6); + --obe-callout-info: hsl(var(--muted)); + --obe-callout-warn: var(--obe-bg-yellow); + --obe-callout-success: var(--obe-bg-green); + --obe-callout-danger: var(--obe-bg-red); + --obe-code-bg: hsl(var(--muted) / 0.6); + --obe-code-border: hsl(var(--border) / 0.7); + --obe-code-com: hsl(var(--muted-foreground)); + --obe-h1-size: calc(var(--obe-font-size) * 1.875); + --obe-h1-leading: 1.3; + --obe-h1-space: 2rem; + --obe-h2-size: calc(var(--obe-font-size) * 1.5); + --obe-h2-leading: 1.3; + --obe-h2-space: 1.5rem; + --obe-h3-size: calc(var(--obe-font-size) * 1.25); + --obe-h3-leading: 1.3; + --obe-h3-space: 1rem; + --z-index-raised: 1; + --z-index-control: 2; + --z-index-drop-indicator: 3; + --z-index-pane-overlay: 4; + --z-index-local-overlay: 5; + --z-index-presence: 6; + --z-index-sticky: 10; + --z-index-inline-hint: 20; + --z-index-raised-chrome: 30; + --z-index-overlay: 40; + --z-index-drawer: 45; + --z-index-menu: 50; + --z-index-nested-menu: 55; + --z-index-lightbox: 60; + --z-index-toast: 100; + --obe-fg-gray: hsl(0 0% 45%); + --obe-fg-brown: hsl(25 35% 38%); + --obe-fg-orange: hsl(28 80% 42%); + --obe-fg-yellow: hsl(42 80% 36%); + --obe-fg-green: hsl(140 50% 34%); + --obe-fg-blue: hsl(210 72% 45%); + --obe-fg-purple: hsl(265 55% 52%); + --obe-fg-pink: hsl(330 65% 48%); + --obe-fg-red: hsl(0 68% 48%); + --obe-bg-gray: hsl(0 0% 50% / 0.1); + --obe-bg-brown: hsl(25 45% 50% / 0.13); + --obe-bg-orange: hsl(28 85% 52% / 0.13); + --obe-bg-yellow: hsl(45 90% 52% / 0.15); + --obe-bg-green: hsl(140 55% 45% / 0.13); + --obe-bg-blue: hsl(210 80% 55% / 0.13); + --obe-bg-purple: hsl(265 60% 58% / 0.14); + --obe-bg-pink: hsl(330 70% 58% / 0.13); + --obe-bg-red: hsl(0 72% 55% / 0.12); + --obe-hl-gray: hsl(0 0% 55% / 0.28); + --obe-hl-brown: hsl(25 50% 50% / 0.3); + --obe-hl-orange: hsl(30 90% 55% / 0.32); + --obe-hl-yellow: hsl(48 95% 55% / 0.4); + --obe-hl-green: hsl(140 60% 48% / 0.3); + --obe-hl-blue: hsl(210 85% 58% / 0.28); + --obe-hl-purple: hsl(265 65% 60% / 0.3); + --obe-hl-pink: hsl(330 75% 60% / 0.3); + --obe-hl-red: hsl(0 75% 58% / 0.28); + --obe-code-kw: hsl(265 55% 48%); + --obe-code-str: hsl(140 50% 30%); + --obe-code-num: hsl(28 80% 38%); + --obe-code-lit: hsl(210 72% 42%); + /* Warm, refined light palette */ --background: 0 0% 100%; --foreground: 34 9% 19%; @@ -300,6 +390,38 @@ } .dark { + --obe-fg-gray: hsl(0 0% 66%); + --obe-fg-brown: hsl(25 40% 62%); + --obe-fg-orange: hsl(28 85% 62%); + --obe-fg-yellow: hsl(45 80% 62%); + --obe-fg-green: hsl(140 50% 60%); + --obe-fg-blue: hsl(210 75% 67%); + --obe-fg-purple: hsl(265 65% 73%); + --obe-fg-pink: hsl(330 70% 71%); + --obe-fg-red: hsl(0 72% 69%); + --obe-bg-gray: hsl(0 0% 78% / 0.1); + --obe-bg-brown: hsl(25 45% 55% / 0.18); + --obe-bg-orange: hsl(28 80% 55% / 0.18); + --obe-bg-yellow: hsl(45 80% 55% / 0.18); + --obe-bg-green: hsl(140 50% 50% / 0.18); + --obe-bg-blue: hsl(210 70% 55% / 0.2); + --obe-bg-purple: hsl(265 55% 60% / 0.22); + --obe-bg-pink: hsl(330 60% 58% / 0.2); + --obe-bg-red: hsl(0 65% 55% / 0.2); + --obe-hl-gray: hsl(0 0% 62% / 0.3); + --obe-hl-brown: hsl(25 45% 50% / 0.34); + --obe-hl-orange: hsl(30 80% 50% / 0.36); + --obe-hl-yellow: hsl(48 80% 50% / 0.34); + --obe-hl-green: hsl(140 50% 45% / 0.34); + --obe-hl-blue: hsl(210 70% 52% / 0.36); + --obe-hl-purple: hsl(265 55% 55% / 0.38); + --obe-hl-pink: hsl(330 60% 55% / 0.36); + --obe-hl-red: hsl(0 65% 52% / 0.36); + --obe-code-kw: hsl(265 65% 73%); + --obe-code-str: hsl(140 50% 60%); + --obe-code-num: hsl(28 85% 62%); + --obe-code-lit: hsl(210 75% 67%); + /* Refined dark palette: main surface dark, sidebar slightly lighter. Softened 2026-06-13: lifted the base, eased the text, and calmed the accent so dark mode reads less harsh (lower contrast, less glare). */ @@ -669,7 +791,7 @@ font-family: var(--ob-font-heading, var(--ob-font-body, inherit)); } [data-placeholder]:empty::before { - color: hsl(var(--muted-foreground) / 0.7); + color: var(--obe-placeholder); font-style: normal; } /* A database host page (compact editor) shows its table right below the tiny @@ -686,7 +808,7 @@ cursor: pointer; white-space: nowrap; padding: 0 4px; - border-radius: calc(var(--radius) - 2px); + border-radius: var(--radius-sm); color: inherit; font-weight: 500; text-decoration: underline; @@ -697,237 +819,18 @@ background-color: hsl(var(--accent)); } -/* ── Reactive blocks (slider / expr / chart) ────────────────────────────── */ -/* Give the interactive blocks a little breathing room within the document - flow so they read as distinct cards rather than crowding adjacent text. */ -.reactive-block { - margin: 0.25rem 0; -} /* @cell references inside an expression render as inline chips: monospace, tinted with the brand color, non-editable as a single atomic token. */ .cell-token { display: inline-block; padding: 0 4px; margin: 0; - border-radius: calc(var(--radius) - 2px); + border-radius: var(--radius-sm); background-color: hsl(var(--brand-subtle)); color: hsl(var(--brand)); font-weight: 600; white-space: nowrap; } -/* Observable Plot legend swatches sit inline; keep their labels readable. */ -.reactive-block figure { - margin: 0; -} -.reactive-block figure > div { - color: hsl(var(--muted-foreground)); - font-size: 0.75rem; -} - -/* ── New block types ──────────────────────────────────────────────────────── */ - -/* Our custom contenteditable regions need an explicit placeholder — a bare - contenteditable doesn't render data-placeholder text natively, so we supply - the content here. */ -.block-callout__body[data-placeholder]:empty::before, -.block-accordion__title[data-placeholder]:empty::before, -.block-accordion__content[data-placeholder]:empty::before, -.block-divider__label[data-placeholder]:empty::before { - content: attr(data-placeholder); - color: hsl(var(--muted-foreground) / 0.6); - pointer-events: none; -} - -/* Callout — a tinted box keyed off the variant hue. */ -.block-callout { - --callout: 217 91% 60%; /* info */ - display: flex; - gap: 0.5rem; - margin: 0.5rem 0; - padding: 0.75rem; - border: 1px solid hsl(var(--callout) / 0.35); - border-radius: var(--radius); - background: hsl(var(--callout) / 0.08); -} -.block-callout[data-variant='warning'] { --callout: 38 92% 50%; } -.block-callout[data-variant='success'] { --callout: 142 71% 45%; } -.block-callout[data-variant='danger'] { --callout: 0 84% 60%; } -.block-callout__emoji { - flex: none; - cursor: pointer; - border: 0; - background: transparent; - font-size: 1.1rem; - line-height: 1.6; - padding: 0; -} -.block-callout__body { - flex: 1; - min-width: 0; - line-height: 1.6; - outline: none; -} - -/* Accordion — collapsible summary + body with a rotating chevron. */ -.block-accordion { - margin: 0.5rem 0; - border: 1px solid hsl(var(--border)); - border-radius: var(--radius); - padding: 0.25rem 0.75rem; -} -.block-accordion__header { - display: flex; - align-items: flex-start; - gap: 0.5rem; -} -.block-accordion__chevron { - flex: none; - display: flex; - cursor: pointer; - border: 0; - background: transparent; - color: hsl(var(--muted-foreground)); - padding: 0.25rem 0; - transition: transform 0.15s ease; -} -.block-accordion[data-open='true'] .block-accordion__chevron { - transform: rotate(90deg); -} -.block-accordion__title { - flex: 1; - font-weight: 600; - padding: 0.25rem 0; - line-height: 1.5; - outline: none; -} -.block-accordion__content { - padding: 0 0 0.5rem 1.5rem; - line-height: 1.6; - outline: none; -} -.block-accordion[data-open='false'] .block-accordion__content { - display: none; -} - -/* Divider — a rule whose style is data-driven; `labeled` adds a centred caption. */ -.block-divider { - display: flex; - align-items: center; - margin: 1rem 0; -} -.block-divider::before { - content: ''; - flex: 1; - border-top: 1px solid hsl(var(--border)); -} -.block-divider[data-style='dashed']::before { border-top-style: dashed; } -.block-divider[data-style='dotted']::before { border-top-style: dotted; } -.block-divider[data-style='thick']::before { border-top-width: 3px; } -.block-divider__label { display: none; } -.block-divider[data-style='labeled'] { gap: 0.75rem; } -.block-divider[data-style='labeled']::after { - content: ''; - flex: 1; - border-top: 1px solid hsl(var(--border)); -} -.block-divider[data-style='labeled'] .block-divider__label { - display: block; - flex: none; - min-width: 1ch; - color: hsl(var(--muted-foreground)); - font-size: 0.8rem; - text-align: center; - outline: none; -} - -/* Button / link card. */ -.block-button { - margin: 0.5rem 0; -} -.block-button__fields { - display: flex; - flex-wrap: wrap; - gap: 0.5rem; - margin-top: 0.5rem; -} -/* Once a URL is set, the document shows just the CTA — the label/URL fields - fold away until "Edit button" (block settings) or until the URL is cleared. */ -.block-button--configured:not(.block-button--editing) .block-button__fields { - display: none; -} -.block-button__input { - flex: 1; - min-width: 8rem; - border: 1px solid hsl(var(--input)); - border-radius: calc(var(--radius) - 2px); - background: transparent; - color: hsl(var(--foreground)); - padding: 0.375rem 0.625rem; - font-size: 0.85rem; - outline: none; -} -.block-button__input:focus { - border-color: hsl(var(--ring)); -} -/* Doubled class bumps specificity so a CTA button never inherits - link-underline styling and reads as a link. */ -.block-button__cta.block-button__cta { - display: inline-block; - background: hsl(var(--primary)); - color: hsl(var(--primary-foreground)); - padding: 0.5rem 1rem; - border-radius: var(--radius); - font-weight: 600; - font-size: 0.9rem; - text-decoration: none; - cursor: pointer; -} -.block-button__cta.is-empty { - background: hsl(var(--muted)); - color: hsl(var(--muted-foreground)); - cursor: default; -} - -/* Table of contents. */ -.block-toc { - margin: 0.5rem 0; - padding: 0.5rem 0.75rem; - border-left: 3px solid hsl(var(--border)); -} -.block-toc__label { - margin-bottom: 0.25rem; - font-size: 0.7rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.04em; - color: hsl(var(--muted-foreground)); -} -.block-toc__empty { - color: hsl(var(--muted-foreground)); - font-size: 0.85rem; - font-style: italic; -} -.block-toc__list { - list-style: none; - margin: 0; - padding: 0; -} -.block-toc__item { - margin: 0; -} -.block-toc__link { - border: 0; - background: transparent; - cursor: pointer; - text-align: left; - color: hsl(var(--muted-foreground)); - font-size: 0.875rem; - padding: 0; -} -.block-toc__link:hover { - color: hsl(var(--foreground)); - text-decoration: underline; -} /* ── Block editor (obe-*) ───────────────────────────────────────────────────── The custom CRDT block editor. Plain classes (no Tailwind apply) so the @@ -959,8 +862,8 @@ margin-inline: auto; /* stylelint-disable-next-line declaration-property-value-allowed-list -- SPC-2: viewport-relative append zone; manifest section 4. */ padding: 0.25rem 0 30vh; - font-size: 15px; - line-height: 1.65; + font-size: var(--obe-font-size); + line-height: var(--obe-leading); color: hsl(var(--foreground)); } @@ -977,7 +880,7 @@ position: relative; display: flex; align-items: flex-start; - border-radius: 8px; + border-radius: var(--radius-sm); padding: 0; } .obe-row-selected { @@ -993,7 +896,7 @@ pointer-events: none; background: hsl(var(--ring) / 0.14); border: 1px solid hsl(var(--ring) / 0.65); - border-radius: 4px; + border-radius: var(--radius-sm); } .obe-marqueeing, .obe-marqueeing * { @@ -1009,7 +912,7 @@ font-weight: 600; color: hsl(var(--primary-foreground)); background: hsl(var(--primary)); - border-radius: 6px; + border-radius: var(--radius-md); box-shadow: 0 2px 8px hsl(var(--foreground) / 0.25); white-space: nowrap; } @@ -1029,7 +932,7 @@ gap: 4px; opacity: 0; pointer-events: none; - transition: opacity 120ms ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } /* Inside columns there's no page gutter — a compact, handle-only grip floats in the column gap. Keep the visible grip above the resize divider; @@ -1080,7 +983,7 @@ height: 1.5rem; display: grid; place-items: center; - border-radius: 6px; + border-radius: var(--radius-md); color: hsl(var(--muted-foreground) / 0.7); font-size: 14px; cursor: pointer; @@ -1139,16 +1042,16 @@ color: hsl(var(--muted-foreground) / 0.45); pointer-events: none; } -.obe-heading { flex: 1; min-width: 0; font-weight: 650; } -.obe-h1 .obe-text { font-size: 1.7rem; line-height: 1.25; margin-top: 0.75rem; } -.obe-h2 .obe-text { font-size: 1.35rem; line-height: 1.3; margin-top: 0.5rem; } -.obe-h3 .obe-text { font-size: 1.12rem; line-height: 1.35; margin-top: 0.25rem; } +.obe-heading { flex: 1; min-width: 0; font-weight: var(--obe-heading-weight); } +.obe-h1 .obe-text { font-size: var(--obe-h1-size); line-height: var(--obe-h1-leading); margin-top: var(--obe-h1-space); } +.obe-h2 .obe-text { font-size: var(--obe-h2-size); line-height: var(--obe-h2-leading); margin-top: var(--obe-h2-space); } +.obe-h3 .obe-text { font-size: var(--obe-h3-size); line-height: var(--obe-h3-leading); margin-top: var(--obe-h3-space); } .obe-code, code.obe-code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.875em; + font-family: var(--obe-font-mono); + font-size: var(--obe-code-inline); background: hsl(var(--muted) / 0.7); - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0 4px; color: hsl(var(--foreground)); } @@ -1162,7 +1065,7 @@ a.obe-link { a.obe-link:hover { text-decoration-color: hsl(var(--foreground)); } a.obe-mention { background: hsl(var(--accent) / 0.8); - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0 4px; text-decoration: none; cursor: pointer; @@ -1207,20 +1110,20 @@ a.obe-mention { display: flex; min-width: 0; gap: 0.5rem; - background: hsl(var(--muted) / 0.55); - border-radius: 8px; + background: var(--obe-callout-info); + border-radius: var(--radius-lg); padding: 0.75rem; margin: 0; } -.obe-callout-warn { background: hsl(40 90% 55% / 0.12); } -.obe-callout-success { background: hsl(140 60% 45% / 0.1); } -.obe-callout-danger { background: hsl(0 75% 55% / 0.09); } +.obe-callout-warn { background: var(--obe-callout-warn); } +.obe-callout-success { background: var(--obe-callout-success); } +.obe-callout-danger { background: var(--obe-callout-danger); } .obe-callout-icon { flex: none; font-size: 1.05rem; line-height: 1.5; cursor: pointer; - border-radius: 6px; + border-radius: var(--radius-md); padding: 0; } .obe-callout-icon:hover { background: var(--hover); } @@ -1237,7 +1140,7 @@ a.obe-mention { min-width: 0; border-left: 3px solid hsl(var(--primary) / 0.4); background: hsl(var(--primary) / 0.05); - border-radius: 0 8px 8px 0; + border-radius: 0 var(--radius-lg) var(--radius-lg) 0; padding: 0.5rem 0.75rem; margin: 0; color: hsl(var(--muted-foreground)); @@ -1273,13 +1176,13 @@ a.obe-mention { position: relative; display: inline-block; max-width: 100%; - border-radius: 8px; + border-radius: var(--radius-lg); } .obe-image-img { display: block; width: 100%; height: auto; - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--muted) / 0.4); -webkit-user-select: none; user-select: none; @@ -1293,12 +1196,12 @@ a.obe-mention { align-items: center; gap: 0.25rem; padding: 0.25rem; - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--background) / 0.86); box-shadow: 0 1px 4px hsl(0 0% 0% / 0.16); backdrop-filter: blur(6px); opacity: 0; - transition: opacity 0.12s ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-image-frame:hover .obe-image-tools, .obe-image-tools:focus-within { opacity: 1; } @@ -1331,10 +1234,10 @@ a.obe-mention { height: 44px; max-height: 60%; transform: translateY(-50%); - border-radius: 6px; + border-radius: var(--radius-md); cursor: ew-resize; opacity: 0; - transition: opacity 0.12s ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-image-resize::after { content: ''; @@ -1343,7 +1246,7 @@ a.obe-mention { margin: auto; width: 4px; height: 100%; - border-radius: 4px; + border-radius: var(--radius-sm); background: hsl(var(--foreground) / 0.35); box-shadow: 0 0 0 2px hsl(var(--background) / 0.7); } @@ -1360,7 +1263,7 @@ a.obe-mention { } .obe-image-alt { font-size: 0.75rem; opacity: 0.85; } .obe-image-alt::placeholder, -.obe-image-caption-input::placeholder { color: hsl(var(--muted-foreground) / 0.55); } +.obe-image-caption-input::placeholder { color: var(--obe-placeholder); } .obe-image-caption { color: hsl(var(--muted-foreground)); font-size: 0.82rem; @@ -1376,12 +1279,12 @@ a.obe-mention { width: 100%; padding: 1.5rem 1rem; border: 1px dashed hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--muted) / 0.35); color: hsl(var(--muted-foreground)); text-align: center; cursor: pointer; - transition: border-color 0.12s ease, background 0.12s ease; + transition: border-color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft); } .obe-image-placeholder:hover { border-color: hsl(var(--primary) / 0.5); @@ -1405,7 +1308,7 @@ a.obe-mention { } /* External image drag hovering the editor — subtle drop-target confirmation so a drop reads as intentional (pairs with the drag/drop handlers). */ -.obe-root.obe-file-dragover { box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.4); border-radius: 8px; } +.obe-root.obe-file-dragover { box-shadow: inset 0 0 0 2px hsl(var(--primary) / 0.4); border-radius: var(--radius-lg); } .obe-root.obe-file-dragover .obe-image-empty .obe-image-placeholder, .obe-root.obe-file-dragover .obe-artifact-empty .obe-image-placeholder { border-color: hsl(var(--primary) / 0.7); @@ -1458,7 +1361,7 @@ a.obe-mention { max-height: 100%; object-fit: contain; transform-origin: center center; - border-radius: 6px; + border-radius: var(--radius-xl); box-shadow: 0 12px 48px hsl(0 0% 0% / 0.55); will-change: transform; user-select: none; @@ -1503,7 +1406,7 @@ a.obe-mention { color: hsl(0 0% 100% / 0.85); background: transparent; cursor: pointer; - transition: background 0.12s ease, color 0.12s ease, opacity 0.12s ease; + transition: background var(--motion-fast) var(--ease-out-soft), color var(--motion-fast) var(--ease-out-soft), opacity var(--motion-fast) var(--ease-out-soft); } .obe-lightbox-zoombtn:hover:not(:disabled) { background: hsl(0 0% 100% / 0.16); @@ -1535,11 +1438,11 @@ a.obe-mention { justify-content: center; width: 2.25rem; height: 2.25rem; - border-radius: 8px; + border-radius: var(--radius-md); color: hsl(0 0% 100% / 0.85); background: hsl(0 0% 0% / 0.35); cursor: pointer; - transition: background 0.12s ease, color 0.12s ease; + transition: background var(--motion-fast) var(--ease-out-soft), color var(--motion-fast) var(--ease-out-soft); } .obe-lightbox-close:hover { background: hsl(0 0% 100% / 0.16); @@ -1582,7 +1485,7 @@ a.obe-mention { font-size: 0.82rem; font-weight: 500; } -.obe-artifact-title::placeholder { color: hsl(var(--muted-foreground) / 0.55); } +.obe-artifact-title::placeholder { color: var(--obe-placeholder); } .obe-artifact-title-static { font-size: 0.82rem; font-weight: 500; @@ -1615,10 +1518,10 @@ a.obe-mention { max-width: 60%; height: 10px; transform: translateX(-50%); - border-radius: 6px; + border-radius: var(--radius-md); cursor: ns-resize; opacity: 0; - transition: opacity 0.12s ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-artifact-resize::after { content: ''; @@ -1627,7 +1530,7 @@ a.obe-mention { margin: auto; width: 100%; height: 4px; - border-radius: 4px; + border-radius: var(--radius-sm); background: hsl(var(--foreground) / 0.35); box-shadow: 0 0 0 2px hsl(var(--background) / 0.7); } @@ -1644,13 +1547,13 @@ a.obe-mention { align-items: center; justify-content: center; padding: 0.25rem; - border-radius: 6px; + border-radius: var(--radius-md); color: hsl(var(--muted-foreground)); background: hsl(var(--background) / 0.85); box-shadow: 0 0 0 1px hsl(var(--border)); cursor: pointer; opacity: 0; - transition: opacity 0.12s ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-artifact-expand:hover { background: var(--hover); color: hsl(var(--foreground)); } /* Keyboard reveal + themed ring live in the shared focus-visible rule below. */ @@ -1702,11 +1605,11 @@ a.obe-mention { width: 100%; max-width: 56rem; margin: auto 0; /* vertically centre short slides; tall ones scroll */ - animation: ob-slide-in 340ms cubic-bezier(0.2, 0.7, 0.2, 1) both; + animation: ob-slide-in var(--motion-slow) var(--ease-out-soft) both; } -.ob-present-full .ob-slide { font-size: 1.08rem; } +.ob-present-full .ob-slide { --obe-font-size: 1.08rem; font-size: var(--obe-font-size); } @keyframes ob-slide-in { - from { opacity: 0; transform: translateY(18px); } + from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } } .ob-slide-title { display: flex; align-items: center; gap: 0.75rem; margin-bottom: 1.5rem; } @@ -1758,7 +1661,7 @@ a.obe-mention { right: 0.9rem; z-index: var(--z-index-sticky); opacity: 0; - transition: opacity 160ms; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .ob-present-full:hover .ob-present-topbar, .ob-present-full .ob-present-topbar:focus-within { opacity: 1; } @@ -1770,11 +1673,11 @@ a.obe-mention { align-items: center; justify-content: center; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--background)); color: hsl(var(--muted-foreground)); cursor: pointer; - transition: background 120ms, color 120ms; + transition: background var(--motion-fast) var(--ease-out-soft), color var(--motion-fast) var(--ease-out-soft); } .ob-present-modebtn:hover { background: var(--hover); color: hsl(var(--foreground)); } @@ -1786,7 +1689,7 @@ a.obe-mention { left: 50%; transform: translateX(-50%); opacity: 0; - transition: opacity 160ms; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .ob-present-full:hover .ob-present-controls, .ob-present-full .ob-present-controls:focus-within { opacity: 1; } @@ -1797,7 +1700,7 @@ a.obe-mention { align-items: center; justify-content: center; border: 1px solid hsl(var(--border)); - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--background)); color: hsl(var(--foreground)); cursor: pointer; @@ -1825,7 +1728,7 @@ a.obe-mention { overflow: hidden; aspect-ratio: 16 / 10; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-xl); background: hsl(var(--background)); } .ob-present-next-scale { @@ -1843,30 +1746,26 @@ a.obe-mention { .obe-codeblock { flex: 1; min-width: 0; - background: hsl(var(--muted) / 0.6); - border: 1px solid hsl(var(--border) / 0.7); - border-radius: 8px; + background: var(--obe-code-bg); + border: 1px solid var(--obe-code-border); + border-radius: var(--radius-lg); padding: 0.75rem 1rem; margin: 0; } .obe-codeblock .obe-text { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; - font-size: 0.85rem; - line-height: 1.55; + font-family: var(--obe-font-mono); + font-size: var(--obe-code-size); + line-height: var(--obe-code-leading); } /* Syntax-highlight tokens (blockeditor/highlight.ts). The tokenizer only wraps runs in these spans — never alters the character stream — so highlighting is purely cosmetic and the caret math is untouched. */ -.obe-tok-com { color: hsl(var(--muted-foreground)); font-style: italic; } -.obe-tok-kw { color: hsl(265 60% 46%); } -.obe-tok-str { color: hsl(140 46% 32%); } -.obe-tok-num { color: hsl(28 78% 42%); } -.obe-tok-lit { color: hsl(210 72% 45%); } -.dark .obe-tok-kw { color: hsl(264 72% 78%); } -.dark .obe-tok-str { color: hsl(140 48% 66%); } -.dark .obe-tok-num { color: hsl(30 80% 66%); } -.dark .obe-tok-lit { color: hsl(208 78% 70%); } +.obe-tok-com { color: var(--obe-code-com); font-style: italic; } +.obe-tok-kw { color: var(--obe-code-kw); } +.obe-tok-str { color: var(--obe-code-str); } +.obe-tok-num { color: var(--obe-code-num); } +.obe-tok-lit { color: var(--obe-code-lit); } /* Columns: a 12-col grid that collapses with its editor container. */ .obe-columns { @@ -1915,9 +1814,9 @@ a.obe-mention { align-items: center; justify-content: center; padding: 0.25rem; - border-radius: 6px; + border-radius: var(--radius-md); color: hsl(var(--muted-foreground)); - transition: color 120ms, background 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft); } .obe-group-btn:hover:not(:disabled) { color: hsl(var(--foreground)); background: var(--hover); } .obe-group-btn-on { color: hsl(var(--primary)); } @@ -2010,10 +1909,10 @@ a.obe-mention { display: grid; place-items: center; color: hsl(var(--muted-foreground) / 0.55); - border-radius: 6px; + border-radius: var(--radius-md); font-size: 13px; opacity: 0; - transition: opacity 120ms ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); cursor: pointer; } .obe-table-wrap:hover .obe-table-add { opacity: 1; } @@ -2030,14 +1929,14 @@ a.obe-mention { color: hsl(var(--muted-foreground)); opacity: 0; pointer-events: none; - transition: opacity 120ms ease; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-table-wrap:hover .obe-table-tools, .obe-table-tools:focus-within { opacity: 1; pointer-events: auto; } .obe-table-tools button { border: 1px solid hsl(var(--border)); background: hsl(var(--card)); - border-radius: 6px; + border-radius: var(--radius-md); padding: 4px 8px; cursor: pointer; } @@ -2067,7 +1966,7 @@ a.obe-mention { color: hsl(var(--muted-foreground) / 0.7); border-radius: var(--radius-sm); opacity: 0; - transition: opacity 120ms ease, background 120ms ease; + transition: opacity var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft); cursor: grab; /* explicit for WKWebView — desktop clicks need a pointer cursor */ z-index: var(--z-index-raised); border: 0; @@ -2132,13 +2031,12 @@ a.obe-mention { overscroll-behavior: contain; background: hsl(var(--popover)); border: 1px solid hsl(var(--border)); - border-radius: var(--radius-md); + border-radius: var(--radius-lg); /* Quiet elevation: a hairline ring + a soft, tight shadow. */ box-shadow: var(--shadow-menu); padding: 0.25rem; - animation: obe-menu-enter var(--default-transition-duration) var(--ease-out-soft); + animation: obe-menu-enter var(--motion-base) var(--ease-out-soft); } -@media (prefers-reduced-motion: reduce) { .obe-slash { animation: none; } } .obe-slash-item { display: flex; width: 100%; @@ -2194,14 +2092,14 @@ a.obe-mention { 0% { background: hsl(var(--primary) / 0.3); } 100% { background: hsl(var(--accent)); } } -.ob-flow-pulse { animation: ob-flow-pulse 0.7s ease-out; } +.ob-flow-pulse { animation: ob-flow-pulse 0.7s var(--ease-out-soft); } /* Dataflow view: clicking a node walks you to its block in the editor. */ @keyframes obe-locate-flash { 0% { background: hsl(var(--primary) / 0.14); } 100% { background: transparent; } } -.obe-locate-flash { animation: obe-locate-flash 1.2s ease-out; border-radius: 8px; } +.obe-locate-flash { animation: obe-locate-flash 1.2s var(--ease-out-soft); border-radius: var(--radius-lg); } /* Database deep links (`?row=`/`?group=`): the targeted row/group header holds a soft tint + ring briefly, then fades — a transient "here it is" after scrolling @@ -2211,11 +2109,11 @@ a.obe-mention { 0%, 45% { background-color: hsl(var(--primary) / 0.16); box-shadow: 0 0 0 2px hsl(var(--ring) / 0.55); } 100% { background-color: transparent; box-shadow: 0 0 0 2px transparent; } } -.ob-anchor-flash { border-radius: 6px; } +.ob-anchor-flash { border-radius: var(--radius-md); } /* Motion-safe only: the JS scroll already falls back to `auto` under reduced-motion, so keep the flash animation off there too. */ @media (prefers-reduced-motion: no-preference) { - .ob-anchor-flash { animation: ob-anchor-flash 1.8s ease-out both; } + .ob-anchor-flash { animation: ob-anchor-flash 1.8s var(--ease-out-soft) both; } } /* Inline toolbar */ @@ -2288,7 +2186,7 @@ a.obe-mention { cancelled by an equal negative margin, so the tint bleeds into the margins while the text stays aligned with untinted blocks. */ .obe-blockbody[class*='obe-bg-'] { - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.25rem 0.5rem; margin-inline: -0.5rem; } @@ -2296,65 +2194,38 @@ a.obe-mention { .obe-blockbody[class*='obe-bg-'] > .obe-group { background: transparent; } /* Text colours (block `fg`, inline `tc`). */ -.obe-fg-gray { color: hsl(0 0% 45%); } -.obe-fg-brown { color: hsl(25 35% 38%); } -.obe-fg-orange { color: hsl(28 80% 42%); } -.obe-fg-yellow { color: hsl(42 80% 36%); } -.obe-fg-green { color: hsl(140 50% 34%); } -.obe-fg-blue { color: hsl(210 72% 45%); } -.obe-fg-purple { color: hsl(265 55% 52%); } -.obe-fg-pink { color: hsl(330 65% 48%); } -.obe-fg-red { color: hsl(0 68% 48%); } -.dark .obe-fg-gray { color: hsl(0 0% 66%); } -.dark .obe-fg-brown { color: hsl(25 40% 62%); } -.dark .obe-fg-orange { color: hsl(28 85% 62%); } -.dark .obe-fg-yellow { color: hsl(45 80% 62%); } -.dark .obe-fg-green { color: hsl(140 50% 60%); } -.dark .obe-fg-blue { color: hsl(210 75% 67%); } -.dark .obe-fg-purple { color: hsl(265 65% 73%); } -.dark .obe-fg-pink { color: hsl(330 70% 71%); } -.dark .obe-fg-red { color: hsl(0 72% 69%); } +.obe-fg-gray { color: var(--obe-fg-gray); } +.obe-fg-brown { color: var(--obe-fg-brown); } +.obe-fg-orange { color: var(--obe-fg-orange); } +.obe-fg-yellow { color: var(--obe-fg-yellow); } +.obe-fg-green { color: var(--obe-fg-green); } +.obe-fg-blue { color: var(--obe-fg-blue); } +.obe-fg-purple { color: var(--obe-fg-purple); } +.obe-fg-pink { color: var(--obe-fg-pink); } +.obe-fg-red { color: var(--obe-fg-red); } /* Block backgrounds (subtle tints). */ -.obe-bg-gray { background-color: hsl(0 0% 50% / 0.1); } -.obe-bg-brown { background-color: hsl(25 45% 50% / 0.13); } -.obe-bg-orange { background-color: hsl(28 85% 52% / 0.13); } -.obe-bg-yellow { background-color: hsl(45 90% 52% / 0.15); } -.obe-bg-green { background-color: hsl(140 55% 45% / 0.13); } -.obe-bg-blue { background-color: hsl(210 80% 55% / 0.13); } -.obe-bg-purple { background-color: hsl(265 60% 58% / 0.14); } -.obe-bg-pink { background-color: hsl(330 70% 58% / 0.13); } -.obe-bg-red { background-color: hsl(0 72% 55% / 0.12); } -.dark .obe-bg-gray { background-color: hsl(0 0% 78% / 0.1); } -.dark .obe-bg-brown { background-color: hsl(25 45% 55% / 0.18); } -.dark .obe-bg-orange { background-color: hsl(28 80% 55% / 0.18); } -.dark .obe-bg-yellow { background-color: hsl(45 80% 55% / 0.18); } -.dark .obe-bg-green { background-color: hsl(140 50% 50% / 0.18); } -.dark .obe-bg-blue { background-color: hsl(210 70% 55% / 0.2); } -.dark .obe-bg-purple { background-color: hsl(265 55% 60% / 0.22); } -.dark .obe-bg-pink { background-color: hsl(330 60% 58% / 0.2); } -.dark .obe-bg-red { background-color: hsl(0 65% 55% / 0.2); } +.obe-bg-gray { background-color: var(--obe-bg-gray); } +.obe-bg-brown { background-color: var(--obe-bg-brown); } +.obe-bg-orange { background-color: var(--obe-bg-orange); } +.obe-bg-yellow { background-color: var(--obe-bg-yellow); } +.obe-bg-green { background-color: var(--obe-bg-green); } +.obe-bg-blue { background-color: var(--obe-bg-blue); } +.obe-bg-purple { background-color: var(--obe-bg-purple); } +.obe-bg-pink { background-color: var(--obe-bg-pink); } +.obe-bg-red { background-color: var(--obe-bg-red); } /* Inline highlights (stronger, with their own radius/padding). */ [class*='obe-hl-'] { border-radius: var(--radius-sm); padding: 0 4px; } -.obe-hl-gray { background-color: hsl(0 0% 55% / 0.28); } -.obe-hl-brown { background-color: hsl(25 50% 50% / 0.3); } -.obe-hl-orange { background-color: hsl(30 90% 55% / 0.32); } -.obe-hl-yellow { background-color: hsl(48 95% 55% / 0.4); } -.obe-hl-green { background-color: hsl(140 60% 48% / 0.3); } -.obe-hl-blue { background-color: hsl(210 85% 58% / 0.28); } -.obe-hl-purple { background-color: hsl(265 65% 60% / 0.3); } -.obe-hl-pink { background-color: hsl(330 75% 60% / 0.3); } -.obe-hl-red { background-color: hsl(0 75% 58% / 0.28); } -.dark .obe-hl-gray { background-color: hsl(0 0% 62% / 0.3); } -.dark .obe-hl-brown { background-color: hsl(25 45% 50% / 0.34); } -.dark .obe-hl-orange { background-color: hsl(30 80% 50% / 0.36); } -.dark .obe-hl-yellow { background-color: hsl(48 80% 50% / 0.34); } -.dark .obe-hl-green { background-color: hsl(140 50% 45% / 0.34); } -.dark .obe-hl-blue { background-color: hsl(210 70% 52% / 0.36); } -.dark .obe-hl-purple { background-color: hsl(265 55% 55% / 0.38); } -.dark .obe-hl-pink { background-color: hsl(330 60% 55% / 0.36); } -.dark .obe-hl-red { background-color: hsl(0 65% 52% / 0.36); } +.obe-hl-gray { background-color: var(--obe-hl-gray); } +.obe-hl-brown { background-color: var(--obe-hl-brown); } +.obe-hl-orange { background-color: var(--obe-hl-orange); } +.obe-hl-yellow { background-color: var(--obe-hl-yellow); } +.obe-hl-green { background-color: var(--obe-hl-green); } +.obe-hl-blue { background-color: var(--obe-hl-blue); } +.obe-hl-purple { background-color: var(--obe-hl-purple); } +.obe-hl-pink { background-color: var(--obe-hl-pink); } +.obe-hl-red { background-color: var(--obe-hl-red); } /* Swatch buttons (toolbar colour panel). */ .obe-sw { @@ -2366,7 +2237,7 @@ a.obe-mention { font-weight: 600; font-size: 0.85rem; cursor: pointer; - border-radius: 6px; + border-radius: var(--radius-md); } .obe-sw:hover { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; } .obe-sw-fill { color: transparent; } @@ -2382,7 +2253,7 @@ a.obe-mention { margin-right: 0.5rem; display: inline-grid; place-items: center; - border-radius: 4px; + border-radius: var(--radius-sm); border: 1px solid hsl(var(--border)); font-size: 0.7rem; font-weight: 700; @@ -2397,7 +2268,7 @@ a.obe-mention { display: block; width: 0.9em; height: 3px; - border-radius: 999px; + border-radius: 9999px; background: linear-gradient(90deg, hsl(0 70% 55%), hsl(45 90% 55%), hsl(210 80% 55%)); } .obe-tb-colors { @@ -2487,20 +2358,20 @@ a.obe-mention { gap: 0.5rem; background: hsl(var(--muted) / 0.5); border: 1px solid hsl(var(--border) / 0.7); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.5rem; } .obe-formula-src { flex: 1; min-width: 0; background: transparent; - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.85rem; outline: none; } .obe-formula-eq { color: hsl(var(--muted-foreground) / 0.7); } .obe-formula-out { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.9rem; font-weight: 600; color: hsl(var(--primary)); @@ -2511,7 +2382,7 @@ a.obe-mention { color: hsl(var(--muted-foreground)); font-size: 0.85rem; border: 1px dashed hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.5rem; } @@ -2525,7 +2396,7 @@ a.obe-mention { align-items: center; gap: 0.75rem; border: 1px dashed hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.5rem 0.75rem; user-select: none; } @@ -2536,7 +2407,7 @@ a.obe-mention { width: 2rem; height: 2rem; flex-shrink: 0; - border-radius: 6px; + border-radius: var(--radius-md); background: hsl(var(--accent) / 0.5); font-size: 1.1rem; color: hsl(var(--muted-foreground)); @@ -2553,7 +2424,7 @@ a.obe-mention { .obe-missing-plugin-meta { font-size: 0.7rem; color: hsl(var(--muted-foreground) / 0.7); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); margin-top: 0; } .obe-missing-plugin-error { @@ -2569,12 +2440,12 @@ a.obe-mention { padding: 0.25rem 0.75rem; font-size: 0.75rem; font-weight: 500; - border-radius: 6px; + border-radius: var(--radius-md); border: 1px solid hsl(var(--border)); background: hsl(var(--background)); color: hsl(var(--foreground)); cursor: pointer; - transition: background 0.15s, border-color 0.15s; + transition: background var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-missing-plugin-install:hover { background: hsl(var(--accent)); @@ -2661,23 +2532,23 @@ a.obe-mention { line-height: inherit; color: inherit; border: 1px solid transparent; - border-radius: 4px; + border-radius: var(--radius-sm); background: transparent; padding: 0; margin: 0; cursor: text; - transition: border-color 120ms, background 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft); } .obe-kit-inline:hover { border-color: hsl(var(--border) / 0.6); } .obe-kit-inline:focus { outline: none; border-color: hsl(var(--ring)); background: hsl(var(--card)); } -.obe-kit-inline::placeholder { color: hsl(var(--muted-foreground) / 0.55); } +.obe-kit-inline::placeholder { color: var(--obe-placeholder); } /* The inline description edits in place (WYSIWYG) and only renders when it has text (see KitFrame) — so it never collapses/unfurls and never shifts the layout. An empty description is added from the settings gear. */ .obe-kit-desc-edit { opacity: 0.85; - transition: border-color 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft); } /* The de-emphasized reactive symbol in a config card: a muted "Variable `x`" @@ -2693,7 +2564,7 @@ a.obe-mention { } .obe-kit-symbol-hint:disabled { cursor: default; } .obe-kit-symbol-code { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); color: hsl(var(--muted-foreground)); background: hsl(var(--muted) / 0.6); border: 1px solid hsl(var(--border) / 0.6); @@ -2703,7 +2574,7 @@ a.obe-mention { .obe-kit-symbol-edit { color: hsl(var(--primary)); opacity: 0; - transition: opacity 120ms; + transition: opacity var(--motion-fast) var(--ease-out-soft); } .obe-kit-symbol-hint:hover .obe-kit-symbol-edit { opacity: 1; } .obe-kit-spacer { flex: 1; } @@ -2726,7 +2597,7 @@ a.obe-mention { cursor: pointer; opacity: 0; pointer-events: none; - transition: opacity 120ms, color 120ms, background 120ms, border-color 120ms; + transition: opacity var(--motion-fast) var(--ease-out-soft), color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-kit:hover .obe-kit-gear, .obe-kit:focus-within .obe-kit-gear, @@ -2749,7 +2620,7 @@ a.obe-mention { gap: 0.5rem 0.75rem; border: 1px solid hsl(var(--border) / 0.7); background: hsl(var(--muted) / 0.4); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.5rem 0.75rem; } .obe-kit-field { @@ -2763,15 +2634,15 @@ a.obe-mention { .obe-kit-field-grow input { flex: 1; min-width: 0; } .obe-kit-name, .obe-kit-num, .obe-kit-options, .obe-kit-select { border: 1px solid hsl(var(--border)); - border-radius: 6px; + border-radius: var(--radius-md); background: hsl(var(--card)); padding: 0.25rem 0.5rem; font-size: 0.78rem; color: hsl(var(--foreground)); } -.obe-kit-name { width: 7rem; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.obe-kit-name { width: 7rem; font-family: var(--obe-font-mono); } .obe-kit-num { width: 4rem; font-variant-numeric: tabular-nums; } -.obe-kit-mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } +.obe-kit-mono { font-family: var(--obe-font-mono); } .obe-kit-empty { font-size: 0.78rem; color: hsl(var(--muted-foreground) / 0.7); font-style: italic; } /* Number stepper */ @@ -2779,7 +2650,7 @@ a.obe-mention { display: inline-flex; align-items: stretch; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); overflow: hidden; background: hsl(var(--card)); } @@ -2790,7 +2661,7 @@ a.obe-mention { font-size: 1rem; color: hsl(var(--muted-foreground)); cursor: pointer; - transition: background 120ms, color 120ms; + transition: background var(--motion-fast) var(--ease-out-soft), color var(--motion-fast) var(--ease-out-soft); } .obe-kit-stepper button:hover:not(:disabled) { background: var(--hover); color: hsl(var(--foreground)); } .obe-kit-stepper input { @@ -2809,7 +2680,7 @@ a.obe-mention { flex: 1; min-width: 8rem; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--card)); padding: 0.375rem 0.625rem; font-size: 0.9rem; @@ -2822,13 +2693,13 @@ a.obe-mention { width: 1.8rem; height: 1.8rem; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--card)); color: hsl(var(--muted-foreground)); font-size: 0.9rem; cursor: pointer; text-decoration: none; - transition: color 120ms, border-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-kit-mini:hover { color: hsl(var(--foreground)); border-color: hsl(var(--ring)); } @@ -2836,13 +2707,13 @@ a.obe-mention { .obe-kit-pills { display: flex; flex-wrap: wrap; gap: 0.25rem; } .obe-kit-pill { border: 1px solid hsl(var(--border)); - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--card)); padding: 0.25rem 0.75rem; font-size: 0.82rem; color: hsl(var(--muted-foreground)); cursor: pointer; - transition: color 120ms, background-color 120ms, border-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), background-color var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-kit-pill:hover:not(:disabled) { border-color: hsl(var(--ring)); color: hsl(var(--foreground)); } .obe-kit-pill-on { @@ -2898,11 +2769,11 @@ a.obe-mention { } .obe-form-kind { border: 1px solid hsl(var(--border)); - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted) / 0.55); padding: 0 0.5rem; color: hsl(var(--muted-foreground)); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.68rem; } .obe-form-builder { @@ -2916,7 +2787,7 @@ a.obe-mention { } .obe-form-empty { border: 1px dashed hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); padding: 0.75rem; color: hsl(var(--muted-foreground)); font-size: 0.82rem; @@ -2933,7 +2804,7 @@ a.obe-mention { width: 100%; box-sizing: border-box; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--muted) / 0.28); padding: 0.375rem 0.5rem; color: hsl(var(--muted-foreground)); @@ -2969,7 +2840,7 @@ a.obe-mention { } .obe-form-state { border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--muted) / 0.35); padding: 0.75rem; color: hsl(var(--foreground)); @@ -3024,7 +2895,7 @@ a.obe-mention { min-width: 0; cursor: grab; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--muted) / 0.24); padding: 0.375rem 0.5rem; color: hsl(var(--foreground)); @@ -3053,7 +2924,7 @@ a.obe-mention { align-items: center; gap: 0.375rem; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--card)); padding: 0.375rem; outline: none; @@ -3079,7 +2950,7 @@ a.obe-mention { height: 1.65rem; cursor: pointer; place-items: center; - border-radius: 6px; + border-radius: var(--radius-md); color: hsl(var(--muted-foreground)); } .obe-form-field-grip { cursor: grab; touch-action: none; } @@ -3096,7 +2967,7 @@ a.obe-mention { } .obe-form-row-copy span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .obe-form-required { - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted) / 0.7); padding: 0 0.375rem; color: hsl(var(--muted-foreground)); @@ -3136,7 +3007,7 @@ a.obe-mention { cursor: pointer; align-items: center; gap: 0.375rem; - border-radius: 4px; + border-radius: var(--radius-sm); padding: 0.375rem 0.5rem; font-size: var(--text-xs); text-align: left; @@ -3150,7 +3021,7 @@ a.obe-mention { align-self: flex-start; cursor: pointer; border: 1px solid hsl(var(--border)); - border-radius: 6px; + border-radius: var(--radius-md); padding: 0.25rem 0.5rem; color: hsl(var(--foreground)); font-size: var(--text-xs); @@ -3181,11 +3052,11 @@ a.obe-mention { align-items: center; gap: 0.5rem; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--card)); padding: 0.5rem 0.75rem; font-size: 0.88rem; - transition: border-color 120ms, background 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft); } .obe-kit-wide .obe-kit-pill:hover, .obe-kit-wide .obe-kit-check:hover { background: hsl(var(--accent)); @@ -3198,7 +3069,7 @@ a.obe-mention { .obe-kit-pill-dot { width: 0.85rem; height: 0.85rem; - border-radius: 999px; + border-radius: 9999px; border: 1.5px solid hsl(var(--muted-foreground) / 0.5); flex-shrink: 0; } @@ -3217,12 +3088,12 @@ a.obe-mention { .obe-kit-switch { width: 2.4rem; height: 1.35rem; - border-radius: 999px; + border-radius: 9999px; border: 1px solid hsl(var(--border)); background: hsl(var(--muted)); position: relative; cursor: pointer; - transition: background 150ms; + transition: background var(--motion-base) var(--ease-out-soft); } .obe-kit-switch-on { background: hsl(var(--primary)); border-color: hsl(var(--primary)); } .obe-kit-knob { @@ -3231,24 +3102,24 @@ a.obe-mention { left: 2px; width: 1.1rem; height: 1.1rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--background)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); - transition: transform 150ms; + transition: transform var(--motion-base) var(--ease-out-soft); } .obe-kit-switch-on .obe-kit-knob { transform: translateX(1rem); } /* Action button */ .obe-kit-action { border: 1px solid hsl(var(--primary)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); padding: 0.25rem 1rem; font-size: 0.88rem; font-weight: 550; cursor: pointer; - transition: opacity 120ms, background-color 120ms; + transition: opacity var(--motion-fast) var(--ease-out-soft), background-color var(--motion-fast) var(--ease-out-soft); } .obe-kit-action:hover { opacity: 0.9; } /* Colour-only press — deepen the fill toward --press-ink (matches Button), no geometry shift. */ @@ -3279,7 +3150,7 @@ a.obe-mention { .obe-chart-xlabels text { text-anchor: middle; } /* Sunburst centre readout (DASH-4): total at rest, hovered slice on hover. */ -.obe-chart-center-value { fill: hsl(var(--foreground)); font-size: 20px; font-weight: 650; font-variant-numeric: tabular-nums; } +.obe-chart-center-value { fill: hsl(var(--foreground)); font-size: 20px; font-weight: var(--obe-heading-weight); font-variant-numeric: tabular-nums; } .obe-chart-center-label { fill: hsl(var(--muted-foreground)); font-size: 11px; } .obe-chart-legend text { text-anchor: start; font-size: 11px; } .obe-chart-msg { fill: hsl(var(--muted-foreground) / 0.8); font-size: 12px; text-anchor: middle; } @@ -3287,7 +3158,7 @@ a.obe-mention { /* KPI / number tile (DASH-5) — the MetricCard look rendered in SVG. */ .obe-chart-kpi-caption { fill: hsl(var(--muted-foreground)); font-size: 12px; font-weight: 600; letter-spacing: 0.06em; text-anchor: middle; text-transform: uppercase; } -.obe-chart-kpi-value { fill: hsl(var(--foreground)); font-size: 60px; font-weight: 650; text-anchor: middle; font-variant-numeric: tabular-nums; } +.obe-chart-kpi-value { fill: hsl(var(--foreground)); font-size: 60px; font-weight: var(--obe-heading-weight); text-anchor: middle; font-variant-numeric: tabular-nums; } .obe-chart-kpi-sub { fill: hsl(var(--muted-foreground)); font-size: 15px; text-anchor: middle; font-variant-numeric: tabular-nums; } .obe-chart-kpi-track { fill: hsl(var(--muted)); } @@ -3304,7 +3175,7 @@ a.obe-mention { .obe-chart-plot { position: relative; } /* Each datum's wrapper. Highlight is shared: the active mark brightens, the rest de-emphasise, so every registered kind gets it from the scaffold. */ -.obe-chart-mark { cursor: pointer; outline: none; transition: opacity 0.12s ease, filter 0.12s ease; } +.obe-chart-mark { cursor: pointer; outline: none; transition: opacity var(--motion-fast) var(--ease-out-soft), filter var(--motion-fast) var(--ease-out-soft); } /* Forced-colors users get no cue from the token stroke ring, so restore a real focus outline there (the `outline:none` above is fine for everyone else, who see the is-active emphasis on focus). */ @@ -3318,7 +3189,7 @@ a.obe-mention { .obe-chart-mark.is-active { filter: brightness(1.06); } .obe-chart-mark.is-active :is(rect, path, circle) { stroke: hsl(var(--foreground) / 0.55); stroke-width: 1.5px; } /* Line/scatter point markers read as part of the line at rest, surfacing on hover/focus. */ -.obe-chart-dot { fill-opacity: 0; transition: fill-opacity 0.12s ease; } +.obe-chart-dot { fill-opacity: 0; transition: fill-opacity var(--motion-fast) var(--ease-out-soft); } .obe-chart-mark.is-active .obe-chart-dot { fill-opacity: 1; } .obe-chart-tooltip { @@ -3351,7 +3222,7 @@ a.obe-mention { .obe-kit-light { width: 0.7rem; height: 0.7rem; - border-radius: 999px; + border-radius: 9999px; flex-shrink: 0; background: color-mix(in srgb, var(--data-gray, #c3c6cb) 35%, transparent); box-shadow: inset 0 0 0 1px var(--data-stroke, rgba(0,0,0,0.12)); @@ -3361,7 +3232,7 @@ a.obe-mention { .obe-kit-light-bad { background: var(--data-status-bad, #deaea6); box-shadow: inset 0 0 0 1px var(--data-stroke, rgba(0,0,0,0.12)), 0 0 0 3px color-mix(in srgb, var(--data-status-bad, #deaea6) 25%, transparent); } .obe-kit-status-label { font-size: 0.88rem; font-weight: 550; } .obe-kit-status-value { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.82rem; color: hsl(var(--muted-foreground)); } @@ -3384,7 +3255,7 @@ a.obe-mention { justify-content: center; width: 0.95rem; height: 0.95rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted)); color: hsl(var(--muted-foreground)); font-size: 0.65rem; @@ -3400,7 +3271,7 @@ a.obe-mention { background: hsl(var(--popover)); color: hsl(var(--popover-foreground)); border: 1px solid hsl(var(--border)); - border-radius: var(--radius-lg); + border-radius: var(--radius-md); padding: 0.5rem 0.75rem; font-size: 0.8rem; font-weight: 400; @@ -3408,9 +3279,9 @@ a.obe-mention { box-shadow: var(--shadow-menu); opacity: 0; pointer-events: none; - transition: opacity 120ms; + transition: opacity var(--motion-fast) var(--ease-in-out-soft); } -.obe-kit-term:hover .obe-kit-tip, .obe-kit-term:focus .obe-kit-tip { opacity: 1; } +.obe-kit-term:hover .obe-kit-tip, .obe-kit-term:focus .obe-kit-tip { opacity: 1; transition-timing-function: var(--ease-out-soft); } /* Link card */ .obe-kit-linkcard-wrap { flex: 1; } @@ -3425,7 +3296,7 @@ a.obe-mention { padding: 0.5rem 0.75rem; text-decoration: none; color: inherit; - transition: border-color 120ms, box-shadow 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft), box-shadow var(--motion-fast) var(--ease-out-soft); } .obe-kit-linkcard:hover { border-color: hsl(var(--ring)); box-shadow: 0 2px 10px rgb(0 0 0 / 0.05); } .obe-kit-linkcard-title { font-size: 0.92rem; font-weight: 600; } @@ -3456,7 +3327,7 @@ a.obe-mention { background: hsl(var(--card)); cursor: pointer; text-align: left; - transition: border-color 120ms, box-shadow 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft), box-shadow var(--motion-fast) var(--ease-out-soft); } .obe-kit-card:hover:not(:disabled) { border-color: hsl(var(--ring)); } .obe-kit-card-on { border-color: hsl(var(--primary)); box-shadow: 0 0 0 1px hsl(var(--primary)); } @@ -3465,7 +3336,7 @@ a.obe-mention { display: block; width: 100%; aspect-ratio: 16 / 10; - border-radius: 6px; + border-radius: var(--radius-sm); background-size: cover; background-position: center; background-color: hsl(var(--muted) / 0.6); @@ -3487,7 +3358,7 @@ a.obe-mention { justify-content: center; width: 1.1rem; height: 1.1rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--primary)); color: hsl(var(--primary-foreground)); } @@ -3498,7 +3369,7 @@ a.obe-mention { min-height: 4.5rem; resize: vertical; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--card)); padding: 0.5rem 0.75rem; font: inherit; @@ -3515,12 +3386,12 @@ a.obe-mention { width: 1.6rem; height: 1.6rem; border: 1px solid hsl(var(--border)); - border-radius: 6px; + border-radius: var(--radius-md); background: hsl(var(--card)); color: hsl(var(--muted-foreground)); font-size: 0.82rem; cursor: pointer; - transition: color 120ms, border-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-kit-richtext-btn:hover { border-color: hsl(var(--ring)); color: hsl(var(--foreground)); } .obe-kit-richtext-b { font-weight: 700; } @@ -3529,7 +3400,7 @@ a.obe-mention { .obe-kit-richtext-body { min-height: 3rem; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-lg); background: hsl(var(--card)); padding: 0.5rem 0.75rem; font-size: 0.9rem; @@ -3545,7 +3416,7 @@ a.obe-mention { /* Inline review: a quiet accent on blocks with an open suggestion or a comment, plus link styling inside a rendered comment body. Presentation-only. */ .ob-block-reviewed { - border-radius: 6px; + border-radius: var(--radius-md); box-shadow: inset 2px 0 0 0 hsl(var(--primary) / 0.55); background: hsl(var(--primary) / 0.05); } @@ -3574,7 +3445,7 @@ a.obe-mention { .obe-rcursor-caret { position: absolute; width: 2px; - border-radius: 999px; + border-radius: 9999px; pointer-events: none; } /* A small nub at the caret top so a peer stays locatable after the label fades. */ @@ -3603,14 +3474,14 @@ a.obe-mention { /* `color` is set inline per-swatch (readableTextColor) for WCAG AA contrast. */ pointer-events: none; box-shadow: 0 1px 3px hsl(0 0% 0% / 0.18); - animation: obe-rcursor-fade 2.8s ease forwards; + animation: obe-rcursor-fade 2.8s var(--ease-in-out-soft) forwards; } @keyframes obe-rcursor-fade { 0%, 65% { opacity: 1; } 100% { opacity: 0; } } @media (prefers-reduced-motion: reduce) { - .obe-rcursor-label { animation: none; opacity: 1; } + .obe-rcursor-label { opacity: 1; } } /* Searchable select trigger (the popover surface is Radix Command). */ @@ -3622,17 +3493,17 @@ a.obe-mention { min-width: 11rem; max-width: 100%; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--card)); padding: 0.375rem 0.625rem; font-size: 0.88rem; color: hsl(var(--foreground)); cursor: pointer; - transition: border-color 120ms; + transition: border-color var(--motion-fast) var(--ease-out-soft); } .obe-kit-searchtrigger:hover:not(:disabled) { border-color: hsl(var(--ring)); } .obe-kit-searchtrigger:disabled { opacity: 0.6; cursor: default; } -.obe-kit-search-placeholder { color: hsl(var(--muted-foreground) / 0.7); } +.obe-kit-search-placeholder { color: var(--obe-placeholder); } /* Tag field: chips + a free-entry input. */ .obe-kit-tags { @@ -3642,7 +3513,7 @@ a.obe-mention { gap: 0.25rem; width: 100%; border: 1px solid hsl(var(--border)); - border-radius: 8px; + border-radius: var(--radius-md); background: hsl(var(--card)); padding: 0.25rem 0.5rem; min-height: 2rem; @@ -3651,7 +3522,7 @@ a.obe-mention { display: inline-flex; align-items: center; gap: 0.25rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted) / 0.7); padding: 0 0.25rem 0 0.5rem; font-size: 0.8rem; @@ -3660,7 +3531,7 @@ a.obe-mention { display: inline-flex; align-items: center; justify-content: center; - border-radius: 999px; + border-radius: 9999px; color: hsl(var(--muted-foreground)); cursor: pointer; } @@ -3675,30 +3546,30 @@ a.obe-mention { outline: none; color: hsl(var(--foreground)); } -.obe-kit-tag-input::placeholder { color: hsl(var(--muted-foreground) / 0.6); } +.obe-kit-tag-input::placeholder { color: var(--obe-placeholder); } /* Progress bar (display, no published value). */ .obe-kit-progress { flex-direction: column; align-items: stretch; gap: 0.25rem; } .obe-kit-progress-head { display: flex; align-items: baseline; justify-content: space-between; gap: 0.5rem; } .obe-kit-progress-label { font-size: 0.85rem; font-weight: 550; } .obe-kit-progress-value { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.8rem; color: hsl(var(--muted-foreground)); } .obe-kit-progress-track { display: block; height: 0.55rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted) / 0.7); overflow: hidden; } .obe-kit-progress-fill { display: block; height: 100%; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--primary)); - transition: width 200ms ease; + transition: width var(--motion-base) var(--ease-out-soft); } /* ── Tabs & accordion containers ───────────────────────────────────────────── */ @@ -3724,12 +3595,12 @@ a.obe-mention { display: inline-flex; align-items: center; gap: 0.25rem; - border-radius: 6px; + border-radius: var(--radius-md); padding: 0.25rem 0.5rem; font-size: 0.78rem; color: hsl(var(--muted-foreground)); cursor: pointer; - transition: color 120ms, background-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), background-color var(--motion-fast) var(--ease-out-soft); } .obe-cnt-add:hover { color: hsl(var(--foreground)); background: var(--hover); } @@ -3745,7 +3616,7 @@ a.obe-mention { font-size: 0.84rem; color: hsl(var(--muted-foreground)); cursor: pointer; - transition: color 120ms, background-color 120ms, border-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), background-color var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-tab:hover:not(:disabled) { background: var(--hover); color: hsl(var(--foreground)); } .obe-tab-on { @@ -3770,7 +3641,7 @@ a.obe-mention { align-items: center; justify-content: center; color: hsl(var(--muted-foreground)); - border-radius: var(--radius-sm); + border-radius: var(--radius-md); cursor: pointer; } .obe-acc-toggle:hover:not(:disabled) { color: hsl(var(--foreground)); background: var(--hover); } @@ -3787,7 +3658,7 @@ a.obe-mention { min-width: 1.1rem; height: 1.1rem; padding: 0 0.25rem; - border-radius: 999px; + border-radius: 9999px; background: hsl(var(--muted) / 0.8); color: hsl(var(--muted-foreground)); font-size: 0.68rem; @@ -3806,7 +3677,7 @@ a.obe-mention { border-bottom: 1px solid hsl(var(--border) / 0.5); } .obe-code-lang-badge { - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.68rem; letter-spacing: 0.02em; text-transform: lowercase; @@ -3826,7 +3697,7 @@ a.obe-mention { padding: 0.25rem; border-radius: var(--radius-md); cursor: pointer; - transition: color 120ms, background 120ms, border-color 120ms; + transition: color var(--motion-fast) var(--ease-out-soft), background var(--motion-fast) var(--ease-out-soft), border-color var(--motion-fast) var(--ease-out-soft); } .obe-code-btn:hover:not(:disabled) { background: var(--hover); color: hsl(var(--foreground)); } .obe-code-btn:disabled { opacity: 0.45; cursor: default; } @@ -3860,7 +3731,7 @@ a.obe-mention { display: block; padding: 0.25rem 0.75rem; border-top: 1px solid hsl(var(--border) / 0.5); - font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-family: var(--obe-font-mono); font-size: 0.85rem; color: hsl(var(--primary)); font-weight: 600; @@ -3877,7 +3748,7 @@ a.obe-mention { .obe-column { position: relative; } .obe-col-divider { position: absolute; - z-index: 4; + z-index: var(--z-index-pane-overlay); left: -1.125rem; top: 0; bottom: 0; @@ -3895,7 +3766,7 @@ a.obe-mention { width: 2px; border-radius: var(--radius-sm); background: transparent; - transition: background 120ms ease; + transition: background var(--motion-fast) var(--ease-out-soft); } .obe-col-divider:hover::after, .obe-col-divider:active::after, @@ -3936,21 +3807,25 @@ a.obe-mention { font-size: 12px; font-weight: 600; font-variant-numeric: tabular-nums; - transition: border-color 0.12s ease; + transition: border-color var(--motion-fast) var(--ease-out-soft); } .ob-map-cluster-wrap:hover .ob-map-cluster { border-color: hsl(var(--ring) / 0.55); } /* Meeting recorder: native playback, copyable transcript and ordinary note rows. */ -.obe-meeting { border: 1px solid var(--color-border); border-radius: var(--radius-md); padding: 16px; } +.obe-meeting { border: 1px solid hsl(var(--border)); border-radius: var(--radius-lg); padding: 16px; } .obe-meeting header, .obe-meeting-controls { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; margin-bottom: 12px; } .obe-meeting header { justify-content: space-between; } -.obe-meeting button { border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 6px 12px; cursor: pointer; } +.obe-meeting button { border: 1px solid hsl(var(--border)); border-radius: var(--radius-md); padding: 6px 12px; cursor: pointer; background: hsl(var(--background)); color: hsl(var(--foreground)); transition: background-color var(--motion-fast) var(--ease-out-soft); } +.obe-meeting button:hover:not(:disabled) { background: var(--hover); } +.obe-meeting button:active { transition: none; } +.obe-meeting-elapsed, .obe-meeting-lock { color: hsl(var(--muted-foreground)); font-size: var(--obe-small-size); } +.obe-meeting-elapsed { font-variant-numeric: tabular-nums; } .obe-meeting button:disabled { opacity: 0.5; cursor: default; } -.obe-meeting button:focus-visible, .obe-meeting a:focus-visible { outline: 2px solid var(--color-ring); outline-offset: 2px; } +.obe-meeting button:focus-visible, .obe-meeting a:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; } .obe-meeting-chunks { display: grid; gap: 12px; margin-block: 12px; } .obe-meeting-chunks li { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; } .obe-meeting audio { max-width: 100%; } .obe-meeting-transcript { user-select: text; white-space: pre-wrap; } -.obe-meeting time { color: var(--color-muted-foreground); font-variant-numeric: tabular-nums; } +.obe-meeting time { color: hsl(var(--muted-foreground)); font-variant-numeric: tabular-nums; } .obe-meeting-summary { user-select: text; white-space: pre-wrap; margin-block: 12px; } -.obe-meeting-notes { border-top: 1px solid var(--color-border); margin-top: 16px; padding-top: 12px; } +.obe-meeting-notes { border-top: 1px solid hsl(var(--border)); margin-top: 16px; padding-top: 12px; } From 2e238d0b7c80cb50cd9b2c2010b1c532a8a1a3cf Mon Sep 17 00:00:00 2001 From: Eliot Lim Date: Fri, 9 Oct 2026 09:07:22 +0800 Subject: [PATCH 3/5] chore(lint): guard design tokens with warning baselines --- eslint-rules/design-token-utils.mjs | 33 +++++++++++++++++ eslint-rules/design-tokens.test.mjs | 26 ++++++++++++++ eslint-rules/no-arbitrary-motion.mjs | 7 ++++ eslint-rules/no-palette-color.mjs | 19 ++++++++++ eslint-rules/no-raw-z.mjs | 3 ++ eslint.config.mjs | 35 ++++++++++++++++--- .../ui/scripts/assert-spacing-stylelint.mjs | 31 +++++++++++++++- .../ui/scripts/design-token-stylelint.mjs | 18 ++++++++++ packages/ui/stylelint.config.mjs | 23 +++++++++--- 9 files changed, 186 insertions(+), 9 deletions(-) create mode 100644 eslint-rules/design-token-utils.mjs create mode 100644 eslint-rules/design-tokens.test.mjs create mode 100644 eslint-rules/no-arbitrary-motion.mjs create mode 100644 eslint-rules/no-palette-color.mjs create mode 100644 eslint-rules/no-raw-z.mjs create mode 100644 packages/ui/scripts/design-token-stylelint.mjs diff --git a/eslint-rules/design-token-utils.mjs b/eslint-rules/design-token-utils.mjs new file mode 100644 index 000000000..5a8ebc232 --- /dev/null +++ b/eslint-rules/design-token-utils.mjs @@ -0,0 +1,33 @@ +// Match class-bearing JSX and hoisted style/class constants, as spacing does. +export function classContext(node) { + for (let current = node.parent; current; current = current.parent) { + if (current.type === 'JSXAttribute') return current.name.name === 'className'; + if (['JSXElement', 'JSXFragment'].includes(current.type)) return false; + if (current.type === 'VariableDeclarator' && /class|style|motion/i.test(current.id.name ?? '')) return true; + if (current.type === 'Property' && /class|style/i.test(current.key.name ?? current.key.value ?? '')) return true; + } + return false; +} +export function classRule(description, rejects, extra = () => ({})) { + return { + meta: {type: 'suggestion', docs: {description}, schema: [], messages: {token: '{{value}} must use a named design token.'}}, + create(context) { + const report = (node, value) => context.report({node, messageId: 'token', data: {value}}); + function check(node, value) { + if (!classContext(node)) return; + for (const utility of value.split(/\s+/)) if (rejects(utility, context.filename)) report(node, utility); + } + const additional = extra(report); + return { + ...additional, + Literal(node) { + if (typeof node.value === 'string') check(node, node.value); + additional.Literal?.(node); + }, + TemplateLiteral(node) { + check(node, node.quasis.map(q => q.value.cooked ?? q.value.raw).join('${}')); + }, + }; + }, + }; +} diff --git a/eslint-rules/design-tokens.test.mjs b/eslint-rules/design-tokens.test.mjs new file mode 100644 index 000000000..bb62999c2 --- /dev/null +++ b/eslint-rules/design-tokens.test.mjs @@ -0,0 +1,26 @@ +import {RuleTester} from 'eslint'; +import {noArbitraryMotion} from './no-arbitrary-motion.mjs'; +import {noPaletteColor} from './no-palette-color.mjs'; +import {noRawZ} from './no-raw-z.mjs'; +const tester = new RuleTester({languageOptions: {ecmaVersion: 2022, sourceType: 'module', parserOptions: {ecmaFeatures: {jsx: true}}}}); +const jsx = value => `
`; +const invalid = values => values.map(value => ({code: jsx(value), errors: [{messageId: 'token'}]})); +tester.run('no-arbitrary-motion', noArbitraryMotion, { + valid: [jsx('duration-(--motion-base) ease-out-soft transition-colors'), + {code: jsx('ease-spring'), filename: '/ui/switch.tsx'}, + {code: jsx('zoom-in-95'), filename: '/ui/overlay-motion.ts'}, + 'const text = "duration-200";'], + invalid: [...invalid(['hover:duration-200', 'duration-[180ms]', 'delay-100', 'delay-[1s]', 'ease-in-out', 'ease-linear', 'ease-[cubic-bezier(0,0,1,1)]', 'animate-[foo_1s]', 'zoom-out-95', 'ease-spring']), + {code: 'const menuClass = `duration-200 ${active}`;', errors: [{messageId: 'token'}]}], +}); +tester.run('no-palette-color', noPaletteColor, { + valid: [jsx('bg-muted text-muted-foreground bg-[var(--data-red)] text-[hsl(var(--foreground))]'), '
', '
'], + invalid: [...invalid(['dark:bg-red-500/20', 'text-slate-100', 'border-t-blue-300', 'bg-[#fff]', 'text-[rgb(0,0,0)]', 'bg-[hsl(0_0%_0%)]']), + {code: '
', errors: [{messageId: 'token'}]}, + {code: '
', errors: [{messageId: 'token'}]}], +}); +tester.run('no-raw-z', noRawZ, { + valid: [jsx('z-menu hover:z-sticky'), 'const label = "z-50";'], + invalid: [...invalid(['z-50', '-z-10', 'hover:z-[1]', 'z-[var(--x)]']), + {code: 'const menuStyles = "z-10";', errors: [{messageId: 'token'}]}], +}); diff --git a/eslint-rules/no-arbitrary-motion.mjs b/eslint-rules/no-arbitrary-motion.mjs new file mode 100644 index 000000000..704149a8a --- /dev/null +++ b/eslint-rules/no-arbitrary-motion.mjs @@ -0,0 +1,7 @@ +import {classRule} from './design-token-utils.mjs'; +export const noArbitraryMotion = classRule('Use the DSX motion scale.', (value, filename) => { + if (/(?:^|:)ease-spring!?$/.test(value) && !/(?:^|\/)switch\.tsx$/.test(filename)) return true; + if (/(?:^|:)zoom-(?:in|out)-95!?$/.test(value)) return !filename.endsWith('/overlay-motion.ts'); + return /(?:^|:)(?:(?:duration|delay)-(?:\d+|\[[^\]]+\])|ease-(?:in|out|in-out|linear|\[[^\]]+\])|animate-\[[^\]]+\])!?$/.test(value); +}); +export default {rules: {'no-arbitrary-motion': noArbitraryMotion}}; diff --git a/eslint-rules/no-palette-color.mjs b/eslint-rules/no-palette-color.mjs new file mode 100644 index 000000000..c21b2fca9 --- /dev/null +++ b/eslint-rules/no-palette-color.mjs @@ -0,0 +1,19 @@ +import {classRule} from './design-token-utils.mjs'; +const colorKey = /^(?:color|background(?:Color)?|border(?:Top|Right|Bottom|Left)?(?:Color)?|outline(?:Color)?|fill|stroke|boxShadow|caretColor|textDecorationColor|accentColor)$/; +const literal = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\(\s*[\d.]/i; +export const noPaletteColor = classRule('Use semantic editor or data colours.', value => + /(?:^|:)(?:bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|outline|from|via|to|decoration|caret|accent)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/[^\s]+)?!?$/.test(value) + || /(?:^|:)(?:bg|text|border(?:-[trblxyse])?|ring|fill|stroke|outline|from|via|to|decoration|caret|accent)-\[/.test(value) && literal.test(value), report => ({ + Literal(node) { + if (typeof node.value !== 'string' || !literal.test(node.value)) return; + const property = node.parent; + if (property.type !== 'Property' || !colorKey.test(property.key.name ?? property.key.value ?? '')) return; + for (let parent = property.parent; parent; parent = parent.parent) { + if (parent.type === 'JSXAttribute') { + if (parent.name.name === 'style') report(node, node.value); + return; + } + } + }, +})); +export default {rules: {'no-palette-color': noPaletteColor}}; diff --git a/eslint-rules/no-raw-z.mjs b/eslint-rules/no-raw-z.mjs new file mode 100644 index 000000000..2ada065b9 --- /dev/null +++ b/eslint-rules/no-raw-z.mjs @@ -0,0 +1,3 @@ +import {classRule} from './design-token-utils.mjs'; +export const noRawZ = classRule('Use a named stacking tier.', value => /(?:^|:)-?z-(?:\d+|\[[^\]]+\])!?$/.test(value)); +export default {rules: {'no-raw-z': noRawZ}}; diff --git a/eslint.config.mjs b/eslint.config.mjs index 234fb9873..d20445e53 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -7,6 +7,9 @@ import nextPlugin from '@next/eslint-plugin-next'; import globals from 'globals'; import e2eIsolation from './eslint-rules/e2e-workspace-isolation.mjs'; import noArbitrarySpacing from './eslint-rules/no-arbitrary-spacing.mjs'; +import noArbitraryMotion from './eslint-rules/no-arbitrary-motion.mjs'; +import noPaletteColor from './eslint-rules/no-palette-color.mjs'; +import noRawZ from './eslint-rules/no-raw-z.mjs'; import noHoverGeometry from './eslint-rules/no-hover-geometry.mjs'; export default tseslint.config( @@ -25,8 +28,6 @@ export default tseslint.config( '**/blob-report/**', // Rust host + generated Tauri capability schemas. 'packages/app/src-tauri/**', - // shadcn/ui primitives, kept verbatim from upstream. - 'packages/ui/src/components/ui/**', // Vendored UMD bundles (d3 / Observable Plot) inlined into the HTML export. 'packages/ui/src/export/vendor/**', // Generated mirror of the ledger plugin's PURE report folds (LX-3) — @@ -54,16 +55,26 @@ export default tseslint.config( }, { // Hover may repaint an element, but it must not alter geometry and shift - // adjacent content. UI primitives remain covered by the upstream ignore. + // adjacent content. plugins: {'layout-shift': noHoverGeometry}, rules: {'layout-shift/no-hover-geometry': 'error'}, }, { // Product spacing follows Tailwind's shared scale; bracket-arbitrary values // for padding, margin, and gaps would silently introduce one-off geometry. - plugins: {tailwind: noArbitrarySpacing}, + plugins: {tailwind: {rules: {...noArbitrarySpacing.rules, ...noArbitraryMotion.rules, ...noPaletteColor.rules, ...noRawZ.rules}}}, rules: {'tailwind/no-arbitrary-spacing': 'error'}, }, + { + // DSX warn rollout: baseline 13 duration + 3 easing utilities, 208 palette + // utilities, 43 numeric z utilities; preserve existing debt without CI failure. + files: ['packages/{ui,web}/src/**/*.{ts,tsx}'], + rules: { + 'tailwind/no-arbitrary-motion': 'warn', + 'tailwind/no-palette-color': 'warn', + 'tailwind/no-raw-z': 'warn', + }, + }, { // Plain Node scripts (build helpers, etc.). files: ['**/*.{js,cjs,mjs}'], @@ -109,4 +120,20 @@ export default tseslint.config( plugins: {e2e: e2eIsolation}, rules: {'e2e/workspace-isolation': 'error'}, }, + { + // Upstream primitives retain their formatting/type lint exemption, but + // DSX guards cover them too (including future overlay motion regressions). + files: ['packages/ui/src/components/ui/**/*.{ts,tsx}'], + rules: { + ...Object.fromEntries(Object.keys({ + ...js.configs.recommended.rules, + ...Object.assign({}, ...tseslint.configs.recommended.map(config => config.rules)), + ...react.configs.flat.recommended.rules, + }).map(name => [name, 'off'])), + 'indent': 'off', 'linebreak-style': 'off', 'quotes': 'off', 'semi': 'off', + 'no-eval': 'off', 'no-new-func': 'off', + 'layout-shift/no-hover-geometry': 'off', + 'tailwind/no-arbitrary-spacing': 'off', + }, + }, ); diff --git a/packages/ui/scripts/assert-spacing-stylelint.mjs b/packages/ui/scripts/assert-spacing-stylelint.mjs index 66c69ad56..61448ea5f 100644 --- a/packages/ui/scripts/assert-spacing-stylelint.mjs +++ b/packages/ui/scripts/assert-spacing-stylelint.mjs @@ -19,7 +19,7 @@ async function assertAllowed(declaration) { async function assertRejected(declaration) { const warnings = await warningsFor(`.spc2-negative-control { ${declaration}; }`); - if (!warnings.some((warning) => warning.rule === rule)) { + if (!warnings.some((warning) => [rule, 'design-tokens/role-values'].includes(warning.rule))) { throw new Error(`SPC-2 stylelint negative control did not reject ${declaration}`); } } @@ -33,3 +33,32 @@ await assertRejected('border-radius: calc(var(--radius) + 3px)'); await assertRejected('PADDING: 7PX'); console.log('SPC-2 stylelint controls passed (2 positive, 4 negative)'); + +// DSX guards: token declarations and semantic colours remain legal. +for (const value of [ + 'transition: color var(--motion-fast) var(--ease-out-soft)', + 'transition-duration: 0s', 'animation-timing-function: linear', + 'color: hsl(var(--foreground) / 0.6)', 'border-radius: var(--radius-lg)', + 'z-index: var(--z-index-menu)', 'font-size: var(--obe-small-size)', + '--motion-fast: 120ms', '--obe-code-num: hsl(28 80% 38%)', +]) { + const warnings = await warningsFor(`.obe-control { ${value}; }`); + if (warnings.length) throw new Error(`DSX allowed ${value}: ${JSON.stringify(warnings)}`); +} +for (const value of [ + 'transition: color 120ms ease', 'animation: enter .2s ease-out', + 'transition-timing-function: cubic-bezier(0, 0, 1, 1)', + 'color: #abcdef', 'border: 1px solid rgb(1 2 3)', + 'border-radius: 4px', 'border-radius: var(--anything)', + 'font-size: 14px', 'z-index: 4', +]) { + const warnings = await warningsFor(`.obe-control { ${value}; }`); + if (!warnings.length || warnings.some(w => w.severity !== 'warning')) throw new Error(`DSX warn guard missed ${value}`); +} +const {readFileSync} = await import('node:fs'); +const css = readFileSync(codeFilename, 'utf8'); +const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) ?? []; +if (reducedBlocks.length !== 2 || !reducedBlocks[0].includes('0.01ms !important') || !reducedBlocks[1].includes('.obe-rcursor-label { opacity: 1; }')) { + throw new Error('Reduced motion must use only the global switch and remote cursor end-state exception'); +} +console.log('DSX stylelint controls passed'); diff --git a/packages/ui/scripts/design-token-stylelint.mjs b/packages/ui/scripts/design-token-stylelint.mjs new file mode 100644 index 000000000..caea15ba0 --- /dev/null +++ b/packages/ui/scripts/design-token-stylelint.mjs @@ -0,0 +1,18 @@ +import stylelint from 'stylelint'; +const ruleName = 'design-tokens/role-values'; +const radiusAtom = String.raw`(?:0|50%|9999px|var\(--(?:radius-(?:sm|md|lg|xl)|ob-page-radius)\))`; +const radius = new RegExp(`^${radiusAtom}(?:\\s+${radiusAtom}){0,3}$`); +const font = /^(?:var\(--obe-(?:font-size|[\w-]+-size|code-inline)\)|inherit|(?:0(?:\.\d+)?|1(?:\.0+)?)em)$/; +const z = /^(?:var\(--z-index-[\w-]+\)|0|auto|-1)$/; +export default stylelint.createPlugin(ruleName, enabled => (root, result) => { + if (!enabled) return; + root.walkDecls(decl => { + const prop = decl.prop.toLowerCase(); + const pattern = prop === 'border-radius' ? radius : prop === 'z-index' ? z + : prop === 'font-size' && /\.obe-[\w-]+/.test(decl.parent.selector ?? '') ? font : null; + if (pattern && !pattern.test(decl.value)) stylelint.utils.report({ + ruleName, result, node: decl, + message: `Use a DSX role token for ${prop}: ${decl.value}`, + }); + }); +}); diff --git a/packages/ui/stylelint.config.mjs b/packages/ui/stylelint.config.mjs index 068d6f33f..bfb7e0a93 100644 --- a/packages/ui/stylelint.config.mjs +++ b/packages/ui/stylelint.config.mjs @@ -1,3 +1,4 @@ +import designTokens from './scripts/design-token-stylelint.mjs'; const gridPx = String.raw`-?(?:4|6|8|10|12|16|20|24|28|32|36|40|44|48|56|64|80|96|112|128|144|160|176|192|208|224|240|256|288|320|384)px`; const gridRem = String.raw`-?(?:0\.25|0\.375|0\.5|0\.625|0\.75|1|1\.25|1\.5|1\.75|2|2\.25|2\.5|2\.75|3|3\.5|4|5|6|7|8|9|10|11|12|13|14|15|16|18|20|24)rem`; const variable = String.raw`var\([^)]*\)`; @@ -8,18 +9,32 @@ const calculation = String.raw`calc\(\s*${calcInitialAtom}(?:\s+[-+*/]\s+${calcO const percentage = String.raw`-?(?:[0-9]*\.)?[0-9]+%`; const spacingAtom = String.raw`(?:0|auto|${gridPx}|${gridRem}|${percentage}|${variable}|${calculation})`; const spacingValue = `/^${spacingAtom}(?:\\s+${spacingAtom}){0,3}$/`; -const radiusAtom = String.raw`(?:0|50%|999px|9999px|4px|6px|8px|${variable}|${calculation}|calc\(var\(--radius\) - 2px\))`; -const radiusValue = `/^${radiusAtom}(?:\\s+${radiusAtom}){0,3}$/`; - export default { + plugins: [designTokens], // The package script intentionally passes only src/index.css; generated CSS // and component-local styles are owned by other spacing clusters. rules: { + // DSX warn-only baseline at foundation input: durations 44, easings ~40, + // literal colours 99, radius 84, editor font sizes ~120, z-index 1. + 'design-tokens/role-values': [true, {severity: 'warning'}], + 'declaration-property-value-disallowed-list': [{ + '/^(transition|animation)(-duration|-delay)?$/': [ + '/(? Date: Fri, 9 Oct 2026 09:09:28 +0800 Subject: [PATCH 4/5] fix(ui): complete token audit and presentation scaling --- eslint.config.mjs | 2 ++ .../ui/scripts/assert-spacing-stylelint.mjs | 6 +++--- .../ui/scripts/design-token-stylelint.mjs | 2 +- packages/ui/src/index.css | 19 ++++++++++++++++--- packages/ui/stylelint.config.mjs | 6 +++++- 5 files changed, 27 insertions(+), 8 deletions(-) diff --git a/eslint.config.mjs b/eslint.config.mjs index d20445e53..0978d0e1d 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -68,6 +68,8 @@ export default tseslint.config( { // DSX warn rollout: baseline 13 duration + 3 easing utilities, 208 palette // utilities, 43 numeric z utilities; preserve existing debt without CI failure. + // Measured with these guards at 1d6ef346: motion 33→16, palette 152→152, + // z 40→40 (includes primitives; excludes generated/vendor sources). files: ['packages/{ui,web}/src/**/*.{ts,tsx}'], rules: { 'tailwind/no-arbitrary-motion': 'warn', diff --git a/packages/ui/scripts/assert-spacing-stylelint.mjs b/packages/ui/scripts/assert-spacing-stylelint.mjs index 61448ea5f..ad93ce1c3 100644 --- a/packages/ui/scripts/assert-spacing-stylelint.mjs +++ b/packages/ui/scripts/assert-spacing-stylelint.mjs @@ -40,13 +40,13 @@ for (const value of [ 'transition-duration: 0s', 'animation-timing-function: linear', 'color: hsl(var(--foreground) / 0.6)', 'border-radius: var(--radius-lg)', 'z-index: var(--z-index-menu)', 'font-size: var(--obe-small-size)', - '--motion-fast: 120ms', '--obe-code-num: hsl(28 80% 38%)', + 'font-size: .875em', 'font-size: 1em', '--motion-fast: 120ms', '--obe-code-num: hsl(28 80% 38%)', ]) { const warnings = await warningsFor(`.obe-control { ${value}; }`); if (warnings.length) throw new Error(`DSX allowed ${value}: ${JSON.stringify(warnings)}`); } for (const value of [ - 'transition: color 120ms ease', 'animation: enter .2s ease-out', + 'transition: color 120ms ease', 'transition-duration: 0ms', 'animation: enter .2s ease-out', 'transition-timing-function: cubic-bezier(0, 0, 1, 1)', 'color: #abcdef', 'border: 1px solid rgb(1 2 3)', 'border-radius: 4px', 'border-radius: var(--anything)', @@ -58,7 +58,7 @@ for (const value of [ const {readFileSync} = await import('node:fs'); const css = readFileSync(codeFilename, 'utf8'); const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) ?? []; -if (reducedBlocks.length !== 2 || !reducedBlocks[0].includes('0.01ms !important') || !reducedBlocks[1].includes('.obe-rcursor-label { opacity: 1; }')) { +if (reducedBlocks.length !== 2 || !reducedBlocks[0].includes('0.01ms !important') || !reducedBlocks[1].includes('.obe-rcursor-label { animation: none; opacity: 1; }')) { throw new Error('Reduced motion must use only the global switch and remote cursor end-state exception'); } console.log('DSX stylelint controls passed'); diff --git a/packages/ui/scripts/design-token-stylelint.mjs b/packages/ui/scripts/design-token-stylelint.mjs index caea15ba0..9fa987981 100644 --- a/packages/ui/scripts/design-token-stylelint.mjs +++ b/packages/ui/scripts/design-token-stylelint.mjs @@ -2,7 +2,7 @@ import stylelint from 'stylelint'; const ruleName = 'design-tokens/role-values'; const radiusAtom = String.raw`(?:0|50%|9999px|var\(--(?:radius-(?:sm|md|lg|xl)|ob-page-radius)\))`; const radius = new RegExp(`^${radiusAtom}(?:\\s+${radiusAtom}){0,3}$`); -const font = /^(?:var\(--obe-(?:font-size|[\w-]+-size|code-inline)\)|inherit|(?:0(?:\.\d+)?|1(?:\.0+)?)em)$/; +const font = /^(?:var\(--obe-(?:font-size|[\w-]+-size|code-inline)\)|inherit|(?:0?(?:\.\d+)|0|1(?:\.0+)?)em)$/; const z = /^(?:var\(--z-index-[\w-]+\)|0|auto|-1)$/; export default stylelint.createPlugin(ruleName, enabled => (root, result) => { if (!enabled) return; diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index 35e12a97d..d3061fd7e 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1607,7 +1607,19 @@ a.obe-mention { margin: auto 0; /* vertically centre short slides; tall ones scroll */ animation: ob-slide-in var(--motion-slow) var(--ease-out-soft) both; } -.ob-present-full .ob-slide { --obe-font-size: 1.08rem; font-size: var(--obe-font-size); } +.ob-present-full .ob-slide { + --obe-font-size: 1.08rem; + /* Recompute derived sizes at the scaling context (CSS variables resolve here). */ + --obe-title-size: calc(var(--obe-font-size) * 2.5); + --obe-code-size: calc(var(--obe-font-size) * 0.875); + --obe-small-size: calc(var(--obe-font-size) * 0.875); + --obe-caption-size: calc(var(--obe-font-size) * 0.75); + --obe-h1-size: calc(var(--obe-font-size) * 1.875); + --obe-h2-size: calc(var(--obe-font-size) * 1.5); + --obe-h3-size: calc(var(--obe-font-size) * 1.25); + --obe-line: calc(var(--obe-font-size) * var(--obe-leading)); + font-size: var(--obe-font-size); +} @keyframes ob-slide-in { from { opacity: 0; transform: translateY(16px); } to { opacity: 1; transform: none; } @@ -1844,7 +1856,7 @@ a.obe-mention { .obe-table-fixed { table-layout: fixed; width: 100%; } .obe-table-col-resizer { position: absolute; - z-index: calc(var(--z-index-local-overlay) - 1); + z-index: var(--z-index-pane-overlay); top: 0; bottom: 0; width: 8px; @@ -3481,7 +3493,8 @@ a.obe-mention { 100% { opacity: 0; } } @media (prefers-reduced-motion: reduce) { - .obe-rcursor-label { opacity: 1; } + /* The forwards fill would override opacity after the global 0.01ms finish. */ + .obe-rcursor-label { animation: none; opacity: 1; } } /* Searchable select trigger (the popover surface is Radix Command). */ diff --git a/packages/ui/stylelint.config.mjs b/packages/ui/stylelint.config.mjs index bfb7e0a93..29ef5058d 100644 --- a/packages/ui/stylelint.config.mjs +++ b/packages/ui/stylelint.config.mjs @@ -16,10 +16,14 @@ export default { rules: { // DSX warn-only baseline at foundation input: durations 44, easings ~40, // literal colours 99, radius 84, editor font sizes ~120, z-index 1. + // Measured at 1d6ef346 with these guards: colour 101→36, radius 84→0, + // font-size 124→119, z-index 2→0 (includes a calculated local tier). + // Duration/easing combined findings 49→7: five attention timers and + // two global reduced-motion overrides remain intentionally literal. 'design-tokens/role-values': [true, {severity: 'warning'}], 'declaration-property-value-disallowed-list': [{ '/^(transition|animation)(-duration|-delay)?$/': [ - '/(? Date: Fri, 9 Oct 2026 11:06:02 +0800 Subject: [PATCH 5/5] fix(ui): apply endorsed DSX foundation corrections --- docs/design/design-system-manifest-2026-10.md | 2 ++ eslint-rules/design-tokens.test.mjs | 4 ++-- eslint-rules/no-arbitrary-motion.mjs | 1 + eslint-rules/no-raw-z.mjs | 2 +- packages/ui/scripts/assert-spacing-stylelint.mjs | 9 ++++++--- packages/ui/scripts/design-token-stylelint.mjs | 1 + packages/ui/src/components/ui/overlay-motion.ts | 1 + packages/ui/src/components/ui/select.tsx | 3 ++- .../src/export/__tests__/foundationTokens.test.ts | 15 ++++++++++++--- packages/ui/src/export/toHtml.ts | 1 + packages/ui/src/index.css | 4 ++-- packages/ui/stylelint.config.mjs | 3 +-- 12 files changed, 32 insertions(+), 14 deletions(-) diff --git a/docs/design/design-system-manifest-2026-10.md b/docs/design/design-system-manifest-2026-10.md index aa2e62b9d..13c700f2c 100644 --- a/docs/design/design-system-manifest-2026-10.md +++ b/docs/design/design-system-manifest-2026-10.md @@ -123,6 +123,8 @@ remote insert is noise (the reference editor also inserts blocks with no entranc dwell 2.8s (:3606), skeleton shimmer 1.6s (:442). They keep their durations but must use an `--ease-*` token instead of `ease-out`/`ease`. Adding a new one requires a row here. +Select exit: **none** — nested dismissable layer. + ### 2.3 Reduced motion | Rule | Decision | diff --git a/eslint-rules/design-tokens.test.mjs b/eslint-rules/design-tokens.test.mjs index bb62999c2..8dfcb7e22 100644 --- a/eslint-rules/design-tokens.test.mjs +++ b/eslint-rules/design-tokens.test.mjs @@ -6,7 +6,7 @@ const tester = new RuleTester({languageOptions: {ecmaVersion: 2022, sourceType: const jsx = value => `
`; const invalid = values => values.map(value => ({code: jsx(value), errors: [{messageId: 'token'}]})); tester.run('no-arbitrary-motion', noArbitraryMotion, { - valid: [jsx('duration-(--motion-base) ease-out-soft transition-colors'), + valid: [jsx('duration-0 active:duration-0!'), jsx('duration-(--motion-base) ease-out-soft transition-colors'), {code: jsx('ease-spring'), filename: '/ui/switch.tsx'}, {code: jsx('zoom-in-95'), filename: '/ui/overlay-motion.ts'}, 'const text = "duration-200";'], @@ -20,7 +20,7 @@ tester.run('no-palette-color', noPaletteColor, { {code: '
', errors: [{messageId: 'token'}]}], }); tester.run('no-raw-z', noRawZ, { - valid: [jsx('z-menu hover:z-sticky'), 'const label = "z-50";'], + valid: [jsx('z-0 hover:z-0!'), jsx('z-menu hover:z-sticky'), 'const label = "z-50";'], invalid: [...invalid(['z-50', '-z-10', 'hover:z-[1]', 'z-[var(--x)]']), {code: 'const menuStyles = "z-10";', errors: [{messageId: 'token'}]}], }); diff --git a/eslint-rules/no-arbitrary-motion.mjs b/eslint-rules/no-arbitrary-motion.mjs index 704149a8a..232bb2785 100644 --- a/eslint-rules/no-arbitrary-motion.mjs +++ b/eslint-rules/no-arbitrary-motion.mjs @@ -1,5 +1,6 @@ import {classRule} from './design-token-utils.mjs'; export const noArbitraryMotion = classRule('Use the DSX motion scale.', (value, filename) => { + if (/(?:^|:)duration-0!?$/.test(value)) return false; if (/(?:^|:)ease-spring!?$/.test(value) && !/(?:^|\/)switch\.tsx$/.test(filename)) return true; if (/(?:^|:)zoom-(?:in|out)-95!?$/.test(value)) return !filename.endsWith('/overlay-motion.ts'); return /(?:^|:)(?:(?:duration|delay)-(?:\d+|\[[^\]]+\])|ease-(?:in|out|in-out|linear|\[[^\]]+\])|animate-\[[^\]]+\])!?$/.test(value); diff --git a/eslint-rules/no-raw-z.mjs b/eslint-rules/no-raw-z.mjs index 2ada065b9..5657c7163 100644 --- a/eslint-rules/no-raw-z.mjs +++ b/eslint-rules/no-raw-z.mjs @@ -1,3 +1,3 @@ import {classRule} from './design-token-utils.mjs'; -export const noRawZ = classRule('Use a named stacking tier.', value => /(?:^|:)-?z-(?:\d+|\[[^\]]+\])!?$/.test(value)); +export const noRawZ = classRule('Use a named stacking tier.', value => !/(?:^|:)z-0!?$/.test(value) && /(?:^|:)-?z-(?:\d+|\[[^\]]+\])!?$/.test(value)); export default {rules: {'no-raw-z': noRawZ}}; diff --git a/packages/ui/scripts/assert-spacing-stylelint.mjs b/packages/ui/scripts/assert-spacing-stylelint.mjs index ad93ce1c3..0b83c5785 100644 --- a/packages/ui/scripts/assert-spacing-stylelint.mjs +++ b/packages/ui/scripts/assert-spacing-stylelint.mjs @@ -19,7 +19,7 @@ async function assertAllowed(declaration) { async function assertRejected(declaration) { const warnings = await warningsFor(`.spc2-negative-control { ${declaration}; }`); - if (!warnings.some((warning) => [rule, 'design-tokens/role-values'].includes(warning.rule))) { + if (!warnings.some((warning) => warning.severity === 'error' && [rule, 'design-tokens/role-values'].includes(warning.rule))) { throw new Error(`SPC-2 stylelint negative control did not reject ${declaration}`); } } @@ -49,12 +49,15 @@ for (const value of [ 'transition: color 120ms ease', 'transition-duration: 0ms', 'animation: enter .2s ease-out', 'transition-timing-function: cubic-bezier(0, 0, 1, 1)', 'color: #abcdef', 'border: 1px solid rgb(1 2 3)', - 'border-radius: 4px', 'border-radius: var(--anything)', - 'font-size: 14px', 'z-index: 4', + 'font-size: 14px', ]) { const warnings = await warningsFor(`.obe-control { ${value}; }`); if (!warnings.length || warnings.some(w => w.severity !== 'warning')) throw new Error(`DSX warn guard missed ${value}`); } +for (const value of ['border-radius: 4px', 'border-radius: var(--anything)', 'z-index: 4']) { + const warnings = await warningsFor(`.obe-control { ${value}; }`); + if (!warnings.length || warnings.some(w => w.severity !== 'error')) throw new Error(`DSX error guard missed ${value}`); +} const {readFileSync} = await import('node:fs'); const css = readFileSync(codeFilename, 'utf8'); const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) ?? []; diff --git a/packages/ui/scripts/design-token-stylelint.mjs b/packages/ui/scripts/design-token-stylelint.mjs index 9fa987981..7db0ed632 100644 --- a/packages/ui/scripts/design-token-stylelint.mjs +++ b/packages/ui/scripts/design-token-stylelint.mjs @@ -12,6 +12,7 @@ export default stylelint.createPlugin(ruleName, enabled => (root, result) => { : prop === 'font-size' && /\.obe-[\w-]+/.test(decl.parent.selector ?? '') ? font : null; if (pattern && !pattern.test(decl.value)) stylelint.utils.report({ ruleName, result, node: decl, + severity: prop === 'font-size' ? 'warning' : 'error', message: `Use a DSX role token for ${prop}: ${decl.value}`, }); }); diff --git a/packages/ui/src/components/ui/overlay-motion.ts b/packages/ui/src/components/ui/overlay-motion.ts index 8c93f68e4..f23d0bf3e 100644 --- a/packages/ui/src/components/ui/overlay-motion.ts +++ b/packages/ui/src/components/ui/overlay-motion.ts @@ -1,6 +1,7 @@ // DSX §2: exits fade only; menus enter 2px from their anchor at scale .98. const fade = 'data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:fade-in-0 data-[state=closed]:fade-out-0 data-[state=closed]:pointer-events-none data-[state=closed]:duration-(--motion-fast) data-[state=open]:ease-out-soft data-[state=closed]:ease-in-out-soft'; export const OVERLAY_MOTION = { + menuEnter: 'data-[state=open]:animate-in fade-in-0 duration-(--motion-base) ease-out-soft zoom-in-[0.98] data-[side=bottom]:slide-in-from-top-0.5 data-[side=left]:slide-in-from-right-0.5 data-[side=right]:slide-in-from-left-0.5 data-[side=top]:slide-in-from-bottom-0.5', menu: `${fade} data-[state=open]:duration-(--motion-base) data-[state=open]:zoom-in-[0.98] data-[state=open]:data-[side=bottom]:slide-in-from-top-0.5 data-[state=open]:data-[side=left]:slide-in-from-right-0.5 data-[state=open]:data-[side=right]:slide-in-from-left-0.5 data-[state=open]:data-[side=top]:slide-in-from-bottom-0.5`, dialog: `${fade} data-[state=open]:duration-(--motion-slow) data-[state=open]:zoom-in-[0.98]`, scrim: `${fade} data-[state=open]:duration-(--motion-slow)`, diff --git a/packages/ui/src/components/ui/select.tsx b/packages/ui/src/components/ui/select.tsx index becc10264..c026f0d1d 100644 --- a/packages/ui/src/components/ui/select.tsx +++ b/packages/ui/src/components/ui/select.tsx @@ -200,7 +200,8 @@ const Select = React.forwardRef(function Select( className={cn( "z-50 max-h-[var(--radix-popover-content-available-height)] min-w-[var(--radix-popover-trigger-width)] max-w-[calc(100vw-1rem)]", "overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-popover p-1 text-popover-foreground shadow-overlay outline-hidden", - OVERLAY_MOTION.menu, + // Entrance only — no exit animation (nested dismissable layer). + OVERLAY_MOTION.menuEnter, )} > {items.length === 0 && ( diff --git a/packages/ui/src/export/__tests__/foundationTokens.test.ts b/packages/ui/src/export/__tests__/foundationTokens.test.ts index bf159f4a3..b2df1548d 100644 --- a/packages/ui/src/export/__tests__/foundationTokens.test.ts +++ b/packages/ui/src/export/__tests__/foundationTokens.test.ts @@ -5,6 +5,12 @@ import {readFileSync} from 'node:fs'; const css: string = readFileSync('src/index.css', 'utf8'); const exporter: string = readFileSync('src/export/toHtml.ts', 'utf8'); const root = css.slice(css.indexOf(' :root {'), css.indexOf(' .dark {')); +const dark = css.slice(css.indexOf(' .dark {'), css.indexOf('\n html,')); +function darkToken(name: string): string { + const match = dark.match(new RegExp(`--${name}: ([^;]+);`)); + expect(match, name).not.toBeNull(); + return match![1]; +} function token(name: string): string { const match = root.match(new RegExp(`--${name}: ([^;]+);`)); expect(match, name).not.toBeNull(); @@ -22,21 +28,24 @@ describe('DSX foundation export mirrors', () => { } expect(exporter).toContain(`font-weight: ${token('obe-heading-weight')}; line-height: ${token('obe-h1-leading')};`); expect(exporter).toContain(`font-family: ${token('obe-font-mono')}; font-size: ${token('obe-code-inline')};`); - expect(exporter).toContain(`pre code { font-size: ${body * 0.875}px; line-height: ${token('obe-code-leading')}; }`); + const codeRatio = Number(token('obe-code-size').match(/\* ([\d.]+)/)![1]); + expect(exporter).toContain(`pre code { font-size: ${body * codeRatio}px; line-height: ${token('obe-code-leading')}; }`); }); it('mirrors borderless callout tints and supports authored warn plus legacy warning', () => { expect(exporter).toContain(`.callout { background: hsl(${token('muted')}); }`); + const dual = exporter.slice(exporter.indexOf('const SCHEME_DUAL ='), exporter.indexOf('const stylesFor =')); + expect(dual).toContain(`.callout { background: hsl(${darkToken('muted')}); }`); for (const [variant, hue] of [['warn', 'yellow'], ['success', 'green'], ['danger', 'red']]) { expect(token(`obe-callout-${variant}`)).toBe(`var(--obe-bg-${hue})`); - expect(exporter).toContain(`background: ${token(`obe-bg-${hue}`)}; }`); + const selector = variant === 'warn' ? '.callout[data-variant=warn], .callout[data-variant=warning]' : `.callout[data-variant=${variant}]`; + expect(exporter).toContain(`${selector} { background: ${token(`obe-bg-${hue}`)}; }`); } expect(exporter).toContain('.callout[data-variant=warn], .callout[data-variant=warning]'); expect(exporter).toContain('padding: 12px; border-radius: 8px; border: none; }'); }); it('declares every palette role and syntax hue in both modes', () => { - const dark = css.slice(css.indexOf(' .dark {'), css.indexOf('\n html,')); for (const role of ['fg', 'bg', 'hl']) { for (const hue of ['gray', 'brown', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink', 'red']) { expect(token(`obe-${role}-${hue}`)).toMatch(/^hsl\(/); diff --git a/packages/ui/src/export/toHtml.ts b/packages/ui/src/export/toHtml.ts index 1211d5a36..2ee2ff57c 100644 --- a/packages/ui/src/export/toHtml.ts +++ b/packages/ui/src/export/toHtml.ts @@ -1254,6 +1254,7 @@ const SCHEME_DUAL = ` :root { color-scheme: light dark; } @media (prefers-color-scheme: dark) { body { background: #18181b; color: #e7e7ea; } + .callout { background: hsl(0 0% 18.5%); } /* Brighter text-colour tokens so palette colours stay legible on the dark page (the light-theme hex go muddy). Inline runs reference these via var(); when this query is inactive the var() falls back to the baked light hex. */ diff --git a/packages/ui/src/index.css b/packages/ui/src/index.css index d3061fd7e..22114b60f 100644 --- a/packages/ui/src/index.css +++ b/packages/ui/src/index.css @@ -1039,7 +1039,7 @@ } .obe-text:empty::before { content: attr(data-placeholder); - color: hsl(var(--muted-foreground) / 0.45); + color: var(--obe-placeholder); pointer-events: none; } .obe-heading { flex: 1; min-width: 0; font-weight: var(--obe-heading-weight); } @@ -3117,7 +3117,7 @@ a.obe-mention { border-radius: 9999px; background: hsl(var(--background)); box-shadow: 0 1px 2px rgb(0 0 0 / 0.25); - transition: transform var(--motion-base) var(--ease-out-soft); + transition: transform var(--motion-base) var(--ease-spring); } .obe-kit-switch-on .obe-kit-knob { transform: translateX(1rem); } diff --git a/packages/ui/stylelint.config.mjs b/packages/ui/stylelint.config.mjs index 29ef5058d..eb2759f83 100644 --- a/packages/ui/stylelint.config.mjs +++ b/packages/ui/stylelint.config.mjs @@ -20,7 +20,7 @@ export default { // font-size 124→119, z-index 2→0 (includes a calculated local tier). // Duration/easing combined findings 49→7: five attention timers and // two global reduced-motion overrides remain intentionally literal. - 'design-tokens/role-values': [true, {severity: 'warning'}], + 'design-tokens/role-values': true, 'declaration-property-value-disallowed-list': [{ '/^(transition|animation)(-duration|-delay)?$/': [ '/(?