@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.
- package/AGENTS.md +3 -1
- package/CHANGELOG.md +59 -0
- package/CONTRIBUTING.md +8 -0
- package/README.md +1 -0
- package/bin/sable-lint.mjs +0 -0
- package/bin/sable.mjs +68 -26
- package/dist/blocks/index.d.ts +396 -0
- package/dist/blocks/index.js +1997 -0
- package/dist/chunk-6LCIBMMG.js +4908 -0
- package/dist/{chunk-AOWBYEQ6.js → chunk-CKNM7KSB.js} +1598 -6464
- package/dist/chunk-K6LII3OE.js +5093 -0
- package/dist/{chunk-PAOZBZME.js → chunk-NGBDP3BM.js} +1 -1
- package/dist/{chunk-5Z5KLOAU.js → chunk-TH563WLW.js} +14 -1
- package/dist/icon/index.js +2 -2
- package/dist/icon-sets/reicon.js +1 -1
- package/dist/index.d.ts +471 -34
- package/dist/index.js +588 -2589
- package/dist/ios/index.d.ts +8 -1
- package/dist/ios/index.js +6 -5
- package/dist/ios/preview.d.ts +11 -1
- package/dist/ios/preview.js +13 -8
- package/dist/ios/styles.css +6 -0
- package/dist/{meta-6dUzgC6M.d.ts → meta-CcE_SeFu.d.ts} +37 -1
- package/dist/patterns/index.d.ts +1 -1
- package/dist/patterns/index.js +29 -30
- package/dist/themes/base/base.css +23 -0
- package/dist/themes/index.js +2 -2
- package/llms/blocks/api-keys-block.md +79 -0
- package/llms/blocks/chat-thread-block.md +51 -0
- package/llms/blocks/command-palette-block.md +48 -0
- package/llms/blocks/cta-band-block.md +62 -0
- package/llms/blocks/danger-zone-block.md +69 -0
- package/llms/blocks/device-comparison-block.md +91 -0
- package/llms/blocks/event-card-block.md +84 -0
- package/llms/blocks/faq-block.md +72 -0
- package/llms/blocks/feature-grid-block.md +75 -0
- package/llms/blocks/feedback-form-block.md +66 -0
- package/llms/blocks/goal-progress-block.md +54 -0
- package/llms/blocks/hero-block.md +65 -0
- package/llms/blocks/invite-team-block.md +61 -0
- package/llms/blocks/invoice-history-block.md +84 -0
- package/llms/blocks/kpi-sparkline-block.md +57 -0
- package/llms/blocks/media-player-block.md +100 -0
- package/llms/blocks/newsletter-block.md +64 -0
- package/llms/blocks/not-found-block.md +56 -0
- package/llms/blocks/notification-list-block.md +63 -0
- package/llms/blocks/notification-settings-block.md +54 -0
- package/llms/blocks/order-tracking-block.md +62 -0
- package/llms/blocks/pricing-card-block.md +82 -0
- package/llms/blocks/pricing-table-block.md +129 -0
- package/llms/blocks/product-card-block.md +69 -0
- package/llms/blocks/profile-card-block.md +103 -0
- package/llms/blocks/profile-form-block.md +114 -0
- package/llms/blocks/revenue-breakdown-block.md +70 -0
- package/llms/blocks/sign-in-block.md +64 -0
- package/llms/blocks/sign-up-block.md +78 -0
- package/llms/blocks/site-footer-block.md +96 -0
- package/llms/blocks/stat-row-block.md +59 -0
- package/llms/blocks/steps-block.md +73 -0
- package/llms/blocks/task-list-block.md +81 -0
- package/llms/blocks/team-members-block.md +91 -0
- package/llms/blocks/testimonial-block.md +78 -0
- package/llms/blocks/traffic-sources-block.md +63 -0
- package/llms/blocks/traffic-trend-block.md +95 -0
- package/llms/blocks/upload-list-block.md +66 -0
- package/llms/blocks/verify-code-block.md +59 -0
- package/llms/blocks/wallet-block.md +84 -0
- package/llms/components/area-chart.md +112 -0
- package/llms/components/bar-chart.md +131 -0
- package/llms/components/button.md +1 -1
- package/llms/components/card.md +2 -2
- package/llms/components/context-menu.md +1 -1
- package/llms/components/date-tile.md +79 -0
- package/llms/components/entity.md +4 -2
- package/llms/components/line-chart.md +101 -0
- package/llms/components/menu.md +1 -1
- package/llms/components/{dots-menu.md → overflow-menu.md} +9 -9
- package/llms/components/pie-chart.md +103 -0
- package/llms/components/radar-chart.md +106 -0
- package/llms/components/radial-chart.md +102 -0
- package/llms/components/segmented-bar.md +102 -0
- package/llms/components/settings-menu.md +2 -2
- package/llms/components/sparkline.md +80 -0
- package/llms/components/table.md +1 -1
- package/llms/patterns/auth-page.md +2 -2
- package/llms/patterns/dashboard-page.md +6 -7
- package/llms/patterns/feed-page.md +7 -7
- package/llms/patterns/list-detail-page.md +7 -15
- package/llms/patterns/settings-page.md +10 -21
- package/llms/tasks/building-an-ios-prototype.md +4 -1
- package/llms/tasks/choosing-a-component.md +2 -2
- package/llms-full.txt +4296 -277
- package/llms.txt +55 -3
- package/package.json +5 -1
- package/registry.json +6383 -3741
- package/targets/ios.json +90 -9
- 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
|
+
```
|