@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,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
+ ```
@@ -0,0 +1,82 @@
1
+ # PricingCardBlock
2
+
3
+ A single-plan pricing card: a Monthly/Yearly ToggleGroup, the price and billing note, a checked feature list, and one full-width call to action.
4
+
5
+ **Intent:** Reach for this when one plan is on offer — an upgrade prompt, a paywall, a plan summary in billing settings. Comparing several tiers side by side is a pricing table, not three of these.
6
+
7
+ **Block:** Commerce · card · designed for 18rem–20rem wide (fills its container up to the max)
8
+
9
+ ## Notes
10
+
11
+ - The period switch is a ToggleGroup (a segmented control), not Tabs: it changes a value in place rather than showing another panel.
12
+ - The price is a heading tier because it is the card's headline; its unit and billing note sit beside and below it in muted body text.
13
+ - `onStart` receives the period showing, so checkout opens on the plan the person was looking at.
14
+
15
+ ## Accessibility
16
+
17
+ - The ToggleGroup carries aria-label="Billing period"; each Toggle's text is its name.
18
+ - The check icons are decorative beside each feature's text.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { useState } from "react"
24
+ import { Button, Card, CardContent, cn, Icon, Toggle, ToggleGroup } from "@sableui/react"
25
+
26
+ const plan = {
27
+ monthly: { price: "$20", note: "Billed monthly. Cancel anytime." },
28
+ yearly: { price: "$16", note: "Billed $192 yearly. You save 20%." },
29
+ features: ["Unlimited projects", "Version history", "Priority support"],
30
+ }
31
+
32
+ type Period = "monthly" | "yearly"
33
+
34
+ export interface PricingCardBlockProps {
35
+ className?: string
36
+ /** false drops the card's surface and border — for a muted section that already frames it. */
37
+ framed?: boolean
38
+ /** Called with the billing period showing when the trial starts. */
39
+ onStart?: (period: Period) => void
40
+ }
41
+
42
+ export function PricingCardBlock({ className, framed = true, onStart }: PricingCardBlockProps) {
43
+ const [period, setPeriod] = useState<Period>("yearly")
44
+ return (
45
+ <Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs", className)}>
46
+ <CardContent className="flex flex-col gap-5">
47
+ <ToggleGroup
48
+ value={[period]}
49
+ onValueChange={(v: unknown[]) => v[0] && setPeriod(v[0] as Period)}
50
+ aria-label="Billing period"
51
+ className="w-full"
52
+ >
53
+ <Toggle value="monthly" className="flex-1">
54
+ Monthly
55
+ </Toggle>
56
+ <Toggle value="yearly" className="flex-1">
57
+ Yearly
58
+ </Toggle>
59
+ </ToggleGroup>
60
+ <div className="flex flex-col gap-1">
61
+ <div className="flex items-baseline gap-1.5">
62
+ <span className="text-heading-2">{plan[period].price}</span>
63
+ <span className="text-body-sm text-muted-foreground">per seat / month</span>
64
+ </div>
65
+ <span className="text-body-sm text-muted-foreground">{plan[period].note}</span>
66
+ </div>
67
+ <ul className="flex flex-col gap-2 text-body-sm">
68
+ {plan.features.map((feature) => (
69
+ <li key={feature} className="flex items-center gap-2">
70
+ <Icon name="check" className="text-success" />
71
+ {feature}
72
+ </li>
73
+ ))}
74
+ </ul>
75
+ <Button className="w-full" onClick={() => onStart?.(period)}>
76
+ Start free trial
77
+ </Button>
78
+ </CardContent>
79
+ </Card>
80
+ )
81
+ }
82
+ ```
@@ -0,0 +1,129 @@
1
+ # PricingTableBlock
2
+
3
+ A three-tier pricing section: heading, a Monthly/Yearly ToggleGroup, and three equal-height plan cards with the recommended one outlined in primary and badged "Most popular".
4
+
5
+ **Intent:** Reach for this on a pricing page or the pricing band of a landing page, to compare plans side by side. A single plan on offer (an upgrade prompt, a paywall) is PricingCardBlock.
6
+
7
+ **Block:** Commerce · section
8
+
9
+ ## Notes
10
+
11
+ - One featured tier at most: it takes the primary button and a primary outline; the others take outline buttons so the recommendation is unmistakable.
12
+ - Cards stretch to the tallest one and push their buttons to the bottom, so the calls to action line up across the row.
13
+ - The yearly saving is stated in the toggle itself, where the choice is made. `onChoose` receives the tier id and the period showing.
14
+
15
+ ## Accessibility
16
+
17
+ - Each plan is an <article> with an h3 under the section's h2, so the tiers are navigable by heading.
18
+ - The ToggleGroup is labelled "Billing period"; prices update in place and stay in the reading order of each card.
19
+
20
+ ## Source
21
+
22
+ ```tsx
23
+ import { useState, useId } from "react"
24
+ import { Badge, Button, Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, cn, Grid, Icon, Region, Stack, Toggle, ToggleGroup } from "@sableui/react"
25
+
26
+ const tiers = [
27
+ {
28
+ id: "starter",
29
+ name: "Starter",
30
+ blurb: "For one person shipping their first editions.",
31
+ monthly: 0,
32
+ yearly: 0,
33
+ cta: "Start for free",
34
+ featured: false,
35
+ features: ["3 live editions", "Collector profiles", "Community support"],
36
+ },
37
+ {
38
+ id: "studio",
39
+ name: "Studio",
40
+ blurb: "For working artists with a steady release schedule.",
41
+ monthly: 20,
42
+ yearly: 16,
43
+ cta: "Start free trial",
44
+ featured: true,
45
+ features: ["Unlimited editions", "Scheduled drops", "Sales analytics", "Priority support"],
46
+ },
47
+ {
48
+ id: "collective",
49
+ name: "Collective",
50
+ blurb: "For galleries and teams releasing together.",
51
+ monthly: 48,
52
+ yearly: 40,
53
+ cta: "Talk to sales",
54
+ featured: false,
55
+ features: ["Everything in Studio", "Up to 10 seats", "Shared payouts", "Roles and approvals"],
56
+ },
57
+ ]
58
+
59
+ type Period = "monthly" | "yearly"
60
+
61
+ export interface PricingTableBlockProps {
62
+ className?: string
63
+ /** Called with the chosen tier and the billing period showing. */
64
+ onChoose?: (tier: string, period: Period) => void
65
+ }
66
+
67
+ export function PricingTableBlock({ className, onChoose }: PricingTableBlockProps) {
68
+ const [period, setPeriod] = useState<Period>("yearly")
69
+ const titleId = useId()
70
+ return (
71
+ <Region as="section" aria-labelledby={titleId} max="wide" className={cn("w-full py-16", className)}>
72
+ <Stack gap={10}>
73
+ <Stack gap={4} align="center" className="text-center">
74
+ <h2 id={titleId} className="text-balance text-heading-1">
75
+ Pricing that grows with your practice
76
+ </h2>
77
+ <p className="max-w-(--measure-prose) text-body-lg text-muted-foreground">
78
+ Every plan includes unlimited collectors and no fees on your first $1,000 in sales.
79
+ </p>
80
+ <ToggleGroup value={[period]} onValueChange={(v: unknown[]) => v[0] && setPeriod(v[0] as Period)} aria-label="Billing period">
81
+ <Toggle value="monthly">Monthly</Toggle>
82
+ <Toggle value="yearly">Yearly · save 20%</Toggle>
83
+ </ToggleGroup>
84
+ </Stack>
85
+ <Grid cols={{ base: 1, md: 3 }} gap={4} className="items-stretch">
86
+ {tiers.map((tier) => (
87
+ <Card
88
+ key={tier.id}
89
+ render={<article />}
90
+ className={cn("flex flex-col", tier.featured && "border-primary ring-1 ring-primary")}
91
+ >
92
+ <CardHeader>
93
+ <CardTitle render={<h3 />}>{tier.name}</CardTitle>
94
+ <CardDescription>{tier.blurb}</CardDescription>
95
+ {tier.featured ? (
96
+ <CardAction>
97
+ <Badge tone="edition" size="sm">
98
+ Most popular
99
+ </Badge>
100
+ </CardAction>
101
+ ) : null}
102
+ </CardHeader>
103
+ <CardContent className="flex flex-1 flex-col gap-5">
104
+ <div className="flex items-baseline gap-1.5">
105
+ <span className="text-heading-2">${tier[period]}</span>
106
+ <span className="text-body-sm text-muted-foreground">per seat / month</span>
107
+ </div>
108
+ <ul className="flex flex-col gap-2 text-body-sm">
109
+ {tier.features.map((feature) => (
110
+ <li key={feature} className="flex items-center gap-2">
111
+ <Icon name="check" className="text-success" />
112
+ {feature}
113
+ </li>
114
+ ))}
115
+ </ul>
116
+ </CardContent>
117
+ <CardFooter>
118
+ <Button className="w-full" variant={tier.featured ? "primary" : "outline"} onClick={() => onChoose?.(tier.id, period)}>
119
+ {tier.cta}
120
+ </Button>
121
+ </CardFooter>
122
+ </Card>
123
+ ))}
124
+ </Grid>
125
+ </Stack>
126
+ </Region>
127
+ )
128
+ }
129
+ ```