@sableui/react 0.1.1 → 0.3.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 (137) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +120 -0
  3. package/CONTRIBUTING.md +8 -0
  4. package/README.md +1 -0
  5. package/bin/sable-lint.mjs +0 -0
  6. package/bin/sable.mjs +68 -26
  7. package/dist/{_contract-CYScOdf5.d.ts → _contract-o3tUc8Ig.d.ts} +1 -1
  8. package/dist/blocks/index.d.ts +396 -0
  9. package/dist/blocks/index.js +1997 -0
  10. package/dist/chunk-6LCIBMMG.js +4908 -0
  11. package/dist/{chunk-J23ELQQZ.js → chunk-CKNM7KSB.js} +1664 -4270
  12. package/dist/chunk-K6LII3OE.js +5093 -0
  13. package/dist/{chunk-RWP4XBSK.js → chunk-KFZ43RKU.js} +1 -1
  14. package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
  15. package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
  16. package/dist/{chunk-JI7WJP2L.js → chunk-X276MF2W.js} +5 -0
  17. package/dist/foundations/index.js +1 -1
  18. package/dist/icon/index.js +2 -2
  19. package/dist/icon-sets/reicon.js +1 -1
  20. package/dist/index.d.ts +624 -133
  21. package/dist/index.js +767 -4964
  22. package/dist/ios/index.d.ts +14 -2
  23. package/dist/ios/index.js +10 -9
  24. package/dist/ios/preview.d.ts +11 -1
  25. package/dist/ios/preview.js +14 -9
  26. package/dist/ios/styles.css +26 -6
  27. package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
  28. package/dist/patterns/index.d.ts +1 -1
  29. package/dist/patterns/index.js +1684 -471
  30. package/dist/presets/index.d.ts +1 -1
  31. package/dist/presets/index.js +1 -1
  32. package/dist/styles.css +238 -40
  33. package/dist/{theme-provider-BWcizhAt.d.ts → theme-provider-BE-KqUG-.d.ts} +1 -1
  34. package/dist/themes/atelier/atelier.css +3 -0
  35. package/dist/themes/atelier/index.d.ts +1 -1
  36. package/dist/themes/base/base.css +34 -4
  37. package/dist/themes/base/index.d.ts +1 -1
  38. package/dist/themes/ember/ember.css +3 -0
  39. package/dist/themes/ember/index.d.ts +1 -1
  40. package/dist/themes/index.d.ts +2 -2
  41. package/dist/themes/index.js +2 -2
  42. package/dist/themes/meridian/index.d.ts +1 -1
  43. package/dist/themes/meridian/meridian.css +10 -5
  44. package/dist/themes/poppy/index.d.ts +1 -1
  45. package/dist/themes/poppy/poppy.css +16 -0
  46. package/dist/themes/prism/index.d.ts +1 -1
  47. package/dist/themes/prism/prism.css +4 -1
  48. package/dist/themes/verdant/index.d.ts +1 -1
  49. package/dist/themes/verdant/verdant.css +7 -2
  50. package/llms/blocks/api-keys-block.md +79 -0
  51. package/llms/blocks/chat-thread-block.md +51 -0
  52. package/llms/blocks/command-palette-block.md +48 -0
  53. package/llms/blocks/cta-band-block.md +62 -0
  54. package/llms/blocks/danger-zone-block.md +69 -0
  55. package/llms/blocks/device-comparison-block.md +91 -0
  56. package/llms/blocks/event-card-block.md +84 -0
  57. package/llms/blocks/faq-block.md +72 -0
  58. package/llms/blocks/feature-grid-block.md +75 -0
  59. package/llms/blocks/feedback-form-block.md +66 -0
  60. package/llms/blocks/goal-progress-block.md +54 -0
  61. package/llms/blocks/hero-block.md +65 -0
  62. package/llms/blocks/invite-team-block.md +61 -0
  63. package/llms/blocks/invoice-history-block.md +84 -0
  64. package/llms/blocks/kpi-sparkline-block.md +57 -0
  65. package/llms/blocks/media-player-block.md +100 -0
  66. package/llms/blocks/newsletter-block.md +64 -0
  67. package/llms/blocks/not-found-block.md +56 -0
  68. package/llms/blocks/notification-list-block.md +63 -0
  69. package/llms/blocks/notification-settings-block.md +54 -0
  70. package/llms/blocks/order-tracking-block.md +62 -0
  71. package/llms/blocks/pricing-card-block.md +82 -0
  72. package/llms/blocks/pricing-table-block.md +129 -0
  73. package/llms/blocks/product-card-block.md +69 -0
  74. package/llms/blocks/profile-card-block.md +103 -0
  75. package/llms/blocks/profile-form-block.md +114 -0
  76. package/llms/blocks/revenue-breakdown-block.md +70 -0
  77. package/llms/blocks/sign-in-block.md +64 -0
  78. package/llms/blocks/sign-up-block.md +78 -0
  79. package/llms/blocks/site-footer-block.md +96 -0
  80. package/llms/blocks/stat-row-block.md +59 -0
  81. package/llms/blocks/steps-block.md +73 -0
  82. package/llms/blocks/task-list-block.md +81 -0
  83. package/llms/blocks/team-members-block.md +91 -0
  84. package/llms/blocks/testimonial-block.md +78 -0
  85. package/llms/blocks/traffic-sources-block.md +63 -0
  86. package/llms/blocks/traffic-trend-block.md +95 -0
  87. package/llms/blocks/upload-list-block.md +66 -0
  88. package/llms/blocks/verify-code-block.md +59 -0
  89. package/llms/blocks/wallet-block.md +84 -0
  90. package/llms/components/area-chart.md +112 -0
  91. package/llms/components/bar-chart.md +131 -0
  92. package/llms/components/bubble.md +5 -5
  93. package/llms/components/button.md +1 -1
  94. package/llms/components/card.md +2 -2
  95. package/llms/components/checkbox-card.md +1 -1
  96. package/llms/components/context-card.md +2 -2
  97. package/llms/components/context-menu.md +1 -1
  98. package/llms/components/date-tile.md +79 -0
  99. package/llms/components/dialog.md +1 -1
  100. package/llms/components/empty-state.md +4 -4
  101. package/llms/components/entity.md +5 -3
  102. package/llms/components/fieldset.md +1 -1
  103. package/llms/components/kbd.md +18 -6
  104. package/llms/components/line-chart.md +101 -0
  105. package/llms/components/menu.md +1 -1
  106. package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
  107. package/llms/components/pie-chart.md +103 -0
  108. package/llms/components/pill.md +1 -1
  109. package/llms/components/radar-chart.md +106 -0
  110. package/llms/components/radial-chart.md +102 -0
  111. package/llms/components/segmented-bar.md +102 -0
  112. package/llms/components/settings-menu.md +2 -2
  113. package/llms/components/side-nav.md +1 -1
  114. package/llms/components/skeleton.md +1 -1
  115. package/llms/components/sparkline.md +80 -0
  116. package/llms/components/table.md +1 -1
  117. package/llms/components/theme-selector.md +1 -1
  118. package/llms/foundations/shape.md +1 -1
  119. package/llms/patterns/auth-page.md +132 -42
  120. package/llms/patterns/brand-landing-page.md +271 -175
  121. package/llms/patterns/chat-panel.md +120 -39
  122. package/llms/patterns/dashboard-page.md +276 -65
  123. package/llms/patterns/feed-page.md +245 -64
  124. package/llms/patterns/landing-page.md +473 -75
  125. package/llms/patterns/list-detail-page.md +377 -68
  126. package/llms/patterns/settings-page.md +268 -67
  127. package/llms/reference/token-intent.md +4 -2
  128. package/llms/reference/utilities.md +20 -5
  129. package/llms/tasks/building-an-ios-prototype.md +4 -1
  130. package/llms/tasks/choosing-a-component.md +2 -2
  131. package/llms-full.txt +6428 -892
  132. package/llms.txt +67 -16
  133. package/package.json +5 -1
  134. package/registry.json +3217 -552
  135. package/targets/ios.json +88 -18
  136. package/llms/components/description.md +0 -96
  137. package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
@@ -0,0 +1,69 @@
1
+ # ProductCardBlock
2
+
3
+ A product card: edge-to-edge artwork, title and maker, an edition Badge, and the price beside a single buy action.
4
+
5
+ **Intent:** Reach for this for a product, edition or listing in a grid or a featured slot. For a row in a dense list, use an Entity with the image as `leading` instead.
6
+
7
+ **Block:** Commerce · card · designed for 16rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The artwork runs to the card's edges; the card clips it to its own corners (overflow-hidden).
12
+ - Prices are `text-mono-lg` — the metric mono tier with tabular figures — never a heading tier.
13
+ - The placeholder art is painted from the palette's decorative tones so it follows the theme; swap it for the product image with real alt text.
14
+
15
+ ## Accessibility
16
+
17
+ - The placeholder art is aria-hidden. A real product image needs alt text naming the product.
18
+ - The card is not a link: it holds its own button. To make the whole card navigate, move the action to the detail page and render the Card as an <a>.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn } from "@sableui/react"
24
+
25
+ const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" }
26
+
27
+ /**
28
+ * Placeholder artwork painted from the palette — a soft mesh of the theme's
29
+ * decorative tones over the brand, so it follows every look. Replace the art
30
+ * <div> with your product's image.
31
+ */
32
+ const ART_TONES = ["info", "collectible", "edition", "standard"]
33
+ const ART_SPOTS = [[18, 22, 58], [82, 18, 55], [70, 88, 60], [12, 92, 55]]
34
+ const artwork = [
35
+ ...ART_SPOTS.map(
36
+ ([x, y, reach], i) =>
37
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
38
+ `radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES[i % ART_TONES.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`,
39
+ ),
40
+ "var(--primary)",
41
+ ].join(", ")
42
+
43
+ export interface ProductCardBlockProps {
44
+ className?: string
45
+ /** false drops the card's surface and border — for a muted section that already frames it. */
46
+ framed?: boolean
47
+ /** The buy action. */
48
+ onCollect?: () => void
49
+ }
50
+
51
+ export function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {
52
+ return (
53
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
54
+ <div aria-hidden className="aspect-[4/3]" style={{ background: artwork }} />
55
+ <CardHeader>
56
+ <CardTitle>{product.title}</CardTitle>
57
+ <CardDescription>{product.maker}</CardDescription>
58
+ <CardAction>
59
+ <Badge tone="edition">{product.edition}</Badge>
60
+ </CardAction>
61
+ </CardHeader>
62
+ <CardFooter className="justify-between pt-4">
63
+ <span className="text-mono-lg">{product.price}</span>
64
+ <Button onClick={onCollect}>Collect</Button>
65
+ </CardFooter>
66
+ </Card>
67
+ )
68
+ }
69
+ ```
@@ -0,0 +1,103 @@
1
+ # ProfileCardBlock
2
+
3
+ A profile card: a cover banner, an avatar rising into it, Share and Follow, the name and handle, a one-line bio and two counts.
4
+
5
+ **Intent:** Reach for this for a person or creator summary — a suggested-follow card, a hover target's full view, the top of a profile sidebar. For a small inline preview on hover, use ContextCard; for a row in a list of people, use Entity.
6
+
7
+ **Block:** Social & messaging · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The avatar overlaps the banner by half its height with a ring in the card colour, so it reads as sitting on the card, not pasted over the art.
12
+ - Follow is the one primary action and flips to a secondary Following once pressed; Share is an icon-only ghost Button beside it.
13
+ - Counts pair a label-tier number with muted body text, so the numbers scan as a row.
14
+
15
+ ## Accessibility
16
+
17
+ - The Share button carries aria-label; Follow's visible text changes with its state.
18
+ - The banner is decorative (aria-hidden); the Avatar's alt is the person's name.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { useState } from "react"
24
+ import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, Icon } from "@sableui/react"
25
+
26
+ const profile = {
27
+ name: "Juno Avery",
28
+ initials: "JA",
29
+ handle: "@juno · Sound and light",
30
+ bio: "Field recordings, slowed down. New edition every full moon.",
31
+ stats: [
32
+ { value: "128", label: "posts" },
33
+ { value: "4.2k", label: "followers" },
34
+ ],
35
+ }
36
+
37
+ /**
38
+ * Placeholder banner painted from the palette — the theme's decorative tones
39
+ * over the brand, so it follows every look. Replace with the person's cover image.
40
+ */
41
+ const ART_TONES = ["info", "collectible", "edition", "standard"]
42
+ const ART_SPOTS = [[10, 50, 60], [50, 0, 55], [90, 60, 60], [40, 100, 50]]
43
+ const banner = [
44
+ ...ART_SPOTS.map(
45
+ ([x, y, reach], i) =>
46
+ // design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
47
+ `radial-gradient(in oklab at ${x}% ${y}%, oklch(from var(--tone-${ART_TONES[i % ART_TONES.length]}) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent ${reach}%)`,
48
+ ),
49
+ "var(--primary)",
50
+ ].join(", ")
51
+
52
+ export interface ProfileCardBlockProps {
53
+ className?: string
54
+ /** false drops the card's surface and border — for a muted section that already frames it. */
55
+ framed?: boolean
56
+ /** Called with the new follow state. */
57
+ onFollowChange?: (following: boolean) => void
58
+ onShare?: () => void
59
+ }
60
+
61
+ export function ProfileCardBlock({ className, framed = true, onFollowChange, onShare }: ProfileCardBlockProps) {
62
+ const [following, setFollowing] = useState(false)
63
+ return (
64
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
65
+ <div aria-hidden className="h-20" style={{ background: banner }} />
66
+ {/* pt-0: the avatar rises into the banner above, so the content
67
+ starts at the banner's edge rather than a full pad below it. */}
68
+ <CardContent className="flex flex-col gap-3 pt-0">
69
+ <div className="flex items-start justify-between">
70
+ <Avatar size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
71
+ <div className="flex gap-1.5 pt-3">
72
+ <Button variant="ghost" size="icon-sm" aria-label="Share profile" onClick={onShare}>
73
+ <Icon name="share" />
74
+ </Button>
75
+ <Button
76
+ size="sm"
77
+ variant={following ? "secondary" : "primary"}
78
+ onClick={() => {
79
+ setFollowing(!following)
80
+ onFollowChange?.(!following)
81
+ }}
82
+ >
83
+ {following ? "Following" : "Follow"}
84
+ </Button>
85
+ </div>
86
+ </div>
87
+ <div className="flex flex-col gap-0.5">
88
+ <CardTitle>{profile.name}</CardTitle>
89
+ <CardDescription>{profile.handle}</CardDescription>
90
+ </div>
91
+ <p className="text-body-sm">{profile.bio}</p>
92
+ <div className="flex gap-4 text-body-sm">
93
+ {profile.stats.map((s) => (
94
+ <span key={s.label}>
95
+ <span className="text-label-lg">{s.value}</span> <span className="text-muted-foreground">{s.label}</span>
96
+ </span>
97
+ ))}
98
+ </div>
99
+ </CardContent>
100
+ </Card>
101
+ )
102
+ }
103
+ ```
@@ -0,0 +1,114 @@
1
+ # ProfileFormBlock
2
+
3
+ A profile settings card: avatar with upload/remove, name and handle side by side, a bio, a timezone Select, and one Save profile in the footer.
4
+
5
+ **Intent:** Reach for this for the profile or account section of settings, or any form of drafted text that is committed together. Toggles that apply instantly belong in NotificationSettingsBlock, never behind this Save.
6
+
7
+ **Block:** Settings & team · card · designed for 20rem–42rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Copied from the SettingsPage page shell's Profile section; the shell keeps its own copy so it stays one self-contained file.
12
+ - One explicit save for the whole card. Short pairs (name + handle) sit side by side from sm up with Grid cols={{ base: 1, sm: 2 }}.
13
+ - The CardTitle renders an h2, so the section joins the page outline under its h1.
14
+
15
+ ## Accessibility
16
+
17
+ - Field wires label, description and error to each control; the handle's URL preview is its description.
18
+ - The photo actions are type="button", so neither one submits the form.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Avatar, Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Field, Form, Grid, Input, Row, Select, SelectContent, SelectItem, SelectTrigger, Stack, Textarea } from "@sableui/react"
24
+
25
+ const profile = {
26
+ name: "Ada Lovelace",
27
+ initials: "AL",
28
+ handle: "ada",
29
+ bio: "Generative artist working in plotted ink and code. New editions of 25 to 100 every month.",
30
+ timezone: "europe-london",
31
+ }
32
+
33
+ const timezones = [
34
+ { value: "europe-london", label: "London (GMT+1)" },
35
+ { value: "europe-paris", label: "Paris (GMT+2)" },
36
+ { value: "america-new-york", label: "New York (GMT−4)" },
37
+ { value: "america-los-angeles", label: "Los Angeles (GMT−7)" },
38
+ { value: "asia-tokyo", label: "Tokyo (GMT+9)" },
39
+ ]
40
+
41
+ export interface ProfileFormBlockProps {
42
+ className?: string
43
+ /** false drops the card's surface and border — for a muted section that already frames it. */
44
+ framed?: boolean
45
+ /** Called with the form's fields on Save profile. */
46
+ onSave?: (fields: FormData) => void
47
+ }
48
+
49
+ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFormBlockProps) {
50
+ return (
51
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
52
+ <CardHeader>
53
+ <CardTitle render={<h2 />}>Profile</CardTitle>
54
+ <CardDescription>How collectors see you.</CardDescription>
55
+ </CardHeader>
56
+ {/* The one explicit save: profile text is drafted, then committed. */}
57
+ <Form
58
+ className="gap-0"
59
+ onSubmit={(event) => {
60
+ event.preventDefault()
61
+ onSave?.(new FormData(event.currentTarget))
62
+ }}
63
+ >
64
+ <CardContent>
65
+ <Stack gap={3}>
66
+ <Row gap={2}>
67
+ <Avatar size="lg" fallback={profile.initials} alt={profile.name} />
68
+ <Stack gap={0.5} align="start">
69
+ <Row gap={1}>
70
+ <Button type="button" variant="outline" size="sm">
71
+ Upload photo
72
+ </Button>
73
+ <Button type="button" variant="ghost" size="sm">
74
+ Remove
75
+ </Button>
76
+ </Row>
77
+ <p className="text-caption text-muted-foreground">Square PNG or JPG, at least 400 px.</p>
78
+ </Stack>
79
+ </Row>
80
+ <Grid cols={{ base: 1, sm: 2 }} gap={3}>
81
+ <Field label="Display name" name="displayName" required>
82
+ <Input defaultValue={profile.name} autoComplete="name" />
83
+ </Field>
84
+ <Field label="Handle" name="handle" description={`atelier.com/@${profile.handle}`} required>
85
+ <Input defaultValue={profile.handle} autoComplete="username" spellCheck={false} />
86
+ </Field>
87
+ </Grid>
88
+ <Field label="Bio" name="bio" description="Shown on your profile and edition pages.">
89
+ <Textarea rows={3} defaultValue={profile.bio} />
90
+ </Field>
91
+ <Grid cols={{ base: 1, sm: 2 }} gap={3}>
92
+ <Field label="Timezone" name="timezone" description="Release times use this zone.">
93
+ <Select defaultValue={profile.timezone} items={timezones}>
94
+ <SelectTrigger />
95
+ <SelectContent>
96
+ {timezones.map((zone) => (
97
+ <SelectItem key={zone.value} value={zone.value}>
98
+ {zone.label}
99
+ </SelectItem>
100
+ ))}
101
+ </SelectContent>
102
+ </Select>
103
+ </Field>
104
+ </Grid>
105
+ </Stack>
106
+ </CardContent>
107
+ <CardFooter className="justify-end">
108
+ <Button type="submit">Save profile</Button>
109
+ </CardFooter>
110
+ </Form>
111
+ </Card>
112
+ )
113
+ }
114
+ ```
@@ -0,0 +1,70 @@
1
+ # RevenueBreakdownBlock
2
+
3
+ A stat card with a breakdown: the KPI in the metric mono tier, its trend Badge, a SegmentedBar of how the total divides, and a StatusDot legend.
4
+
5
+ **Intent:** Reach for this when a headline number is worth splitting into a few categories — revenue by product, storage by type, tickets by priority. A plain KPI with no split is a smaller stat card (see DashboardPage's stat row).
6
+
7
+ **Block:** Dashboard · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The value is `text-mono-xl` — tabular figures, never a heading tier — and the label above it is CardDescription.
12
+ - The legend reflows from two columns to one by measuring in em, so larger type or a narrower card never truncates a label.
13
+ - Each category keeps the same tone in the bar and its legend dot; reuse those tones wherever the category appears.
14
+
15
+ ## Accessibility
16
+
17
+ - The SegmentedBar is one image whose name reads every share; the legend repeats it visually and is aria-hidden so it is not announced twice.
18
+ - The trend Badge states the change as text (+8.2%), not by colour.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Badge, Card, CardAction, CardContent, CardDescription, CardHeader, cn, Grid, SegmentedBar, StatusDot } from "@sableui/react"
24
+
25
+ const revenue = { label: "Revenue this month", total: "$12,480", change: "+8.2%" }
26
+
27
+ const segments = [
28
+ { label: "Editions", value: 42, tone: "edition" },
29
+ { label: "Collectibles", value: 27, tone: "collectible" },
30
+ { label: "Standard", value: 19, tone: "standard" },
31
+ { label: "Commissions", value: 12, tone: "info" },
32
+ ] as const
33
+
34
+ export interface RevenueBreakdownBlockProps {
35
+ className?: string
36
+ /** false drops the card's surface and border — for a muted section that already frames it. */
37
+ framed?: boolean
38
+ }
39
+
40
+ export function RevenueBreakdownBlock({ className, framed = true }: RevenueBreakdownBlockProps) {
41
+ return (
42
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
43
+ <CardHeader>
44
+ <CardDescription>{revenue.label}</CardDescription>
45
+ <CardAction>
46
+ <Badge tone="success" size="sm">
47
+ {revenue.change}
48
+ </Badge>
49
+ </CardAction>
50
+ </CardHeader>
51
+ <CardContent className="flex flex-col gap-4 pt-2">
52
+ <span className="text-mono-xl">{revenue.total}</span>
53
+ <SegmentedBar label="Revenue by type" segments={[...segments]} size="lg" />
54
+ {/* Two columns while each entry fits whole, one when they would not:
55
+ sized in em, so a larger type or a narrower card drops to one.
56
+ aria-hidden: the bar already reads every share aloud. */}
57
+ <Grid aria-hidden minItemWidth="9.5em" gap="calc(var(--spacing) * 1.5) calc(var(--spacing) * 4)">
58
+ {segments.map((s) => (
59
+ <div key={s.label} className="flex items-center gap-2 text-body-sm">
60
+ <StatusDot tone={s.tone} />
61
+ <span className="flex-1 text-muted-foreground">{s.label}</span>
62
+ <span className="text-mono-caption">{s.value}%</span>
63
+ </div>
64
+ ))}
65
+ </Grid>
66
+ </CardContent>
67
+ </Card>
68
+ )
69
+ }
70
+ ```
@@ -0,0 +1,64 @@
1
+ # SignInBlock
2
+
3
+ A sign-in card: email and password, a primary Sign in, and a passkey alternative under an "or" separator.
4
+
5
+ **Intent:** Reach for this wherever someone signs in inside a surface you already have — a Sheet, a Dialog, a gated panel. For a whole sign-in route with brand art beside the form, start from the AuthPage page shell instead.
6
+
7
+ **Block:** Auth · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - One primary action. The passkey is an outline Button under a labelled Separator, so it reads as an alternative, not a second submit.
12
+ - The fields are a real <form>: Enter submits, and the browser's password manager recognises the autocomplete hints.
13
+ - framed={false} keeps the padding and drops the surface, for a Sheet or Dialog that is already the frame.
14
+
15
+ ## Accessibility
16
+
17
+ - Inputs carry aria-label because the design shows placeholders, not visible labels; add Field labels if your brief wants them visible.
18
+ - `onSignIn` receives "password" or "passkey", so focus and error handling stay with the caller's auth flow.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Icon, Input, Separator } from "@sableui/react"
24
+
25
+ export interface SignInBlockProps {
26
+ className?: string
27
+ /** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
28
+ framed?: boolean
29
+ /** Called with the method the person chose. Wire your auth here. */
30
+ onSignIn?: (method: "password" | "passkey") => void
31
+ }
32
+
33
+ export function SignInBlock({ className, framed = true, onSignIn }: SignInBlockProps) {
34
+ return (
35
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
36
+ <CardHeader>
37
+ <CardTitle>Welcome back</CardTitle>
38
+ <CardDescription>Sign in to pick up where you left off.</CardDescription>
39
+ </CardHeader>
40
+ <CardContent>
41
+ <form
42
+ className="flex flex-col gap-4"
43
+ onSubmit={(event) => {
44
+ event.preventDefault()
45
+ onSignIn?.("password")
46
+ }}
47
+ >
48
+ <div className="flex flex-col gap-2">
49
+ <Input name="email" type="email" autoComplete="email" placeholder="you@studio.com" aria-label="Email" />
50
+ <Input name="password" type="password" autoComplete="current-password" placeholder="Password" aria-label="Password" />
51
+ </div>
52
+ <Button type="submit" className="w-full">
53
+ Sign in
54
+ </Button>
55
+ <Separator label="or" />
56
+ <Button variant="outline" className="w-full" iconStart={<Icon name="lock" />} onClick={() => onSignIn?.("passkey")}>
57
+ Use a passkey
58
+ </Button>
59
+ </form>
60
+ </CardContent>
61
+ </Card>
62
+ )
63
+ }
64
+ ```
@@ -0,0 +1,78 @@
1
+ # SignUpBlock
2
+
3
+ A sign-up card: name, work email and password as labelled Fields, one full-width Create account, and a link to sign in instead.
4
+
5
+ **Intent:** Reach for this to create an account inside a surface you already have, or as the form of a sign-up route. For the whole route with brand art beside it, start from the AuthPage page shell and drop this card in.
6
+
7
+ **Block:** Auth · card · designed for 18rem–24rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Visible labels, not placeholders: someone filling a new form needs to see what each field is after typing in it.
12
+ - The password rule is stated up front in the Field description, before anyone fails it.
13
+ - `onSignUp` receives the fields only after the browser's own validation (required, type=email, minLength) passes.
14
+
15
+ ## Accessibility
16
+
17
+ - Field wires each label, description and error to its input; never hand-roll <label> + <input> pairs.
18
+ - autoComplete hints (name, email, new-password) let password managers offer to generate and save the password.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Button, Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Field, Form, Input } from "@sableui/react"
24
+
25
+ export interface SignUpBlockProps {
26
+ className?: string
27
+ /** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
28
+ framed?: boolean
29
+ /** Called with the submitted fields once the browser's validation passes. */
30
+ onSignUp?: (account: { name: string; email: string; password: string }) => void
31
+ }
32
+
33
+ export function SignUpBlock({ className, framed = true, onSignUp }: SignUpBlockProps) {
34
+ return (
35
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
36
+ <CardHeader>
37
+ <CardTitle>Create your account</CardTitle>
38
+ <CardDescription>Free for 14 days. No card needed.</CardDescription>
39
+ </CardHeader>
40
+ <Form
41
+ className="gap-0"
42
+ onSubmit={(event) => {
43
+ event.preventDefault()
44
+ const data = new FormData(event.currentTarget)
45
+ onSignUp?.({
46
+ name: String(data.get("name") ?? ""),
47
+ email: String(data.get("email") ?? ""),
48
+ password: String(data.get("password") ?? ""),
49
+ })
50
+ }}
51
+ >
52
+ <CardContent className="flex flex-col gap-3">
53
+ <Field label="Name" name="name" required>
54
+ <Input autoComplete="name" />
55
+ </Field>
56
+ <Field label="Work email" name="email" required>
57
+ <Input type="email" autoComplete="email" placeholder="you@studio.com" />
58
+ </Field>
59
+ <Field label="Password" name="password" description="At least 12 characters." required>
60
+ <Input type="password" autoComplete="new-password" minLength={12} />
61
+ </Field>
62
+ </CardContent>
63
+ <CardFooter className="flex-col items-stretch gap-3">
64
+ <Button type="submit" className="w-full">
65
+ Create account
66
+ </Button>
67
+ <p className="text-center text-caption text-muted-foreground">
68
+ Already have an account?{" "}
69
+ <a href="/sign-in" className="text-foreground underline underline-offset-4 motion-interactive hover:text-muted-foreground">
70
+ Sign in
71
+ </a>
72
+ </p>
73
+ </CardFooter>
74
+ </Form>
75
+ </Card>
76
+ )
77
+ }
78
+ ```
@@ -0,0 +1,96 @@
1
+ # SiteFooterBlock
2
+
3
+ A site footer: the brand and a one-line tagline beside link groups in a labelled Footer nav, over a copyright line.
4
+
5
+ **Intent:** Reach for this at the foot of a marketing or docs site. App screens inside an AppShell take no footer; their navigation lives in the sidebar.
6
+
7
+ **Block:** Marketing · section
8
+
9
+ ## Notes
10
+
11
+ - Copied from the LandingPage page shell's footer.
12
+ - Every link is at least 36px tall (min-h-9), so the dense list is still an easy touch target.
13
+ - Groups go two up on a phone and three up from sm; add a group only if it has three links or more.
14
+
15
+ ## Accessibility
16
+
17
+ - Renders <footer> with a <nav aria-label="Footer">, distinct from the page's main navigation.
18
+ - Group titles are h2s, so the footer's structure is navigable by heading.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { cn, Region, Stack } from "@sableui/react"
24
+
25
+ const brand = { name: "Sable", tagline: "React components, patterns, and tokens for products built with AI agents." }
26
+
27
+ const groups = [
28
+ {
29
+ title: "Product",
30
+ links: [
31
+ { label: "Components", href: "/components" },
32
+ { label: "Patterns", href: "/patterns" },
33
+ { label: "Themes", href: "/themes" },
34
+ { label: "Canvas", href: "/canvas" },
35
+ ],
36
+ },
37
+ {
38
+ title: "Resources",
39
+ links: [
40
+ { label: "Documentation", href: "/docs" },
41
+ { label: "Changelog", href: "/changelog" },
42
+ { label: "llms.txt", href: "/llms.txt" },
43
+ ],
44
+ },
45
+ {
46
+ title: "Community",
47
+ links: [
48
+ { label: "GitHub", href: "/github" },
49
+ { label: "npm", href: "/npm" },
50
+ { label: "Discussions", href: "/discussions" },
51
+ ],
52
+ },
53
+ ]
54
+
55
+ export interface SiteFooterBlockProps {
56
+ className?: string
57
+ }
58
+
59
+ export function SiteFooterBlock({ className }: SiteFooterBlockProps) {
60
+ return (
61
+ <footer className={cn("w-full border-t border-border", className)}>
62
+ <Region max="wide" className="py-12">
63
+ <div className="grid gap-10 md:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
64
+ <Stack gap={3} className="max-w-xs">
65
+ <a href="/" className="w-fit text-title-lg motion-interactive hover:text-muted-foreground">
66
+ {brand.name}
67
+ </a>
68
+ <p className="text-body-sm text-muted-foreground">{brand.tagline}</p>
69
+ </Stack>
70
+ <nav aria-label="Footer" className="grid grid-cols-2 gap-8 sm:grid-cols-3">
71
+ {groups.map((group) => (
72
+ <div key={group.title}>
73
+ <h2 className="text-label-md text-muted-foreground">{group.title}</h2>
74
+ <ul className="mt-3 flex flex-col gap-1">
75
+ {group.links.map((link) => (
76
+ <li key={link.label}>
77
+ <a href={link.href} className="flex min-h-9 items-center text-body-sm motion-interactive hover:text-muted-foreground">
78
+ {link.label}
79
+ </a>
80
+ </li>
81
+ ))}
82
+ </ul>
83
+ </div>
84
+ ))}
85
+ </nav>
86
+ </div>
87
+ <div className="mt-12 border-t border-border pt-6">
88
+ <p className="text-caption text-muted-foreground">
89
+ © 2026 {brand.name}. Released under the MIT license.
90
+ </p>
91
+ </div>
92
+ </Region>
93
+ </footer>
94
+ )
95
+ }
96
+ ```
@@ -0,0 +1,59 @@
1
+ # StatRowBlock
2
+
3
+ A KPI row: four stat cards, each a label, the value in the metric mono tier, and a trend Badge with its comparison note — 2 × 2 on a phone, one row from xl.
4
+
5
+ **Intent:** Reach for this at the top of a dashboard or report, above the tables and charts it summarises. One headline number worth splitting into categories is RevenueBreakdownBlock.
6
+
7
+ **Block:** Dashboard · section
8
+
9
+ ## Notes
10
+
11
+ - Copied from the DashboardPage page shell's stat row.
12
+ - Values are `text-mono-xl` — tabular figures, never a heading tier — so the four numbers align digit for digit.
13
+ - The trend Badge sits with its note in CardContent, so the value keeps the full card width when the row folds to 2 × 2.
14
+ - Keep it to four. A fifth stat is a sign one of them belongs in a table.
15
+
16
+ ## Accessibility
17
+
18
+ - The trend is stated in text (+8.2%) with an arrow icon; the tone is reinforcement, not the signal.
19
+ - Each card's label is read before its value, in source order.
20
+
21
+ ## Source
22
+
23
+ ```tsx
24
+ import { Badge, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Grid, Icon, Row } from "@sableui/react"
25
+
26
+ const stats = [
27
+ { label: "Revenue", value: "$18,240", delta: "+8.2%", up: true, note: "vs. $16,857" },
28
+ { label: "Collects", value: "1,284", delta: "+3.1%", up: true, note: "182 first-time" },
29
+ { label: "Collectors", value: "312", delta: "+12.4%", up: true, note: "Best month yet" },
30
+ { label: "Sell-through", value: "64%", delta: "−1.8%", up: false, note: "6 open editions" },
31
+ ]
32
+
33
+ export interface StatRowBlockProps {
34
+ className?: string
35
+ }
36
+
37
+ export function StatRowBlock({ className }: StatRowBlockProps) {
38
+ return (
39
+ <Grid cols={{ base: 2, xl: 4 }} gap={3} className={cn("w-full", className)}>
40
+ {stats.map((stat) => (
41
+ <Card key={stat.label}>
42
+ <CardHeader>
43
+ <CardDescription>{stat.label}</CardDescription>
44
+ <CardTitle className="text-mono-xl">{stat.value}</CardTitle>
45
+ </CardHeader>
46
+ <CardContent>
47
+ <Row gap={1} wrap>
48
+ <Badge tone={stat.up ? "success" : "warning"} size="sm" icon={<Icon name={stat.up ? "arrow-up" : "arrow-down"} />}>
49
+ {stat.delta}
50
+ </Badge>
51
+ <span className="text-caption text-muted-foreground">{stat.note}</span>
52
+ </Row>
53
+ </CardContent>
54
+ </Card>
55
+ ))}
56
+ </Grid>
57
+ )
58
+ }
59
+ ```