@sableui/react 0.5.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 (82) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +135 -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 +279 -30
  75. package/scripts/design-presets.mjs +27 -4
  76. package/scripts/grade.mjs +2 -1
  77. package/scripts/label-contrast.mjs +138 -0
  78. package/scripts/lint-rules.mjs +23 -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
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. 105 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. 106 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
 
@@ -423,6 +423,12 @@ drift; repeated overrides can indicate a missing system hook rather than app mis
423
423
  Run relevant `sable doctor`, `sable lint <paths> --json` and `sable grade <paths>
424
424
  --json` checks. Lint is lexical; source grades and pattern similarity do not judge
425
425
  taste. Deliberately static motion slots remain valid with appropriate feedback.
426
+ Lint's `label-contrast` measures an app's overrides of the brand fills (`--primary`,
427
+ `--sidebar-primary`, `--highlight`) and status fills (`--destructive`, `--success`,
428
+ `--tone-warning`, `--tone-info`) against their labels on top of the installed theme, and
429
+ names the nearest pair that reads. Report it with that pair as a recommendation.
430
+ A reasoned `design-lint-disable-next-line label-contrast` is the app's recorded
431
+ choice: list it as intentional, not as a defect.
426
432
 
427
433
  ## Deliver evidence-backed findings
428
434
 
@@ -917,7 +923,9 @@ Entity rows in a Card) or `redesign` (desktop workspace tools).
917
923
  3. Compose: `NativeApp` at the root (`mode` sets light or dark, also on
918
924
  `<html>` so portaled Sable popups follow); one `NavigationStack` per tab,
919
925
  each holding an `IosPage` with an `IosNavbar` and Sable content; an
920
- `IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms.
926
+ `IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms
927
+ (`background="opaque"` for a form at the medium detent, and the block
928
+ `framed={false}`: the sheet is already its surface).
921
929
  Keep the app's `IconProvider`.
922
930
  4. Frame it on a host page with `PhoneFrame` from `@sableui/react/ios/preview`
923
931
  and `@sableui/react/ios/frame.css` (Sable's Phone around an iframe at
@@ -1173,7 +1181,7 @@ button and text node takes a minute and settles the argument.
1173
1181
 
1174
1182
  ## 1. Wire the package
1175
1183
 
1176
- Follow **Consuming** in `llms.txt`: `.npmrc`, `pnpm add`, React dedupe, the CSS
1184
+ Follow **Consuming** in `llms.txt`: `pnpm add`, React dedupe, the CSS
1177
1185
  import order, `@source`, fonts, providers. React dedupe is the one that fails
1178
1186
  loudest and least clearly ("invalid hook call").
1179
1187
 
@@ -1319,6 +1327,30 @@ light and dark modes and test long text, focus, disabled controls, floating
1319
1327
  surfaces, and errors. Token validation checks the contract and safe CSS structure;
1320
1328
  rendered review establishes contrast, coherence, and suitability.
1321
1329
 
1330
+ `--check` also measures the label on every filled role against its `-foreground`,
1331
+ in both modes: the brand fills `primary`, `sidebar-primary` and `highlight`, and the
1332
+ status fills `destructive`, `success`, `tone-warning` and `tone-info`. A label must
1333
+ clear 4.5:1 and APCA Lc 60, because a pair can pass the ratio and still read muddy
1334
+ (dark ink on a bright red-orange). A failing pair stops the check with
1335
+ `ERR_PRESET_CONTRAST` and the nearest pair that reads, with the fill's lightness
1336
+ moved and its hue kept. A fill keeps the ink its light-mode colour takes, so a
1337
+ colour with a white label keeps white in dark mode and its fill comes down to meet
1338
+ it. Offer that pair to the user.
1339
+
1340
+ Each status colour has two tokens. The fill (`destructive`) carries its label;
1341
+ `destructive-text` is the same colour drawn on a surface, and `text-destructive`,
1342
+ `border-destructive` and `ring-destructive` read it. In dark mode the two usually
1343
+ differ: a saturated fill low enough for white, and a lighter text value that reads
1344
+ on a dark card. Author both; lowering the fill never darkens the page's error text.
1345
+ If the user chose the failing pair on purpose (a brand book, a reference to match),
1346
+ keep it and record the decision in the preset rather than overriding it:
1347
+
1348
+ ```json
1349
+ "exceptions": [{ "rule": "label-contrast", "mode": "dark", "token": "primary", "reason": "Brand book specifies dark ink on the bright fill." }]
1350
+ ```
1351
+
1352
+ An accepted pair is reported with its reason on every check; it is never silent.
1353
+
1322
1354
  Canvas's Export view downloads this same format with resolved theme values and
1323
1355
  the current customizations. Enter a name and design guidance, save the JSON, and
1324
1356
  place it in the target project. The browser cannot write into that project for
@@ -1950,6 +1982,15 @@ Use opaque `focus-visible:ring-ring` or `ring-sidebar-ring`; reducing the ring t
1950
1982
  `faded-focus-ring` rule catches those classes. A token contrast report establishes
1951
1983
  the tested pairs, not the entire rendered product's accessibility.
1952
1984
 
1985
+ A label on a filled role (a primary or destructive Button, the sidebar's active
1986
+ item, a highlight, a success or warning badge) should clear 4.5:1 and APCA Lc 60 in
1987
+ both modes. A colour retuned for dark mode often lands where dark ink passes the
1988
+ ratio but reads muddy. `label-contrast`
1989
+ in `sable lint` and `sable preset --check` find it and suggest the nearest pair
1990
+ that reads; offer that pair. Keep a pair the user chose deliberately, recorded with
1991
+ its reason (a lint disable or a preset `exceptions` entry), instead of overriding
1992
+ it to finish.
1993
+
1953
1994
  Describe a defect as a visible or behavioral consequence: "The heading wraps to
1954
1995
  six lines at 390px and pushes the action below the intended first decision point."
1955
1996
  Then record the repair and before/after artifact IDs in `iterations`. Avoid vague
@@ -2175,7 +2216,12 @@ Font stacks are requirements, not downloaded fonts. Declare matching stacks in
2175
2216
  the font metadata and tokens. Resolve internal palette variables before saving;
2176
2217
  portable presets may reference public Sable tokens, not private palette names.
2177
2218
  The validator rejects unknown tokens and CSS that can escape its declaration.
2178
- It does not certify contrast or that a CSS value makes visual sense.
2219
+ `sable preset --check` also holds labels on brand and status fills to 4.5:1 and APCA Lc 60
2220
+ and suggests a pair that reads; a preset may keep a pair deliberately through
2221
+ `exceptions` with a reason (see `sable docs creating-a-preset`). Neither certifies
2222
+ the rendered product's contrast or that a CSS value makes visual sense. Do not
2223
+ change a supplied preset's colours to clear a finding without asking; report the
2224
+ finding and the suggested pair.
2179
2225
 
2180
2226
  Motion durations are nominal times at scale 1. `presetCss()` and the CLI connect
2181
2227
  them to the live `--motion-scale` policy, so reduced motion still stops recipes.
@@ -2870,6 +2916,7 @@ Sable's components ship accessible behavior from Base UI. These rules cover what
2870
2916
 
2871
2917
  - **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.
2872
2918
  - **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.*
2919
+ - **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.*
2873
2920
  - **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.
2874
2921
  - **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.
2875
2922
  - **Never:** outline: none without a replacement. **Instead:** focus-visible:ring-2 ring-ring. Keyboard users lose their place.
@@ -2932,22 +2979,7 @@ Split sign-in page: a form column (brand mark, heading, email + password, primar
2932
2979
 
2933
2980
  ```tsx
2934
2981
  import { useState } from "react"
2935
- import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, Icon, Input, Region, Separator, Stack } from "@sableui/react"
2936
-
2937
- // A soft mesh of the theme's decorative tones over its brand colour, so the
2938
- // artwork follows every theme and mode. Each tone is lifted from its text
2939
- // strength (dark, restrained) to an art strength.
2940
- const artwork = [
2941
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2942
- "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%)",
2943
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2944
- "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%)",
2945
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2946
- "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%)",
2947
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
2948
- "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%)",
2949
- "var(--primary)",
2950
- ].join(", ")
2982
+ import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, GradientArt, Icon, Input, Region, Separator, Stack } from "@sableui/react"
2951
2983
 
2952
2984
  export function AuthPage() {
2953
2985
  const [pending, setPending] = useState(false)
@@ -3035,7 +3067,8 @@ export function AuthPage() {
3035
3067
 
3036
3068
  {/* The brand panel is decoration: on a phone the form is the whole screen. */}
3037
3069
  <aside aria-label="Featured release" className="hidden p-3 lg:flex">
3038
- <div className="flex flex-1 flex-col justify-end rounded-xl p-8" style={{ background: artwork }}>
3070
+ {/* Art painted from the theme's tones over its brand, so it follows every look. */}
3071
+ <GradientArt seed="Untitled (gold rush)" className="flex flex-1 flex-col justify-end rounded-xl p-8">
3039
3072
  <Card className="max-w-md">
3040
3073
  <CardContent>
3041
3074
  <Stack gap={3}>
@@ -3047,14 +3080,14 @@ export function AuthPage() {
3047
3080
  </p>
3048
3081
  <Entity
3049
3082
  className="p-0"
3050
- leading={<Avatar fallback="MO" />}
3083
+ leading={<Avatar variant="art" fallback="MO" />}
3051
3084
  title="Mara Okafor"
3052
3085
  subtitle="Untitled (gold rush), 2026"
3053
3086
  />
3054
3087
  </Stack>
3055
3088
  </CardContent>
3056
3089
  </Card>
3057
- </div>
3090
+ </GradientArt>
3058
3091
  </aside>
3059
3092
  </main>
3060
3093
  )
@@ -3442,7 +3475,7 @@ export function BrandLandingPage() {
3442
3475
  </blockquote>
3443
3476
  </Stack>
3444
3477
  <figcaption className="flex items-center gap-3">
3445
- <Avatar size="lg" fallback="MO" />
3478
+ <Avatar variant="art" size="lg" fallback="MO" />
3446
3479
  <div>
3447
3480
  <p className="text-title-sm">Maya Okafor</p>
3448
3481
  <p className="text-body-sm text-muted-foreground">Head of operations, Fielddesk</p>
@@ -3664,7 +3697,7 @@ export function ChatPanel() {
3664
3697
  // at that size reads as a speck); `alt` names it for a screen reader, which
3665
3698
  // otherwise gets nothing from a fallback-only avatar.
3666
3699
  const assistant = (
3667
- <Avatar
3700
+ <Avatar variant="art"
3668
3701
  size="sm"
3669
3702
  alt="Assistant"
3670
3703
  fallback={<Icon name="sparkles" className="text-base" />}
@@ -3992,7 +4025,7 @@ export function DashboardPage() {
3992
4025
  <span className="text-title-sm">Atelier</span>
3993
4026
  </Row>
3994
4027
  }
3995
- actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
4028
+ actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
3996
4029
  />
3997
4030
  }
3998
4031
  bottomNav={
@@ -4027,7 +4060,7 @@ export function DashboardPage() {
4027
4060
  <SidebarFooter>
4028
4061
  <Entity
4029
4062
  className="[--entity-py:0]"
4030
- leading={<Avatar size="sm" fallback="AL" alt="" />}
4063
+ leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
4031
4064
  title="Ada Lovelace"
4032
4065
  subtitle="@ada"
4033
4066
  action={
@@ -4199,7 +4232,7 @@ export function DashboardPage() {
4199
4232
  {collectors.map((person) => (
4200
4233
  <Entity
4201
4234
  key={person.id}
4202
- leading={<Avatar size="sm" fallback={person.initials} alt="" />}
4235
+ leading={<Avatar variant="art" size="sm" fallback={person.initials} alt="" />}
4203
4236
  title={person.name}
4204
4237
  subtitle={`${person.handle} · ${person.pieces} pieces`}
4205
4238
  action={<span className="text-mono-sm">{usd.format(person.spent)}</span>}
@@ -4246,7 +4279,7 @@ export function DashboardPage() {
4246
4279
  {activity.map((item) => (
4247
4280
  <Entity
4248
4281
  key={item.id}
4249
- leading={<Avatar size="sm" fallback={item.initials} alt="" />}
4282
+ leading={<Avatar variant="art" size="sm" fallback={item.initials} alt="" />}
4250
4283
  title={item.who}
4251
4284
  subtitle={item.what}
4252
4285
  action={<RelativeTime date={Date.now() - item.when} className="text-caption text-muted-foreground" />}
@@ -4360,7 +4393,7 @@ const renderPost = (post: Post) => (
4360
4393
  <Card>
4361
4394
  <CardList>
4362
4395
  <Entity
4363
- leading={<Avatar fallback={post.initials} alt={post.author} />}
4396
+ leading={<Avatar variant="art" fallback={post.initials} alt={post.author} />}
4364
4397
  title={post.author}
4365
4398
  subtitle={`${post.handle} · ${post.time}`}
4366
4399
  action={
@@ -4441,7 +4474,7 @@ export function FeedPage() {
4441
4474
  <Card>
4442
4475
  <CardContent>
4443
4476
  <Row gap={1.5} align="start">
4444
- <Avatar fallback="AL" alt="Ada Lovelace" />
4477
+ <Avatar variant="art" fallback="AL" alt="Ada Lovelace" />
4445
4478
  <Stack gap={1.5} className="min-w-0 flex-1">
4446
4479
  <Textarea
4447
4480
  autoResize
@@ -4508,7 +4541,7 @@ export function FeedPage() {
4508
4541
  {suggestions.map((person) => (
4509
4542
  <Entity
4510
4543
  key={person.id}
4511
- leading={<Avatar fallback={person.initials} alt={person.name} />}
4544
+ leading={<Avatar variant="art" fallback={person.initials} alt={person.name} />}
4512
4545
  title={person.name}
4513
4546
  subtitle={person.handle}
4514
4547
  action={
@@ -4528,7 +4561,7 @@ export function FeedPage() {
4528
4561
  {upcoming.map((drop) => (
4529
4562
  <Entity
4530
4563
  key={drop.id}
4531
- leading={<Avatar fallback={drop.initials} alt={drop.artist} />}
4564
+ leading={<Avatar variant="art" fallback={drop.initials} alt={drop.artist} />}
4532
4565
  title={drop.title}
4533
4566
  subtitle={`${drop.artist} · ${drop.date}`}
4534
4567
  action={
@@ -4861,7 +4894,7 @@ export function LandingPage() {
4861
4894
  {deploys.map((deploy) => (
4862
4895
  <Entity
4863
4896
  key={deploy.meta}
4864
- leading={<Avatar size="sm" fallback={deploy.initials} alt={deploy.name} />}
4897
+ leading={<Avatar variant="art" size="sm" fallback={deploy.initials} alt={deploy.name} />}
4865
4898
  title={deploy.title}
4866
4899
  subtitle={deploy.meta}
4867
4900
  action={
@@ -5287,7 +5320,8 @@ export function ListDetailPage() {
5287
5320
  selected={isSelected}
5288
5321
  onSelect={() => select(m.id)}
5289
5322
  leading={
5290
- <Avatar
5323
+ <Avatar variant="art"
5324
+ seed={m.name}
5291
5325
  fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
5292
5326
  status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
5293
5327
  // The row's title already names it: keep the initials out of the row's name.
@@ -5349,7 +5383,7 @@ export function ListDetailPage() {
5349
5383
  <Card>
5350
5384
  <CardHeader>
5351
5385
  <Row gap={2}>
5352
- <Avatar
5386
+ <Avatar variant="art"
5353
5387
  size="lg"
5354
5388
  fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
5355
5389
  status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
@@ -5562,7 +5596,7 @@ export function SettingsPage() {
5562
5596
  <span className="text-title-sm">Atelier</span>
5563
5597
  </Row>
5564
5598
  }
5565
- actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
5599
+ actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
5566
5600
  />
5567
5601
  }
5568
5602
  bottomNav={
@@ -5597,7 +5631,7 @@ export function SettingsPage() {
5597
5631
  <SidebarFooter>
5598
5632
  <Entity
5599
5633
  className="[--entity-py:0]"
5600
- leading={<Avatar size="sm" fallback="AL" alt="" />}
5634
+ leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
5601
5635
  title="Ada Lovelace"
5602
5636
  subtitle="@ada"
5603
5637
  action={
@@ -5647,7 +5681,7 @@ export function SettingsPage() {
5647
5681
  <CardContent>
5648
5682
  <Stack gap={3}>
5649
5683
  <Row gap={2}>
5650
- <Avatar size="lg" fallback="AL" alt="Ada Lovelace" />
5684
+ <Avatar variant="art" size="lg" fallback="AL" alt="Ada Lovelace" />
5651
5685
  <Stack gap={0.5} align="start">
5652
5686
  <Row gap={1}>
5653
5687
  <Button type="button" variant="outline" size="sm">
@@ -6108,7 +6142,7 @@ export function DangerZoneBlock({ className, framed = true, onDelete }: DangerZo
6108
6142
  Permanently remove your profile, editions, and sales history. Collectors keep the editions they own.
6109
6143
  </CardDescription>
6110
6144
  </CardHeader>
6111
- <CardFooter className="justify-end">
6145
+ <CardFooter actions className="justify-end">
6112
6146
  <Button variant="destructive" onClick={() => setOpen(true)}>
6113
6147
  Delete account
6114
6148
  </Button>
@@ -6296,7 +6330,7 @@ export function EventCardBlock({ className, framed = true, onRespond }: EventCar
6296
6330
  <CardFooter className="flex-wrap justify-between gap-3">
6297
6331
  <AvatarGroup aria-label={`${event.total} attendees`} total={event.total} size="sm" surface={framed ? "card" : undefined} fit>
6298
6332
  {event.attendees.map((initials) => (
6299
- <Avatar key={initials} fallback={initials} alt={initials} />
6333
+ <Avatar variant="art" key={initials} fallback={initials} alt={initials} />
6300
6334
  ))}
6301
6335
  </AvatarGroup>
6302
6336
  <div className="flex gap-2">
@@ -6522,7 +6556,7 @@ export function FeedbackFormBlock({ className, framed = true, onDone }: Feedback
6522
6556
  </ToggleGroup>
6523
6557
  <Textarea placeholder="What worked, what didn’t…" aria-label="Notes" rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} />
6524
6558
  </CardContent>
6525
- <CardFooter className="justify-end">
6559
+ <CardFooter actions className="justify-end">
6526
6560
  <Button variant="ghost" size="sm" onClick={() => onDone?.(false)}>
6527
6561
  Skip
6528
6562
  </Button>
@@ -6705,7 +6739,7 @@ export function InviteTeamBlock({ className, framed = true, onSend }: InviteTeam
6705
6739
  <CardContent className="flex flex-col gap-4">
6706
6740
  <AvatarGroup aria-label={`${collaborators.length} collaborators`} size="sm" surface={framed ? "card" : undefined}>
6707
6741
  {collaborators.map((initials) => (
6708
- <Avatar key={initials} fallback={initials} alt={initials} />
6742
+ <Avatar variant="art" key={initials} fallback={initials} alt={initials} />
6709
6743
  ))}
6710
6744
  </AvatarGroup>
6711
6745
  <form
@@ -6896,24 +6930,10 @@ A now-playing card: cover, title and artist as an Entity with a Like action, a p
6896
6930
 
6897
6931
  ```tsx
6898
6932
  import { useState } from "react"
6899
- import { Button, Card, CardContent, cn, Entity, Icon, Slider } from "@sableui/react"
6933
+ import { Button, Card, CardContent, cn, Entity, GradientArt, Icon, Slider } from "@sableui/react"
6900
6934
 
6901
6935
  const track = { title: "Low tide, high noon", artist: "Juno Avery", position: 38, elapsed: "1:24", remaining: "-2:16" }
6902
6936
 
6903
- /**
6904
- * Placeholder cover painted from the palette — the theme's decorative tones
6905
- * over the brand, so it follows every look. Replace with the cover image.
6906
- */
6907
- const ART_TONES = ["info", "collectible", "edition"]
6908
- const ART_SPOTS = [[20, 20, 70], [85, 30, 60], [60, 90, 65]]
6909
- const cover = [
6910
- ...ART_SPOTS.map(
6911
- ([x, y, reach], i) =>
6912
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
6913
- `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}%)`,
6914
- ),
6915
- "var(--primary)",
6916
- ].join(", ")
6917
6937
 
6918
6938
  export interface MediaPlayerBlockProps {
6919
6939
  className?: string
@@ -6934,7 +6954,8 @@ export function MediaPlayerBlock({ className, framed = true, onPlayingChange, on
6934
6954
  card already pads it. */}
6935
6955
  <Entity
6936
6956
  className="p-0"
6937
- leading={<div aria-hidden className="size-14 rounded-md" style={{ background: cover }} />}
6957
+ // Placeholder cover painted from the theme. Replace with the cover image.
6958
+ leading={<GradientArt aria-hidden seed={track.title} className="size-14 rounded-md" />}
6938
6959
  title={track.title}
6939
6960
  subtitle={track.artist}
6940
6961
  action={
@@ -7113,7 +7134,7 @@ An activity list: Entity rows with the actor's Avatar, who did what, and a compa
7113
7134
  ## Notes
7114
7135
 
7115
7136
  - Rows are Card + CardList + Entity, inset from the card's padding at any density.
7116
- - Unread is a quiet info dot after the time — the list stays calm when most items are unread.
7137
+ - 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.
7117
7138
  - framed={false} drops the surface for a Popover, which already is one.
7118
7139
 
7119
7140
  ## Accessibility
@@ -7144,7 +7165,7 @@ export function NotificationListBlock({ className, framed = true }: Notification
7144
7165
  {notifications.map((n) => (
7145
7166
  <Entity
7146
7167
  key={n.name}
7147
- leading={<Avatar size="sm" fallback={n.initials} alt={n.name} />}
7168
+ leading={<Avatar variant="art" size="sm" fallback={n.initials} alt={n.name} />}
7148
7169
  title={
7149
7170
  <>
7150
7171
  {n.name}
@@ -7154,8 +7175,8 @@ export function NotificationListBlock({ className, framed = true }: Notification
7154
7175
  subtitle={n.what}
7155
7176
  action={
7156
7177
  <span className="flex items-center gap-1.5 text-caption text-muted-foreground">
7157
- {n.when}
7158
7178
  {n.unread ? <StatusDot tone="info" /> : null}
7179
+ {n.when}
7159
7180
  </span>
7160
7181
  }
7161
7182
  />
@@ -7529,25 +7550,10 @@ A product card: edge-to-edge artwork, title and maker, an edition Badge, and the
7529
7550
  ## Source
7530
7551
 
7531
7552
  ```tsx
7532
- import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn } from "@sableui/react"
7553
+ import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, GradientArt } from "@sableui/react"
7533
7554
 
7534
7555
  const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" }
7535
7556
 
7536
- /**
7537
- * Placeholder artwork painted from the palette — a soft mesh of the theme's
7538
- * decorative tones over the brand, so it follows every look. Replace the art
7539
- * <div> with your product's image.
7540
- */
7541
- const ART_TONES = ["info", "collectible", "edition", "standard"]
7542
- const ART_SPOTS = [[18, 22, 58], [82, 18, 55], [70, 88, 60], [12, 92, 55]]
7543
- const artwork = [
7544
- ...ART_SPOTS.map(
7545
- ([x, y, reach], i) =>
7546
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
7547
- `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}%)`,
7548
- ),
7549
- "var(--primary)",
7550
- ].join(", ")
7551
7557
 
7552
7558
  export interface ProductCardBlockProps {
7553
7559
  className?: string
@@ -7560,7 +7566,8 @@ export interface ProductCardBlockProps {
7560
7566
  export function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {
7561
7567
  return (
7562
7568
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
7563
- <div aria-hidden className="aspect-[4/3]" style={{ background: artwork }} />
7569
+ {/* Placeholder art painted from the theme, so it follows every look. Replace with the product image. */}
7570
+ <GradientArt aria-hidden seed={product.title} className="aspect-[4/3]" />
7564
7571
  <CardHeader>
7565
7572
  <CardTitle>{product.title}</CardTitle>
7566
7573
  <CardDescription>{product.maker}</CardDescription>
@@ -7602,7 +7609,7 @@ A profile card: a cover banner, an avatar rising into it, Share and Follow, the
7602
7609
 
7603
7610
  ```tsx
7604
7611
  import { useState } from "react"
7605
- import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, Icon } from "@sableui/react"
7612
+ import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, GradientArt, Icon } from "@sableui/react"
7606
7613
 
7607
7614
  const profile = {
7608
7615
  name: "Juno Avery",
@@ -7615,20 +7622,6 @@ const profile = {
7615
7622
  ],
7616
7623
  }
7617
7624
 
7618
- /**
7619
- * Placeholder banner painted from the palette — the theme's decorative tones
7620
- * over the brand, so it follows every look. Replace with the person's cover image.
7621
- */
7622
- const ART_TONES = ["info", "collectible", "edition", "standard"]
7623
- const ART_SPOTS = [[10, 50, 60], [50, 0, 55], [90, 60, 60], [40, 100, 50]]
7624
- const banner = [
7625
- ...ART_SPOTS.map(
7626
- ([x, y, reach], i) =>
7627
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
7628
- `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}%)`,
7629
- ),
7630
- "var(--primary)",
7631
- ].join(", ")
7632
7625
 
7633
7626
  export interface ProfileCardBlockProps {
7634
7627
  className?: string
@@ -7643,12 +7636,13 @@ export function ProfileCardBlock({ className, framed = true, onFollowChange, onS
7643
7636
  const [following, setFollowing] = useState(false)
7644
7637
  return (
7645
7638
  <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
7646
- <div aria-hidden className="h-20" style={{ background: banner }} />
7639
+ {/* Placeholder banner painted from the theme, so it follows every look. Replace with the person's cover image. */}
7640
+ <GradientArt aria-hidden seed={profile.handle} className="h-20" />
7647
7641
  {/* pt-0: the avatar rises into the banner above, so the content
7648
7642
  starts at the banner's edge rather than a full pad below it. */}
7649
7643
  <CardContent className="flex flex-col gap-3 pt-0">
7650
7644
  <div className="flex items-start justify-between">
7651
- <Avatar size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
7645
+ <Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
7652
7646
  <div className="flex gap-1.5 pt-3">
7653
7647
  <Button variant="ghost" size="icon-sm" aria-label="Share profile" onClick={onShare}>
7654
7648
  <Icon name="share" />
@@ -7751,7 +7745,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
7751
7745
  <CardContent>
7752
7746
  <Stack gap={3}>
7753
7747
  <Row gap={2}>
7754
- <Avatar size="lg" fallback={profile.initials} alt={profile.name} />
7748
+ <Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} />
7755
7749
  <Stack gap={0.5} align="start">
7756
7750
  <Row gap={1}>
7757
7751
  <Button type="button" variant="outline" size="sm">
@@ -7791,7 +7785,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
7791
7785
  </Grid>
7792
7786
  </Stack>
7793
7787
  </CardContent>
7794
- <CardFooter className="justify-end">
7788
+ <CardFooter actions className="justify-end">
7795
7789
  <Button type="submit">Save profile</Button>
7796
7790
  </CardFooter>
7797
7791
  </Form>
@@ -8400,7 +8394,7 @@ export function TeamMembersBlock({ className, framed = true, onInvite, onMemberA
8400
8394
  {members.map((m) => (
8401
8395
  <Entity
8402
8396
  key={m.id}
8403
- leading={<Avatar size="sm" fallback={m.initials} alt={m.name} />}
8397
+ leading={<Avatar variant="art" size="sm" fallback={m.initials} alt={m.name} />}
8404
8398
  title={m.name}
8405
8399
  subtitle={m.email}
8406
8400
  action={
@@ -8490,7 +8484,7 @@ export function TestimonialBlock({ className }: TestimonialBlockProps) {
8490
8484
  <blockquote className="text-balance text-heading-3 sm:text-heading-2">“{story.quote}”</blockquote>
8491
8485
  </Stack>
8492
8486
  <figcaption className="flex items-center gap-3">
8493
- <Avatar size="lg" fallback={story.initials} alt={story.name} />
8487
+ <Avatar variant="art" size="lg" fallback={story.initials} alt={story.name} />
8494
8488
  <div>
8495
8489
  <p className="text-title-sm">{story.name}</p>
8496
8490
  <p className="text-body-sm text-muted-foreground">{story.role}</p>
@@ -9993,6 +9987,7 @@ Use Avatar to represent a person, team, or account with a compact visual identit
9993
9987
 
9994
9988
  ### Best practices
9995
9989
 
9990
+ - 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.
9996
9991
  - Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
9997
9992
  - Do: Use initials or a simple glyph fallback so missing images still communicate identity.
9998
9993
  - Do: Use consistent sizes within a group; reserve lg for profile headers or major identity moments.
@@ -10031,14 +10026,17 @@ Use Avatar to represent a person, team, or account with a compact visual identit
10031
10026
  | fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
10032
10027
  | 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. |
10033
10028
  | size | "sm" \| "md" \| "lg" | "md" | Square footprint: 32px / 40px / 48px. |
10029
+ | 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. |
10030
+ | 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. |
10034
10031
 
10035
10032
  ## Variants
10036
10033
 
10037
10034
  - **size**: sm, md, lg
10035
+ - **variant**: neutral, art
10038
10036
 
10039
10037
  ## Tokens used
10040
10038
 
10041
- `muted`, `muted-foreground`, `background`, `card`, `popover`
10039
+ `muted`, `muted-foreground`, `background`, `card`, `popover`, `overlay`, `overlay-foreground`
10042
10040
 
10043
10041
  ## Accessibility
10044
10042
 
@@ -10071,6 +10069,14 @@ import { Avatar } from "@sableui/react"
10071
10069
  <Avatar fallback="AM" alt="Alex Morgan" />
10072
10070
  ```
10073
10071
 
10072
+ ### Art fallback
10073
+
10074
+ ```tsx
10075
+ import { Avatar } from "@sableui/react"
10076
+
10077
+ <Avatar variant="art" fallback="AM" alt="Alex Morgan" />
10078
+ ```
10079
+
10074
10080
  ### Sizes
10075
10081
 
10076
10082
  ```tsx
@@ -11185,7 +11191,7 @@ Use Card for a discrete thing with its own boundary, such as one product, one no
11185
11191
  | 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. |
11186
11192
  | 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. |
11187
11193
  | 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. |
11188
- | CardFooter | ComponentPropsWithRef<"div"> | Bottom row for actions/meta. Padding follows the density knob. |
11194
+ | 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. |
11189
11195
 
11190
11196
  ## Props
11191
11197
 
@@ -13637,6 +13643,89 @@ import { GlidingHighlight } from "@sableui/react"
13637
13643
 
13638
13644
  ---
13639
13645
 
13646
+ # GradientArt
13647
+
13648
+ **Status:** beta
13649
+
13650
+ 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.
13651
+
13652
+ **Keywords:** gradient, mesh gradient, grain, shader, artwork, placeholder image, cover, banner, avatar fallback, generative
13653
+
13654
+ ## Usage
13655
+
13656
+ 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.
13657
+
13658
+ ### Best practices
13659
+
13660
+ - 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.
13661
+ - 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.
13662
+ - 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.
13663
+ - 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.
13664
+ - 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.
13665
+ - 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.
13666
+
13667
+ ## Props
13668
+
13669
+ | Name | Type | Default | Description |
13670
+ | --- | --- | --- | --- |
13671
+ | variant | "mesh" \| "grain" | "mesh" | mesh: soft spots of colour pulled together by gentle waves. grain: glowing light over deep corners with a printed texture. |
13672
+ | 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. |
13673
+ | seed | string \| number | — | Fixes the composition: the same seed paints the same art in every theme. Pass a stable id. |
13674
+ | children | ReactNode | — | Rendered above the art — initials, a glyph, a short label. |
13675
+ | className | string | — | Size and shape: aspect, height, rounding. |
13676
+
13677
+ ## Variants
13678
+
13679
+ - **variant**: mesh, grain
13680
+
13681
+ ## Tokens used
13682
+
13683
+ `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`
13684
+
13685
+ ## Motion
13686
+
13687
+ - **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.
13688
+ - **response** · root · _no motion_ — Artwork, not a control: it does not react to the pointer.
13689
+ - **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.
13690
+ - **activity** · root · _no motion_ — Static by design: nothing animates between paints, so it costs nothing while idle and respects reduced motion without a branch.
13691
+
13692
+ ## Accessibility
13693
+
13694
+ - 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.
13695
+ - Children render above the art in the normal reading order, so initials or a label are announced as usual.
13696
+
13697
+ ## Related
13698
+
13699
+ - Components: Avatar, Card, AspectBox
13700
+
13701
+ ## Examples
13702
+
13703
+ ### Product cover
13704
+
13705
+ ```tsx
13706
+ import { GradientArt } from "@sableui/react"
13707
+
13708
+ <GradientArt seed="Untitled (gold rush)" className="aspect-[4/3] rounded-lg" />
13709
+ ```
13710
+
13711
+ ### Grain banner
13712
+
13713
+ ```tsx
13714
+ import { GradientArt } from "@sableui/react"
13715
+
13716
+ <GradientArt variant="grain" seed="juno" tones={["info", "edition"]} className="h-20 rounded-md" />
13717
+ ```
13718
+
13719
+ ### Avatar with initials
13720
+
13721
+ ```tsx
13722
+ import { GradientArt } from "@sableui/react"
13723
+
13724
+ <GradientArt seed="Mara Okafor" className="grid size-10 place-items-center rounded-full text-label-md text-overlay-foreground">MO</GradientArt>
13725
+ ```
13726
+
13727
+ ---
13728
+
13640
13729
  # InlineStatus
13641
13730
 
13642
13731
  **Status:** stable
@@ -17337,7 +17426,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
17337
17426
  | SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
17338
17427
  | SheetTitle | Base UI Drawer.Title props + className | The sheet's accessible name — wires aria-labelledby on the popup. |
17339
17428
  | SheetDescription | Base UI Drawer.Description props + className | Muted supporting line; wires aria-describedby. |
17340
- | SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. |
17429
+ | 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. |
17341
17430
 
17342
17431
  ## Props
17343
17432
 
@@ -19998,7 +20087,7 @@ import { PanelGroup, Workspace, WorkspaceBar, WorkspaceCanvas } from "@sableui/r
19998
20087
 
19999
20088
  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`.
20000
20089
 
20001
- ### semantic (45)
20090
+ ### semantic (49)
20002
20091
 
20003
20092
  - **`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.
20004
20093
  - **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.
@@ -20070,6 +20159,14 @@ Which token to reach for, and what it is most often confused with. Generated fro
20070
20159
  - **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.
20071
20160
  - **`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.
20072
20161
  - **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.
20162
+ - **`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.
20163
+ - **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.
20164
+ - **`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.
20165
+ - **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.
20166
+ - **`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.
20167
+ - **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.
20168
+ - **`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.
20169
+ - **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.
20073
20170
  - **`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.
20074
20171
  - **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.
20075
20172
  - **`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.