@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,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
+ ```
@@ -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
+ ```