@sableui/react 0.5.0 → 0.6.1
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 +2 -2
- package/CHANGELOG.md +148 -0
- package/CONTRIBUTING.md +1 -1
- package/THIRD_PARTY_NOTICES.md +11 -0
- package/bin/sable-lint.mjs +2 -1
- package/bin/sable.mjs +3 -2
- package/dist/{_contract-Cn3lUpih.d.ts → _contract-C2M1SjBf.d.ts} +20 -2
- package/dist/blocks/index.js +19 -51
- package/dist/{chunk-RCLKXF2N.js → chunk-7QSDYBXO.js} +610 -30
- package/dist/{chunk-GU3T7SBA.js → chunk-EDXLM7YL.js} +1 -1
- package/dist/chunk-FKZXAFIY.js +12 -0
- package/dist/{chunk-OQACYT2D.js → chunk-NETUFI34.js} +23 -1
- package/dist/{chunk-ZBZMKOO7.js → chunk-VVW4UCDI.js} +3 -3
- package/dist/foundations/index.js +6 -0
- package/dist/index.d.ts +127 -11
- package/dist/index.js +23 -13
- package/dist/ios/index.d.ts +6 -1
- package/dist/ios/index.js +4 -3
- package/dist/ios/styles.css +9 -0
- package/dist/patterns/index.js +22 -28
- package/dist/presets/index.d.ts +108 -5
- package/dist/presets/index.js +239 -6
- package/dist/styles.css +50 -0
- package/dist/{theme-provider-CGeiusgu.d.ts → theme-provider-kQszHYji.d.ts} +1 -1
- package/dist/themes/atelier/atelier.css +31 -3
- package/dist/themes/atelier/index.d.ts +1 -1
- package/dist/themes/ember/ember.css +38 -7
- package/dist/themes/ember/index.d.ts +1 -1
- package/dist/themes/index.d.ts +2 -2
- package/dist/themes/meridian/index.d.ts +1 -1
- package/dist/themes/meridian/meridian.css +33 -4
- package/dist/themes/poppy/index.d.ts +1 -1
- package/dist/themes/poppy/poppy.css +28 -0
- package/dist/themes/prism/index.d.ts +1 -1
- package/dist/themes/prism/prism.css +26 -0
- package/dist/themes/sable/index.d.ts +1 -1
- package/dist/themes/sable/sable.css +24 -11
- package/dist/themes/verdant/index.d.ts +1 -1
- package/dist/themes/verdant/verdant.css +34 -4
- package/llms/blocks/danger-zone-block.md +1 -1
- package/llms/blocks/event-card-block.md +1 -1
- package/llms/blocks/feedback-form-block.md +1 -1
- package/llms/blocks/invite-team-block.md +1 -1
- package/llms/blocks/media-player-block.md +3 -16
- package/llms/blocks/notification-list-block.md +3 -3
- package/llms/blocks/product-card-block.md +3 -17
- package/llms/blocks/profile-card-block.md +4 -17
- package/llms/blocks/profile-form-block.md +2 -2
- package/llms/blocks/team-members-block.md +1 -1
- package/llms/blocks/testimonial-block.md +1 -1
- package/llms/components/avatar.md +13 -1
- package/llms/components/card.md +1 -1
- package/llms/components/gradient-art.md +80 -0
- package/llms/components/sheet.md +1 -1
- package/llms/foundations/accessibility.md +1 -0
- package/llms/patterns/auth-page.md +5 -19
- package/llms/patterns/brand-landing-page.md +1 -1
- package/llms/patterns/chat-panel.md +1 -1
- package/llms/patterns/dashboard-page.md +4 -4
- package/llms/patterns/feed-page.md +4 -4
- package/llms/patterns/landing-page.md +1 -1
- package/llms/patterns/list-detail-page.md +3 -2
- package/llms/patterns/settings-page.md +3 -3
- package/llms/reference/token-intent.md +9 -1
- package/llms/tasks/auditing-an-app.md +6 -0
- package/llms/tasks/building-an-ios-prototype.md +3 -1
- package/llms/tasks/converting-an-app.md +1 -1
- package/llms/tasks/creating-a-preset.md +24 -0
- package/llms/tasks/reviewing-a-design.md +9 -0
- package/llms/tasks/using-presets.md +6 -1
- package/llms-full.txt +201 -104
- package/llms.txt +3 -2
- package/package.json +9 -7
- package/registry.json +283 -30
- package/scripts/design-presets.mjs +27 -4
- package/scripts/grade.mjs +2 -1
- package/scripts/label-contrast.mjs +174 -0
- package/scripts/lint-rules.mjs +39 -2
- package/skills/sable/SKILL.md +3 -1
- package/targets/ios.json +18 -2
- package/third-party/paper-shaders-LICENSE.txt +201 -0
- package/third-party/paper-shaders-NOTICE.txt +5 -0
|
@@ -9,7 +9,7 @@ An activity list: Entity rows with the actor's Avatar, who did what, and a compa
|
|
|
9
9
|
## Notes
|
|
10
10
|
|
|
11
11
|
- Rows are Card + CardList + Entity, inset from the card's padding at any density.
|
|
12
|
-
- Unread is a quiet info dot
|
|
12
|
+
- Unread is a quiet info dot before the time, so the times keep one right edge — the list stays calm when most items are unread.
|
|
13
13
|
- framed={false} drops the surface for a Popover, which already is one.
|
|
14
14
|
|
|
15
15
|
## Accessibility
|
|
@@ -40,7 +40,7 @@ export function NotificationListBlock({ className, framed = true }: Notification
|
|
|
40
40
|
{notifications.map((n) => (
|
|
41
41
|
<Entity
|
|
42
42
|
key={n.name}
|
|
43
|
-
leading={<Avatar size="sm" fallback={n.initials} alt={n.name} />}
|
|
43
|
+
leading={<Avatar variant="art" size="sm" fallback={n.initials} alt={n.name} />}
|
|
44
44
|
title={
|
|
45
45
|
<>
|
|
46
46
|
{n.name}
|
|
@@ -50,8 +50,8 @@ export function NotificationListBlock({ className, framed = true }: Notification
|
|
|
50
50
|
subtitle={n.what}
|
|
51
51
|
action={
|
|
52
52
|
<span className="flex items-center gap-1.5 text-caption text-muted-foreground">
|
|
53
|
-
{n.when}
|
|
54
53
|
{n.unread ? <StatusDot tone="info" /> : null}
|
|
54
|
+
{n.when}
|
|
55
55
|
</span>
|
|
56
56
|
}
|
|
57
57
|
/>
|
|
@@ -20,25 +20,10 @@ A product card: edge-to-edge artwork, title and maker, an edition Badge, and the
|
|
|
20
20
|
## Source
|
|
21
21
|
|
|
22
22
|
```tsx
|
|
23
|
-
import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn } from "@sableui/react"
|
|
23
|
+
import { Badge, Button, Card, CardAction, CardDescription, CardFooter, CardHeader, CardTitle, cn, GradientArt } from "@sableui/react"
|
|
24
24
|
|
|
25
25
|
const product = { title: "Untitled (gold rush)", maker: "Mara Okafor", edition: "12 of 50", price: "0.42 SOL" }
|
|
26
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
27
|
|
|
43
28
|
export interface ProductCardBlockProps {
|
|
44
29
|
className?: string
|
|
@@ -51,7 +36,8 @@ export interface ProductCardBlockProps {
|
|
|
51
36
|
export function ProductCardBlock({ className, framed = true, onCollect }: ProductCardBlockProps) {
|
|
52
37
|
return (
|
|
53
38
|
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
|
|
54
|
-
|
|
39
|
+
{/* Placeholder art painted from the theme, so it follows every look. Replace with the product image. */}
|
|
40
|
+
<GradientArt aria-hidden seed={product.title} className="aspect-[4/3]" />
|
|
55
41
|
<CardHeader>
|
|
56
42
|
<CardTitle>{product.title}</CardTitle>
|
|
57
43
|
<CardDescription>{product.maker}</CardDescription>
|
|
@@ -21,7 +21,7 @@ A profile card: a cover banner, an avatar rising into it, Share and Follow, the
|
|
|
21
21
|
|
|
22
22
|
```tsx
|
|
23
23
|
import { useState } from "react"
|
|
24
|
-
import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, Icon } from "@sableui/react"
|
|
24
|
+
import { Avatar, Button, Card, CardContent, CardDescription, CardTitle, cn, GradientArt, Icon } from "@sableui/react"
|
|
25
25
|
|
|
26
26
|
const profile = {
|
|
27
27
|
name: "Juno Avery",
|
|
@@ -34,20 +34,6 @@ const profile = {
|
|
|
34
34
|
],
|
|
35
35
|
}
|
|
36
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
37
|
|
|
52
38
|
export interface ProfileCardBlockProps {
|
|
53
39
|
className?: string
|
|
@@ -62,12 +48,13 @@ export function ProfileCardBlock({ className, framed = true, onFollowChange, onS
|
|
|
62
48
|
const [following, setFollowing] = useState(false)
|
|
63
49
|
return (
|
|
64
50
|
<Card variant={framed ? "default" : "flat"} className={cn("w-full max-w-xs overflow-hidden", className)}>
|
|
65
|
-
|
|
51
|
+
{/* Placeholder banner painted from the theme, so it follows every look. Replace with the person's cover image. */}
|
|
52
|
+
<GradientArt aria-hidden seed={profile.handle} className="h-20" />
|
|
66
53
|
{/* pt-0: the avatar rises into the banner above, so the content
|
|
67
54
|
starts at the banner's edge rather than a full pad below it. */}
|
|
68
55
|
<CardContent className="flex flex-col gap-3 pt-0">
|
|
69
56
|
<div className="flex items-start justify-between">
|
|
70
|
-
<Avatar size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
|
|
57
|
+
<Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} className="-mt-6 ring-4 ring-card" />
|
|
71
58
|
<div className="flex gap-1.5 pt-3">
|
|
72
59
|
<Button variant="ghost" size="icon-sm" aria-label="Share profile" onClick={onShare}>
|
|
73
60
|
<Icon name="share" />
|
|
@@ -64,7 +64,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
|
|
|
64
64
|
<CardContent>
|
|
65
65
|
<Stack gap={3}>
|
|
66
66
|
<Row gap={2}>
|
|
67
|
-
<Avatar size="lg" fallback={profile.initials} alt={profile.name} />
|
|
67
|
+
<Avatar variant="art" size="lg" fallback={profile.initials} alt={profile.name} />
|
|
68
68
|
<Stack gap={0.5} align="start">
|
|
69
69
|
<Row gap={1}>
|
|
70
70
|
<Button type="button" variant="outline" size="sm">
|
|
@@ -104,7 +104,7 @@ export function ProfileFormBlock({ className, framed = true, onSave }: ProfileFo
|
|
|
104
104
|
</Grid>
|
|
105
105
|
</Stack>
|
|
106
106
|
</CardContent>
|
|
107
|
-
<CardFooter className="justify-end">
|
|
107
|
+
<CardFooter actions className="justify-end">
|
|
108
108
|
<Button type="submit">Save profile</Button>
|
|
109
109
|
</CardFooter>
|
|
110
110
|
</Form>
|
|
@@ -54,7 +54,7 @@ export function TeamMembersBlock({ className, framed = true, onInvite, onMemberA
|
|
|
54
54
|
{members.map((m) => (
|
|
55
55
|
<Entity
|
|
56
56
|
key={m.id}
|
|
57
|
-
leading={<Avatar size="sm" fallback={m.initials} alt={m.name} />}
|
|
57
|
+
leading={<Avatar variant="art" size="sm" fallback={m.initials} alt={m.name} />}
|
|
58
58
|
title={m.name}
|
|
59
59
|
subtitle={m.email}
|
|
60
60
|
action={
|
|
@@ -50,7 +50,7 @@ export function TestimonialBlock({ className }: TestimonialBlockProps) {
|
|
|
50
50
|
<blockquote className="text-balance text-heading-3 sm:text-heading-2">“{story.quote}”</blockquote>
|
|
51
51
|
</Stack>
|
|
52
52
|
<figcaption className="flex items-center gap-3">
|
|
53
|
-
<Avatar size="lg" fallback={story.initials} alt={story.name} />
|
|
53
|
+
<Avatar variant="art" size="lg" fallback={story.initials} alt={story.name} />
|
|
54
54
|
<div>
|
|
55
55
|
<p className="text-title-sm">{story.name}</p>
|
|
56
56
|
<p className="text-body-sm text-muted-foreground">{story.role}</p>
|
|
@@ -12,6 +12,7 @@ Use Avatar to represent a person, team, or account with a compact visual identit
|
|
|
12
12
|
|
|
13
13
|
### Best practices
|
|
14
14
|
|
|
15
|
+
- Do: Use `variant="art"` where people without photos sit side by side — a member list, an activity feed, a chat. Seeded by the person, each gets their own colour, so faces stay tellable apart at a glance; the neutral fallback makes everyone the same grey circle.
|
|
15
16
|
- Do: Provide meaningful alt text for profile images, or an empty alt when the adjacent text already names the person.
|
|
16
17
|
- Do: Use initials or a simple glyph fallback so missing images still communicate identity.
|
|
17
18
|
- Do: Use consistent sizes within a group; reserve lg for profile headers or major identity moments.
|
|
@@ -50,14 +51,17 @@ Use Avatar to represent a person, team, or account with a compact visual identit
|
|
|
50
51
|
| fallbackDelay | number | — | Delay before showing the fallback, in ms — avoids a flash on fast loads. |
|
|
51
52
|
| status | ReactNode | — | A presence indicator pinned to the bottom-end edge, usually <StatusDot tone="success" />. It sits outside the clipped circle and wears a ring in the surface colour (--avatar-group-ring, else the page background). Use this instead of absolutely positioning a dot over an Avatar. |
|
|
52
53
|
| size | "sm" \| "md" \| "lg" | "md" | Square footprint: 32px / 40px / 48px. |
|
|
54
|
+
| variant | "neutral" \| "art" | "neutral" | What the fallback looks like. neutral: initials on the muted surface. art: initials over GradientArt seeded by the person, so each person without a photo gets their own stable colour from the theme. |
|
|
55
|
+
| seed | string \| number | — | The art variant's seed. Defaults to the fallback text, then `alt`, so a person keeps one colour wherever they appear; pass a user id to tell apart two people who share initials. |
|
|
53
56
|
|
|
54
57
|
## Variants
|
|
55
58
|
|
|
56
59
|
- **size**: sm, md, lg
|
|
60
|
+
- **variant**: neutral, art
|
|
57
61
|
|
|
58
62
|
## Tokens used
|
|
59
63
|
|
|
60
|
-
`muted`, `muted-foreground`, `background`, `card`, `popover`
|
|
64
|
+
`muted`, `muted-foreground`, `background`, `card`, `popover`, `overlay`, `overlay-foreground`
|
|
61
65
|
|
|
62
66
|
## Accessibility
|
|
63
67
|
|
|
@@ -90,6 +94,14 @@ import { Avatar } from "@sableui/react"
|
|
|
90
94
|
<Avatar fallback="AM" alt="Alex Morgan" />
|
|
91
95
|
```
|
|
92
96
|
|
|
97
|
+
### Art fallback
|
|
98
|
+
|
|
99
|
+
```tsx
|
|
100
|
+
import { Avatar } from "@sableui/react"
|
|
101
|
+
|
|
102
|
+
<Avatar variant="art" fallback="AM" alt="Alex Morgan" />
|
|
103
|
+
```
|
|
104
|
+
|
|
93
105
|
### Sizes
|
|
94
106
|
|
|
95
107
|
```tsx
|
package/llms/components/card.md
CHANGED
|
@@ -39,7 +39,7 @@ Use Card for a discrete thing with its own boundary, such as one product, one no
|
|
|
39
39
|
| CardAction | ComponentPropsWithRef<"div"> | Header-aligned top-right action slot (e.g. an overflow menu, switch, or close button). Place inside CardHeader, which becomes a 2-column grid when present and collapses to a single column otherwise. |
|
|
40
40
|
| CardContent | ComponentPropsWithRef<"div"> & { flush?: boolean } | The body region. Padding follows the density knob on all sides — does not assume a preceding CardHeader, so it is safe to use standalone (e.g. no CardHeader in the card). When paired with CardHeader, the seam stays tight because CardHeader zeroes its own bottom padding. `flush` runs it edge to edge for a table: the Table insets its first and last columns by exactly the card padding, so it lines up with the header — never hand-set pl-6/pr-6 on columns. |
|
|
41
41
|
| CardList | ComponentPropsWithRef<"div"> | Rows that run edge to edge in the card (Entity rows, most often), divided by hairlines. Insets each row to the card's own padding, so a list card lines up with every other card at any density — no hand-tuned px-* on the rows. Under a CardHeader it opens with a hairline and a gap; alone it fills the card. |
|
|
42
|
-
| CardFooter | ComponentPropsWithRef<"div"> | Bottom row for actions/meta. Padding follows the density knob. |
|
|
42
|
+
| CardFooter | ComponentPropsWithRef<"div"> & { actions?: boolean } | Bottom row for actions/meta. Padding follows the density knob. `actions` marks it as the card's action row (buttons only): in a sheet (IosSheet, a bottom SheetContent) it becomes a full-width stack pinned to the bottom, primary on top; on a page nothing changes. |
|
|
43
43
|
|
|
44
44
|
## Props
|
|
45
45
|
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# GradientArt
|
|
2
|
+
|
|
3
|
+
**Status:** beta
|
|
4
|
+
|
|
5
|
+
Placeholder artwork painted by a static WebGL shader from the preset's own art direction (the --art-* tokens: palette, ground, warp, softness, grain) — Ember burns, Prism airbrushes, Meridian is grainy monochrome — with a CSS gradient underneath for prerendered pages and browsers without WebGL2.
|
|
6
|
+
|
|
7
|
+
**Keywords:** gradient, mesh gradient, grain, shader, artwork, placeholder image, cover, banner, avatar fallback, generative
|
|
8
|
+
|
|
9
|
+
## Usage
|
|
10
|
+
|
|
11
|
+
Use GradientArt where a real image belongs but none exists yet, or where the image is decoration: a product cover before upload, a profile banner, an album tile, an avatar with no photo. It reads the theme's own tokens, so it follows every preset, mode and live edit; `seed` fixes each item's composition so a list of covers or avatars stays individually recognisable.
|
|
12
|
+
|
|
13
|
+
### Best practices
|
|
14
|
+
|
|
15
|
+
- Do: Pass a stable `seed` per item — the name for an avatar, the title for a cover. Each item then keeps the same art across renders, reloads and themes, which is what lets someone recognise it.
|
|
16
|
+
- Do: Size it with layout classes (`aspect-[4/3]`, `h-20`, `size-10 rounded-full`) and put any glyph or initials inside as children; they render above the art.
|
|
17
|
+
- Do: Keep the default `mesh` for covers, tiles and avatars. Reach for `grain` where a printed, tactile surface suits — a banner, a hero card — and keep one variant per surface type so art reads as a system.
|
|
18
|
+
- Don't: Do not put body text over it. The art spans the palette's lightest and most saturated tones, so no single ink is guaranteed to clear contrast across it; reserve overlays for a short label on a scrim or an avatar's initials.
|
|
19
|
+
- Don't: Do not pass colours, and reach for `tones` rarely. The art follows the preset's --art-* tokens on purpose; to change how a product's art looks, set the --theme-art-* hooks in its theme or preset, so every cover, banner and avatar changes together.
|
|
20
|
+
- Don't: Do not use it as a page or hero background. It paints at the element's pixel size, so a full-viewport instance costs a large frame on every theme change; it is sized for covers, banners and tiles.
|
|
21
|
+
|
|
22
|
+
## Props
|
|
23
|
+
|
|
24
|
+
| Name | Type | Default | Description |
|
|
25
|
+
| --- | --- | --- | --- |
|
|
26
|
+
| variant | "mesh" \| "grain" | "mesh" | mesh: soft spots of colour pulled together by gentle waves. grain: glowing light over deep corners with a printed texture. |
|
|
27
|
+
| tones | ("info" \| "standard" \| "collectible" \| "edition" \| "warning")[] | — | Paint these `--tone-*` colours instead of the preset's art palette. Leave unset to follow the preset's art direction. |
|
|
28
|
+
| seed | string \| number | — | Fixes the composition: the same seed paints the same art in every theme. Pass a stable id. |
|
|
29
|
+
| children | ReactNode | — | Rendered above the art — initials, a glyph, a short label. |
|
|
30
|
+
| className | string | — | Size and shape: aspect, height, rounding. |
|
|
31
|
+
|
|
32
|
+
## Variants
|
|
33
|
+
|
|
34
|
+
- **variant**: mesh, grain
|
|
35
|
+
|
|
36
|
+
## Tokens used
|
|
37
|
+
|
|
38
|
+
`art-1`, `art-2`, `art-3`, `art-4`, `art-5`, `art-6`, `art-ground`, `art-warp`, `art-softness`, `art-grain`, `primary`, `tone-info`, `tone-standard`, `tone-collectible`, `tone-edition`, `tone-warning`
|
|
39
|
+
|
|
40
|
+
## Motion
|
|
41
|
+
|
|
42
|
+
- **arrival** · canvas · `motion-interactive` — The shader frame fades in over the CSS gradient it replaces, so the swap from fallback to shader reads as the art settling rather than a flash.
|
|
43
|
+
- **response** · root · _no motion_ — Artwork, not a control: it does not react to the pointer.
|
|
44
|
+
- **transition** · canvas · _custom (outside the recipe layer)_ — A theme change repaints the frame in place on the next animation frame; there are no states to move between.
|
|
45
|
+
- **activity** · root · _no motion_ — Static by design: nothing animates between paints, so it costs nothing while idle and respects reduced motion without a branch.
|
|
46
|
+
|
|
47
|
+
## Accessibility
|
|
48
|
+
|
|
49
|
+
- Decorative by default: the canvas is aria-hidden. When the art stands for something (an avatar), label the element that contains it, not the art.
|
|
50
|
+
- Children render above the art in the normal reading order, so initials or a label are announced as usual.
|
|
51
|
+
|
|
52
|
+
## Related
|
|
53
|
+
|
|
54
|
+
- Components: Avatar, Card, AspectBox
|
|
55
|
+
|
|
56
|
+
## Examples
|
|
57
|
+
|
|
58
|
+
### Product cover
|
|
59
|
+
|
|
60
|
+
```tsx
|
|
61
|
+
import { GradientArt } from "@sableui/react"
|
|
62
|
+
|
|
63
|
+
<GradientArt seed="Untitled (gold rush)" className="aspect-[4/3] rounded-lg" />
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Grain banner
|
|
67
|
+
|
|
68
|
+
```tsx
|
|
69
|
+
import { GradientArt } from "@sableui/react"
|
|
70
|
+
|
|
71
|
+
<GradientArt variant="grain" seed="juno" tones={["info", "edition"]} className="h-20 rounded-md" />
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
### Avatar with initials
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
import { GradientArt } from "@sableui/react"
|
|
78
|
+
|
|
79
|
+
<GradientArt seed="Mara Okafor" className="grid size-10 place-items-center rounded-full text-label-md text-overlay-foreground">MO</GradientArt>
|
|
80
|
+
```
|
package/llms/components/sheet.md
CHANGED
|
@@ -39,7 +39,7 @@ Use Sheet for secondary workflows that slide in while preserving context from th
|
|
|
39
39
|
| SheetHeader | ComponentProps<"div"> | Top section holding SheetTitle/SheetDescription. |
|
|
40
40
|
| SheetTitle | Base UI Drawer.Title props + className | The sheet's accessible name — wires aria-labelledby on the popup. |
|
|
41
41
|
| SheetDescription | Base UI Drawer.Description props + className | Muted supporting line; wires aria-describedby. |
|
|
42
|
-
| SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. |
|
|
42
|
+
| SheetFooter | ComponentProps<"div"> | Bottom row for actions, pinned after the scrollable body. In a bottom sheet it stacks: full width, the primary on top, every button at the cta height. |
|
|
43
43
|
|
|
44
44
|
## Props
|
|
45
45
|
|
|
@@ -29,6 +29,7 @@ Sable's components ship accessible behavior from Base UI. These rules cover what
|
|
|
29
29
|
|
|
30
30
|
- **Never:** Color as the only signal (a red border with no message). **Instead:** Color plus text or an icon. Roughly 1 in 12 men cannot tell red from green.
|
|
31
31
|
- **Never:** Opacity on the focus ring. **Instead:** ring-ring at full opacity; tune the token in the theme. A faded ring loses the contrast the theme tested. *Enforced by sable-lint: faded-focus-ring.*
|
|
32
|
+
- **Never:** A brand or status fill retuned under its old label — dark ink on a bright red that passes 4.5:1 at APCA Lc 42. **Instead:** A label that clears 4.5:1 and APCA Lc 60 in both modes. Move the fill's lightness, not its hue, keeping the ink its colour takes. A status colour on a surface is its own token (`--destructive-text`), so the fill can move freely. The ratio alone passes labels that read muddy. A deliberate brand pair is allowed with a recorded reason (lint disable or preset exceptions). *Enforced by sable-lint: label-contrast.*
|
|
32
33
|
- **Never:** Blocking paste, or type="number" for codes and card numbers. **Instead:** type="text" inputMode="numeric" with the right autocomplete. Blocking paste breaks password managers; number inputs scroll-change identifiers.
|
|
33
34
|
- **Never:** forced-color-adjust: none to 'fix' high-contrast mode. **Instead:** Let the system colors apply; verify the result. It removes the user's chosen colors exactly where they need them.
|
|
34
35
|
- **Never:** outline: none without a replacement. **Instead:** focus-visible:ring-2 ring-ring. Keyboard users lose their place.
|
|
@@ -28,22 +28,7 @@ Split sign-in page: a form column (brand mark, heading, email + password, primar
|
|
|
28
28
|
|
|
29
29
|
```tsx
|
|
30
30
|
import { useState } from "react"
|
|
31
|
-
import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, Icon, Input, Region, Separator, Stack } from "@sableui/react"
|
|
32
|
-
|
|
33
|
-
// A soft mesh of the theme's decorative tones over its brand colour, so the
|
|
34
|
-
// artwork follows every theme and mode. Each tone is lifted from its text
|
|
35
|
-
// strength (dark, restrained) to an art strength.
|
|
36
|
-
const artwork = [
|
|
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 18% 22%, oklch(from var(--tone-info) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 58%)",
|
|
39
|
-
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
40
|
-
"radial-gradient(in oklab at 82% 18%, oklch(from var(--tone-collectible) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
|
|
41
|
-
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
42
|
-
"radial-gradient(in oklab at 70% 88%, oklch(from var(--tone-edition) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 60%)",
|
|
43
|
-
// design-lint-disable-next-line inline-color -- relative colour derived from the theme's own --tone token, so it follows every look
|
|
44
|
-
"radial-gradient(in oklab at 12% 92%, oklch(from var(--tone-standard) clamp(0.5, calc(l + 0.16), 0.9) max(calc(c * 1.25), 0.1) h), transparent 55%)",
|
|
45
|
-
"var(--primary)",
|
|
46
|
-
].join(", ")
|
|
31
|
+
import { Avatar, Badge, Button, Card, CardContent, Entity, Field, Form, GradientArt, Icon, Input, Region, Separator, Stack } from "@sableui/react"
|
|
47
32
|
|
|
48
33
|
export function AuthPage() {
|
|
49
34
|
const [pending, setPending] = useState(false)
|
|
@@ -131,7 +116,8 @@ export function AuthPage() {
|
|
|
131
116
|
|
|
132
117
|
{/* The brand panel is decoration: on a phone the form is the whole screen. */}
|
|
133
118
|
<aside aria-label="Featured release" className="hidden p-3 lg:flex">
|
|
134
|
-
|
|
119
|
+
{/* Art painted from the theme's tones over its brand, so it follows every look. */}
|
|
120
|
+
<GradientArt seed="Untitled (gold rush)" className="flex flex-1 flex-col justify-end rounded-xl p-8">
|
|
135
121
|
<Card className="max-w-md">
|
|
136
122
|
<CardContent>
|
|
137
123
|
<Stack gap={3}>
|
|
@@ -143,14 +129,14 @@ export function AuthPage() {
|
|
|
143
129
|
</p>
|
|
144
130
|
<Entity
|
|
145
131
|
className="p-0"
|
|
146
|
-
leading={<Avatar fallback="MO" />}
|
|
132
|
+
leading={<Avatar variant="art" fallback="MO" />}
|
|
147
133
|
title="Mara Okafor"
|
|
148
134
|
subtitle="Untitled (gold rush), 2026"
|
|
149
135
|
/>
|
|
150
136
|
</Stack>
|
|
151
137
|
</CardContent>
|
|
152
138
|
</Card>
|
|
153
|
-
</
|
|
139
|
+
</GradientArt>
|
|
154
140
|
</aside>
|
|
155
141
|
</main>
|
|
156
142
|
)
|
|
@@ -377,7 +377,7 @@ export function BrandLandingPage() {
|
|
|
377
377
|
</blockquote>
|
|
378
378
|
</Stack>
|
|
379
379
|
<figcaption className="flex items-center gap-3">
|
|
380
|
-
<Avatar size="lg" fallback="MO" />
|
|
380
|
+
<Avatar variant="art" size="lg" fallback="MO" />
|
|
381
381
|
<div>
|
|
382
382
|
<p className="text-title-sm">Maya Okafor</p>
|
|
383
383
|
<p className="text-body-sm text-muted-foreground">Head of operations, Fielddesk</p>
|
|
@@ -77,7 +77,7 @@ export function ChatPanel() {
|
|
|
77
77
|
// at that size reads as a speck); `alt` names it for a screen reader, which
|
|
78
78
|
// otherwise gets nothing from a fallback-only avatar.
|
|
79
79
|
const assistant = (
|
|
80
|
-
<Avatar
|
|
80
|
+
<Avatar variant="art"
|
|
81
81
|
size="sm"
|
|
82
82
|
alt="Assistant"
|
|
83
83
|
fallback={<Icon name="sparkles" className="text-base" />}
|
|
@@ -111,7 +111,7 @@ export function DashboardPage() {
|
|
|
111
111
|
<span className="text-title-sm">Atelier</span>
|
|
112
112
|
</Row>
|
|
113
113
|
}
|
|
114
|
-
actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
114
|
+
actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
115
115
|
/>
|
|
116
116
|
}
|
|
117
117
|
bottomNav={
|
|
@@ -146,7 +146,7 @@ export function DashboardPage() {
|
|
|
146
146
|
<SidebarFooter>
|
|
147
147
|
<Entity
|
|
148
148
|
className="[--entity-py:0]"
|
|
149
|
-
leading={<Avatar size="sm" fallback="AL" alt="" />}
|
|
149
|
+
leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
|
|
150
150
|
title="Ada Lovelace"
|
|
151
151
|
subtitle="@ada"
|
|
152
152
|
action={
|
|
@@ -318,7 +318,7 @@ export function DashboardPage() {
|
|
|
318
318
|
{collectors.map((person) => (
|
|
319
319
|
<Entity
|
|
320
320
|
key={person.id}
|
|
321
|
-
leading={<Avatar size="sm" fallback={person.initials} alt="" />}
|
|
321
|
+
leading={<Avatar variant="art" size="sm" fallback={person.initials} alt="" />}
|
|
322
322
|
title={person.name}
|
|
323
323
|
subtitle={`${person.handle} · ${person.pieces} pieces`}
|
|
324
324
|
action={<span className="text-mono-sm">{usd.format(person.spent)}</span>}
|
|
@@ -365,7 +365,7 @@ export function DashboardPage() {
|
|
|
365
365
|
{activity.map((item) => (
|
|
366
366
|
<Entity
|
|
367
367
|
key={item.id}
|
|
368
|
-
leading={<Avatar size="sm" fallback={item.initials} alt="" />}
|
|
368
|
+
leading={<Avatar variant="art" size="sm" fallback={item.initials} alt="" />}
|
|
369
369
|
title={item.who}
|
|
370
370
|
subtitle={item.what}
|
|
371
371
|
action={<RelativeTime date={Date.now() - item.when} className="text-caption text-muted-foreground" />}
|
|
@@ -95,7 +95,7 @@ const renderPost = (post: Post) => (
|
|
|
95
95
|
<Card>
|
|
96
96
|
<CardList>
|
|
97
97
|
<Entity
|
|
98
|
-
leading={<Avatar fallback={post.initials} alt={post.author} />}
|
|
98
|
+
leading={<Avatar variant="art" fallback={post.initials} alt={post.author} />}
|
|
99
99
|
title={post.author}
|
|
100
100
|
subtitle={`${post.handle} · ${post.time}`}
|
|
101
101
|
action={
|
|
@@ -176,7 +176,7 @@ export function FeedPage() {
|
|
|
176
176
|
<Card>
|
|
177
177
|
<CardContent>
|
|
178
178
|
<Row gap={1.5} align="start">
|
|
179
|
-
<Avatar fallback="AL" alt="Ada Lovelace" />
|
|
179
|
+
<Avatar variant="art" fallback="AL" alt="Ada Lovelace" />
|
|
180
180
|
<Stack gap={1.5} className="min-w-0 flex-1">
|
|
181
181
|
<Textarea
|
|
182
182
|
autoResize
|
|
@@ -243,7 +243,7 @@ export function FeedPage() {
|
|
|
243
243
|
{suggestions.map((person) => (
|
|
244
244
|
<Entity
|
|
245
245
|
key={person.id}
|
|
246
|
-
leading={<Avatar fallback={person.initials} alt={person.name} />}
|
|
246
|
+
leading={<Avatar variant="art" fallback={person.initials} alt={person.name} />}
|
|
247
247
|
title={person.name}
|
|
248
248
|
subtitle={person.handle}
|
|
249
249
|
action={
|
|
@@ -263,7 +263,7 @@ export function FeedPage() {
|
|
|
263
263
|
{upcoming.map((drop) => (
|
|
264
264
|
<Entity
|
|
265
265
|
key={drop.id}
|
|
266
|
-
leading={<Avatar fallback={drop.initials} alt={drop.artist} />}
|
|
266
|
+
leading={<Avatar variant="art" fallback={drop.initials} alt={drop.artist} />}
|
|
267
267
|
title={drop.title}
|
|
268
268
|
subtitle={`${drop.artist} · ${drop.date}`}
|
|
269
269
|
action={
|
|
@@ -309,7 +309,7 @@ export function LandingPage() {
|
|
|
309
309
|
{deploys.map((deploy) => (
|
|
310
310
|
<Entity
|
|
311
311
|
key={deploy.meta}
|
|
312
|
-
leading={<Avatar size="sm" fallback={deploy.initials} alt={deploy.name} />}
|
|
312
|
+
leading={<Avatar variant="art" size="sm" fallback={deploy.initials} alt={deploy.name} />}
|
|
313
313
|
title={deploy.title}
|
|
314
314
|
subtitle={deploy.meta}
|
|
315
315
|
action={
|
|
@@ -212,7 +212,8 @@ export function ListDetailPage() {
|
|
|
212
212
|
selected={isSelected}
|
|
213
213
|
onSelect={() => select(m.id)}
|
|
214
214
|
leading={
|
|
215
|
-
<Avatar
|
|
215
|
+
<Avatar variant="art"
|
|
216
|
+
seed={m.name}
|
|
216
217
|
fallback={m.pending ? <Icon name="mail" className="size-5" /> : m.initials}
|
|
217
218
|
status={m.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
218
219
|
// The row's title already names it: keep the initials out of the row's name.
|
|
@@ -274,7 +275,7 @@ export function ListDetailPage() {
|
|
|
274
275
|
<Card>
|
|
275
276
|
<CardHeader>
|
|
276
277
|
<Row gap={2}>
|
|
277
|
-
<Avatar
|
|
278
|
+
<Avatar variant="art"
|
|
278
279
|
size="lg"
|
|
279
280
|
fallback={selected.pending ? <Icon name="mail" className="size-6" /> : selected.initials}
|
|
280
281
|
status={selected.lastActive === "Online now" ? <StatusDot tone="success" /> : undefined}
|
|
@@ -75,7 +75,7 @@ export function SettingsPage() {
|
|
|
75
75
|
<span className="text-title-sm">Atelier</span>
|
|
76
76
|
</Row>
|
|
77
77
|
}
|
|
78
|
-
actions={<Avatar size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
78
|
+
actions={<Avatar variant="art" size="sm" fallback="AL" alt="Ada Lovelace" />}
|
|
79
79
|
/>
|
|
80
80
|
}
|
|
81
81
|
bottomNav={
|
|
@@ -110,7 +110,7 @@ export function SettingsPage() {
|
|
|
110
110
|
<SidebarFooter>
|
|
111
111
|
<Entity
|
|
112
112
|
className="[--entity-py:0]"
|
|
113
|
-
leading={<Avatar size="sm" fallback="AL" alt="" />}
|
|
113
|
+
leading={<Avatar variant="art" size="sm" fallback="AL" alt="" />}
|
|
114
114
|
title="Ada Lovelace"
|
|
115
115
|
subtitle="@ada"
|
|
116
116
|
action={
|
|
@@ -160,7 +160,7 @@ export function SettingsPage() {
|
|
|
160
160
|
<CardContent>
|
|
161
161
|
<Stack gap={3}>
|
|
162
162
|
<Row gap={2}>
|
|
163
|
-
<Avatar size="lg" fallback="AL" alt="Ada Lovelace" />
|
|
163
|
+
<Avatar variant="art" size="lg" fallback="AL" alt="Ada Lovelace" />
|
|
164
164
|
<Stack gap={0.5} align="start">
|
|
165
165
|
<Row gap={1}>
|
|
166
166
|
<Button type="button" variant="outline" size="sm">
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
Which token to reach for, and what it is most often confused with. Generated from `src/themes/token-docs.ts`; the full data is `registry.json → tokens`.
|
|
4
4
|
|
|
5
|
-
### semantic (
|
|
5
|
+
### semantic (49)
|
|
6
6
|
|
|
7
7
|
- **`background`** — The page. The furthest-back surface in the page register. *Use for:* The document body, and any surface meant to read as 'the page itself'. Never anything that floats: background is the BOTTOM of the surface stack (background < card < popover in every dark mode), so a floating panel painted with it reads as a hole beneath the cards it hovers over.
|
|
8
8
|
- **Not** `popover` for anything floating, `card` for a bounded block on the page. `canvas`. `canvas` is the *work* surface in a tool — the artboard being edited — which is not the page behind the UI. Using `background` for an artboard makes the thing being edited indistinguishable from the chrome around it.
|
|
@@ -74,6 +74,14 @@ Which token to reach for, and what it is most often confused with. Generated fro
|
|
|
74
74
|
- **Not** `foreground`. That is the ink for `background` only; on any other surface it is a contrast gamble that happens to pass in the default theme and fails in another.
|
|
75
75
|
- **`highlight-foreground`** — Ink paired with `highlight`. Evaluate the rendered contrast together; never choose it independently. *Use for:* Whenever the background is `highlight`. Use them as a pair — `bg-highlight text-highlight-foreground` — so a theme can retune the surface and the ink together.
|
|
76
76
|
- **Not** `foreground`. That is the ink for `background` only; on any other surface it is a contrast gamble that happens to pass in the default theme and fails in another.
|
|
77
|
+
- **`destructive-text`** — `destructive` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-destructive`, `border-destructive` or `ring-destructive` — they read this token; only `bg-destructive` reads the fill.
|
|
78
|
+
- **Not** `destructive` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
|
|
79
|
+
- **`success-text`** — `success` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-success`, `border-success` or `ring-success` — they read this token; only `bg-success` reads the fill.
|
|
80
|
+
- **Not** `success` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
|
|
81
|
+
- **`tone-warning-text`** — `tone-warning` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-tone-warning`, `border-tone-warning` or `ring-tone-warning` — they read this token; only `bg-tone-warning` reads the fill.
|
|
82
|
+
- **Not** `tone-warning` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
|
|
83
|
+
- **`tone-info-text`** — `tone-info` drawn on a surface: text, icons, borders and focus rings. *Use for:* Write `text-tone-info`, `border-tone-info` or `ring-tone-info` — they read this token; only `bg-tone-info` reads the fill.
|
|
84
|
+
- **Not** `tone-info` itself for marks on a page. The fill is tuned to carry a label (dark mode lowers it under white ink), which can leave it too dark to read on a dark card.
|
|
77
85
|
- **`overlay`** — A scrim over the page behind a modal, and the wash behind media chrome. *Use for:* Dialog and sheet backdrops, letterboxing, a pill sitting on top of an image.
|
|
78
86
|
- **Not** `bg-black/50`. Pure black is never right — it crushes whatever is behind it, and a theme cannot tune it. This token is the sanctioned answer, which is why `raw-black-white` is a lint rule.
|
|
79
87
|
- **`overlay-foreground`** — Ink paired with `overlay`. Evaluate the rendered contrast together; never choose it independently. *Use for:* Whenever the background is `overlay`. Use them as a pair — `bg-overlay text-overlay-foreground` — so a theme can retune the surface and the ink together.
|
|
@@ -51,6 +51,12 @@ drift; repeated overrides can indicate a missing system hook rather than app mis
|
|
|
51
51
|
Run relevant `sable doctor`, `sable lint <paths> --json` and `sable grade <paths>
|
|
52
52
|
--json` checks. Lint is lexical; source grades and pattern similarity do not judge
|
|
53
53
|
taste. Deliberately static motion slots remain valid with appropriate feedback.
|
|
54
|
+
Lint's `label-contrast` measures an app's overrides of the brand fills (`--primary`,
|
|
55
|
+
`--sidebar-primary`, `--highlight`) and status fills (`--destructive`, `--success`,
|
|
56
|
+
`--tone-warning`, `--tone-info`) against their labels on top of the installed theme, and
|
|
57
|
+
names the nearest pair that reads. Report it with that pair as a recommendation.
|
|
58
|
+
A reasoned `design-lint-disable-next-line label-contrast` is the app's recorded
|
|
59
|
+
choice: list it as intentional, not as a defect.
|
|
54
60
|
|
|
55
61
|
## Deliver evidence-backed findings
|
|
56
62
|
|
|
@@ -70,7 +70,9 @@ Entity rows in a Card) or `redesign` (desktop workspace tools).
|
|
|
70
70
|
3. Compose: `NativeApp` at the root (`mode` sets light or dark, also on
|
|
71
71
|
`<html>` so portaled Sable popups follow); one `NavigationStack` per tab,
|
|
72
72
|
each holding an `IosPage` with an `IosNavbar` and Sable content; an
|
|
73
|
-
`IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms
|
|
73
|
+
`IosTabbar` of `IosTab`s with Sable `Icon`s; `IosSheet` around Sable forms
|
|
74
|
+
(`background="opaque"` for a form at the medium detent, and the block
|
|
75
|
+
`framed={false}`: the sheet is already its surface).
|
|
74
76
|
Keep the app's `IconProvider`.
|
|
75
77
|
4. Frame it on a host page with `PhoneFrame` from `@sableui/react/ios/preview`
|
|
76
78
|
and `@sableui/react/ios/frame.css` (Sable's Phone around an iframe at
|
|
@@ -27,7 +27,7 @@ button and text node takes a minute and settles the argument.
|
|
|
27
27
|
|
|
28
28
|
## 1. Wire the package
|
|
29
29
|
|
|
30
|
-
Follow **Consuming** in `llms.txt`:
|
|
30
|
+
Follow **Consuming** in `llms.txt`: `pnpm add`, React dedupe, the CSS
|
|
31
31
|
import order, `@source`, fonts, providers. React dedupe is the one that fails
|
|
32
32
|
loudest and least clearly ("invalid hook call").
|
|
33
33
|
|
|
@@ -31,6 +31,30 @@ light and dark modes and test long text, focus, disabled controls, floating
|
|
|
31
31
|
surfaces, and errors. Token validation checks the contract and safe CSS structure;
|
|
32
32
|
rendered review establishes contrast, coherence, and suitability.
|
|
33
33
|
|
|
34
|
+
`--check` also measures the label on every filled role against its `-foreground`,
|
|
35
|
+
in both modes: the brand fills `primary`, `sidebar-primary` and `highlight`, and the
|
|
36
|
+
status fills `destructive`, `success`, `tone-warning` and `tone-info`. A label must
|
|
37
|
+
clear 4.5:1 and APCA Lc 60, because a pair can pass the ratio and still read muddy
|
|
38
|
+
(dark ink on a bright red-orange). A failing pair stops the check with
|
|
39
|
+
`ERR_PRESET_CONTRAST` and the nearest pair that reads, with the fill's lightness
|
|
40
|
+
moved and its hue kept. A fill keeps the ink its light-mode colour takes, so a
|
|
41
|
+
colour with a white label keeps white in dark mode and its fill comes down to meet
|
|
42
|
+
it. Offer that pair to the user.
|
|
43
|
+
|
|
44
|
+
Each status colour has two tokens. The fill (`destructive`) carries its label;
|
|
45
|
+
`destructive-text` is the same colour drawn on a surface, and `text-destructive`,
|
|
46
|
+
`border-destructive` and `ring-destructive` read it. In dark mode the two usually
|
|
47
|
+
differ: a saturated fill low enough for white, and a lighter text value that reads
|
|
48
|
+
on a dark card. Author both; lowering the fill never darkens the page's error text.
|
|
49
|
+
If the user chose the failing pair on purpose (a brand book, a reference to match),
|
|
50
|
+
keep it and record the decision in the preset rather than overriding it:
|
|
51
|
+
|
|
52
|
+
```json
|
|
53
|
+
"exceptions": [{ "rule": "label-contrast", "mode": "dark", "token": "primary", "reason": "Brand book specifies dark ink on the bright fill." }]
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
An accepted pair is reported with its reason on every check; it is never silent.
|
|
57
|
+
|
|
34
58
|
Canvas's Export view downloads this same format with resolved theme values and
|
|
35
59
|
the current customizations. Enter a name and design guidance, save the JSON, and
|
|
36
60
|
place it in the target project. The browser cannot write into that project for
|
|
@@ -92,6 +92,15 @@ Use opaque `focus-visible:ring-ring` or `ring-sidebar-ring`; reducing the ring t
|
|
|
92
92
|
`faded-focus-ring` rule catches those classes. A token contrast report establishes
|
|
93
93
|
the tested pairs, not the entire rendered product's accessibility.
|
|
94
94
|
|
|
95
|
+
A label on a filled role (a primary or destructive Button, the sidebar's active
|
|
96
|
+
item, a highlight, a success or warning badge) should clear 4.5:1 and APCA Lc 60 in
|
|
97
|
+
both modes. A colour retuned for dark mode often lands where dark ink passes the
|
|
98
|
+
ratio but reads muddy. `label-contrast`
|
|
99
|
+
in `sable lint` and `sable preset --check` find it and suggest the nearest pair
|
|
100
|
+
that reads; offer that pair. Keep a pair the user chose deliberately, recorded with
|
|
101
|
+
its reason (a lint disable or a preset `exceptions` entry), instead of overriding
|
|
102
|
+
it to finish.
|
|
103
|
+
|
|
95
104
|
Describe a defect as a visible or behavioral consequence: "The heading wraps to
|
|
96
105
|
six lines at 390px and pushes the action below the intended first decision point."
|
|
97
106
|
Then record the repair and before/after artifact IDs in `iterations`. Avoid vague
|