diff --git a/scripts/design-system/check-color.mjs b/scripts/design-system/check-color.mjs index a869a30e..cb483fd2 100644 --- a/scripts/design-system/check-color.mjs +++ b/scripts/design-system/check-color.mjs @@ -341,6 +341,10 @@ function auditLayers() { // What this still catches is the mistake it was written for: a role invented // by symmetry, restating one step, that no design asked for. const NAME_IS_EARNED = new Map([ + [ + "--border-control", + "An input boundary must clear 3:1 against its surface, unlike a decorative divider.", + ], ["--text-primary", "Three text levels, enforced by name."], ["--text-secondary", "Three text levels, enforced by name."], ["--text-tertiary", "Three text levels, enforced by name."], diff --git a/scripts/design-system/check-contrast.mjs b/scripts/design-system/check-contrast.mjs index 6d41337f..abf3ff18 100644 --- a/scripts/design-system/check-contrast.mjs +++ b/scripts/design-system/check-contrast.mjs @@ -80,6 +80,8 @@ const TEXT_ROLES = [ "--text-disabled", "--purple-12", // accent text: links, active nav, chip labels "--red-12", // error text: failed session start, rejected form + "--amber-12", // warning text in delivery notices and dialogs + "--green-12", // completion text in the foundation alignment proposal ]; /** @@ -98,6 +100,7 @@ const PAIRS = [ // now that the roles are gone. Still measured as a pair, because the text // follows the fill: move the fill and this has to be re-measured. ["--neutral-1", "--neutral-11"], + ["--neutral-1", "--neutral-12"], ]; /** @@ -108,6 +111,8 @@ const PAIRS = [ * cursor — and the hover is the harder one, which is where the gap was. */ const TINT_PAIRS = [ + ["--amber-12", "--amber-3"], + ["--green-12", "--green-3"], ["--purple-12", "--purple-3"], ["--purple-12", "--purple-4"], ]; diff --git a/scripts/design-system/check-type.mjs b/scripts/design-system/check-type.mjs index 161e6a47..e2525c04 100644 --- a/scripts/design-system/check-type.mjs +++ b/scripts/design-system/check-type.mjs @@ -39,6 +39,9 @@ const VIEWER = fileURLToPath( /** Size roles a component may use. Kept in sync with typography.css. */ const SIZE_ROLES = [ + "label", + "label-sm", + "caption", "display", "title", "heading", @@ -58,8 +61,6 @@ const SIZE_ROLES = [ */ const RETIRED_ROLES = new Map([ ["subheading", "text-heading, or text-body-lg if it is prose"], - ["label", "text-body, or text-body-sm in dense chrome"], - ["caption", "text-body-sm"], ["meta", "text-body-sm"], ["code", "text-mono"], ]); @@ -132,9 +133,9 @@ const RULES = [ // // `font-semibold` and `font-normal` are absent from this list on purpose: // they are the two legal weights. - pattern: /\bfont-(?:thin|extralight|light|medium|bold|extrabold|black)\b/g, + pattern: /\bfont-(?:thin|extralight|light|bold|extrabold|black)\b/g, message: - "off-ramp font weight — the system is 400 and 600. Bold is font-semibold. If a one-off genuinely needs another weight, add it to OVERRIDES with a reason.", + "off-ramp font weight — the system is 400 and 500. Emphasis is font-medium; legacy font-semibold resolves to 500. If a one-off genuinely needs another weight, add it to OVERRIDES with a reason.", }, { id: "retired-role", diff --git a/src/shared/design-system/DESIGN.md b/src/shared/design-system/DESIGN.md index 4a3132a4..26ab966d 100644 --- a/src/shared/design-system/DESIGN.md +++ b/src/shared/design-system/DESIGN.md @@ -1,5 +1,27 @@ # DESIGN.md +## Current local adoption + +The product owner has requested BlockUI foundations in the actual Buzz app. +Shared neutrals now use BlockUI text and surface values; primary actions are +neutral. Inter uses the BlockUI 16/24 body, 14/20 small body, 20/28 lead, +24/24 section, 32/32 title and 56/56 hero settings, with 400/500 weights. +Labels have separate 16/24 and 14/20 roles; caption is 12/16. +Buzz retains its 11/13/15 mono roles for source code. +Existing host color names alias the shared tokens. Panel padding is 24, control +inset 16, group gap 32 and page-section gap 64 (all rem-based). +This supersedes the historical 14px body, 400/600 and neutral-ramp values below. +Dark secondary/tertiary text select the lighter BlockUI ramp steps to preserve +Buzz’s APCA targets on raised panels; the raw BlockUI grays remain in the palette. +Buzz keeps its full-window gradient backdrops and glass navigation in both modes. +The app and shared system use the same backdrop and glass materials; content +panels keep the new neutral surfaces. Status, picker, dialog and host compatibility colors now resolve to the shared +palette. Application typography, insets, gaps and corners use shared roles. +The app-wide foundation guard covers src/ (including the Emoji Mart adapter); +layout dimensions, emoji artwork geometry and terminal ANSI/artwork remain +renderer-owned. The terminal UI itself uses the shared colors and mono type. + + How to design well in this client. The token registry says which value to use; this says what tokens cannot express — the judgement a designer makes without thinking and an agent gets wrong without being told. Read it before building a surface. Run `pnpm design:dev` and open `/tests/fixtures/design-system.html` to see the system rendered from the tokens themselves. diff --git a/src/shared/design-system/styles/components.css b/src/shared/design-system/styles/components.css index 3ff57cd5..23b6bae9 100644 --- a/src/shared/design-system/styles/components.css +++ b/src/shared/design-system/styles/components.css @@ -11,7 +11,8 @@ padding: 0 var(--space-control-inset); background: transparent; color: var(--text-primary); - font: inherit; + font-family: inherit; + @apply text-label; cursor: pointer; transition: color var(--duration-state) var(--easing-state), @@ -21,21 +22,22 @@ } .buzz-button[data-size="compact"] { + @apply text-label-sm; height: var(--size-row); border-radius: var(--radius-row); padding-inline: var(--space-control-inset); } .buzz-button[data-variant="primary"] { - background: var(--purple-9); - color: var(--text-on-accent); + background: var(--neutral-11); + color: var(--neutral-1); } /* The accent's own hover step, not a fade. `opacity: 0.9` lightened the label along with the fill and composited an unnamed colour nothing could measure — the move check-color.mjs rejects everywhere else. */ .buzz-button[data-variant="primary"]:hover:not([data-disabled]) { - background: var(--purple-10); + background: var(--neutral-12); } .buzz-button[data-variant="quiet"] { @@ -104,12 +106,12 @@ } .buzz-button[data-icon-variant="solid"] { - background: var(--purple-9); - color: var(--text-on-accent); + background: var(--neutral-11); + color: var(--neutral-1); } .buzz-button[data-icon-variant="solid"]:hover:not([data-disabled]) { - background: var(--purple-10); + background: var(--neutral-12); } /* A gentle acknowledgement/submit action on a quiet surface. This cannot be @@ -187,7 +189,7 @@ } html[data-keyboard-navigation] .buzz-preview-card-example:focus-visible { - outline: 2px solid var(--purple-8); + outline: 2px solid var(--text-primary); outline-offset: 2px; } @@ -557,7 +559,7 @@ } html[data-keyboard-navigation] .search-field input:focus-visible { - outline: 2px solid var(--purple-8); + outline: 2px solid var(--text-primary); outline-offset: 2px; } @@ -664,7 +666,7 @@ html[data-keyboard-navigation] .buzz-button:focus-visible, html[data-keyboard-navigation] .navigation-item:focus-visible, html[data-keyboard-navigation] .buzz-tabs-tab:focus-visible { - outline: 2px solid var(--purple-8); + outline: 2px solid var(--text-primary); outline-offset: 2px; } @@ -745,7 +747,7 @@ background: var(--neutral-3); } .buzz-accordion-trigger:focus-visible { - outline: 2px solid var(--purple-8); + outline: 2px solid var(--text-primary); outline-offset: 2px; } .buzz-accordion-trigger[aria-expanded="true"] > svg { @@ -817,7 +819,7 @@ cursor: not-allowed; } html[data-keyboard-navigation] .buzz-switch-control:focus-visible { - outline: 2px solid var(--purple-8); + outline: 2px solid var(--text-primary); outline-offset: 2px; } } diff --git a/src/shared/design-system/styles/tokens.css b/src/shared/design-system/styles/tokens.css index 173bb443..1960588e 100644 --- a/src/shared/design-system/styles/tokens.css +++ b/src/shared/design-system/styles/tokens.css @@ -1,3 +1,5 @@ +/* BlockUI neutral values (canonical text and ratified surface ladder), adapted + to the existing Buzz ramp. Chromatic ramps and glass remain Buzz-specific. */ /* * Buzz colour tokens. * @@ -289,17 +291,17 @@ is a deliberately cool near-neutral, not a warm tint; use it directly rather than preserve an exploratory #f9f9f9 stop or recreate it with alpha. */ - --neutral-2: #f5f5f6; /* quiet compact controls, callouts, and grouping */ - --neutral-3: #f0f0f0; /* available for a future, genuinely recessed region; bg-panel in dark */ - --neutral-4: #e8e8e8; /* border-primary in light mode */ - --neutral-5: #e0e0e0; /* unused in light; float + selected, dark */ - --neutral-6: #d9d9d9; /* border-primary in dark mode */ - --neutral-7: #cecece; /* no readers */ - --neutral-8: #bbbbbb; /* text-disabled */ - --neutral-9: #8d8d8d; /* text-tertiary */ - --neutral-10: #646464; /* text-secondary */ - --neutral-11: #262626; /* inverse fill, paired with neutral-1 */ - --neutral-12: #0a0a0a; /* text-primary */ + --neutral-2: #f0f0f0; /* quiet compact controls, callouts, and grouping */ + --neutral-3: #e8e8e8; /* available for a future, genuinely recessed region; bg-panel in dark */ + --neutral-4: #dadada; /* border-primary in light mode */ + --neutral-5: #cccccc; /* unused in light; float + selected, dark */ + --neutral-6: #b3b3b3; /* border-primary in dark mode */ + --neutral-7: #959595; /* no readers */ + --neutral-8: #808080; /* text-disabled */ + --neutral-9: #666666; /* text-tertiary */ + --neutral-10: #525252; /* text-secondary */ + --neutral-11: #191919; /* inverse fill, paired with neutral-1 */ + --neutral-12: #000000; /* text-primary */ /* --- Glass. A translucency ramp of fills. ---------------- */ --glass-1: #ffffff5c; /* barely there */ @@ -343,12 +345,16 @@ The scale is intentionally short: 4/8/12/16/20/24 covers the repeated panel gap, row inset, header inset, and workspace gutter without turning every measured distance into a token. */ + --space-half: 0.125rem; + --space-1h: 0.375rem; --space-1: 0.25rem; --space-2: 0.5rem; --space-3: 0.75rem; --space-4: 1rem; --space-5: 1.25rem; --space-6: 1.5rem; + --space-8: 2rem; + --space-16: 4rem; /* --- Corner. Four shapes, each with a distinct job. ------- */ --corner-row: 0.625rem; @@ -373,24 +379,24 @@ are deliberately tighter at the bottom. A documented divergence, not drift: if these ever want to be palette steps, the palette's gray ramp is what should change. */ - --neutral-1: #0f0f0f; - --neutral-2: #161616; - --neutral-3: #1c1c1c; + --neutral-1: #000000; + --neutral-2: #101010; + --neutral-3: #1a1a1a; --neutral-4: #232323; - --neutral-5: #2a2a2a; - --neutral-6: #333333; - --neutral-7: #424242; - --neutral-8: #5a5a5a; + --neutral-5: #333333; + --neutral-6: #404040; + --neutral-7: #595959; + --neutral-8: #737373; /* Steps 9 and 10 are lighter than a linear ramp would put them, because in dark mode they carry text and APCA is polarity-asymmetric: light-on-dark needs more separation than the WCAG ratio implies. At #6e6e6e and #8f8f8f they measured Lc 23–26 and Lc 38–41 against the Lc 60 body-text target while passing WCAG AA — legal, and visibly murky. Sized against the lightest dark surface (bg-float #2a2a2a), which is the binding case. */ - --neutral-9: #a6a6a6; - --neutral-10: #c1c1c1; - --neutral-11: #e5e5e5; - --neutral-12: #f5f5f5; + --neutral-9: #808080; + --neutral-10: #a4a4a4; + --neutral-11: #c7c7c7; + --neutral-12: #ffffff; /* Every coloured family is absent here on purpose. A family points at a palette step, and `.dark` already redefines the whole palette — so @@ -491,6 +497,12 @@ --bg-app: var(--gradient-1); --bg-panel: var(--neutral-1); /* dark: neutral-3 */ --bg-float: var(--neutral-1); /* dark: neutral-5 */ + --bg-scrim: #00000060; + --border-control: var(--neutral-8); + /* Emoji artwork scales with text without becoming a readable type role. */ + --size-emoji-display: calc(var(--type-rem) * 2.625); + --size-emoji-line: calc(var(--type-rem) * 3); + --size-emoji-inline: calc(var(--type-rem) * 1.375); /* `bg-hover` was here and is gone. It was neutral-4 in both modes, so `bg-neutral-4` says it exactly. Its five call sites now do. @@ -515,13 +527,13 @@ control is unavailable, and WCAG exempts inactive controls for the same reason. Raising it to Lc 60 would make disabled read as enabled. Never put information here that a person needs. */ - --text-disabled: var(--neutral-8); + --text-disabled: var(--neutral-7); /* One border, not three. `primary` names the sole deliberate weight, and neutral-4 keeps it at the threshold of perception over both the panel and the atmospheric backdrop. A second weight arrives only with a design that proves a different boundary needs one. */ - --border-primary: var(--neutral-4); + --border-primary: var(--neutral-3); /* --- Identity: deliberately absent. ---------------------- There were six accent roles and every one held the same purple @@ -601,10 +613,11 @@ /* --- Geometry. Repeated relationships from Messages. ----- */ --space-workspace-inset: var(--space-4); --space-panel-gap: var(--space-2); - --space-panel-inset: var(--space-5); - --space-control-inset: var(--space-3); + --space-panel-inset: var(--space-6); + --space-control-inset: var(--space-4); --space-row-gap: var(--space-2); - --space-section-gap: var(--space-4); + --space-section-gap: var(--space-8); + --space-page-section-gap: var(--space-16); /* An inline chip sits in a line of text, so its inset and gap are tighter than a control's and are its own repeated relationship. Both are below the 4px spacing floor because a chip hugs its label — the design draws 3px of @@ -678,22 +691,18 @@ .dark { --bg-panel: var(--neutral-3); --bg-float: var(--neutral-5); + --bg-scrim: #000000a6; + --border-control: var(--neutral-8); --bg-chrome-selected: var(--neutral-5); - /* Two steps lighter than light mode's neutral-4, because a hairline needs more - separation on a dark surface than the same step number provides. Measured - against every surface a border sits on: neutral-4 is 1.08:1 on a panel and - 1.09:1 on the floating composer — drawn, and effectively invisible. - neutral-6 is 1.35 and 1.14. - - Not neutral-5, which is one step and would have been the obvious answer: - `bg-float` and `bg-chrome-selected` are both neutral-5 in dark, so a border - on a floating surface would measure 1.00:1 — the same colour as the thing it - is meant to bound. + /* BlockUI's darker secondary/tertiary grays fall below Buzz's APCA targets + on raised panels. Keep the BlockUI ramp, selecting its lighter steps for + readable text: secondary >= 60, metadata >= 45 on every opaque surface. */ + --text-secondary: var(--neutral-11); + --text-tertiary: var(--neutral-10); - **This asymmetry is the reason the role exists.** Light and dark do not have - to reach for the same step, and no `border-neutral-4` class could say this. */ - --border-primary: var(--neutral-6); + /* BlockUI subtle border: E8E8E8 in light, 232323 in dark. */ + --border-primary: var(--neutral-4); --rim-lit: #ffffff2e; --rim-shade: #ffffff0f; @@ -841,6 +850,7 @@ --color-orange-12: var(--orange-12); --color-app: var(--bg-app); + --color-inset: var(--neutral-2); --color-panel: var(--bg-panel); --color-float: var(--bg-float); diff --git a/src/shared/design-system/styles/typography.css b/src/shared/design-system/styles/typography.css index ca4094fb..b342c363 100644 --- a/src/shared/design-system/styles/typography.css +++ b/src/shared/design-system/styles/typography.css @@ -1,210 +1,73 @@ -/* - * Buzz typography tokens. - * - * Same three layers as colour, and only the role layer is ever used when - * building a screen: - * - * LAYER 1 private ramps --type-size-*, --type-leading-*, --type-tracking-* - * LAYER 2 public roles --text-body, --text-title, ... - * LAYER 3 components text-body, text-title (Tailwind utilities) - * - * Rules, matching the colour system: - * - Literal sizes exist only in the ramp. A role points at a step; a component - * points at a role. No component holds a size. - * - A role carries its whole typographic setting — size, line height, letter - * spacing, weight — because those four are one decision, not four. Tailwind - * v4 supports this via the `--text---` convention, so - * `text-body` alone produces correctly set text. - * - Every size is relative. Nothing here may be expressed in px: fixed pixel - * text freezes against zoom and breaks the person's font-size preference. - * This is the one rule the existing client already learned the hard way. - * - * Naming note: colour roles are also spelled `--text-*` (`--text-primary`) but - * are registered in Tailwind's `--color-*` namespace, while sizes register in - * `--text-*`. So `text-primary` is a colour and `text-body` is a setting — the - * same split Tailwind itself makes. Size roles are named for editorial jobs - * (body, title, caption) and colour roles for emphasis levels (primary, - * secondary), so no one name ever means both. - * - * See DESIGN.md for the judgement these values cannot express. - */ - -/* ============================================================ - LAYER 1 — PRIVATE RAMPS - ============================================================ */ - +/* BlockUI reading, title and label sizes/leading, rendered in Inter. + Sizes follow the host text preference and root zoom. Mono retains Buzz's + existing readable 11/13/15 steps for source code, paths and identifiers. + Cash Sans kerning is not assumed to be exact for Inter. */ :root { - /* --- The zoom and preference dials. ----------------------- - Two independent controls compose here, as in the existing client: - - - Cmd +/- scales the real root font size, so every rem in the app — text, - gaps, radii — grows together. True zoom. - - The font-size preference sets --type-scale, nudging only the type ramp - below, so text changes size without moving the layout. - - Every size derives from this virtual rem, which is itself rem-relative and - therefore rides on top of zoom automatically. At the default preference it - equals 1rem, making --type-size-4 read as 14px. */ --font-system-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; --type-scale: 1; --type-rem: calc(1rem * var(--type-scale)); - - /* --- Size. Seven steps, sized from the product. ----------- - Measured, not composed: across 73 real Buzz screens, 90% of all text is one - size and two sizes cover 93%. 16/18/20/22 together were 1.5%, scattered and - inconsistent — a document outline (H1…H4) imposed on an app that does not - have one. So the ramp is deliberately short, and the gap between 16 and 24 - is deliberately empty. - - Mono sits one step below its sans partner throughout, because at equal size - a monospace face reads larger than Inter and pulls the eye off the sentence. - One rule, three rungs: 11↔12, 13↔14, 15↔16. */ - --type-size-1: calc(var(--type-rem) * 0.6875); /* 11px mono small */ - --type-size-2: calc(var(--type-rem) * 0.75); /* 12px body small */ - --type-size-3: calc(var(--type-rem) * 0.8125); /* 13px mono */ - --type-size-4: calc(var(--type-rem) * 0.875); /* 14px body — the default */ - --type-size-5: calc(var(--type-rem) * 0.9375); /* 15px mono large */ - --type-size-6: calc( - var(--type-rem) * - 1 - ); /* 16px body large, heading */ - --type-size-7: calc(var(--type-rem) * 1.5); /* 24px title */ - --type-size-8: calc(var(--type-rem) * 2); /* 32px display */ - - /* --- Line height. Unitless, so it scales with its own size. - Tight for display text, generous for anything read in quantity, and exactly - 1 for single-line meta so a timestamp cannot enlarge its own row. */ - --type-leading-flat: 1; - --type-leading-tight: 1.2; - --type-leading-snug: 1.35; - --type-leading-normal: 1.5; - --type-leading-relaxed: 1.6; - - /* --- Tracking. An optical correction, not a style. -------- - Inter is drawn for interface text and needs progressively tighter spacing as - it grows: spacing that looks correct at 14px looks loose at 32px. Named per - size step rather than per role, so the correction follows the size it - corrects — two roles at 16px get the same tracking by construction. Turns - slightly positive at the smallest step, where letters need air. */ - --type-tracking-1: 0.005em; /* 11px */ - --type-tracking-2: 0em; /* 12px */ - --type-tracking-3: -0.003em; /* 13px */ - --type-tracking-4: -0.006em; /* 14px */ - --type-tracking-5: -0.009em; /* 15px */ - --type-tracking-6: -0.011em; /* 16px */ - --type-tracking-7: -0.019em; /* 24px */ - --type-tracking-8: -0.024em; /* 32px */ - - /* --- Weight. Two, with two jobs. -------------------------- - 400 is content — everything read. 600 is structure and emphasis: the thing - that names what you are looking at, or the words a sentence leans on. - - Inter Variable can render any weight, which is exactly why only these are - named. 500 was rendered as the marker for a selected channel, an active tab, - and an unread row, and does not read as intent in a scanned list — a - sub-pixel stem difference at body size, yet heavy enough to muddy a column. - 700 is louder than this product needs. Weight is not a ramp. */ --type-weight-normal: 400; - --type-weight-semibold: 600; + --type-weight-medium: 500; + --type-weight-semibold: var(--type-weight-medium); /* Existing consumers. */ } - -/* Conversation type contract, carried over from the existing client: - 13 / 14 / 15px before keyboard zoom. */ :root[data-font-size="smaller"] { - --type-scale: calc(13 / 14); + --type-scale: 0.9; } - :root[data-font-size="larger"] { - --type-scale: calc(15 / 14); + --type-scale: 1.1; } - -/* ============================================================ - LAYER 3 — PUBLIC ROLES, REGISTERED WITH TAILWIND - - Layers 2 and 3 are one block for typography. A colour role can be a bare - custom property because a component reads it through `bg-*` or `text-*`; a - type role has to be a Tailwind theme key or the paired line height, tracking, - and weight cannot travel with the size. So the role IS the registration. - - Named for the job the text does, never for its size — `text-title`, not - `text-28`. A size name is a value in disguise and goes stale the moment the - ramp moves. - ============================================================ */ - @theme { --font-sans: "Inter Variable", Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; --font-mono: var(--font-system-mono); - - /* ---- Sans. Six roles. ----------------------------------- */ - - /* Display. Onboarding and empty states — a screen with nothing on it yet, or - one asking a single question. Most screens have none. */ - --text-display: var(--type-size-8); - --text-display--line-height: var(--type-leading-tight); - --text-display--letter-spacing: var(--type-tracking-8); + --font-weight-semibold: 500; /* Legacy emphasis resolves to the shared medium weight. */ + --text-display: calc(var(--type-rem) * 3.5); + --text-display--line-height: 1; + --text-display--letter-spacing: -0.04em; --text-display--font-weight: var(--type-weight-normal); - - /* Title. The headline *inside* content: a feed card, a thread topic. At 24px - the size carries it, which is why the weight stays at 400 — this is the size - and weight Buzz's own card headlines measure. */ - --text-title: var(--type-size-7); - --text-title--line-height: var(--type-leading-tight); - --text-title--letter-spacing: var(--type-tracking-7); - --text-title--font-weight: var(--type-weight-normal); - - /* Heading. The name of the thing you are looking at: a channel, a panel, a - section. Same size as body-lg and separated from it by weight alone — which - is what the shipping client does at 16/600, and the only text on a channel - screen larger than a message. Deliberately close to body: in a dense app a - section name needs to be identifiable, not loud. */ - --text-heading: var(--type-size-6); - --text-heading--line-height: var(--type-leading-snug); - --text-heading--letter-spacing: var(--type-tracking-6); - --text-heading--font-weight: var(--type-weight-semibold); - - /* Body large. Reading columns and introductions, where a paragraph is the - point rather than a description of something else. */ - --text-body-lg: var(--type-size-6); - --text-body-lg--line-height: var(--type-leading-relaxed); - --text-body-lg--letter-spacing: var(--type-tracking-6); + --text-title: calc(var(--type-rem) * 2); + --text-title--line-height: 1; + --text-title--letter-spacing: -0.015em; + --text-title--font-weight: var(--type-weight-medium); + --text-heading: calc(var(--type-rem) * 1.5); + --text-heading--line-height: 1; + --text-heading--letter-spacing: -0.0075em; + --text-heading--font-weight: var(--type-weight-medium); + --text-body-lg: calc(var(--type-rem) * 1.25); + --text-body-lg--line-height: 1.4; + --text-body-lg--letter-spacing: -0.01em; --text-body-lg--font-weight: var(--type-weight-normal); - - /* Body. The default, and 90% of the product. If you are unsure, this is it. */ - --text-body: var(--type-size-4); - --text-body--line-height: var(--type-leading-normal); - --text-body--letter-spacing: var(--type-tracking-4); + --text-body: calc(var(--type-rem) * 1); + --text-body--line-height: 1.5; + --text-body--letter-spacing: -0.005em; --text-body--font-weight: var(--type-weight-normal); - - /* Body small. Secondary text: descriptions, timestamps, chip and tab labels, - help text under a control. The floor — see DESIGN.md § Type. */ - --text-body-sm: var(--type-size-2); - --text-body-sm--line-height: var(--type-leading-normal); - --text-body-sm--letter-spacing: var(--type-tracking-2); + --text-body-sm: calc(var(--type-rem) * 0.875); + --text-body-sm--line-height: 1.42857; + --text-body-sm--letter-spacing: -0.0025em; --text-body-sm--font-weight: var(--type-weight-normal); - - /* ---- Mono. Three roles, each one step below its sans partner. ---- */ - - /* Mono large, paired with body-lg. Codes and keys a person has to transcribe - or read aloud — the one case where mono is the point rather than the - content's format. */ - --text-mono-lg: var(--type-size-5); - --text-mono-lg--line-height: var(--type-leading-normal); - --text-mono-lg--letter-spacing: var(--type-tracking-5); + --text-label: calc(var(--type-rem) * 1); + --text-label--line-height: 1.5; + --text-label--letter-spacing: 0em; + --text-label--font-weight: var(--type-weight-medium); + --text-label-sm: calc(var(--type-rem) * 0.875); + --text-label-sm--line-height: 1.42857; + --text-label-sm--letter-spacing: 0.0025em; + --text-label-sm--font-weight: var(--type-weight-medium); + --text-caption: calc(var(--type-rem) * 0.75); + --text-caption--line-height: 1.33333; + --text-caption--letter-spacing: 0em; + --text-caption--font-weight: var(--type-weight-normal); + --text-mono-lg: calc(var(--type-rem) * 0.9375); + --text-mono-lg--line-height: 1.5; + --text-mono-lg--letter-spacing: 0em; --text-mono-lg--font-weight: var(--type-weight-normal); - - /* Mono, paired with body. Inline code, pubkeys, paths, branch names, hex - values — anything where the characters matter individually. */ - --text-mono: var(--type-size-3); - --text-mono--line-height: var(--type-leading-normal); - --text-mono--letter-spacing: var(--type-tracking-3); + --text-mono: calc(var(--type-rem) * 0.8125); + --text-mono--line-height: 1.5; + --text-mono--letter-spacing: 0em; --text-mono--font-weight: var(--type-weight-normal); - - /* Mono small, paired with body-sm. Terminal tabs, code-block headers, dense - chrome. Nothing a person reads in quantity. */ - --text-mono-sm: var(--type-size-1); - --text-mono-sm--line-height: var(--type-leading-flat); - --text-mono-sm--letter-spacing: var(--type-tracking-1); + --text-mono-sm: calc(var(--type-rem) * 0.6875); + --text-mono-sm--line-height: 1.45455; + --text-mono-sm--letter-spacing: 0em; --text-mono-sm--font-weight: var(--type-weight-normal); } diff --git a/src/shared/design-system/tokens/registry.ts b/src/shared/design-system/tokens/registry.ts index 7a99406b..e0299555 100644 --- a/src/shared/design-system/tokens/registry.ts +++ b/src/shared/design-system/tokens/registry.ts @@ -472,94 +472,124 @@ export interface TypeRole { export const TYPE_ROLES: TypeRole[] = [ { token: "text-display", - pointsAt: "size 8", - size: "32px", - lineHeight: "1.2", - tracking: "-0.024em", + pointsAt: "type-rem", + size: "56px", + lineHeight: "1", + tracking: "-0.04em", weight: "400", - use: "Onboarding and empty states — a screen with nothing on it yet, or one asking a single question. Most screens have none.", + use: "Expressive welcome or hero.", status: "core", }, { token: "text-title", - pointsAt: "size 7", - size: "24px", - lineHeight: "1.2", - tracking: "-0.019em", - weight: "400", - use: "The headline inside content: a feed card, a thread topic. At this size the size carries it, which is why the weight stays at 400.", + pointsAt: "type-rem", + size: "32px", + lineHeight: "1", + tracking: "-0.015em", + weight: "500", + use: "Screen title.", status: "core", }, { token: "text-heading", - pointsAt: "size 6", - size: "16px", - lineHeight: "1.35", - tracking: "-0.011em", - weight: "600", - use: "The name of the thing you are looking at: a channel, a panel, a section. Same size as body-lg, separated from it by weight alone.", + pointsAt: "type-rem", + size: "24px", + lineHeight: "1", + tracking: "-0.0075em", + weight: "500", + use: "Content section title.", status: "core", }, { token: "text-body-lg", - pointsAt: "size 6", - size: "16px", - lineHeight: "1.6", - tracking: "-0.011em", + pointsAt: "type-rem", + size: "20px", + lineHeight: "1.4", + tracking: "-0.01em", weight: "400", - use: "Reading columns and introductions, where a paragraph is the point rather than a description of something else.", + use: "Lead paragraph.", status: "core", }, { token: "text-body", - pointsAt: "size 4", - size: "14px", + pointsAt: "type-rem", + size: "16px", lineHeight: "1.5", - tracking: "-0.006em", + tracking: "-0.005em", weight: "400", - use: "The default, and 90% of the product. If you are unsure, this is it.", + use: "Reading text and messages.", status: "core", }, { token: "text-body-sm", - pointsAt: "size 2", - size: "12px", + pointsAt: "type-rem", + size: "14px", + lineHeight: "1.42857", + tracking: "-0.0025em", + weight: "400", + use: "Supporting or dense reading text.", + status: "core", + }, + { + token: "text-label", + pointsAt: "type-rem", + size: "16px", lineHeight: "1.5", tracking: "0em", + weight: "500", + use: "Controls and panel titles.", + status: "core", + }, + { + token: "text-label-sm", + pointsAt: "type-rem", + size: "14px", + lineHeight: "1.42857", + tracking: "0.0025em", + weight: "500", + use: "Compact controls and row labels.", + status: "core", + }, + { + token: "text-caption", + pointsAt: "type-rem", + size: "12px", + lineHeight: "1.33333", + tracking: "0em", weight: "400", - use: "Secondary text: descriptions, timestamps, chip and tab labels, help text under a control. The smallest text in the product.", + use: "Timestamps and metadata.", status: "core", }, { token: "text-mono-lg", - pointsAt: "size 5", + pointsAt: "type-rem", size: "15px", lineHeight: "1.5", - tracking: "-0.009em", + tracking: "0em", weight: "400", - use: "Codes and keys a person has to transcribe or read aloud. Paired with body-lg.", + use: "Transcribed codes.", status: "core", mono: true, }, { token: "text-mono", - pointsAt: "size 3", + pointsAt: "type-rem", size: "13px", lineHeight: "1.5", - tracking: "-0.003em", + tracking: "0em", weight: "400", - use: "Inline code, pubkeys, paths, branch names, hex values — anything where the characters matter individually. Paired with body.", + use: "Code and identifiers.", status: "core", mono: true, }, { token: "text-mono-sm", - pointsAt: "size 1", + pointsAt: "type-rem", size: "11px", - lineHeight: "1", - tracking: "0.005em", + lineHeight: "1.45455", + tracking: "0em", weight: "400", - use: "Terminal tabs, code-block headers, dense chrome. Nothing read in quantity. Paired with body-sm.", + use: "Compact code metadata.", status: "core", mono: true, }, @@ -585,47 +615,37 @@ export const TYPE_RAMPS = [ id: "size", name: "Size", description: - "Eight steps, sized from the product rather than composed. Across 73 real Buzz screens, 90% of all text is one size and two sizes cover 93%; 16/18/20/22 together were 1.5%, scattered and inconsistent. So the ramp is deliberately short and the gap between 16 and 24 is deliberately empty — an app does not have a document outline. Mono sits one step below its sans partner throughout. Every value derives from a virtual rem, so the whole ramp follows the person's font-size preference and keyboard zoom.", - steps: [ - { step: 1, job: "mono small", value: "11px" }, - { step: 2, job: "body small", value: "12px" }, - { step: 3, job: "mono", value: "13px" }, - { step: 4, job: "body — the default", value: "14px" }, - { step: 5, job: "mono large", value: "15px" }, - { step: 6, job: "body large, heading", value: "16px" }, - { step: 7, job: "title", value: "24px" }, - { step: 8, job: "display", value: "32px" }, - ], - }, - { - id: "tracking", - name: "Tracking", - description: - "An optical correction, not a style. Inter needs progressively tighter spacing as it grows — tracking that looks correct at 14px looks loose at 32px — so the ramp is named per size step rather than per role, and the correction follows the size it corrects. Two roles at 16px therefore get the same tracking by construction. It turns slightly positive at the smallest step, where letters need air to stay legible.", - steps: [ - { step: 8, job: "32px", value: "-0.024em" }, - { step: 7, job: "24px", value: "-0.019em" }, - { step: 6, job: "16px", value: "-0.011em" }, - { step: 5, job: "15px", value: "-0.009em" }, - { step: 4, job: "14px", value: "-0.006em" }, - { step: 3, job: "13px", value: "-0.003em" }, - { step: 2, job: "12px", value: "0em" }, - { step: 1, job: "11px", value: "0.005em" }, - ], + "BlockUI reading and title sizes in Inter, with Buzz's existing monospace sizes.", + steps: TYPE_ROLES.map((role, index) => ({ + step: index + 1, + job: role.token, + value: role.size, + })), }, { id: "weight", name: "Weight", - description: - "Two values with two jobs, not a ramp. 400 is content — everything read. 600 is structure and emphasis: the thing that names what you are looking at, or the words a sentence leans on. 500 was rendered as the marker for a selected channel, an active tab, and an unread row, and does not read as intent in a scanned list — a sub-pixel stem difference at body size, yet heavy enough to muddy a column. 700 is louder than this product needs. State is said with colour, a fill, or a dot.", + description: "400 for reading, 500 for labels and structure.", steps: [ - { step: 400, job: "content", value: "400" }, - { step: 600, job: "structure and emphasis", value: "600" }, + { step: 1, job: "reading", value: "400" }, + { step: 2, job: "structure", value: "500" }, ], }, ]; export const SPACE = [ + { + step: 0.5, + variable: "--space-half", + value: "2px", + use: "Compact control inset.", + }, + { + step: 1.5, + variable: "--space-1h", + value: "6px", + use: "Compact control gap.", + }, { step: 1, variable: "--space-1", @@ -662,9 +682,17 @@ export const SPACE = [ value: "24px", use: "Separation between distinct content groups.", }, + { step: 8, variable: "--space-8", value: "32px", use: "Content groups." }, + { step: 16, variable: "--space-16", value: "64px", use: "Page sections." }, ]; export const SPACE_ROLES = [ + { + token: "space-page-section-gap", + variable: "--space-page-section-gap", + pointsAt: "space 16", + use: "Separation between page sections.", + }, { token: "space-workspace-inset", variable: "--space-workspace-inset", @@ -680,13 +708,13 @@ export const SPACE_ROLES = [ { token: "space-panel-inset", variable: "--space-panel-inset", - pointsAt: "space 5", + pointsAt: "space 6", use: "The horizontal inset inside a full panel.", }, { token: "space-control-inset", variable: "--space-control-inset", - pointsAt: "space 3", + pointsAt: "space 4", use: "The horizontal inset inside controls and navigation rows.", }, { @@ -698,7 +726,7 @@ export const SPACE_ROLES = [ { token: "space-section-gap", variable: "--space-section-gap", - pointsAt: "space 4", + pointsAt: "space 8", use: "The gap between adjacent navigator sections. Larger than the 1px between rows inside one, because that difference is the only thing saying where a group ends.", }, ]; diff --git a/src/shared/design-system/ui/PanelHeader.tsx b/src/shared/design-system/ui/PanelHeader.tsx index 9415fa51..d628c621 100644 --- a/src/shared/design-system/ui/PanelHeader.tsx +++ b/src/shared/design-system/ui/PanelHeader.tsx @@ -18,7 +18,7 @@ export function PanelHeader({
{icon} {typeof title === "string" ? ( -

{title}

+

{title}

) : ( title )} diff --git a/src/shared/styles/globals.css b/src/shared/styles/globals.css index dfe5a760..e6ee75aa 100644 --- a/src/shared/styles/globals.css +++ b/src/shared/styles/globals.css @@ -18,27 +18,44 @@ /* Legacy utility compatibility. Prefix-specific primary avoids stealing the system's text-primary and border-primary meanings. */ @theme inline { + --spacing: var(--space-1); /* Scale type, not Tailwind spacing (which is also rem-based). Plugins can consume these utilities or multiply custom font sizes by --buzz-text-scale. */ - --text-xs: calc(0.75rem * var(--buzz-text-scale, 1)); - --text-sm: calc(0.875rem * var(--buzz-text-scale, 1)); - --text-base: calc(1rem * var(--buzz-text-scale, 1)); - --text-lg: calc(1.125rem * var(--buzz-text-scale, 1)); - --text-xl: calc(1.25rem * var(--buzz-text-scale, 1)); - --text-2xl: calc(1.5rem * var(--buzz-text-scale, 1)); - --text-3xl: calc(1.875rem * var(--buzz-text-scale, 1)); - --text-4xl: calc(2.25rem * var(--buzz-text-scale, 1)); - --text-5xl: calc(3rem * var(--buzz-text-scale, 1)); - --text-6xl: calc(3.75rem * var(--buzz-text-scale, 1)); - --text-7xl: calc(4.5rem * var(--buzz-text-scale, 1)); - --text-8xl: calc(6rem * var(--buzz-text-scale, 1)); - --text-9xl: calc(8rem * var(--buzz-text-scale, 1)); + --text-xs: var(--text-caption); + --text-xs--line-height: var(--text-caption--line-height); + --text-sm: var(--text-body-sm); + --text-sm--line-height: var(--text-body-sm--line-height); + --text-base: var(--text-body); + --text-base--line-height: var(--text-body--line-height); + --text-lg: var(--text-body-lg); + --text-lg--line-height: var(--text-body-lg--line-height); + --text-xl: var(--text-body-lg); + --text-xl--line-height: var(--text-body-lg--line-height); + --text-2xl: var(--text-heading); + --text-2xl--line-height: var(--text-heading--line-height); + --text-3xl: var(--text-title); + --text-3xl--line-height: var(--text-title--line-height); + --text-4xl: var(--text-title); + --text-4xl--line-height: var(--text-title--line-height); + --text-5xl: var(--text-display); + --text-5xl--line-height: var(--text-display--line-height); + --text-6xl: var(--text-display); + --text-6xl--line-height: var(--text-display--line-height); + --text-7xl: var(--text-display); + --text-7xl--line-height: var(--text-display--line-height); + --text-8xl: var(--text-display); + --text-8xl--line-height: var(--text-display--line-height); + --text-9xl: var(--text-display); + --text-9xl--line-height: var(--text-display--line-height); --font-mono: var(--buzz-mono-family); - --font-legacy-sans: - Inter, ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", - sans-serif; - --radius-3xl: var(--radius-card); - --radius-xl: var(--radius-legacy-control); + --font-legacy-sans: var(--font-sans); + --radius-xs: var(--radius-chip); + --radius-sm: var(--radius-chip); + --radius-md: var(--radius-chip); + --radius-lg: var(--radius-row); + --radius-xl: var(--radius-control); + --radius-2xl: var(--radius-panel); + --radius-3xl: var(--radius-panel); --color-ink: var(--text); --color-muted: var(--text-muted); --color-line: var(--border); @@ -72,21 +89,30 @@ overscroll-behavior: none; } * { + scrollbar-width: thin; box-sizing: border-box; } + :where(strong, b) { + font-weight: var(--type-weight-medium); + } body { - font-size: calc(1rem * var(--buzz-text-scale, 1)); + font-size: var(--text-body); + line-height: var(--text-body--line-height); + letter-spacing: var(--text-body--letter-spacing); margin: 0; } button:not(:where([data-buzz-ui], [data-buzz-ui] *)) { cursor: pointer; border: 1px solid var(--border); border-radius: var(--radius-legacy-control); - padding: 8px 14px; + padding: var(--space-2) var(--space-control-inset); background: var(--surface); color: var(--accent); font: inherit; - font-size: calc(14px * var(--buzz-text-scale, 1)); + font-size: var(--text-label); + line-height: var(--text-label--line-height); + letter-spacing: var(--text-label--letter-spacing); + font-weight: var(--text-label--font-weight); } input:not(:where([data-buzz-ui], [data-buzz-ui] *)), textarea:not(:where([data-buzz-ui], [data-buzz-ui] *)), @@ -113,29 +139,29 @@ cursor: default; } h1:not(:where([data-buzz-ui], [data-buzz-ui] *)) { - font-size: clamp( - calc(28px * var(--buzz-text-scale, 1)), - calc(4vw * var(--buzz-text-scale, 1)), - calc(40px * var(--buzz-text-scale, 1)) - ); - line-height: 1.2; - font-weight: 600; - margin: 8px 0 24px; - letter-spacing: -1px; + font-size: var(--text-title); + line-height: var(--text-title--line-height); + letter-spacing: var(--text-title--letter-spacing); + font-weight: var(--text-title--font-weight); + margin: var(--space-2) 0 var(--space-8); } h2:not(:where([data-buzz-ui], [data-buzz-ui] *)) { - font-size: calc(22px * var(--buzz-text-scale, 1)); - margin: 28px 0 8px; - font-weight: 600; + font-size: var(--text-heading); + line-height: var(--text-heading--line-height); + letter-spacing: var(--text-heading--letter-spacing); + margin: var(--space-section-gap) 0 var(--space-4); + font-weight: var(--type-weight-medium); } h3:not(:where([data-buzz-ui], [data-buzz-ui] *)) { - font-size: calc(17px * var(--buzz-text-scale, 1)); - margin: 0 0 6px; - font-weight: 600; + font-size: var(--text-label); + line-height: var(--text-label--line-height); + letter-spacing: var(--text-label--letter-spacing); + margin: 0 0 var(--space-1h); + font-weight: var(--type-weight-medium); } p:not(:where([data-buzz-ui], [data-buzz-ui] *)) { - line-height: 1.6; - margin: 8px 0; + line-height: var(--text-body--line-height); + margin: var(--space-2) 0; overflow-wrap: anywhere; } } @@ -166,19 +192,9 @@ outline-offset: 3px; } .shell-background { - background-image: - radial-gradient(circle, var(--shell-dot) 0.8px, transparent 1px), - var(--shell-image); - background-size: - 36px 36px, - 100% 100%, - 100% 100%; - background-position: - 16px 8px, - center, - center; - background-repeat: repeat, no-repeat, no-repeat; + background: var(--bg-app); } + .shell-header { @apply grid h-14 shrink-0 items-center gap-3 px-4; grid-template-columns: minmax(0, 1fr) minmax(0, auto) minmax(0, 1fr); @@ -187,10 +203,10 @@ @apply flex min-w-0 items-center justify-start; } .shell-header-mac .shell-communities { - padding-left: 88px; + padding-left: var(--space-16); } .shell-pages { - @apply flex min-w-0 items-center gap-1 overflow-x-auto rounded-full border border-shell-edge/70 bg-surface/25 p-1; + @apply glass-primary flex min-w-0 items-center gap-1 overflow-x-auto rounded-full p-1; max-width: min(calc(100vw - 400px), 640px); } .shell-actions { @@ -209,26 +225,26 @@ } } .shell-tab { - @apply flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border-0 bg-transparent px-4 py-1.5 text-sm leading-[calc(1rem*var(--buzz-text-scale,1))] text-ink hover:bg-surface/80 hover:text-ink; + @apply flex shrink-0 items-center gap-1.5 whitespace-nowrap rounded-full border-0 bg-transparent px-4 py-1.5 text-label-sm text-ink hover:bg-neutral-3 hover:text-ink; } .shell-icon { - @apply flex size-9 items-center justify-center rounded-full border-0 bg-surface/50 p-0 text-ink hover:bg-surface hover:text-ink; + @apply glass-primary-interactive flex size-9 items-center justify-center rounded-full border-0 p-0 text-ink hover:text-ink; } .shell-tab[aria-current="page"], .shell-icon[aria-current="page"] { @apply bg-surface text-ink; } .muted { - @apply text-[length:calc(13px*var(--buzz-text-scale,1))] font-normal text-muted; + @apply text-body-sm text-muted; } .eyebrow { - @apply text-[length:calc(11px*var(--buzz-text-scale,1))] font-semibold tracking-[0.16em] text-muted; + @apply text-caption text-muted; } .actions { @apply flex flex-wrap gap-2; } .notice { - @apply my-5 rounded-2xl border border-warning-border bg-warning-surface p-5 text-sm text-warning; + @apply my-5 rounded-2xl border border-warning-border bg-warning-surface p-5 text-body-sm text-warning; } .error, .danger { @@ -237,20 +253,18 @@ } /* Host text preference feeds the system ramp exactly once; rem geometry is unchanged. - Legacy palette, canvas, radii and type remain deliberately independent until migrated. */ + Compatibility names forward to shared values; the host owns appearance. */ :root { --type-scale: var(--buzz-text-scale, 1); - --buzz-mono-family: - ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", - "Courier New", monospace; + --buzz-mono-family: var(--font-system-mono); --default-mono-font-family: var(--buzz-mono-family); } @layer base { body { font-family: var(--font-legacy-sans); - line-height: 1.5; - letter-spacing: normal; + line-height: var(--text-body--line-height); + letter-spacing: var(--text-body--letter-spacing); } } @@ -263,7 +277,7 @@ @layer components { /* Components carry the boundary themselves, including portal roots. Product compositions can opt in with the same marker without copying component CSS. */ - [data-buzz-ui] { + :where([data-buzz-ui]) { --buzz-mono-family: var(--font-system-mono); font-family: var(--font-sans); font-size: var(--text-body); @@ -285,7 +299,7 @@ } @layer base { - :where([data-buzz-ui]) :where(code, kbd, pre, samp) { + :where(code, kbd, pre, samp) { font-family: var(--buzz-mono-family); font-variant-numeric: tabular-nums; } @@ -297,5 +311,5 @@ background-color: var(--primary); } @utility bg-primary-hover { - background-color: color-mix(in oklab, var(--primary) 90%, transparent); + background-color: var(--neutral-12); } diff --git a/src/shared/styles/tokens.css b/src/shared/styles/tokens.css index 2f05e04a..ef7250bb 100644 --- a/src/shared/styles/tokens.css +++ b/src/shared/styles/tokens.css @@ -3,87 +3,84 @@ @layer base { :root { color-scheme: light; - --text: #242e31; - --text-muted: #626b70; - --surface: #ffffff; - --surface-accent: #f5f5f4; - --surface-hover: #eef1f2; - --surface-control: #f1f4f5; - --surface-input: #fafafa; - --surface-elevated: #fafbf8; - --border: #e8e9e7; - --border-input: #8a9498; - --accent: #313f43; /* Legacy foreground, NOT shadcn's accent background. */ - --primary: #313f43; - --on-primary: #ffffff; - --selected: #dfebf1; - --on-selected: #305c72; - --focus: #397c98; - --link: #36657d; - --action: #f3e8ff; - --on-action: #242e31; - --danger: #a12f27; - --warning: #80501e; - --warning-surface: #fff7da; - --warning-border: #d7b265; - --success: #276440; - --success-surface: #e5f3e9; - --workspace: #e7f0ef; - --shell-image: url("/shell-gradient.png"); - --shell-dot: #536d7929; - --shell-edge: #ffffff; - --overlay: #17261e60; - --toggle-track: #a0a7ab; - --toggle-thumb: #ffffff; - --elevation-card: 0 1px 4px #202c3214; - --elevation-surface: 0 1px 3px #202c3210, 0 8px 28px #202c3208; - --elevation-popover: 0 8px 32px #202c3220; - --elevation-dialog: 0 2rem 6rem #0003; - --elevation-dock: -12px 0 32px #253d4c14; - --radius-card: 24px; - --radius-card-compact: 18px; - --radius-legacy-control: 12px; + --text: var(--text-primary); + --text-muted: var(--text-secondary); + --surface: var(--bg-panel); + --surface-accent: var(--neutral-2); + --surface-hover: var(--neutral-3); + --surface-control: var(--neutral-2); + --surface-input: var(--neutral-2); + --surface-elevated: var(--bg-float); + --border: var(--border-primary); + --border-input: var(--border-control); + --accent: var( + --text-primary + ); /* Legacy foreground, NOT shadcn's accent background. */ + --primary: var(--neutral-11); + --on-primary: var(--neutral-1); + --selected: var(--neutral-3); + --on-selected: var(--text-primary); + --focus: var(--text-primary); + --link: var(--text-primary); + --action: var(--neutral-3); + --on-action: var(--text-primary); + --danger: var(--red-12); + --warning: var(--amber-12); + --warning-surface: var(--amber-3); + --warning-border: var(--amber-8); + --success: var(--green-12); + --success-surface: var(--green-3); + /* The window/theme-color fallback must stay a color, behind the gradient. */ + --workspace: var(--neutral-1); + --shell-edge: var(--border-primary); + --overlay: var(--bg-scrim); + --toggle-track: var(--neutral-7); + --toggle-thumb: var(--neutral-1); + --elevation-card: var(--shadow-xs); + --elevation-surface: var(--shadow-sm); + --elevation-popover: var(--shadow-sm); + --elevation-dialog: var(--shadow-sm); + --elevation-dock: var(--shadow-sm); + --radius-card: var(--radius-panel); + --radius-card-compact: var(--radius-panel); + --radius-legacy-control: var(--radius-control); } :root[data-color-mode="dark"] { color-scheme: dark; - --text: #e6edf0; - --text-muted: #aab8c0; - --surface: #1b252b; - --surface-accent: #26333b; - --surface-hover: #30414b; - --surface-control: #293740; - --surface-input: #202c33; - --surface-elevated: #233039; - --border: #384b56; - --border-input: #708791; - --accent: #d6e3eb; - --primary: #d6e3eb; - --on-primary: #172229; - --selected: #304c5e; - --on-selected: #c5e6fc; - --focus: #98c7ed; - --link: #a3d3f0; - --action: #534467; - --on-action: #f5eaff; - --danger: #ffaaa0; - --warning: #f5cf89; - --warning-surface: #3d3222; - --warning-border: #927444; - --success: #9cdbb0; - --success-surface: #20392d; - --workspace: #11181d; - --shell-image: - radial-gradient(ellipse at 15% 0%, #203d53, transparent 65%), - radial-gradient(ellipse at 90% 100%, #39304b, transparent 70%); - --shell-dot: #a7c8db24; - --shell-edge: #43535f; - --overlay: #060c13a6; - --toggle-track: #708791; - --toggle-thumb: #172229; - --elevation-card: 0 1px 4px #0003; - --elevation-surface: 0 1px 3px #0004, 0 8px 28px #0002; - --elevation-popover: 0 8px 32px #0006; - --elevation-dialog: 0 2rem 6rem #0008; - --elevation-dock: -12px 0 32px #0005; + --text: var(--text-primary); + --text-muted: var(--text-secondary); + --surface: var(--bg-panel); + --surface-accent: var(--neutral-2); + --surface-hover: var(--neutral-3); + --surface-control: var(--neutral-2); + --surface-input: var(--neutral-2); + --surface-elevated: var(--bg-float); + --border: var(--border-primary); + --border-input: var(--border-control); + --accent: var(--text-primary); + --primary: var(--neutral-11); + --on-primary: var(--neutral-1); + --selected: var(--neutral-3); + --on-selected: var(--text-primary); + --focus: var(--text-primary); + --link: var(--text-primary); + --action: var(--neutral-3); + --on-action: var(--text-primary); + --danger: var(--red-12); + --warning: var(--amber-12); + --warning-surface: var(--amber-3); + --warning-border: var(--amber-8); + --success: var(--green-12); + --success-surface: var(--green-3); + --workspace: var(--neutral-1); + --shell-edge: var(--border-primary); + --overlay: var(--bg-scrim); + --toggle-track: var(--neutral-7); + --toggle-thumb: var(--neutral-1); + --elevation-card: var(--shadow-xs); + --elevation-surface: var(--shadow-sm); + --elevation-popover: var(--shadow-sm); + --elevation-dialog: var(--shadow-sm); + --elevation-dock: var(--shadow-sm); } } diff --git a/src/shared/theme/tokens.test.ts b/src/shared/theme/tokens.test.ts index 58a55a8b..c2f5ed3f 100644 --- a/src/shared/theme/tokens.test.ts +++ b/src/shared/theme/tokens.test.ts @@ -10,7 +10,43 @@ const blocks = [...css.matchAll(/:root[^{}]*\{([^}]+)\}/g)].map((match) => ]), ), ); -function luminance(hex: string) { + +// Resolve the real shared palette and host aliases in cascade order. The host +// no longer owns literal colors; a missing or cyclic alias must fail the test. +function resolvedPalette(mode: "light" | "dark") { + const source = ( + readFileSync("src/shared/design-system/styles/tokens.css", "utf8") + + "\n" + + css + ).replace(/\/\*[\s\S]*?\*\//g, ""); + const values: Record = {}; + for (const [, selector = "", body = ""] of source.matchAll( + /([^{}]+)\{([^{}]*)\}/g, + )) { + if (!selector.includes(":root") && !selector.includes(".dark")) continue; + const dark = + selector.includes('data-color-mode="dark"') || selector.includes(".dark"); + if (dark && mode !== "dark") continue; + for (const [, name = "", value = ""] of body.matchAll( + /(--[\w-]+):\s*([^;]+);/g, + )) + values[name] = value.trim(); + } + const resolve = (name: string, seen = new Set()): string => { + if (seen.has(name)) throw new Error(`Cyclic token: ${name}`); + seen.add(name); + const value = values[name]; + if (!value) throw new Error(`Missing token: ${name}`); + const alias = /^var\((--[\w-]+)\)$/.exec(value); + return alias?.[1] ? resolve(alias[1], seen) : value; + }; + return Object.fromEntries( + Object.keys(values).map((name) => [name, resolve(name)]), + ); +} + +function luminance(hex: string | undefined) { + if (!hex) throw new Error("Missing contrast color"); expect(hex).toMatch(/^#[\da-f]{6}$/i); const rgb = [1, 3, 5] .map((i) => Number.parseInt(hex.slice(i, i + 2), 16) / 255) @@ -20,7 +56,7 @@ function luminance(hex: string) { 0, ); } -function contrast(a: string, b: string) { +function contrast(a: string | undefined, b: string | undefined) { const x = luminance(a); const y = luminance(b); const low = Math.min(x, y); @@ -40,7 +76,7 @@ it("both palettes define every color/elevation role, with shared radii", () => { it.each([0, 1])( "palette %i has AA text/control token pairs (not a rendered audit)", (index) => { - const t = blocks[index]; + const t = resolvedPalette(index === 0 ? "light" : "dark"); const textPairs: [string, string][] = [ ...[ "--surface", diff --git a/tests/browser/appearance.spec.mjs b/tests/browser/appearance.spec.mjs index bc945ca2..2e012e87 100644 --- a/tests/browser/appearance.spec.mjs +++ b/tests/browser/appearance.spec.mjs @@ -13,12 +13,19 @@ async function expectMode(page, mode) { await expect(page.locator("html")).toHaveCSS("color-scheme", mode); await expect(page.locator("html")).toHaveCSS( "background-color", - mode === "dark" ? "rgb(17, 24, 29)" : "rgb(231, 240, 239)", + mode === "dark" ? "rgb(0, 0, 0)" : "rgb(255, 255, 255)", ); await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute( "content", - mode === "dark" ? "#11181d" : "#e7f0ef", + mode === "dark" ? /^#(?:000|000000)$/ : /^#(?:fff|ffffff)$/, ); + await expect(page.locator(".shell-background")).toHaveCSS( + "background-image", + /linear-gradient/, + ); + await expect( + page.getByRole("navigation", { name: "Pages", exact: true }), + ).toHaveCSS("backdrop-filter", /blur\(/); } test("Appearance changes and restores both modes, native keyboard controls, dialogs and narrow layout", async ({ @@ -41,10 +48,10 @@ test("Appearance changes and restores both modes, native keyboard controls, dial .locator(".shell-tab") .first() .evaluate((el) => getComputedStyle(el).color), - ).toBe("rgb(230, 237, 240)"); + ).toBe("rgb(255, 255, 255)"); await expect(button(page, "Appearance")).toHaveCSS( "background-color", - "rgb(27, 37, 43)", + "rgb(26, 26, 26)", ); expect(await page.evaluate((key) => localStorage.getItem(key), key)).toBe( "dark", @@ -74,7 +81,7 @@ test("Appearance changes and restores both modes, native keyboard controls, dial await expect(dialog).toBeVisible(); await expect(dialog).toHaveCSS( "background-color", - mode === "dark" ? "rgb(27, 37, 43)" : "rgb(255, 255, 255)", + mode === "dark" ? "rgb(26, 26, 26)" : "rgb(255, 255, 255)", ); await page.keyboard.press("Escape"); } @@ -188,7 +195,7 @@ test("saved dark document paints before the application module is allowed to exe await expect(page.locator("html")).toHaveCSS("color-scheme", "dark"); await expect(page.locator("html")).toHaveCSS( "background-color", - "rgb(17, 24, 29)", + "rgb(0, 0, 0)", ); expect(await page.locator("#root").innerHTML()).toBe(""); // Observe the painted document for two frames with the entire React bundle still withheld. @@ -200,7 +207,7 @@ test("saved dark document paints before the application module is allowed to exe ); await expect(page.locator("html")).toHaveCSS( "background-color", - "rgb(17, 24, 29)", + "rgb(0, 0, 0)", ); // Pre-paint coverage must never start (then tear down) a relay session. expect(relayRequests).toEqual([]); @@ -255,15 +262,15 @@ test("compiled host preserves compatibility utility meanings", async ({ await page.getByRole("radio", { name: mode, exact: true }).check(); await expect(page.locator("#primary-text")).toHaveCSS( "color", - mode === "Light" ? "rgb(10, 10, 10)" : "rgb(245, 245, 245)", + mode === "Light" ? "rgb(0, 0, 0)" : "rgb(255, 255, 255)", ); await expect(page.locator("#primary-border")).toHaveCSS( "border-top-color", - mode === "Light" ? "rgb(232, 232, 232)" : "rgb(51, 51, 51)", + mode === "Light" ? "rgb(232, 232, 232)" : "rgb(35, 35, 35)", ); await expect(page.locator("#old-primary")).toHaveCSS( "background-color", - mode === "Light" ? "rgb(49, 63, 67)" : "rgb(214, 227, 235)", + mode === "Light" ? "rgb(25, 25, 25)" : "rgb(199, 199, 199)", ); await expect(page.locator("#old-primary")).toHaveCSS( "border-radius", @@ -274,9 +281,9 @@ test("compiled host preserves compatibility utility meanings", async ({ "font-family", /ui-monospace/, ); - await expect(page.locator(`#${id}`)).not.toHaveCSS( + await expect(page.locator(`#${id}`)).toHaveCSS( "font-family", - /JetBrains/, + /JetBrains Mono/, ); } await expect(page.locator("#new-mono")).toHaveCSS( diff --git a/tests/browser/emoji.spec.mjs b/tests/browser/emoji.spec.mjs index 27c43d7e..37422f6a 100644 --- a/tests/browser/emoji.spec.mjs +++ b/tests/browser/emoji.spec.mjs @@ -495,8 +495,7 @@ test("community picker uses keyboard, proxy thumbnails, event-local history and ? "rgb(66, 66, 66) 0px 0px 0px 2px" : "rgb(206, 206, 206) 0px 0px 0px 2px", ); - await expect(search).toHaveCSS("font-family", /^Inter, /); - await expect(search).not.toHaveCSS("font-family", /Inter Variable/); + await expect(search).toHaveCSS("font-family", /Inter Variable/); await expect(search).toHaveValue("party"); await expect(search).toBeFocused(); expect(await searchNode.evaluate((node) => node.isConnected)).toBe(true); diff --git a/tests/browser/gifs.spec.mjs b/tests/browser/gifs.spec.mjs index 5f16a340..81a6f022 100644 --- a/tests/browser/gifs.spec.mjs +++ b/tests/browser/gifs.spec.mjs @@ -128,8 +128,7 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({ }; }; const emojiSearchStyle = await emojiSearch.evaluate(searchStyle); - expect(emojiSearchStyle.fontFamily).toMatch(/^Inter, /); - expect(emojiSearchStyle.fontFamily).not.toContain("Inter Variable"); + expect(emojiSearchStyle.fontFamily).toMatch(/Inter Variable/); const emojiContentHeight = ( await page.locator("em-emoji-picker").boundingBox() ).height; @@ -236,7 +235,7 @@ test("relay-backed GIF tab searches KLIPY and inserts URL-only media", async ({ }); await expect(draft).toHaveValue(""); const composer = draft.locator("xpath=ancestor::form"); - await expect(composer).toHaveCSS("border-top-color", "rgb(138, 148, 152)"); + await expect(composer).toHaveCSS("border-top-color", "rgb(128, 128, 128)"); await expect(composer).toHaveCSS("box-shadow", "none"); await expect(search).toHaveAttribute("spellcheck", "false"); await expect(search).toHaveAttribute("autocorrect", "off"); diff --git a/tests/browser/mentions.spec.mjs b/tests/browser/mentions.spec.mjs index fe7cbe1f..0f47e519 100644 --- a/tests/browser/mentions.spec.mjs +++ b/tests/browser/mentions.spec.mjs @@ -74,8 +74,8 @@ test("actual composer selects namesakes by exact key, publishes channel/reply ta .getByRole("region", { name: "Notification recipients" }) .getByRole("button") .first(); - await expect(chip).toHaveCSS("background-color", "rgb(83, 68, 103)"); - await expect(chip).toHaveCSS("color", "rgb(245, 234, 255)"); + await expect(chip).toHaveCSS("background-color", "rgb(26, 26, 26)"); + await expect(chip).toHaveCSS("color", "rgb(255, 255, 255)"); await page.screenshot({ path: test.info().outputPath("mention-recipients.png"), });