@sableui/react 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
  3. package/dist/{chunk-J23ELQQZ.js → chunk-AOWBYEQ6.js} +4177 -1917
  4. package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
  5. package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
  6. package/dist/foundations/index.js +1 -1
  7. package/dist/index.d.ts +155 -101
  8. package/dist/index.js +582 -2778
  9. package/dist/ios/index.d.ts +6 -1
  10. package/dist/ios/index.js +4 -4
  11. package/dist/ios/preview.js +1 -1
  12. package/dist/ios/styles.css +20 -6
  13. package/dist/patterns/index.js +1683 -469
  14. package/dist/presets/index.d.ts +1 -1
  15. package/dist/presets/index.js +1 -1
  16. package/dist/styles.css +238 -40
  17. package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
  18. package/dist/themes/atelier/atelier.css +3 -0
  19. package/dist/themes/atelier/index.d.ts +1 -1
  20. package/dist/themes/base/base.css +11 -4
  21. package/dist/themes/base/index.d.ts +1 -1
  22. package/dist/themes/ember/ember.css +3 -0
  23. package/dist/themes/ember/index.d.ts +1 -1
  24. package/dist/themes/index.d.ts +2 -2
  25. package/dist/themes/meridian/index.d.ts +1 -1
  26. package/dist/themes/meridian/meridian.css +10 -5
  27. package/dist/themes/poppy/index.d.ts +1 -1
  28. package/dist/themes/poppy/poppy.css +16 -0
  29. package/dist/themes/prism/index.d.ts +1 -1
  30. package/dist/themes/prism/prism.css +4 -1
  31. package/dist/themes/verdant/index.d.ts +1 -1
  32. package/dist/themes/verdant/verdant.css +7 -2
  33. package/llms/components/bubble.md +5 -5
  34. package/llms/components/checkbox-card.md +1 -1
  35. package/llms/components/context-card.md +2 -2
  36. package/llms/components/dialog.md +1 -1
  37. package/llms/components/empty-state.md +4 -4
  38. package/llms/components/entity.md +1 -1
  39. package/llms/components/fieldset.md +1 -1
  40. package/llms/components/kbd.md +18 -6
  41. package/llms/components/pill.md +1 -1
  42. package/llms/components/side-nav.md +1 -1
  43. package/llms/components/skeleton.md +1 -1
  44. package/llms/components/theme-selector.md +1 -1
  45. package/llms/foundations/shape.md +1 -1
  46. package/llms/patterns/auth-page.md +132 -42
  47. package/llms/patterns/brand-landing-page.md +271 -175
  48. package/llms/patterns/chat-panel.md +120 -39
  49. package/llms/patterns/dashboard-page.md +277 -65
  50. package/llms/patterns/feed-page.md +245 -64
  51. package/llms/patterns/landing-page.md +473 -75
  52. package/llms/patterns/list-detail-page.md +385 -68
  53. package/llms/patterns/settings-page.md +279 -67
  54. package/llms/reference/token-intent.md +4 -2
  55. package/llms/reference/utilities.md +20 -5
  56. package/llms-full.txt +2244 -727
  57. package/llms.txt +14 -15
  58. package/package.json +1 -1
  59. package/registry.json +261 -238
  60. package/targets/ios.json +4 -15
  61. package/llms/components/description.md +0 -96
package/llms-full.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # Sable (@sableui/react)
2
2
 
3
- > An owned, themeable React component system. Default theme: **Base** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 97 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
3
+ > An owned, themeable React component system. Default theme: **Base** (dark-by-default, content-first, fluid product UI). Behavior from Base UI; styling via Tailwind v4 + semantic CSS-variable tokens; Reicon icons via a pluggable adapter. 96 components, 13 layout primitives, and a **patterns** tier of canonical page shells. The full, per-component contract is the generated `registry.json`.
4
4
 
5
5
  ## Start here — brief, composition, evidence
6
6
 
@@ -2573,7 +2573,7 @@ Each step is 4px apart, so common insets land on a real step. Themes and the cus
2573
2573
  ## Rules
2574
2574
 
2575
2575
  1. **Nested corners are concentric: inner radius = outer radius − inset. Write it as rounded-concentric-<outer>/<inset>, not a hardcoded step.** A 12px popover with 4px padding takes 8px items. The utility computes the subtraction from the live tokens, so the pair stays concentric when a theme or the customizer rescales the radius ladder; a hardcoded rounded-sm drifts.
2576
- 2. **A list of rows inside a rounded popup uses popup-inset on the popup, rounded-popup-item on the rows and popup-bleed on a separator.** All three derive from the popup's radius: the inset grows to a third of it (never below --spacing(1), so density moves it too), the rows take what is left, and a separator bleeds by exactly the inset. A rounder look gets more room instead of rows that round into pills, and the default ladder still lands on the familiar 4px inset and 8px rows. *Enforced by sable-lint: popup-separator-bleed.*
2576
+ 2. **A list of rows inside a rounded popup uses popup-inset on the popup, rounded-popup-item on the rows and popup-bleed on a separator.** All three derive from the popup's radius: the inset grows to a third of it (never below --spacing(1), so density moves it too), the rows take what is left after the inset and the 1px border, and a separator bleeds by exactly the inset. A rounder look gets more room instead of rows that round into pills; the default ladder lands on a 4px inset and 7px rows. *Enforced by sable-lint: popup-separator-bleed.*
2577
2577
  3. **A Button, Pill or Badge gets its look from a variant, never from override classes.** border, bg, rounded, ring and shadow classes on a component fight the variant's own hover, selected and focus states, and give one call site a look no other site can ask for. Use the variant that exists (Button variant="floating" over content, "on-color" on a fill; Pill appearance="outline"; Badge appearance and tone) or add one to the component. *Enforced by sable-lint: component-restyle.*
2578
2578
  4. **The outer radius is always at least the inner radius.** If the inset is larger than the outer radius, square the inner element or step the container up.
2579
2579
  5. **Never mix radii within one component.** A component reads as one object when its corners agree.
@@ -2897,68 +2897,158 @@ The region is in the accessibility tree before its text changes, so the change i
2897
2897
 
2898
2898
  # AuthPage
2899
2899
 
2900
- Centered auth card: a form-width region vertically centered in the viewport, one Card holding the Form, an alternate-action divider, and a quiet switch-to-sign-up link beneath it.
2900
+ Split sign-in page: a form column (brand mark, heading, email + password, primary sign-in, passkey and wallet alternatives, sign-up switch, terms) beside a brand panel painted from the theme's tones with a featured release card. Below lg the panel drops away and the form is the whole screen.
2901
2901
 
2902
2902
  **Intent:** Reach for this for sign-in, sign-up, forgot-password, and any single-purpose one-column form route (invite accept, unsubscribe). Not for multi-section forms (use SettingsPage).
2903
2903
 
2904
2904
  ## Notes
2905
2905
 
2906
- - Region cap is `form` (480px) — the narrowest named cap; auth never needs more.
2907
- - The card is the only surface on the page: no app chrome, no sidebar — minimal exit paths.
2908
- - min-h-dvh + grid place-items-center centers the card without magic offsets.
2909
- - Field handles label/error wiring; the submit button is full-width — the primary (and only) action.
2910
- - Secondary actions (here, switch to sign-up; add forgot password the same way) are quiet `link-underline` links, never competing buttons.
2906
+ - The form column is a `form`-capped Region (480px), so the controls keep the same measure whether the panel is present or not.
2907
+ - No card around the form: the page itself is the surface, and the brand panel carries the identity. A card on a blank page reads as a template.
2908
+ - The heading is `text-heading-1` in an h1 — it is the page outline, so it takes a heading tier, not a card title.
2909
+ - Brand mark, form and terms are a three-part column: mark pinned to the top, form vertically centred in the remaining space, terms pinned to the bottom.
2910
+ - Forgot password sits under the password field, right-aligned, before the submit button — so it comes right after the field in tab order. Field has no label-row action slot yet.
2911
+ - One primary action. Passkey and wallet are outline buttons under an `or` Separator; switching to sign-up is a quiet `link-underline` link.
2912
+ - Validation uses Field `validate` for product copy instead of the browser's generic required message. A rejected sign-in comes back through Form `errors` on the password field; the submit Button shows `loading` while the request is in flight.
2913
+ - The brand panel artwork is a mesh of the theme's decorative tones over `--primary`, so it re-paints for every theme and mode. The relative-colour `oklch(from var(--tone-*) …)` lift needs an `inline-color` disable with a reason.
2914
+ - The panel is `hidden lg:flex`: on a phone the form is the whole screen, with no decorative scroll above it.
2911
2915
 
2912
2916
  ## Accessibility
2913
2917
 
2914
- - The route renders inside <main> with the page h1 in the card header (an h1 inside CardTitle, which carries the type).
2915
- - Field auto-wires labels, describedby, and invalid state onto the inputs.
2916
- - Autocomplete attributes (email, current-password) keep password managers working.
2918
+ - The route renders inside <main> with one h1; the brand panel is an <aside> labelled "Featured release".
2919
+ - Field auto-wires labels, describedby, and invalid state onto the inputs; on submit, the first invalid field is focused.
2920
+ - Autocomplete attributes (`email webauthn`, `current-password`) keep password managers and passkey autofill working.
2921
+ - The Forgot password and Create one links use `hit-target` to meet the 44px touch floor without changing layout.
2922
+ - The artwork swatches are aria-hidden decoration; the brand link's accessible name is the wordmark.
2917
2923
 
2918
2924
  ## Source
2919
2925
 
2920
2926
  ```tsx
2921
- import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Field, Form, Input, Region, Separator, Stack } from "@sableui/react"
2927
+ import { useState } from "react"
2928
+ import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, Icon, Input, Region, Separator, Stack } from "@sableui/react"
2929
+
2930
+ // A soft mesh of the theme's decorative tones over its brand colour, so the
2931
+ // artwork follows every theme and mode. Each tone is lifted from its text
2932
+ // strength (dark, restrained) to an art strength.
2933
+ const artwork = [
2934
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2935
+ "radial-gradient(in oklab at 18% 22%, oklch(from var(--tone-info) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 58%)",
2936
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2937
+ "radial-gradient(in oklab at 82% 18%, oklch(from var(--tone-collectible) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
2938
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2939
+ "radial-gradient(in oklab at 70% 88%, oklch(from var(--tone-edition) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 60%)",
2940
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2941
+ "radial-gradient(in oklab at 12% 92%, oklch(from var(--tone-standard) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
2942
+ "var(--primary)",
2943
+ ].join(", ")
2922
2944
 
2923
2945
  export function AuthPage() {
2946
+ const [pending, setPending] = useState(false)
2947
+ const [errors, setErrors] = useState<Record<string, string>>({})
2948
+
2924
2949
  return (
2925
- <main className="grid min-h-dvh place-items-center bg-background">
2926
- <Region max="form" safeArea={["top", "bottom"]}>
2927
- <Stack gap={3}>
2928
- <Card>
2929
- <CardHeader>
2930
- {/* CardTitle carries the type; the h1 inside it is the page heading. */}
2931
- <CardTitle>
2932
- <h1>Sign in</h1>
2933
- </CardTitle>
2934
- <CardDescription>Welcome back — pick up where you left off.</CardDescription>
2935
- </CardHeader>
2936
- <CardContent>
2937
- <Form onSubmit={(event) => event.preventDefault()}>
2938
- <Field label="Email" required>
2939
- <Input type="email" name="email" autoComplete="email" placeholder="you@example.com" />
2940
- </Field>
2941
- <Field label="Password" required>
2942
- <Input type="password" name="password" autoComplete="current-password" />
2950
+ <main className="grid min-h-dvh bg-background lg:grid-cols-[minmax(0,1fr)_minmax(0,1.1fr)]">
2951
+ <Region max="form" safeArea={["top", "bottom"]} className="flex flex-col">
2952
+ {/* safeArea owns the region's own padding, so the breathing room sits one level in. */}
2953
+ <Stack gap={0} className="flex-1 py-8 sm:py-10">
2954
+ <a href="/" className="flex items-center gap-2.5 self-start rounded-sm motion-interactive motion-press">
2955
+ {/* The same mark the app shell's sidebar and header carry. */}
2956
+ <Icon name="gem" variant="solid" className="text-2xl" />
2957
+ <span className="text-title-lg">Atelier</span>
2958
+ </a>
2959
+
2960
+ <Stack gap={4} justify="center" className="flex-1 py-12">
2961
+ <Stack gap={1.5}>
2962
+ <h1 className="text-heading-1">Welcome back</h1>
2963
+ <p className="text-body-lg text-muted-foreground">
2964
+ Sign in to collect new releases and manage your editions.
2965
+ </p>
2966
+ </Stack>
2967
+
2968
+ <Form
2969
+ className="gap-5"
2970
+ errors={errors}
2971
+ onFormSubmit={() => {
2972
+ setPending(true)
2973
+ // Replace with your auth call; a rejected sign-in comes back as a field error.
2974
+ setTimeout(() => {
2975
+ setPending(false)
2976
+ setErrors({ password: "That email and password don’t match. Try again or reset your password." })
2977
+ }, 900)
2978
+ }}
2979
+ >
2980
+ <Field label="Email" name="email" validate={(value) => (value ? null : "Enter your email address.")}>
2981
+ <Input type="email" autoComplete="email webauthn" placeholder="you@example.com" />
2982
+ </Field>
2983
+ <Stack gap={1.5}>
2984
+ <Field label="Password" name="password" validate={(value) => (value ? null : "Enter your password.")}>
2985
+ <Input type="password" autoComplete="current-password" />
2943
2986
  </Field>
2944
- <Button type="submit" className="w-full">
2945
- Sign in
2946
- </Button>
2947
- <Separator label="or" />
2948
- <Button variant="outline" className="w-full">
2949
- Continue with wallet
2950
- </Button>
2951
- </Form>
2952
- </CardContent>
2953
- </Card>
2954
- <p className="text-center text-body-sm text-muted-foreground">
2955
- No account?{" "}
2956
- <a href="/signup" className="link-underline motion-interactive text-foreground">
2957
- Create one
2987
+ <a href="/forgot-password" className="hit-target self-end text-body-sm text-muted-foreground link-underline motion-interactive">
2988
+ Forgot password?
2989
+ </a>
2990
+ </Stack>
2991
+ <Button type="submit" size="cta" loading={pending} loadingLabel="Signing in" className="w-full">
2992
+ Sign in
2993
+ </Button>
2994
+ </Form>
2995
+
2996
+ <Separator label="or" />
2997
+
2998
+ <Stack gap={1.5}>
2999
+ <Button variant="outline" size="cta" className="w-full">
3000
+ Sign in with a passkey
3001
+ </Button>
3002
+ <Button variant="outline" size="cta" className="w-full">
3003
+ Connect a wallet
3004
+ </Button>
3005
+ </Stack>
3006
+
3007
+ <p className="text-body-md text-muted-foreground">
3008
+ No account?{" "}
3009
+ <a href="/signup" className="hit-target link-underline motion-interactive text-foreground">
3010
+ Create one
3011
+ </a>
3012
+ </p>
3013
+ </Stack>
3014
+
3015
+ <p className="text-body-xs text-muted-foreground">
3016
+ By signing in you agree to the{" "}
3017
+ <a href="/terms" className="link-underline motion-interactive">
3018
+ Terms
3019
+ </a>{" "}
3020
+ and{" "}
3021
+ <a href="/privacy" className="link-underline motion-interactive">
3022
+ Privacy Policy
2958
3023
  </a>
3024
+ .
2959
3025
  </p>
2960
3026
  </Stack>
2961
3027
  </Region>
3028
+
3029
+ {/* The brand panel is decoration: on a phone the form is the whole screen. */}
3030
+ <aside aria-label="Featured release" className="hidden p-3 lg:flex">
3031
+ <div className="flex flex-1 flex-col justify-end rounded-xl p-8" style={{ background: artwork }}>
3032
+ <Card className="max-w-md">
3033
+ <CardContent>
3034
+ <Stack gap={3}>
3035
+ <Badge tone="edition" className="self-start">
3036
+ Edition of 50
3037
+ </Badge>
3038
+ <p className="text-title-lg">
3039
+ “Every edition is a conversation with whoever holds it next.”
3040
+ </p>
3041
+ <Entity
3042
+ className="p-0"
3043
+ leading={<Avatar fallback="MO" />}
3044
+ title="Mara Okafor"
3045
+ subtitle="Untitled (gold rush), 2026"
3046
+ />
3047
+ </Stack>
3048
+ </CardContent>
3049
+ </Card>
3050
+ </div>
3051
+ </aside>
2962
3052
  </main>
2963
3053
  )
2964
3054
  }
@@ -2968,80 +3058,114 @@ export function AuthPage() {
2968
3058
 
2969
3059
  # BrandLandingPage
2970
3060
 
2971
- High-fidelity brand/product landing page: pill navigation, editorial hero type, a dark product scene, calm status tones, CTA pair, feature cards, metrics, and a complete branded footer.
3061
+ High-fidelity brand/product landing page: pill navigation, editorial hero type, a dark product scene, a value section, a how-it-works sequence, a customer story with outcomes, a closing CTA band, and a complete branded footer.
2972
3062
 
2973
3063
  **Intent:** Use this for a polished product marketing route that still needs to show a credible app surface. It is warmer and more tactile than the canonical LandingPage, but avoids trend-driven neon and generic hype cues.
2974
3064
 
2975
3065
  ## Notes
2976
3066
 
2977
- - One display headline owns the first viewport; supporting copy stays in body-lg at prose measure.
3067
+ - The story runs promise → product → value → process → proof → ask: hero, product scene, 'Why Cove' value cards, a three-step how-it-works list, a customer story with outcome figures, then one closing CTA. Each section adds new evidence; no number is repeated from the product scene.
3068
+ - One display headline owns the first viewport; supporting copy stays in body-lg at prose measure. Section headings are heading-1 under a `text-overline` eyebrow.
2978
3069
  - The nav spends one compact pill surface, with a clear brand signal and two primary paths: sign in and request access.
2979
- - The product scene uses overlay tokens for a stable dark media surface, then semantic tones for status and chart signals.
2980
- - The footer closes the page with brand reassurance, a three-column sitemap, a digest signup, operational mini-metrics, and legal links.
2981
- - Every surface outside the product scene is a real Card (feature, metric and digest cards); status marks are StatusDot. The scene's nested panels take `rounded-concentric-xl/<inset>` from the shell's radius, so the corners stay parallel at any radius preset.
2982
- - Radius and shadow are theme-controlled. In Cove the same rounded-lg/xl classes read larger and softer; in other themes the structure still holds.
2983
- - Color is intentionally broad: porcelain/plum/lavender/blue/mint/saffron/coral through semantic roles, not a one-note purple gradient.
3070
+ - The product scene and the closing CTA band are dark islands: a `dark` class on the wrapper re-scopes every semantic token, so surfaces, muted ink, Badge tones and Buttons take the theme's own dark values in either page mode. Never fake a dark panel with overlay ink at reduced opacity — status tones tuned for a light page vanish on it.
3071
+ - The scene is a picture of the product: `aria-hidden` + `inert` keep its sample button and figures out of the tab order and the outline. On narrow screens it drops the side column and keeps the signal map, so it scales instead of stacking into a second page.
3072
+ - The chart keeps past weeks on the quiet `secondary` fill and gives the current week the one accent, so the signal reads at a glance.
3073
+ - Value cards are real Cards with a neutral `sable-well` icon tile; the customer story is a Card rendered as a figure, with outcome figures split by Separator. The scene's nested panels take `rounded-concentric-xl/<inset>` from the shell's radius, so the corners stay parallel at any radius preset.
3074
+ - The footer closes the page with brand reassurance, a plain status Badge, a three-column sitemap (three columns at every width), a digest signup, and legal links.
3075
+ - All figures, names and the testimonial are sample content; replace them with real, verifiable claims.
2984
3076
 
2985
3077
  ## Accessibility
2986
3078
 
2987
3079
  - The brand nav is a labeled nav landmark with real links and visible text.
3080
+ - Each content section is a labelled region (by its h2, or aria-label for the customer story); the decorative product scene is hidden from assistive tech and inert.
3081
+ - The how-it-works steps are an ordered list; the testimonial is a figure with a blockquote and a figcaption naming the speaker.
2988
3082
  - The footer is a real footer landmark with a labeled footer nav, visible link text, and a labeled email field.
2989
- - TextReveal preserves the full headline as one accessible label and honors reduced-motion policy.
2990
- - Status dots are paired with visible text labels so color is never the only signal.
3083
+ - TextReveal preserves the full headline as one accessible label and honors reduced-motion policy; Reveal shows the value cards immediately when motion is off.
3084
+ - Status dots and the status Badge are paired with visible text labels so color is never the only signal.
2991
3085
 
2992
3086
  ## Source
2993
3087
 
2994
3088
  ```tsx
2995
- import { Badge, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, Icon, Input, Region, Row, Stack, StatusDot, TextReveal } from "@sableui/react"
3089
+ import { Avatar, Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, Grid, Icon, Input, Region, Reveal, Row, Separator, Stack, StatusDot, TextReveal } from "@sableui/react"
2996
3090
 
2997
- const navItems = ["Platform", "Signals", "Security", "Company"]
3091
+ const navItems = [
3092
+ { label: "Platform", href: "#platform" },
3093
+ { label: "How it works", href: "#how-it-works" },
3094
+ { label: "Customers", href: "#customers" },
3095
+ { label: "Pricing", href: "/pricing" },
3096
+ ]
2998
3097
 
2999
- const metrics = [
3000
- { label: "Checks cleared", value: "1.8M", detail: "in the last 30 days" },
3001
- { label: "Review time", value: "42s", detail: "median approval path" },
3002
- { label: "Spaces online", value: "284", detail: "across active teams" },
3098
+ const spaces = [
3099
+ { name: "Waypoint", value: "98.4%", label: "clear routes" },
3100
+ { name: "Fielddesk", value: "12", label: "open holds" },
3101
+ { name: "Archive", value: "4.8k", label: "sealed records" },
3003
3102
  ]
3004
3103
 
3005
- const workflow = [
3104
+ const routes = [
3006
3105
  { label: "Intake", state: "Open", tone: "info" as const },
3007
3106
  { label: "Review", state: "Live", tone: "edition" as const },
3008
3107
  { label: "Archive", state: "Safe", tone: "success" as const },
3009
3108
  ]
3010
3109
 
3110
+ // Weekly approvals: past weeks recede, the current week carries the signal.
3011
3111
  const bars = [
3012
- { height: "38%", tone: "bg-tone-info" },
3013
- { height: "56%", tone: "bg-tone-edition" },
3014
- { height: "44%", tone: "bg-tone-standard" },
3015
- { height: "68%", tone: "bg-success" },
3016
- { height: "52%", tone: "bg-tone-info" },
3017
- { height: "78%", tone: "bg-highlight" },
3018
- { height: "60%", tone: "bg-tone-edition" },
3019
- { height: "86%", tone: "bg-success" },
3020
- { height: "64%", tone: "bg-tone-standard" },
3112
+ { week: "w1", height: "38%", current: false },
3113
+ { week: "w2", height: "56%", current: false },
3114
+ { week: "w3", height: "44%", current: false },
3115
+ { week: "w4", height: "68%", current: false },
3116
+ { week: "w5", height: "52%", current: false },
3117
+ { week: "w6", height: "78%", current: false },
3118
+ { week: "w7", height: "60%", current: false },
3119
+ { week: "w8", height: "72%", current: false },
3120
+ { week: "w9", height: "88%", current: true },
3021
3121
  ]
3022
3122
 
3023
- const features = [
3123
+ const sceneMetrics = [
3124
+ { label: "Cleared", value: "1.8M", detail: "last 30 days" },
3125
+ { label: "Review", value: "42s", detail: "median path" },
3126
+ { label: "Spaces", value: "284", detail: "active teams" },
3127
+ ]
3128
+
3129
+ const values = [
3024
3130
  {
3025
3131
  icon: "lock" as const,
3026
3132
  title: "Private by default",
3027
- body: "Workspace roles, approval trails, and handoff history stay clear without turning the interface into compliance fog.",
3133
+ body: "Roles, approvals, and handoff history stay with the people who need them. No shared inboxes, no forwarded screenshots.",
3028
3134
  },
3029
3135
  {
3030
- icon: "layout" as const,
3031
- title: "Brand and product together",
3032
- body: "Editorial sections and dense console views share the same semantic tokens, radius, and motion contract.",
3136
+ icon: "bell" as const,
3137
+ title: "Signals before surprises",
3138
+ body: "Cove watches every route and surfaces stalled reviews and expiring approvals while there is still time to act.",
3033
3139
  },
3034
3140
  {
3035
- icon: "circle-check" as const,
3036
- title: "Calm confidence",
3037
- body: "Large type, tactile controls, and focused status tones make complex work feel understandable on first pass.",
3141
+ icon: "clock" as const,
3142
+ title: "A trail for every decision",
3143
+ body: "Each approval keeps its context, reviewer, and timestamp, so an audit takes an afternoon instead of a quarter.",
3038
3144
  },
3039
3145
  ]
3040
3146
 
3041
- const reviews = [
3042
- { name: "Waypoint", value: "98.4%", label: "clear routes" },
3043
- { name: "Fielddesk", value: "12", label: "open holds" },
3044
- { name: "Archive", value: "4.8k", label: "sealed records" },
3147
+ const steps = [
3148
+ {
3149
+ number: "01",
3150
+ title: "Connect your spaces",
3151
+ body: "Bring teams, vendors, and record stores into one workspace, with roles that mirror how you already work.",
3152
+ },
3153
+ {
3154
+ number: "02",
3155
+ title: "Route every approval",
3156
+ body: "Requests follow the path you define, with reviewers, deadlines, and escalation built in from the start.",
3157
+ },
3158
+ {
3159
+ number: "03",
3160
+ title: "Seal the record",
3161
+ body: "Approved work is archived with its full context and is ready for review the moment it closes.",
3162
+ },
3163
+ ]
3164
+
3165
+ const outcomes = [
3166
+ { value: "3.2×", label: "faster approvals in the first quarter" },
3167
+ { value: "68%", label: "fewer follow-ups on handoffs" },
3168
+ { value: "1 day", label: "to prepare a quarterly audit" },
3045
3169
  ]
3046
3170
 
3047
3171
  const footerGroups = [
@@ -3049,9 +3173,9 @@ const footerGroups = [
3049
3173
  title: "Platform",
3050
3174
  links: [
3051
3175
  { label: "Overview", href: "#platform" },
3052
- { label: "Signals", href: "#signals" },
3053
- { label: "Approvals", href: "#approvals" },
3054
- { label: "Records", href: "#records" },
3176
+ { label: "Signals", href: "/signals" },
3177
+ { label: "Approvals", href: "/approvals" },
3178
+ { label: "Records", href: "/records" },
3055
3179
  ],
3056
3180
  },
3057
3181
  {
@@ -3059,7 +3183,7 @@ const footerGroups = [
3059
3183
  links: [
3060
3184
  { label: "About", href: "/about" },
3061
3185
  { label: "Security", href: "/security" },
3062
- { label: "Customers", href: "/customers" },
3186
+ { label: "Customers", href: "#customers" },
3063
3187
  { label: "Careers", href: "/careers" },
3064
3188
  ],
3065
3189
  },
@@ -3074,10 +3198,10 @@ const footerGroups = [
3074
3198
  },
3075
3199
  ]
3076
3200
 
3077
- const footerSignals = [
3078
- { label: "Uptime", value: "99.98%" },
3079
- { label: "Regions", value: "12" },
3080
- { label: "Response", value: "42s" },
3201
+ const legalLinks = [
3202
+ { label: "Privacy", href: "/privacy" },
3203
+ { label: "Terms", href: "/terms" },
3204
+ { label: "Contact", href: "/contact" },
3081
3205
  ]
3082
3206
 
3083
3207
  export function BrandLandingPage() {
@@ -3098,8 +3222,8 @@ export function BrandLandingPage() {
3098
3222
  <div className="hidden lg:block">
3099
3223
  <Row gap={1}>
3100
3224
  {navItems.map((item) => (
3101
- <Button key={item} variant="ghost" size="sm" render={<a href={`#${item.toLowerCase()}`} />}>
3102
- {item}
3225
+ <Button key={item.label} variant="ghost" size="sm" render={<a href={item.href} />}>
3226
+ {item.label}
3103
3227
  </Button>
3104
3228
  ))}
3105
3229
  </Row>
@@ -3117,16 +3241,16 @@ export function BrandLandingPage() {
3117
3241
  </header>
3118
3242
 
3119
3243
  <main>
3120
- <Region as="section" max="wide" className="pb-16 pt-14 sm:pt-20 lg:pb-24">
3121
- <div className="grid items-center gap-10 lg:grid-cols-[0.92fr_1.08fr]">
3122
- <Stack gap={4} className="max-w-3xl">
3244
+ <Region as="section" max="wide" className="pb-20 pt-14 sm:pt-20 lg:pb-28">
3245
+ <div className="grid items-center gap-12 lg:grid-cols-[1fr_1.1fr] lg:gap-10">
3246
+ <Stack gap={3} className="max-w-2xl">
3123
3247
  <Badge appearance="outline" icon={<Icon name="lock" className="text-sm" />}>
3124
3248
  Private operations, made legible
3125
3249
  </Badge>
3126
3250
  <TextReveal
3127
3251
  as="h1"
3128
- text="A calmer command center for high-trust digital work"
3129
- className="max-w-4xl text-balance text-display-xl sm:text-display-3xl"
3252
+ text="A calmer command center for high-trust work"
3253
+ className="text-balance text-display-lg sm:text-display-2xl"
3130
3254
  once
3131
3255
  />
3132
3256
  <p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
@@ -3134,89 +3258,96 @@ export function BrandLandingPage() {
3134
3258
  surface: approvals, signals, records, and handoffs in one
3135
3259
  understandable flow.
3136
3260
  </p>
3137
- <Row gap={2} wrap>
3261
+ <Row gap={2} wrap className="pt-3">
3138
3262
  <Button size="cta" render={<a href="/demo" />}>
3139
3263
  Book a walkthrough
3140
3264
  </Button>
3141
3265
  <Button size="cta" variant="outline" render={<a href="#platform" />}>
3142
- Explore platform
3266
+ Explore the platform
3143
3267
  </Button>
3144
3268
  </Row>
3145
3269
  </Stack>
3146
3270
 
3147
- <div className="relative">
3148
- {/* Nested surfaces take concentric corners: each level's radius is
3149
- the shell's xl minus every inset between it and the shell edge. */}
3150
- <div className="overflow-hidden rounded-xl border border-border-floating bg-overlay p-3 text-overlay-foreground shadow-xl">
3151
- <div className="rounded-concentric-xl/3 border border-overlay-foreground/10 bg-overlay-foreground/8">
3152
- <div className="flex items-center justify-between border-b border-overlay-foreground/10 px-4 py-3">
3271
+ {/* The product scene is a dark island: `dark` re-scopes every
3272
+ semantic token, so its surfaces, muted ink and status tones
3273
+ are the theme's own dark values in either page mode. It is a
3274
+ picture of the product, so it stays out of the tab order and
3275
+ the accessibility tree; the hero copy carries its meaning. */}
3276
+ <div className="dark" aria-hidden inert>
3277
+ <div className="overflow-hidden rounded-xl border border-border-floating bg-popover p-3 text-popover-foreground shadow-xl">
3278
+ {/* Nested surfaces take concentric corners: each level's radius is
3279
+ the shell's xl minus every inset between it and the shell edge. */}
3280
+ <div className="rounded-concentric-xl/3 border border-border bg-card">
3281
+ <div className="flex items-center justify-between border-b border-border px-4 py-3">
3153
3282
  <Row gap={1}>
3154
3283
  <StatusDot tone="warning" />
3155
3284
  <StatusDot tone="edition" />
3156
3285
  <StatusDot tone="success" />
3157
3286
  </Row>
3158
- <span className="text-label-sm text-overlay-foreground/70">Live workspace</span>
3287
+ <span className="text-label-sm text-muted-foreground">Live workspace</span>
3159
3288
  </div>
3160
3289
  <div className="grid gap-3 p-3 md:grid-cols-[0.7fr_1.3fr]">
3161
- <div className="space-y-3">
3162
- <div className="rounded-concentric-xl/6 border border-overlay-foreground/10 bg-overlay-foreground/8 p-4">
3163
- <p className="text-label-sm text-overlay-foreground/60">Spaces</p>
3290
+ <div className="space-y-3 max-md:hidden">
3291
+ <div className="rounded-concentric-xl/6 border border-border bg-background p-4">
3292
+ <p className="text-label-sm text-muted-foreground">Spaces</p>
3164
3293
  <div className="mt-4 space-y-2">
3165
- {reviews.map((review) => (
3166
- <div key={review.name} className="rounded-concentric-xl/10 bg-overlay p-3">
3294
+ {spaces.map((space) => (
3295
+ <div key={space.name} className="rounded-concentric-xl/10 bg-card p-3">
3167
3296
  <Row justify="between">
3168
- <span className="text-title-sm">{review.name}</span>
3169
- <span className="text-mono-sm text-overlay-foreground">{review.value}</span>
3297
+ <span className="text-title-sm">{space.name}</span>
3298
+ <span className="text-mono-sm">{space.value}</span>
3170
3299
  </Row>
3171
- <p className="mt-1 text-body-xs text-overlay-foreground/60">{review.label}</p>
3300
+ <p className="mt-1 text-body-xs text-muted-foreground">{space.label}</p>
3172
3301
  </div>
3173
3302
  ))}
3174
3303
  </div>
3175
3304
  </div>
3176
- <div className="rounded-concentric-xl/6 border border-overlay-foreground/10 bg-overlay-foreground/8 p-4">
3305
+ <div className="rounded-concentric-xl/6 border border-border bg-background p-4">
3177
3306
  <Row justify="between">
3178
3307
  <span className="text-title-sm">Route status</span>
3179
- <Badge tone="success" size="sm">Healthy</Badge>
3308
+ <Badge tone="success" size="sm" dot>
3309
+ Healthy
3310
+ </Badge>
3180
3311
  </Row>
3181
3312
  <div className="mt-4 space-y-2">
3182
- {workflow.map((item) => (
3183
- <Row key={item.label} justify="between" className="rounded-full bg-overlay px-3 py-2">
3313
+ {routes.map((route) => (
3314
+ <Row key={route.label} justify="between" className="rounded-full bg-card px-3 py-2">
3184
3315
  <Row gap={1}>
3185
- <StatusDot tone={item.tone} />
3186
- <span className="text-body-sm">{item.label}</span>
3316
+ <StatusDot tone={route.tone} />
3317
+ <span className="text-body-sm">{route.label}</span>
3187
3318
  </Row>
3188
- <span className="text-label-sm text-overlay-foreground/60">{item.state}</span>
3319
+ <span className="text-label-sm text-muted-foreground">{route.state}</span>
3189
3320
  </Row>
3190
3321
  ))}
3191
3322
  </div>
3192
3323
  </div>
3193
3324
  </div>
3194
3325
 
3195
- <div className="rounded-concentric-xl/6 border border-overlay-foreground/10 bg-overlay-foreground/8 p-4">
3196
- <Row justify="between" className="border-b border-overlay-foreground/10 pb-4">
3326
+ <div className="rounded-concentric-xl/6 border border-border bg-background p-4">
3327
+ <Row justify="between" className="border-b border-border pb-4">
3197
3328
  <div>
3198
- <p className="text-label-sm text-overlay-foreground/60">Signal map</p>
3199
- <h2 className="mt-1 text-heading-3">Every approval path in view</h2>
3329
+ <p className="text-label-sm text-muted-foreground">Signal map</p>
3330
+ <p className="mt-1 text-heading-4 sm:text-heading-3">Every approval path in view</p>
3200
3331
  </div>
3201
3332
  <Button variant="secondary" size="sm">Review</Button>
3202
3333
  </Row>
3203
- <div className="mt-6 grid min-h-72 grid-rows-[1fr_auto] gap-5">
3204
- <div className="grid grid-cols-9 items-end gap-2 rounded-concentric-xl/10 bg-overlay p-4">
3205
- {bars.map((bar, index) => (
3206
- <div key={`${bar.tone}-${index}`} className="flex h-56 items-end">
3334
+ <div className="mt-5 grid gap-4 sm:mt-6 sm:gap-5">
3335
+ <div className="grid grid-cols-9 items-end gap-1.5 rounded-concentric-xl/10 bg-card p-4 sm:gap-2">
3336
+ {bars.map((bar) => (
3337
+ <div key={bar.week} className="flex h-36 items-end sm:h-56">
3207
3338
  <div
3208
- className={`w-full rounded-full ${bar.tone}`}
3339
+ className={`w-full rounded-full ${bar.current ? "bg-success" : "bg-secondary"}`}
3209
3340
  style={{ height: bar.height }}
3210
3341
  />
3211
3342
  </div>
3212
3343
  ))}
3213
3344
  </div>
3214
- <div className="grid gap-2 sm:grid-cols-3">
3215
- {metrics.map((metric) => (
3216
- <div key={metric.label} className="rounded-concentric-xl/10 border border-overlay-foreground/10 bg-overlay p-3">
3217
- <p className="text-label-sm text-overlay-foreground/60">{metric.label}</p>
3218
- <p className="mt-2 text-heading-3">{metric.value}</p>
3219
- <p className="mt-1 text-body-xs text-overlay-foreground/60">{metric.detail}</p>
3345
+ <div className="grid grid-cols-3 gap-2">
3346
+ {sceneMetrics.map((metric) => (
3347
+ <div key={metric.label} className="rounded-concentric-xl/10 border border-border bg-card p-3">
3348
+ <p className="truncate text-label-sm text-muted-foreground">{metric.label}</p>
3349
+ <p className="mt-2 text-heading-4 sm:text-heading-3">{metric.value}</p>
3350
+ <p className="mt-1 text-body-xs text-muted-foreground max-sm:hidden">{metric.detail}</p>
3220
3351
  </div>
3221
3352
  ))}
3222
3353
  </div>
@@ -3229,54 +3360,136 @@ export function BrandLandingPage() {
3229
3360
  </div>
3230
3361
  </Region>
3231
3362
 
3232
- <section id="platform" className="border-y border-border bg-muted/40 py-14">
3363
+ <section id="platform" aria-labelledby="brand-platform-title" className="border-y border-border bg-muted/40 py-20 lg:py-28">
3233
3364
  <Region max="wide">
3234
- <div className="grid gap-5 lg:grid-cols-[0.8fr_1.2fr]">
3235
- <Stack gap={3}>
3236
- <Badge>Design mood</Badge>
3237
- <h2 className="text-heading-1">Marketing warmth, product discipline.</h2>
3238
- <p className="max-w-(--measure-prose) text-body-md text-muted-foreground">
3239
- The page uses a pill nav, tactile surfaces, soft status tones, and
3240
- slower ease-out movement. It stays broad in color so it does not
3241
- collapse into purple-brand sameness.
3365
+ <Stack gap={6}>
3366
+ <Stack gap={2} className="max-w-2xl">
3367
+ <p className="text-overline text-muted-foreground">Why Cove</p>
3368
+ <h2 id="brand-platform-title" className="text-balance text-heading-1">
3369
+ One private workspace for the decisions that matter.
3370
+ </h2>
3371
+ <p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
3372
+ Most operations work lives in threads, spreadsheets, and memory.
3373
+ Cove gives it a home: every request routed, every signal visible,
3374
+ every outcome on the record.
3242
3375
  </p>
3243
3376
  </Stack>
3244
- <div className="grid gap-3 md:grid-cols-3">
3245
- {features.map((feature) => (
3246
- <Card key={feature.title} render={<article />} className="h-full">
3247
- <CardContent>
3248
- <span className="grid size-10 place-items-center rounded-md bg-highlight text-highlight-foreground">
3249
- <Icon name={feature.icon} className="text-xl" />
3250
- </span>
3251
- <h3 className="mt-5 text-title-lg">{feature.title}</h3>
3252
- <p className="mt-2 text-body-sm text-muted-foreground">{feature.body}</p>
3253
- </CardContent>
3254
- </Card>
3255
- ))}
3256
- </div>
3257
- </div>
3377
+ <Reveal>
3378
+ <Grid minItemWidth="calc(var(--spacing) * 64)" gap={2}>
3379
+ {values.map((value) => (
3380
+ <Card key={value.title} render={<article />} className="h-full">
3381
+ <CardContent className="flex flex-col gap-2">
3382
+ <span className="mb-4 grid size-11 place-items-center rounded-md border border-border sable-well">
3383
+ <Icon name={value.icon} className="text-xl" />
3384
+ </span>
3385
+ <h3 className="text-title-lg">{value.title}</h3>
3386
+ <p className="text-body-sm text-muted-foreground">{value.body}</p>
3387
+ </CardContent>
3388
+ </Card>
3389
+ ))}
3390
+ </Grid>
3391
+ </Reveal>
3392
+ </Stack>
3258
3393
  </Region>
3259
3394
  </section>
3260
3395
 
3261
- <Region as="section" max="wide" className="py-14">
3262
- <div className="grid gap-3 md:grid-cols-3">
3263
- {metrics.map((metric) => (
3264
- <Card key={metric.label}>
3265
- <CardContent>
3266
- <p className="text-label-md text-muted-foreground">{metric.label}</p>
3267
- <p className="mt-3 text-display-lg">{metric.value}</p>
3268
- <p className="mt-1 text-body-sm text-muted-foreground">{metric.detail}</p>
3396
+ <Region as="section" id="how-it-works" aria-labelledby="brand-steps-title" max="wide" className="py-20 lg:py-28">
3397
+ <div className="grid gap-12 lg:grid-cols-[1fr_2fr] lg:gap-16">
3398
+ <Stack gap={2} className="max-w-md">
3399
+ <p className="text-overline text-muted-foreground">How it works</p>
3400
+ <h2 id="brand-steps-title" className="text-balance text-heading-1">
3401
+ From request to sealed record.
3402
+ </h2>
3403
+ <p className="text-body-md text-muted-foreground">
3404
+ Teams are usually live within a week. Cove adapts to the routes
3405
+ you already run instead of asking you to start over.
3406
+ </p>
3407
+ <div className="pt-3">
3408
+ <Button variant="outline" iconEnd={<Icon name="arrow-right" />} render={<a href="/guides/rollout" />}>
3409
+ Read the rollout guide
3410
+ </Button>
3411
+ </div>
3412
+ </Stack>
3413
+ <ol className="grid gap-10 sm:grid-cols-3 sm:gap-6">
3414
+ {steps.map((step) => (
3415
+ <li key={step.number} className="flex flex-col gap-2 border-t border-border-strong pt-6">
3416
+ <span className="text-mono-sm text-muted-foreground">{step.number}</span>
3417
+ <h3 className="mt-3 text-title-lg sm:mt-6">{step.title}</h3>
3418
+ <p className="text-body-sm text-muted-foreground">{step.body}</p>
3419
+ </li>
3420
+ ))}
3421
+ </ol>
3422
+ </div>
3423
+ </Region>
3424
+
3425
+ <Region as="section" id="customers" aria-label="Customer story" max="wide" className="pb-20 lg:pb-28">
3426
+ <Card render={<figure />}>
3427
+ <div className="grid lg:grid-cols-[1.5fr_1fr]">
3428
+ <CardContent className="flex flex-col justify-between gap-10 sm:p-10">
3429
+ <Stack gap={3}>
3430
+ <p className="text-overline text-muted-foreground">Customer story</p>
3431
+ <blockquote className="text-balance text-heading-3 sm:text-heading-2">
3432
+ “We used to chase approvals across four tools. Now every request
3433
+ has an owner, a deadline, and a record, and our auditors stopped
3434
+ asking for screenshots.”
3435
+ </blockquote>
3436
+ </Stack>
3437
+ <figcaption className="flex items-center gap-3">
3438
+ <Avatar size="lg" fallback="MO" />
3439
+ <div>
3440
+ <p className="text-title-sm">Maya Okafor</p>
3441
+ <p className="text-body-sm text-muted-foreground">Head of operations, Fielddesk</p>
3442
+ </div>
3443
+ </figcaption>
3444
+ </CardContent>
3445
+ <div className="border-border max-lg:border-t lg:border-s">
3446
+ <CardContent className="flex h-full flex-col justify-center gap-6 sm:p-10">
3447
+ {outcomes.map((outcome, index) => (
3448
+ <Stack key={outcome.label} gap={3}>
3449
+ {index > 0 && <Separator />}
3450
+ <div>
3451
+ <p className="text-heading-1 sm:text-display-lg">{outcome.value}</p>
3452
+ <p className="mt-1 text-body-sm text-muted-foreground">{outcome.label}</p>
3453
+ </div>
3454
+ </Stack>
3455
+ ))}
3269
3456
  </CardContent>
3270
- </Card>
3271
- ))}
3457
+ </div>
3458
+ </div>
3459
+ </Card>
3460
+ </Region>
3461
+
3462
+ <Region as="section" aria-labelledby="brand-cta-title" max="wide" className="pb-20 lg:pb-28">
3463
+ <div className="dark">
3464
+ <div className="rounded-xl border border-border bg-card px-6 py-12 text-card-foreground sm:px-12 sm:py-16">
3465
+ <div className="flex flex-col gap-8 lg:flex-row lg:items-end lg:justify-between">
3466
+ <Stack gap={3} className="max-w-2xl">
3467
+ <h2 id="brand-cta-title" className="text-balance text-heading-1">
3468
+ See your own approval paths in Cove.
3469
+ </h2>
3470
+ <p className="text-body-lg text-muted-foreground">
3471
+ A 30-minute walkthrough with your routes, your roles, and your
3472
+ records. No slides.
3473
+ </p>
3474
+ </Stack>
3475
+ <Row gap={2} wrap>
3476
+ <Button size="cta" render={<a href="/demo" />}>
3477
+ Book a walkthrough
3478
+ </Button>
3479
+ <Button size="cta" variant="ghost" render={<a href="/pricing" />}>
3480
+ View pricing
3481
+ </Button>
3482
+ </Row>
3483
+ </div>
3484
+ </div>
3272
3485
  </div>
3273
3486
  </Region>
3274
3487
  </main>
3275
3488
 
3276
3489
  <footer className="border-t border-border bg-muted/35">
3277
- <Region max="wide" className="py-10 sm:py-12">
3278
- <div className="grid gap-8 lg:grid-cols-[1fr_1.35fr_1fr]">
3279
- <Stack gap={3} className="max-w-md">
3490
+ <Region max="wide" className="py-12 sm:py-16">
3491
+ <div className="grid gap-10 lg:grid-cols-[1fr_1.35fr_1fr] lg:gap-8">
3492
+ <Stack gap={4} className="max-w-sm">
3280
3493
  <a href="/" className="flex w-fit items-center gap-2 rounded-full motion-interactive motion-press">
3281
3494
  <span className="grid size-9 place-items-center rounded-full bg-primary text-primary-foreground">
3282
3495
  <Icon name="sparkles" className="text-sm" />
@@ -3287,20 +3500,15 @@ export function BrandLandingPage() {
3287
3500
  A calm operating surface for teams that need trust, speed, and
3288
3501
  context without the noise.
3289
3502
  </p>
3290
- <Row gap={1} wrap>
3291
- <Badge size="sm" icon={<Icon name="lock" />}>
3292
- Private
3293
- </Badge>
3294
- <Badge tone="success" size="sm" icon={<Icon name="circle-check" />}>
3295
- Live
3296
- </Badge>
3297
- </Row>
3503
+ <Badge appearance="plain" tone="success" dot>
3504
+ All systems normal
3505
+ </Badge>
3298
3506
  </Stack>
3299
3507
 
3300
- <nav aria-label="Footer" className="grid gap-6 sm:grid-cols-3">
3508
+ <nav aria-label="Footer" className="grid grid-cols-3 gap-6">
3301
3509
  {footerGroups.map((group) => (
3302
3510
  <div key={group.title}>
3303
- <h2 className="text-label-md text-muted-foreground">{group.title}</h2>
3511
+ <h2 className="text-label-md">{group.title}</h2>
3304
3512
  <ul className="mt-3 space-y-1">
3305
3513
  {group.links.map((link) => (
3306
3514
  <li key={link.label}>
@@ -3317,22 +3525,17 @@ export function BrandLandingPage() {
3317
3525
  ))}
3318
3526
  </nav>
3319
3527
 
3320
- <Card>
3528
+ <Card className="h-fit">
3321
3529
  <CardHeader>
3322
3530
  {/* CardTitle carries the type; the h2 inside it keeps the outline. */}
3323
3531
  <CardTitle>
3324
- <h2>Signal digest</h2>
3532
+ <h2>The Cove digest</h2>
3325
3533
  </CardTitle>
3326
3534
  <CardDescription>
3327
- One weekly note on trusted workflows and product releases.
3535
+ One note a month on trusted workflows and product releases.
3328
3536
  </CardDescription>
3329
- <CardAction>
3330
- <span className="grid size-9 place-items-center rounded-full bg-highlight text-highlight-foreground">
3331
- <Icon name="sparkles" className="text-sm" />
3332
- </span>
3333
- </CardAction>
3334
3537
  </CardHeader>
3335
- <CardContent className="flex flex-col gap-4">
3538
+ <CardContent>
3336
3539
  <form action="/newsletter" className="grid gap-2 sm:grid-cols-[1fr_auto]">
3337
3540
  <label htmlFor="brand-footer-email" className="sr-only">
3338
3541
  Work email
@@ -3345,42 +3548,25 @@ export function BrandLandingPage() {
3345
3548
  placeholder="you@company.com"
3346
3549
  />
3347
3550
  <Button type="submit" className="w-full sm:w-auto">
3348
- Join
3551
+ Subscribe
3349
3552
  </Button>
3350
3553
  </form>
3351
- <div className="grid grid-cols-3 gap-2">
3352
- {footerSignals.map((signal) => (
3353
- <div key={signal.label} className="rounded-md bg-muted p-3">
3354
- <p className="text-mono-sm">{signal.value}</p>
3355
- <p className="mt-1 text-caption text-muted-foreground">{signal.label}</p>
3356
- </div>
3357
- ))}
3358
- </div>
3359
3554
  </CardContent>
3360
3555
  </Card>
3361
3556
  </div>
3362
3557
 
3363
- <div className="mt-10 flex flex-col gap-3 border-t border-border pt-5 text-caption text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
3558
+ <div className="mt-12 flex flex-col gap-3 border-t border-border pt-6 text-caption text-muted-foreground sm:flex-row sm:items-center sm:justify-between">
3364
3559
  <p>© 2026 Cove. All rights reserved.</p>
3365
- <Row gap={3} wrap>
3366
- <a
3367
- href="/privacy"
3368
- className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
3369
- >
3370
- Privacy
3371
- </a>
3372
- <a
3373
- href="/terms"
3374
- className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
3375
- >
3376
- Terms
3377
- </a>
3378
- <a
3379
- href="/contact"
3380
- className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
3381
- >
3382
- Contact
3383
- </a>
3560
+ <Row gap={4} wrap>
3561
+ {legalLinks.map((link) => (
3562
+ <a
3563
+ key={link.label}
3564
+ href={link.href}
3565
+ className="flex min-h-9 min-w-9 items-center motion-interactive hover:text-foreground"
3566
+ >
3567
+ {link.label}
3568
+ </a>
3569
+ ))}
3384
3570
  </Row>
3385
3571
  </div>
3386
3572
  </Region>
@@ -3406,7 +3592,11 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
3406
3592
  - Region cap is `feed` (640px) — a conversation is a reading column, and the Bubble measure cap is tuned to the same assumption.
3407
3593
  - The composer sits outside the scroll region, in a bordered CardFooter, so it stays put while the transcript moves.
3408
3594
  - Grouping is derived from the data (`turns[i - 1].mine === turn.mine`) rather than hand-set, and the tail is dropped on continuations — that pairing is what makes a run read as one block.
3409
- - The assistant's long-form answer uses `variant="plain"`. A tinted box around a code block reads as a quote rather than as a turn.
3595
+ - What the assistant did before answering — a doc it read, a tool it ran — is a `Marker variant="note"` in that message's `header`, so it reads as part of the turn rather than as an event between turns.
3596
+ - The assistant's long-form answer uses `variant="plain"` and continues the same grouped run. A tinted box around a code block reads as a quote rather than as a turn. A plain Bubble sizes to its content with no measure cap, so it takes `max-w-full` here: bounded by the column, a wide code line scrolls inside the CodeBlock instead of pushing the transcript sideways on a phone.
3597
+ - A settled answer carries its actions (copy, regenerate) in the Message `footer` as a `Toolbar` of icon `ToolbarButton`s with tooltips — one tab stop, arrow keys between them. The answer still streaming has none: there is nothing to copy or regenerate until it settles, and Stop lives in the composer.
3598
+ - One turn, one Message: the reader's question and the file it refers to stack inside a single end-aligned Message rather than being sent as two.
3599
+ - Assistant turns share one Avatar with a glyph fallback and `alt="Assistant"` — initials like "AI" read as a person, and a fallback-only avatar is otherwise unnamed.
3410
3600
  - The answer in flight is a `StreamingText` in a `plain` Bubble, and ONE `pending` flag drives both it and the composer's `busy`. They are the same wait reported at the two ends of the panel; letting them drift gives you a composer still saying 'sending' under an answer that already finished.
3411
3601
  - A spinner `Marker` (or `ThinkingOrb`) belongs BEFORE the first token and is replaced once tokens land, not kept alongside. Two indicators for one wait read as two things happening — which is also why `StreamingText` takes `caret={false}` when something adjacent already reports it.
3412
3602
  - MessageScroller needs no configuring for this: it re-follows on "a message grew" (ResizeObserver) as well as "a message arrived", so the newest token stays pinned while a reader who has scrolled up is left alone.
@@ -3416,12 +3606,12 @@ A full-height conversation surface: titled header, scrolling transcript, and a c
3416
3606
 
3417
3607
  - The panel header's title is the page's <h1> (an h1 inside CardTitle, which carries the type); the transcript is a scroll region with native overflow, so keyboard scrolling works unchanged.
3418
3608
  - The answer in flight carries `aria-busy` rather than sitting in a live region. That is deliberate: a live region around streamed text announces every token, which is unusable. Announce once when the turn settles, from whatever owns the turn — the incomplete state is still exposed, just not narrated.
3419
- - The composer is a real <form> whose Enter handling skips IME composition, and every icon-only control in the panel carries an accessible label.
3609
+ - The composer is a real <form> whose Enter handling skips IME composition, and every icon-only control in the panel carries an accessible label — the answer actions also carry a tooltip, and the stop control is named for what it stops (`stopLabel="Stop answering"`).
3420
3610
 
3421
3611
  ## Source
3422
3612
 
3423
3613
  ```tsx
3424
- import { Attachment, Avatar, Bubble, BubbleReaction, BubbleReactions, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, ChatComposer, CodeBlock, Icon, Marker, MenuRadioGroup, MenuRadioItem, Message, MessageScroller, Page, SettingsMenu, SettingsRow, StreamingText } from "@sableui/react"
3614
+ import { Attachment, Avatar, Bubble, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, ChatComposer, CodeBlock, Icon, Marker, MenuRadioGroup, MenuRadioItem, Message, MessageScroller, Page, SettingsMenu, SettingsRow, SimpleTooltip, StreamingText, Toolbar, ToolbarButton } from "@sableui/react"
3425
3615
 
3426
3616
  /**
3427
3617
  * The request is in flight. In an app this is your send state — from `useChat`,
@@ -3434,9 +3624,14 @@ const pending = true
3434
3624
 
3435
3625
  /** The answer so far. Append tokens to this; `StreamingText` assumes it only grows. */
3436
3626
  const streamed =
3437
- "Anchor on the oldest rendered message and add back however far it moved. Appends"
3627
+ "Yes, appends are already safe: your layout effect measures before paint. The jump is on the prepend path, which reads scrollHeight after the new rows"
3438
3628
 
3439
- const turns = [
3629
+ /**
3630
+ * The opening exchange. `step` is what the assistant did before answering — a
3631
+ * doc it read, a tool it ran — and it rides in that message's header, so it
3632
+ * reads as part of the turn rather than as an event between turns.
3633
+ */
3634
+ const turns: { id: string; mine: boolean; step?: string; body: string }[] = [
3440
3635
  {
3441
3636
  id: "t_01",
3442
3637
  mine: true,
@@ -3445,6 +3640,7 @@ const turns = [
3445
3640
  {
3446
3641
  id: "t_02",
3447
3642
  mine: false,
3643
+ step: "Read message-scroller.md",
3448
3644
  body: "Almost always the anchor. If you preserve the scroll position by measuring the container, prepending history moves everything under the reader.",
3449
3645
  },
3450
3646
  {
@@ -3455,6 +3651,19 @@ const turns = [
3455
3651
  ]
3456
3652
 
3457
3653
  export function ChatPanel() {
3654
+ // One identity for every assistant turn. A glyph rather than initials: "AI"
3655
+ // in a circle reads as a person called A. I. The glyph is sized to the
3656
+ // circle (the fallback's own size is tuned for two letters, and a lone icon
3657
+ // at that size reads as a speck); `alt` names it for a screen reader, which
3658
+ // otherwise gets nothing from a fallback-only avatar.
3659
+ const assistant = (
3660
+ <Avatar
3661
+ size="sm"
3662
+ alt="Assistant"
3663
+ fallback={<Icon name="sparkles" className="text-base" />}
3664
+ />
3665
+ )
3666
+
3458
3667
  return (
3459
3668
  <Page as="main" max="feed" gap={0} className="py-6">
3460
3669
  {/* h-dvh minus the page's own padding (py-6 = 12 spacing units, in
@@ -3473,8 +3682,12 @@ export function ChatPanel() {
3473
3682
  </CardTitle>
3474
3683
  <CardDescription>Assistant · answers from your team's docs</CardDescription>
3475
3684
  <CardAction>
3476
- <Button variant="outline" size="icon-sm" aria-label="Start a new chat">
3477
- <Icon name="plus" />
3685
+ {/* Labelled, not a bare plus: the composer already has a plus
3686
+ (attach), and two identical glyphs a panel apart that do
3687
+ different things is a guessing game. */}
3688
+ <Button variant="ghost" size="sm">
3689
+ <Icon name="edit" />
3690
+ New chat
3478
3691
  </Button>
3479
3692
  </CardAction>
3480
3693
  </CardHeader>
@@ -3484,32 +3697,67 @@ export function ChatPanel() {
3484
3697
  <MessageScroller className="min-h-0 flex-1 p-(--card-p)">
3485
3698
  <Marker variant="separator">Today</Marker>
3486
3699
 
3487
- {turns.map((turn, i) => (
3488
- <Message
3489
- key={turn.id}
3490
- messageId={turn.id}
3491
- align={turn.mine ? "end" : "start"}
3492
- grouped={i > 0 && turns[i - 1].mine === turn.mine}
3493
- avatar={turn.mine ? undefined : <Avatar size="sm" fallback="AI" />}
3494
- >
3495
- <Bubble
3496
- variant={turn.mine ? "primary" : "muted"}
3700
+ {turns.map((turn, i) => {
3701
+ // Derived, not hand-set: a turn continues a run when the one
3702
+ // before it has the same author. A continuation drops its
3703
+ // avatar (Message keeps the column) AND its tail (Bubble keeps
3704
+ // the ear's space) — together, that makes the run one block.
3705
+ const continues = i > 0 && turns[i - 1].mine === turn.mine
3706
+ return (
3707
+ <Message
3708
+ key={turn.id}
3709
+ messageId={turn.id}
3497
3710
  align={turn.mine ? "end" : "start"}
3498
- tail={i === 0 || turns[i - 1].mine !== turn.mine}
3711
+ grouped={continues}
3712
+ avatar={turn.mine ? undefined : assistant}
3713
+ header={
3714
+ turn.step ? (
3715
+ <Marker variant="note" icon="book" className="justify-start">
3716
+ {turn.step}
3717
+ </Marker>
3718
+ ) : undefined
3719
+ }
3499
3720
  >
3500
- {turn.body}
3501
- </Bubble>
3502
- </Message>
3503
- ))}
3504
-
3505
- <Marker variant="note" icon="sparkles">
3506
- Read message-scroller.md
3507
- </Marker>
3508
-
3509
- {/* An assistant's long-form answer: no bubble, because a tinted box
3510
- around a code block reads as a quote rather than as a turn. */}
3511
- <Message messageId="t_04" avatar={<Avatar size="sm" fallback="AI" />}>
3512
- <Bubble variant="plain">
3721
+ <Bubble
3722
+ variant={turn.mine ? "primary" : "muted"}
3723
+ align={turn.mine ? "end" : "start"}
3724
+ tail={!continues}
3725
+ >
3726
+ {turn.body}
3727
+ </Bubble>
3728
+ </Message>
3729
+ )
3730
+ })}
3731
+
3732
+ {/* The same run, continued: long-form output with no bubble,
3733
+ because a tinted box around a code block reads as a quote
3734
+ rather than as a turn. Its footer carries what the reader does
3735
+ WITH a settled answer, as a Toolbar: one tab stop, arrow keys
3736
+ between the actions. */}
3737
+ <Message
3738
+ messageId="t_04"
3739
+ grouped
3740
+ avatar={assistant}
3741
+ footer={
3742
+ <Toolbar aria-label="Answer actions">
3743
+ <SimpleTooltip content="Copy answer">
3744
+ <ToolbarButton size="icon-sm" aria-label="Copy answer">
3745
+ <Icon name="copy" />
3746
+ </ToolbarButton>
3747
+ </SimpleTooltip>
3748
+ <SimpleTooltip content="Regenerate">
3749
+ <ToolbarButton size="icon-sm" aria-label="Regenerate answer">
3750
+ <Icon name="refresh" />
3751
+ </ToolbarButton>
3752
+ </SimpleTooltip>
3753
+ </Toolbar>
3754
+ }
3755
+ >
3756
+ {/* max-w-full: a plain Bubble sizes to its content with no
3757
+ measure cap, and a code line is wider than a phone. Bounded
3758
+ by the column, the CodeBlock scrolls inside itself instead of
3759
+ pushing the whole transcript sideways. */}
3760
+ <Bubble variant="plain" className="max-w-full">
3513
3761
  <p className="text-body-md text-foreground">
3514
3762
  Here is the shape of it:
3515
3763
  </p>
@@ -3520,28 +3768,46 @@ export function ChatPanel() {
3520
3768
  filename="preserve.ts"
3521
3769
  />
3522
3770
  </Bubble>
3523
- <BubbleReactions>
3524
- <BubbleReaction emoji="🎯" count={2} active />
3525
- </BubbleReactions>
3526
3771
  </Message>
3527
3772
 
3773
+ {/* The reader's follow-up is one turn: the question and the file
3774
+ it refers to stack in a single Message, so they share a side, an
3775
+ edge and a timestamp rather than reading as two sends. */}
3528
3776
  <Message messageId="t_05" align="end">
3529
- <Attachment name="scroller.tsx" meta="4.2 KB · TSX" />
3777
+ <Bubble variant="primary" align="end">
3778
+ Here's ours. Does the same fix cover new messages arriving?
3779
+ </Bubble>
3780
+ <Attachment
3781
+ name="scroller.tsx"
3782
+ meta="4.2 KB · TSX"
3783
+ onOpen={() => undefined}
3784
+ />
3530
3785
  </Message>
3531
3786
 
3532
3787
  {/* The answer arriving. A `Marker` with a spinner — or ThinkingOrb —
3533
3788
  is what sits here BEFORE the first token; once tokens land it is
3534
3789
  replaced rather than kept, because two indicators for one wait
3535
- read as two things happening.
3790
+ read as two things happening. What stays is the step it took,
3791
+ as a finished note in the header.
3536
3792
 
3537
- No bubble, same as the long-form answer above: a tinted box around
3538
- a growing paragraph makes the reflow look like the box breathing.
3793
+ No bubble, same as the long-form answer above: a tinted box
3794
+ around a growing paragraph makes the reflow look like the box
3795
+ breathing. And no answer actions yet — there is nothing to copy
3796
+ or regenerate until it settles; Stop lives in the composer.
3539
3797
 
3540
3798
  MessageScroller needs no help with this — it already re-follows on
3541
3799
  "a message grew" (ResizeObserver), not just on "a message arrived",
3542
3800
  so the newest token stays pinned while the reader can still scroll
3543
3801
  up without being yanked back. */}
3544
- <Message messageId="t_06" avatar={<Avatar size="sm" fallback="AI" />}>
3802
+ <Message
3803
+ messageId="t_06"
3804
+ avatar={assistant}
3805
+ header={
3806
+ <Marker variant="note" icon="file" className="justify-start">
3807
+ Read scroller.tsx
3808
+ </Marker>
3809
+ }
3810
+ >
3545
3811
  <Bubble variant="plain">
3546
3812
  <StreamingText text={streamed} streaming={pending} />
3547
3813
  </Bubble>
@@ -3557,6 +3823,7 @@ export function ChatPanel() {
3557
3823
  // second request on top of the one still running.
3558
3824
  busy={pending}
3559
3825
  onStop={() => undefined}
3826
+ stopLabel="Stop answering"
3560
3827
  onAttach={() => undefined}
3561
3828
  footer="Enter to send · Shift+Enter for a new line"
3562
3829
  // Model and effort sit in `actions`, at the send end of the row:
@@ -3607,59 +3874,102 @@ export function ChatPanel() {
3607
3874
 
3608
3875
  # DashboardPage
3609
3876
 
3610
- App-shell dashboard: sidebar chrome (AppHeader + BottomNav below lg), a wide content region, a stat-card row that breathes, and a data table — the default shape for operator/analytics home routes.
3877
+ App-shell creator dashboard: a branded Sidebar with an account footer (AppHeader + BottomNav below lg), a KPI row, an edge-to-edge editions table with sell-through Meters, and supporting payout, collector, release and activity cards — the default shape for an operator or analytics home route.
3611
3878
 
3612
- **Intent:** Reach for this when a route's job is monitoring or managing many records (metrics, deployments, orders, posts). Not for reading-focused routes (use FeedPage) or single-record routes (use ListDetailPage's detail side).
3879
+ **Intent:** Reach for this when a route's job is monitoring or managing many records (sales, orders, editions, posts) with a few headline numbers. Not for reading-focused routes (use FeedPage) or single-record routes (use ListDetailPage's detail side).
3613
3880
 
3614
3881
  ## Notes
3615
3882
 
3616
3883
  - Region cap is `wide` (1280px): dashboards hold tables and multi-column grids, never a reading column.
3617
- - Stat cards: CardHeader carries label (CardDescription) + value + trend badge (CardAction pins it top-right); KPI values are `text-mono-xl` — the metric mono tier, tabular figures — never a heading tier.
3618
- - Stat cards use an explicit cols map (1 → 2 → 4) — a 4-stat row must fold to 2×2, never strand an orphan the way auto-fit can.
3619
- - The table runs flush to the card edges with `<CardContent flush>`, which hands the card's padding to the table (`--table-edge-px`), so the first and last columns line up with the CardHeader at any card density — never hand-set pl-6/pr-6. Numeric columns align end in mono.
3620
- - The Activity list is Entity rows in CardList, inset to the card padding and divided by hairlines — the same list shape as ListDetailPage, never CardContent with hand-tuned px.
3621
- - Believable density is part of the pattern: 7 rows, commit hashes in mono-caption, RelativeTime muted — never 3 rows of filler.
3884
+ - Hierarchy runs headline → records → context: KPI row, then the primary table beside its two closest companions (payout, top collectors), then the lower-priority lists (upcoming, activity). Every row of the grid is filled so the route ends on content, not empty canvas.
3885
+ - Stat cards: CardHeader carries the label (CardDescription) and the value; KPI values are `text-mono-xl` — the metric mono tier, tabular figures — never a heading tier. The trend Badge sits with its comparison note in CardContent, so the value keeps the full card width when the row folds to 2×2 on a phone.
3886
+ - Stat cards use an explicit cols map (2 → 4) — a 4-stat row must fold to 2×2, never strand an orphan the way auto-fit can.
3887
+ - The table runs flush to the card edges with `<CardContent flush>`, which hands the card's padding to the table (`--table-edge-px`), so the first and last columns line up with the CardHeader at any card density — never hand-set pl-6/pr-6. Numeric columns align end in mono; Table scrolls horizontally inside the card on narrow screens.
3888
+ - Sold / supply is a `Meter` (how full a known range is), not `Progress` (task progress). Status is `Badge appearance="plain" dot`, with the app's own states mapped to tones once at the top of the file.
3889
+ - Lists are Entity rows in CardList, inset to the card padding and divided by hairlines — never CardContent with hand-tuned px. A card that shares a grid row with a taller one becomes `flex flex-col` and pins its CardFooter with `mt-auto`.
3890
+ - Brand and account live in the Sidebar: SidebarHeader holds the mark + wordmark, SidebarFooter holds the signed-in Entity with a DotsMenu. The footer rebinds `--foreground` to `--sidebar-foreground`, because Entity's title and the ghost trigger ink with page tokens and a theme may paint the rail dark on a light page; it keeps to a title line, since there is no sidebar-muted ink for a subtitle.
3622
3891
  - The page has exactly one heading-1 (PageHeader); card titles are Title tier, not headings.
3623
3892
  - Chrome (Sidebar) is outside the content grid; the Page region caps only content.
3624
- - Sidebar hides below lg, so the shell's header carries an AppHeader and its footer a BottomNav with the same destinations — never strand mobile users without navigation.
3893
+ - Sidebar hides below lg, so the shell's header carries an AppHeader (brand + account) and its footer a BottomNav with the same destinations — never strand mobile users without navigation.
3625
3894
 
3626
3895
  ## Accessibility
3627
3896
 
3628
3897
  - AppShell provides the skip link and <main id="main-content"> landmark.
3629
3898
  - The active SidebarItem and BottomNavItem carry aria-current via `active`.
3630
3899
  - DataTable headers are real <th>; sortable columns are announced.
3900
+ - Each sell-through Meter has an aria-label naming the edition and aria-valuetext reading "184 of 250 sold".
3901
+ - Avatars beside a visible name use an empty alt so the name is not read twice.
3631
3902
 
3632
3903
  ## Source
3633
3904
 
3634
3905
  ```tsx
3635
- import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardList, CardTitle, DataTable, Entity, Grid, Icon, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarHeader, SidebarItem, SidebarNav } from "@sableui/react"
3906
+ import { AppHeader, AppShell, Avatar, Badge, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, DataTable, DotsMenu, Entity, Grid, Icon, MenuItem, MenuSeparator, Meter, NotificationBadge, Page, PageHeader, RelativeTime, Row, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, Stack } from "@sableui/react"
3907
+
3908
+ // Edition status → Badge tone. Sable has no status vocabulary of its own;
3909
+ // an app maps ITS states onto tones once, here, and every badge follows.
3910
+ const STATUS_TONE = { live: "success", scheduled: "info", "sold-out": "edition", closed: "neutral" } as const
3911
+ const STATUS_LABEL = { live: "Live", scheduled: "Scheduled", "sold-out": "Sold out", closed: "Closed" } as const
3912
+ type EditionStatus = keyof typeof STATUS_TONE
3636
3913
 
3637
3914
  const MIN = 60 * 1000
3638
3915
  const HOUR = 60 * MIN
3916
+ const DAY = 24 * HOUR
3917
+
3918
+ const usd = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD", maximumFractionDigits: 0 })
3919
+ const usdCents = new Intl.NumberFormat("en-US", { style: "currency", currency: "USD" })
3920
+ const shortDate = (offset: number) =>
3921
+ new Date(Date.now() + offset).toLocaleDateString("en-US", { month: "short", day: "numeric" })
3639
3922
 
3640
3923
  const stats = [
3641
- { label: "Revenue", value: "$12,480", delta: "+8.2%", up: true, note: "vs. previous 30 days" },
3642
- { label: "Collects", value: "1,284", delta: "+3.1%", up: true, note: "182 from new collectors" },
3643
- { label: "New collectors", value: "312", delta: "+12.4%", up: true, note: "best month this year" },
3644
- { label: "Sell-through", value: "64%", delta: "−1.8%", up: false, note: "across 48 active editions" },
3924
+ { label: "Revenue", value: "$18,240", delta: "+8.2%", up: true, note: "vs. $16,857" },
3925
+ { label: "Collects", value: "1,284", delta: "+3.1%", up: true, note: "182 first-time" },
3926
+ { label: "Collectors", value: "312", delta: "+12.4%", up: true, note: "Best month yet" },
3927
+ { label: "Sell-through", value: "64%", delta: "−1.8%", up: false, note: "6 open editions" },
3645
3928
  ]
3646
3929
 
3647
- const deployments = [
3648
- { id: "dp_01", project: "storefront", commit: "a41f2c9", status: "ready" as const, duration: "42s", updated: 2 * MIN },
3649
- { id: "dp_02", project: "api", commit: "7c03bd1", status: "building" as const, duration: "1m 08s", updated: 9 * MIN },
3650
- { id: "dp_03", project: "docs", commit: "e992a70", status: "queued" as const, duration: "—", updated: 12 * MIN },
3651
- { id: "dp_04", project: "worker", commit: "51b8e04", status: "failed" as const, duration: "18s", updated: 1 * HOUR },
3652
- { id: "dp_05", project: "storefront", commit: "90cc41a", status: "ready" as const, duration: "39s", updated: 3 * HOUR },
3653
- { id: "dp_06", project: "renderer", commit: "b7714dd", status: "ready" as const, duration: "2m 31s", updated: 5 * HOUR },
3654
- { id: "dp_07", project: "api", commit: "f30a9e2", status: "ready" as const, duration: "1m 02s", updated: 8 * HOUR },
3930
+ const editions = [
3931
+ { id: "ed_01", title: "Gold Rush", status: "live" as const, sold: 184, supply: 250, price: 48, released: -6 * DAY },
3932
+ { id: "ed_02", title: "Quiet Harbor", status: "sold-out" as const, sold: 100, supply: 100, price: 120, released: -19 * DAY },
3933
+ { id: "ed_03", title: "Dusk, no. 4", status: "live" as const, sold: 61, supply: 150, price: 64, released: -2 * DAY },
3934
+ { id: "ed_04", title: "Tidewater", status: "live" as const, sold: 212, supply: 300, price: 36, released: -27 * DAY },
3935
+ { id: "ed_05", title: "Salt Study", status: "closed" as const, sold: 38, supply: 75, price: 90, released: -44 * DAY },
3936
+ { id: "ed_06", title: "Paper Moon", status: "sold-out" as const, sold: 50, supply: 50, price: 220, released: -58 * DAY },
3937
+ { id: "ed_07", title: "Night Signal", status: "scheduled" as const, sold: 0, supply: 200, price: 56, released: 3 * DAY },
3938
+ { id: "ed_08", title: "Low Tide, Blue", status: "closed" as const, sold: 72, supply: 120, price: 42, released: -71 * DAY },
3939
+ { id: "ed_09", title: "Ember Field", status: "sold-out" as const, sold: 80, supply: 80, price: 140, released: -86 * DAY },
3940
+ ]
3941
+ const editionCount = 24
3942
+
3943
+ const payout = {
3944
+ amount: 8214.6,
3945
+ date: 5 * DAY,
3946
+ lines: [
3947
+ { label: "Sales", value: 9127.33 },
3948
+ { label: "Platform fee (10%)", value: -912.73 },
3949
+ { label: "Deposits to", value: "Checking ••4821" },
3950
+ ],
3951
+ }
3952
+
3953
+ const collectors = [
3954
+ { id: "co_01", name: "Nina Simone", handle: "@nina", initials: "NS", pieces: 14, spent: 1460 },
3955
+ { id: "co_02", name: "Grace Hopper", handle: "@grace", initials: "GH", pieces: 11, spent: 1128 },
3956
+ { id: "co_03", name: "Frida Kahlo", handle: "@frida", initials: "FK", pieces: 9, spent: 942 },
3957
+ { id: "co_04", name: "Mary Shelley", handle: "@mary", initials: "MS", pieces: 6, spent: 588 },
3958
+ ]
3959
+
3960
+ const upcoming = [
3961
+ { id: "up_01", title: "Night Signal", detail: "Edition of 200 · $56", at: 3 * DAY },
3962
+ { id: "up_02", title: "Tidewater, second printing", detail: "Edition of 100 · $40", at: 12 * DAY },
3963
+ { id: "up_03", title: "Dusk, no. 5", detail: "Edition of 150 · $64", at: 26 * DAY },
3964
+ { id: "up_04", title: "Quiet Harbor, artist proofs", detail: "Edition of 10 · $480", at: 34 * DAY },
3655
3965
  ]
3656
3966
 
3657
3967
  const activity = [
3658
- { id: "ac_01", who: "Nina Simone", initials: "NS", what: "collected Gold Rush #42", when: 2 * MIN },
3659
- { id: "ac_02", who: "Grace Hopper", initials: "GH", what: "minted a 100-run edition", when: 26 * MIN },
3660
- { id: "ac_03", who: "Ada Lovelace", initials: "AL", what: "followed you", when: 1 * HOUR },
3661
- { id: "ac_04", who: "Mary Shelley", initials: "MS", what: "collected Quiet Harbor", when: 3 * HOUR },
3662
- { id: "ac_05", who: "Frida Kahlo", initials: "FK", what: "tipped 0.2 SOL on Process", when: 6 * HOUR },
3968
+ { id: "ac_01", who: "Nina Simone", initials: "NS", what: "collected Gold Rush · 185 of 250", when: 2 * MIN },
3969
+ { id: "ac_02", who: "Grace Hopper", initials: "GH", what: "collected Dusk, no. 4 · 61 of 150", when: 26 * MIN },
3970
+ { id: "ac_03", who: "Frida Kahlo", initials: "FK", what: "tipped $20 on Tidewater", when: 1 * HOUR },
3971
+ { id: "ac_04", who: "Mary Shelley", initials: "MS", what: "started following you", when: 3 * HOUR },
3972
+ { id: "ac_05", who: "Nina Simone", initials: "NS", what: "set a reminder for Night Signal", when: 6 * HOUR },
3663
3973
  ]
3664
3974
 
3665
3975
  export function DashboardPage() {
@@ -3667,7 +3977,17 @@ export function DashboardPage() {
3667
3977
  <AppShell
3668
3978
  // Sidebar hides below lg, so the same destinations ride in the shell's
3669
3979
  // mobile slots — without them the route has no navigation under 1024px.
3670
- header={<AppHeader title={<span className="text-title-sm">Atelier</span>} />}
3980
+ header={
3981
+ <AppHeader
3982
+ title={
3983
+ <Row gap={1} align="center">
3984
+ <Icon name="gem" variant="solid" className="text-xl" />
3985
+ <span className="text-title-sm">Atelier</span>
3986
+ </Row>
3987
+ }
3988
+ actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
3989
+ />
3990
+ }
3671
3991
  bottomNav={
3672
3992
  <BottomNav aria-label="Primary">
3673
3993
  <BottomNavItem icon={<Icon name="grid" />} label="Overview" showLabel active render={<a href="/" />} />
@@ -3680,90 +4000,249 @@ export function DashboardPage() {
3680
4000
  sidebar={
3681
4001
  <Sidebar>
3682
4002
  <SidebarHeader>
3683
- <span className="text-title-sm">Atelier</span>
4003
+ <Row gap={1} align="center" className="px-3">
4004
+ <Icon name="gem" variant="solid" className="text-xl" />
4005
+ <span className="text-title-sm">Atelier</span>
4006
+ </Row>
3684
4007
  </SidebarHeader>
3685
4008
  <SidebarNav>
3686
4009
  <SidebarItem icon={<Icon name="grid" />} label="Overview" active render={<a href="/" />} />
3687
4010
  <SidebarItem icon={<Icon name="image-stack" />} label="Editions" render={<a href="/editions" />} />
3688
4011
  <SidebarItem icon={<Icon name="user" />} label="Collectors" render={<a href="/collectors" />} />
3689
- <SidebarItem icon={<Icon name="bell" />} label="Activity" render={<a href="/activity" />} />
4012
+ <SidebarItem
4013
+ icon={<Icon name="bell" />}
4014
+ label="Activity"
4015
+ badge={<NotificationBadge count={4} tone="neutral" />}
4016
+ render={<a href="/activity" />}
4017
+ />
3690
4018
  <SidebarItem icon={<Icon name="gear" />} label="Settings" render={<a href="/settings" />} />
3691
4019
  </SidebarNav>
4020
+ {/* Entity and DotsMenu ink with page tokens; a theme can paint the rail
4021
+ dark on a light page, so this subtree reads the rail's ink instead. */}
4022
+ <SidebarFooter className="[--foreground:var(--sidebar-foreground)]">
4023
+ <Entity
4024
+ className="[--entity-py:0]"
4025
+ leading={<Avatar size="sm" fallback="AL" alt="" />}
4026
+ title="Ada Lovelace"
4027
+ action={
4028
+ <DotsMenu label="Account" size="icon-sm">
4029
+ <MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
4030
+ <MenuItem render={<a href="/settings" />}>Settings</MenuItem>
4031
+ <MenuSeparator />
4032
+ <MenuItem>Sign out</MenuItem>
4033
+ </DotsMenu>
4034
+ }
4035
+ />
4036
+ </SidebarFooter>
3692
4037
  </Sidebar>
3693
4038
  }
3694
4039
  >
3695
4040
  <Page max="wide" gap={4} className="py-6">
3696
4041
  <PageHeader
3697
4042
  title="Overview"
3698
- description="Sales, collects, and edition activity for the last 30 days."
4043
+ description="Sales and collector activity for the last 30 days."
3699
4044
  actions={
3700
4045
  <Row gap={2}>
3701
- <Button variant="outline">Export</Button>
3702
- <Button>New edition</Button>
4046
+ <Button variant="outline">
4047
+ <Icon name="download" />
4048
+ Export
4049
+ </Button>
4050
+ <Button>
4051
+ <Icon name="plus" />
4052
+ New edition
4053
+ </Button>
3703
4054
  </Row>
3704
4055
  }
3705
4056
  />
3706
- <Grid cols={{ base: 1, sm: 2, xl: 4 }} gap={3}>
4057
+ <Grid cols={{ base: 2, xl: 4 }} gap={3}>
3707
4058
  {stats.map((stat) => (
3708
4059
  <Card key={stat.label}>
3709
4060
  <CardHeader>
3710
4061
  <CardDescription>{stat.label}</CardDescription>
3711
4062
  <CardTitle className="text-mono-xl">{stat.value}</CardTitle>
3712
- <CardAction>
3713
- <Badge tone={stat.up ? "success" : "neutral"} size="sm">
3714
- <Icon name={stat.up ? "chevron-up" : "chevron-down"} />
3715
- {stat.delta}
3716
- </Badge>
3717
- </CardAction>
3718
4063
  </CardHeader>
3719
4064
  <CardContent>
3720
- <p className="text-caption text-muted-foreground">{stat.note}</p>
4065
+ <Row gap={1} wrap>
4066
+ <Badge
4067
+ tone={stat.up ? "success" : "warning"}
4068
+ size="sm"
4069
+ icon={<Icon name={stat.up ? "arrow-up" : "arrow-down"} />}
4070
+ >
4071
+ {stat.delta}
4072
+ </Badge>
4073
+ <span className="text-caption text-muted-foreground">{stat.note}</span>
4074
+ </Row>
3721
4075
  </CardContent>
3722
4076
  </Card>
3723
4077
  ))}
3724
4078
  </Grid>
3725
4079
  <Grid cols={{ base: 1, xl: 3 }} gap={3}>
3726
- <Card className="xl:col-span-2">
4080
+ <Card className="flex flex-col xl:col-span-2">
3727
4081
  <CardHeader>
3728
- <CardTitle>Recent deployments</CardTitle>
3729
- <CardDescription>Builds across every project, newest first.</CardDescription>
3730
- <CardAction>
3731
- <Button variant="ghost" size="sm" render={<a href="/deployments" />}>
3732
- View all
3733
- </Button>
3734
- </CardAction>
4082
+ <CardTitle>Editions</CardTitle>
4083
+ <CardDescription>Sales across your releases, newest first.</CardDescription>
3735
4084
  </CardHeader>
3736
- <CardContent flush>
4085
+ <CardContent flush className="mt-4 flex-1">
3737
4086
  <DataTable
3738
- data={deployments}
4087
+ data={editions}
3739
4088
  rowKey={(row) => row.id}
4089
+ defaultSort={{ key: "released", direction: "desc" }}
3740
4090
  columns={[
3741
- { key: "project", header: "Project", sortable: true, className: "text-label-md" },
3742
- { key: "commit", header: "Commit", cell: (row) => <span className="text-mono-caption text-muted-foreground">{row.commit}</span> },
3743
- { key: "status", header: "Status", cell: (row) => <Badge appearance="plain" tone={statusTone(row.status)} dot>{row.status}</Badge> },
3744
- { key: "duration", header: "Duration", align: "end", cell: (row) => <span className="text-mono-caption">{row.duration}</span> },
3745
- { key: "updated", header: "Updated", align: "end", cell: (row) => <RelativeTime date={Date.now() - row.updated} className="text-body-xs text-muted-foreground" /> },
4091
+ { key: "title", header: "Edition", sortable: true, className: "text-label-md whitespace-nowrap" },
4092
+ {
4093
+ key: "status",
4094
+ header: "Status",
4095
+ cell: (row) => (
4096
+ <Badge appearance="plain" tone={STATUS_TONE[row.status as EditionStatus]} dot>
4097
+ {STATUS_LABEL[row.status as EditionStatus]}
4098
+ </Badge>
4099
+ ),
4100
+ },
4101
+ {
4102
+ key: "sold",
4103
+ header: "Sold",
4104
+ sortable: true,
4105
+ sortValue: (row) => row.sold / row.supply,
4106
+ cell: (row) => (
4107
+ <Row gap={3} align="center" className="min-w-40">
4108
+ <Meter
4109
+ value={row.sold}
4110
+ max={row.supply}
4111
+ size="sm"
4112
+ tone={row.sold === row.supply ? "success" : "primary"}
4113
+ aria-label={`${row.title} sold`}
4114
+ getAriaValueText={(_, v) => `${v} of ${row.supply} sold`}
4115
+ className="flex-1"
4116
+ />
4117
+ <span className="text-mono-caption whitespace-nowrap text-muted-foreground">
4118
+ {row.sold}/{row.supply}
4119
+ </span>
4120
+ </Row>
4121
+ ),
4122
+ },
4123
+ {
4124
+ key: "revenue",
4125
+ header: "Revenue",
4126
+ align: "end",
4127
+ sortable: true,
4128
+ sortValue: (row) => row.sold * row.price,
4129
+ cell: (row) =>
4130
+ row.sold > 0 ? (
4131
+ <span className="text-mono-sm">{usd.format(row.sold * row.price)}</span>
4132
+ ) : (
4133
+ <span className="text-mono-sm text-muted-foreground">—</span>
4134
+ ),
4135
+ },
4136
+ {
4137
+ key: "released",
4138
+ header: "Release",
4139
+ align: "end",
4140
+ sortable: true,
4141
+ cell: (row) => (
4142
+ <span className="text-body-sm whitespace-nowrap text-muted-foreground">{shortDate(row.released)}</span>
4143
+ ),
4144
+ },
3746
4145
  ]}
3747
4146
  />
3748
4147
  </CardContent>
4148
+ <CardFooter className="justify-between">
4149
+ <span className="text-caption text-muted-foreground">
4150
+ Showing {editions.length} of {editionCount} editions
4151
+ </span>
4152
+ <Button variant="ghost" size="sm" render={<a href="/editions" />}>
4153
+ View all editions
4154
+ <Icon name="arrow-right" />
4155
+ </Button>
4156
+ </CardFooter>
3749
4157
  </Card>
3750
- <Card>
4158
+ <Stack gap={3}>
4159
+ <Card>
4160
+ <CardHeader>
4161
+ <CardDescription>Next payout</CardDescription>
4162
+ <CardTitle className="text-mono-xl">{usdCents.format(payout.amount)}</CardTitle>
4163
+ <CardAction>
4164
+ <Badge tone="info" size="sm" icon={<Icon name="calendar" />}>
4165
+ {shortDate(payout.date)}
4166
+ </Badge>
4167
+ </CardAction>
4168
+ </CardHeader>
4169
+ <CardContent>
4170
+ <dl className="sable-list-dl text-body-sm">
4171
+ {payout.lines.map((line) => (
4172
+ <div key={line.label} className="contents">
4173
+ <dt>{line.label}</dt>
4174
+ <dd className={typeof line.value === "number" ? "text-end text-mono-sm" : "text-end"}>
4175
+ {typeof line.value === "number" ? usdCents.format(line.value) : line.value}
4176
+ </dd>
4177
+ </div>
4178
+ ))}
4179
+ </dl>
4180
+ </CardContent>
4181
+ </Card>
4182
+ <Card>
4183
+ <CardHeader>
4184
+ <CardTitle>Top collectors</CardTitle>
4185
+ <CardDescription>By spend this month.</CardDescription>
4186
+ <CardAction>
4187
+ <Button variant="ghost" size="sm" render={<a href="/collectors" />}>
4188
+ View all
4189
+ </Button>
4190
+ </CardAction>
4191
+ </CardHeader>
4192
+ <CardList>
4193
+ {collectors.map((person) => (
4194
+ <Entity
4195
+ key={person.id}
4196
+ leading={<Avatar size="sm" fallback={person.initials} alt="" />}
4197
+ title={person.name}
4198
+ subtitle={`${person.handle} · ${person.pieces} pieces`}
4199
+ action={<span className="text-mono-sm">{usd.format(person.spent)}</span>}
4200
+ />
4201
+ ))}
4202
+ </CardList>
4203
+ </Card>
4204
+ </Stack>
4205
+ </Grid>
4206
+ <Grid cols={{ base: 1, xl: 3 }} gap={3}>
4207
+ <Card className="flex flex-col">
4208
+ <CardHeader>
4209
+ <CardTitle>Upcoming releases</CardTitle>
4210
+ <CardDescription>Scheduled to open to collectors.</CardDescription>
4211
+ </CardHeader>
4212
+ <CardList>
4213
+ {upcoming.map((release) => (
4214
+ <Entity
4215
+ key={release.id}
4216
+ title={release.title}
4217
+ subtitle={release.detail}
4218
+ action={<RelativeTime date={Date.now() + release.at} className="text-caption text-muted-foreground" />}
4219
+ />
4220
+ ))}
4221
+ </CardList>
4222
+ <CardFooter className="mt-auto">
4223
+ <Button variant="outline" size="sm" render={<a href="/editions/new" />}>
4224
+ <Icon name="calendar" />
4225
+ Schedule a release
4226
+ </Button>
4227
+ </CardFooter>
4228
+ </Card>
4229
+ <Card className="xl:col-span-2">
3751
4230
  <CardHeader>
3752
4231
  <CardTitle>Activity</CardTitle>
3753
- <CardDescription>Collects, mints, and follows.</CardDescription>
4232
+ <CardDescription>Collects, tips, and follows.</CardDescription>
4233
+ <CardAction>
4234
+ <Button variant="ghost" size="sm" render={<a href="/activity" />}>
4235
+ View all
4236
+ </Button>
4237
+ </CardAction>
3754
4238
  </CardHeader>
3755
4239
  <CardList>
3756
4240
  {activity.map((item) => (
3757
4241
  <Entity
3758
4242
  key={item.id}
3759
- density="compact"
3760
- leading={<Avatar size="sm" fallback={item.initials} alt={item.who} />}
3761
- title={
3762
- <span className="text-body-sm">
3763
- <span className="text-label-md">{item.who}</span>{" "}
3764
- <span className="text-muted-foreground">{item.what}</span>
3765
- </span>
3766
- }
4243
+ leading={<Avatar size="sm" fallback={item.initials} alt="" />}
4244
+ title={item.who}
4245
+ subtitle={item.what}
3767
4246
  action={<RelativeTime date={Date.now() - item.when} className="text-caption text-muted-foreground" />}
3768
4247
  />
3769
4248
  ))}
@@ -3780,103 +4259,284 @@ export function DashboardPage() {
3780
4259
 
3781
4260
  # FeedPage
3782
4261
 
3783
- Single-column reading feed: a feed-width region, one Card per post (Entity byline, zero-shift media, body copy, quiet action row), and explicit load-more pagination.
4262
+ Social reading feed with a side rail: a feed-width main column (composer, For you / Following Tabs, one Card per post, an edition drop with price, supply Meter and Collect, a Skeleton for the next page) beside a sticky rail of who-to-follow and upcoming drops that drops away below lg.
3784
4263
 
3785
- **Intent:** Reach for this for timelines, activity streams, and any reverse-chronological reading surface. Not for record management (DashboardPage) or side-by-side browsing (ListDetailPage).
4264
+ **Intent:** Reach for this for timelines, activity streams, and any reverse-chronological reading surface where people also act on what they read. Not for record management (DashboardPage) or side-by-side browsing (ListDetailPage).
3786
4265
 
3787
4266
  ## Notes
3788
4267
 
3789
- - Region cap is `feed` (640px) — a single reading column; media and copy share one measure.
3790
- - Post media sits in an AspectBox so late-loading images never shift the column.
3791
- - The byline is Entity (Avatar + name + handle) in a CardList — the same row primitive as everywhere else, inset to the card padding by CardList rather than by zeroing paddings on CardHeader and Entity.
3792
- - Actions are ghost sm Buttons (icon + count) in a Row, pulled out by the button's own padding (`-ms-3`) so the first icon sits on the text edge: quiet chrome under content, not competing pills.
3793
- - Pagination is an explicit load-more Button (secondary; set `loading` while the next page fetches — `loadingLabel` names that state), not infinite scroll — predictable for keyboard and AT users.
4268
+ - The main column is capped at `--region-feed` (640px) inside a `wide` page, so posts keep a reading measure while the rail fills the desktop width. The rail is a fixed `w-80` aside, `sticky` at lg and `hidden` below it — its content is supplementary, so nothing is lost on mobile.
4269
+ - Feed scope is `Tabs` (underline, stretched to the column with `flex`): peer views of the same feed. The composer sits above them because posting applies to both.
4270
+ - The composer is a Card holding `Textarea autoResize` plus ghost icon Buttons and a secondary `Post` — not ChatComposer, which submits on Enter and is for conversations.
4271
+ - Each post is a Card: an Entity byline in a CardList (with a DotsMenu for post actions), body copy, then media in an AspectBox at the asset's own ratio, inset with `rounded-concentric-lg/6` so its corners follow the card's in every theme.
4272
+ - An edition drop adds a `Badge appearance="overlay"` on the artwork, title and price, the one primary Button on the page (`Collect`), and a `Meter` for supply — collected against a known maximum is a level, not task progress.
4273
+ - Actions are ghost sm Buttons (icon + count) in a Row pulled out by the button's own padding (`-ms-3`) so the first icon sits on the text edge.
4274
+ - Loading the next page shows a Skeleton post in the shape the post will land in, above the explicit load-more Button in its `loading` state — predictable for keyboard and AT users, unlike infinite scroll.
3794
4275
 
3795
4276
  ## Accessibility
3796
4277
 
3797
- - Each post is an <article>, so AT users can jump between posts.
3798
- - Icon-only action buttons carry aria-labels.
3799
- - Media images get meaningful alt text from the post, empty alt only if decorative.
4278
+ - Each post is an <article>, so AT users can jump between posts; the rail is a labelled <aside> landmark.
4279
+ - Rail card titles carry role="heading" aria-level=2 under the page's one h1.
4280
+ - Icon-only buttons and every Follow / Remind button carry aria-labels naming their target; counts stay in the accessible name.
4281
+ - The supply Meter is role=meter with a getAriaValueText reading ("62 of 100 collected").
4282
+ - The Skeleton card is aria-busy; the loading Button keeps its label ("Loading posts").
3800
4283
 
3801
4284
  ## Source
3802
4285
 
3803
4286
  ```tsx
3804
- import { AspectBox, Avatar, Button, Card, CardContent, CardList, Entity, Icon, Page, PageHeader, Row, Stack } from "@sableui/react"
4287
+ import { AspectBox, Avatar, Badge, Button, Card, CardContent, CardHeader, CardList, CardTitle, DotsMenu, Entity, Icon, MenuItem, MenuSeparator, Meter, MeterLabel, MeterTrack, MeterValue, Page, PageHeader, Row, Skeleton, Stack, Tabs, TabsList, TabsPanel, TabsTrigger, Textarea } from "@sableui/react"
4288
+
4289
+ type Post = {
4290
+ id: string
4291
+ author: string
4292
+ handle: string
4293
+ initials: string
4294
+ time: string
4295
+ following: boolean
4296
+ body: string
4297
+ media?: { src: string; alt: string }
4298
+ drop?: { title: string; price: string; collected: number; supply: number }
4299
+ likes: number
4300
+ comments: number
4301
+ }
3805
4302
 
3806
- const posts = [
4303
+ const posts: Post[] = [
3807
4304
  {
3808
4305
  id: "p_01",
3809
4306
  author: "Ada Lovelace",
3810
- handle: "@ada · 2h",
4307
+ handle: "@ada",
3811
4308
  initials: "AL",
3812
- body: "New edition drops Friday — 100 signed prints, each with the on-chain original.",
3813
- image: "/media/edition-cover.svg",
3814
- alt: "Edition cover — Gold Rush series",
4309
+ time: "2h",
4310
+ following: true,
4311
+ body: "Gold Rush is live. 100 signed prints, each paired with the on-chain original. Thank you for waiting.",
4312
+ media: { src: "/media/edition-cover.svg", alt: "Gold Rush, a gold-leaf study in overlapping arcs" },
4313
+ drop: { title: "Gold Rush", price: "$120", collected: 62, supply: 100 },
3815
4314
  likes: 128,
3816
4315
  comments: 24,
3817
4316
  },
3818
4317
  {
3819
4318
  id: "p_02",
3820
4319
  author: "Grace Hopper",
3821
- handle: "@grace · 5h",
4320
+ handle: "@grace",
3822
4321
  initials: "GH",
3823
- body: "Process shot from this week's series. Compilers and copper ink.",
3824
- image: "/media/process-shot.svg",
3825
- alt: "Work-in-progress plotter drawing in copper ink",
4322
+ time: "5h",
4323
+ following: true,
4324
+ body: "Process shot from this week. Compilers and copper ink — the plotter ran for eleven hours on this one.",
4325
+ media: { src: "/media/process-shot.svg", alt: "Work-in-progress plotter drawing in copper ink" },
3826
4326
  likes: 86,
3827
4327
  comments: 9,
3828
4328
  },
4329
+ {
4330
+ id: "p_03",
4331
+ author: "Nina Simone",
4332
+ handle: "@nina",
4333
+ initials: "NS",
4334
+ time: "8h",
4335
+ following: false,
4336
+ body: "Collectors of Dusk, no. 3 — your unlockable score is now in your library. Headphones on for this one.",
4337
+ likes: 214,
4338
+ comments: 31,
4339
+ },
4340
+ ]
4341
+
4342
+ const suggestions = [
4343
+ { id: "u_01", name: "Mary Shelley", handle: "@mary", initials: "MS" },
4344
+ { id: "u_02", name: "Nina Simone", handle: "@nina", initials: "NS" },
4345
+ ]
4346
+
4347
+ const upcoming = [
4348
+ { id: "d_01", title: "Quiet Harbor", artist: "Mary Shelley", initials: "MS", date: "Sat, Oct 12" },
4349
+ { id: "d_02", title: "Dusk, no. 4", artist: "Nina Simone", initials: "NS", date: "Tue, Oct 15" },
3829
4350
  ]
3830
4351
 
4352
+ const renderPost = (post: Post) => (
4353
+ <article key={post.id}>
4354
+ <Card>
4355
+ <CardList>
4356
+ <Entity
4357
+ leading={<Avatar fallback={post.initials} alt={post.author} />}
4358
+ title={post.author}
4359
+ subtitle={`${post.handle} · ${post.time}`}
4360
+ action={
4361
+ <DotsMenu label={`Post options for ${post.author}`} size="icon-sm">
4362
+ <MenuItem>Copy link</MenuItem>
4363
+ <MenuItem>Mute {post.handle}</MenuItem>
4364
+ <MenuSeparator />
4365
+ <MenuItem variant="destructive">Report post</MenuItem>
4366
+ </DotsMenu>
4367
+ }
4368
+ />
4369
+ </CardList>
4370
+ <CardContent className="flex flex-col gap-4 pt-0">
4371
+ <p className="text-body-md text-foreground">{post.body}</p>
4372
+ {post.media ? (
4373
+ <AspectBox ratio={16 / 9} className="overflow-hidden rounded-concentric-lg/6 bg-muted">
4374
+ <img src={post.media.src} alt={post.media.alt} className="size-full object-cover" />
4375
+ {post.drop ? (
4376
+ <div className="absolute start-3 top-3">
4377
+ <Badge appearance="overlay" size="sm" dot>
4378
+ Live drop
4379
+ </Badge>
4380
+ </div>
4381
+ ) : null}
4382
+ </AspectBox>
4383
+ ) : null}
4384
+ {post.drop ? (
4385
+ <Stack gap={1.5}>
4386
+ <Row gap={2} align="center" justify="between">
4387
+ <div className="min-w-0">
4388
+ <p className="text-title-md text-foreground">{post.drop.title}</p>
4389
+ <p className="text-body-sm text-muted-foreground">
4390
+ {`${post.drop.price} · Edition of ${post.drop.supply}`}
4391
+ </p>
4392
+ </div>
4393
+ <Button>{`Collect for ${post.drop.price}`}</Button>
4394
+ </Row>
4395
+ <Meter
4396
+ value={post.drop.collected}
4397
+ max={post.drop.supply}
4398
+ size="sm"
4399
+ getAriaValueText={(_, value) => `${value} of ${post.drop?.supply} collected`}
4400
+ >
4401
+ <Row gap={2} align="center" justify="between" className="mb-2">
4402
+ <MeterLabel>{`${post.drop.collected} collected`}</MeterLabel>
4403
+ <MeterValue>{(_, value) => `${(post.drop?.supply ?? 0) - value} left`}</MeterValue>
4404
+ </Row>
4405
+ <MeterTrack />
4406
+ </Meter>
4407
+ </Stack>
4408
+ ) : null}
4409
+ {/* Pulled out by exactly a sm Button's own padding (px-3), so the
4410
+ first icon lands on the text edge above it. */}
4411
+ <Row gap={0.5} className="-ms-3">
4412
+ <Button variant="ghost" size="sm" aria-label={`Like, ${post.likes} likes`}>
4413
+ <Icon name="heart" />
4414
+ {post.likes}
4415
+ </Button>
4416
+ <Button variant="ghost" size="sm" aria-label={`Comment, ${post.comments} comments`}>
4417
+ <Icon name="comment" />
4418
+ {post.comments}
4419
+ </Button>
4420
+ <Button variant="ghost" size="sm" aria-label="Share">
4421
+ <Icon name="share" />
4422
+ </Button>
4423
+ </Row>
4424
+ </CardContent>
4425
+ </Card>
4426
+ </article>
4427
+ )
4428
+
3831
4429
  export function FeedPage() {
3832
4430
  return (
3833
- <Page as="main" max="feed" gap={4} className="py-6">
3834
- <PageHeader title="Home" description="Latest posts from artists you follow." />
3835
- <Stack gap={3}>
3836
- {posts.map((post) => (
3837
- <article key={post.id}>
3838
- <Card className="overflow-hidden">
3839
- {/* The byline is an Entity row in CardList, which insets it to the
3840
- card's own padding on every side — no hand-zeroed paddings. */}
3841
- <CardList>
3842
- <Entity
3843
- leading={<Avatar fallback={post.initials} alt={post.author} />}
3844
- title={post.author}
3845
- subtitle={post.handle}
3846
- action={
3847
- <Button variant="ghost" size="icon-sm" aria-label="Post options">
3848
- <Icon name="ellipsis" />
4431
+ <Page as="main" max="wide" gap={4} className="py-6">
4432
+ <Row gap={4} align="start" justify="center">
4433
+ <Stack gap={3} className="min-w-0 max-w-(--region-feed) flex-1">
4434
+ <PageHeader title="Home" />
4435
+ <Card>
4436
+ <CardContent>
4437
+ <Row gap={1.5} align="start">
4438
+ <Avatar fallback="AL" alt="Ada Lovelace" />
4439
+ <Stack gap={1.5} className="min-w-0 flex-1">
4440
+ <Textarea
4441
+ autoResize
4442
+ rows={2}
4443
+ aria-label="New post"
4444
+ placeholder="Share a process shot or an upcoming drop"
4445
+ />
4446
+ <Row gap={1} align="center" justify="between">
4447
+ <Row gap={0.5}>
4448
+ <Button variant="ghost" size="icon-sm" aria-label="Add images">
4449
+ <Icon name="image-stack" />
4450
+ </Button>
4451
+ <Button variant="ghost" size="icon-sm" aria-label="Attach an edition">
4452
+ <Icon name="gem" />
4453
+ </Button>
4454
+ </Row>
4455
+ <Button variant="secondary" size="sm">
4456
+ Post
3849
4457
  </Button>
3850
- }
3851
- />
4458
+ </Row>
4459
+ </Stack>
4460
+ </Row>
4461
+ </CardContent>
4462
+ </Card>
4463
+ <Tabs defaultValue="for-you">
4464
+ <TabsList className="flex">
4465
+ <TabsTrigger value="for-you">For you</TabsTrigger>
4466
+ <TabsTrigger value="following">Following</TabsTrigger>
4467
+ </TabsList>
4468
+ <TabsPanel value="for-you">
4469
+ <Stack gap={3}>
4470
+ {posts.map(renderPost)}
4471
+ {/* The next page, in the shape it will land in, while it fetches. */}
4472
+ <Card aria-busy="true">
4473
+ <CardContent className="flex flex-col gap-4">
4474
+ <Row gap={1.5} align="center">
4475
+ <Skeleton className="size-10 rounded-full" />
4476
+ <Stack gap={1}>
4477
+ <Skeleton className="h-3 w-32" />
4478
+ <Skeleton className="h-3 w-20" />
4479
+ </Stack>
4480
+ </Row>
4481
+ <Skeleton className="h-3 w-full" />
4482
+ <Skeleton className="h-3 w-2/3" />
4483
+ </CardContent>
4484
+ </Card>
4485
+ <Button variant="secondary" loading loadingLabel="Loading posts">
4486
+ Load more posts
4487
+ </Button>
4488
+ </Stack>
4489
+ </TabsPanel>
4490
+ <TabsPanel value="following">
4491
+ <Stack gap={3}>{posts.filter((post) => post.following).map(renderPost)}</Stack>
4492
+ </TabsPanel>
4493
+ </Tabs>
4494
+ </Stack>
4495
+ <aside aria-label="Suggestions" className="sticky top-6 hidden w-80 shrink-0 lg:block">
4496
+ <Stack gap={3}>
4497
+ <Card>
4498
+ <CardHeader>
4499
+ <CardTitle role="heading" aria-level={2}>Who to follow</CardTitle>
4500
+ </CardHeader>
4501
+ <CardList>
4502
+ {suggestions.map((person) => (
4503
+ <Entity
4504
+ key={person.id}
4505
+ leading={<Avatar fallback={person.initials} alt={person.name} />}
4506
+ title={person.name}
4507
+ subtitle={person.handle}
4508
+ action={
4509
+ <Button variant="secondary" size="sm" aria-label={`Follow ${person.name}`}>
4510
+ Follow
4511
+ </Button>
4512
+ }
4513
+ />
4514
+ ))}
3852
4515
  </CardList>
3853
- <AspectBox ratio={16 / 9}>
3854
- <img src={post.image} alt={post.alt} className="size-full object-cover" />
3855
- </AspectBox>
3856
- <CardContent className="flex flex-col gap-2">
3857
- <p className="text-body-md text-foreground">{post.body}</p>
3858
- {/* Pulled out by exactly a sm Button's own padding (px-3), so the
3859
- first icon lands on the text edge above it. Both are on the
3860
- spacing scale, so they stay aligned at every density. */}
3861
- <Row gap={1} className="-ms-3">
3862
- <Button variant="ghost" size="sm" aria-label={`Like — ${post.likes}`}>
3863
- <Icon name="heart" />
3864
- {post.likes}
3865
- </Button>
3866
- <Button variant="ghost" size="sm" aria-label={`Comments — ${post.comments}`}>
3867
- <Icon name="comment" />
3868
- {post.comments}
3869
- </Button>
3870
- <Button variant="ghost" size="sm" aria-label="Share">
3871
- <Icon name="share" />
3872
- </Button>
3873
- </Row>
3874
- </CardContent>
3875
4516
  </Card>
3876
- </article>
3877
- ))}
3878
- </Stack>
3879
- <Button variant="secondary" loadingLabel="Loading…">Load more posts</Button>
4517
+ <Card>
4518
+ <CardHeader>
4519
+ <CardTitle role="heading" aria-level={2}>Upcoming drops</CardTitle>
4520
+ </CardHeader>
4521
+ <CardList>
4522
+ {upcoming.map((drop) => (
4523
+ <Entity
4524
+ key={drop.id}
4525
+ leading={<Avatar fallback={drop.initials} alt={drop.artist} />}
4526
+ title={drop.title}
4527
+ subtitle={`${drop.artist} · ${drop.date}`}
4528
+ action={
4529
+ <Button variant="ghost" size="icon-sm" aria-label={`Remind me about ${drop.title}`}>
4530
+ <Icon name="bell" />
4531
+ </Button>
4532
+ }
4533
+ />
4534
+ ))}
4535
+ </CardList>
4536
+ </Card>
4537
+ </Stack>
4538
+ </aside>
4539
+ </Row>
3880
4540
  </Page>
3881
4541
  )
3882
4542
  }
@@ -3886,120 +4546,518 @@ export function FeedPage() {
3886
4546
 
3887
4547
  # LandingPage
3888
4548
 
3889
- Marketing hero + feature grid: display-tier headline as the page's single loud moment, an eyebrow kicker, a two-action CTA row, and a three-up feature grid that reveals on scroll.
4549
+ Product / open-source landing page: a centered hero with an eyebrow Badge, one display headline, lede, two CTAs and a copyable install command; a Browser-framed product preview composed from real components; a six-up feature grid; a setup section with tabbed code; an agent-workflow section with terminal output; a CTA card; and a link-column footer.
3890
4550
 
3891
- **Intent:** Reach for this for the marketing/landing route of a product — the one page where Display type and scroll reveals belong. Not for product UI (display type and one-per-view rules forbid it inside the app).
4551
+ **Intent:** Reach for this for the marketing route of a developer product, library, or tool: the page where the product shows itself working and the reader installs or signs up. For a branded, editorial marketing site use BrandLandingPage. Not for product UI (display type and one-per-view rules forbid it inside the app).
3892
4552
 
3893
4553
  ## Notes
3894
4554
 
3895
- - Display type is the marketing voice: exactly one display headline per page, tight leading, weight 700+.
3896
- - The eyebrow is a small outline Badge (`size="sm"`) — the badge's own size, never a type class forced onto its label; hierarchy above the headline is eyebrow → display → lede.
3897
- - The lede is body-lg capped at the prose measure — the headline may be wide, reading text may not.
3898
- - Two CTAs max: one primary, one quiet outline. More actions dilute the hero.
3899
- - Reveal wraps below-the-fold sections only; it no-ops under reduced motion via the motion policy.
3900
- - Feature cards are Cards in a Grid with minItemWidth (three-up that reflows on its own width), each icon + title + body-sm copy.
4555
+ - Display type is the marketing voice: exactly one display headline per page (`text-display-lg` on phones, `text-display-2xl` from `sm`). Every section below uses `text-heading-1` with a `text-overline` kicker.
4556
+ - The eyebrow is a small outline Badge (`size="sm"`): the badge's own size, never a type class forced onto its label. Hierarchy above the fold is eyebrow, display, lede, CTAs, install command.
4557
+ - The lede is body-lg capped at the prose measure. The headline may be wide; reading text may not.
4558
+ - Two CTAs max per row: one primary, one quiet outline. The install line is a `CodeBlock` with `prompt="$"`, so it truncates on one line and copy takes only the command.
4559
+ - Show the product instead of describing it. The preview is real components (PageHeader, metric Cards, a Meter, a CardList of Entity rows, Message and Bubble, ChatComposer) inside `Browser`, wrapped in an `inert` div so the picture's controls never take focus. Secondary columns hide on small screens rather than stacking into a long mock.
4560
+ - Features are Cards in a fixed `Grid cols={{ base: 1, sm: 2, lg: 3 }}`, so six items always land as full rows (3 x 2, 2 x 3, 1 x 6) without an orphan.
4561
+ - Multi-line code uses `CodeBlock` with a filename; switch related snippets with `Tabs`, defaulting to the most complete one. Terminal output passes `highlight={false}` so a code highlighter does not colour prose.
4562
+ - Reveal wraps below-the-fold blocks only (features, code, CTA). It no-ops under reduced motion via the motion policy.
4563
+ - Hide nav groups on narrow screens by wrapping them in a `max-md:hidden` div: a `Row` or `Button` sets its own display, which a `hidden` class on the same element does not reliably override.
3901
4564
 
3902
4565
  ## Accessibility
3903
4566
 
3904
- - The hero headline is the page h1; feature titles are h2s restyled with the Title tier.
4567
+ - The hero headline is the page h1; section titles are h2s and feature and step titles are h3s. Footer column labels are h2s inside the footer nav.
4568
+ - The product preview is a `figure` with a visible figcaption. Its content is `inert`, which removes it from the tab order and the accessibility tree, so its headings and buttons do not pollute the outline.
3905
4569
  - Reveal renders content immediately (no animation) when prefers-reduced-motion is set.
3906
- - CTA buttons are real links or buttons with visible text — no icon-only CTAs.
4570
+ - CTA buttons are real links with visible text, with no icon-only CTAs. Footer links keep a 36px minimum row height for touch.
3907
4571
 
3908
4572
  ## Source
3909
4573
 
3910
4574
  ```tsx
3911
- import { Badge, Button, Card, CardContent, Grid, Icon, Region, Reveal, Row, Stack } from "@sableui/react"
4575
+ import { Avatar, Badge, Browser, Bubble, Button, Card, CardAction, CardContent, CardDescription, CardHeader, CardList, CardTitle, ChatComposer, Code, CodeBlock, Entity, Grid, Icon, Message, Meter, MeterLabel, MeterTrack, MeterValue, PageHeader, Region, Reveal, Row, Stack, Tabs, TabsList, TabsPanel, TabsTrigger } from "@sableui/react"
3912
4576
 
3913
- const features = [
4577
+ const navLinks = [
4578
+ { label: "Docs", href: "/docs" },
4579
+ { label: "Components", href: "/components" },
4580
+ { label: "Patterns", href: "/patterns" },
4581
+ { label: "Themes", href: "/themes" },
4582
+ ]
4583
+
4584
+ const deploys = [
3914
4585
  {
3915
- icon: "sparkles" as const,
3916
- title: "Own the surface",
3917
- body: "Semantic tokens end-to-end — re-theme the whole product without touching a component.",
4586
+ initials: "MO",
4587
+ name: "Maya Ortiz",
4588
+ title: "Add usage-based billing",
4589
+ meta: "main · a3f9c21 · 2m ago",
4590
+ status: "Building",
4591
+ tone: "info" as const,
4592
+ pulse: true,
3918
4593
  },
4594
+ {
4595
+ initials: "SR",
4596
+ name: "Sam Reyes",
4597
+ title: "Fix search ranking for long queries",
4598
+ meta: "main · 81be04d · 18m ago",
4599
+ status: "Ready",
4600
+ tone: "success" as const,
4601
+ pulse: false,
4602
+ },
4603
+ {
4604
+ initials: "JL",
4605
+ name: "Jun Lee",
4606
+ title: "Rotate webhook signing keys",
4607
+ meta: "main · 5c7e9aa · 1h ago",
4608
+ status: "Ready",
4609
+ tone: "success" as const,
4610
+ pulse: false,
4611
+ },
4612
+ ]
4613
+
4614
+ const features = [
3919
4615
  {
3920
4616
  icon: "component" as const,
3921
- title: "Behavior included",
3922
- body: "Focus, keyboard nav, and ARIA ride on Base UI primitives under every surface.",
4617
+ title: "Behavior built in",
4618
+ body: "Focus management, keyboard navigation, and ARIA come from Base UI primitives under every control.",
4619
+ },
4620
+ {
4621
+ icon: "eyedropper" as const,
4622
+ title: "Semantic tokens end to end",
4623
+ body: "Re-theme the whole product, light and dark, from one preset without touching a component.",
3923
4624
  },
3924
4625
  {
3925
4626
  icon: "layout" as const,
3926
4627
  title: "Pages, not just parts",
3927
- body: "Purpose-named regions, an 8px rhythm, and canonical page shells for the routes every product needs.",
4628
+ body: "Landing, dashboard, settings, auth, and list-detail shells to copy and adapt.",
4629
+ },
4630
+ {
4631
+ icon: "book" as const,
4632
+ title: "A contract agents can read",
4633
+ body: "registry.json, llms.txt, and a CLI hand agents the exact props, examples, and rules.",
4634
+ },
4635
+ {
4636
+ icon: "circle-check" as const,
4637
+ title: "Lint that knows the design",
4638
+ body: "sable-lint flags raw colors, hand-built controls, and motion that ignores reduced motion.",
4639
+ },
4640
+ {
4641
+ icon: "play" as const,
4642
+ title: "Motion with an off switch",
4643
+ body: "Named recipes run on one scale variable, so reduced motion stops every transition at once.",
4644
+ },
4645
+ ]
4646
+
4647
+ const steps = [
4648
+ {
4649
+ title: "Install the package",
4650
+ body: "One dependency. React 19 and Tailwind v4 are the only peers.",
4651
+ },
4652
+ {
4653
+ title: "Import the styles",
4654
+ body: "Tailwind first, then Sable's tokens, then a source glob over the package.",
4655
+ },
4656
+ {
4657
+ title: "Compose your screens",
4658
+ body: "Build from components and layout primitives. Your theme does the rest.",
4659
+ },
4660
+ ]
4661
+
4662
+ const stylesCode = `@import "tailwindcss";
4663
+ @import "@sableui/react/styles.css";
4664
+ @source "../node_modules/@sableui/react/dist/**/*.{js,mjs}";`
4665
+
4666
+ // design-lint-disable-next-line self-import -- a consumer code sample displayed on the page, not an import
4667
+ const composeCode = `import { Button, Card, CardContent, CardHeader, CardTitle } from "@sableui/react"
4668
+
4669
+ export function DeployCard() {
4670
+ return (
4671
+ <Card>
4672
+ <CardHeader>
4673
+ <CardTitle>Production</CardTitle>
4674
+ </CardHeader>
4675
+ <CardContent>
4676
+ <Button>Deploy</Button>
4677
+ </CardContent>
4678
+ </Card>
4679
+ )
4680
+ }`
4681
+
4682
+ const terminalCode = `$ npx sable search "confirm before deleting"
4683
+ component DestructiveActionModal
4684
+ component Dialog
4685
+
4686
+ $ npx sable component DestructiveActionModal --props
4687
+ props, a11y contract, composed examples
4688
+
4689
+ $ npx sable lint src
4690
+ 0 findings in 42 files`
4691
+
4692
+ const agentPoints = [
4693
+ "Search ranks components, patterns, tokens, and docs for a plain-language need.",
4694
+ "Every component ships its props, accessibility contract, and composed examples.",
4695
+ "The same lint runs in the editor, in CI, and inside the agent's own loop.",
4696
+ ]
4697
+
4698
+ const footerGroups = [
4699
+ {
4700
+ title: "Product",
4701
+ links: [
4702
+ { label: "Components", href: "/components" },
4703
+ { label: "Patterns", href: "/patterns" },
4704
+ { label: "Themes", href: "/themes" },
4705
+ { label: "Canvas", href: "/canvas" },
4706
+ ],
4707
+ },
4708
+ {
4709
+ title: "Resources",
4710
+ links: [
4711
+ { label: "Documentation", href: "/docs" },
4712
+ { label: "Changelog", href: "/changelog" },
4713
+ { label: "llms.txt", href: "/llms.txt" },
4714
+ { label: "registry.json", href: "/registry.json" },
4715
+ ],
4716
+ },
4717
+ {
4718
+ title: "Community",
4719
+ links: [
4720
+ { label: "GitHub", href: "/github" },
4721
+ { label: "npm", href: "/npm" },
4722
+ { label: "Discussions", href: "/discussions" },
4723
+ ],
3928
4724
  },
3929
4725
  ]
3930
4726
 
3931
4727
  export function LandingPage() {
3932
4728
  return (
3933
- <div className="min-h-dvh bg-background">
3934
- <header>
3935
- <Region as="nav" aria-label="Main" className="grid h-16 grid-cols-[1fr_auto_1fr] items-center">
3936
- <span className="justify-self-start text-title-sm">Sable</span>
3937
- <Row gap={1} className="justify-self-center max-sm:hidden">
3938
- <Button variant="ghost" size="sm" render={<a href="/docs" />}>
3939
- Docs
3940
- </Button>
3941
- <Button variant="ghost" size="sm" render={<a href="/patterns" />}>
3942
- Patterns
3943
- </Button>
3944
- <Button variant="ghost" size="sm" render={<a href="/themes" />}>
3945
- Themes
4729
+ <div className="min-h-dvh bg-background text-foreground">
4730
+ <header className="border-b border-border">
4731
+ <Region as="nav" aria-label="Main" max="wide" className="grid h-16 grid-cols-[1fr_auto_1fr] items-center">
4732
+ <a href="/" className="justify-self-start text-title-lg motion-interactive hover:text-muted-foreground">
4733
+ Sable
4734
+ </a>
4735
+ <div className="justify-self-center max-md:hidden">
4736
+ <Row gap={1}>
4737
+ {navLinks.map((link) => (
4738
+ <Button key={link.label} variant="ghost" size="sm" render={<a href={link.href} />}>
4739
+ {link.label}
4740
+ </Button>
4741
+ ))}
4742
+ </Row>
4743
+ </div>
4744
+ <Row gap={1} className="col-start-3 justify-self-end">
4745
+ <div className="max-sm:hidden">
4746
+ <Button variant="ghost" size="sm" render={<a href="/github" />}>
4747
+ GitHub
4748
+ </Button>
4749
+ </div>
4750
+ <Button size="sm" render={<a href="/docs/getting-started" />}>
4751
+ Get started
3946
4752
  </Button>
3947
4753
  </Row>
3948
- <Button size="sm" className="justify-self-end" render={<a href="/signup" />}>
3949
- Get started
3950
- </Button>
3951
4754
  </Region>
3952
4755
  </header>
4756
+
3953
4757
  <main>
3954
- <Region as="section" max="wide" className="py-24">
3955
- <Stack gap={4} align="center" className="text-center">
3956
- <Badge appearance="outline" size="sm">
3957
- Now in beta
3958
- </Badge>
3959
- <h1 className="max-w-4xl text-balance text-display-2xl">
3960
- Ship product UI your agents can't get wrong
3961
- </h1>
3962
- <p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
3963
- An owned, themeable component system with enforced tokens, typography,
3964
- and motion — documented for machines as carefully as for people.
3965
- </p>
3966
- <Row gap={2} justify="center">
3967
- <Button size="cta">Get started</Button>
3968
- <Button size="cta" variant="outline">
3969
- Read the docs
3970
- </Button>
3971
- </Row>
4758
+ <Region as="section" max="wide" className="pb-16 pt-16 sm:pt-24">
4759
+ <Stack gap={8} align="center">
4760
+ <Stack gap={4} align="center" className="text-center">
4761
+ <Badge appearance="outline" size="sm" icon={<Icon name="sparkles" />}>
4762
+ Open source · MIT licensed
4763
+ </Badge>
4764
+ <h1 className="max-w-4xl text-balance text-display-lg sm:text-display-2xl">
4765
+ Product UI your agents can't get wrong
4766
+ </h1>
4767
+ <p className="max-w-(--measure-prose) text-pretty text-body-lg text-muted-foreground">
4768
+ Sable is a themeable React component library with enforced tokens,
4769
+ typography, and motion, documented for coding agents as carefully
4770
+ as for people.
4771
+ </p>
4772
+ <Row gap={2} justify="center" wrap className="pt-2">
4773
+ <Button size="cta" render={<a href="/docs/getting-started" />}>
4774
+ Get started
4775
+ </Button>
4776
+ <Button size="cta" variant="outline" render={<a href="/components" />}>
4777
+ Browse components
4778
+ </Button>
4779
+ </Row>
4780
+ </Stack>
4781
+ <div className="w-full max-w-sm">
4782
+ <CodeBlock prompt="$" code="pnpm add @sableui/react" />
4783
+ </div>
3972
4784
  </Stack>
3973
4785
  </Region>
3974
- <Region as="section" max="wide" className="pb-24">
3975
- <Reveal>
3976
- {/* Three-up that reflows on its own width (minItemWidth), one Card
3977
- per feature — never a hand-drawn bordered grid. */}
3978
- <Grid minItemWidth="calc(var(--spacing) * 64)">
3979
- {features.map((feature) => (
3980
- <Card key={feature.title}>
3981
- <CardContent className="flex flex-col gap-2">
3982
- <Icon name={feature.icon} className="text-xl text-muted-foreground" />
3983
- <h2 className="text-title-lg">{feature.title}</h2>
3984
- <p className="text-body-sm text-muted-foreground">{feature.body}</p>
4786
+
4787
+ <Region as="section" aria-label="Product preview" max="wide" className="pb-20 sm:pb-28">
4788
+ <figure>
4789
+ <Browser url="atlas.example/deploys" className="shadow-xl">
4790
+ {/* A picture of software, not live UI: inert keeps its controls
4791
+ out of the tab order and the accessibility tree. */}
4792
+ <div inert className="grid gap-4 p-4 sm:p-6 lg:grid-cols-[minmax(0,1fr)_20rem]">
4793
+ <Stack gap={4} className="min-w-0">
4794
+ <PageHeader
4795
+ titleAs="h2"
4796
+ title="Deployments"
4797
+ description="atlas-web · production"
4798
+ actions={
4799
+ <Row gap={2}>
4800
+ <Button variant="outline" size="sm">
4801
+ View logs
4802
+ </Button>
4803
+ <Button size="sm" iconStart={<Icon name="upload" />}>
4804
+ Deploy
4805
+ </Button>
4806
+ </Row>
4807
+ }
4808
+ />
4809
+ <Grid cols={{ base: 2, md: 3 }} gap={3}>
4810
+ <Card density="compact">
4811
+ <CardHeader>
4812
+ <CardTitle>Uptime</CardTitle>
4813
+ <CardDescription>Last 30 days</CardDescription>
4814
+ </CardHeader>
4815
+ <CardContent>
4816
+ <p className="text-heading-3">99.98%</p>
4817
+ </CardContent>
4818
+ </Card>
4819
+ <Card density="compact">
4820
+ <CardHeader>
4821
+ <CardTitle>p95 latency</CardTitle>
4822
+ <CardDescription>Edge, all regions</CardDescription>
4823
+ </CardHeader>
4824
+ <CardContent>
4825
+ <p className="text-heading-3">182 ms</p>
4826
+ </CardContent>
4827
+ </Card>
4828
+ <Card density="compact" className="max-md:hidden">
4829
+ <CardHeader>
4830
+ <CardTitle>Build minutes</CardTitle>
4831
+ <CardDescription>Team plan</CardDescription>
4832
+ </CardHeader>
4833
+ <CardContent>
4834
+ <Meter value={64} tone="standard">
4835
+ <Row justify="between" wrap className="mb-2">
4836
+ <MeterLabel>Used</MeterLabel>
4837
+ <MeterValue>{(_, value) => `${(value * 30).toLocaleString("en-US")} of 3,000`}</MeterValue>
4838
+ </Row>
4839
+ <MeterTrack />
4840
+ </Meter>
4841
+ </CardContent>
4842
+ </Card>
4843
+ </Grid>
4844
+ <Card>
4845
+ <CardHeader>
4846
+ <CardTitle>Recent deploys</CardTitle>
4847
+ <CardDescription>Production branch</CardDescription>
4848
+ <CardAction>
4849
+ <Badge appearance="plain" tone="success" dot>
4850
+ Healthy
4851
+ </Badge>
4852
+ </CardAction>
4853
+ </CardHeader>
4854
+ <CardList>
4855
+ {deploys.map((deploy) => (
4856
+ <Entity
4857
+ key={deploy.meta}
4858
+ leading={<Avatar size="sm" fallback={deploy.initials} alt={deploy.name} />}
4859
+ title={deploy.title}
4860
+ subtitle={deploy.meta}
4861
+ action={
4862
+ <Badge appearance="plain" tone={deploy.tone} dot pulse={deploy.pulse}>
4863
+ {deploy.status}
4864
+ </Badge>
4865
+ }
4866
+ />
4867
+ ))}
4868
+ </CardList>
4869
+ </Card>
4870
+ </Stack>
4871
+ <Card className="max-lg:hidden">
4872
+ <CardHeader>
4873
+ <CardTitle>Release assistant</CardTitle>
4874
+ <CardDescription>Summaries from today's deploys</CardDescription>
4875
+ </CardHeader>
4876
+ <CardContent className="flex flex-col gap-4">
4877
+ <Message align="end">
4878
+ <Bubble variant="primary" align="end">
4879
+ What shipped to production today?
4880
+ </Bubble>
4881
+ </Message>
4882
+ <Message>
4883
+ <Bubble>
4884
+ Three deploys. Search ranking is fixed, webhook keys are
4885
+ rotated, and usage-based billing is building now.
4886
+ </Bubble>
4887
+ </Message>
4888
+ <ChatComposer placeholder="Ask about a deploy" />
3985
4889
  </CardContent>
3986
4890
  </Card>
3987
- ))}
3988
- </Grid>
4891
+ </div>
4892
+ </Browser>
4893
+ <figcaption className="mt-4 text-center text-body-sm text-muted-foreground">
4894
+ Every surface in this preview is a Sable component, styled by the active theme.
4895
+ </figcaption>
4896
+ </figure>
4897
+ </Region>
4898
+
4899
+ <section aria-labelledby="landing-features" className="border-y border-border bg-muted/40 py-20 sm:py-28">
4900
+ <Region max="wide">
4901
+ <Stack gap={10}>
4902
+ <Stack gap={3} className="max-w-2xl">
4903
+ <p className="text-overline text-muted-foreground">Why Sable</p>
4904
+ <h2 id="landing-features" className="text-balance text-heading-1">
4905
+ A design system with the rules written down
4906
+ </h2>
4907
+ <p className="text-body-lg text-muted-foreground">
4908
+ The decisions a senior designer would make are encoded once, so every
4909
+ screen your team or your agent builds starts from them.
4910
+ </p>
4911
+ </Stack>
4912
+ <Reveal>
4913
+ <Grid cols={{ base: 1, sm: 2, lg: 3 }} gap={4}>
4914
+ {features.map((feature) => (
4915
+ <Card key={feature.title} render={<article />} className="h-full">
4916
+ <CardContent className="flex flex-col gap-3">
4917
+ <span className="grid size-10 place-items-center rounded-md sable-well">
4918
+ <Icon name={feature.icon} className="text-xl" />
4919
+ </span>
4920
+ <h3 className="pt-2 text-title-lg">{feature.title}</h3>
4921
+ <p className="text-body-sm text-muted-foreground">{feature.body}</p>
4922
+ </CardContent>
4923
+ </Card>
4924
+ ))}
4925
+ </Grid>
4926
+ </Reveal>
4927
+ </Stack>
4928
+ </Region>
4929
+ </section>
4930
+
4931
+ <Region as="section" aria-labelledby="landing-setup" max="wide" className="py-20 sm:py-28">
4932
+ <div className="grid items-start gap-10 lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] lg:gap-16">
4933
+ <Stack gap={8}>
4934
+ <Stack gap={3}>
4935
+ <p className="text-overline text-muted-foreground">Setup</p>
4936
+ <h2 id="landing-setup" className="text-balance text-heading-1">
4937
+ From install to first screen in three steps
4938
+ </h2>
4939
+ </Stack>
4940
+ <ol className="flex flex-col gap-6">
4941
+ {steps.map((step, index) => (
4942
+ <li key={step.title} className="grid grid-cols-[auto_1fr] gap-4">
4943
+ <span className="pt-0.5 text-mono-md text-muted-foreground">0{index + 1}</span>
4944
+ <div>
4945
+ <h3 className="text-title-lg">{step.title}</h3>
4946
+ <p className="mt-1 text-body-md text-muted-foreground">{step.body}</p>
4947
+ </div>
4948
+ </li>
4949
+ ))}
4950
+ </ol>
4951
+ </Stack>
4952
+ <Reveal className="min-w-0">
4953
+ <Tabs defaultValue="compose">
4954
+ <TabsList>
4955
+ <TabsTrigger value="install">Install</TabsTrigger>
4956
+ <TabsTrigger value="styles">Styles</TabsTrigger>
4957
+ <TabsTrigger value="compose">Compose</TabsTrigger>
4958
+ </TabsList>
4959
+ <TabsPanel value="install" className="pt-4">
4960
+ <CodeBlock prompt="$" code="pnpm add @sableui/react" />
4961
+ </TabsPanel>
4962
+ <TabsPanel value="styles" className="pt-4">
4963
+ <CodeBlock filename="app.css" code={stylesCode} />
4964
+ </TabsPanel>
4965
+ <TabsPanel value="compose" className="pt-4">
4966
+ <CodeBlock filename="deploy-card.tsx" code={composeCode} />
4967
+ </TabsPanel>
4968
+ </Tabs>
4969
+ </Reveal>
4970
+ </div>
4971
+ </Region>
4972
+
4973
+ <section aria-labelledby="landing-agents" className="border-t border-border py-20 sm:py-28">
4974
+ <Region max="wide">
4975
+ <div className="grid items-center gap-10 lg:grid-cols-2 lg:gap-16">
4976
+ <Stack gap={5}>
4977
+ <Stack gap={3}>
4978
+ <p className="text-overline text-muted-foreground">Built for agents</p>
4979
+ <h2 id="landing-agents" className="text-balance text-heading-1">
4980
+ Your agent reads the same contract you do
4981
+ </h2>
4982
+ <p className="text-body-lg text-muted-foreground">
4983
+ Point Claude Code, Codex, or Cursor at the installed package. The{" "}
4984
+ <Code>sable</Code> CLI answers what to use, how to use it, and whether
4985
+ the result holds up.
4986
+ </p>
4987
+ </Stack>
4988
+ <ul className="sable-list">
4989
+ {agentPoints.map((point) => (
4990
+ <li key={point}>{point}</li>
4991
+ ))}
4992
+ </ul>
4993
+ </Stack>
4994
+ <Reveal className="min-w-0">
4995
+ <CodeBlock filename="Terminal" code={terminalCode} highlight={false} />
4996
+ </Reveal>
4997
+ </div>
4998
+ </Region>
4999
+ </section>
5000
+
5001
+ <Region as="section" aria-labelledby="landing-cta" max="wide" className="pb-20 sm:pb-28">
5002
+ <Reveal>
5003
+ <Card>
5004
+ <CardContent>
5005
+ <Stack gap={4} align="center" className="py-8 text-center sm:py-12">
5006
+ <h2 id="landing-cta" className="max-w-2xl text-balance text-heading-1">
5007
+ Build the product, not another component library
5008
+ </h2>
5009
+ <p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
5010
+ Install Sable, pick a preset, and ship the first screen today.
5011
+ </p>
5012
+ <Row gap={2} justify="center" wrap className="pt-2">
5013
+ <Button size="cta" render={<a href="/docs/getting-started" />}>
5014
+ Get started
5015
+ </Button>
5016
+ <Button size="cta" variant="outline" render={<a href="/github" />}>
5017
+ Star on GitHub
5018
+ </Button>
5019
+ </Row>
5020
+ </Stack>
5021
+ </CardContent>
5022
+ </Card>
3989
5023
  </Reveal>
3990
5024
  </Region>
3991
5025
  </main>
5026
+
3992
5027
  <footer className="border-t border-border">
3993
- <Region className="flex h-16 items-center justify-between">
3994
- <span className="text-caption text-muted-foreground">© 2026 Sable</span>
3995
- <Row gap={3}>
3996
- <a href="/github" className="text-caption text-muted-foreground motion-interactive hover:text-foreground">
3997
- GitHub
3998
- </a>
3999
- <a href="/changelog" className="text-caption text-muted-foreground motion-interactive hover:text-foreground">
4000
- Changelog
4001
- </a>
4002
- </Row>
5028
+ <Region max="wide" className="py-12">
5029
+ <div className="grid gap-10 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
5030
+ <Stack gap={3} className="max-w-xs">
5031
+ <a href="/" className="w-fit text-title-lg motion-interactive hover:text-muted-foreground">
5032
+ Sable
5033
+ </a>
5034
+ <p className="text-body-sm text-muted-foreground">
5035
+ React components, patterns, and tokens for products built with AI agents.
5036
+ </p>
5037
+ </Stack>
5038
+ <nav aria-label="Footer" className="grid grid-cols-2 gap-8 sm:grid-cols-3">
5039
+ {footerGroups.map((group) => (
5040
+ <div key={group.title}>
5041
+ <h2 className="text-label-md text-muted-foreground">{group.title}</h2>
5042
+ <ul className="mt-3 flex flex-col gap-1">
5043
+ {group.links.map((link) => (
5044
+ <li key={link.label}>
5045
+ <a
5046
+ href={link.href}
5047
+ className="flex min-h-9 items-center text-body-sm motion-interactive hover:text-muted-foreground"
5048
+ >
5049
+ {link.label}
5050
+ </a>
5051
+ </li>
5052
+ ))}
5053
+ </ul>
5054
+ </div>
5055
+ ))}
5056
+ </nav>
5057
+ </div>
5058
+ <div className="mt-12 border-t border-border pt-6">
5059
+ <p className="text-caption text-muted-foreground">© 2026 Sable. Released under the MIT license.</p>
5060
+ </div>
4003
5061
  </Region>
4004
5062
  </footer>
4005
5063
  </div>
@@ -4011,98 +5069,415 @@ export function LandingPage() {
4011
5069
 
4012
5070
  # ListDetailPage
4013
5071
 
4014
- Two-column list-detail: a wide region split 8/4, the record list as Entity rows in the main column, and a sticky supporting rail (detail, filters, or summary) beside it.
5072
+ Searchable list beside the selected record's detail: interactive Entity rows in a CardList (5 of 12 columns) and a sticky detail pane (7) with an editable field, a label/value list, record actions with a destructive confirmation, and recent activity. Below lg it becomes a drill-in.
4015
5073
 
4016
- **Intent:** Reach for this when a route lists records AND keeps one selected/summarized in view (members + member detail, inbox + message, orders + order). For pure browsing with no rail, a single-column FeedPage or DashboardPage table is simpler.
5074
+ **Intent:** Reach for this when a route lists records AND works on one at a time (members + member, inbox + message, orders + order). For pure browsing with no selection, a single-column FeedPage or a DashboardPage table is simpler.
4017
5075
 
4018
5076
  ## Notes
4019
5077
 
4020
- - Region cap is `wide`; the split is Columns 12 → Col 8 + Col 4, collapsing to stacked full-width columns below lg via breakpoint maps.
4021
- - List rows are Entity inside one bordered Card, in CardList: it insets each row to the card padding and divides them with hairlines — on a dark canvas rows need an anchoring surface, not a flat float.
4022
- - Pending records live in the same list, muted (muted title + `Invited` badge), instead of a separate section.
4023
- - The rail card holds label/value pairs as a Description list — muted terms, mono (tabular) values — plus one quiet action. Never hand-built `justify-between` rows.
4024
- - The rail is sticky (top on the 4px scale) so the summary stays in view while the list scrolls.
5078
+ - Region cap is `wide`; Columns 12 → Col 5 (list) + Col 7 (detail). Below lg the list and detail share one column as a drill-in: selecting a row shows the detail with an `All members` back button (lg:hidden), and the back button returns to the list. The panes swap with `max-lg:hidden`, so desktop always shows both.
5079
+ - Rows are interactive Entity inside one Card + CardList. Selection is `data-selected` on the row painted by `sable-surface-interactive`; `sable-surface` on the list card makes that step 12% ink off the card, so a selected row stays distinct from its muted avatar.
5080
+ - Search filters the list in place (name, email, role). No matches swap the CardList for an EmptyState with a Clear search action; the selected detail stays open.
5081
+ - A pending invite lives in the same list: a mail glyph in the Avatar, an `Invited` Badge, and an invite-specific detail (role on joining, sent, expires, invited by; Resend and Revoke actions).
5082
+ - The role Select edits the record in place; its Field description states what the role can do. The owner's role is read-only and the owner has no remove action.
5083
+ - Read-only facts are a native `<dl className="sable-list-dl sable-list-dl-2">` inside an `@container`, so pairs go two-up only when the pane is wide enough.
5084
+ - Remove / revoke run through DestructiveActionModal; on confirm the next row is selected. Page-level counts (members, pending, seats) live in the PageHeader description and update with the list.
5085
+ - The detail pane is sticky (lg:top-6) so it stays in view while a long list scrolls.
4025
5086
 
4026
5087
  ## Accessibility
4027
5088
 
4028
- - Entity rows given `interactive` + `onSelect` (to open a member's detail) render as real buttons; keyboard focus follows list order. Rows here are presentational.
4029
- - The rail is an <aside> landmark; the list column is the main content.
5089
+ - Each row is a real button (Entity `interactive` + `onSelect`): Tab moves through rows, Enter/Space selects. The selected row carries visually hidden `(selected)` text in its name; list avatars are aria-hidden so initials are not read before the name.
5090
+ - The detail pane is a labelled region (`Member details`) whose title is a level-2 heading.
5091
+ - On a phone, drilling in moves focus to the back button; going back returns focus to the row that was open. After a remove, focus lands on the newly selected row; after Clear search, on the search field.
4030
5092
 
4031
5093
  ## Source
4032
5094
 
4033
5095
  ```tsx
4034
- import { Avatar, Badge, Button, Card, CardContent, CardHeader, CardList, CardTitle, Col, Columns, Description, DescriptionItem, Entity, Page, PageHeader, SearchInput, Stack } from "@sableui/react"
5096
+ import { useState, useEffect } from "react"
5097
+ import { Avatar, Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, EmptyState, Entity, Field, Icon, Page, PageHeader, Row, SearchInput, Select, SelectContent, SelectItem, SelectTrigger, Stack, StatusDot } from "@sableui/react"
5098
+
5099
+ type Role = "owner" | "admin" | "editor" | "viewer"
5100
+
5101
+ const roles: { value: Role; label: string }[] = [
5102
+ { value: "owner", label: "Owner" },
5103
+ { value: "admin", label: "Admin" },
5104
+ { value: "editor", label: "Editor" },
5105
+ { value: "viewer", label: "Viewer" },
5106
+ ]
5107
+
5108
+ const roleLabel: Record<Role, string> = {
5109
+ owner: "Owner",
5110
+ admin: "Admin",
5111
+ editor: "Editor",
5112
+ viewer: "Viewer",
5113
+ }
4035
5114
 
4036
- const members = [
4037
- { id: "u_01", name: "Ada Lovelace", handle: "@ada", role: "Owner", initials: "AL", pending: false },
4038
- { id: "u_02", name: "Grace Hopper", handle: "@grace", role: "Admin", initials: "GH", pending: false },
4039
- { id: "u_03", name: "Mary Shelley", handle: "@mary", role: "Member", initials: "MS", pending: false },
4040
- { id: "u_04", name: "Nina Simone", handle: "@nina", role: "Member", initials: "NS", pending: false },
4041
- { id: "u_05", name: "Frida Kahlo", handle: "@frida", role: "Member", initials: "FK", pending: false },
4042
- { id: "u_06", name: "hopper@navy.mil", handle: "invited 2 days ago", role: "Member", initials: "?", pending: true },
5115
+ const roleSummary: Record<Role, string> = {
5116
+ owner: "Full access, including payouts and billing. Transfer ownership in studio settings.",
5117
+ admin: "Manages members, editions and payouts.",
5118
+ editor: "Drafts, prices and releases editions.",
5119
+ viewer: "Sees editions and sales, but can't change them.",
5120
+ }
5121
+
5122
+ type Activity = { id: string; icon: string; text: string; when: string }
5123
+
5124
+ type Member = {
5125
+ id: string
5126
+ name: string
5127
+ email: string
5128
+ initials: string
5129
+ role: Role
5130
+ pending: boolean
5131
+ handle?: string
5132
+ joined?: string
5133
+ lastActive?: string
5134
+ editions?: number
5135
+ invitedBy?: string
5136
+ sent?: string
5137
+ expires?: string
5138
+ resent?: boolean
5139
+ activity: Activity[]
5140
+ }
5141
+
5142
+ const seats = 10
5143
+
5144
+ const initialMembers: Member[] = [
5145
+ {
5146
+ id: "u_01", name: "Ada Lovelace", handle: "@ada", email: "ada@atelier.art", initials: "AL", role: "owner", pending: false,
5147
+ joined: "Mar 4, 2024", lastActive: "Online now", editions: 12,
5148
+ activity: [
5149
+ { id: "a1", icon: "gem", text: "Released Analytical Engine No. 3 — edition of 50", when: "2 hours ago" },
5150
+ { id: "a2", icon: "mail", text: "Invited louise@bourgeois.studio as Editor", when: "2 days ago" },
5151
+ ],
5152
+ },
5153
+ {
5154
+ id: "u_02", name: "Grace Hopper", handle: "@grace", email: "grace@atelier.art", initials: "GH", role: "admin", pending: false,
5155
+ joined: "Mar 9, 2024", lastActive: "20 minutes ago", editions: 4,
5156
+ activity: [
5157
+ { id: "a1", icon: "user", text: "Changed Frida Kahlo's role to Viewer", when: "Yesterday" },
5158
+ { id: "a2", icon: "edit", text: "Updated pricing on First Compiler — edition of 10", when: "3 days ago" },
5159
+ { id: "a3", icon: "gem", text: "Released First Compiler — edition of 10", when: "Sep 28" },
5160
+ ],
5161
+ },
5162
+ {
5163
+ id: "u_03", name: "Mary Shelley", handle: "@mary", email: "mary@atelier.art", initials: "MS", role: "editor", pending: false,
5164
+ joined: "Jun 18, 2024", lastActive: "3 hours ago", editions: 7,
5165
+ activity: [
5166
+ { id: "a1", icon: "calendar", text: "Scheduled Frankenstein Studies for Oct 14", when: "3 hours ago" },
5167
+ { id: "a2", icon: "gem", text: "Released The Last Man — edition of 25", when: "Sep 21" },
5168
+ ],
5169
+ },
5170
+ {
5171
+ id: "u_04", name: "Nina Simone", handle: "@nina", email: "nina@atelier.art", initials: "NS", role: "editor", pending: false,
5172
+ joined: "Jan 12, 2025", lastActive: "Yesterday", editions: 3,
5173
+ activity: [
5174
+ { id: "a1", icon: "upload", text: "Uploaded artwork for Little Girl Blue", when: "Yesterday" },
5175
+ { id: "a2", icon: "gem", text: "Released Feeling Good — edition of 100", when: "Aug 30" },
5176
+ ],
5177
+ },
5178
+ {
5179
+ id: "u_05", name: "Frida Kahlo", handle: "@frida", email: "frida@atelier.art", initials: "FK", role: "viewer", pending: false,
5180
+ joined: "Jul 2, 2025", lastActive: "4 days ago", editions: 0,
5181
+ activity: [{ id: "a1", icon: "comment", text: "Commented on the Las Dos Fridas draft", when: "4 days ago" }],
5182
+ },
5183
+ {
5184
+ id: "i_01", name: "louise@bourgeois.studio", email: "louise@bourgeois.studio", initials: "", role: "editor", pending: true,
5185
+ invitedBy: "Ada Lovelace", sent: "2 days ago", expires: "In 5 days",
5186
+ activity: [],
5187
+ },
4043
5188
  ]
4044
5189
 
4045
5190
  export function ListDetailPage() {
5191
+ const [members, setMembers] = useState(initialMembers)
5192
+ const [selectedId, setSelectedId] = useState<string | null>("u_02")
5193
+ const [query, setQuery] = useState("")
5194
+ // Below lg the list and the detail share one column: selecting drills in.
5195
+ const [showDetail, setShowDetail] = useState(false)
5196
+ const [confirming, setConfirming] = useState(false)
5197
+ const listRef = useRef<HTMLDivElement>(null)
5198
+ const backRef = useRef<HTMLButtonElement>(null)
5199
+ const searchRef = useRef<HTMLInputElement>(null)
5200
+
5201
+ const needle = query.trim().toLowerCase()
5202
+ const visible = members.filter((m) =>
5203
+ [m.name, m.email, m.handle ?? "", roleLabel[m.role]].some((v) => v.toLowerCase().includes(needle)),
5204
+ )
5205
+ const selected = members.find((m) => m.id === selectedId) ?? null
5206
+ const active = members.filter((m) => !m.pending).length
5207
+ const pending = members.length - active
5208
+
5209
+ // Drilling in on a phone moves focus to the detail's back control; the
5210
+ // control is display:none at lg, so desktop focus stays on the list.
5211
+ useEffect(() => {
5212
+ if (showDetail && backRef.current?.offsetParent) backRef.current.focus()
5213
+ }, [showDetail, selectedId])
5214
+
5215
+ function select(id: string) {
5216
+ setSelectedId(id)
5217
+ setShowDetail(true)
5218
+ }
5219
+
5220
+ function focusRow(id: string | undefined) {
5221
+ requestAnimationFrame(() => {
5222
+ listRef.current?.querySelector<HTMLButtonElement>(`[data-member-id="${id}"] button`)?.focus()
5223
+ })
5224
+ }
5225
+
5226
+ function backToList() {
5227
+ setShowDetail(false)
5228
+ focusRow(selectedId ?? undefined)
5229
+ }
5230
+
5231
+ function setRole(id: string, role: Role) {
5232
+ setMembers((list) => list.map((m) => (m.id === id ? { ...m, role } : m)))
5233
+ }
5234
+
5235
+ function resend(id: string) {
5236
+ setMembers((list) =>
5237
+ list.map((m) => (m.id === id ? { ...m, sent: "Just now", expires: "In 7 days", resent: true } : m)),
5238
+ )
5239
+ }
5240
+
5241
+ function remove(id: string) {
5242
+ const index = members.findIndex((m) => m.id === id)
5243
+ const rest = members.filter((m) => m.id !== id)
5244
+ setMembers(rest)
5245
+ const next = rest[Math.min(index, rest.length - 1)]?.id
5246
+ setSelectedId(next ?? null)
5247
+ setShowDetail(false)
5248
+ // The control that opened the confirmation is gone; land on the next row.
5249
+ focusRow(next)
5250
+ }
5251
+
4046
5252
  return (
4047
5253
  <Page as="main" max="wide" gap={4} className="py-6">
4048
5254
  <PageHeader
4049
5255
  title="Members"
4050
- description="Everyone with access to this workspace."
4051
- actions={<Button>Invite member</Button>}
5256
+ description={`${active} members · ${pending} pending ${pending === 1 ? "invite" : "invites"} · ${members.length} of ${seats} seats used`}
5257
+ actions={<Button iconStart={<Icon name="plus" />}>Invite member</Button>}
4052
5258
  />
4053
5259
  <Columns count={{ base: 1, lg: 12 }}>
4054
- <Col span={{ base: 1, lg: 8 }}>
5260
+ <Col span={{ base: 1, lg: 5 }} className={showDetail ? "max-lg:hidden" : undefined}>
4055
5261
  <Stack gap={3}>
4056
- <SearchInput placeholder="Search members" aria-label="Search members" />
4057
- <Card className="overflow-hidden">
4058
- <CardList>
4059
- {members.map((member) => (
4060
- <Entity
4061
- key={member.id}
4062
- leading={
4063
- <Avatar
4064
- fallback={member.pending ? "•" : member.initials}
4065
- alt={member.pending ? "" : member.name}
4066
- />
4067
- }
4068
- title={
4069
- <span className={member.pending ? "text-muted-foreground" : undefined}>
4070
- {member.name}
4071
- </span>
4072
- }
4073
- subtitle={member.handle}
4074
- action={
4075
- member.pending ? (
4076
- <Badge>Invited</Badge>
4077
- ) : (
4078
- <Badge appearance="outline">{member.role}</Badge>
4079
- )
4080
- }
4081
- />
4082
- ))}
4083
- </CardList>
5262
+ <SearchInput
5263
+ ref={searchRef}
5264
+ value={query}
5265
+ onValueChange={setQuery}
5266
+ placeholder="Search name, email or role"
5267
+ aria-label="Search members"
5268
+ />
5269
+ {/* sable-surface derives the rows' selected step from the card (12% ink),
5270
+ so a selected row stays distinct from its muted avatar. */}
5271
+ <Card ref={listRef} className="sable-surface">
5272
+ {visible.length > 0 ? (
5273
+ <CardList aria-label="Members">
5274
+ {visible.map((m) => {
5275
+ const isSelected = m.id === selectedId
5276
+ return (
5277
+ <Entity
5278
+ key={m.id}
5279
+ data-member-id={m.id}
5280
+ data-selected={isSelected ? "" : undefined}
5281
+ // No selected variant on Entity yet: the relative-surface recipe paints
5282
+ // data-selected one step off the card.
5283
+ className="sable-surface-interactive"
5284
+ interactive
5285
+ onSelect={() => select(m.id)}
5286
+ leading={
5287
+ <Avatar
5288
+ fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
5289
+ status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
5290
+ // The row's title already names it: keep the initials out of the row's name.
5291
+ aria-hidden
5292
+ />
5293
+ }
5294
+ title={
5295
+ <>
5296
+ {m.name}
5297
+ {isSelected ? <span className="sr-only"> (selected)</span> : null}
5298
+ </>
5299
+ }
5300
+ subtitle={
5301
+ m.pending
5302
+ ? `${roleLabel[m.role]} · invite sent ${m.sent?.toLowerCase()}`
5303
+ : `${roleLabel[m.role]} · ${m.handle}`
5304
+ }
5305
+ action={m.pending ? <Badge tone="info">Invited</Badge> : undefined}
5306
+ />
5307
+ )
5308
+ })}
5309
+ </CardList>
5310
+ ) : (
5311
+ <CardContent>
5312
+ <EmptyState
5313
+ icon={<Icon name="search" />}
5314
+ title="No members found"
5315
+ description={`Nobody matches “${query.trim()}”. Try a name, email or role.`}
5316
+ action={
5317
+ <Button variant="secondary" size="sm" onClick={() => {
5318
+ setQuery("")
5319
+ searchRef.current?.focus()
5320
+ }}>
5321
+ Clear search
5322
+ </Button>
5323
+ }
5324
+ />
5325
+ </CardContent>
5326
+ )}
4084
5327
  </Card>
4085
5328
  </Stack>
4086
5329
  </Col>
4087
- <Col span={{ base: 1, lg: 4 }}>
4088
- <aside className="sticky top-6">
4089
- <Card>
4090
- <CardHeader>
4091
- <CardTitle>Workspace</CardTitle>
4092
- </CardHeader>
4093
- <CardContent className="flex flex-col gap-3">
4094
- {/* Label/value pairs are a Description list, not hand-built rows. */}
4095
- <Description>
4096
- <DescriptionItem term="Members" detail={<span className="text-mono-md">5</span>} />
4097
- <DescriptionItem term="Pending invites" detail={<span className="text-mono-md">1</span>} />
4098
- <DescriptionItem term="Seats used" detail={<span className="text-mono-md">6 / 10</span>} />
4099
- </Description>
4100
- <Button variant="outline" size="sm" className="mt-1 self-start">
4101
- Manage plan
4102
- </Button>
4103
- </CardContent>
4104
- </Card>
4105
- </aside>
5330
+ <Col span={{ base: 1, lg: 7 }} className={showDetail ? undefined : "max-lg:hidden"}>
5331
+ <Stack role="region" aria-label="Member details" gap={3} className="lg:sticky lg:top-6">
5332
+ <Button
5333
+ ref={backRef}
5334
+ variant="ghost"
5335
+ size="sm"
5336
+ iconStart={<Icon name="chevron-left" />}
5337
+ onClick={backToList}
5338
+ className="self-start lg:hidden"
5339
+ >
5340
+ All members
5341
+ </Button>
5342
+ {selected === null ? (
5343
+ <EmptyState
5344
+ surface="card"
5345
+ icon={<Icon name="user" />}
5346
+ title="No member selected"
5347
+ description="Choose someone from the list to see their role and activity."
5348
+ />
5349
+ ) : (
5350
+ <>
5351
+ <Card>
5352
+ <CardHeader>
5353
+ <Row gap={2}>
5354
+ <Avatar
5355
+ size="lg"
5356
+ fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
5357
+ status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
5358
+ alt={selected.pending ? "Pending invite" : selected.name}
5359
+ />
5360
+ <Stack gap={0} className="min-w-0">
5361
+ <CardTitle role="heading" aria-level={2} className="truncate">
5362
+ {selected.name}
5363
+ </CardTitle>
5364
+ <CardDescription className="truncate">
5365
+ {selected.pending ? "Hasn't joined yet" : `${selected.handle} · ${selected.email}`}
5366
+ </CardDescription>
5367
+ </Stack>
5368
+ </Row>
5369
+ {selected.pending ? (
5370
+ <CardAction>
5371
+ <Badge tone="info">Invited</Badge>
5372
+ </CardAction>
5373
+ ) : null}
5374
+ </CardHeader>
5375
+ <CardContent>
5376
+ <Stack gap={5}>
5377
+ <Field
5378
+ label={selected.pending ? "Role on joining" : "Role"}
5379
+ description={roleSummary[selected.role]}
5380
+ >
5381
+ <Select
5382
+ items={roles}
5383
+ value={selected.role}
5384
+ onValueChange={(value) => setRole(selected.id, value as Role)}
5385
+ disabled={selected.role === "owner"}
5386
+ >
5387
+ <SelectTrigger className="max-w-72" />
5388
+ <SelectContent>
5389
+ {roles
5390
+ .filter((r) => r.value !== "owner" || selected.role === "owner")
5391
+ .map((r) => (
5392
+ <SelectItem key={r.value} value={r.value}>
5393
+ {r.label}
5394
+ </SelectItem>
5395
+ ))}
5396
+ </SelectContent>
5397
+ </Select>
5398
+ </Field>
5399
+ <div className="@container">
5400
+ {selected.pending ? (
5401
+ <dl className="sable-list-dl sable-list-dl-2">
5402
+ <dt>Sent</dt>
5403
+ <dd>{selected.sent}</dd>
5404
+ <dt>Expires</dt>
5405
+ <dd>{selected.expires}</dd>
5406
+ <dt>Invited by</dt>
5407
+ <dd>{selected.invitedBy}</dd>
5408
+ </dl>
5409
+ ) : (
5410
+ <dl className="sable-list-dl sable-list-dl-2">
5411
+ <dt>Email</dt>
5412
+ <dd>{selected.email}</dd>
5413
+ <dt>Joined</dt>
5414
+ <dd>{selected.joined}</dd>
5415
+ <dt>Last active</dt>
5416
+ <dd className={selected.lastActive === "Online now" ? "text-success" : undefined}>
5417
+ {selected.lastActive}
5418
+ </dd>
5419
+ <dt>Editions released</dt>
5420
+ <dd>{selected.editions}</dd>
5421
+ </dl>
5422
+ )}
5423
+ </div>
5424
+ </Stack>
5425
+ </CardContent>
5426
+ {selected.role !== "owner" ? (
5427
+ <CardFooter>
5428
+ <Row gap={2} wrap>
5429
+ {selected.pending ? (
5430
+ <Button
5431
+ variant="secondary"
5432
+ size="sm"
5433
+ iconStart={<Icon name={selected.resent ? "check" : "send"} />}
5434
+ disabled={selected.resent}
5435
+ onClick={() => resend(selected.id)}
5436
+ >
5437
+ {selected.resent ? "Invite resent" : "Resend invite"}
5438
+ </Button>
5439
+ ) : null}
5440
+ <Button variant="outline" size="sm" onClick={() => setConfirming(true)}>
5441
+ {selected.pending ? "Revoke invite" : "Remove from studio"}
5442
+ </Button>
5443
+ </Row>
5444
+ </CardFooter>
5445
+ ) : null}
5446
+ </Card>
5447
+ {selected.activity.length > 0 ? (
5448
+ <Card>
5449
+ <CardHeader>
5450
+ <CardTitle role="heading" aria-level={3}>
5451
+ Recent activity
5452
+ </CardTitle>
5453
+ </CardHeader>
5454
+ <CardList>
5455
+ {selected.activity.map((a) => (
5456
+ <Entity
5457
+ key={a.id}
5458
+ leading={<Icon name={a.icon} className="text-muted-foreground" />}
5459
+ title={a.text}
5460
+ subtitle={a.when}
5461
+ />
5462
+ ))}
5463
+ </CardList>
5464
+ </Card>
5465
+ ) : null}
5466
+ <DestructiveActionModal
5467
+ open={confirming}
5468
+ onOpenChange={setConfirming}
5469
+ title={selected.pending ? "Revoke this invite?" : `Remove ${selected.name}?`}
5470
+ description={
5471
+ selected.pending
5472
+ ? `${selected.email} won't be able to join with the link they were sent.`
5473
+ : "They lose access to the studio right away. Editions they released stay on sale."
5474
+ }
5475
+ confirmLabel={selected.pending ? "Revoke invite" : "Remove member"}
5476
+ onConfirm={() => remove(selected.id)}
5477
+ />
5478
+ </>
5479
+ )}
5480
+ </Stack>
4106
5481
  </Col>
4107
5482
  </Columns>
4108
5483
  </Page>
@@ -4114,37 +5489,84 @@ export function ListDetailPage() {
4114
5489
 
4115
5490
  # SettingsPage
4116
5491
 
4117
- Stacked settings sections inside the app shell: a content-width region, one Fieldset card per concern (each with its own save), auto-wired Form/Field rows, and a destructive zone last.
5492
+ Account settings inside the app shell: a sticky SideNav section rail beside a column of section Cards — a Profile form with the page's one explicit save, Card + CardList + Entity rows with Switches that apply instantly, a read-only payouts summary, account rows, and a delete card confirmed through DestructiveActionModal.
4118
5493
 
4119
5494
  **Intent:** Reach for this for account/profile/workspace settings and any 'edit a handful of grouped preferences' route. Not for a single short form (use AuthPage's centered form shape) or bulk record editing (use DashboardPage + DataTable).
4120
5495
 
4121
5496
  ## Notes
4122
5497
 
4123
- - Region cap is `content` (896px): settings panes hold labeled rows and the occasional 2-up field, never a full grid.
4124
- - One Fieldset per concern, each with its own footer save — per-section saves beat one page-wide submit for settings.
4125
- - Field wires label/description/error a11y automatically; never hand-roll <label> + <input> pairs here.
4126
- - The destructive section is visually last, isolated in its own Fieldset with a destructive-variant action.
4127
- - Toggles use orientation="horizontal" Fields so the label sits beside the Switch.
5498
+ - Region cap is `wide`, split on a 12-column track: a 3-column SideNav rail and a 9-column section column. The rail fills the space a lone content-width column would leave empty and gives the page its table of contents.
5499
+ - The rail is `SideNav` (the documented settings sub-nav), sticky, with in-page anchor items; it hides below lg, where the sections simply stack and the shell's BottomNav carries app navigation.
5500
+ - One save model, stated in the copy: drafted text (the Profile form) commits through its single footer save; toggles apply the moment they change, and the Notifications description says so. Never pair a Switch with a Save button.
5501
+ - Settings rows are Card + CardList + Entity with the Switch in `action` — the row owns its title and one-line description; keep descriptions short, because Entity truncates its subtitle.
5502
+ - Each section is a Card whose CardTitle holds an h2, so the page has a real heading outline under PageHeader's h1; Cards carry the anchor id and `scroll-mt-*` so anchor jumps clear the top edge.
5503
+ - Field wires label/description/error a11y automatically; never hand-roll <label> + <input> pairs. Pair fields side by side with Grid cols={{ base: 1, sm: 2 }}.
5504
+ - The destructive action is last and isolated in its own Card, and it never acts directly: it opens DestructiveActionModal with type-to-confirm on the handle.
4128
5505
  - Lives in the same AppShell as DashboardPage: Sidebar from lg up, AppHeader + BottomNav below it, with Settings marked active in both.
4129
5506
 
4130
5507
  ## Accessibility
4131
5508
 
4132
5509
  - AppShell provides the skip link and <main id="main-content"> landmark; the active SidebarItem and BottomNavItem carry aria-current.
4133
- - Each section is a real <fieldset> with a <legend> (FieldsetLegend).
5510
+ - The section rail is a labelled <nav> ("Settings sections") distinct from the primary navigation; its active item carries aria-current.
5511
+ - Every section has an h2, so screen-reader heading navigation mirrors the rail.
5512
+ - Each Switch takes its row title as aria-label, since the Entity title is not a <label>.
4134
5513
  - Field auto-wires htmlFor/id, aria-describedby (description + error), aria-invalid, and required.
4135
- - The danger action is a real button labeled by its visible text — no icon-only destructive controls.
5514
+ - DestructiveActionModal is an alertdialog: focus opens on Cancel, the backdrop does not dismiss, and the confirm stays disabled until the handle is typed.
4136
5515
 
4137
5516
  ## Source
4138
5517
 
4139
5518
  ```tsx
4140
- import { AppHeader, AppShell, BottomNav, BottomNavItem, Button, Field, Fieldset, FieldsetContent, FieldsetDescription, FieldsetFooter, FieldsetLegend, Form, Icon, Input, Page, PageHeader, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarHeader, SidebarItem, SidebarNav, Switch, Textarea } from "@sableui/react"
5519
+ import { useState } from "react"
5520
+ import { AppHeader, AppShell, Avatar, BottomNav, BottomNavItem, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardList, CardTitle, Col, Columns, DestructiveActionModal, DotsMenu, Entity, Field, Form, Grid, Icon, Input, MenuItem, MenuSeparator, NotificationBadge, Page, PageHeader, Row, Select, SelectContent, SelectItem, SelectTrigger, Sidebar, SidebarFooter, SidebarHeader, SidebarItem, SidebarNav, SideNav, SideNavItem, Stack, Switch, Textarea } from "@sableui/react"
5521
+
5522
+ const sections = [
5523
+ { id: "profile", label: "Profile" },
5524
+ { id: "notifications", label: "Notifications" },
5525
+ { id: "payouts", label: "Payouts" },
5526
+ { id: "account", label: "Account" },
5527
+ ]
5528
+
5529
+ const timezones = [
5530
+ { value: "europe-london", label: "London (GMT+1)" },
5531
+ { value: "europe-paris", label: "Paris (GMT+2)" },
5532
+ { value: "america-new-york", label: "New York (GMT−4)" },
5533
+ { value: "america-los-angeles", label: "Los Angeles (GMT−7)" },
5534
+ { value: "asia-tokyo", label: "Tokyo (GMT+9)" },
5535
+ ]
5536
+
5537
+ const notifications = [
5538
+ { name: "notifyCollects", title: "New collects", description: "When someone collects your work", on: true },
5539
+ { name: "notifySoldOut", title: "Sold out", description: "When an edition sells its last copy", on: true },
5540
+ { name: "notifyFollowers", title: "New followers", description: "When a collector follows you", on: false },
5541
+ { name: "notifyDigest", title: "Weekly digest", description: "Sales and collectors, every Monday", on: true },
5542
+ { name: "notifyProduct", title: "Product updates", description: "New Atelier features", on: false },
5543
+ ]
5544
+
5545
+ const payouts = [
5546
+ { label: "Next payout", value: "$1,284.60", note: "Friday, Oct 16" },
5547
+ { label: "Pending", value: "$412.00", note: "Clears in 7 days" },
5548
+ { label: "Paid this year", value: "$18,920.00", note: "Across 214 collects" },
5549
+ ]
4141
5550
 
4142
5551
  export function SettingsPage() {
5552
+ const [activeSection, setActiveSection] = useState("profile")
5553
+ const [deleteOpen, setDeleteOpen] = useState(false)
5554
+
4143
5555
  return (
4144
5556
  <AppShell
4145
5557
  // Sidebar hides below lg, so the same destinations ride in the shell's
4146
5558
  // mobile slots — without them the route has no navigation under 1024px.
4147
- header={<AppHeader title={<span className="text-title-sm">Atelier</span>} />}
5559
+ header={
5560
+ <AppHeader
5561
+ title={
5562
+ <Row gap={1} align="center">
5563
+ <Icon name="gem" variant="solid" className="text-xl" />
5564
+ <span className="text-title-sm">Atelier</span>
5565
+ </Row>
5566
+ }
5567
+ actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
5568
+ />
5569
+ }
4148
5570
  bottomNav={
4149
5571
  <BottomNav aria-label="Primary">
4150
5572
  <BottomNavItem icon={<Icon name="grid" />} label="Overview" showLabel render={<a href="/" />} />
@@ -4157,78 +5579,243 @@ export function SettingsPage() {
4157
5579
  sidebar={
4158
5580
  <Sidebar>
4159
5581
  <SidebarHeader>
4160
- <span className="text-title-sm">Atelier</span>
5582
+ <Row gap={1} align="center" className="px-3">
5583
+ <Icon name="gem" variant="solid" className="text-xl" />
5584
+ <span className="text-title-sm">Atelier</span>
5585
+ </Row>
4161
5586
  </SidebarHeader>
4162
5587
  <SidebarNav>
4163
5588
  <SidebarItem icon={<Icon name="grid" />} label="Overview" render={<a href="/" />} />
4164
5589
  <SidebarItem icon={<Icon name="image-stack" />} label="Editions" render={<a href="/editions" />} />
4165
5590
  <SidebarItem icon={<Icon name="user" />} label="Collectors" render={<a href="/collectors" />} />
4166
- <SidebarItem icon={<Icon name="bell" />} label="Activity" render={<a href="/activity" />} />
5591
+ <SidebarItem
5592
+ icon={<Icon name="bell" />}
5593
+ label="Activity"
5594
+ badge={<NotificationBadge count={4} tone="neutral" />}
5595
+ render={<a href="/activity" />}
5596
+ />
4167
5597
  <SidebarItem icon={<Icon name="gear" />} label="Settings" active render={<a href="/settings" />} />
4168
5598
  </SidebarNav>
5599
+ {/* Entity and DotsMenu ink with page tokens; a theme can paint the rail
5600
+ dark on a light page, so this subtree reads the rail's ink instead. */}
5601
+ <SidebarFooter className="[--foreground:var(--sidebar-foreground)]">
5602
+ <Entity
5603
+ className="[--entity-py:0]"
5604
+ leading={<Avatar size="sm" fallback="AL" alt="" />}
5605
+ title="Ada Lovelace"
5606
+ action={
5607
+ <DotsMenu label="Account" size="icon-sm">
5608
+ <MenuItem render={<a href="/@ada" />}>View profile</MenuItem>
5609
+ <MenuItem render={<a href="/settings" />}>Settings</MenuItem>
5610
+ <MenuSeparator />
5611
+ <MenuItem>Sign out</MenuItem>
5612
+ </DotsMenu>
5613
+ }
5614
+ />
5615
+ </SidebarFooter>
4169
5616
  </Sidebar>
4170
5617
  }
4171
5618
  >
4172
5619
  {/* No `as="main"`: AppShell already renders <main id="main-content">. */}
4173
- <Page max="content" gap={4} className="py-6">
5620
+ <Page max="wide" gap={4} className="py-6">
4174
5621
  <PageHeader
4175
5622
  title="Settings"
4176
- description="Your profile, notifications, and account controls."
5623
+ description="Manage your profile, notifications, and payouts."
4177
5624
  />
4178
- <Form onSubmit={(event) => event.preventDefault()}>
4179
- <Fieldset>
4180
- <FieldsetLegend>Profile</FieldsetLegend>
4181
- <FieldsetDescription>How you appear across the app.</FieldsetDescription>
4182
- <FieldsetContent>
4183
- <Field label="Display name" required>
4184
- <Input name="displayName" defaultValue="ada" />
4185
- </Field>
4186
- <Field label="Bio" description="Shown on your public profile.">
4187
- <Textarea name="bio" rows={3} placeholder="Tell collectors who you are" />
4188
- </Field>
4189
- <Field label="Timezone">
4190
- <Select defaultValue="utc" name="timezone">
4191
- <SelectTrigger />
4192
- <SelectContent>
4193
- <SelectItem value="utc">UTC</SelectItem>
4194
- <SelectItem value="est">Eastern (UTC−5)</SelectItem>
4195
- <SelectItem value="pst">Pacific (UTC−8)</SelectItem>
4196
- </SelectContent>
4197
- </Select>
4198
- </Field>
4199
- </FieldsetContent>
4200
- <FieldsetFooter>
4201
- <Button type="submit">Save profile</Button>
4202
- </FieldsetFooter>
4203
- </Fieldset>
4204
- </Form>
4205
- <Form onSubmit={(event) => event.preventDefault()}>
4206
- <Fieldset>
4207
- <FieldsetLegend>Notifications</FieldsetLegend>
4208
- <FieldsetDescription>What we email you about.</FieldsetDescription>
4209
- <FieldsetContent>
4210
- <Field label="New collector alerts" orientation="horizontal">
4211
- <Switch name="collectorAlerts" defaultChecked />
4212
- </Field>
4213
- <Field label="Weekly digest" orientation="horizontal">
4214
- <Switch name="weeklyDigest" />
4215
- </Field>
4216
- </FieldsetContent>
4217
- <FieldsetFooter>
4218
- <Button type="submit">Save notifications</Button>
4219
- </FieldsetFooter>
4220
- </Fieldset>
4221
- </Form>
4222
- <Fieldset>
4223
- <FieldsetLegend>Danger zone</FieldsetLegend>
4224
- <FieldsetDescription>
4225
- Deleting your account removes your posts and collections permanently.
4226
- </FieldsetDescription>
4227
- <FieldsetFooter>
4228
- <Button variant="destructive">Delete account</Button>
4229
- </FieldsetFooter>
4230
- </Fieldset>
5625
+ <Columns gap={4}>
5626
+ {/* The section rail is a desktop aid; on mobile the sections simply stack. */}
5627
+ <Col span={{ base: 12, lg: 3 }} className="hidden lg:block">
5628
+ <SideNav aria-label="Settings sections" className="sticky top-6">
5629
+ {sections.map((section) => (
5630
+ <SideNavItem
5631
+ key={section.id}
5632
+ label={section.label}
5633
+ active={activeSection === section.id}
5634
+ onClick={() => setActiveSection(section.id)}
5635
+ render={<a href={`#${section.id}`} />}
5636
+ />
5637
+ ))}
5638
+ </SideNav>
5639
+ </Col>
5640
+ <Col span={{ base: 12, lg: 9 }}>
5641
+ <Stack gap={3}>
5642
+ <Card id="profile" className="scroll-mt-6">
5643
+ <CardHeader>
5644
+ <CardTitle>
5645
+ <h2>Profile</h2>
5646
+ </CardTitle>
5647
+ <CardDescription>How collectors see you on Atelier.</CardDescription>
5648
+ </CardHeader>
5649
+ {/* The one explicit save on the page: profile text is drafted, then
5650
+ committed. Toggles below apply the moment they change. */}
5651
+ <Form onSubmit={(event) => event.preventDefault()} className="gap-0">
5652
+ <CardContent>
5653
+ <Stack gap={3}>
5654
+ <Row gap={2}>
5655
+ <Avatar size="lg" fallback="AL" alt="Ada Lovelace" />
5656
+ <Stack gap={0.5} align="start">
5657
+ <Row gap={1}>
5658
+ <Button type="button" variant="outline" size="sm">
5659
+ Upload photo
5660
+ </Button>
5661
+ <Button type="button" variant="ghost" size="sm">
5662
+ Remove
5663
+ </Button>
5664
+ </Row>
5665
+ <p className="text-caption text-muted-foreground">
5666
+ Square PNG or JPG, at least 400 px.
5667
+ </p>
5668
+ </Stack>
5669
+ </Row>
5670
+ <Grid cols={{ base: 1, sm: 2 }} gap={3}>
5671
+ <Field label="Display name" name="displayName" required>
5672
+ <Input defaultValue="Ada Lovelace" autoComplete="name" />
5673
+ </Field>
5674
+ <Field label="Handle" name="handle" description="atelier.com/@ada" required>
5675
+ <Input defaultValue="ada" autoComplete="username" spellCheck={false} />
5676
+ </Field>
5677
+ </Grid>
5678
+ <Field label="Bio" name="bio" description="Shown on your profile and edition pages.">
5679
+ <Textarea
5680
+ rows={3}
5681
+ defaultValue="Generative artist working in plotted ink and code. New editions of 25 to 100 every month."
5682
+ />
5683
+ </Field>
5684
+ <Grid cols={{ base: 1, sm: 2 }} gap={3}>
5685
+ <Field label="Timezone" name="timezone" description="Release times use this zone.">
5686
+ <Select defaultValue="europe-london" items={timezones}>
5687
+ <SelectTrigger />
5688
+ <SelectContent>
5689
+ {timezones.map((zone) => (
5690
+ <SelectItem key={zone.value} value={zone.value}>
5691
+ {zone.label}
5692
+ </SelectItem>
5693
+ ))}
5694
+ </SelectContent>
5695
+ </Select>
5696
+ </Field>
5697
+ </Grid>
5698
+ </Stack>
5699
+ </CardContent>
5700
+ <CardFooter className="justify-end">
5701
+ <Button type="submit">Save profile</Button>
5702
+ </CardFooter>
5703
+ </Form>
5704
+ </Card>
5705
+
5706
+ <Card id="notifications" className="scroll-mt-6">
5707
+ <CardHeader>
5708
+ <CardTitle>
5709
+ <h2>Notifications</h2>
5710
+ </CardTitle>
5711
+ <CardDescription>
5712
+ Email updates about your editions. Changes save automatically.
5713
+ </CardDescription>
5714
+ </CardHeader>
5715
+ <CardList>
5716
+ {notifications.map((item) => (
5717
+ <Entity
5718
+ key={item.name}
5719
+ title={item.title}
5720
+ subtitle={item.description}
5721
+ action={
5722
+ <Switch
5723
+ name={item.name}
5724
+ defaultChecked={item.on}
5725
+ aria-label={item.title}
5726
+ />
5727
+ }
5728
+ />
5729
+ ))}
5730
+ </CardList>
5731
+ </Card>
5732
+
5733
+ <Card id="payouts" className="scroll-mt-6">
5734
+ <CardHeader>
5735
+ <CardTitle>
5736
+ <h2>Payouts</h2>
5737
+ </CardTitle>
5738
+ <CardDescription>Paid in USD every Friday to Chase checking ••4821.</CardDescription>
5739
+ <CardAction>
5740
+ <Button variant="outline" size="sm" render={<a href="/settings/payouts" />}>
5741
+ Manage payouts
5742
+ </Button>
5743
+ </CardAction>
5744
+ </CardHeader>
5745
+ <CardContent>
5746
+ <Grid cols={{ base: 1, sm: 3 }} gap={3}>
5747
+ {payouts.map((stat) => (
5748
+ <Stack key={stat.label} gap={0.5}>
5749
+ <p className="text-body-sm text-muted-foreground">{stat.label}</p>
5750
+ <p className="text-mono-lg text-foreground">{stat.value}</p>
5751
+ <p className="text-caption text-muted-foreground">{stat.note}</p>
5752
+ </Stack>
5753
+ ))}
5754
+ </Grid>
5755
+ </CardContent>
5756
+ </Card>
5757
+
5758
+ <Card id="account" className="scroll-mt-6">
5759
+ <CardHeader>
5760
+ <CardTitle>
5761
+ <h2>Account</h2>
5762
+ </CardTitle>
5763
+ <CardDescription>Sign-in details and your data.</CardDescription>
5764
+ </CardHeader>
5765
+ <CardList>
5766
+ <Entity
5767
+ leading={<Icon name="mail" className="text-lg text-muted-foreground" />}
5768
+ title="Email"
5769
+ subtitle="ada@atelier.art"
5770
+ action={
5771
+ <Button variant="outline" size="sm">
5772
+ Change
5773
+ </Button>
5774
+ }
5775
+ />
5776
+ <Entity
5777
+ leading={<Icon name="download" className="text-lg text-muted-foreground" />}
5778
+ title="Export data"
5779
+ subtitle="Editions, sales, and collectors"
5780
+ action={
5781
+ <Button variant="outline" size="sm">
5782
+ Export
5783
+ </Button>
5784
+ }
5785
+ />
5786
+ </CardList>
5787
+ </Card>
5788
+
5789
+ <Card>
5790
+ <CardHeader>
5791
+ <CardTitle>
5792
+ <h2>Delete account</h2>
5793
+ </CardTitle>
5794
+ <CardDescription>
5795
+ Permanently remove your profile, editions, and sales history. Collectors keep
5796
+ the editions they own.
5797
+ </CardDescription>
5798
+ </CardHeader>
5799
+ <CardFooter className="justify-end">
5800
+ <Button variant="destructive" onClick={() => setDeleteOpen(true)}>
5801
+ Delete account
5802
+ </Button>
5803
+ </CardFooter>
5804
+ </Card>
5805
+ </Stack>
5806
+ </Col>
5807
+ </Columns>
4231
5808
  </Page>
5809
+ <DestructiveActionModal
5810
+ open={deleteOpen}
5811
+ onOpenChange={setDeleteOpen}
5812
+ title="Delete your account?"
5813
+ description="This permanently removes your profile, 12 editions, and sales history. Pending payouts are still sent. This can't be undone."
5814
+ confirmLabel="Delete account"
5815
+ confirmingLabel="Deleting…"
5816
+ confirmText="ada"
5817
+ onConfirm={() => setDeleteOpen(false)}
5818
+ />
4232
5819
  </AppShell>
4233
5820
  )
4234
5821
  }
@@ -5820,7 +7407,7 @@ import { Browser, Region } from "@sableui/react"
5820
7407
 
5821
7408
  **Status:** stable
5822
7409
 
5823
- The surface a message's content sits on: four treatments, an align-aware tail corner, a readable measure cap, and an optional row of reaction pills.
7410
+ The surface a message's content sits on: four treatments, an align-aware tail, a readable measure cap, and an optional row of reaction pills.
5824
7411
 
5825
7412
  **Keywords:** chat bubble, message bubble, speech bubble, reactions, conversation, messaging, ai chat
5826
7413
 
@@ -5832,7 +7419,7 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
5832
7419
 
5833
7420
  - Do: Use `muted` for the other party and `primary` for the reader's own messages. Two treatments is the whole vocabulary a conversation needs to say who is speaking.
5834
7421
  - Do: Reach for `variant="plain"` for long-form assistant output — rendered markdown, code blocks, tables. A tinted box around three paragraphs reads as a quote block rather than as a turn, and `plain` drops the measure cap with the surface.
5835
- - Do: Pass the same `align` you gave the enclosing Message, so the flattened corner lands on the speaker's side.
7422
+ - Do: Pass the same `align` you gave the enclosing Message, so the tail lands on the speaker's side.
5836
7423
  - Do: Set `tail={false}` on the continuation bubbles in a grouped run — the tail marks where a speaker starts, and repeating it on every line makes a block of one person's messages look like four separate turns.
5837
7424
  - Do: Let the register size it. Inside `<Density value="compact">` a Bubble sets itself in the workspace prose tier automatically — a docked agent panel needs no per-call-site sizing, and hardcoding one defeats the mechanism.
5838
7425
  - Don't: Do not remove the measure cap to fill a wide window. ~42rem is a reading measure; a bubble spanning a 1600px viewport is a paragraph at 140 characters per line.
@@ -5844,7 +7431,7 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
5844
7431
 
5845
7432
  | Part | Required | Description |
5846
7433
  | --- | --- | --- |
5847
- | Bubble | yes | The surface itself. Holds the variant, the side, and the flattened tail corner. |
7434
+ | Bubble | yes | The surface itself. Holds the variant, the side, and the tail. |
5848
7435
  | BubbleReactions | no | The row of reaction pills, just under the bubble at the message column's small gap, so it reads as belonging to that message rather than the next row. |
5849
7436
  | BubbleReaction | no | One reaction pill — emoji, count (hidden at 1), and pressed state. |
5850
7437
 
@@ -5860,8 +7447,8 @@ The tinted (or untinted) surface a turn's content sits on. Kept separate from Me
5860
7447
  | Name | Type | Default | Description |
5861
7448
  | --- | --- | --- | --- |
5862
7449
  | variant | "muted" \| "primary" \| "outline" \| "plain" | "muted" | Surface treatment. `muted` for the other party, `primary` for the reader's own messages, `outline` for a quieter own-side, `plain` for long-form output that should not sit in a box (also drops the measure cap and padding). |
5863
- | align | "start" \| "end" | "start" | Which side of the conversation the bubble is on — flattens the corner nearest the speaker. Match it to the enclosing Message. |
5864
- | tail | boolean | true | Flatten the speaker-side corner. A flattened corner rather than a drawn arrow, so it holds up in every theme's radius language (flat through fully pilled) with no per-theme geometry. Turn it off for the continuation bubbles in a grouped run. |
7450
+ | align | "start" \| "end" | "start" | Which side of the conversation the bubble is on — puts the tail on the speaker's side. Match it to the enclosing Message. |
7451
+ | tail | boolean | true | Draw the tail: a small ear off the speaker-side top corner, which goes square to meet it. The same 5px in every theme's radius language, flat through fully pilled. The bubble keeps the ear's space either way, so turning it off for the continuation bubbles in a grouped run keeps the run aligned. |
5865
7452
  | ...div props | ComponentPropsWithRef<"div"> | — | Spread onto the surface element. |
5866
7453
 
5867
7454
  ## Variants
@@ -6709,7 +8296,7 @@ Use CheckboxCard when an option needs more visual weight, supporting text, or ic
6709
8296
  | onCheckedChange | (checked: boolean, eventDetails) => void | — | Fires with the next checked state (Base UI passes eventDetails second). Pair with `checked` for a controlled card; omit both to let it manage its own state. |
6710
8297
  | title | ReactNode | — | The card's primary label (text-title-sm). |
6711
8298
  | description | ReactNode | — | Optional supporting copy beneath the title (text-body-sm, muted). |
6712
- | icon | ReactNode | — | Optional leading glyph (a solid <Icon>); takes the tone colour. |
8299
+ | icon | ReactNode | — | Optional glyph (a solid <Icon>) in the tone colour. Its presence switches to the two-dimensional card RadioCard uses — icon top-left, checkbox top-right, copy below — so the two cards match in one form. |
6713
8300
  | tone | "neutral" \| "standard" \| "collectible" \| "edition" \| "info" \| "warning" | "neutral" | Semantic colour the checked state carries across the border, checkbox box, and icon. |
6714
8301
  | value | string | — | Names the card inside a group-owned CheckboxGroup: the card is checked when the group's value array includes it. Also the value submitted with a form. Inside a CheckboxGroup, `name` stands in for `value` when `value` is omitted. |
6715
8302
  | checked / onCheckedChange / defaultChecked / disabled | Base UI Checkbox.Root props | — | Standard checkbox state. Without a group-owned value, each card is independent — drive checked/onCheckedChange per item for multi-select. |
@@ -7459,9 +9046,9 @@ import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger, Entity } f
7459
9046
  ### With stat row
7460
9047
 
7461
9048
  ```tsx
7462
- import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger, Description, DescriptionItem, Entity } from "@sableui/react"
9049
+ import { Avatar, ContextCard, ContextCardContent, ContextCardTrigger, Entity } from "@sableui/react"
7463
9050
 
7464
- <ContextCard><ContextCardTrigger render={<a href='/u/nyx' className='text-title-sm underline-offset-2 hover:underline'>@nyx</a>} /><ContextCardContent><Entity className='[--entity-px:0] [--entity-py:0]' leading={<Avatar fallback='NY' size='lg' />} title='Nyx' subtitle='@nyx' /><Description cols='2' className='mt-4 border-t border-border pt-3'><DescriptionItem term='Posts' detail='128' /><DescriptionItem term='Followers' detail='4.2k' /></Description></ContextCardContent></ContextCard>
9051
+ <ContextCard><ContextCardTrigger render={<a href='/u/nyx' className='text-title-sm underline-offset-2 hover:underline'>@nyx</a>} /><ContextCardContent><Entity className='[--entity-px:0] [--entity-py:0]' leading={<Avatar fallback='NY' size='lg' />} title='Nyx' subtitle='@nyx' /><div className='@container mt-4 border-t border-border pt-3'><dl className='sable-list-dl sable-list-dl-2'><dt>Posts</dt><dd>128</dd><dt>Followers</dt><dd>4.2k</dd></dl></div></ContextCardContent></ContextCard>
7465
9052
  ```
7466
9053
 
7467
9054
  ---
@@ -7750,105 +9337,6 @@ import { DatePicker } from "@sableui/react"
7750
9337
 
7751
9338
  ---
7752
9339
 
7753
- # Description
7754
-
7755
- **Status:** stable
7756
-
7757
- A labeled key/value list (definition list). Compose with DescriptionTerm + DescriptionDetail, or the ergonomic DescriptionItem shorthand. Aligns terms and details into a grid; cols="2" packs pairs two-wide.
7758
-
7759
- **Keywords:** description list, metadata, details, key value
7760
-
7761
- ## Usage
7762
-
7763
- Use Description to present label-value details where each term needs a clear corresponding value.
7764
-
7765
- ### Best practices
7766
-
7767
- - Do: Use it for object metadata, settings summaries, and read-only record details.
7768
- - Do: Keep terms brief and values scannable so the list works as reference material.
7769
- - Don't: Do not use Description for editable forms; pair Label and form controls instead.
7770
- - Don't: Do not use it for a single key/value. A definition list of one is a labelled value — use a Label and the value, and keep the list semantics for actual lists.
7771
-
7772
- ### Anatomy
7773
-
7774
- | Part | Required | Description |
7775
- | --- | --- | --- |
7776
- | DescriptionItem | yes | One term and detail pair. |
7777
- | DescriptionTerm | yes | The label for the value. |
7778
- | DescriptionDetail | yes | The value or explanatory detail. |
7779
-
7780
- ## Subcomponents
7781
-
7782
- | Name | Type | Description |
7783
- | --- | --- | --- |
7784
- | DescriptionTerm | <dt> props + className | The key. Muted label tier; sits in the first grid column. |
7785
- | DescriptionDetail | <dd> props + tone?: "default" \| "muted" \| "success" \| "warning" \| "destructive" + className | The value. Body tier on the foreground; sits in the second grid column. tone colors the value to encode a status (e.g. Ended → muted, Active → success), replacing inline conditional color classes; defaults to "default". |
7786
- | DescriptionItem | { term: ReactNode; detail?: ReactNode; tone?: "default" \| "muted" \| "success" \| "warning" \| "destructive"; children? } | Ergonomic shorthand for one term/detail pair — renders a DescriptionTerm followed by a DescriptionDetail. Pass the value via detail or children; tone (defaults to "default") colors the detail value the same way it does on DescriptionDetail. |
7787
-
7788
- ## Props
7789
-
7790
- | Name | Type | Default | Description |
7791
- | --- | --- | --- | --- |
7792
- | cols | "1" \| "2" | "1" | Column density. One term/detail column, or two pairs per row once the list's container is at least @md (28rem) wide; narrower, it stays one column. A container query, not a viewport breakpoint, so a narrow card on a wide screen stays one column. With cols="2" the <dl> is wrapped in an @container div. |
7793
-
7794
- ## Variants
7795
-
7796
- - **cols**: 1, 2
7797
- - **tone**: default, muted, success, warning, destructive
7798
-
7799
- ## Tokens used
7800
-
7801
- `muted-foreground`, `foreground`, `success`, `tone-warning`, `destructive`
7802
-
7803
- ## Accessibility
7804
-
7805
- - Renders semantic <dl>/<dt>/<dd> elements so assistive tech announces the term/detail relationship.
7806
- - tone is a visual emphasis only — keep the value text itself meaningful (e.g. "Ended"), never color alone.
7807
-
7808
- ## Related
7809
-
7810
- - Components: Fieldset, Entity
7811
-
7812
- ## Examples
7813
-
7814
- ### Metadata list
7815
-
7816
- ```tsx
7817
- import { Description, DescriptionItem } from "@sableui/react"
7818
-
7819
- <Description>
7820
- <DescriptionItem term="Network" detail="Solana" />
7821
- <DescriptionItem term="Supply" detail="100" />
7822
- <DescriptionItem term="Royalty" detail="5%" />
7823
- </Description>
7824
- ```
7825
-
7826
- ### Composed term/detail
7827
-
7828
- ```tsx
7829
- import { Description, DescriptionDetail, DescriptionTerm } from "@sableui/react"
7830
-
7831
- <Description>
7832
- <DescriptionTerm>Network</DescriptionTerm>
7833
- <DescriptionDetail>Solana</DescriptionDetail>
7834
- <DescriptionTerm>Contract</DescriptionTerm>
7835
- <DescriptionDetail className="text-mono-sm">7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU</DescriptionDetail>
7836
- </Description>
7837
- ```
7838
-
7839
- ### Toned status value
7840
-
7841
- ```tsx
7842
- import { Description, DescriptionItem } from "@sableui/react"
7843
-
7844
- <Description>
7845
- <DescriptionItem term="Edition" detail="Midnight Archive" />
7846
- <DescriptionItem term="Status" detail={isEnded ? "Ended" : "Active"} tone={isEnded ? "muted" : "success"} />
7847
- </Description>
7848
- ```
7849
-
7850
- ---
7851
-
7852
9340
  # DestructiveActionModal
7853
9341
 
7854
9342
  **Status:** stable
@@ -7968,7 +9456,7 @@ Use Dialog for focused decisions, short modal forms, confirmations, and content
7968
9456
  | Name | Type | Description |
7969
9457
  | --- | --- | --- |
7970
9458
  | DialogContent | Base UI Popup props + size?: "default" \| "fullscreen" + showCloseButton?: boolean + scroll?: boolean + className | Backdrop + popup; portals to <body>. Composes DialogOverlay by default. size="fullscreen" is the media viewer: a near-opaque scrim (bg-overlay/95), an edge-to-edge popup that centres its children with no card chrome, motion-fade instead of motion-pop, and a safe-area-aware floating close button (Button variant="floating"); at that size showCloseButton defaults to true and scroll to false, and the media fits itself with max-h-full max-w-full object-contain. At the default size, showCloseButton (defaults to false) renders a built-in close (X) button in the top-end corner, and the header leaves room for it (Icon name="xmark"). The popup caps at the viewport height less a 1rem margin and wraps its children in a scrolling region, so a tall dialog scrolls instead of overflowing a viewport whose own scroll is locked; pass scroll={false} when the dialog owns its internal layout (a pinned header over a scrolling body) and supplies its own min-h-0 scroll region. |
7971
- | DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/50 by default, near-opaque bg-overlay/95 at size="fullscreen". Composed into DialogContent (at the matching size) by default; exported for manual styling. |
9459
+ | DialogOverlay | Base UI Backdrop props + size?: "default" \| "fullscreen" + className | The styleable scrim/backdrop — bg-overlay/(--scrim-alpha) by default (50% light, 72% dark, so it dims a near-black page as visibly as a white one), near-opaque bg-overlay/95 at size="fullscreen". Composed into DialogContent (at the matching size) by default; exported for manual styling. |
7972
9460
  | DialogTrigger | Base UI Dialog.Trigger props (render) | Element that opens the dialog. Compose via render prop. |
7973
9461
  | DialogClose | Base UI Dialog.Close props (render) | Element that dismisses the dialog. Compose via render prop. |
7974
9462
  | DialogHeader | ComponentProps<"div"> | Top section holding DialogTitle/DialogDescription. |
@@ -8129,7 +9617,7 @@ import { DotsMenu, Icon, MenuItem, MenuSeparator } from "@sableui/react"
8129
9617
 
8130
9618
  **Status:** stable
8131
9619
 
8132
- The centered empty / error block: optional icon disc or error code, title, description and an action. tone="destructive" makes it an announced error, size="lg" a page-level state, surface="card" a self-contained card.
9620
+ The centered empty / error block: optional icon or error code, title, description and an action. tone="destructive" makes it an announced error, size="lg" a page-level state, surface="card" a self-contained card.
8133
9621
 
8134
9622
  **Keywords:** empty, zero state, blank slate, no results, placeholder, onboarding, no data
8135
9623
 
@@ -8160,12 +9648,12 @@ Use EmptyState when a content area has no items, no search results, or no first-
8160
9648
 
8161
9649
  | Name | Type | Default | Description |
8162
9650
  | --- | --- | --- | --- |
8163
- | icon | ReactNode | — | Optional leading glyph — pass an `<Icon />`. Rendered in a soft muted disc. |
9651
+ | icon | ReactNode | — | Optional glyph above the title — an `<Icon />` takes the muted ink at 3xl; an image or illustration renders as given. |
8164
9652
  | title | ReactNode | — | The headline. Short and direct. |
8165
9653
  | description | ReactNode | — | Optional supporting line beneath the title. |
8166
9654
  | action | ReactNode | — | Optional action below the copy — typically a `<Button />`. |
8167
9655
  | code | ReactNode | — | A large figure above the title, such as an error code (404). |
8168
- | tone | "neutral" \| "destructive" | "neutral" | destructive is an error state: a destructive icon disc (a warning by default) and role="alert" so it is announced. |
9656
+ | tone | "neutral" \| "destructive" | "neutral" | destructive is an error state: a destructive icon (a warning by default) and role="alert" so it is announced. |
8169
9657
  | size | "md" \| "lg" | "md" | lg is page-level: the title is an h2 and the block takes more room. md sits in a panel, list or card. |
8170
9658
  | surface | "none" \| "card" | "none" | card draws it as a Card, for an error or empty block standing on its own in a page. |
8171
9659
 
@@ -8181,7 +9669,7 @@ Use EmptyState when a content area has no items, no search results, or no first-
8181
9669
 
8182
9670
  ## Accessibility
8183
9671
 
8184
- - Decorative icon discs should not carry meaning the title doesn't already convey.
9672
+ - Decorative icons should not carry meaning the title doesn't already convey.
8185
9673
  - Pair with an actionable next step so the zero-state is a path forward, not a dead end.
8186
9674
 
8187
9675
  ## Related
@@ -8289,7 +9777,7 @@ Use Entity to show a compact identity block for a person, organization, file, pr
8289
9777
 
8290
9778
  ## Related
8291
9779
 
8292
- - Components: Avatar, Badge, Description
9780
+ - Components: Avatar, Badge
8293
9781
 
8294
9782
  ## Examples
8295
9783
 
@@ -8435,7 +9923,7 @@ Use Fieldset to group related controls under a shared label or explanation.
8435
9923
 
8436
9924
  ## Related
8437
9925
 
8438
- - Components: Form, Label, Description
9926
+ - Components: Form, Label
8439
9927
 
8440
9928
  ## Examples
8441
9929
 
@@ -8992,7 +10480,7 @@ import { Input } from "@sableui/react"
8992
10480
 
8993
10481
  **Status:** stable
8994
10482
 
8995
- A keyboard key indicator. Renders a styled <kbd> key cap in monospace; compose several with a + between to show a shortcut combo. `mod` renders the platform modifier (⌘ or Ctrl).
10483
+ A keyboard key indicator. Renders a raised <kbd> key cap sized from the surrounding text, so one Kbd fits body copy, menus and tooltips; KbdGroup sets one cap per key for a combo. `mod` renders the platform modifier (⌘ or Ctrl); `variant="ghost"` is the bare label for menu rows and tooltips.
8996
10484
 
8997
10485
  **Keywords:** keyboard, shortcut, key, kbd
8998
10486
 
@@ -9003,8 +10491,10 @@ Use Kbd to display keyboard keys or shortcuts in help text, menus, and docs.
9003
10491
  ### Best practices
9004
10492
 
9005
10493
  - Do: Use it only for literal keyboard input such as Cmd, Enter, or Esc.
9006
- - Do: Keep multi-key shortcuts readable by separating individual keys.
10494
+ - Do: Show a combo as one cap per key inside KbdGroup — `<KbdGroup><Kbd mod /><Kbd>K</Kbd></KbdGroup>` — not several keys typed into one cap.
10495
+ - Do: Use `variant="ghost"` where the shortcut sits on a surface of its own — a menu row's trailing slot, a tooltip — so it reads as a hint, not a second control.
9007
10496
  - Don't: Do not use Kbd as a decorative badge or button replacement.
10497
+ - Don't: Do not set a text size on a Kbd. It sizes itself from the text around it.
9008
10498
  - Don't: Do not hardcode ⌘ or Ctrl. The modifier differs by platform; use `<Kbd mod />`, which renders ⌘ on macOS/iOS and Ctrl elsewhere, or Windows users read Mac keys.
9009
10499
 
9010
10500
  ## Props
@@ -9012,17 +10502,19 @@ Use Kbd to display keyboard keys or shortcuts in help text, menus, and docs.
9012
10502
  | Name | Type | Default | Description |
9013
10503
  | --- | --- | --- | --- |
9014
10504
  | mod | boolean | false | Render the platform's shortcut modifier in place of children: ⌘ on macOS and iOS, Ctrl everywhere else. Server-rendered as Ctrl and corrected on hydration. |
10505
+ | variant | "key" \| "ghost" | "key" | `key`: a raised cap on the surface it sits on, with a heavier bottom edge as the key's lip. `ghost`: the bare label, for menu rows, tooltips and other surfaces that are already a frame. |
9015
10506
  | className | string | — | Merge additional classes onto the key cap. |
9016
10507
  | ...props | ComponentPropsWithRef<"kbd"> | — | All native <kbd> attributes (children, ref, etc.). |
9017
10508
 
9018
10509
  ## Tokens used
9019
10510
 
9020
- `border`, `muted`, `muted-foreground`
10511
+ `border`, `border-strong`, `background`, `muted-foreground`
9021
10512
 
9022
10513
  ## Accessibility
9023
10514
 
9024
10515
  - Uses the semantic <kbd> element so assistive tech announces keyboard input.
9025
- - Monospace key text keeps single keys and symbols legible at small sizes.
10516
+ - KbdGroup is itself a <kbd> around the per-key caps — the HTML spelling of one key combination — so the combo is announced as a single piece of input.
10517
+ - Sized in em from the surrounding text, so it scales with the user's text size and never shrinks below 12px.
9026
10518
 
9027
10519
  ## Related
9028
10520
 
@@ -9041,9 +10533,17 @@ import { Kbd } from "@sableui/react"
9041
10533
  ### Combo
9042
10534
 
9043
10535
  ```tsx
9044
- import { Kbd, Row } from "@sableui/react"
10536
+ import { Kbd, KbdGroup } from "@sableui/react"
10537
+
10538
+ <KbdGroup><Kbd mod /><Kbd>K</Kbd></KbdGroup>
10539
+ ```
10540
+
10541
+ ### In a menu row
10542
+
10543
+ ```tsx
10544
+ import { Kbd, MenuItem } from "@sableui/react"
9045
10545
 
9046
- <Row gap={1}><Kbd mod /><span>+</span><Kbd>K</Kbd></Row>
10546
+ <MenuItem>Search<KbdGroup className="ms-auto"><Kbd variant="ghost" mod /><Kbd variant="ghost">K</Kbd></KbdGroup></MenuItem>
9047
10547
  ```
9048
10548
 
9049
10549
  ---
@@ -10764,7 +12264,7 @@ Use Pill for a row of filters or choices a person clicks to narrow or toggle wha
10764
12264
  | --- | --- | --- | --- |
10765
12265
  | selected | boolean | false | Marks the pill as chosen. Solid lifts onto the primary surface; outline takes a primary tint and soft primary edge. Sets aria-pressed. |
10766
12266
  | appearance | "solid" \| "outline" | "solid" | solid for a filter row; outline for quiet chips that annotate other content (a reaction under a message, a tag on a card), where a filled chip would compete with what it annotates. |
10767
- | size | "md" \| "sm" | "md" | sm sits on the density ladder's xs control height, for chips under or inside other content. |
12267
+ | size | "md" \| "sm" | "md" | md sits on the density ladder's xs control height (28px); sm is 24px, for chips under or inside other content. |
10768
12268
  | icon | ReactNode | — | Optional leading glyph (e.g. <Icon name="lock" />), rendered before the children with the base gap. |
10769
12269
 
10770
12270
  ## Variants
@@ -12113,7 +13613,7 @@ Use SideNav for vertical navigation inside a settings page, documentation area,
12113
13613
  - Do: Use aria-label on SideNav when a page contains more than one nav landmark.
12114
13614
  - Do: Group related items with SideNavGroup when the rail has more than one conceptual area.
12115
13615
  - Do: Use size='lg' and activeVariant='sidebar' when the nav is part of persistent app chrome.
12116
- - Do: Inside a rounded, padded frame, give each item rounded-concentric-<outer>/<inset> (e.g. rounded-concentric-lg/2 in a rounded-lg p-2 box). The rows' own radius is fixed per size, and a same-radius row inset into a frame reads as a second system.
13616
+ - Do: Rows are rounded-sm, which nests in the usual rounded-lg p-2 frame. In any other rounded, padded frame give each item rounded-concentric-<outer>/<inset> — a same-radius row inset into a frame reads as a second system.
12117
13617
  - Don't: Use SideNav for bottom mobile navigation; use BottomNav in AppShell's bottomNav slot.
12118
13618
  - Don't: Mix content and sidebar active variants in the same rail unless individual items intentionally override the group.
12119
13619
 
@@ -12239,7 +13739,7 @@ Use Skeleton to reserve space and hint at the shape of content while data is loa
12239
13739
  - **arrival** · Root · _no motion_ — A skeleton stands in for content that has not arrived; fading the placeholder in adds a wait before the wait.
12240
13740
  - **response** · Root · _no motion_ — Nothing here is interactive. A skeleton that reacted to the pointer would invite a click on a thing that cannot be clicked.
12241
13741
  - **transition** · Root · _no motion_ — The skeleton has one state. Swapping it for real content is the parent's job, and the parent decides whether that swap is animated.
12242
- - **activity** · Root · `motion-shimmer` — The sweep is the entire point of the component: it is what separates 'still loading' from 'loaded, and empty'. A static grey block is indistinguishable from a broken layout, which is why users reload the page.
13742
+ - **activity** · Root · `motion-shimmer` — The sweep is the entire point of the component: it is what separates 'still loading' from 'loaded, and empty'. A static grey block is indistinguishable from a broken layout, which is why users reload the page. It is one band of light against the viewport, so every skeleton in a loading layout lights in sequence as a single wave rather than each block flashing at its own speed; each pass eases in, glides and settles, then rests off-screen before the next.
12243
13743
 
12244
13744
  ## Accessibility
12245
13745
 
@@ -13258,7 +14758,7 @@ Use ThemeSelector wherever a person picks a theme. The default `cards` layout is
13258
14758
 
13259
14759
  ### Best practices
13260
14760
 
13261
- - Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme's swatch strip, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (a Popover, or `sable-frame sable-surface sable-surface-popover sable-frame-popover`) and their corners nest concentrically.
14761
+ - Do: Use layout="list" in a popover, a menu, or a docked panel. Rows sit on the --control-sm ladder and still show each theme's swatch strip, so the choice stays visible rather than described. Rows take `sable-frame-inner`, so host the list in a sable-frame (`sable-frame sable-surface sable-surface-popover sable-frame-popover` — PopoverContent is not one; give it that class list, or its p-4 leaves the rows unnested) and their corners nest concentrically.
13262
14762
  - Do: Pass mode={useSableTheme().mode} so every preview renders in the colour mode actually in use, rather than in each theme's manifest default.
13263
14763
  - Do: Load each theme's fonts in the host app. Every card carries a type specimen in that theme's own face so people can compare typography as well as color. A theme whose fonts are not loaded falls back to system-ui, and its specimen stops telling the truth.
13264
14764
  - Don't: Add a viewport breakpoint to force a column count — `className="sm:grid-cols-1"` or similar. `sm:` keys off the VIEWPORT, not this component's container, so on a wide screen it does nothing for a narrow parent. The cards grid already reflows on its own width via auto-fit.
@@ -14452,7 +15952,7 @@ import { PanelGroup, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/r
14452
15952
 
14453
15953
  Which token to reach for, and what it is most often confused with. Generated from `src/themes/token-docs.ts`; the full data is `registry.json → tokens`.
14454
15954
 
14455
- ### semantic (44)
15955
+ ### semantic (45)
14456
15956
 
14457
15957
  - **`background`** — The page. The furthest-back surface in the page register. *Use for:* The document body, and any surface meant to read as 'the page itself'. Never anything that floats: background is the BOTTOM of the surface stack (background < card < popover in every dark mode), so a floating panel painted with it reads as a hole beneath the cards it hovers over.
14458
15958
  - **Not** `popover` for anything floating, `card` for a bounded block on the page. `canvas`. `canvas` is the *work* surface in a tool — the artboard being edited — which is not the page behind the UI. Using `background` for an artboard makes the thing being edited indistinguishable from the chrome around it.
@@ -14474,8 +15974,10 @@ Which token to reach for, and what it is most often confused with. Generated fro
14474
15974
  - **Not** `border-strong`. Reach for `border-strong` when the line carries *structure* (a grid rule, a table divider, a section separator) and for `border` when it merely bounds a control.
14475
15975
  - **`border-strong`** — A structural divider. Visibly stronger than `border`. *Use for:* Grid lines, table rules, separators between major sections.
14476
15976
  - **Not** `border`. If removing the line would make the layout ambiguous, it is structural and belongs here.
14477
- - **`input`** — The outline of a form control specifically. *Use for:* Text inputs, textareas, selects, and the resting edge of a checkbox or radio.
15977
+ - **`input`** — The outline of a form control specifically. *Use for:* Text inputs, textareas and selects. Not the edge of a checkbox, radio or switch — that is `control-border`.
14478
15978
  - **Not** `border`. They are often the same value, but a theme is entitled to make form controls read differently from other chrome, and only this token gives it the hook.
15979
+ - **`control-border`** — The resting edge of a control whose outline is the whole control. Held to 3:1 (WCAG 1.4.11) on every surface. *Use for:* An unchecked checkbox, an unselected radio, an off switch track, a selection card's check or radio mark — anywhere removing the edge would make the control disappear.
15980
+ - **Not** `input`. A text field is also found by its fill, caret and placeholder, so its outline may stay quiet; a checkbox has nothing else, and at `input`'s strength it measures about 1.4:1.
14479
15981
  - **`ring`** — The focus indicator colour. *Use for:* Only for `focus-visible`. Nothing else.
14480
15982
  - **Not** `primary`. Focus must remain distinguishable from selection; if the ring is the same colour as a selected state, a keyboard user cannot tell where focus is.
14481
15983
  - **`primary`** — The one high-emphasis action colour. *Use for:* The single most important action on a surface — one per view. A submit button, the primary CTA.
@@ -14803,7 +16305,7 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
14803
16305
  - **Instead of** `bg-muted`, which is fixed: inside a popover it can sit within a step of the panel in some dark modes. And never a hand-mixed `color-mix(… var(--foreground) …)` or an outline on every well to compensate — that turns a panel into a stack of boxes. For a segmented control itself, use `ToggleGroup`; the well is for what no component draws.
14804
16306
  - `<div className="sable-well rounded-md p-1">…</div>`
14805
16307
 
14806
- ### frame (6)
16308
+ ### frame (7)
14807
16309
 
14808
16310
  - **`sable-frame`** — A bordered, padded, rounded container that computes the radius its children need: `--sable-frame-inner-radius` = radius − padding − border width, floored at `radius-sm`. *Use for:* A container whose children have their own rounded backgrounds (rows, tiles, a nested preview), so the corners nest concentrically and keep doing so when the radius ladder is rescaled. Defaults: `radius-lg`, `--spacing(4)` padding (1rem at the default scale, and it follows the density control), a 1px edge from the surface (`--sable-surface-edge`, else `border`). Size it with `sable-frame-r-*` / `sable-frame-p-*`; children take `sable-frame-inner`.
14809
16311
  - **Instead of** `rounded-lg p-4 border` on the parent plus a guessed `rounded-sm` on each child — the guess is concentric only at the default ladder. For a one-off nested corner without a frame, `rounded-concentric-*`.
@@ -14811,6 +16313,9 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
14811
16313
  - **`sable-frame-popover`** — The popup frame recipe: `radius-md`, `--spacing(1)` padding, the `border-floating` edge and `shadow-lg`, applied over `sable-frame`. *Use for:* A hand-composed floating list or panel that must match Sable's menus (with `sable-frame sable-surface sable-surface-popover`); rows inside take `sable-frame-inner`.
14812
16314
  - **Instead of** Copying a Menu's class string. And use `Menu`, `Select`, `Popover` or `Combobox` first — they own focus and dismissal; this is only for floating chrome no component covers.
14813
16315
  - `<div className="sable-frame sable-surface sable-surface-popover sable-frame-popover">…</div>`
16316
+ - **`sable-frame-tool`** — The frame for edge-to-edge tool chrome at the theme's radius, with no padding. It publishes concentric geometry for what's inside: `--panel-inset` (row side padding, grown so rows clear the corner), `--panel-field-radius` (compact fields curve parallel to the frame) and `--panel-edge` (the first and last section's extra top and bottom). PanelSection and the xs field shell read them. Applied over `sable-frame`. *Use for:* Framing PanelSections, ScrubFields, a Tree, a StatusBar or a PanelGroup outside a Workspace (a docs demo, a floating inspector). Add `overflow-hidden` to clip to the corners.
16317
+ - **Instead of** `rounded-lg border overflow-hidden` by hand: the panel keeps its square-column 8px inset and small field radius, so a rounded preset crowds the first label and last field into the curve.
16318
+ - `<div className="sable-frame sable-frame-tool overflow-hidden bg-panel" data-density="compact"><PanelSection title="Layer">…</PanelSection></div>`
14814
16319
  - **`sable-frame-borderless`** — Sets a `sable-frame`'s border width to 0 while keeping the inner-radius math correct. *Use for:* A frame that separates by fill or space rather than an edge (a well, a tinted group) but still needs concentric children.
14815
16320
  - **Instead of** `border-0` on a `sable-frame`, which removes the border but leaves 1px in the inner-radius calculation.
14816
16321
  - `<div className="sable-frame sable-frame-borderless sable-well sable-frame-r-md sable-frame-p-1">…</div>`
@@ -14826,14 +16331,14 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
14826
16331
 
14827
16332
  ### geometry (4)
14828
16333
 
14829
- - **`rounded-concentric-*`** — A border radius computed as an outer radius minus an inset: `rounded-concentric-<radius>/<spacing>`, clamped at zero. `rounded-concentric-md/1` = `radius-md` − `spacing-1`. *Use for:* A one-off rounded element nested inside another — a tab trigger inside a tab list, a thumb inside a track, a row inside a well — where a `sable-frame` would be overkill. The outer radius and the inset are the parent's.
16334
+ - **`rounded-concentric-*`** — A border radius computed as an outer radius minus an inset: `rounded-concentric-<radius>/<spacing>`, floored at `radius-xs` so a deep inset never leaves a 1-2px sliver. `rounded-concentric-md/1` = `radius-md` − `spacing-1`. *Use for:* A one-off rounded element nested inside another — a tab trigger inside a tab list, a thumb inside a track, a row inside a well — where a `sable-frame` would be overkill. The outer radius and the inset are the parent's.
14830
16335
  - **Instead of** Picking the next ladder step down by eye (`rounded-sm` inside `rounded-md p-1`). That is concentric only at the default ladder; the utility recomputes from live tokens. Registered in `cn()` as a radius, so it correctly overrides a component's own `rounded-*`.
14831
16336
  - `<div className="sable-well rounded-md p-1"><button className="rounded-concentric-md/1 px-2 py-1">…</button></div>`
14832
16337
  - **`popup-inset`** — The padding of a popup list, derived from the popup's radius: a third of `radius-md`, at least `--spacing(1)`. A rounder popup gets more room, and the floor is on the spacing scale, so the density control moves it. *Use for:* On a hand-composed rounded-md popup that holds a list of rows. Sable's Menu, Select, Combobox and ContextMenu already use it.
14833
16338
  - **Instead of** `p-1`. A fixed inset at a larger radius preset leaves the rows' corners approaching half their height, so every row turns into a pill.
14834
16339
  - `<div className="rounded-md bg-popover popup-inset shadow-md">…</div>`
14835
16340
  - **`rounded-popup-item`** — The row radius that nests inside a `popup-inset` popup: `radius-md` minus the popup inset, clamped at zero. *Use for:* On every row inside a `popup-inset` container.
14836
- - **Instead of** `rounded-sm` on menu rows, which matches only the default ladder (12px popup, 4px inset, 8px rows).
16341
+ - **Instead of** `rounded-sm` on menu rows, which matches no ladder exactly (a 12px popup with a 4px inset and 1px border wants 7px rows).
14837
16342
  - `<div role="option" className="rounded-popup-item px-2 py-1.5 sable-highlighted:bg-accent">…</div>`
14838
16343
  - **`popup-bleed`** — A negative inline margin of exactly one `popup-inset`, so a separator inside a padded popup runs edge to edge at every radius and density. *Use for:* On a hand-composed separator (a 1px rule) inside a `popup-inset` popup. Sable's MenuSeparator, ContextMenuSeparator and SelectSeparator already use it.
14839
16344
  - **Instead of** `-mx-1`, which matches the inset only at the default ladder and stops short of both edges once a rounder preset or a roomier density grows it. design-lint flags it (`popup-separator-bleed`).
@@ -14866,8 +16371,20 @@ The `@utility` and `@custom-variant` layer in `styles.css`: what each one is, wh
14866
16371
  - **Instead of** `scrollbar-quiet` on a vertical list. A vertical region with no bar and no fade tells a reader nothing about how far through they are.
14867
16372
  - `<div className="flex gap-3 overflow-x-auto scrollbar-hidden">…</div>`
14868
16373
 
14869
- ### text (8)
14870
-
16374
+ ### text (12)
16375
+
16376
+ - **`sable-list`** — A bulleted list as typography: body type, a hanging muted marker, and item rhythm. Put it on a native `<ul>`. *Use for:* Running content with bullets: release notes, a feature list, steps that are not ordered.
16377
+ - **Instead of** A stack of `<div>`s with a hand-drawn dot, or an unstyled `<ul>` that inherits the reset (no markers, no indent).
16378
+ - `<ul className="sable-list"><li>Faster sync</li><li>Offline drafts</li></ul>`
16379
+ - **`sable-list-numbered`** — A numbered list as typography: `sable-list` with decimal markers in tabular figures. Put it on a native `<ol>`. *Use for:* Ordered content in running text: setup steps, a ranked list. For a stepped flow with state, use Stepper.
16380
+ - **Instead of** Numbers typed into the text, which neither renumber nor read as a list to assistive tech.
16381
+ - `<ol className="sable-list-numbered"><li>Install</li><li>Import the styles</li></ol>`
16382
+ - **`sable-list-dl`** — Label/value pairs on a native `<dl>`: muted label-tier `<dt>`s and body-tier `<dd>`s on a baseline-aligned two-column grid; long values wrap inside their column. Colour a status value with a `text-*` utility on its `<dd>`. *Use for:* Read-only details: an order summary, a record's metadata, a settings summary, profile stats.
16383
+ - **Instead of** A grid of `<div>`s (screen readers lose the term/value pairing), or Table for a single record. For editable values use Form and Field.
16384
+ - `<dl className="sable-list-dl"><dt>Network</dt><dd>Solana</dd><dt>Status</dt><dd className="text-success">Active</dd></dl>`
16385
+ - **`sable-list-dl-2`** — Two label/value pairs a row once the nearest `@container` ancestor is at least 28rem wide. Add to `sable-list-dl`. *Use for:* A compact metadata block in a wide card or panel. Put `@container` on a parent so it measures the room it actually has.
16386
+ - **Instead of** A viewport breakpoint, which gives a narrow card on a wide screen four columns it cannot fit.
16387
+ - `<div className="@container"><dl className="sable-list-dl sable-list-dl-2">…</dl></div>`
14871
16388
  - **`text-overline`** — The eyebrow / overline / kicker tier: small, letter-spaced, uppercase by default (a theme may set sentence case via `--type-overline-transform`). *Use for:* A section eyebrow above a heading, a stat's label, a badge-style category line. Sits ABOVE and categorises.
14872
16389
  - **Instead of** `text-label-xs uppercase tracking-wider` assembled by hand, or `text-caption`, which sits BELOW and explains. In tool chrome, panel headers use `text-ui-section` instead.
14873
16390
  - `<p className="text-overline text-muted-foreground">Latest news</p>`