@sableui/react 0.2.0 → 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 (97) hide show
  1. package/AGENTS.md +3 -1
  2. package/CHANGELOG.md +59 -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/blocks/index.d.ts +396 -0
  8. package/dist/blocks/index.js +1997 -0
  9. package/dist/chunk-6LCIBMMG.js +4908 -0
  10. package/dist/{chunk-AOWBYEQ6.js → chunk-CKNM7KSB.js} +1598 -6464
  11. package/dist/chunk-K6LII3OE.js +5093 -0
  12. package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
  13. package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
  14. package/dist/icon/index.js +2 -2
  15. package/dist/icon-sets/reicon.js +1 -1
  16. package/dist/index.d.ts +471 -34
  17. package/dist/index.js +588 -2589
  18. package/dist/ios/index.d.ts +8 -1
  19. package/dist/ios/index.js +6 -5
  20. package/dist/ios/preview.d.ts +11 -1
  21. package/dist/ios/preview.js +13 -8
  22. package/dist/ios/styles.css +6 -0
  23. package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
  24. package/dist/patterns/index.d.ts +1 -1
  25. package/dist/patterns/index.js +29 -30
  26. package/dist/themes/base/base.css +23 -0
  27. package/dist/themes/index.js +2 -2
  28. package/llms/blocks/api-keys-block.md +79 -0
  29. package/llms/blocks/chat-thread-block.md +51 -0
  30. package/llms/blocks/command-palette-block.md +48 -0
  31. package/llms/blocks/cta-band-block.md +62 -0
  32. package/llms/blocks/danger-zone-block.md +69 -0
  33. package/llms/blocks/device-comparison-block.md +91 -0
  34. package/llms/blocks/event-card-block.md +84 -0
  35. package/llms/blocks/faq-block.md +72 -0
  36. package/llms/blocks/feature-grid-block.md +75 -0
  37. package/llms/blocks/feedback-form-block.md +66 -0
  38. package/llms/blocks/goal-progress-block.md +54 -0
  39. package/llms/blocks/hero-block.md +65 -0
  40. package/llms/blocks/invite-team-block.md +61 -0
  41. package/llms/blocks/invoice-history-block.md +84 -0
  42. package/llms/blocks/kpi-sparkline-block.md +57 -0
  43. package/llms/blocks/media-player-block.md +100 -0
  44. package/llms/blocks/newsletter-block.md +64 -0
  45. package/llms/blocks/not-found-block.md +56 -0
  46. package/llms/blocks/notification-list-block.md +63 -0
  47. package/llms/blocks/notification-settings-block.md +54 -0
  48. package/llms/blocks/order-tracking-block.md +62 -0
  49. package/llms/blocks/pricing-card-block.md +82 -0
  50. package/llms/blocks/pricing-table-block.md +129 -0
  51. package/llms/blocks/product-card-block.md +69 -0
  52. package/llms/blocks/profile-card-block.md +103 -0
  53. package/llms/blocks/profile-form-block.md +114 -0
  54. package/llms/blocks/revenue-breakdown-block.md +70 -0
  55. package/llms/blocks/sign-in-block.md +64 -0
  56. package/llms/blocks/sign-up-block.md +78 -0
  57. package/llms/blocks/site-footer-block.md +96 -0
  58. package/llms/blocks/stat-row-block.md +59 -0
  59. package/llms/blocks/steps-block.md +73 -0
  60. package/llms/blocks/task-list-block.md +81 -0
  61. package/llms/blocks/team-members-block.md +91 -0
  62. package/llms/blocks/testimonial-block.md +78 -0
  63. package/llms/blocks/traffic-sources-block.md +63 -0
  64. package/llms/blocks/traffic-trend-block.md +95 -0
  65. package/llms/blocks/upload-list-block.md +66 -0
  66. package/llms/blocks/verify-code-block.md +59 -0
  67. package/llms/blocks/wallet-block.md +84 -0
  68. package/llms/components/area-chart.md +112 -0
  69. package/llms/components/bar-chart.md +131 -0
  70. package/llms/components/button.md +1 -1
  71. package/llms/components/card.md +2 -2
  72. package/llms/components/context-menu.md +1 -1
  73. package/llms/components/date-tile.md +79 -0
  74. package/llms/components/entity.md +4 -2
  75. package/llms/components/line-chart.md +101 -0
  76. package/llms/components/menu.md +1 -1
  77. package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
  78. package/llms/components/pie-chart.md +103 -0
  79. package/llms/components/radar-chart.md +106 -0
  80. package/llms/components/radial-chart.md +102 -0
  81. package/llms/components/segmented-bar.md +102 -0
  82. package/llms/components/settings-menu.md +2 -2
  83. package/llms/components/sparkline.md +80 -0
  84. package/llms/components/table.md +1 -1
  85. package/llms/patterns/auth-page.md +2 -2
  86. package/llms/patterns/dashboard-page.md +6 -7
  87. package/llms/patterns/feed-page.md +7 -7
  88. package/llms/patterns/list-detail-page.md +7 -15
  89. package/llms/patterns/settings-page.md +10 -21
  90. package/llms/tasks/building-an-ios-prototype.md +4 -1
  91. package/llms/tasks/choosing-a-component.md +2 -2
  92. package/llms-full.txt +4296 -277
  93. package/llms.txt +55 -3
  94. package/package.json +5 -1
  95. package/registry.json +6383 -3741
  96. package/targets/ios.json +90 -9
  97. package/dist/{chunk-L6Y46C62.js → chunk-MXJX3NZI.js} +1 -1
@@ -0,0 +1,65 @@
1
+ # HeroBlock
2
+
3
+ A centred hero: an outline Badge eyebrow, the page's h1 at display size, a lede, a primary and an outline call to action, and an install command in a CodeBlock.
4
+
5
+ **Intent:** Reach for this to open a product or developer-tool landing page. A brand page that leads with a product scene beside the headline is the BrandLandingPage page shell's hero.
6
+
7
+ **Block:** Marketing · section
8
+
9
+ ## Notes
10
+
11
+ - Copied from the LandingPage page shell's hero.
12
+ - The headline is the page's only h1 — place one HeroBlock per page.
13
+ - Two actions, one primary. The install command is the third, quieter way in for developers who already decided.
14
+ - The lede is capped at the prose measure and the headline at max-w-4xl, so both stay readable on wide screens.
15
+
16
+ ## Accessibility
17
+
18
+ - Calls to action are links styled as Buttons (render={<a />}), because they navigate.
19
+ - The CodeBlock's copy button is labelled and announces when it has copied.
20
+
21
+ ## Source
22
+
23
+ ```tsx
24
+ import { Badge, Button, cn, CodeBlock, Icon, Region, Row, Stack } from "@sableui/react"
25
+
26
+ const hero = {
27
+ eyebrow: "Open source · MIT licensed",
28
+ title: "Product UI your agents can't get wrong",
29
+ lede: "Sable is a themeable React component library with enforced tokens, typography, and motion, documented for coding agents as carefully as for people.",
30
+ primary: { label: "Get started", href: "/docs/getting-started" },
31
+ secondary: { label: "Browse components", href: "/components" },
32
+ install: "pnpm add @sableui/react",
33
+ }
34
+
35
+ export interface HeroBlockProps {
36
+ className?: string
37
+ }
38
+
39
+ export function HeroBlock({ className }: HeroBlockProps) {
40
+ return (
41
+ <Region as="section" max="wide" className={cn("w-full py-16 sm:py-24", className)}>
42
+ <Stack gap={8} align="center">
43
+ <Stack gap={4} align="center" className="text-center">
44
+ <Badge appearance="outline" size="sm" icon={<Icon name="sparkles" />}>
45
+ {hero.eyebrow}
46
+ </Badge>
47
+ <h1 className="max-w-4xl text-balance text-display-lg sm:text-display-2xl">{hero.title}</h1>
48
+ <p className="max-w-(--measure-prose) text-pretty text-body-lg text-muted-foreground">{hero.lede}</p>
49
+ <Row gap={2} justify="center" wrap className="pt-2">
50
+ <Button size="cta" render={<a href={hero.primary.href} />}>
51
+ {hero.primary.label}
52
+ </Button>
53
+ <Button size="cta" variant="outline" render={<a href={hero.secondary.href} />}>
54
+ {hero.secondary.label}
55
+ </Button>
56
+ </Row>
57
+ </Stack>
58
+ <div className="w-full max-w-sm">
59
+ <CodeBlock prompt="$" code={hero.install} />
60
+ </div>
61
+ </Stack>
62
+ </Region>
63
+ )
64
+ }
65
+ ```
@@ -0,0 +1,61 @@
1
+ # InviteTeamBlock
2
+
3
+ An invite card: who is already on the team as an AvatarGroup, and an email field with Send.
4
+
5
+ **Intent:** Reach for this for team or project invites in onboarding, an empty workspace, or a settings section. For managing an existing member list with roles, use a CardList of Entity rows instead.
6
+
7
+ **Block:** Settings & team · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The field and Send are a <form>, so Enter sends; `onSend` receives the typed address.
12
+ - The AvatarGroup ring matches the card surface when framed and inherits the host surface when not.
13
+
14
+ ## Accessibility
15
+
16
+ - The AvatarGroup carries an aria-label with the count, so the stack reads as one item, not four initials.
17
+ - The email Input carries aria-label; there is no visible label in this compact layout.
18
+
19
+ ## Source
20
+
21
+ ```tsx
22
+ import { Avatar, AvatarGroup, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Input } from "@sableui/react"
23
+
24
+ const collaborators = ["MO", "SR", "JL", "AK"]
25
+
26
+ export interface InviteTeamBlockProps {
27
+ className?: string
28
+ /** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
29
+ framed?: boolean
30
+ /** Called with the address typed, on Send or Enter. */
31
+ onSend?: (email: string) => void
32
+ }
33
+
34
+ export function InviteTeamBlock({ className, framed = true, onSend }: InviteTeamBlockProps) {
35
+ return (
36
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
37
+ <CardHeader>
38
+ <CardTitle>Invite your team</CardTitle>
39
+ <CardDescription>Collaborators can edit every project.</CardDescription>
40
+ </CardHeader>
41
+ <CardContent className="flex flex-col gap-4">
42
+ <AvatarGroup aria-label={`${collaborators.length} collaborators`} size="sm" surface={framed ? "card" : undefined}>
43
+ {collaborators.map((initials) => (
44
+ <Avatar key={initials} fallback={initials} alt={initials} />
45
+ ))}
46
+ </AvatarGroup>
47
+ <form
48
+ className="flex gap-2"
49
+ onSubmit={(event) => {
50
+ event.preventDefault()
51
+ onSend?.(String(new FormData(event.currentTarget).get("email") ?? ""))
52
+ }}
53
+ >
54
+ <Input name="email" type="email" placeholder="name@company.com" aria-label="Email address" />
55
+ <Button type="submit">Send</Button>
56
+ </form>
57
+ </CardContent>
58
+ </Card>
59
+ )
60
+ }
61
+ ```
@@ -0,0 +1,84 @@
1
+ # InvoiceHistoryBlock
2
+
3
+ A billing history card: a flush Table of date, plan, amount and status per invoice, with a download button on each row.
4
+
5
+ **Intent:** Reach for this in billing settings, under the current plan. For a long, filterable history (hundreds of rows, date ranges, export), use DataTable on its own page.
6
+
7
+ **Block:** Commerce · card · designed for 20rem–42rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The table runs edge to edge (CardContent flush); its first and last columns still line up with the card header's padding.
12
+ - Amounts are a numeric column — right-aligned with tabular figures — so they compare by eye.
13
+ - On a narrow screen the table scrolls sideways inside the card (with a quiet scrollbar) rather than squeezing its columns.
14
+
15
+ ## Accessibility
16
+
17
+ - The download column has a screen-reader-only header, and each button names its invoice ("Download INV-0042").
18
+ - Status is text in a Badge; the dot's colour only reinforces it.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Badge, Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Icon, Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@sableui/react"
24
+
25
+ const invoices = [
26
+ { id: "INV-0042", date: "Oct 1, 2026", plan: "Studio · yearly", amount: "$192.00", status: "Paid" },
27
+ { id: "INV-0031", date: "Sep 1, 2026", plan: "Studio · monthly", amount: "$20.00", status: "Paid" },
28
+ { id: "INV-0027", date: "Aug 1, 2026", plan: "Studio · monthly", amount: "$20.00", status: "Refunded" },
29
+ { id: "INV-0019", date: "Jul 1, 2026", plan: "Starter", amount: "$0.00", status: "Paid" },
30
+ ]
31
+
32
+ export interface InvoiceHistoryBlockProps {
33
+ className?: string
34
+ /** false drops the card's surface and border — for a muted section that already frames it. */
35
+ framed?: boolean
36
+ /** Called with the invoice id to download. */
37
+ onDownload?: (id: string) => void
38
+ }
39
+
40
+ export function InvoiceHistoryBlock({ className, framed = true, onDownload }: InvoiceHistoryBlockProps) {
41
+ return (
42
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-2xl", className)}>
43
+ <CardHeader>
44
+ <CardTitle render={<h2 />}>Invoices</CardTitle>
45
+ <CardDescription>Sent to billing@studio.com on the 1st of each month.</CardDescription>
46
+ </CardHeader>
47
+ <CardContent flush className="overflow-x-auto scrollbar-quiet">
48
+ <Table>
49
+ <TableHeader>
50
+ <TableRow>
51
+ <TableHead>Date</TableHead>
52
+ <TableHead>Plan</TableHead>
53
+ <TableHead numeric>Amount</TableHead>
54
+ <TableHead>Status</TableHead>
55
+ <TableHead className="w-0">
56
+ <span className="sr-only">Download</span>
57
+ </TableHead>
58
+ </TableRow>
59
+ </TableHeader>
60
+ <TableBody>
61
+ {invoices.map((invoice) => (
62
+ <TableRow key={invoice.id}>
63
+ <TableCell className="whitespace-nowrap">{invoice.date}</TableCell>
64
+ <TableCell className="text-muted-foreground">{invoice.plan}</TableCell>
65
+ <TableCell numeric>{invoice.amount}</TableCell>
66
+ <TableCell>
67
+ <Badge appearance="plain" tone={invoice.status === "Paid" ? "success" : "neutral"} dot>
68
+ {invoice.status}
69
+ </Badge>
70
+ </TableCell>
71
+ <TableCell>
72
+ <Button variant="ghost" size="icon-sm" aria-label={`Download ${invoice.id}`} onClick={() => onDownload?.(invoice.id)}>
73
+ <Icon name="download" />
74
+ </Button>
75
+ </TableCell>
76
+ </TableRow>
77
+ ))}
78
+ </TableBody>
79
+ </Table>
80
+ </CardContent>
81
+ </Card>
82
+ )
83
+ }
84
+ ```
@@ -0,0 +1,57 @@
1
+ # KpiSparklineBlock
2
+
3
+ A KPI row with trends: three stat cards, each the value in the metric mono tier, a Sparkline of its last twelve weeks, and the change as a Badge.
4
+
5
+ **Intent:** Reach for this at the top of a dashboard or a landing page's proof section, where each number needs its recent shape. Without the trend, StatRowBlock is calmer; for one headline trend, TrafficTrendBlock.
6
+
7
+ **Block:** Dashboard · section
8
+
9
+ ## Notes
10
+
11
+ - Each sparkline is coloured by what its measure is, and the change Badge by whether it is good: churn falling is good news in a warning-toned trend.
12
+ - The sparklines draw on as the row scrolls into view.
13
+ - One column on a phone, three from sm.
14
+
15
+ ## Accessibility
16
+
17
+ - Each Sparkline is an image named by the measure and the period, with its range spoken.
18
+ - The change is stated in text (+8.2% vs last quarter) with an arrow icon; colour only reinforces it.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Badge, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, cn, Grid, Icon, Sparkline } from "@sableui/react"
24
+
25
+ const kpis = [
26
+ { label: "Revenue", value: "$18,240", delta: "+8.2%", up: true, trend: [12, 14, 13, 17, 16, 19, 18, 21, 20, 24, 23, 26], tone: "success" },
27
+ { label: "Active users", value: "3,912", delta: "+3.1%", up: true, trend: [30, 31, 29, 33, 34, 33, 36, 35, 37, 38, 37, 39], tone: "info" },
28
+ { label: "Churn", value: "4.1%", delta: "−0.6%", up: false, trend: [9, 8, 9, 7, 8, 6, 7, 5, 6, 4, 5, 4], tone: "warning" },
29
+ ] as const
30
+
31
+ export interface KpiSparklineBlockProps {
32
+ className?: string
33
+ }
34
+
35
+ export function KpiSparklineBlock({ className }: KpiSparklineBlockProps) {
36
+ return (
37
+ <Grid cols={{ base: 1, sm: 3 }} gap={3} className={cn("w-full", className)}>
38
+ {kpis.map((kpi) => (
39
+ <Card key={kpi.label}>
40
+ <CardHeader>
41
+ <CardDescription>{kpi.label}</CardDescription>
42
+ <CardTitle className="text-mono-xl">{kpi.value}</CardTitle>
43
+ <CardAction>
44
+ <Sparkline label={`${kpi.label}, last 12 weeks`} data={[...kpi.trend]} variant="area" tone={kpi.tone} />
45
+ </CardAction>
46
+ </CardHeader>
47
+ <CardContent>
48
+ <Badge tone={kpi.up ? "success" : "neutral"} size="sm" icon={<Icon name={kpi.up ? "arrow-up" : "arrow-down"} />}>
49
+ {kpi.delta} vs last quarter
50
+ </Badge>
51
+ </CardContent>
52
+ </Card>
53
+ ))}
54
+ </Grid>
55
+ )
56
+ }
57
+ ```
@@ -0,0 +1,100 @@
1
+ # MediaPlayerBlock
2
+
3
+ A now-playing card: cover, title and artist as an Entity with a Like action, a position Slider with elapsed and remaining time, and previous / play-pause / next.
4
+
5
+ **Intent:** Reach for this for audio or video playback controls outside the media itself — a mini player, a podcast card, a preview in a feed. Playback over the video frame is the Video component's own chrome.
6
+
7
+ **Block:** Media & files · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Play/pause is the one filled control, centred between two ghost skips.
12
+ - Times are `text-mono-caption`, so the digits do not shift as they count.
13
+ - The cover is painted from the palette as a placeholder; swap it for the real artwork.
14
+
15
+ ## Accessibility
16
+
17
+ - Every icon-only Button carries aria-label, and play/pause's label follows its state.
18
+ - The Slider carries aria-label="Position". Give it aria-valuetext in your player ("1:24 of 3:40") so the position is read as time, not a percentage.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { useState } from "react"
24
+ import { Button, Card, CardContent, cn, Entity, Icon, Slider } from "@sableui/react"
25
+
26
+ const track = { title: "Low tide, high noon", artist: "Juno Avery", position: 38, elapsed: "1:24", remaining: "-2:16" }
27
+
28
+ /**
29
+ * Placeholder cover painted from the palette — the theme's decorative tones
30
+ * over the brand, so it follows every look. Replace with the cover image.
31
+ */
32
+ const ART_TONES = ["info", "collectible", "edition"]
33
+ const ART_SPOTS = [[20, 20, 70], [85, 30, 60], [60, 90, 65]]
34
+ const cover = [
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 MediaPlayerBlockProps {
44
+ className?: string
45
+ /** false drops the card's surface and border — for a Sheet or muted section that already frames it. */
46
+ framed?: boolean
47
+ /** Called with the new playing state. */
48
+ onPlayingChange?: (playing: boolean) => void
49
+ onPrevious?: () => void
50
+ onNext?: () => void
51
+ }
52
+
53
+ export function MediaPlayerBlock({ className, framed = true, onPlayingChange, onPrevious, onNext }: MediaPlayerBlockProps) {
54
+ const [playing, setPlaying] = useState(true)
55
+ return (
56
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
57
+ <CardContent className="flex flex-col gap-4">
58
+ {/* Entity's own row padding is for lists; inside a card's content the
59
+ card already pads it. */}
60
+ <Entity
61
+ className="p-0"
62
+ leading={<div aria-hidden className="size-14 rounded-md" style={{ background: cover }} />}
63
+ title={track.title}
64
+ subtitle={track.artist}
65
+ action={
66
+ <Button variant="ghost" size="icon-sm" aria-label="Like">
67
+ <Icon name="heart" />
68
+ </Button>
69
+ }
70
+ />
71
+ <div className="flex flex-col gap-1.5">
72
+ <Slider defaultValue={track.position} aria-label="Position" />
73
+ <div className="flex justify-between text-mono-caption text-muted-foreground">
74
+ <span>{track.elapsed}</span>
75
+ <span>{track.remaining}</span>
76
+ </div>
77
+ </div>
78
+ <div className="flex items-center justify-center gap-3">
79
+ <Button variant="ghost" size="icon" aria-label="Previous" onClick={onPrevious}>
80
+ <Icon name="previous" variant="solid" />
81
+ </Button>
82
+ <Button
83
+ size="icon"
84
+ aria-label={playing ? "Pause" : "Play"}
85
+ onClick={() => {
86
+ setPlaying(!playing)
87
+ onPlayingChange?.(!playing)
88
+ }}
89
+ >
90
+ <Icon name={playing ? "pause" : "play"} variant="solid" />
91
+ </Button>
92
+ <Button variant="ghost" size="icon" aria-label="Next" onClick={onNext}>
93
+ <Icon name="next" variant="solid" />
94
+ </Button>
95
+ </div>
96
+ </CardContent>
97
+ </Card>
98
+ )
99
+ }
100
+ ```
@@ -0,0 +1,64 @@
1
+ # NewsletterBlock
2
+
3
+ A newsletter sign-up card: the list's name, what it sends and how often, and an email field with Subscribe.
4
+
5
+ **Intent:** Reach for this in a footer, at the end of an article, or beside a changelog. Creating an account is SignUpBlock; inviting a teammate is InviteTeamBlock.
6
+
7
+ **Block:** Marketing · card · designed for 18rem–24rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Copied from the BrandLandingPage page shell's footer.
12
+ - The description says how often mail arrives — the one thing people want to know before they subscribe.
13
+ - The field and button sit side by side from sm up and stack on a phone, where the button goes full width.
14
+
15
+ ## Accessibility
16
+
17
+ - The input has a real (visually hidden) <label>, not just a placeholder.
18
+ - The CardTitle renders an h2, so the sign-up is reachable by heading in a footer.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { useId } from "react"
24
+ import { Button, Card, CardContent, CardDescription, CardHeader, CardTitle, cn, Input } from "@sableui/react"
25
+
26
+ const newsletter = { title: "The Cove digest", body: "One note a month on trusted workflows and product releases." }
27
+
28
+ export interface NewsletterBlockProps {
29
+ className?: string
30
+ /** false drops the card's surface and border — for a footer or muted section that already frames it. */
31
+ framed?: boolean
32
+ /** Called with the address on Subscribe or Enter. */
33
+ onSubscribe?: (email: string) => void
34
+ }
35
+
36
+ export function NewsletterBlock({ className, framed = true, onSubscribe }: NewsletterBlockProps) {
37
+ const emailId = useId()
38
+ return (
39
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-sm", className)}>
40
+ <CardHeader>
41
+ <CardTitle render={<h2 />}>{newsletter.title}</CardTitle>
42
+ <CardDescription>{newsletter.body}</CardDescription>
43
+ </CardHeader>
44
+ <CardContent>
45
+ <form
46
+ className="grid gap-2 sm:grid-cols-[1fr_auto]"
47
+ onSubmit={(event) => {
48
+ event.preventDefault()
49
+ onSubscribe?.(String(new FormData(event.currentTarget).get("email") ?? ""))
50
+ }}
51
+ >
52
+ <label htmlFor={emailId} className="sr-only">
53
+ Work email
54
+ </label>
55
+ <Input id={emailId} name="email" type="email" autoComplete="email" placeholder="you@company.com" required />
56
+ <Button type="submit" className="w-full sm:w-auto">
57
+ Subscribe
58
+ </Button>
59
+ </form>
60
+ </CardContent>
61
+ </Card>
62
+ )
63
+ }
64
+ ```
@@ -0,0 +1,56 @@
1
+ # NotFoundBlock
2
+
3
+ A not-found page body: the status code in mono, a plain-language h1, what probably happened, and two ways on — home and search.
4
+
5
+ **Intent:** Reach for this as the body of a 404 route. An empty list or a search with no results inside a working page is EmptyState, not this.
6
+
7
+ **Block:** System · section
8
+
9
+ ## Notes
10
+
11
+ - The heading says what happened in words; the code is a small mono label for people who look for it.
12
+ - Two ways on, one primary. Never a dead end with only the browser's back button.
13
+ - Capped at the `form` region width, so the short message stays a tight, centred column.
14
+
15
+ ## Accessibility
16
+
17
+ - It renders the page's h1 — use it as the route's main content, inside <main>.
18
+ - Both actions are links styled as Buttons, because they navigate.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Button, cn, Icon, Region, Row, Stack } from "@sableui/react"
24
+
25
+ const notFound = {
26
+ code: "404",
27
+ title: "This page isn't here",
28
+ body: "It may have moved, or the link may be mistyped. Check the address, or head back to somewhere you know.",
29
+ home: { label: "Go home", href: "/" },
30
+ help: { label: "Search the docs", href: "/docs" },
31
+ }
32
+
33
+ export interface NotFoundBlockProps {
34
+ className?: string
35
+ }
36
+
37
+ export function NotFoundBlock({ className }: NotFoundBlockProps) {
38
+ return (
39
+ <Region as="section" max="form" className={cn("w-full py-16 sm:py-24", className)}>
40
+ <Stack gap={4} align="center" className="text-center">
41
+ <p className="text-mono-md text-muted-foreground">{notFound.code}</p>
42
+ <h1 className="text-balance text-heading-1">{notFound.title}</h1>
43
+ <p className="text-pretty text-body-md text-muted-foreground">{notFound.body}</p>
44
+ <Row gap={2} justify="center" wrap className="pt-2">
45
+ <Button iconStart={<Icon name="home" />} render={<a href={notFound.home.href} />}>
46
+ {notFound.home.label}
47
+ </Button>
48
+ <Button variant="outline" iconStart={<Icon name="search" />} render={<a href={notFound.help.href} />}>
49
+ {notFound.help.label}
50
+ </Button>
51
+ </Row>
52
+ </Stack>
53
+ </Region>
54
+ )
55
+ }
56
+ ```
@@ -0,0 +1,63 @@
1
+ # NotificationListBlock
2
+
3
+ An activity list: Entity rows with the actor's Avatar, who did what, and a compact time with an unread StatusDot.
4
+
5
+ **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.
6
+
7
+ **Block:** Social & messaging · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Rows are Card + CardList + Entity, inset from the card's padding at any density.
12
+ - Unread is a quiet info dot after the time — the list stays calm when most items are unread.
13
+ - framed={false} drops the surface for a Popover, which already is one.
14
+
15
+ ## Accessibility
16
+
17
+ - The unread dot is decorative; each unread row also carries a screen-reader-only "(unread)" after the name.
18
+ - Each Avatar's alt is the actor's name.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { Avatar, Card, CardList, cn, Entity, StatusDot } from "@sableui/react"
24
+
25
+ const notifications = [
26
+ { initials: "MO", name: "Mara Okafor", what: "collected your edition", when: "2m", unread: true },
27
+ { initials: "SR", name: "Sam Rivera", what: "left a comment on Dusk", when: "1h", unread: false },
28
+ ]
29
+
30
+ export interface NotificationListBlockProps {
31
+ className?: string
32
+ /** false drops the card's surface and border — for a Popover or Sheet that already frames it. */
33
+ framed?: boolean
34
+ }
35
+
36
+ export function NotificationListBlock({ className, framed = true }: NotificationListBlockProps) {
37
+ return (
38
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
39
+ <CardList>
40
+ {notifications.map((n) => (
41
+ <Entity
42
+ key={n.name}
43
+ leading={<Avatar size="sm" fallback={n.initials} alt={n.name} />}
44
+ title={
45
+ <>
46
+ {n.name}
47
+ {n.unread ? <span className="sr-only"> (unread)</span> : null}
48
+ </>
49
+ }
50
+ subtitle={n.what}
51
+ action={
52
+ <span className="flex items-center gap-1.5 text-caption text-muted-foreground">
53
+ {n.when}
54
+ {n.unread ? <StatusDot tone="info" /> : null}
55
+ </span>
56
+ }
57
+ />
58
+ ))}
59
+ </CardList>
60
+ </Card>
61
+ )
62
+ }
63
+ ```
@@ -0,0 +1,54 @@
1
+ # NotificationSettingsBlock
2
+
3
+ A preferences card: Entity rows in a CardList, each with a title, a one-line detail and a Switch that applies instantly.
4
+
5
+ **Intent:** Reach for this for any short list of on/off preferences — notifications, privacy, feature flags in a settings section. For a full settings route with a section rail, start from the SettingsPage page shell.
6
+
7
+ **Block:** Settings & team · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - Switches apply the moment they change; never pair them with a Save button. `onChange` receives the row id and the new state.
12
+ - Rows are Card + CardList + Entity, so they inset with the card's padding at any density.
13
+
14
+ ## Accessibility
15
+
16
+ - Each Switch takes its row title as aria-label, since the Entity title is not a <label>.
17
+ - Keep details to one line — Entity truncates its subtitle.
18
+
19
+ ## Source
20
+
21
+ ```tsx
22
+ import { Card, CardList, cn, Entity, Switch } from "@sableui/react"
23
+
24
+ const preferences = [
25
+ { id: "email", title: "Email notifications", detail: "Replies and mentions", on: true },
26
+ { id: "push", title: "Push notifications", detail: "On this device", on: false },
27
+ { id: "digest", title: "Weekly digest", detail: "Every Monday", on: true },
28
+ ]
29
+
30
+ export interface NotificationSettingsBlockProps {
31
+ className?: string
32
+ /** false drops the card's surface and border — for a Sheet, Dialog or muted section that already frames it. */
33
+ framed?: boolean
34
+ /** Called the moment a switch changes. Toggles apply instantly; there is no Save. */
35
+ onChange?: (id: string, on: boolean) => void
36
+ }
37
+
38
+ export function NotificationSettingsBlock({ className, framed = true, onChange }: NotificationSettingsBlockProps) {
39
+ return (
40
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
41
+ <CardList>
42
+ {preferences.map((p) => (
43
+ <Entity
44
+ key={p.id}
45
+ title={p.title}
46
+ subtitle={p.detail}
47
+ action={<Switch defaultChecked={p.on} aria-label={p.title} onCheckedChange={(on: boolean) => onChange?.(p.id, on)} />}
48
+ />
49
+ ))}
50
+ </CardList>
51
+ </Card>
52
+ )
53
+ }
54
+ ```
@@ -0,0 +1,62 @@
1
+ # OrderTrackingBlock
2
+
3
+ An order status card: order number, item and a status Badge in the header over a vertical dot Stepper of delivery milestones.
4
+
5
+ **Intent:** Reach for this for anything that moves through known stages — a delivery, an application, a review, a deployment. For an open-ended activity log, use a list of Entity rows with timestamps instead.
6
+
7
+ **Block:** Commerce · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The Stepper's `current` is the step under way; finished steps show their timestamp and future steps show none.
12
+ - The status Badge restates the current stage in one word so the card scans without reading the steps.
13
+
14
+ ## Accessibility
15
+
16
+ - The Stepper is labelled "Delivery status" and marks the current step for assistive tech.
17
+ - The Badge's tone is backed by its text; colour alone never carries the status.
18
+
19
+ ## Source
20
+
21
+ ```tsx
22
+ import { Badge, Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, cn, Stepper, StepperItem } from "@sableui/react"
23
+
24
+ // design-lint-disable-next-line inline-color -- an order number, not a colour
25
+ const order = { number: "Order #2048", item: "Giclée print, 18 × 24 in", status: "In transit", current: 2 }
26
+
27
+ const steps = [
28
+ { title: "Order placed", detail: "Oct 9, 10:14 AM" },
29
+ { title: "Printed and packed", detail: "Oct 10, 4:02 PM" },
30
+ { title: "Out for delivery", detail: "Arriving today by 6 PM" },
31
+ { title: "Delivered", detail: undefined },
32
+ ]
33
+
34
+ export interface OrderTrackingBlockProps {
35
+ className?: string
36
+ /** false drops the card's surface and border — for a Sheet or muted section that already frames it. */
37
+ framed?: boolean
38
+ }
39
+
40
+ export function OrderTrackingBlock({ className, framed = true }: OrderTrackingBlockProps) {
41
+ return (
42
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
43
+ <CardHeader>
44
+ <CardTitle>{order.number}</CardTitle>
45
+ <CardDescription>{order.item}</CardDescription>
46
+ <CardAction>
47
+ <Badge tone="warning" size="sm">
48
+ {order.status}
49
+ </Badge>
50
+ </CardAction>
51
+ </CardHeader>
52
+ <CardContent>
53
+ <Stepper orientation="vertical" indicator="dot" current={order.current} aria-label="Delivery status">
54
+ {steps.map((step) => (
55
+ <StepperItem key={step.title} title={step.title} description={step.detail} />
56
+ ))}
57
+ </Stepper>
58
+ </CardContent>
59
+ </Card>
60
+ )
61
+ }
62
+ ```