@sableui/react 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/AGENTS.md +2 -2
  2. package/CHANGELOG.md +189 -0
  3. package/CONTRIBUTING.md +1 -1
  4. package/THIRD_PARTY_NOTICES.md +11 -0
  5. package/bin/sable-lint.mjs +2 -1
  6. package/bin/sable.mjs +3 -2
  7. package/dist/{_contract-Cn3lUpih.d.ts → _contract-C2M1SjBf.d.ts} +20 -2
  8. package/dist/blocks/index.js +21 -53
  9. package/dist/{chunk-CCUDFLU3.js → chunk-7QSDYBXO.js} +652 -38
  10. package/dist/{chunk-FBSL2LZQ.js → chunk-EDXLM7YL.js} +11 -30
  11. package/dist/chunk-FKZXAFIY.js +12 -0
  12. package/dist/{chunk-OQACYT2D.js → chunk-NETUFI34.js} +23 -1
  13. package/dist/{chunk-NGBDP3BM.js → chunk-QJ6FWTUG.js} +1 -1
  14. package/dist/{chunk-D2ETQBHV.js → chunk-VVW4UCDI.js} +5 -5
  15. package/dist/{chunk-TH563WLW.js → chunk-XAZGMY73.js} +10 -3
  16. package/dist/foundations/index.js +6 -0
  17. package/dist/icon/index.js +2 -2
  18. package/dist/icon-sets/brands.d.ts +0 -27
  19. package/dist/icon-sets/brands.js +7 -1
  20. package/dist/icon-sets/reicon.js +1 -1
  21. package/dist/index.d.ts +164 -23
  22. package/dist/index.js +116 -29
  23. package/dist/ios/index.d.ts +6 -1
  24. package/dist/ios/index.js +4 -3
  25. package/dist/ios/styles.css +9 -0
  26. package/dist/patterns/index.js +24 -30
  27. package/dist/presets/index.d.ts +108 -5
  28. package/dist/presets/index.js +239 -6
  29. package/dist/styles.css +57 -0
  30. package/dist/{theme-provider-CGeiusgu.d.ts → theme-provider-kQszHYji.d.ts} +1 -1
  31. package/dist/themes/atelier/atelier.css +31 -3
  32. package/dist/themes/atelier/index.d.ts +1 -1
  33. package/dist/themes/ember/ember.css +38 -7
  34. package/dist/themes/ember/index.d.ts +1 -1
  35. package/dist/themes/index.d.ts +2 -2
  36. package/dist/themes/meridian/index.d.ts +1 -1
  37. package/dist/themes/meridian/meridian.css +33 -4
  38. package/dist/themes/poppy/index.d.ts +1 -1
  39. package/dist/themes/poppy/poppy.css +28 -0
  40. package/dist/themes/prism/index.d.ts +1 -1
  41. package/dist/themes/prism/prism.css +26 -0
  42. package/dist/themes/sable/index.d.ts +1 -1
  43. package/dist/themes/sable/sable.css +24 -11
  44. package/dist/themes/verdant/index.d.ts +1 -1
  45. package/dist/themes/verdant/verdant.css +34 -4
  46. package/llms/blocks/danger-zone-block.md +1 -1
  47. package/llms/blocks/event-card-block.md +1 -1
  48. package/llms/blocks/feedback-form-block.md +1 -1
  49. package/llms/blocks/invite-team-block.md +1 -1
  50. package/llms/blocks/media-player-block.md +3 -16
  51. package/llms/blocks/notification-list-block.md +3 -3
  52. package/llms/blocks/product-card-block.md +3 -17
  53. package/llms/blocks/profile-card-block.md +4 -17
  54. package/llms/blocks/profile-form-block.md +2 -2
  55. package/llms/blocks/team-members-block.md +1 -1
  56. package/llms/blocks/testimonial-block.md +1 -1
  57. package/llms/components/avatar.md +13 -1
  58. package/llms/components/card.md +1 -1
  59. package/llms/components/gradient-art.md +80 -0
  60. package/llms/components/sheet.md +1 -1
  61. package/llms/components/tabs.md +1 -1
  62. package/llms/components/toast.md +12 -1
  63. package/llms/foundations/accessibility.md +1 -0
  64. package/llms/patterns/auth-page.md +5 -19
  65. package/llms/patterns/brand-landing-page.md +1 -1
  66. package/llms/patterns/chat-panel.md +1 -1
  67. package/llms/patterns/dashboard-page.md +4 -4
  68. package/llms/patterns/feed-page.md +4 -4
  69. package/llms/patterns/landing-page.md +1 -1
  70. package/llms/patterns/list-detail-page.md +3 -2
  71. package/llms/patterns/settings-page.md +3 -3
  72. package/llms/reference/token-intent.md +9 -1
  73. package/llms/tasks/auditing-an-app.md +6 -0
  74. package/llms/tasks/building-an-ios-prototype.md +3 -1
  75. package/llms/tasks/converting-an-app.md +1 -1
  76. package/llms/tasks/creating-a-preset.md +24 -0
  77. package/llms/tasks/reviewing-a-design.md +9 -0
  78. package/llms/tasks/using-presets.md +6 -1
  79. package/llms-full.txt +214 -106
  80. package/llms.txt +4 -3
  81. package/package.json +9 -7
  82. package/registry.json +310 -32
  83. package/scripts/design-presets.mjs +27 -4
  84. package/scripts/grade.mjs +2 -1
  85. package/scripts/label-contrast.mjs +138 -0
  86. package/scripts/lint-rules.mjs +23 -2
  87. package/skills/sable/SKILL.md +3 -1
  88. package/targets/ios.json +18 -2
  89. package/third-party/paper-shaders-LICENSE.txt +201 -0
  90. package/third-party/paper-shaders-NOTICE.txt +5 -0
package/AGENTS.md CHANGED
@@ -45,11 +45,11 @@ These are short on purpose. Each links to a foundations doc with the full vocabu
45
45
  6. **Motion has a reason and a recipe.** Apply a named `motion-*` recipe; drop to `duration-(--motion-duration-*)` + `ease-*` tokens only when none fits. Never Tailwind's `animate-*`, never a raw `duration-150`. Exits are faster than entrances and never overshoot. → `sable docs motion`
46
46
  7. **Icons by semantic name.** `<Icon name="check" />`; size with `text-{size}`, never type utilities. A mark attached to a line of type (dot, bullet, leading icon) sits in an `h-[1lh] items-center` box and is sized in em, never nudged with px. → `sable docs typography`
47
47
  8. **Every scroll container declares a policy:** `scrollbar-quiet` (vertical) or `scrollbar-hidden` (horizontal, with an edge fade carrying the affordance).
48
- 9. **Accessible by construction.** WCAG AA contrast, 44px hit areas, visible focus, logical properties, live regions that exist before they speak. → `sable docs accessibility`
48
+ 9. **Accessible by construction.** WCAG AA contrast (and a label on a brand or status fill also clears APCA Lc 60 — `label-contrast`), 44px hit areas, visible focus, logical properties, live regions that exist before they speak. → `sable docs accessibility`
49
49
 
50
50
  ## Customizing: themes are presets
51
51
 
52
- Pick a theme as a starting point, then override tokens on top of it. Every token a theme sets can be reset by the app on `:root` (or any subtree) after the theme CSS loads — radius, density, type scale, motion scale, accent — and components follow, because they only read tokens. The docs site's **Customize** panel does exactly this live and exports the result as CSS; paste it after your Sable imports. Retrieve the overridable set with `npx sable token --list`.
52
+ Pick a theme as a starting point, then override tokens on top of it. Every token a theme sets can be reset by the app on `:root` (or any subtree) after the theme CSS loads — radius, density, type scale, motion scale, accent — and components follow, because they only read tokens. The docs site's **Customize** panel does exactly this live and exports the result as CSS; paste it after your Sable imports. Retrieve the overridable set with `npx sable token --list`. When you retune a filled role (`--primary`, `--sidebar-primary`, `--highlight`, `--destructive`, `--success`, `--tone-warning`, `--tone-info`), check the label on it: `sable lint` measures the pair against the installed theme and suggests one that reads. If the pair is a deliberate brand choice, keep it with `/* design-lint-disable-next-line label-contrast -- <reason> */`. Status colours have a second token for the colour drawn on a surface — `--destructive-text`, `--success-text`, `--tone-warning-text`, `--tone-info-text` — which `text-`, `border-` and `ring-<status>` read; only `bg-<status>` reads the fill. Retune both.
53
53
 
54
54
  ## Wire it up
55
55
 
package/CHANGELOG.md CHANGED
@@ -1,5 +1,194 @@
1
1
  # What's new
2
2
 
3
+ ## 0.6.0
4
+
5
+ ### Art from the theme
6
+
7
+ - **`GradientArt`, placeholder artwork painted by a shader.** A static WebGL
8
+ mesh gradient (Paper Shaders' static mesh gradient; `variant="grain"` for
9
+ their grain gradient) painted from the theme, for covers, banners, tiles and
10
+ avatars with no image yet. `seed` fixes each item's composition and leading
11
+ colour, so a row of covers or avatars stays individually recognisable in
12
+ every theme; children render above the art. It paints once on mount, on
13
+ resize and when the theme changes, through one WebGL context shared by every
14
+ instance, so a page of avatars costs one context. A CSS gradient stays
15
+ underneath for prerendered pages and browsers without WebGL2.
16
+ - **Each preset has its own art direction.** New optional `--art-*` tokens
17
+ (`art-1` … `art-6`, `art-ground`, `art-warp`, `art-softness`, `art-grain`;
18
+ `ART_TOKENS` in the contract) set the art's palette and character, through
19
+ `--theme-art-*` hooks like the workspace surfaces. Ember burns (fire falling
20
+ off into warm black), Poppy is candy blobs, Prism airbrushed pastels, Verdant
21
+ stained glass, Meridian grainy monochrome, Atelier a dark studio with one
22
+ amber glow; Sable keeps its tones. The colours derive from each theme's own
23
+ tokens where its art direction allows, so a brand change carries into the
24
+ art. Presets may declare the hooks too.
25
+ - **`Avatar variant="art"`.** The fallback paints the initials over
26
+ `GradientArt` seeded by the person (the initials, then `alt`, or `seed`), so
27
+ everyone without a photo gets their own colour and keeps it wherever they
28
+ appear. The default stays `neutral`.
29
+ - **Blocks and patterns use it.** The product card, media player and profile
30
+ card blocks and the auth page paint their placeholder art with
31
+ `GradientArt` instead of hand-written CSS gradients, and the avatars across
32
+ the blocks and page patterns use `variant="art"`.
33
+ - **New dependency:** `@paper-design/shaders` 0.0.81 (Apache-2.0), pinned
34
+ exactly while it is pre-1.0. Only the two fragment shaders and the noise
35
+ texture are imported; the notice is in `THIRD_PARTY_NOTICES.md`.
36
+
37
+ ### Labels on filled colours
38
+
39
+ - **Labels on brand and status fills now have to pass APCA as well as WCAG.** A
40
+ filled control's label is held to 4.5:1 and APCA Lc 60 in both modes, for
41
+ `primary`, `sidebar-primary`, `highlight`, `destructive`, `success`,
42
+ `tone-warning` and `tone-info`. The ratio alone let dark ink on a bright red
43
+ through at Lc 38–46, which reads muddy. Every shipped theme is tested against
44
+ both floors, with no exceptions.
45
+ - **Status colours have a separate token for text.** `--destructive-text`,
46
+ `--success-text`, `--tone-warning-text` and `--tone-info-text`.
47
+ `text-destructive`, `border-destructive`, `ring-destructive`, `outline-`,
48
+ `decoration-`, `fill-` and `stroke-` now read the `-text` token; only
49
+ `bg-destructive` reads the fill. The class names are unchanged. A dark-mode
50
+ fill can now sit low enough for a white label while error text stays light
51
+ enough to read on a dark card. Badge's soft status tones and the destructive
52
+ Menu item mix their ink from the `-text` token too: from the fill, Meridian's
53
+ dark destructive menu item measured 3.8:1; every theme now clears 4.5:1.
54
+ **Migration:** if your app retunes `--destructive` (or `--success`,
55
+ `--tone-warning`, `--tone-info`), set its `-text` as well, or text, borders
56
+ and rings keep the theme's colour. Presets saved on 0.5 keep working: a
57
+ missing `-text` token takes its fill, which is what text read before.
58
+ - **Destructive buttons have white labels in dark mode.** In Sable, Atelier,
59
+ Ember, Meridian and Verdant the dark destructive fill moved down (Sable
60
+ 68%→59%, Meridian 66%→55%, Atelier 66%→58.5%, Verdant 64%→57.5%, Ember
61
+ 62%→58%) and its label went from dark to white. Error text reads the lighter
62
+ `-text` value, so it is no darker than before.
63
+ - **Dark highlights have white labels in Sable and Ember.** Sable's dark
64
+ `--highlight` went from 70% to 57% and Ember's from 65.2% to 58%; dark ink on
65
+ the brighter fill read at Lc 42–46.
66
+ - **Small status fixes.** In Ember, Atelier, Meridian and Verdant, dark
67
+ `success` and `tone-info` are a little lighter so the dark label clears Lc
68
+ 60. In light mode, Sable's `text-tone-info` is one step darker and Verdant's
69
+ `text-tone-warning` is 51%; both measured just under 4.5:1 on `muted`.
70
+
71
+ ### Lint and CLI
72
+
73
+ - **New `sable lint` rule: `label-contrast`.** It finds an app's overrides of a
74
+ brand or status fill or its `-foreground`, completes the pair from the
75
+ installed theme and measures both modes. Findings carry file:line and the
76
+ nearest pair that reads: the fill's lightness moves, the hue stays. Keep a
77
+ deliberate brand pair with `/* design-lint-disable-next-line label-contrast
78
+ -- <reason> */`. `sable grade` runs the same rule. Apps that passed lint on
79
+ 0.5 can fail it now.
80
+ - **`sable preset --check` measures labels on fills.** A failing pair stops the
81
+ check with `ERR_PRESET_CONTRAST`, the suggested pair and a ready-to-paste
82
+ `exceptions` entry. Presets accept an optional `exceptions` array (`rule`,
83
+ `mode`, `token`, `reason`); an accepted pair is reported with its reason on
84
+ every check instead of failing. `@sableui/react/presets` exports
85
+ `presetLabelContrast()` and the measuring helpers (`checkLabelledFills`,
86
+ `fitFillToInk`, `inkFor`, `inkPolarity`, `labelContrast`, `LABELLED_FILLS`,
87
+ `BRAND_FILLS`, `STATUS_FILLS`).
88
+
89
+ ### Sheets own their actions
90
+
91
+ - **A sheet lays out the actions inside it.** `CardFooter` has an `actions`
92
+ prop for a footer that holds only buttons. Inside a bottom `SheetContent` or
93
+ an `IosSheet`, that footer becomes a full-width stack pinned to the bottom:
94
+ the primary on top, every button at the cta height. On a page nothing
95
+ changes. `SheetFooter` stacks the same way in a bottom sheet, capped at
96
+ `max-w-sm` and centred; side sheets keep the right-aligned row.
97
+ DangerZoneBlock, FeedbackFormBlock and ProfileFormBlock mark their footers.
98
+ - **Only the sheet's own content.** A Dialog, Popover or side Sheet opened from
99
+ inside a bottom sheet presents a page again, so its footer is a row. Only the
100
+ outermost flat Card in a sheet bleeds into the sheet's padding (lining its
101
+ content up with the sheet's title and close button), and only that card's
102
+ footer stacks; a card nested inside it is left alone.
103
+ - **`PresentationProvider`, `usePresentation`** and the `Presentation` type let
104
+ a custom container declare itself a sheet.
105
+ - **`IosSheet background="opaque"`** keeps the floating medium-detent shape but
106
+ fills it solid, like SwiftUI's `.presentationBackground`, for forms whose
107
+ fields would otherwise sit on whatever the glass blurs. In an iOS sheet, a
108
+ form or grid between the content and its action bar keeps its own layout.
109
+ The iOS target is marked beta, and `sable target ios` prints its layout rules.
110
+
111
+ ### Components
112
+
113
+ - **Tab triggers space a badge or icon from the label.** Pill and underline
114
+ `TabsTrigger` use `--control-gap`, as Button does; the label's trimmed span
115
+ had dropped a typed space, so a badge touched the text.
116
+ - **NotificationListBlock puts the unread dot before the time**, so the times
117
+ line up on one right edge.
118
+
119
+ ### On sableui.com
120
+
121
+ - **Customize was redesigned.** Simple is gestures over previews: the character
122
+ pad, one shape preview for corners and depth together, a live palette strip
123
+ of what the page is painted with, single-line font rows. Advanced is a
124
+ property grid in the workspace register (compact density, `PanelSection`,
125
+ `PropertyRow`/`PropertyPair`, `ScrubField`, `Select`): one exact anchor per
126
+ domain with the ladder it resolves to beneath it. Mode, Reset all and Export
127
+ sit in a footer both views share, and every section resets on its own.
128
+ - **Status colours lean toward the brand within their meaning.** Destructive,
129
+ warning and success each move toward the brand inside a hue range the
130
+ presets already ship (destructive 5–30°, warning 50–90°, success 140–175°),
131
+ warning keeps 30° from destructive, and saturation follows the brand at half
132
+ strength with a floor. Advanced has a Status lean control.
133
+ - **Customize fits labels to brand and status fills**: the label ink is chosen
134
+ once, from the brand as picked, and each mode's fill moves to meet it.
135
+ - The homepage now says what is true: Sable is open source under MIT and
136
+ installs from npm.
137
+
138
+ ## 0.5.0
139
+
140
+ - **Stacked toasts.** `<ToastProvider stacked>` stacks toasts like a deck of
141
+ cards: the newest in front, older ones peeking out behind it a step smaller,
142
+ so a burst of toasts no longer takes over the corner of the page. Hovering
143
+ or focusing the stack fans it out into the list. It works at every
144
+ `position`. `ToastContent` is the new part that holds a toast's row; in a
145
+ stacked viewport it hides the cards behind the front one. Collapsed, the
146
+ deck casts one shadow rather than one per card, so a theme with hard offset
147
+ shadows (Poppy) stays clean.
148
+ - **Depth in Customize.** On sableui.com, Customize has a Depth control (Flat ↔
149
+ Lifted) and a Shadow style (Preset, Soft, Crisp, Offset). They write the five
150
+ `--theme-shadow-*` tokens for light and dark, so the CSS export and preset
151
+ download carry them. Depth scales the reach and strength of every step
152
+ together; lifting a flat preset such as Meridian brings in soft shadows. The
153
+ shadow takes the preset's neutral hue. Advanced replaces the slider with
154
+ exact numbers set on the largest step, which the others follow: light angle
155
+ (90° is overhead), distance, blur and opacity. Blur takes one
156
+ blur-to-distance ratio, so an Offset shadow can be softened; opacity is set
157
+ in light mode and dark mode darkens in proportion.
158
+ - **Knob shadows stay on their track.** The Switch thumb, Slider thumb and
159
+ ColorField handle used `shadow-sm`, the elevation step for raised surfaces.
160
+ Where a theme or a Depth setting made that a long offset (Poppy's hard
161
+ drops), the shadow fell outside the switch's track. Knobs now take
162
+ `--knob-shadow`, one small contact shadow that themes can set through
163
+ `--theme-knob-shadow`. In Sable it looks the same as before.
164
+ - **Pill tabs take the theme's button shape.** `TabsList variant="pill"`
165
+ triggers used the small radius, so in Sable they were rounded rectangles
166
+ beside pill buttons. They now read `--button-radius`
167
+ as `Button` does: pills in Sable, 12px in Poppy, square in Meridian, and a
168
+ theme's own button shape wherever it sets one.
169
+ - **CodeBlock no longer scrolls code under its copy button.** Without a
170
+ filename header, the copy button floated over the code, so on a narrow
171
+ screen a long line ran underneath it. It now has its own column beside the
172
+ code. The code's edges fade while there is more to scroll, like `Scroller`'s.
173
+ - **Scroller's edge fades follow content and size changes.** They were measured
174
+ on mount and on scroll only, so a list that grew, shrank or was resized kept
175
+ the fade it started with until it was scrolled.
176
+ - **Icons no longer redraw on every render.** The default Reicon set and the
177
+ brand set rewrote their SVG each time a parent re-rendered (React 19
178
+ re-applies `dangerouslySetInnerHTML` when the object changes). Inside a
179
+ toast that never settled and froze the page.
180
+ - **A card that is only a header keeps its bottom padding.** `CardHeader` leaves
181
+ the bottom padding to the content that follows it; with nothing after it (a
182
+ stat card: label, number, sparkline) the number sat on the card's edge.
183
+ - **Line and area sparklines span their own range.** They used to include zero,
184
+ so a run from 12 to 26 used only the top half of the box: a flat squiggle over
185
+ a solid block. Bar sparklines keep the zero baseline, since a bar's length is
186
+ the value.
187
+ - **Combobox chips are balanced.** The remove button was a full 28px icon
188
+ button, which swelled each chip into a 34px pill and left more room after the
189
+ label than before it. It is now drawn to the chip (20px, with a 14px X) and
190
+ keeps a 28px pointer target, so the label and the X sit evenly in a 23px chip.
191
+
3
192
  ## 0.4.0
4
193
 
5
194
  ### The default theme is now called Sable
package/CONTRIBUTING.md CHANGED
@@ -117,7 +117,7 @@ deliberately reverses: `DESIGN.md → Registers`. What a contributor needs to kn
117
117
 
118
118
  ## Theme contract
119
119
 
120
- A theme is a CSS file that defines every token in `themes/_contract.ts` for `:root` (light) and `.dark` (dark), plus an optional TS manifest. Components depend on the **semantic** layer; each theme supplies its own palette underneath. Alternate themes activate through the root `data-sable-theme` attribute; consuming apps should usually let `<SableThemeProvider>` own that wiring and read/update it through `useSableTheme()`. To make a new theme: copy `themes/sable/`, swap the palette + semantic values, keep the token names identical, add the manifest to `themes/registry.ts`, import its CSS from `src/themes.css`, add `./theme/<id>` + `./theme/<id>.css` to both `exports` and `publishConfig.exports`, and cover it in `themes.test.ts`. `build-registry` fails on a theme missing from any of those, and generates `registry.json → themes.available` from the manifests — never hand-edit that list. Re-skinning never touches component code.
120
+ A theme is a CSS file that defines every token in `themes/_contract.ts` for `:root` (light) and `.dark` (dark), plus an optional TS manifest. Components depend on the **semantic** layer; each theme supplies its own palette underneath. Alternate themes activate through the root `data-sable-theme` attribute; consuming apps should usually let `<SableThemeProvider>` own that wiring and read/update it through `useSableTheme()`. To make a new theme: copy `themes/sable/`, swap the palette + semantic values, keep the token names identical, add the manifest to `themes/registry.ts`, import its CSS from `src/themes.css`, add `./theme/<id>` + `./theme/<id>.css` to both `exports` and `publishConfig.exports`, and cover it in `themes.test.ts` — which holds every label on a brand or status fill (`primary`, `sidebar-primary`, `highlight`, `destructive`, `success`, `tone-warning`, `tone-info`) to 4.5:1 **and** APCA Lc 60 with no exceptions; apps and presets may record exceptions, Sable's own themes may not. Each status colour also has a `-text` token (the colour on a surface, read by `text-`/`border-`/`ring-<status>` through Tailwind's per-property namespaces in styles.css), gated as text on `background`, `card`, `popover` and `muted`. `build-registry` fails on a theme missing from any of those, and generates `registry.json → themes.available` from the manifests — never hand-edit that list. Re-skinning never touches component code.
121
121
 
122
122
  **Themes are presets, not the unit of customization.** Every token a theme sets can also be set by an app (or by the docs customizer) on `:root` after the theme loads. A theme is the starting point a consumer picks; the customizer exports the deltas on top of it. Keep that working: a theme must not set a token with `!important` or under a selector more specific than `[data-sable-theme="<id>"]`, or a consumer override cannot win.
123
123
 
@@ -16,10 +16,21 @@ Third-party software, fonts, and trademarks retain their own terms.
16
16
  | [React](https://github.com/facebook/react) | MIT | Component runtime |
17
17
  | [Reicon](https://github.com/dqev/reicon) | MIT | Default interface icons and bundled brand icons |
18
18
  | [Konsta UI](https://github.com/konstaui/konsta) | MIT | Optional native controls for iOS prototypes |
19
+ | [Paper Shaders](https://github.com/paper-design/shaders) | Apache-2.0 | GradientArt's mesh and grain fragment shaders |
19
20
 
20
21
  Library dependencies are external to Sable's library build and carry their
21
22
  license notices in their distributions.
22
23
 
24
+ ## Paper Shaders
25
+
26
+ GradientArt renders fragment shaders from `@paper-design/shaders` 0.0.81, a
27
+ dependency. Its vertex shader is adapted from that package's
28
+ `dist/vertex-shader.js` (outputs the two fragment shaders do not read are
29
+ removed) and lives in `src/components/gradient-art/renderer.ts`. Copyright 2026
30
+ Paper, "Powered by Paper Shaders" (https://shaders.paper.design); the license
31
+ and NOTICE are included as `third-party/paper-shaders-LICENSE.txt` and
32
+ `third-party/paper-shaders-NOTICE.txt`.
33
+
23
34
  ## Brand icons
24
35
 
25
36
  The 23 bundled brand marks come from `reicon-brands` 1.0.2. Sable normalizes them
@@ -27,6 +27,7 @@ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"
27
27
  import { dirname, join, relative, resolve, sep } from "node:path"
28
28
  import { fileURLToPath } from "node:url"
29
29
  import { ignoreGlob, lintText, makeRules, report } from "../scripts/lint-rules.mjs"
30
+ import { labelContrastScanner } from "../scripts/label-contrast.mjs"
30
31
 
31
32
  const HERE = dirname(fileURLToPath(import.meta.url))
32
33
  const PKG_ROOT = join(HERE, "..")
@@ -53,7 +54,7 @@ const motionTokens = new Set([
53
54
  REGISTRY.tokens?.motion?.scale,
54
55
  ].filter(Boolean))
55
56
 
56
- const rules = makeRules({ semanticTokens, motionTokens }).filter((r) => r.scope !== "package")
57
+ const rules = makeRules({ semanticTokens, motionTokens, labelContrast: await labelContrastScanner(PKG_ROOT, REGISTRY.themes?.default) }).filter((r) => r.scope !== "package")
57
58
 
58
59
  /* ── Args. ─────────────────────────────────────────────────────────────────── */
59
60
  const args = process.argv.slice(2)
package/bin/sable.mjs CHANGED
@@ -84,7 +84,7 @@ const COMMANDS = [
84
84
  arg: null,
85
85
  flags: [
86
86
  { name: "--template", value: null, describe: "Print a complete editable Sable preset JSON record." },
87
- { name: "--check", value: "file.json", describe: "Validate the schema and Sable token contract." },
87
+ { name: "--check", value: "file.json", describe: "Validate the schema, the Sable token contract, and labels on brand and status fills (accept a pair with `exceptions`)." },
88
88
  { name: "--css", value: "file.json", describe: "Generate scoped theme CSS from a validated preset." },
89
89
  ],
90
90
  returns: ["preset.template", "preset.check", "preset.css"],
@@ -282,6 +282,7 @@ const COMMANDS = [
282
282
  const ERROR_CODES = [
283
283
  { code: "ERR_TARGET_MISSING", when: "The installed package is missing its optional target mapping artifact." },
284
284
  { code: "ERR_PRESET_INVALID", when: "A preset is missing required metadata, semantic values, or uses unsupported tokens or CSS." },
285
+ { code: "ERR_PRESET_CONTRAST", when: "A label on a brand or status fill (primary, sidebar-primary, highlight, destructive, success, tone-warning, tone-info) misses 4.5:1 or APCA Lc 60. Apply the suggested pair, or record an `exceptions` entry with a reason." },
285
286
  { code: "ERR_AGENT_SETUP", when: "The packaged skill is missing, the destination is linked, or existing local edits would be overwritten." },
286
287
  { code: "ERR_UNKNOWN_COMMAND", when: "The first argument is not a command." },
287
288
  { code: "ERR_MISSING_ARGUMENT", when: "A command that requires an argument got none." },
@@ -1213,7 +1214,7 @@ const HANDLERS = {
1213
1214
  const contexts = (match.contexts ?? []).map(c => `${c.role}: ${c.strategy} → ${c.importFrom} (${c.components.join(" + ")})`).join("\n")
1214
1215
  ok("target.component", match, `${match.source}: ${match.strategy}\n${contexts || `${match.importFrom ?? "app"} → ${match.components.join(" + ") || match.strategy}`}\n${match.guidance}`)
1215
1216
  }
1216
- ok("target", target, `iOS ${target.platformVersion}: ${target.designPolicy.principle}\n${target.designPolicy.rule}\n` + Object.values(target.components).map(c => `${c.source}: ${c.strategy} → ${c.strategy === "contextual" ? "choose by role" : c.components.join(" + ") || "redesign for phone"}`).join("\n"))
1217
+ ok("target", target, `iOS ${target.platformVersion}${target.status ? ` (${target.status})` : ""}: ${target.designPolicy.principle}\n${target.statusNote ? `${target.statusNote}\n` : ""}${target.designPolicy.rule}\n${(target.designPolicy.layout ?? []).map((l) => `Layout: ${l}\n`).join("")}` + Object.values(target.components).map(c => `${c.source}: ${c.strategy} → ${c.strategy === "contextual" ? "choose by role" : c.components.join(" + ") || "redesign for phone"}`).join("\n"))
1217
1218
  },
1218
1219
  preset: () => cmdDesignTool("design-presets.mjs"),
1219
1220
  setup: () => {
@@ -7,7 +7,7 @@
7
7
  * To author a new theme: copy `themes/sable/`, keep these token names, and
8
8
  * supply your own palette + values. See CONTRIBUTING.md → "Theme contract".
9
9
  */
10
- declare const SEMANTIC_TOKENS: readonly ["background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "muted", "muted-foreground", "border", "border-strong", "input", "control-border", "ring", "primary", "primary-foreground", "secondary", "secondary-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground", "success", "success-foreground", "tone-standard", "tone-collectible", "tone-edition", "tone-info", "tone-warning", "highlight", "tone-standard-foreground", "tone-collectible-foreground", "tone-edition-foreground", "tone-info-foreground", "tone-warning-foreground", "highlight-foreground", "overlay", "overlay-foreground", "sidebar", "sidebar-foreground", "sidebar-primary", "sidebar-primary-foreground", "sidebar-accent", "sidebar-accent-foreground", "sidebar-border", "sidebar-ring"];
10
+ declare const SEMANTIC_TOKENS: readonly ["background", "foreground", "card", "card-foreground", "popover", "popover-foreground", "muted", "muted-foreground", "border", "border-strong", "input", "control-border", "ring", "primary", "primary-foreground", "secondary", "secondary-foreground", "accent", "accent-foreground", "destructive", "destructive-foreground", "success", "success-foreground", "tone-standard", "tone-collectible", "tone-edition", "tone-info", "tone-warning", "highlight", "tone-standard-foreground", "tone-collectible-foreground", "tone-edition-foreground", "tone-info-foreground", "tone-warning-foreground", "highlight-foreground", "destructive-text", "success-text", "tone-warning-text", "tone-info-text", "overlay", "overlay-foreground", "sidebar", "sidebar-foreground", "sidebar-primary", "sidebar-primary-foreground", "sidebar-accent", "sidebar-accent-foreground", "sidebar-border", "sidebar-ring"];
11
11
  type SemanticToken = (typeof SEMANTIC_TOKENS)[number];
12
12
  /**
13
13
  * Motion tokens (mode-independent). A theme MAY override these to retune its
@@ -106,6 +106,24 @@ type DensityToken = (typeof DENSITY_TOKENS)[number];
106
106
  */
107
107
  declare const WORKSPACE_SURFACE_TOKENS: readonly ["canvas", "canvas-foreground", "panel", "panel-foreground", "panel-raised", "panel-field", "panel-field-hover", "hairline", "hairline-strong"];
108
108
  type WorkspaceSurfaceToken = (typeof WORKSPACE_SURFACE_TOKENS)[number];
109
+ /**
110
+ * Art direction for generated imagery (GradientArt) — like the workspace
111
+ * surfaces, every theme gets them from styles.css with a `--theme-*` hook, so a
112
+ * theme declares only what makes its art its own.
113
+ *
114
+ * - `art-1` … `art-6` are the colours the art is painted from, in placement
115
+ * order; unset slots are transparent and skipped. The default is the four
116
+ * decorative tones, lifted to an art strength.
117
+ * - `art-ground` is what the colours glow over (grain's ground, the CSS
118
+ * fallback's base). The default is the brand steeped in the first tone.
119
+ * - `art-warp` (0–1) bends the field; `art-softness` (0–1) is how far colours
120
+ * melt into each other, 0 crisp blobs to 1 one wash; `art-grain` (0–1) is a
121
+ * film-grain overlay for themes whose imagery carries one.
122
+ *
123
+ * Derive the colours from the theme's own tokens (`oklch(from var(--primary)
124
+ * …)`) where the art direction allows, so a brand change carries into the art.
125
+ */
126
+ declare const ART_TOKENS: readonly ["art-1", "art-2", "art-3", "art-4", "art-5", "art-6", "art-ground", "art-warp", "art-softness", "art-grain"];
109
127
  /**
110
128
  * UI type tiers (mode-independent) — the chrome typography register.
111
129
  *
@@ -152,4 +170,4 @@ interface ThemeManifest {
152
170
  };
153
171
  }
154
172
 
155
- export { DENSITY_TOKENS as D, MOTION_TOKENS as M, RADIUS_TOKENS as R, SEMANTIC_TOKENS as S, type ThemeManifest as T, WORKSPACE_SURFACE_TOKENS as W, type ThemeMode as a, type DensityToken as b, type MotionToken as c, type RadiusToken as d, type SemanticToken as e, TYPE_OVERLINE_TOKENS as f, TYPE_REFINEMENT_TOKENS as g, TYPE_SCALE_TOKENS as h, TYPE_UI_TOKENS as i, type TypeOverlineToken as j, type TypeRefinementToken as k, type TypeScaleToken as l, type TypeUiToken as m, type WorkspaceSurfaceToken as n };
173
+ export { ART_TOKENS as A, DENSITY_TOKENS as D, MOTION_TOKENS as M, RADIUS_TOKENS as R, SEMANTIC_TOKENS as S, type ThemeManifest as T, WORKSPACE_SURFACE_TOKENS as W, type ThemeMode as a, type DensityToken as b, type MotionToken as c, type RadiusToken as d, type SemanticToken as e, TYPE_OVERLINE_TOKENS as f, TYPE_REFINEMENT_TOKENS as g, TYPE_SCALE_TOKENS as h, TYPE_UI_TOKENS as i, type TypeOverlineToken as j, type TypeRefinementToken as k, type TypeScaleToken as l, type TypeUiToken as m, type WorkspaceSurfaceToken as n };
@@ -1,9 +1,10 @@
1
- import { Kbd, CommandMenuPanel, ToggleGroup, Toggle, BarChart, DateTile, Accordion, AccordionItem, AccordionTrigger, AccordionPanel, RadialChart, Sparkline, Slider, Stepper, StepperItem, SegmentedBar, Checkbox, PieChart, AreaChart, OtpField } from '../chunk-FBSL2LZQ.js';
2
- import { Card, CardHeader, CardTitle, CardDescription, CardAction, Button, CardList, Entity, OverflowMenu, MenuItem, MenuSeparator, Code, CardContent, Message, Bubble, CardFooter, DestructiveActionModal, Badge, AvatarGroup, Avatar, Textarea, CodeBlock, Input, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, StatusDot, Switch, Form, Field, Select, SelectTrigger, SelectContent, SelectItem, Separator, Label, Attachment } from '../chunk-CCUDFLU3.js';
1
+ import { Kbd, CommandMenuPanel, ToggleGroup, Toggle, BarChart, DateTile, Accordion, AccordionItem, AccordionTrigger, AccordionPanel, RadialChart, Sparkline, Slider, Stepper, StepperItem, SegmentedBar, Checkbox, PieChart, AreaChart, OtpField } from '../chunk-EDXLM7YL.js';
2
+ import { Card, CardHeader, CardTitle, CardDescription, CardAction, Button, CardList, Entity, OverflowMenu, MenuItem, MenuSeparator, Code, CardContent, Message, Bubble, CardFooter, DestructiveActionModal, Badge, AvatarGroup, Avatar, Textarea, CodeBlock, Input, Table, TableHeader, TableRow, TableHead, TableBody, TableCell, GradientArt, StatusDot, Switch, Form, Field, Select, SelectTrigger, SelectContent, SelectItem, Separator, Label, Attachment } from '../chunk-7QSDYBXO.js';
3
+ import '../chunk-FKZXAFIY.js';
3
4
  import { Region, Stack, Row, Grid } from '../chunk-3IWZYBGH.js';
4
5
  import { cn } from '../chunk-SRQWQUTW.js';
5
- import { Icon } from '../chunk-NGBDP3BM.js';
6
- import '../chunk-TH563WLW.js';
6
+ import { Icon } from '../chunk-QJ6FWTUG.js';
7
+ import '../chunk-XAZGMY73.js';
7
8
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
8
9
  import { useId, useState } from 'react';
9
10
 
@@ -162,7 +163,7 @@ function DangerZoneBlock({ className, framed = true, onDelete }) {
162
163
  /* @__PURE__ */ jsx(CardTitle, { render: /* @__PURE__ */ jsx("h2", {}), children: "Delete account" }),
163
164
  /* @__PURE__ */ jsx(CardDescription, { children: "Permanently remove your profile, editions, and sales history. Collectors keep the editions they own." })
164
165
  ] }),
165
- /* @__PURE__ */ jsx(CardFooter, { className: "justify-end", children: /* @__PURE__ */ jsx(Button, { variant: "destructive", onClick: () => setOpen(true), children: "Delete account" }) })
166
+ /* @__PURE__ */ jsx(CardFooter, { actions: true, className: "justify-end", children: /* @__PURE__ */ jsx(Button, { variant: "destructive", onClick: () => setOpen(true), children: "Delete account" }) })
166
167
  ] }),
167
168
  /* @__PURE__ */ jsx(
168
169
  DestructiveActionModal,
@@ -300,7 +301,7 @@ function EventCardBlock({ className, framed = true, onRespond }) {
300
301
  ] })
301
302
  ] }),
302
303
  /* @__PURE__ */ jsxs(CardFooter, { className: "flex-wrap justify-between gap-3", children: [
303
- /* @__PURE__ */ jsx(AvatarGroup, { "aria-label": `${event.total} attendees`, total: event.total, size: "sm", surface: framed ? "card" : void 0, fit: true, children: event.attendees.map((initials) => /* @__PURE__ */ jsx(Avatar, { fallback: initials, alt: initials }, initials)) }),
304
+ /* @__PURE__ */ jsx(AvatarGroup, { "aria-label": `${event.total} attendees`, total: event.total, size: "sm", surface: framed ? "card" : void 0, fit: true, children: event.attendees.map((initials) => /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: initials, alt: initials }, initials)) }),
304
305
  /* @__PURE__ */ jsxs("div", { className: "flex gap-2", children: [
305
306
  /* @__PURE__ */ jsx(Button, { variant: "outline", size: "sm", onClick: () => onRespond?.("decline"), children: "Decline" }),
306
307
  /* @__PURE__ */ jsx(Button, { size: "sm", onClick: () => onRespond?.("join"), children: "Join" })
@@ -434,7 +435,7 @@ function FeedbackFormBlock({ className, framed = true, onDone }) {
434
435
  /* @__PURE__ */ jsx(ToggleGroup, { value: [score], onValueChange: (v) => v[0] && setScore(String(v[0])), "aria-label": "Rating", className: "w-full", children: scale.map((n) => /* @__PURE__ */ jsx(Toggle, { value: n, className: "flex-1", children: n }, n)) }),
435
436
  /* @__PURE__ */ jsx(Textarea, { placeholder: "What worked, what didn\u2019t\u2026", "aria-label": "Notes", rows: 3, value: notes, onChange: (e) => setNotes(e.target.value) })
436
437
  ] }),
437
- /* @__PURE__ */ jsxs(CardFooter, { className: "justify-end", children: [
438
+ /* @__PURE__ */ jsxs(CardFooter, { actions: true, className: "justify-end", children: [
438
439
  /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "sm", onClick: () => onDone?.(false), children: "Skip" }),
439
440
  /* @__PURE__ */ jsx(Button, { size: "sm", onClick: () => onDone?.(true, { score: Number(score), notes }), children: "Send feedback" })
440
441
  ] })
@@ -543,7 +544,7 @@ function InviteTeamBlock({ className, framed = true, onSend }) {
543
544
  /* @__PURE__ */ jsx(CardDescription, { children: "Collaborators can edit every project." })
544
545
  ] }),
545
546
  /* @__PURE__ */ jsxs(CardContent, { className: "flex flex-col gap-4", children: [
546
- /* @__PURE__ */ jsx(AvatarGroup, { "aria-label": `${collaborators.length} collaborators`, size: "sm", surface: framed ? "card" : void 0, children: collaborators.map((initials) => /* @__PURE__ */ jsx(Avatar, { fallback: initials, alt: initials }, initials)) }),
547
+ /* @__PURE__ */ jsx(AvatarGroup, { "aria-label": `${collaborators.length} collaborators`, size: "sm", surface: framed ? "card" : void 0, children: collaborators.map((initials) => /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: initials, alt: initials }, initials)) }),
547
548
  /* @__PURE__ */ jsxs(
548
549
  "form",
549
550
  {
@@ -665,17 +666,6 @@ var kpiSparklineBlockMeta = {
665
666
  ]
666
667
  };
667
668
  var track = { title: "Low tide, high noon", artist: "Juno Avery", position: 38, elapsed: "1:24", remaining: "-2:16" };
668
- var ART_TONES = ["info", "collectible", "edition"];
669
- var ART_SPOTS = [[20, 20, 70], [85, 30, 60], [60, 90, 65]];
670
- var cover = [
671
- ...ART_SPOTS.map(
672
- ([x, y, reach], i) => (
673
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
674
- `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}%)`
675
- )
676
- ),
677
- "var(--primary)"
678
- ].join(", ");
679
669
  function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }) {
680
670
  const [playing, setPlaying] = useState(true);
681
671
  return /* @__PURE__ */ jsx(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-xs", className), children: /* @__PURE__ */ jsxs(CardContent, { className: "flex flex-col gap-4", children: [
@@ -683,7 +673,7 @@ function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPreviou
683
673
  Entity,
684
674
  {
685
675
  className: "p-0",
686
- leading: /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "size-14 rounded-md", style: { background: cover } }),
676
+ leading: /* @__PURE__ */ jsx(GradientArt, { "aria-hidden": true, seed: track.title, className: "size-14 rounded-md" }),
687
677
  title: track.title,
688
678
  subtitle: track.artist,
689
679
  action: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Like", children: /* @__PURE__ */ jsx(Icon, { name: "heart" }) })
@@ -821,15 +811,15 @@ function NotificationListBlock({ className, framed = true }) {
821
811
  return /* @__PURE__ */ jsx(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-xs overflow-hidden", className), children: /* @__PURE__ */ jsx(CardList, { children: notifications.map((n) => /* @__PURE__ */ jsx(
822
812
  Entity,
823
813
  {
824
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: n.initials, alt: n.name }),
814
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: n.initials, alt: n.name }),
825
815
  title: /* @__PURE__ */ jsxs(Fragment, { children: [
826
816
  n.name,
827
817
  n.unread ? /* @__PURE__ */ jsx("span", { className: "sr-only", children: " (unread)" }) : null
828
818
  ] }),
829
819
  subtitle: n.what,
830
820
  action: /* @__PURE__ */ jsxs("span", { className: "flex items-center gap-1.5 text-caption text-muted-foreground", children: [
831
- n.when,
832
- n.unread ? /* @__PURE__ */ jsx(StatusDot, { tone: "info" }) : null
821
+ n.unread ? /* @__PURE__ */ jsx(StatusDot, { tone: "info" }) : null,
822
+ n.when
833
823
  ] })
834
824
  },
835
825
  n.name
@@ -846,7 +836,7 @@ var notificationListBlockMeta = {
846
836
  intent: "Reach for this for a notifications popover, a recent-activity card or an inbox preview. A full inbox with selection and bulk actions is a ListDetailPage.",
847
837
  notes: [
848
838
  "Rows are Card + CardList + Entity, inset from the card's padding at any density.",
849
- "Unread is a quiet info dot after the time \u2014 the list stays calm when most items are unread.",
839
+ "Unread is a quiet info dot before the time, so the times keep one right edge \u2014 the list stays calm when most items are unread.",
850
840
  "framed={false} drops the surface for a Popover, which already is one."
851
841
  ],
852
842
  a11y: [
@@ -1071,20 +1061,9 @@ var pricingTableBlockMeta = {
1071
1061
  ]
1072
1062
  };
1073
1063
  var product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" };
1074
- var ART_TONES2 = ["info", "collectible", "edition", "standard"];
1075
- var ART_SPOTS2 = [[18, 22, 58], [82, 18, 55], [70, 88, 60], [12, 92, 55]];
1076
- var artwork = [
1077
- ...ART_SPOTS2.map(
1078
- ([x, y, reach], i) => (
1079
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
1080
- `radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES2[i % ART_TONES2.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`
1081
- )
1082
- ),
1083
- "var(--primary)"
1084
- ].join(", ");
1085
1064
  function ProductCardBlock({ className, framed = true, onCollect }) {
1086
1065
  return /* @__PURE__ */ jsxs(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-xs overflow-hidden", className), children: [
1087
- /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "aspect-[4/3]", style: { background: artwork } }),
1066
+ /* @__PURE__ */ jsx(GradientArt, { "aria-hidden": true, seed: product.title, className: "aspect-[4/3]" }),
1088
1067
  /* @__PURE__ */ jsxs(CardHeader, { children: [
1089
1068
  /* @__PURE__ */ jsx(CardTitle, { children: product.title }),
1090
1069
  /* @__PURE__ */ jsx(CardDescription, { children: product.maker }),
@@ -1125,24 +1104,13 @@ var profile = {
1125
1104
  { value: "4.2k", label: "followers" }
1126
1105
  ]
1127
1106
  };
1128
- var ART_TONES3 = ["info", "collectible", "edition", "standard"];
1129
- var ART_SPOTS3 = [[10, 50, 60], [50, 0, 55], [90, 60, 60], [40, 100, 50]];
1130
- var banner = [
1131
- ...ART_SPOTS3.map(
1132
- ([x, y, reach], i) => (
1133
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
1134
- `radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES3[i % ART_TONES3.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`
1135
- )
1136
- ),
1137
- "var(--primary)"
1138
- ].join(", ");
1139
1107
  function ProfileCardBlock({ className, framed = true, onFollowChange, onShare }) {
1140
1108
  const [following, setFollowing] = useState(false);
1141
1109
  return /* @__PURE__ */ jsxs(Card, { variant: framed ? "default" : "flat", className: cn("w-full max-w-xs overflow-hidden", className), children: [
1142
- /* @__PURE__ */ jsx("div", { "aria-hidden": true, className: "h-20", style: { background: banner } }),
1110
+ /* @__PURE__ */ jsx(GradientArt, { "aria-hidden": true, seed: profile.handle, className: "h-20" }),
1143
1111
  /* @__PURE__ */ jsxs(CardContent, { className: "flex flex-col gap-3 pt-0", children: [
1144
1112
  /* @__PURE__ */ jsxs("div", { className: "flex items-start justify-between", children: [
1145
- /* @__PURE__ */ jsx(Avatar, { size: "lg", fallback: profile.initials, alt: profile.name, className: "-mt-6 ring-4 ring-card" }),
1113
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "lg", fallback: profile.initials, alt: profile.name, className: "-mt-6 ring-4 ring-card" }),
1146
1114
  /* @__PURE__ */ jsxs("div", { className: "flex gap-1.5 pt-3", children: [
1147
1115
  /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": "Share profile", onClick: onShare, children: /* @__PURE__ */ jsx(Icon, { name: "share" }) }),
1148
1116
  /* @__PURE__ */ jsx(
@@ -1222,7 +1190,7 @@ function ProfileFormBlock({ className, framed = true, onSave }) {
1222
1190
  children: [
1223
1191
  /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
1224
1192
  /* @__PURE__ */ jsxs(Row, { gap: 2, children: [
1225
- /* @__PURE__ */ jsx(Avatar, { size: "lg", fallback: profile2.initials, alt: profile2.name }),
1193
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "lg", fallback: profile2.initials, alt: profile2.name }),
1226
1194
  /* @__PURE__ */ jsxs(Stack, { gap: 0.5, align: "start", children: [
1227
1195
  /* @__PURE__ */ jsxs(Row, { gap: 1, children: [
1228
1196
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", children: "Upload photo" }),
@@ -1241,7 +1209,7 @@ function ProfileFormBlock({ className, framed = true, onSave }) {
1241
1209
  /* @__PURE__ */ jsx(SelectContent, { children: timezones.map((zone) => /* @__PURE__ */ jsx(SelectItem, { value: zone.value, children: zone.label }, zone.value)) })
1242
1210
  ] }) }) })
1243
1211
  ] }) }),
1244
- /* @__PURE__ */ jsx(CardFooter, { className: "justify-end", children: /* @__PURE__ */ jsx(Button, { type: "submit", children: "Save profile" }) })
1212
+ /* @__PURE__ */ jsx(CardFooter, { actions: true, className: "justify-end", children: /* @__PURE__ */ jsx(Button, { type: "submit", children: "Save profile" }) })
1245
1213
  ]
1246
1214
  }
1247
1215
  )
@@ -1639,7 +1607,7 @@ function TeamMembersBlock({ className, framed = true, onInvite, onMemberAction }
1639
1607
  /* @__PURE__ */ jsx(CardList, { children: members.map((m) => /* @__PURE__ */ jsx(
1640
1608
  Entity,
1641
1609
  {
1642
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: m.initials, alt: m.name }),
1610
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: m.initials, alt: m.name }),
1643
1611
  title: m.name,
1644
1612
  subtitle: m.email,
1645
1613
  action: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
@@ -1697,7 +1665,7 @@ function TestimonialBlock({ className }) {
1697
1665
  ] })
1698
1666
  ] }),
1699
1667
  /* @__PURE__ */ jsxs("figcaption", { className: "flex items-center gap-3", children: [
1700
- /* @__PURE__ */ jsx(Avatar, { size: "lg", fallback: story.initials, alt: story.name }),
1668
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "lg", fallback: story.initials, alt: story.name }),
1701
1669
  /* @__PURE__ */ jsxs("div", { children: [
1702
1670
  /* @__PURE__ */ jsx("p", { className: "text-title-sm", children: story.name }),
1703
1671
  /* @__PURE__ */ jsx("p", { className: "text-body-sm text-muted-foreground", children: story.role })