@sableui/react 0.5.0 → 0.6.1

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 (82) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +148 -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 +19 -51
  9. package/dist/{chunk-RCLKXF2N.js → chunk-7QSDYBXO.js} +610 -30
  10. package/dist/{chunk-GU3T7SBA.js → chunk-EDXLM7YL.js} +1 -1
  11. package/dist/chunk-FKZXAFIY.js +12 -0
  12. package/dist/{chunk-OQACYT2D.js → chunk-NETUFI34.js} +23 -1
  13. package/dist/{chunk-ZBZMKOO7.js → chunk-VVW4UCDI.js} +3 -3
  14. package/dist/foundations/index.js +6 -0
  15. package/dist/index.d.ts +127 -11
  16. package/dist/index.js +23 -13
  17. package/dist/ios/index.d.ts +6 -1
  18. package/dist/ios/index.js +4 -3
  19. package/dist/ios/styles.css +9 -0
  20. package/dist/patterns/index.js +22 -28
  21. package/dist/presets/index.d.ts +108 -5
  22. package/dist/presets/index.js +239 -6
  23. package/dist/styles.css +50 -0
  24. package/dist/{theme-provider-CGeiusgu.d.ts → theme-provider-kQszHYji.d.ts} +1 -1
  25. package/dist/themes/atelier/atelier.css +31 -3
  26. package/dist/themes/atelier/index.d.ts +1 -1
  27. package/dist/themes/ember/ember.css +38 -7
  28. package/dist/themes/ember/index.d.ts +1 -1
  29. package/dist/themes/index.d.ts +2 -2
  30. package/dist/themes/meridian/index.d.ts +1 -1
  31. package/dist/themes/meridian/meridian.css +33 -4
  32. package/dist/themes/poppy/index.d.ts +1 -1
  33. package/dist/themes/poppy/poppy.css +28 -0
  34. package/dist/themes/prism/index.d.ts +1 -1
  35. package/dist/themes/prism/prism.css +26 -0
  36. package/dist/themes/sable/index.d.ts +1 -1
  37. package/dist/themes/sable/sable.css +24 -11
  38. package/dist/themes/verdant/index.d.ts +1 -1
  39. package/dist/themes/verdant/verdant.css +34 -4
  40. package/llms/blocks/danger-zone-block.md +1 -1
  41. package/llms/blocks/event-card-block.md +1 -1
  42. package/llms/blocks/feedback-form-block.md +1 -1
  43. package/llms/blocks/invite-team-block.md +1 -1
  44. package/llms/blocks/media-player-block.md +3 -16
  45. package/llms/blocks/notification-list-block.md +3 -3
  46. package/llms/blocks/product-card-block.md +3 -17
  47. package/llms/blocks/profile-card-block.md +4 -17
  48. package/llms/blocks/profile-form-block.md +2 -2
  49. package/llms/blocks/team-members-block.md +1 -1
  50. package/llms/blocks/testimonial-block.md +1 -1
  51. package/llms/components/avatar.md +13 -1
  52. package/llms/components/card.md +1 -1
  53. package/llms/components/gradient-art.md +80 -0
  54. package/llms/components/sheet.md +1 -1
  55. package/llms/foundations/accessibility.md +1 -0
  56. package/llms/patterns/auth-page.md +5 -19
  57. package/llms/patterns/brand-landing-page.md +1 -1
  58. package/llms/patterns/chat-panel.md +1 -1
  59. package/llms/patterns/dashboard-page.md +4 -4
  60. package/llms/patterns/feed-page.md +4 -4
  61. package/llms/patterns/landing-page.md +1 -1
  62. package/llms/patterns/list-detail-page.md +3 -2
  63. package/llms/patterns/settings-page.md +3 -3
  64. package/llms/reference/token-intent.md +9 -1
  65. package/llms/tasks/auditing-an-app.md +6 -0
  66. package/llms/tasks/building-an-ios-prototype.md +3 -1
  67. package/llms/tasks/converting-an-app.md +1 -1
  68. package/llms/tasks/creating-a-preset.md +24 -0
  69. package/llms/tasks/reviewing-a-design.md +9 -0
  70. package/llms/tasks/using-presets.md +6 -1
  71. package/llms-full.txt +201 -104
  72. package/llms.txt +3 -2
  73. package/package.json +9 -7
  74. package/registry.json +283 -30
  75. package/scripts/design-presets.mjs +27 -4
  76. package/scripts/grade.mjs +2 -1
  77. package/scripts/label-contrast.mjs +174 -0
  78. package/scripts/lint-rules.mjs +39 -2
  79. package/skills/sable/SKILL.md +3 -1
  80. package/targets/ios.json +18 -2
  81. package/third-party/paper-shaders-LICENSE.txt +201 -0
  82. package/third-party/paper-shaders-NOTICE.txt +5 -0
@@ -9,7 +9,7 @@ An activity list: Entity rows with the actor's Avatar, who did what, and a compa
9
9
  ## Notes
10
10
 
11
11
  - Rows are Card + CardList + Entity, inset from the card's padding at any density.
12
- - Unread is a quiet info dot after the time — the list stays calm when most items are unread.
12
+ - Unread is a quiet info dot before the time, so the times keep one right edge — the list stays calm when most items are unread.
13
13
  - framed={false} drops the surface for a Popover, which already is one.
14
14
 
15
15
  ## Accessibility
@@ -40,7 +40,7 @@ export function NotificationListBlock({ className, framed = true }: Notification
40
40
  {notifications.map((n) => (
41
41
  <Entity
42
42
  key={n.name}
43
- leading={<Avatar size="sm" fallback={n.initials} alt={n.name} />}
43
+ leading={<Avatar variant="art" size="sm" fallback={n.initials} alt={n.name} />}
44
44
  title={
45
45
  <>
46
46
  {n.name}
@@ -50,8 +50,8 @@ export function NotificationListBlock({ className, framed = true }: Notification
50
50
  subtitle={n.what}
51
51
  action={
52
52
  <span className="flex items-center gap-1.5 text-caption text-muted-foreground">
53
- {n.when}
54
53
  {n.unread ? <StatusDot tone="info" /> : null}
54
+ {n.when}
55
55
  </span>
56
56
  }
57
57
  />
@@ -20,25 +20,10 @@ A product card: edge-to-edge artwork, title and maker, an edition Badge, and the
20
20
  ## Source
21
21
 
22
22
  ```tsx
23
- import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn } from "@sableui/react"
23
+ import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, GradientArt } from "@sableui/react"
24
24
 
25
25
  const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" }
26
26
 
27
- /**
28
- * Placeholder artwork painted from the palette — a soft mesh of the theme's
29
- * decorative tones over the brand, so it follows every look. Replace the art
30
- * <div> with your product's image.
31
- */
32
- const ART_TONES = ["info", "collectible", "edition", "standard"]
33
- const ART_SPOTS = [[18, 22, 58], [82, 18, 55], [70, 88, 60], [12, 92, 55]]
34
- const artwork = [
35
- ...ART_SPOTS.map(
36
- ([x, y, reach], i) =>
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 ${x}% ${y}%, oklch(from var(--tone-${ART_TONES[i % ART_TONES.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`,
39
- ),
40
- "var(--primary)",
41
- ].join(", ")
42
27
 
43
28
  export interface ProductCardBlockProps {
44
29
  className?: string
@@ -51,7 +36,8 @@ export interface ProductCardBlockProps {
51
36
  export function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {
52
37
  return (
53
38
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
54
- <div aria-hidden className="aspect-[4/3]" style={{ background: artwork }} />
39
+ {/* Placeholder art painted from the theme, so it follows every look. Replace with the product image. */}
40
+ <GradientArt aria-hidden seed={product.title} className="aspect-[4/3]" />
55
41
  <CardHeader>
56
42
  <CardTitle>{product.title}</CardTitle>
57
43
  <CardDescription>{product.maker}</CardDescription>
@@ -21,7 +21,7 @@ A profile card: a cover banner, an avatar rising into it, Share and Follow, the
21
21
 
22
22
  ```tsx
23
23
  import { useState } from "react"
24
- import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, Icon } from "@sableui/react"
24
+ import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, GradientArt, Icon } from "@sableui/react"
25
25
 
26
26
  const profile = {
27
27
  name: "Juno Avery",
@@ -34,20 +34,6 @@ const profile = {
34
34
  ],
35
35
  }
36
36
 
37
- /**
38
- * Placeholder banner painted from the palette — the theme's decorative tones
39
- * over the brand, so it follows every look. Replace with the person's cover image.
40
- */
41
- const ART_TONES = ["info", "collectible", "edition", "standard"]
42
- const ART_SPOTS = [[10, 50, 60], [50, 0, 55], [90, 60, 60], [40, 100, 50]]
43
- const banner = [
44
- ...ART_SPOTS.map(
45
- ([x, y, reach], i) =>
46
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
47
- `radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES[i % ART_TONES.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`,
48
- ),
49
- "var(--primary)",
50
- ].join(", ")
51
37
 
52
38
  export interface ProfileCardBlockProps {
53
39
  className?: string
@@ -62,12 +48,13 @@ export function ProfileCardBlock({ className, framed = true, onFollowChange, onS
62
48
  const [following, setFollowing] = useState(false)
63
49
  return (
64
50
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
65
- <div aria-hidden className="h-20" style={{ background: banner }} />
51
+ {/* Placeholder banner painted from the theme, so it follows every look. Replace with the person's cover image. */}
52
+ <GradientArt aria-hidden seed={profile.handle} className="h-20" />
66
53
  {/* pt-0: the avatar rises into the banner above, so the content
67
54
  starts at the banner's edge rather than a full pad below it. */}
68
55
  <CardContent className="flex flex-col gap-3 pt-0">
69
56
  <div className="flex items-start justify-between">
70
- <Avatar size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
57
+ <Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
71
58
  <div className="flex gap-1.5 pt-3">
72
59
  <Button variant="ghost" size="icon-sm" aria-label="Share profile" onClick={onShare}>
73
60
  <Icon name="share" />
@@ -64,7 +64,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
64
64
  <CardContent>
65
65
  <Stack gap={3}>
66
66
  <Row gap={2}>
67
- <Avatar size="lg" fallback={profile.initials} alt={profile.name} />
67
+ <Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} />
68
68
  <Stack gap={0.5} align="start">
69
69
  <Row gap={1}>
70
70
  <Button type="button" variant="outline" size="sm">
@@ -104,7 +104,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
104
104
  </Grid>
105
105
  </Stack>
106
106
  </CardContent>
107
- <CardFooter className="justify-end">
107
+ <CardFooter actions className="justify-end">
108
108
  <Button type="submit">Save profile</Button>
109
109
  </CardFooter>
110
110
  </Form>
@@ -54,7 +54,7 @@ export function TeamMembersBlock({ className, framed = true, onInvite, onMemberA
54
54
  {members.map((m) => (
55
55
  <Entity
56
56
  key={m.id}
57
- leading={<Avatar size="sm" fallback={m.initials} alt={m.name} />}
57
+ leading={<Avatar variant="art" size="sm" fallback={m.initials} alt={m.name} />}
58
58
  title={m.name}
59
59
  subtitle={m.email}
60
60
  action={
@@ -50,7 +50,7 @@ export function TestimonialBlock({ className }: TestimonialBlockProps) {
50
50
  <blockquote className="text-balance text-heading-3 sm:text-heading-2">“{story.quote}”</blockquote>
51
51
  </Stack>
52
52
  <figcaption className="flex items-center gap-3">
53
- <Avatar size="lg" fallback={story.initials} alt={story.name} />
53
+ <Avatar variant="art" size="lg" fallback={story.initials} alt={story.name} />
54
54
  <div>
55
55
  <p className="text-title-sm">{story.name}</p>
56
56
  <p className="text-body-sm text-muted-foreground">{story.role}</p>
@@ -12,6 +12,7 @@ Use Avatar to represent a person, team, or account with a compact visual identit
12
12
 
13
13
  ### Best practices
14
14
 
15
+ - Do: Use `variant="art"` where people without photos sit side by side — a member list, an activity feed, a chat. Seeded by the person, each gets their own colour, so faces stay tellable apart at a glance; the neutral fallback makes everyone the same grey circle.
15
16
  - Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
16
17
  - Do: Use initials or a simple glyph fallback so missing images still communicate identity.
17
18
  - Do: Use consistent sizes within a group; reserve lg for profile headers or major identity moments.
@@ -50,14 +51,17 @@ Use Avatar to represent a person, team, or account with a compact visual identit
50
51
  | fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
51
52
  | status | ReactNode | — | A presence indicator pinned to the bottom-end edge, usually <StatusDot tone="success" />. It sits outside the clipped circle and wears a ring in the surface colour (--avatar-group-ring, else the page background). Use this instead of absolutely positioning a dot over an Avatar. |
52
53
  | size | "sm" \| "md" \| "lg" | "md" | Square footprint: 32px / 40px / 48px. |
54
+ | variant | "neutral" \| "art" | "neutral" | What the fallback looks like. neutral: initials on the muted surface. art: initials over GradientArt seeded by the person, so each person without a photo gets their own stable colour from the theme. |
55
+ | seed | string \| number | — | The art variant's seed. Defaults to the fallback text, then `alt`, so a person keeps one colour wherever they appear; pass a user id to tell apart two people who share initials. |
53
56
 
54
57
  ## Variants
55
58
 
56
59
  - **size**: sm, md, lg
60
+ - **variant**: neutral, art
57
61
 
58
62
  ## Tokens used
59
63
 
60
- `muted`, `muted-foreground`, `background`, `card`, `popover`
64
+ `muted`, `muted-foreground`, `background`, `card`, `popover`, `overlay`, `overlay-foreground`
61
65
 
62
66
  ## Accessibility
63
67
 
@@ -90,6 +94,14 @@ import { Avatar } from "@sableui/react"
90
94
  <Avatar fallback="AM" alt="Alex Morgan" />
91
95
  ```
92
96
 
97
+ ### Art fallback
98
+
99
+ ```tsx
100
+ import { Avatar } from "@sableui/react"
101
+
102
+ <Avatar variant="art" fallback="AM" alt="Alex Morgan" />
103
+ ```
104
+
93
105
  ### Sizes
94
106
 
95
107
  ```tsx
@@ -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
 
@@ -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