@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
@@ -1,5 +1,6 @@
1
- import { TextReveal, Reveal, MessageScroller, Marker, Toolbar, ToolbarButton, StreamingText, ChatComposer, SettingsMenu, SettingsRow, AppShell, PageHeader, DataTable, Meter, RelativeTime, Sidebar, SidebarHeader, SidebarNav, SidebarItem, NotificationBadge, SidebarFooter, BottomNav, BottomNavItem, AppHeader, Tabs, TabsList, TabsTrigger, TabsPanel, Skeleton, Browser, MeterLabel, MeterValue, MeterTrack, SearchInput, EmptyState, SideNav, SideNavItem } from '../chunk-ZBZMKOO7.js';
2
- import { Form, Field, Input, Button, Separator, Card, CardContent, Badge, Entity, Avatar, StatusDot, CardHeader, CardTitle, CardDescription, CardAction, Message, Bubble, CodeBlock, SimpleTooltip, Attachment, CardFooter, MenuRadioGroup, MenuRadioItem, CardList, OverflowMenu, MenuItem, MenuSeparator, Textarea, Code, Select, SelectTrigger, SelectContent, SelectItem, DestructiveActionModal, Switch } from '../chunk-RCLKXF2N.js';
1
+ import { TextReveal, Reveal, MessageScroller, Marker, Toolbar, ToolbarButton, StreamingText, ChatComposer, SettingsMenu, SettingsRow, AppShell, PageHeader, DataTable, Meter, RelativeTime, Sidebar, SidebarHeader, SidebarNav, SidebarItem, NotificationBadge, SidebarFooter, BottomNav, BottomNavItem, AppHeader, Tabs, TabsList, TabsTrigger, TabsPanel, Skeleton, Browser, MeterLabel, MeterValue, MeterTrack, SearchInput, EmptyState, SideNav, SideNavItem } from '../chunk-VVW4UCDI.js';
2
+ import { Form, Field, Input, Button, Separator, GradientArt, Card, CardContent, Badge, Entity, Avatar, StatusDot, CardHeader, CardTitle, CardDescription, CardAction, Message, Bubble, CodeBlock, SimpleTooltip, Attachment, CardFooter, MenuRadioGroup, MenuRadioItem, CardList, OverflowMenu, MenuItem, MenuSeparator, Textarea, Code, Select, SelectTrigger, SelectContent, SelectItem, DestructiveActionModal, Switch } from '../chunk-7QSDYBXO.js';
3
+ import '../chunk-FKZXAFIY.js';
3
4
  import { Region, Stack, Row, Grid, Page, Columns, Col, AspectBox } from '../chunk-3IWZYBGH.js';
4
5
  import '../chunk-SRQWQUTW.js';
5
6
  import { Icon } from '../chunk-QJ6FWTUG.js';
@@ -7,17 +8,6 @@ import '../chunk-XAZGMY73.js';
7
8
  import { useState, useRef, useEffect } from 'react';
8
9
  import { jsxs, jsx, Fragment } from 'react/jsx-runtime';
9
10
 
10
- var artwork = [
11
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
12
- "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%)",
13
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
14
- "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%)",
15
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
16
- "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%)",
17
- // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
18
- "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%)",
19
- "var(--primary)"
20
- ].join(", ");
21
11
  function AuthPage() {
22
12
  const [pending2, setPending] = useState(false);
23
13
  const [errors, setErrors] = useState({});
@@ -76,14 +66,14 @@ function AuthPage() {
76
66
  "."
77
67
  ] })
78
68
  ] }) }),
79
- /* @__PURE__ */ jsx("aside", { "aria-label": "Featured release", className: "hidden p-3 lg:flex", children: /* @__PURE__ */ jsx("div", { className: "flex flex-1 flex-col justify-end rounded-xl p-8", style: { background: artwork }, children: /* @__PURE__ */ jsx(Card, { className: "max-w-md", children: /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
69
+ /* @__PURE__ */ jsx("aside", { "aria-label": "Featured release", className: "hidden p-3 lg:flex", children: /* @__PURE__ */ jsx(GradientArt, { seed: "Untitled (gold rush)", className: "flex flex-1 flex-col justify-end rounded-xl p-8", children: /* @__PURE__ */ jsx(Card, { className: "max-w-md", children: /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
80
70
  /* @__PURE__ */ jsx(Badge, { tone: "edition", className: "self-start", children: "Edition of 50" }),
81
71
  /* @__PURE__ */ jsx("p", { className: "text-title-lg", children: "\u201CEvery edition is a conversation with whoever holds it next.\u201D" }),
82
72
  /* @__PURE__ */ jsx(
83
73
  Entity,
84
74
  {
85
75
  className: "p-0",
86
- leading: /* @__PURE__ */ jsx(Avatar, { fallback: "MO" }),
76
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: "MO" }),
87
77
  title: "Mara Okafor",
88
78
  subtitle: "Untitled (gold rush), 2026"
89
79
  }
@@ -353,7 +343,7 @@ function BrandLandingPage() {
353
343
  /* @__PURE__ */ jsx("blockquote", { className: "text-balance text-heading-3 sm:text-heading-2", children: "\u201CWe used to chase approvals across four tools. Now every request has an owner, a deadline, and a record, and our auditors stopped asking for screenshots.\u201D" })
354
344
  ] }),
355
345
  /* @__PURE__ */ jsxs("figcaption", { className: "flex items-center gap-3", children: [
356
- /* @__PURE__ */ jsx(Avatar, { size: "lg", fallback: "MO" }),
346
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "lg", fallback: "MO" }),
357
347
  /* @__PURE__ */ jsxs("div", { children: [
358
348
  /* @__PURE__ */ jsx("p", { className: "text-title-sm", children: "Maya Okafor" }),
359
349
  /* @__PURE__ */ jsx("p", { className: "text-body-sm text-muted-foreground", children: "Head of operations, Fielddesk" })
@@ -486,6 +476,7 @@ function ChatPanel() {
486
476
  const assistant = /* @__PURE__ */ jsx(
487
477
  Avatar,
488
478
  {
479
+ variant: "art",
489
480
  size: "sm",
490
481
  alt: "Assistant",
491
482
  fallback: /* @__PURE__ */ jsx(Icon, { name: "sparkles", className: "text-base" })
@@ -711,7 +702,7 @@ function DashboardPage() {
711
702
  /* @__PURE__ */ jsx(Icon, { name: "gem", variant: "solid", className: "text-xl" }),
712
703
  /* @__PURE__ */ jsx("span", { className: "text-title-sm", children: "Atelier" })
713
704
  ] }),
714
- actions: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: "AL", alt: "Ada Lovelace" })
705
+ actions: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: "AL", alt: "Ada Lovelace" })
715
706
  }
716
707
  ),
717
708
  bottomNav: /* @__PURE__ */ jsxs(BottomNav, { "aria-label": "Primary", children: [
@@ -745,7 +736,7 @@ function DashboardPage() {
745
736
  Entity,
746
737
  {
747
738
  className: "[--entity-py:0]",
748
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: "AL", alt: "" }),
739
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: "AL", alt: "" }),
749
740
  title: "Ada Lovelace",
750
741
  subtitle: "@ada",
751
742
  action: /* @__PURE__ */ jsxs(OverflowMenu, { label: "Account", size: "icon-sm", children: [
@@ -890,7 +881,7 @@ function DashboardPage() {
890
881
  /* @__PURE__ */ jsx(CardList, { children: collectors.map((person) => /* @__PURE__ */ jsx(
891
882
  Entity,
892
883
  {
893
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: person.initials, alt: "" }),
884
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: person.initials, alt: "" }),
894
885
  title: person.name,
895
886
  subtitle: `${person.handle} \xB7 ${person.pieces} pieces`,
896
887
  action: /* @__PURE__ */ jsx("span", { className: "text-mono-sm", children: usd.format(person.spent) })
@@ -929,7 +920,7 @@ function DashboardPage() {
929
920
  /* @__PURE__ */ jsx(CardList, { children: activity.map((item) => /* @__PURE__ */ jsx(
930
921
  Entity,
931
922
  {
932
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: item.initials, alt: "" }),
923
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: item.initials, alt: "" }),
933
924
  title: item.who,
934
925
  subtitle: item.what,
935
926
  action: /* @__PURE__ */ jsx(RelativeTime, { date: Date.now() - item.when, className: "text-caption text-muted-foreground" })
@@ -1019,7 +1010,7 @@ var renderPost = (post) => /* @__PURE__ */ jsx("article", { children: /* @__PURE
1019
1010
  /* @__PURE__ */ jsx(CardList, { children: /* @__PURE__ */ jsx(
1020
1011
  Entity,
1021
1012
  {
1022
- leading: /* @__PURE__ */ jsx(Avatar, { fallback: post.initials, alt: post.author }),
1013
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: post.initials, alt: post.author }),
1023
1014
  title: post.author,
1024
1015
  subtitle: `${post.handle} \xB7 ${post.time}`,
1025
1016
  action: /* @__PURE__ */ jsxs(OverflowMenu, { label: `Post options for ${post.author}`, size: "icon-sm", children: [
@@ -1082,7 +1073,7 @@ function FeedPage() {
1082
1073
  /* @__PURE__ */ jsxs(Stack, { gap: 3, className: "min-w-0 max-w-(--region-feed) flex-1", children: [
1083
1074
  /* @__PURE__ */ jsx(PageHeader, { title: "Home" }),
1084
1075
  /* @__PURE__ */ jsx(Card, { children: /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(Row, { gap: 1.5, align: "start", children: [
1085
- /* @__PURE__ */ jsx(Avatar, { fallback: "AL", alt: "Ada Lovelace" }),
1076
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: "AL", alt: "Ada Lovelace" }),
1086
1077
  /* @__PURE__ */ jsxs(Stack, { gap: 1.5, className: "min-w-0 flex-1", children: [
1087
1078
  /* @__PURE__ */ jsx(
1088
1079
  Textarea,
@@ -1131,7 +1122,7 @@ function FeedPage() {
1131
1122
  /* @__PURE__ */ jsx(CardList, { children: suggestions.map((person) => /* @__PURE__ */ jsx(
1132
1123
  Entity,
1133
1124
  {
1134
- leading: /* @__PURE__ */ jsx(Avatar, { fallback: person.initials, alt: person.name }),
1125
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: person.initials, alt: person.name }),
1135
1126
  title: person.name,
1136
1127
  subtitle: person.handle,
1137
1128
  action: /* @__PURE__ */ jsx(Button, { variant: "secondary", size: "sm", "aria-label": `Follow ${person.name}`, children: "Follow" })
@@ -1144,7 +1135,7 @@ function FeedPage() {
1144
1135
  /* @__PURE__ */ jsx(CardList, { children: upcoming2.map((drop) => /* @__PURE__ */ jsx(
1145
1136
  Entity,
1146
1137
  {
1147
- leading: /* @__PURE__ */ jsx(Avatar, { fallback: drop.initials, alt: drop.artist }),
1138
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", fallback: drop.initials, alt: drop.artist }),
1148
1139
  title: drop.title,
1149
1140
  subtitle: `${drop.artist} \xB7 ${drop.date}`,
1150
1141
  action: /* @__PURE__ */ jsx(Button, { variant: "ghost", size: "icon-sm", "aria-label": `Remind me about ${drop.title}`, children: /* @__PURE__ */ jsx(Icon, { name: "bell" }) })
@@ -1394,7 +1385,7 @@ function LandingPage() {
1394
1385
  /* @__PURE__ */ jsx(CardList, { children: deploys.map((deploy) => /* @__PURE__ */ jsx(
1395
1386
  Entity,
1396
1387
  {
1397
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: deploy.initials, alt: deploy.name }),
1388
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: deploy.initials, alt: deploy.name }),
1398
1389
  title: deploy.title,
1399
1390
  subtitle: deploy.meta,
1400
1391
  action: /* @__PURE__ */ jsx(Badge, { appearance: "plain", tone: deploy.tone, dot: true, pulse: deploy.pulse, children: deploy.status })
@@ -1721,6 +1712,8 @@ function ListDetailPage() {
1721
1712
  leading: /* @__PURE__ */ jsx(
1722
1713
  Avatar,
1723
1714
  {
1715
+ variant: "art",
1716
+ seed: m.name,
1724
1717
  fallback: m.pending ? /* @__PURE__ */ jsx(Icon, { name: "mail", className: "size-5" }) : m.initials,
1725
1718
  status: m.lastActive === "Online now" ? /* @__PURE__ */ jsx(StatusDot, { tone: "success" }) : void 0,
1726
1719
  "aria-hidden": true
@@ -1773,6 +1766,7 @@ function ListDetailPage() {
1773
1766
  /* @__PURE__ */ jsx(
1774
1767
  Avatar,
1775
1768
  {
1769
+ variant: "art",
1776
1770
  size: "lg",
1777
1771
  fallback: selected.pending ? /* @__PURE__ */ jsx(Icon, { name: "mail", className: "size-6" }) : selected.initials,
1778
1772
  status: selected.lastActive === "Online now" ? /* @__PURE__ */ jsx(StatusDot, { tone: "success" }) : void 0,
@@ -1928,7 +1922,7 @@ function SettingsPage() {
1928
1922
  /* @__PURE__ */ jsx(Icon, { name: "gem", variant: "solid", className: "text-xl" }),
1929
1923
  /* @__PURE__ */ jsx("span", { className: "text-title-sm", children: "Atelier" })
1930
1924
  ] }),
1931
- actions: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: "AL", alt: "Ada Lovelace" })
1925
+ actions: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: "AL", alt: "Ada Lovelace" })
1932
1926
  }
1933
1927
  ),
1934
1928
  bottomNav: /* @__PURE__ */ jsxs(BottomNav, { "aria-label": "Primary", children: [
@@ -1962,7 +1956,7 @@ function SettingsPage() {
1962
1956
  Entity,
1963
1957
  {
1964
1958
  className: "[--entity-py:0]",
1965
- leading: /* @__PURE__ */ jsx(Avatar, { size: "sm", fallback: "AL", alt: "" }),
1959
+ leading: /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "sm", fallback: "AL", alt: "" }),
1966
1960
  title: "Ada Lovelace",
1967
1961
  subtitle: "@ada",
1968
1962
  action: /* @__PURE__ */ jsxs(OverflowMenu, { label: "Account", size: "icon-sm", children: [
@@ -2003,7 +1997,7 @@ function SettingsPage() {
2003
1997
  /* @__PURE__ */ jsxs(Form, { onSubmit: (event) => event.preventDefault(), className: "gap-0", children: [
2004
1998
  /* @__PURE__ */ jsx(CardContent, { children: /* @__PURE__ */ jsxs(Stack, { gap: 3, children: [
2005
1999
  /* @__PURE__ */ jsxs(Row, { gap: 2, children: [
2006
- /* @__PURE__ */ jsx(Avatar, { size: "lg", fallback: "AL", alt: "Ada Lovelace" }),
2000
+ /* @__PURE__ */ jsx(Avatar, { variant: "art", size: "lg", fallback: "AL", alt: "Ada Lovelace" }),
2007
2001
  /* @__PURE__ */ jsxs(Stack, { gap: 0.5, align: "start", children: [
2008
2002
  /* @__PURE__ */ jsxs(Row, { gap: 1, children: [
2009
2003
  /* @__PURE__ */ jsx(Button, { type: "button", variant: "outline", size: "sm", children: "Upload photo" }),
@@ -1,4 +1,81 @@
1
- import { T as ThemeManifest } from '../_contract-Cn3lUpih.js';
1
+ import { T as ThemeManifest } from '../_contract-C2M1SjBf.js';
2
+
3
+ /** An OKLCH colour, lightness 0–1. Extra fields (alpha) ride along untouched. */
4
+ interface OklchColor {
5
+ l: number;
6
+ c: number;
7
+ h: number;
8
+ }
9
+ /** Ratio and Lc of a label on a fill, and whether it clears both floors. */
10
+ declare function labelContrast(ink: OklchColor, fill: OklchColor): {
11
+ ratio: number;
12
+ lc: number;
13
+ reads: boolean;
14
+ };
15
+ /**
16
+ * The ink for a fill that cannot move (a status colour, a decorative tone).
17
+ * One that reads; of two, the stronger. Of none, the one meeting the ratio,
18
+ * because APCA cannot waive it; failing that, the stronger.
19
+ */
20
+ declare function inkFor<T extends OklchColor>(fill: OklchColor, inks: readonly T[]): T;
21
+ /**
22
+ * The fill nearest `fill` on which `ink` reads: lightness moves away from the
23
+ * ink in small steps, hue holds, and chroma is kept unless the new lightness
24
+ * cannot show it. Returns `fill` itself when the ink already reads, and the
25
+ * furthest step tried when nothing in range does.
26
+ */
27
+ declare function fitFillToInk<T extends OklchColor>(fill: T, ink: OklchColor): T;
28
+ /**
29
+ * Which ink a colour takes: the one that reads on it with the least change to
30
+ * it — none, when one already reads (the stronger, when both do). Decide this
31
+ * once, on the colour as picked, then `fitFillToInk` each mode's fill to it.
32
+ */
33
+ declare function inkPolarity<T extends OklchColor>(color: OklchColor, inks: readonly T[]): T;
34
+ /** Fills a theme or preset authors from its brand. Their ink follows the colour as picked. */
35
+ declare const BRAND_FILLS: readonly ["primary", "sidebar-primary", "highlight"];
36
+ /**
37
+ * Status fills. Each has a separate `-text` token for the colour drawn on a
38
+ * surface (`text-destructive` reads `--destructive-text`), so the fill is free to
39
+ * sit where its label reads — a dark-mode red under white ink — without taking
40
+ * the page's error text down with it.
41
+ */
42
+ declare const STATUS_FILLS: readonly ["destructive", "success", "tone-warning", "tone-info"];
43
+ /**
44
+ * Every fill a label sits on that the rule measures. Sable's own themes are held
45
+ * to it (themes.test.ts); a preset or an app is told, offered a fix, and may keep
46
+ * its choice on the record.
47
+ */
48
+ declare const LABELLED_FILLS: readonly ["primary", "sidebar-primary", "highlight", "destructive", "success", "tone-warning", "tone-info"];
49
+ /** The rule's id, shared by `sable-lint`, `sable preset --check` and preset `exceptions`. */
50
+ declare const LABEL_CONTRAST_RULE = "label-contrast";
51
+ interface LabelFinding {
52
+ mode: "light" | "dark";
53
+ /** The fill token, without `--`; its ink is `${fill}-foreground`. */
54
+ fill: string;
55
+ ratio: number;
56
+ lc: number;
57
+ /** The nearest pair that reads, decided as the customizer decides it. */
58
+ suggestion: {
59
+ fill: string;
60
+ ink: string;
61
+ };
62
+ }
63
+ /**
64
+ * Check each labelled fill against its ink, in both modes.
65
+ *
66
+ * Takes resolved values (`oklch(…)` or hex; anything else is counted as
67
+ * unmeasured, never guessed). Which ink a fix uses is decided on the LIGHT-mode
68
+ * fill — the colour as picked — so a dark-mode failure on a colour that takes
69
+ * white is fixed by bringing the fill down, not by flipping the label dark.
70
+ */
71
+ declare function checkLabelledFills(tokens: {
72
+ light: Record<string, string | undefined>;
73
+ dark: Record<string, string | undefined>;
74
+ }): {
75
+ findings: LabelFinding[];
76
+ measured: number;
77
+ unmeasured: string[];
78
+ };
2
79
 
3
80
  /** The public theme vocabulary. Presets never need component overrides. */
4
81
  declare const PRESET_TOKEN_NAMES: readonly string[];
@@ -26,12 +103,38 @@ interface SablePreset {
26
103
  light: Record<string, string>;
27
104
  dark: Record<string, string>;
28
105
  };
106
+ /**
107
+ * Sable's opinions the preset declines, on the record. A check reports an
108
+ * accepted finding with its reason instead of failing on it.
109
+ */
110
+ exceptions?: PresetException[];
111
+ }
112
+ /** A deliberate departure from a Sable check — e.g. a brand that requires dark ink on its primary. */
113
+ interface PresetException {
114
+ rule: typeof LABEL_CONTRAST_RULE;
115
+ mode: "light" | "dark";
116
+ /** The fill token, e.g. `primary`. */
117
+ token: (typeof LABELLED_FILLS)[number];
118
+ reason: string;
29
119
  }
30
- /** Structural/contract validation; visual contrast and brief fit still need review. */
31
- declare function validatePreset(input: unknown): string[];
32
- declare function parsePreset(input: unknown): SablePreset;
120
+ declare function validatePreset(raw: unknown): string[];
121
+ declare function parsePreset(raw: unknown): SablePreset;
33
122
  declare function presetManifest(input: SablePreset): ThemeManifest;
34
123
  /** Scoped CSS can coexist with built-in presets. Never changes :root globally. */
35
124
  declare function presetCss(input: SablePreset): string;
125
+ /**
126
+ * Labels on the preset's brand and status fills (contrast.ts, "Labels on fills"): each
127
+ * failing pair with the nearest pair that reads. Findings the preset accepts in
128
+ * `exceptions` come back under `accepted` with their reason — reported, not
129
+ * failed, because the choice is the preset author's to make.
130
+ */
131
+ declare function presetLabelContrast(input: SablePreset): {
132
+ open: LabelFinding[];
133
+ accepted: (LabelFinding & {
134
+ reason: string;
135
+ })[];
136
+ measured: number;
137
+ unmeasured: string[];
138
+ };
36
139
 
37
- export { PRESET_TOKEN_NAMES, type SablePreset, parsePreset, presetCss, presetManifest, validatePreset };
140
+ export { BRAND_FILLS, LABELLED_FILLS, LABEL_CONTRAST_RULE, type LabelFinding, type OklchColor, PRESET_TOKEN_NAMES, type PresetException, STATUS_FILLS, type SablePreset, checkLabelledFills, fitFillToInk, inkFor, inkPolarity, labelContrast, parsePreset, presetCss, presetLabelContrast, presetManifest, validatePreset };
@@ -1,10 +1,193 @@
1
- import { SEMANTIC_TOKENS, WORKSPACE_SURFACE_TOKENS, DERIVED_EDGE_TOKENS, RADIUS_TOKENS, MOTION_TOKENS, TYPE_SCALE_TOKENS, TYPE_REFINEMENT_TOKENS, TYPE_OVERLINE_TOKENS, DENSITY_TOKENS, LAYERING_TOKENS, TYPE_UI_TOKENS } from '../chunk-OQACYT2D.js';
1
+ import { SEMANTIC_TOKENS, WORKSPACE_SURFACE_TOKENS, DERIVED_EDGE_TOKENS, RADIUS_TOKENS, MOTION_TOKENS, TYPE_SCALE_TOKENS, TYPE_REFINEMENT_TOKENS, TYPE_OVERLINE_TOKENS, DENSITY_TOKENS, LAYERING_TOKENS, TYPE_UI_TOKENS, ART_TOKENS } from '../chunk-NETUFI34.js';
2
+
3
+ // src/themes/contrast.ts
4
+ function oklchToLinearRgb(L, C, H) {
5
+ const h = H * Math.PI / 180;
6
+ const a = C * Math.cos(h);
7
+ const b = C * Math.sin(h);
8
+ const l = (L + 0.3963377774 * a + 0.2158037573 * b) ** 3;
9
+ const m = (L - 0.1055613458 * a - 0.0638541728 * b) ** 3;
10
+ const s = (L - 0.0894841775 * a - 1.291485548 * b) ** 3;
11
+ return [
12
+ 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s,
13
+ -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s,
14
+ -0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s
15
+ ];
16
+ }
17
+ function hexToLinearRgb(hex) {
18
+ const m = hex.trim().match(/^#([0-9a-f]{6})$/i);
19
+ if (!m) return null;
20
+ const n = Number.parseInt(m[1], 16);
21
+ const toLinear = (c) => c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
22
+ return [
23
+ toLinear((n >> 16 & 255) / 255),
24
+ toLinear((n >> 8 & 255) / 255),
25
+ toLinear((n & 255) / 255)
26
+ ];
27
+ }
28
+ var clamp01 = (x) => Math.min(1, Math.max(0, x));
29
+ function linearToSrgb(c) {
30
+ const v = clamp01(c);
31
+ return v <= 31308e-7 ? 12.92 * v : 1.055 * v ** (1 / 2.4) - 0.055;
32
+ }
33
+ function parseColorWithAlpha(value) {
34
+ const v = value.trim();
35
+ if (v.startsWith("#")) {
36
+ const rgb2 = hexToLinearRgb(v.length === 9 ? v.slice(0, 7) : v);
37
+ if (!rgb2) return null;
38
+ const alpha2 = v.length === 9 ? Number.parseInt(v.slice(7), 16) / 255 : 1;
39
+ return { rgb: rgb2, alpha: Number.isNaN(alpha2) ? 1 : alpha2 };
40
+ }
41
+ const m = v.match(
42
+ /^oklch\(\s*([\d.]+)(%?)\s+([\d.]+)\s+([\d.]+)(?:\s*\/\s*([\d.]+)(%?))?\s*\)/i
43
+ );
44
+ if (!m) return null;
45
+ const rgb = oklchToLinearRgb(
46
+ m[2] === "%" ? Number(m[1]) / 100 : Number(m[1]),
47
+ Number(m[3]),
48
+ Number(m[4])
49
+ );
50
+ const alpha = m[5] === void 0 ? 1 : m[6] === "%" ? Number(m[5]) / 100 : Number(m[5]);
51
+ return { rgb, alpha };
52
+ }
53
+ function luminance([r, g, b]) {
54
+ return 0.2126 * clamp01(r) + 0.7152 * clamp01(g) + 0.0722 * clamp01(b);
55
+ }
56
+ function contrastRatio(a, b) {
57
+ const [l1, l2] = [luminance(a), luminance(b)];
58
+ return (Math.max(l1, l2) + 0.05) / (Math.min(l1, l2) + 0.05);
59
+ }
60
+ var CONTRAST_FLOOR = 4.5;
61
+ var toSrgb = linearToSrgb;
62
+ function apcaLc(ink, surface) {
63
+ const Y = (rgb) => {
64
+ const [r, g, b] = rgb.map(toSrgb);
65
+ const y = 0.2126729 * r ** 2.4 + 0.7151522 * g ** 2.4 + 0.072175 * b ** 2.4;
66
+ return y < 0.022 ? y + (0.022 - y) ** 1.414 : y;
67
+ };
68
+ const Yt = Y(ink);
69
+ const Yb = Y(surface);
70
+ if (Math.abs(Yb - Yt) < 5e-4) return 0;
71
+ let out;
72
+ if (Yb > Yt) {
73
+ const s = (Yb ** 0.56 - Yt ** 0.57) * 1.14;
74
+ out = s < 0.035 ? 0 : s - 0.027;
75
+ } else {
76
+ const s = (Yb ** 0.65 - Yt ** 0.62) * 1.14;
77
+ out = s > -0.035 ? 0 : s + 0.027;
78
+ }
79
+ return out * 100;
80
+ }
81
+ var PERCEPTUAL_FLOOR = 60;
82
+ var GAMUT_EPSILON = 1e-4;
83
+ function isInSrgbGamut(rgb, epsilon = GAMUT_EPSILON) {
84
+ return rgb.every((c) => c >= -epsilon && c <= 1 + epsilon);
85
+ }
86
+ var linearOf = ({ l, c, h }) => oklchToLinearRgb(l, c, h);
87
+ function maxChromaAt(l, h) {
88
+ if (l <= 0 || l >= 1) return 0;
89
+ let lo = 0;
90
+ let hi = 0.4;
91
+ for (let i = 0; i < 24; i++) {
92
+ const mid = (lo + hi) / 2;
93
+ if (isInSrgbGamut(oklchToLinearRgb(l, mid, h))) lo = mid;
94
+ else hi = mid;
95
+ }
96
+ return lo;
97
+ }
98
+ function labelContrast(ink, fill) {
99
+ const a = linearOf(ink);
100
+ const b = linearOf(fill);
101
+ const ratio = contrastRatio(a, b);
102
+ const lc = apcaLc(a, b);
103
+ return { ratio, lc, reads: ratio >= CONTRAST_FLOOR && Math.abs(lc) >= PERCEPTUAL_FLOOR };
104
+ }
105
+ function inkFor(fill, inks) {
106
+ const rank = (ink) => {
107
+ const v = labelContrast(ink, fill);
108
+ return (v.reads ? 2 : v.ratio >= CONTRAST_FLOOR ? 1 : 0) * 1e3 + Math.abs(v.lc);
109
+ };
110
+ return inks.reduce((best, ink) => rank(ink) > rank(best) ? ink : best);
111
+ }
112
+ var FILL_GAMUT_SAFETY = 0.98;
113
+ function fitFillToInk(fill, ink) {
114
+ if (labelContrast(ink, fill).reads) return fill;
115
+ const step = ink.l > fill.l ? -5e-3 : 5e-3;
116
+ let candidate = fill;
117
+ for (let l = fill.l + step; l > 0.05 && l < 0.98; l += step) {
118
+ candidate = { ...fill, l, c: Math.min(fill.c, maxChromaAt(l, fill.h) * FILL_GAMUT_SAFETY) };
119
+ if (labelContrast(ink, candidate).reads) return candidate;
120
+ }
121
+ return candidate;
122
+ }
123
+ function inkPolarity(color, inks) {
124
+ const cost = (ink) => {
125
+ const fitted = fitFillToInk(color, ink);
126
+ const v = labelContrast(ink, fitted);
127
+ return (v.reads ? 0 : 10) + Math.abs(fitted.l - color.l) - Math.abs(v.lc) / 1e4;
128
+ };
129
+ return inks.reduce((best, ink) => cost(ink) < cost(best) ? ink : best);
130
+ }
131
+ function linearRgbToOklch([r, g, b]) {
132
+ const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
133
+ const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
134
+ const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
135
+ const L = 0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s;
136
+ const A = 1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s;
137
+ const B = 0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s;
138
+ const c = Math.hypot(A, B);
139
+ return { l: L, c, h: c < 1e-4 ? 0 : (Math.atan2(B, A) * 180 / Math.PI + 360) % 360 };
140
+ }
141
+ var formatOklchColor = ({ l, c, h }) => `oklch(${(l * 100).toFixed(1)}% ${c.toFixed(3)} ${h.toFixed(1)})`;
142
+ var BRAND_FILLS = ["primary", "sidebar-primary", "highlight"];
143
+ var STATUS_FILLS = ["destructive", "success", "tone-warning", "tone-info"];
144
+ var LABELLED_FILLS = [...BRAND_FILLS, ...STATUS_FILLS];
145
+ var LABEL_CONTRAST_RULE = "label-contrast";
146
+ function checkLabelledFills(tokens) {
147
+ const findings = [];
148
+ const unmeasured = [];
149
+ let measured = 0;
150
+ const read = (value) => {
151
+ const parsed = value ? parseColorWithAlpha(value) : null;
152
+ return parsed && parsed.alpha >= 1 ? linearRgbToOklch(parsed.rgb) : null;
153
+ };
154
+ for (const mode of ["light", "dark"]) {
155
+ for (const fill of LABELLED_FILLS) {
156
+ const rawFill = tokens[mode][fill];
157
+ const rawInk = tokens[mode][`${fill}-foreground`];
158
+ if (!rawFill && !rawInk) continue;
159
+ const fillColor = read(rawFill);
160
+ const ink = read(rawInk);
161
+ if (!fillColor || !ink) {
162
+ unmeasured.push(`${mode}.${fill}`);
163
+ continue;
164
+ }
165
+ measured++;
166
+ const verdict = labelContrast(ink, fillColor);
167
+ if (verdict.reads) continue;
168
+ const opposite = ink.l > 0.5 ? { l: 0.2, c: 0.03, h: fillColor.h } : { l: 0.985, c: 0.01, h: fillColor.h };
169
+ const reference = read(tokens.light[fill]) ?? fillColor;
170
+ const chosen = inkPolarity(reference, [ink, { ...opposite, h: reference.h }]);
171
+ const nextInk = chosen === ink ? ink : opposite;
172
+ findings.push({
173
+ mode,
174
+ fill,
175
+ ratio: verdict.ratio,
176
+ lc: verdict.lc,
177
+ suggestion: { fill: formatOklchColor(fitFillToInk(fillColor, nextInk)), ink: formatOklchColor(nextInk) }
178
+ });
179
+ }
180
+ }
181
+ return { findings, measured, unmeasured };
182
+ }
2
183
 
3
184
  // src/presets/index.ts
4
185
  var PRESET_TOKEN_NAMES = [.../* @__PURE__ */ new Set([
5
186
  ...SEMANTIC_TOKENS,
6
187
  ...WORKSPACE_SURFACE_TOKENS.map((name) => `theme-${name}`),
7
188
  ...DERIVED_EDGE_TOKENS.map((name) => `theme-${name}`),
189
+ // Art direction: optional hooks — a preset without them paints the default art.
190
+ ...ART_TOKENS.map((name) => `theme-${name}`),
8
191
  ...WORKSPACE_SURFACE_TOKENS,
9
192
  ...DERIVED_EDGE_TOKENS,
10
193
  ...RADIUS_TOKENS,
@@ -38,7 +221,31 @@ var tokenNames = new Set(PRESET_TOKEN_NAMES);
38
221
  var record = (value) => !!value && typeof value === "object" && !Array.isArray(value);
39
222
  var text = (value) => typeof value === "string" && value.trim().length > 0;
40
223
  var safeCss = (value) => value.length <= 2048 && !/[;{}<>\\@]|\/\*|\*\/|!important|url\s*\(|expression\s*\(|REPLACE/i.test(value);
41
- function validatePreset(input) {
224
+ var STATUS_TEXT_FROM_FILL = {
225
+ "destructive-text": "destructive",
226
+ "success-text": "success",
227
+ "tone-warning-text": "tone-warning",
228
+ "tone-info-text": "tone-info"
229
+ };
230
+ function upgradePreset(input) {
231
+ if (!record(input) || !record(input.tokens)) return input;
232
+ const tokens = input.tokens;
233
+ const shared = record(tokens.shared) ? tokens.shared : {};
234
+ const upgraded = { ...tokens };
235
+ for (const mode of ["light", "dark"]) {
236
+ const values = tokens[mode];
237
+ if (!record(values)) continue;
238
+ const next = { ...values };
239
+ for (const [name, fill] of Object.entries(STATUS_TEXT_FROM_FILL)) {
240
+ const value = values[fill] ?? shared[fill];
241
+ if (!text(values[name]) && !text(shared[name]) && text(value)) next[name] = value;
242
+ }
243
+ upgraded[mode] = next;
244
+ }
245
+ return { ...input, tokens: upgraded };
246
+ }
247
+ function validatePreset(raw) {
248
+ const input = upgradePreset(raw);
42
249
  if (!record(input)) return ["Preset must be a JSON object."];
43
250
  const issues = [];
44
251
  if (input.schemaVersion !== 1) issues.push("Unsupported preset schemaVersion; expected 1.");
@@ -58,6 +265,13 @@ function validatePreset(input) {
58
265
  if (!record(item) || !text(item.source) || !text(item.observation) || !["measured", "estimated", "derived", "user-edited"].includes(String(item.method))) issues.push(`Invalid reference observation ${index + 1}.`);
59
266
  });
60
267
  }
268
+ if (input.exceptions !== void 0) {
269
+ if (!Array.isArray(input.exceptions) || input.exceptions.length > 50) issues.push("exceptions must be an array of at most 50 entries.");
270
+ else input.exceptions.forEach((item, index) => {
271
+ if (!record(item) || item.rule !== LABEL_CONTRAST_RULE || !["light", "dark"].includes(String(item.mode)) || !LABELLED_FILLS.includes(item.token) || !text(item.reason) || item.reason.length > 500)
272
+ issues.push(`Invalid exception ${index + 1}: needs rule "${LABEL_CONTRAST_RULE}", mode light|dark, token (${LABELLED_FILLS.join(", ")}) and a reason.`);
273
+ });
274
+ }
61
275
  if (!record(input.tokens)) return [...issues, "tokens must contain shared, light, and dark objects."];
62
276
  const groups = input.tokens;
63
277
  for (const group of ["shared", "light", "dark"]) {
@@ -92,10 +306,10 @@ function validatePreset(input) {
92
306
  }
93
307
  return issues;
94
308
  }
95
- function parsePreset(input) {
96
- const issues = validatePreset(input);
309
+ function parsePreset(raw) {
310
+ const issues = validatePreset(raw);
97
311
  if (issues.length) throw new Error(issues.join("\n"));
98
- return input;
312
+ return upgradePreset(raw);
99
313
  }
100
314
  function presetManifest(input) {
101
315
  const preset = parsePreset(input);
@@ -122,5 +336,24 @@ ${declarations(preset.tokens.light)}
122
336
  }
123
337
  `;
124
338
  }
339
+ function presetLabelContrast(input) {
340
+ const preset = parsePreset(input);
341
+ const values = (mode) => {
342
+ const all = { ...preset.tokens.shared, ...preset.tokens[mode] };
343
+ const resolve = (value, depth = 0) => depth > 10 ? value : value.replace(/^var\(\s*--([\w-]+)\s*\)$/, (m, name) => all[name] ? resolve(all[name], depth + 1) : m);
344
+ return Object.fromEntries(Object.entries(all).map(([name, value]) => [name, resolve(value)]));
345
+ };
346
+ const { findings, measured, unmeasured } = checkLabelledFills({ light: values("light"), dark: values("dark") });
347
+ const reasonFor = (f) => preset.exceptions?.find((e) => e.rule === LABEL_CONTRAST_RULE && e.mode === f.mode && e.token === f.fill)?.reason;
348
+ return {
349
+ open: findings.filter((f) => !reasonFor(f)),
350
+ accepted: findings.flatMap((f) => {
351
+ const reason = reasonFor(f);
352
+ return reason ? [{ ...f, reason }] : [];
353
+ }),
354
+ measured,
355
+ unmeasured
356
+ };
357
+ }
125
358
 
126
- export { PRESET_TOKEN_NAMES, parsePreset, presetCss, presetManifest, validatePreset };
359
+ export { BRAND_FILLS, LABELLED_FILLS, LABEL_CONTRAST_RULE, PRESET_TOKEN_NAMES, STATUS_FILLS, checkLabelledFills, fitFillToInk, inkFor, inkPolarity, labelContrast, parsePreset, presetCss, presetLabelContrast, presetManifest, validatePreset };
package/dist/styles.css CHANGED
@@ -109,6 +109,39 @@
109
109
  --color-tone-edition-foreground: var(--tone-edition-foreground);
110
110
  --color-tone-warning-foreground: var(--tone-warning-foreground);
111
111
  --color-tone-info-foreground: var(--tone-info-foreground);
112
+ /* Status text: a status colour drawn on a surface. Tailwind resolves each
113
+ property's own namespace before --color-*, so `text-destructive`,
114
+ `border-destructive`, `ring-destructive` (and outline, decoration, fill,
115
+ stroke) read --destructive-text while `bg-destructive` keeps the fill.
116
+ Same class names; the value fits the job. */
117
+ --text-color-destructive: var(--destructive-text);
118
+ --border-color-destructive: var(--destructive-text);
119
+ --ring-color-destructive: var(--destructive-text);
120
+ --outline-color-destructive: var(--destructive-text);
121
+ --text-decoration-color-destructive: var(--destructive-text);
122
+ --fill-destructive: var(--destructive-text);
123
+ --stroke-destructive: var(--destructive-text);
124
+ --text-color-success: var(--success-text);
125
+ --border-color-success: var(--success-text);
126
+ --ring-color-success: var(--success-text);
127
+ --outline-color-success: var(--success-text);
128
+ --text-decoration-color-success: var(--success-text);
129
+ --fill-success: var(--success-text);
130
+ --stroke-success: var(--success-text);
131
+ --text-color-tone-warning: var(--tone-warning-text);
132
+ --border-color-tone-warning: var(--tone-warning-text);
133
+ --ring-color-tone-warning: var(--tone-warning-text);
134
+ --outline-color-tone-warning: var(--tone-warning-text);
135
+ --text-decoration-color-tone-warning: var(--tone-warning-text);
136
+ --fill-tone-warning: var(--tone-warning-text);
137
+ --stroke-tone-warning: var(--tone-warning-text);
138
+ --text-color-tone-info: var(--tone-info-text);
139
+ --border-color-tone-info: var(--tone-info-text);
140
+ --ring-color-tone-info: var(--tone-info-text);
141
+ --outline-color-tone-info: var(--tone-info-text);
142
+ --text-decoration-color-tone-info: var(--tone-info-text);
143
+ --fill-tone-info: var(--tone-info-text);
144
+ --stroke-tone-info: var(--tone-info-text);
112
145
  --color-highlight-foreground: var(--highlight-foreground);
113
146
 
114
147
  /* Overlay — fixed dark scrim/media surface + its light ink (bg-overlay,
@@ -352,6 +385,23 @@
352
385
  are hard 3–12px drops); on a 20px knob inside its track that drop escaped
353
386
  the track. A knob only sits on its rail, so it keeps one small shadow. */
354
387
  --knob-shadow: var(--theme-knob-shadow, 0 1px 2px 0 rgb(0 0 0 / 0.05));
388
+
389
+ /* Art direction for generated imagery (GradientArt; see ART_TOKENS in
390
+ themes/_contract.ts). The default paints the four decorative tones,
391
+ lifted from their text strength to an art strength, glowing over the brand
392
+ steeped in the first tone; a theme overrides any of it through the
393
+ --theme-art-* hooks. Declared here, on every scope, so a .dark island or a
394
+ preset-scoped preview paints from its own tokens. */
395
+ --art-1: var(--theme-art-1, oklch(from var(--tone-info) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h));
396
+ --art-2: var(--theme-art-2, oklch(from var(--tone-collectible) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h));
397
+ --art-3: var(--theme-art-3, oklch(from var(--tone-edition) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h));
398
+ --art-4: var(--theme-art-4, oklch(from var(--tone-standard) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h));
399
+ --art-5: var(--theme-art-5, transparent);
400
+ --art-6: var(--theme-art-6, transparent);
401
+ --art-ground: var(--theme-art-ground, color-mix(in oklab, var(--primary) 55%, oklch(from var(--tone-info) 0.3 c h)));
402
+ --art-warp: var(--theme-art-warp, 0.8);
403
+ --art-softness: var(--theme-art-softness, 0.88);
404
+ --art-grain: var(--theme-art-grain, 0);
355
405
  }
356
406
 
357
407
  /* ==========================================================================
@@ -1,6 +1,6 @@
1
1
  import * as react from 'react';
2
2
  import { ReactNode } from 'react';
3
- import { T as ThemeManifest } from './_contract-Cn3lUpih.js';
3
+ import { T as ThemeManifest } from './_contract-C2M1SjBf.js';
4
4
 
5
5
  declare const SABLE_THEME_ATTRIBUTE: "data-sable-theme";
6
6
  declare const DEFAULT_THEME_ID: string;