@sableui/react 0.4.0 → 0.6.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 (90) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +189 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/THIRD_PARTY_NOTICES.md +11 -0
  5. package/bin/sable-lint.mjs +2 -1
  6. package/bin/sable.mjs +3 -2
  7. package/dist/{_contract-Cn3lUpih.d.ts → _contract-C2M1SjBf.d.ts} +20 -2
  8. package/dist/blocks/index.js +21 -53
  9. package/dist/{chunk-CCUDFLU3.js → chunk-7QSDYBXO.js} +652 -38
  10. package/dist/{chunk-FBSL2LZQ.js → chunk-EDXLM7YL.js} +11 -30
  11. package/dist/chunk-FKZXAFIY.js +12 -0
  12. package/dist/{chunk-OQACYT2D.js → chunk-NETUFI34.js} +23 -1
  13. package/dist/{chunk-NGBDP3BM.js → chunk-QJ6FWTUG.js} +1 -1
  14. package/dist/{chunk-D2ETQBHV.js → chunk-VVW4UCDI.js} +5 -5
  15. package/dist/{chunk-TH563WLW.js → chunk-XAZGMY73.js} +10 -3
  16. package/dist/foundations/index.js +6 -0
  17. package/dist/icon/index.js +2 -2
  18. package/dist/icon-sets/brands.d.ts +0 -27
  19. package/dist/icon-sets/brands.js +7 -1
  20. package/dist/icon-sets/reicon.js +1 -1
  21. package/dist/index.d.ts +164 -23
  22. package/dist/index.js +116 -29
  23. package/dist/ios/index.d.ts +6 -1
  24. package/dist/ios/index.js +4 -3
  25. package/dist/ios/styles.css +9 -0
  26. package/dist/patterns/index.js +24 -30
  27. package/dist/presets/index.d.ts +108 -5
  28. package/dist/presets/index.js +239 -6
  29. package/dist/styles.css +57 -0
  30. package/dist/{theme-provider-CGeiusgu.d.ts → theme-provider-kQszHYji.d.ts} +1 -1
  31. package/dist/themes/atelier/atelier.css +31 -3
  32. package/dist/themes/atelier/index.d.ts +1 -1
  33. package/dist/themes/ember/ember.css +38 -7
  34. package/dist/themes/ember/index.d.ts +1 -1
  35. package/dist/themes/index.d.ts +2 -2
  36. package/dist/themes/meridian/index.d.ts +1 -1
  37. package/dist/themes/meridian/meridian.css +33 -4
  38. package/dist/themes/poppy/index.d.ts +1 -1
  39. package/dist/themes/poppy/poppy.css +28 -0
  40. package/dist/themes/prism/index.d.ts +1 -1
  41. package/dist/themes/prism/prism.css +26 -0
  42. package/dist/themes/sable/index.d.ts +1 -1
  43. package/dist/themes/sable/sable.css +24 -11
  44. package/dist/themes/verdant/index.d.ts +1 -1
  45. package/dist/themes/verdant/verdant.css +34 -4
  46. package/llms/blocks/danger-zone-block.md +1 -1
  47. package/llms/blocks/event-card-block.md +1 -1
  48. package/llms/blocks/feedback-form-block.md +1 -1
  49. package/llms/blocks/invite-team-block.md +1 -1
  50. package/llms/blocks/media-player-block.md +3 -16
  51. package/llms/blocks/notification-list-block.md +3 -3
  52. package/llms/blocks/product-card-block.md +3 -17
  53. package/llms/blocks/profile-card-block.md +4 -17
  54. package/llms/blocks/profile-form-block.md +2 -2
  55. package/llms/blocks/team-members-block.md +1 -1
  56. package/llms/blocks/testimonial-block.md +1 -1
  57. package/llms/components/avatar.md +13 -1
  58. package/llms/components/card.md +1 -1
  59. package/llms/components/gradient-art.md +80 -0
  60. package/llms/components/sheet.md +1 -1
  61. package/llms/components/tabs.md +1 -1
  62. package/llms/components/toast.md +12 -1
  63. package/llms/foundations/accessibility.md +1 -0
  64. package/llms/patterns/auth-page.md +5 -19
  65. package/llms/patterns/brand-landing-page.md +1 -1
  66. package/llms/patterns/chat-panel.md +1 -1
  67. package/llms/patterns/dashboard-page.md +4 -4
  68. package/llms/patterns/feed-page.md +4 -4
  69. package/llms/patterns/landing-page.md +1 -1
  70. package/llms/patterns/list-detail-page.md +3 -2
  71. package/llms/patterns/settings-page.md +3 -3
  72. package/llms/reference/token-intent.md +9 -1
  73. package/llms/tasks/auditing-an-app.md +6 -0
  74. package/llms/tasks/building-an-ios-prototype.md +3 -1
  75. package/llms/tasks/converting-an-app.md +1 -1
  76. package/llms/tasks/creating-a-preset.md +24 -0
  77. package/llms/tasks/reviewing-a-design.md +9 -0
  78. package/llms/tasks/using-presets.md +6 -1
  79. package/llms-full.txt +214 -106
  80. package/llms.txt +4 -3
  81. package/package.json +9 -7
  82. package/registry.json +310 -32
  83. package/scripts/design-presets.mjs +27 -4
  84. package/scripts/grade.mjs +2 -1
  85. package/scripts/label-contrast.mjs +138 -0
  86. package/scripts/lint-rules.mjs +23 -2
  87. package/skills/sable/SKILL.md +3 -1
  88. package/targets/ios.json +18 -2
  89. package/third-party/paper-shaders-LICENSE.txt +201 -0
  90. package/third-party/paper-shaders-NOTICE.txt +5 -0
@@ -39,7 +39,7 @@ Use Card for a discrete thing with its own boundary, such as one product, one no
39
39
  | CardAction | ComponentPropsWithRef<"div"> | Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise. |
40
40
  | CardContent | ComponentPropsWithRef<"div"> & { flush?: boolean } | The body region. Padding follows the density knob on all sides — does not assume a preceding CardHeader, so it is safe to use standalone (e.g. no CardHeader in the card). When paired with CardHeader, the seam stays tight because CardHeader zeroes its own bottom padding. `flush` runs it edge to edge for a table: the Table insets its first and last columns by exactly the card padding, so it lines up with the header — never hand-set pl-6/pr-6 on columns. |
41
41
  | CardList | ComponentPropsWithRef<"div"> | Rows that run edge to edge in the card (Entity rows, most often), divided by hairlines. Insets each row to the card's own padding, so a list card lines up with every other card at any density — no hand-tuned px-* on the rows. Under a CardHeader it opens with a hairline and a gap; alone it fills the card. |
42
- | CardFooter | ComponentPropsWithRef<"div"> | Bottom row for actions/meta. Padding follows the density knob. |
42
+ | CardFooter | ComponentPropsWithRef<"div"> & { actions?: boolean } | Bottom row for actions/meta. Padding follows the density knob. `actions` marks it as the card's action row (buttons only): in a sheet (IosSheet, a bottom SheetContent) it becomes a full-width stack pinned to the bottom, primary on top; on a page nothing changes. |
43
43
 
44
44
  ## Props
45
45
 
@@ -0,0 +1,80 @@
1
+ # GradientArt
2
+
3
+ **Status:** beta
4
+
5
+ Placeholder artwork painted by a static WebGL shader from the preset's own art direction (the --art-* tokens: palette, ground, warp, softness, grain) — Ember burns, Prism airbrushes, Meridian is grainy monochrome — with a CSS gradient underneath for prerendered pages and browsers without WebGL2.
6
+
7
+ **Keywords:** gradient, mesh gradient, grain, shader, artwork, placeholder image, cover, banner, avatar fallback, generative
8
+
9
+ ## Usage
10
+
11
+ Use GradientArt where a real image belongs but none exists yet, or where the image is decoration: a product cover before upload, a profile banner, an album tile, an avatar with no photo. It reads the theme's own tokens, so it follows every preset, mode and live edit; `seed` fixes each item's composition so a list of covers or avatars stays individually recognisable.
12
+
13
+ ### Best practices
14
+
15
+ - Do: Pass a stable `seed` per item — the name for an avatar, the title for a cover. Each item then keeps the same art across renders, reloads and themes, which is what lets someone recognise it.
16
+ - Do: Size it with layout classes (`aspect-[4/3]`, `h-20`, `size-10 rounded-full`) and put any glyph or initials inside as children; they render above the art.
17
+ - Do: Keep the default `mesh` for covers, tiles and avatars. Reach for `grain` where a printed, tactile surface suits — a banner, a hero card — and keep one variant per surface type so art reads as a system.
18
+ - Don't: Do not put body text over it. The art spans the palette's lightest and most saturated tones, so no single ink is guaranteed to clear contrast across it; reserve overlays for a short label on a scrim or an avatar's initials.
19
+ - Don't: Do not pass colours, and reach for `tones` rarely. The art follows the preset's --art-* tokens on purpose; to change how a product's art looks, set the --theme-art-* hooks in its theme or preset, so every cover, banner and avatar changes together.
20
+ - Don't: Do not use it as a page or hero background. It paints at the element's pixel size, so a full-viewport instance costs a large frame on every theme change; it is sized for covers, banners and tiles.
21
+
22
+ ## Props
23
+
24
+ | Name | Type | Default | Description |
25
+ | --- | --- | --- | --- |
26
+ | variant | "mesh" \| "grain" | "mesh" | mesh: soft spots of colour pulled together by gentle waves. grain: glowing light over deep corners with a printed texture. |
27
+ | tones | ("info" \| "standard" \| "collectible" \| "edition" \| "warning")[] | — | Paint these `--tone-*` colours instead of the preset's art palette. Leave unset to follow the preset's art direction. |
28
+ | seed | string \| number | — | Fixes the composition: the same seed paints the same art in every theme. Pass a stable id. |
29
+ | children | ReactNode | — | Rendered above the art — initials, a glyph, a short label. |
30
+ | className | string | — | Size and shape: aspect, height, rounding. |
31
+
32
+ ## Variants
33
+
34
+ - **variant**: mesh, grain
35
+
36
+ ## Tokens used
37
+
38
+ `art-1`, `art-2`, `art-3`, `art-4`, `art-5`, `art-6`, `art-ground`, `art-warp`, `art-softness`, `art-grain`, `primary`, `tone-info`, `tone-standard`, `tone-collectible`, `tone-edition`, `tone-warning`
39
+
40
+ ## Motion
41
+
42
+ - **arrival** · canvas · `motion-interactive` — The shader frame fades in over the CSS gradient it replaces, so the swap from fallback to shader reads as the art settling rather than a flash.
43
+ - **response** · root · _no motion_ — Artwork, not a control: it does not react to the pointer.
44
+ - **transition** · canvas · _custom (outside the recipe layer)_ — A theme change repaints the frame in place on the next animation frame; there are no states to move between.
45
+ - **activity** · root · _no motion_ — Static by design: nothing animates between paints, so it costs nothing while idle and respects reduced motion without a branch.
46
+
47
+ ## Accessibility
48
+
49
+ - Decorative by default: the canvas is aria-hidden. When the art stands for something (an avatar), label the element that contains it, not the art.
50
+ - Children render above the art in the normal reading order, so initials or a label are announced as usual.
51
+
52
+ ## Related
53
+
54
+ - Components: Avatar, Card, AspectBox
55
+
56
+ ## Examples
57
+
58
+ ### Product cover
59
+
60
+ ```tsx
61
+ import { GradientArt } from "@sableui/react"
62
+
63
+ <GradientArt seed="Untitled (gold rush)" className="aspect-[4/3] rounded-lg" />
64
+ ```
65
+
66
+ ### Grain banner
67
+
68
+ ```tsx
69
+ import { GradientArt } from "@sableui/react"
70
+
71
+ <GradientArt variant="grain" seed="juno" tones={["info", "edition"]} className="h-20 rounded-md" />
72
+ ```
73
+
74
+ ### Avatar with initials
75
+
76
+ ```tsx
77
+ import { GradientArt } from "@sableui/react"
78
+
79
+ <GradientArt seed="Mara Okafor" className="grid size-10 place-items-center rounded-full text-label-md text-overlay-foreground">MO</GradientArt>
80
+ ```
@@ -39,7 +39,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
39
39
  | SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
40
40
  | SheetTitle | Base UI Drawer.Title props + className | The sheet's accessible name — wires aria-labelledby on the popup. |
41
41
  | SheetDescription | Base UI Drawer.Description props + className | Muted supporting line; wires aria-describedby. |
42
- | SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. |
42
+ | SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. In a bottom sheet it stacks: full width, the primary on top, every button at the cta height. |
43
43
 
44
44
  ## Props
45
45
 
@@ -31,7 +31,7 @@ Use Tabs to switch between related panels within the same page context.
31
31
 
32
32
  | Name | Type | Description |
33
33
  | --- | --- | --- |
34
- | TabsList | Base UI Tabs.List props (activateOnFocus, loopFocus) + variant?: "underline" \| "pill" + className | The tablist row. variant="underline" (default) draws a divider rule beneath the triggers; variant="pill" drops the rule and fills the active trigger. Triggers inherit the variant. |
34
+ | TabsList | Base UI Tabs.List props (activateOnFocus, loopFocus) + variant?: "underline" \| "pill" + className | The tablist row. variant="underline" (default) draws a divider rule beneath the triggers; variant="pill" drops the rule and fills the active trigger, which takes the theme's button shape (--button-radius). Triggers inherit the variant. |
35
35
  | TabsTrigger | Base UI Tabs.Tab props (value, disabled) + className | A single tab button. value pairs it with the panel of the same value. |
36
36
  | TabsPanel | Base UI Tabs.Panel props (value, keepMounted) + className | The content shown when the matching trigger is active. |
37
37
  | TabsContent | Alias of TabsPanel | Re-export of TabsPanel for consumers that prefer this name. |
@@ -34,6 +34,7 @@ Use Toast for transient feedback caused by a user action: saved, failed, copied,
34
34
  | Name | Type | Description |
35
35
  | --- | --- | --- |
36
36
  | ToastRoot | Base UI Toast.Root props + className (requires the `toast` object) | A single toast surface. Animates enter/leave via data-[starting-style]/data-[ending-style]; swipe-to-dismiss is wired by Base UI. |
37
+ | ToastContent | Base UI Toast.Content props + className | The toast's contents as one row. In a stacked viewport it fades on the cards behind the front one and returns when the stack fans out. Wrap a hand-composed toast's parts in it so a stacked viewport can hide them. |
37
38
  | ToastTitle | Base UI Toast.Title props + className | The toast headline (text-title-sm). Auto-associated as the toast's accessible name. |
38
39
  | ToastDescription | Base UI Toast.Description props + className | Supporting body copy beneath the title (text-body-sm, muted). Auto-associated as the toast's accessible description. |
39
40
  | ToastClose | Base UI Toast.Close props + className | Dismiss button. Base UI handles the close and leave animation; labelled "Dismiss" by default. |
@@ -43,8 +44,9 @@ Use Toast for transient feedback caused by a user action: saved, failed, copied,
43
44
 
44
45
  | Name | Type | Default | Description |
45
46
  | --- | --- | --- | --- |
46
- | ToastProvider | { children, timeout?, limit?, closeIcon?, position?, viewportClassName? } | — | Wraps Base UI Toast.Provider and renders the themed Viewport + toast list. Mount once near the app root, above any caller of useToast(). |
47
+ | ToastProvider | { children, timeout?, limit?, closeIcon?, position?, stacked?, viewportClassName? } | — | Wraps Base UI Toast.Provider and renders the themed Viewport + toast list. Mount once near the app root, above any caller of useToast(). |
47
48
  | position | "top-left" \| "top-center" \| "top-right" \| "bottom-left" \| "bottom-center" \| "bottom-right" | "top-right" | Screen anchor for the toast stack. |
49
+ | stacked | boolean | false | Stack the toasts like a deck of cards, the newest in front and the older ones peeking out behind it, a step smaller, instead of listing them one under another. Hovering or focusing the stack fans it out into the list. Use it when an app can raise several toasts in a row, so they don't take over the corner of the page. |
48
50
  | viewportClassName | string | — | Extra classes merged LAST onto the fixed viewport region — for safe-area insets or an offset (e.g. clearing a sidebar). |
49
51
  | timeout | number | 5000 | Default auto-dismiss time in ms for toasts. 0 keeps a toast until it is dismissed. Error toasts, loading toasts, and any toast carrying actionProps default to 0 regardless of this value; a per-toast timeout overrides both. |
50
52
  | limit | number | 3 | Maximum toasts shown at once; older toasts are pushed out when the limit is exceeded. |
@@ -62,6 +64,7 @@ Use Toast for transient feedback caused by a user action: saved, failed, copied,
62
64
  - **arrival** · ToastRoot · `motion-slide-left` — A toast interrupts without being asked, so where it came from is the reader's first question. Sliding in from the viewport edge answers it and marks the toast as belonging to the edge rather than to whatever it happens to cover.
63
65
  - **response** · ToastAction · `motion-interactive` — Toasts are on a timer, so the action has to look actionable at a glance — there is no time to hunt for the hit target.
64
66
  - **transition** · ToastRoot · `motion-slide-left` — Leaving the same way it entered keeps the stack legible when several dismiss at once; toasts that fade in place leave the remaining ones jumping to fill gaps with no visible cause.
67
+ - **transition** · ToastRoot (stacked) · _custom (outside the recipe layer)_ — A stacked toast arrives from the anchored edge in front of the others, which step back and shrink to make room; hovering fans the deck out into the list. Moving every card, rather than snapping them, shows that the new one went on top and the old ones are still there.
65
68
  - **activity** · ToastRoot · _no motion_ — A toast reports a result that has already happened. Anything still in flight belongs in the surface that started it, where the reader is still looking.
66
69
 
67
70
  ## Accessibility
@@ -121,6 +124,14 @@ import { toast } from "@sableui/react"
121
124
  toast.message("Post archived", { actionProps: { children: "Undo", onClick: () => restorePost() } })
122
125
  ```
123
126
 
127
+ ### Stacked
128
+
129
+ ```tsx
130
+ import { ToastProvider } from "@sableui/react"
131
+
132
+ <ToastProvider stacked position="bottom-right">{children}</ToastProvider>
133
+ ```
134
+
124
135
  ### Provider with app chrome
125
136
 
126
137
  ```tsx
@@ -29,6 +29,7 @@ Sable's components ship accessible behavior from Base UI. These rules cover what
29
29
 
30
30
  - **Never:** Color as the only signal (a red border with no message). **Instead:** Color plus text or an icon. Roughly 1 in 12 men cannot tell red from green.
31
31
  - **Never:** Opacity on the focus ring. **Instead:** ring-ring at full opacity; tune the token in the theme. A faded ring loses the contrast the theme tested. *Enforced by sable-lint: faded-focus-ring.*
32
+ - **Never:** A brand or status fill retuned under its old label — dark ink on a bright red that passes 4.5:1 at APCA Lc 42. **Instead:** A label that clears 4.5:1 and APCA Lc 60 in both modes. Move the fill's lightness, not its hue, keeping the ink its colour takes. A status colour on a surface is its own token (`--destructive-text`), so the fill can move freely. The ratio alone passes labels that read muddy. A deliberate brand pair is allowed with a recorded reason (lint disable or preset exceptions). *Enforced by sable-lint: label-contrast.*
32
33
  - **Never:** Blocking paste, or type="number" for codes and card numbers. **Instead:** type="text" inputMode="numeric" with the right autocomplete. Blocking paste breaks password managers; number inputs scroll-change identifiers.
33
34
  - **Never:** forced-color-adjust: none to 'fix' high-contrast mode. **Instead:** Let the system colors apply; verify the result. It removes the user's chosen colors exactly where they need them.
34
35
  - **Never:** outline: none without a replacement. **Instead:** focus-visible:ring-2 ring-ring. Keyboard users lose their place.
@@ -28,22 +28,7 @@ Split sign-in page: a form column (brand mark, heading, email + password, primar
28
28
 
29
29
  ```tsx
30
30
  import { useState } from "react"
31
- import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, Icon, Input, Region, Separator, Stack } from "@sableui/react"
32
-
33
- // A soft mesh of the theme's decorative tones over its brand colour, so the
34
- // artwork follows every theme and mode. Each tone is lifted from its text
35
- // strength (dark, restrained) to an art strength.
36
- const artwork = [
37
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
38
- "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%)",
39
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
40
- "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%)",
41
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
42
- "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%)",
43
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
44
- "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%)",
45
- "var(--primary)",
46
- ].join(", ")
31
+ import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, GradientArt, Icon, Input, Region, Separator, Stack } from "@sableui/react"
47
32
 
48
33
  export function AuthPage() {
49
34
  const [pending, setPending] = useState(false)
@@ -131,7 +116,8 @@ export function AuthPage() {
131
116
 
132
117
  {/* The brand panel is decoration: on a phone the form is the whole screen. */}
133
118
  <aside aria-label="Featured release" className="hidden p-3 lg:flex">
134
- <div className="flex flex-1 flex-col justify-end rounded-xl p-8" style={{ background: artwork }}>
119
+ {/* Art painted from the theme's tones over its brand, so it follows every look. */}
120
+ <GradientArt seed="Untitled (gold rush)" className="flex flex-1 flex-col justify-end rounded-xl p-8">
135
121
  <Card className="max-w-md">
136
122
  <CardContent>
137
123
  <Stack gap={3}>
@@ -143,14 +129,14 @@ export function AuthPage() {
143
129
  </p>
144
130
  <Entity
145
131
  className="p-0"
146
- leading={<Avatar fallback="MO" />}
132
+ leading={<Avatar variant="art" fallback="MO" />}
147
133
  title="Mara Okafor"
148
134
  subtitle="Untitled (gold rush), 2026"
149
135
  />
150
136
  </Stack>
151
137
  </CardContent>
152
138
  </Card>
153
- </div>
139
+ </GradientArt>
154
140
  </aside>
155
141
  </main>
156
142
  )
@@ -377,7 +377,7 @@ export function BrandLandingPage() {
377
377
  </blockquote>
378
378
  </Stack>
379
379
  <figcaption className="flex items-center gap-3">
380
- <Avatar size="lg" fallback="MO" />
380
+ <Avatar variant="art" size="lg" fallback="MO" />
381
381
  <div>
382
382
  <p className="text-title-sm">Maya Okafor</p>
383
383
  <p className="text-body-sm text-muted-foreground">Head of operations, Fielddesk</p>
@@ -77,7 +77,7 @@ export function ChatPanel() {
77
77
  // at that size reads as a speck); `alt` names it for a screen reader, which
78
78
  // otherwise gets nothing from a fallback-only avatar.
79
79
  const assistant = (
80
- <Avatar
80
+ <Avatar variant="art"
81
81
  size="sm"
82
82
  alt="Assistant"
83
83
  fallback={<Icon name="sparkles" className="text-base" />}
@@ -111,7 +111,7 @@ export function DashboardPage() {
111
111
  <span className="text-title-sm">Atelier</span>
112
112
  </Row>
113
113
  }
114
- actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
114
+ actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
115
115
  />
116
116
  }
117
117
  bottomNav={
@@ -146,7 +146,7 @@ export function DashboardPage() {
146
146
  <SidebarFooter>
147
147
  <Entity
148
148
  className="[--entity-py:0]"
149
- leading={<Avatar size="sm" fallback="AL" alt="" />}
149
+ leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
150
150
  title="Ada Lovelace"
151
151
  subtitle="@ada"
152
152
  action={
@@ -318,7 +318,7 @@ export function DashboardPage() {
318
318
  {collectors.map((person) => (
319
319
  <Entity
320
320
  key={person.id}
321
- leading={<Avatar size="sm" fallback={person.initials} alt="" />}
321
+ leading={<Avatar variant="art" size="sm" fallback={person.initials} alt="" />}
322
322
  title={person.name}
323
323
  subtitle={`${person.handle} · ${person.pieces} pieces`}
324
324
  action={<span className="text-mono-sm">{usd.format(person.spent)}</span>}
@@ -365,7 +365,7 @@ export function DashboardPage() {
365
365
  {activity.map((item) => (
366
366
  <Entity
367
367
  key={item.id}
368
- leading={<Avatar size="sm" fallback={item.initials} alt="" />}
368
+ leading={<Avatar variant="art" size="sm" fallback={item.initials} alt="" />}
369
369
  title={item.who}
370
370
  subtitle={item.what}
371
371
  action={<RelativeTime date={Date.now() - item.when} className="text-caption text-muted-foreground" />}
@@ -95,7 +95,7 @@ const renderPost = (post: Post) => (
95
95
  <Card>
96
96
  <CardList>
97
97
  <Entity
98
- leading={<Avatar fallback={post.initials} alt={post.author} />}
98
+ leading={<Avatar variant="art" fallback={post.initials} alt={post.author} />}
99
99
  title={post.author}
100
100
  subtitle={`${post.handle} · ${post.time}`}
101
101
  action={
@@ -176,7 +176,7 @@ export function FeedPage() {
176
176
  <Card>
177
177
  <CardContent>
178
178
  <Row gap={1.5} align="start">
179
- <Avatar fallback="AL" alt="Ada Lovelace" />
179
+ <Avatar variant="art" fallback="AL" alt="Ada Lovelace" />
180
180
  <Stack gap={1.5} className="min-w-0 flex-1">
181
181
  <Textarea
182
182
  autoResize
@@ -243,7 +243,7 @@ export function FeedPage() {
243
243
  {suggestions.map((person) => (
244
244
  <Entity
245
245
  key={person.id}
246
- leading={<Avatar fallback={person.initials} alt={person.name} />}
246
+ leading={<Avatar variant="art" fallback={person.initials} alt={person.name} />}
247
247
  title={person.name}
248
248
  subtitle={person.handle}
249
249
  action={
@@ -263,7 +263,7 @@ export function FeedPage() {
263
263
  {upcoming.map((drop) => (
264
264
  <Entity
265
265
  key={drop.id}
266
- leading={<Avatar fallback={drop.initials} alt={drop.artist} />}
266
+ leading={<Avatar variant="art" fallback={drop.initials} alt={drop.artist} />}
267
267
  title={drop.title}
268
268
  subtitle={`${drop.artist} · ${drop.date}`}
269
269
  action={
@@ -309,7 +309,7 @@ export function LandingPage() {
309
309
  {deploys.map((deploy) => (
310
310
  <Entity
311
311
  key={deploy.meta}
312
- leading={<Avatar size="sm" fallback={deploy.initials} alt={deploy.name} />}
312
+ leading={<Avatar variant="art" size="sm" fallback={deploy.initials} alt={deploy.name} />}
313
313
  title={deploy.title}
314
314
  subtitle={deploy.meta}
315
315
  action={
@@ -212,7 +212,8 @@ export function ListDetailPage() {
212
212
  selected={isSelected}
213
213
  onSelect={() => select(m.id)}
214
214
  leading={
215
- <Avatar
215
+ <Avatar variant="art"
216
+ seed={m.name}
216
217
  fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
217
218
  status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
218
219
  // The row's title already names it: keep the initials out of the row's name.
@@ -274,7 +275,7 @@ export function ListDetailPage() {
274
275
  <Card>
275
276
  <CardHeader>
276
277
  <Row gap={2}>
277
- <Avatar
278
+ <Avatar variant="art"
278
279
  size="lg"
279
280
  fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
280
281
  status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
@@ -75,7 +75,7 @@ export function SettingsPage() {
75
75
  <span className="text-title-sm">Atelier</span>
76
76
  </Row>
77
77
  }
78
- actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
78
+ actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
79
79
  />
80
80
  }
81
81
  bottomNav={
@@ -110,7 +110,7 @@ export function SettingsPage() {
110
110
  <SidebarFooter>
111
111
  <Entity
112
112
  className="[--entity-py:0]"
113
- leading={<Avatar size="sm" fallback="AL" alt="" />}
113
+ leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
114
114
  title="Ada Lovelace"
115
115
  subtitle="@ada"
116
116
  action={
@@ -160,7 +160,7 @@ export function SettingsPage() {
160
160
  <CardContent>
161
161
  <Stack gap={3}>
162
162
  <Row gap={2}>
163
- <Avatar size="lg" fallback="AL" alt="Ada Lovelace" />
163
+ <Avatar variant="art" size="lg" fallback="AL" alt="Ada Lovelace" />
164
164
  <Stack gap={0.5} align="start">
165
165
  <Row gap={1}>
166
166
  <Button type="button" variant="outline" size="sm">
@@ -2,7 +2,7 @@
2
2
 
3
3
  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`.
4
4
 
5
- ### semantic (45)
5
+ ### semantic (49)
6
6
 
7
7
  - **`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.
8
8
  - **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.
@@ -74,6 +74,14 @@ Which token to reach for, and what it is most often confused with. Generated fro
74
74
  - **Not** `foreground`. That is the ink for `background` only; on any other surface it is a contrast gamble that happens to pass in the default theme and fails in another.
75
75
  - **`highlight-foreground`** — Ink paired with `highlight`. Evaluate the rendered contrast together; never choose it independently. *Use for:* Whenever the background is `highlight`. Use them as a pair — `bg-highlight text-highlight-foreground` — so a theme can retune the surface and the ink together.
76
76
  - **Not** `foreground`. That is the ink for `background` only; on any other surface it is a contrast gamble that happens to pass in the default theme and fails in another.
77
+ - **`destructive-text`** — `destructive` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-destructive`, `border-destructive` or `ring-destructive` — they read this token; only `bg-destructive` reads the fill.
78
+ - **Not** `destructive` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
79
+ - **`success-text`** — `success` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-success`, `border-success` or `ring-success` — they read this token; only `bg-success` reads the fill.
80
+ - **Not** `success` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
81
+ - **`tone-warning-text`** — `tone-warning` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-tone-warning`, `border-tone-warning` or `ring-tone-warning` — they read this token; only `bg-tone-warning` reads the fill.
82
+ - **Not** `tone-warning` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
83
+ - **`tone-info-text`** — `tone-info` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-tone-info`, `border-tone-info` or `ring-tone-info` — they read this token; only `bg-tone-info` reads the fill.
84
+ - **Not** `tone-info` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
77
85
  - **`overlay`** — A scrim over the page behind a modal, and the wash behind media chrome. *Use for:* Dialog and sheet backdrops, letterboxing, a pill sitting on top of an image.
78
86
  - **Not** `bg-black/50`. Pure black is never right — it crushes whatever is behind it, and a theme cannot tune it. This token is the sanctioned answer, which is why `raw-black-white` is a lint rule.
79
87
  - **`overlay-foreground`** — Ink paired with `overlay`. Evaluate the rendered contrast together; never choose it independently. *Use for:* Whenever the background is `overlay`. Use them as a pair — `bg-overlay text-overlay-foreground` — so a theme can retune the surface and the ink together.
@@ -51,6 +51,12 @@ drift; repeated overrides can indicate a missing system hook rather than app mis
51
51
  Run relevant `sable doctor`, `sable lint <paths> --json` and `sable grade <paths>
52
52
  --json` checks. Lint is lexical; source grades and pattern similarity do not judge
53
53
  taste. Deliberately static motion slots remain valid with appropriate feedback.
54
+ Lint's `label-contrast` measures an app's overrides of the brand fills (`--primary`,
55
+ `--sidebar-primary`, `--highlight`) and status fills (`--destructive`, `--success`,
56
+ `--tone-warning`, `--tone-info`) against their labels on top of the installed theme, and
57
+ names the nearest pair that reads. Report it with that pair as a recommendation.
58
+ A reasoned `design-lint-disable-next-line label-contrast` is the app's recorded
59
+ choice: list it as intentional, not as a defect.
54
60
 
55
61
  ## Deliver evidence-backed findings
56
62
 
@@ -70,7 +70,9 @@ Entity rows in a Card) or `redesign` (desktop workspace tools).
70
70
  3. Compose: `NativeApp` at the root (`mode` sets light or dark, also on
71
71
  `<html>` so portaled Sable popups follow); one `NavigationStack` per tab,
72
72
  each holding an `IosPage` with an `IosNavbar` and Sable content; an
73
- `IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms.
73
+ `IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms
74
+ (`background="opaque"` for a form at the medium detent, and the block
75
+ `framed={false}`: the sheet is already its surface).
74
76
  Keep the app's `IconProvider`.
75
77
  4. Frame it on a host page with `PhoneFrame` from `@sableui/react/ios/preview`
76
78
  and `@sableui/react/ios/frame.css` (Sable's Phone around an iframe at
@@ -27,7 +27,7 @@ button and text node takes a minute and settles the argument.
27
27
 
28
28
  ## 1. Wire the package
29
29
 
30
- Follow **Consuming** in `llms.txt`: `.npmrc`, `pnpm add`, React dedupe, the CSS
30
+ Follow **Consuming** in `llms.txt`: `pnpm add`, React dedupe, the CSS
31
31
  import order, `@source`, fonts, providers. React dedupe is the one that fails
32
32
  loudest and least clearly ("invalid hook call").
33
33
 
@@ -31,6 +31,30 @@ light and dark modes and test long text, focus, disabled controls, floating
31
31
  surfaces, and errors. Token validation checks the contract and safe CSS structure;
32
32
  rendered review establishes contrast, coherence, and suitability.
33
33
 
34
+ `--check` also measures the label on every filled role against its `-foreground`,
35
+ in both modes: the brand fills `primary`, `sidebar-primary` and `highlight`, and the
36
+ status fills `destructive`, `success`, `tone-warning` and `tone-info`. A label must
37
+ clear 4.5:1 and APCA Lc 60, because a pair can pass the ratio and still read muddy
38
+ (dark ink on a bright red-orange). A failing pair stops the check with
39
+ `ERR_PRESET_CONTRAST` and the nearest pair that reads, with the fill's lightness
40
+ moved and its hue kept. A fill keeps the ink its light-mode colour takes, so a
41
+ colour with a white label keeps white in dark mode and its fill comes down to meet
42
+ it. Offer that pair to the user.
43
+
44
+ Each status colour has two tokens. The fill (`destructive`) carries its label;
45
+ `destructive-text` is the same colour drawn on a surface, and `text-destructive`,
46
+ `border-destructive` and `ring-destructive` read it. In dark mode the two usually
47
+ differ: a saturated fill low enough for white, and a lighter text value that reads
48
+ on a dark card. Author both; lowering the fill never darkens the page's error text.
49
+ If the user chose the failing pair on purpose (a brand book, a reference to match),
50
+ keep it and record the decision in the preset rather than overriding it:
51
+
52
+ ```json
53
+ "exceptions": [{ "rule": "label-contrast", "mode": "dark", "token": "primary", "reason": "Brand book specifies dark ink on the bright fill." }]
54
+ ```
55
+
56
+ An accepted pair is reported with its reason on every check; it is never silent.
57
+
34
58
  Canvas's Export view downloads this same format with resolved theme values and
35
59
  the current customizations. Enter a name and design guidance, save the JSON, and
36
60
  place it in the target project. The browser cannot write into that project for
@@ -92,6 +92,15 @@ Use opaque `focus-visible:ring-ring` or `ring-sidebar-ring`; reducing the ring t
92
92
  `faded-focus-ring` rule catches those classes. A token contrast report establishes
93
93
  the tested pairs, not the entire rendered product's accessibility.
94
94
 
95
+ A label on a filled role (a primary or destructive Button, the sidebar's active
96
+ item, a highlight, a success or warning badge) should clear 4.5:1 and APCA Lc 60 in
97
+ both modes. A colour retuned for dark mode often lands where dark ink passes the
98
+ ratio but reads muddy. `label-contrast`
99
+ in `sable lint` and `sable preset --check` find it and suggest the nearest pair
100
+ that reads; offer that pair. Keep a pair the user chose deliberately, recorded with
101
+ its reason (a lint disable or a preset `exceptions` entry), instead of overriding
102
+ it to finish.
103
+
95
104
  Describe a defect as a visible or behavioral consequence: "The heading wraps to
96
105
  six lines at 390px and pushes the action below the intended first decision point."
97
106
  Then record the repair and before/after artifact IDs in `iterations`. Avoid vague
@@ -18,7 +18,12 @@ Font stacks are requirements, not downloaded fonts. Declare matching stacks in
18
18
  the font metadata and tokens. Resolve internal palette variables before saving;
19
19
  portable presets may reference public Sable tokens, not private palette names.
20
20
  The validator rejects unknown tokens and CSS that can escape its declaration.
21
- It does not certify contrast or that a CSS value makes visual sense.
21
+ `sable preset --check` also holds labels on brand and status fills to 4.5:1 and APCA Lc 60
22
+ and suggests a pair that reads; a preset may keep a pair deliberately through
23
+ `exceptions` with a reason (see `sable docs creating-a-preset`). Neither certifies
24
+ the rendered product's contrast or that a CSS value makes visual sense. Do not
25
+ change a supplied preset's colours to clear a finding without asking; report the
26
+ finding and the suggested pair.
22
27
 
23
28
  Motion durations are nominal times at scale 1. `presetCss()` and the CLI connect
24
29
  them to the live `--motion-scale` policy, so reduced motion still stops recipes.