@teamblind-chorus/ui 1.0.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/LICENSE +21 -0
- package/README.md +112 -0
- package/agents/AGENTS.md +143 -0
- package/agents/DESIGN.md +1311 -0
- package/agents/LOVABLE.md +472 -0
- package/agents/anti-patterns.md +533 -0
- package/agents/catalog.md +232 -0
- package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
- package/agents/components/avatar-rail/avatar-rail.md +103 -0
- package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
- package/agents/components/badge/badge.family.json +45 -0
- package/agents/components/badge/badge.md +10 -0
- package/agents/components/badge/role.md +100 -0
- package/agents/components/badge/role.spec.json +75 -0
- package/agents/components/badge/update.md +132 -0
- package/agents/components/badge/update.spec.json +114 -0
- package/agents/components/banner/banner.family.json +28 -0
- package/agents/components/banner/banner.md +136 -0
- package/agents/components/banner/banner.spec.json +136 -0
- package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
- package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
- package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
- package/agents/components/bubble/bubble.family.json +29 -0
- package/agents/components/bubble/bubble.md +134 -0
- package/agents/components/bubble/bubble.spec.json +91 -0
- package/agents/components/button/button.family.json +76 -0
- package/agents/components/button/button.md +31 -0
- package/agents/components/button/check.md +138 -0
- package/agents/components/button/check.spec.json +161 -0
- package/agents/components/button/fab.md +161 -0
- package/agents/components/button/fab.spec.json +106 -0
- package/agents/components/button/icon.md +141 -0
- package/agents/components/button/icon.spec.json +164 -0
- package/agents/components/button/standard.md +219 -0
- package/agents/components/button/standard.spec.json +205 -0
- package/agents/components/button/text.md +186 -0
- package/agents/components/button/text.spec.json +215 -0
- package/agents/components/button/toggle.md +108 -0
- package/agents/components/button/toggle.spec.json +124 -0
- package/agents/components/button/toolbar.md +189 -0
- package/agents/components/button/toolbar.spec.json +109 -0
- package/agents/components/carousel/carousel.family.json +41 -0
- package/agents/components/carousel/carousel.md +40 -0
- package/agents/components/carousel/post.md +148 -0
- package/agents/components/carousel/post.spec.json +229 -0
- package/agents/components/carousel/profile.md +184 -0
- package/agents/components/carousel/profile.spec.json +219 -0
- package/agents/components/chip/chip.family.json +37 -0
- package/agents/components/chip/chip.md +10 -0
- package/agents/components/chip/filter.md +212 -0
- package/agents/components/chip/filter.spec.json +124 -0
- package/agents/components/chip/tag.md +137 -0
- package/agents/components/chip/tag.spec.json +104 -0
- package/agents/components/dialog/dialog.family.json +29 -0
- package/agents/components/dialog/dialog.md +113 -0
- package/agents/components/dialog/dialog.spec.json +156 -0
- package/agents/components/directory-list/directory-list.family.json +46 -0
- package/agents/components/directory-list/directory-list.md +87 -0
- package/agents/components/directory-list/directory-list.spec.json +104 -0
- package/agents/components/divider/divider.family.json +28 -0
- package/agents/components/divider/divider.md +78 -0
- package/agents/components/divider/divider.spec.json +51 -0
- package/agents/components/feed/ad.md +108 -0
- package/agents/components/feed/ad.spec.json +187 -0
- package/agents/components/feed/feed.family.json +48 -0
- package/agents/components/feed/feed.md +30 -0
- package/agents/components/feed/post.md +240 -0
- package/agents/components/feed/post.spec.json +361 -0
- package/agents/components/form-field/form-field.family.json +50 -0
- package/agents/components/form-field/form-field.md +11 -0
- package/agents/components/form-field/input.md +198 -0
- package/agents/components/form-field/input.spec.json +202 -0
- package/agents/components/form-field/search.md +81 -0
- package/agents/components/form-field/search.spec.json +135 -0
- package/agents/components/form-field/select.md +101 -0
- package/agents/components/form-field/select.spec.json +194 -0
- package/agents/components/form-field/textarea.md +89 -0
- package/agents/components/form-field/textarea.spec.json +176 -0
- package/agents/components/header/header.family.json +43 -0
- package/agents/components/header/header.md +18 -0
- package/agents/components/header/main.md +101 -0
- package/agents/components/header/main.spec.json +117 -0
- package/agents/components/header/sub.md +129 -0
- package/agents/components/header/sub.spec.json +81 -0
- package/agents/components/list/accordion.md +183 -0
- package/agents/components/list/accordion.spec.json +201 -0
- package/agents/components/list/entry.md +280 -0
- package/agents/components/list/entry.spec.json +237 -0
- package/agents/components/list/list.family.json +75 -0
- package/agents/components/list/list.md +24 -0
- package/agents/components/list/radio.md +144 -0
- package/agents/components/list/radio.spec.json +186 -0
- package/agents/components/list/standard.md +262 -0
- package/agents/components/list/standard.spec.json +221 -0
- package/agents/components/metadata/compact.md +69 -0
- package/agents/components/metadata/compact.spec.json +69 -0
- package/agents/components/metadata/metadata.family.json +42 -0
- package/agents/components/metadata/metadata.md +26 -0
- package/agents/components/metadata/standard.md +104 -0
- package/agents/components/metadata/standard.spec.json +152 -0
- package/agents/components/nav-card/nav-card.family.json +29 -0
- package/agents/components/nav-card/nav-card.md +179 -0
- package/agents/components/nav-card/nav-card.spec.json +161 -0
- package/agents/components/nav-list/nav-list.family.json +46 -0
- package/agents/components/nav-list/nav-list.md +91 -0
- package/agents/components/nav-list/nav-list.spec.json +107 -0
- package/agents/components/navigation-bar/main.md +201 -0
- package/agents/components/navigation-bar/main.spec.json +109 -0
- package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
- package/agents/components/navigation-bar/navigation-bar.md +21 -0
- package/agents/components/navigation-bar/search.md +96 -0
- package/agents/components/navigation-bar/search.spec.json +142 -0
- package/agents/components/navigation-bar/sub.md +174 -0
- package/agents/components/navigation-bar/sub.spec.json +123 -0
- package/agents/components/page-shell/page-shell.family.json +22 -0
- package/agents/components/page-shell/page-shell.md +51 -0
- package/agents/components/profile-header/profile-header.family.json +29 -0
- package/agents/components/profile-header/profile-header.md +149 -0
- package/agents/components/profile-header/profile-header.spec.json +200 -0
- package/agents/components/progress/progress.family.json +27 -0
- package/agents/components/progress/progress.md +38 -0
- package/agents/components/progress/progress.spec.json +67 -0
- package/agents/components/side-sheet/side-sheet.family.json +30 -0
- package/agents/components/side-sheet/side-sheet.md +154 -0
- package/agents/components/side-sheet/side-sheet.spec.json +109 -0
- package/agents/components/skeleton/skeleton.family.json +28 -0
- package/agents/components/skeleton/skeleton.md +123 -0
- package/agents/components/skeleton/skeleton.spec.json +73 -0
- package/agents/components/status-tag/status-tag.family.json +26 -0
- package/agents/components/status-tag/status-tag.md +114 -0
- package/agents/components/status-tag/status-tag.spec.json +69 -0
- package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
- package/agents/components/suggestion-list/suggestion-list.md +91 -0
- package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
- package/agents/components/switch/switch.family.json +27 -0
- package/agents/components/switch/switch.md +114 -0
- package/agents/components/switch/switch.spec.json +123 -0
- package/agents/components/tab-bar/tab-bar.family.json +27 -0
- package/agents/components/tab-bar/tab-bar.md +178 -0
- package/agents/components/tab-bar/tab-bar.spec.json +184 -0
- package/agents/components/tabs/rounded.md +150 -0
- package/agents/components/tabs/rounded.spec.json +140 -0
- package/agents/components/tabs/segmented.md +114 -0
- package/agents/components/tabs/segmented.spec.json +100 -0
- package/agents/components/tabs/tabs.family.json +59 -0
- package/agents/components/tabs/tabs.md +18 -0
- package/agents/components/tabs/underline.md +147 -0
- package/agents/components/tabs/underline.spec.json +139 -0
- package/agents/components/thumbnail/thumbnail.family.json +28 -0
- package/agents/components/thumbnail/thumbnail.md +152 -0
- package/agents/components/thumbnail/thumbnail.spec.json +172 -0
- package/agents/components/toast/toast.family.json +28 -0
- package/agents/components/toast/toast.md +133 -0
- package/agents/components/toast/toast.spec.json +89 -0
- package/agents/components/tooltip/tooltip.family.json +29 -0
- package/agents/components/tooltip/tooltip.md +139 -0
- package/agents/components/tooltip/tooltip.spec.json +110 -0
- package/agents/compose.md +240 -0
- package/agents/icons.json +831 -0
- package/agents/images.md +66 -0
- package/agents/manifest.json +87 -0
- package/agents/patterns/README.md +59 -0
- package/agents/patterns/actions.md +50 -0
- package/agents/patterns/browsing.md +52 -0
- package/agents/patterns/communications.md +56 -0
- package/agents/patterns/layout.md +72 -0
- package/agents/patterns/modals.md +50 -0
- package/agents/patterns/visual.md +55 -0
- package/agents/reconstruct.md +55 -0
- package/agents/scoped-adoption.md +111 -0
- package/agents/tokens.usage.json +1657 -0
- package/agents/usage.json +422 -0
- package/dist/icons/index.cjs +1332 -0
- package/dist/icons/index.cjs.map +1 -0
- package/dist/icons/index.d.cts +228 -0
- package/dist/icons/index.d.ts +228 -0
- package/dist/icons/index.js +1114 -0
- package/dist/icons/index.js.map +1 -0
- package/dist/index.cjs +5905 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +896 -0
- package/dist/index.d.ts +896 -0
- package/dist/index.js +5847 -0
- package/dist/index.js.map +1 -0
- package/dist/styles.css +5765 -0
- package/eslint/README.md +79 -0
- package/eslint/index.js +78 -0
- package/eslint/rules.js +472 -0
- package/eslint/test.mjs +135 -0
- package/package.json +96 -0
- package/placeholder.png +0 -0
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# Underline
|
|
2
|
+
|
|
3
|
+
The canonical content-section switcher — a row of tabs anchored above a panel, with a 2px `onSurface` indicator that slides along the active tab's bottom edge.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** moving between peer panels of the same surface. **Skip when** the row is an in-place mode change — use [Segmented](./segmented.md) instead.
|
|
6
|
+
|
|
7
|
+
**Layout inset.** full-bleed — edge-to-edge family. Sits as a direct child of the page shell so the indicator travels between page edges; the row pays its own 16px inline padding via `layout.container.*` — do **not** wrap it in another padding div, or the page rail double-pays. Inside a bounded surface (Card / Dialog / BottomSheet / Sheet), apply the negative-margin opt-out — see [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
Headline form — three peer panels switched by a tab row. Adaptive width (see [Behavior](#behavior)).
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
tabs/underline/default
|
|
15
|
+
---
|
|
16
|
+
import { Tabs, Tab } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<Tabs variant="underline" value="overview" aria-label="Sections">
|
|
19
|
+
<Tab value="overview">Overview</Tab>
|
|
20
|
+
<Tab value="props">Props</Tab>
|
|
21
|
+
<Tab value="examples">Examples</Tab>
|
|
22
|
+
</Tabs>
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Use cases
|
|
26
|
+
|
|
27
|
+
### With icon
|
|
28
|
+
|
|
29
|
+
A leading glyph before the label.
|
|
30
|
+
|
|
31
|
+
```preview
|
|
32
|
+
tabs/underline/leading-icon
|
|
33
|
+
---
|
|
34
|
+
import { Tabs, Tab } from '@teamblind-chorus/ui';
|
|
35
|
+
import { PlusIcon, CheckedIcon } from '@teamblind-chorus/ui/icons';
|
|
36
|
+
|
|
37
|
+
<Tabs variant="underline" value="posts" aria-label="Profile sections">
|
|
38
|
+
<Tab value="posts" leadingIcon={<PlusIcon />}>Posts</Tab>
|
|
39
|
+
<Tab value="replies">Replies</Tab>
|
|
40
|
+
<Tab value="likes" leadingIcon={<CheckedIcon />}>Likes</Tab>
|
|
41
|
+
</Tabs>
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
### Auto-fit
|
|
45
|
+
|
|
46
|
+
Wider terminal layout of Adaptive width — tabs share row width equally; indicator widens to match.
|
|
47
|
+
|
|
48
|
+
```preview
|
|
49
|
+
tabs/underline/auto-fit
|
|
50
|
+
---
|
|
51
|
+
import { Tabs, Tab } from '@teamblind-chorus/ui';
|
|
52
|
+
|
|
53
|
+
<Tabs variant="underline" value="feed" aria-label="Sections">
|
|
54
|
+
<Tab value="feed">Feed</Tab>
|
|
55
|
+
<Tab value="channels">Channels</Tab>
|
|
56
|
+
<Tab value="members">Members</Tab>
|
|
57
|
+
</Tabs>
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Overflow
|
|
61
|
+
|
|
62
|
+
Narrower terminal layout — tabs hold content width and the row scrolls. The trailing 48px (`ref.space.600`) paints as a transparent `mask-image` edge fade; clears when scrolled to the last tab.
|
|
63
|
+
|
|
64
|
+
```preview
|
|
65
|
+
tabs/underline/overflow
|
|
66
|
+
---
|
|
67
|
+
import { Tabs, Tab } from '@teamblind-chorus/ui';
|
|
68
|
+
|
|
69
|
+
<Tabs variant="underline" value="overview" aria-label="Sections">
|
|
70
|
+
<Tab value="overview">Overview</Tab>
|
|
71
|
+
<Tab value="props">Props</Tab>
|
|
72
|
+
<Tab value="examples">Examples</Tab>
|
|
73
|
+
<Tab value="accessibility">Accessibility</Tab>
|
|
74
|
+
<Tab value="changelog">Changelog</Tab>
|
|
75
|
+
<Tab value="related">Related components</Tab>
|
|
76
|
+
<Tab value="discussion">Discussion</Tab>
|
|
77
|
+
</Tabs>
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
### Focus indicator
|
|
81
|
+
|
|
82
|
+
Static specimen — pins the keyboard-focus ring to the selected tab. See top-level [Focus indicator](#focus-indicator).
|
|
83
|
+
|
|
84
|
+
```preview
|
|
85
|
+
tabs/underline/focused
|
|
86
|
+
---
|
|
87
|
+
import { Tabs, Tab } from '@teamblind-chorus/ui';
|
|
88
|
+
|
|
89
|
+
<Tabs variant="underline" value="posts" aria-label="Profile">
|
|
90
|
+
<Tab value="posts" state="focused">Posts</Tab>
|
|
91
|
+
<Tab value="replies">Replies</Tab>
|
|
92
|
+
<Tab value="likes">Likes</Tab>
|
|
93
|
+
</Tabs>
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## Slots
|
|
97
|
+
|
|
98
|
+
- **label** — tab's accessible name. Required, single line.
|
|
99
|
+
- **leadingIcon** (optional) — context glyph sized at `sys.icon.md` (16) before the label.
|
|
100
|
+
|
|
101
|
+
## Anatomy
|
|
102
|
+
|
|
103
|
+
| Prop / state | Container | Label color | Indicator |
|
|
104
|
+
|------------------------|--------------------|---------------------------------------|-----------------------------------|
|
|
105
|
+
| **Tab — unselected** | transparent | `sys.color.outline` (muted foreground) | none |
|
|
106
|
+
| **Tab — selected** | transparent | `sys.color.onSurface` (strong foreground) | 2px `sys.borderWidth.thin` × `sys.color.onSurface` along the bottom edge |
|
|
107
|
+
| **Container row** | transparent, 16px inline padding, 1px `sys.color.outlineVariant` bottom divider running the full row width. Selected indicator paints over this divider. Edge fade (rightmost 48px / `ref.space.600`) paints via `mask-image` only while overflow is present. | — | — |
|
|
108
|
+
|
|
109
|
+
## Sizes
|
|
110
|
+
|
|
111
|
+
A single fixed rung — the 40px footprint stays constant across breakpoints.
|
|
112
|
+
|
|
113
|
+
| Property | Value | Token |
|
|
114
|
+
|-----------------------------------|----------------------|-------------------------------------|
|
|
115
|
+
| Container padding (inline) | 16px | `sys.layout.container.md` |
|
|
116
|
+
| Min-height (per tab) | 40px | `ref.space.500` ‡ |
|
|
117
|
+
| Padding (block × inline) | 8 × 16 | `sys.layout.container.xs` × `sys.layout.container.md` |
|
|
118
|
+
| Inter-tab gap | 0 | — † |
|
|
119
|
+
| Slot gap (icon ↔ label) | 4px | `sys.layout.inline.sm` |
|
|
120
|
+
| Indicator height | 2px | `sys.borderWidth.thin` |
|
|
121
|
+
| Container bottom divider | 1px | `sys.borderWidth.hairline` × `sys.color.outlineVariant` |
|
|
122
|
+
| Label | 14 / Semibold | `sys.typo.label.md` |
|
|
123
|
+
| Icon | 16px | `sys.icon.md` |
|
|
124
|
+
| Edge fade width | 48px | `ref.space.600` — trailing `mask-image`, on overflow only |
|
|
125
|
+
|
|
126
|
+
‡ `min-height` binds raw `ref.space.*` — `sys.*` does not currently expose a 40px step.
|
|
127
|
+
|
|
128
|
+
† Adjacent tabs sit flush; the visible 32px breathing comes from each tab's 16px inline padding on both sides.
|
|
129
|
+
|
|
130
|
+
## States
|
|
131
|
+
|
|
132
|
+
| State | Overlay opacity | Additional |
|
|
133
|
+
|------------|----------------------------|-----------------------------------------------------------------------------|
|
|
134
|
+
| `default` | — | Container + label at rest. |
|
|
135
|
+
| `hovered` | `sys.state.hover` (8%) | Pointer-driven via `:hover`. |
|
|
136
|
+
| `pressed` | `sys.state.pressed` (16%) | Pointer-driven via `:active`. |
|
|
137
|
+
| `selected` | — | Label flips to `sys.color.onSurface`; 2px indicator slides to the new tab. |
|
|
138
|
+
| `disabled` | overlay suppressed | Label at `sys.state.disabled` (40%) opacity, focus ring suppressed, `cursor: not-allowed`. |
|
|
139
|
+
|
|
140
|
+
## Focus indicator
|
|
141
|
+
|
|
142
|
+
**Composition: Inward** — row is a horizontal scroller, so an outward ring would clip. Paints as inset shadows inside the tab's bounding box on a `::after` overlay above the state-overlay tint and underline indicator. Focus never shifts a tab. Trigger: `:focus-visible`. See [Focus ring composition](../../DESIGN.md#focus-ring-composition).
|
|
143
|
+
|
|
144
|
+
## Behavior
|
|
145
|
+
|
|
146
|
+
- **Adaptive width.** One width mode — the row measures intrinsic tab widths against the container on every layout pass via `ResizeObserver`. If `Σ tab.intrinsicWidth ≤ container.clientWidth`, tabs share width equally (`flex: 1 1 0`) and the indicator widens to match; otherwise tabs hold content width (`flex: 0 0 auto`) and the row scrolls with the trailing edge fade. Mode flip is layout-immediate (no animation). No `fullWidth` prop — caller sizes the container; row picks Fit on its own.
|
|
147
|
+
- **Sliding indicator.** One indicator in the row, not one per tab. Selecting a different tab slides the 2px (`sys.borderWidth.thin`) stroke into the new tab's `offsetLeft` / `offsetWidth` over 220ms — reads as a single continuous gesture. The indicator scrolls with the row so it stays glued to its tab; on first paint the slide is suppressed; when Adaptive width flips between Fit and Scroll, the indicator's width is re-measured.
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "Tabs",
|
|
4
|
+
"family": "tabs",
|
|
5
|
+
"subcomponent": "underline",
|
|
6
|
+
"description": "Horizontal tab row with a single 2px (`sys.borderWidth.thin`) `sys.color.onSurface` indicator that slides between the active tab's bottom edge on selection. Default content-section switcher.",
|
|
7
|
+
"element": "div",
|
|
8
|
+
"props": {
|
|
9
|
+
"variant": {
|
|
10
|
+
"type": "literal",
|
|
11
|
+
"value": "underline"
|
|
12
|
+
},
|
|
13
|
+
"value": {
|
|
14
|
+
"type": "string",
|
|
15
|
+
"description": "The active tab's value."
|
|
16
|
+
},
|
|
17
|
+
"onChange": {
|
|
18
|
+
"type": "function",
|
|
19
|
+
"description": "Fired with the next value when a tab is selected."
|
|
20
|
+
},
|
|
21
|
+
"aria-label": {
|
|
22
|
+
"type": "string",
|
|
23
|
+
"description": "Accessible name for the tablist."
|
|
24
|
+
},
|
|
25
|
+
"embedded": {
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"default": false,
|
|
28
|
+
"description": "Composition mode flag. When `true` (or when Tabs is a direct child of `.chorus-carousel` / `.chorus-feed`), enters **embedded mode**: zeroes its own `background` + `padding` so chrome defers to the host. Pass explicitly inside `<Carousel>` / `<Feed>` for the contract to be visible in JSX; the DOM-ancestry safety net in styles.css also activates the mode when omitted. See `compositionModes` in `tabs.family.json`."
|
|
29
|
+
}
|
|
30
|
+
},
|
|
31
|
+
"behavior": {
|
|
32
|
+
"adaptiveWidth": {
|
|
33
|
+
"description": "Row measures Σ(tab.intrinsicWidth) vs container.clientWidth on every layout pass. Fits → tabs stretch (flex: 1 1 0); exceeds → tabs hold content width (flex: 0 0 auto) and the row scrolls horizontally with a trailing Edge fade. Re-evaluated via ResizeObserver on container + tab row. Not a caller-supplied prop."
|
|
34
|
+
}
|
|
35
|
+
},
|
|
36
|
+
"tabProps": {
|
|
37
|
+
"value": {
|
|
38
|
+
"type": "string",
|
|
39
|
+
"required": true
|
|
40
|
+
},
|
|
41
|
+
"leadingIcon": {
|
|
42
|
+
"type": "node",
|
|
43
|
+
"optional": true
|
|
44
|
+
},
|
|
45
|
+
"disabled": {
|
|
46
|
+
"type": "boolean",
|
|
47
|
+
"default": false
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
"slots": {
|
|
51
|
+
"label": {
|
|
52
|
+
"required": true,
|
|
53
|
+
"description": "Tab's accessible name. Single line.",
|
|
54
|
+
"accepts": [
|
|
55
|
+
"text"
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
"leadingIcon": {
|
|
59
|
+
"required": false,
|
|
60
|
+
"description": "Context glyph before label.",
|
|
61
|
+
"accepts": [
|
|
62
|
+
"icon"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
65
|
+
},
|
|
66
|
+
"sizing": {
|
|
67
|
+
"minHeight": "ref.space.500",
|
|
68
|
+
"containerPaddingInline": "sys.layout.container.md",
|
|
69
|
+
"paddingBlock": "sys.layout.container.xs",
|
|
70
|
+
"paddingInline": "sys.layout.container.md",
|
|
71
|
+
"interTabGap": "0",
|
|
72
|
+
"slotGap": "sys.layout.inline.sm",
|
|
73
|
+
"indicatorHeight": "sys.borderWidth.thin",
|
|
74
|
+
"dividerWidth": "sys.borderWidth.hairline",
|
|
75
|
+
"dividerColor": "sys.color.outlineVariant",
|
|
76
|
+
"labelTypo": "sys.typo.label.md",
|
|
77
|
+
"iconSize": "sys.icon.md",
|
|
78
|
+
"fadeWidth": "ref.space.600"
|
|
79
|
+
},
|
|
80
|
+
"selectionStates": {
|
|
81
|
+
"unselected": {
|
|
82
|
+
"label": "sys.color.outline",
|
|
83
|
+
"indicator": null
|
|
84
|
+
},
|
|
85
|
+
"selected": {
|
|
86
|
+
"label": "sys.color.onSurface",
|
|
87
|
+
"indicator": "sys.color.onSurface"
|
|
88
|
+
}
|
|
89
|
+
},
|
|
90
|
+
"states": {
|
|
91
|
+
"default": {
|
|
92
|
+
"overlay": null
|
|
93
|
+
},
|
|
94
|
+
"hovered": {
|
|
95
|
+
"overlay": {
|
|
96
|
+
"color": "label",
|
|
97
|
+
"opacity": "sys.state.hover"
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
"pressed": {
|
|
101
|
+
"overlay": {
|
|
102
|
+
"color": "label",
|
|
103
|
+
"opacity": "sys.state.pressed"
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
"disabled": {
|
|
107
|
+
"overlay": null,
|
|
108
|
+
"containerOpacity": "sys.state.disabled",
|
|
109
|
+
"suppressFocusRing": true,
|
|
110
|
+
"cursor": "not-allowed"
|
|
111
|
+
}
|
|
112
|
+
},
|
|
113
|
+
"focusIndicator": {
|
|
114
|
+
"description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Composes over whichever lifecycle state the tab is in.",
|
|
115
|
+
"composition": "inward",
|
|
116
|
+
"compositionReason": "The Tabs row is a horizontal scroller (`overflow-x: auto`, which coerces `overflow-y: auto`), so an outward ring would clip at the row's top/bottom edges. Segmented and Rounded re-anchor the chip's ring the same way — their rows are scrollers too.",
|
|
117
|
+
"overlay": {
|
|
118
|
+
"color": "label",
|
|
119
|
+
"opacity": "sys.state.focus"
|
|
120
|
+
},
|
|
121
|
+
"ring": {
|
|
122
|
+
"outerWidth": "sys.borderWidth.thin",
|
|
123
|
+
"outerColor": "sys.color.focus",
|
|
124
|
+
"outerLayerPosition": "depth 0..2px from the tab edge (the outer stroke)",
|
|
125
|
+
"insetWidth": "sys.borderWidth.hairline",
|
|
126
|
+
"insetColor": "sys.color.focusInset",
|
|
127
|
+
"insetLayerPosition": "depth 2..3px from the tab edge (the counter-ring just inside the outer stroke)",
|
|
128
|
+
"implementation": "inset box-shadow on the tab's `::after` overlay (sits above the state-overlay `::before`, the label/icon, and the underline indicator). Constrained strictly inside the tab's footprint and never exceeds it."
|
|
129
|
+
},
|
|
130
|
+
"trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
|
|
131
|
+
},
|
|
132
|
+
"forbidden": [
|
|
133
|
+
"underline tabs given raw <button>/<a> or bare-text children instead of <Tab value=…> elements — the sliding indicator (.chorus-tabs__indicator), the useAdaptiveFit measurement that sets data-fit, the aria-selected/data-value binding, and the active label styling all live on <Tab>; raw children render as unstyled run-together text with no indicator and a broken scroll/stretch layout",
|
|
134
|
+
"active indicator painted with sys.color.brand — underline uses sys.color.onSurface for the indicator",
|
|
135
|
+
"tabs wrapped in extra horizontal padding — tabs is full-bleed by family declaration",
|
|
136
|
+
"tab label sizing below sys.typo.label.md (14px) — smaller text breaks Korean / CJK hierarchy",
|
|
137
|
+
"manual underline drawn via `border-bottom:` — indicator is the `::after` overlay, not a real border"
|
|
138
|
+
]
|
|
139
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../family.schema.json",
|
|
3
|
+
"family": "thumbnail",
|
|
4
|
+
"name": "Thumbnail",
|
|
5
|
+
"description": "Image-bearing rectangle that surfaces alongside list rows, feed items, and channel headers. Single-spec family.",
|
|
6
|
+
"useCases": [
|
|
7
|
+
"avatar",
|
|
8
|
+
"channel image",
|
|
9
|
+
"list leading image",
|
|
10
|
+
"feed thumbnail",
|
|
11
|
+
"circular image"
|
|
12
|
+
],
|
|
13
|
+
"visualReuse": "open",
|
|
14
|
+
"layoutInset": "inline",
|
|
15
|
+
"spec": "thumbnail.md",
|
|
16
|
+
"usage": {
|
|
17
|
+
"note": "Pure circular image primitive (the channel/author avatar) — badges ride via the `updateDot` / `logoBadge` props, not children; carries no label of its own.",
|
|
18
|
+
"example": "<Thumbnail size={48} src=\"…\" alt=\"…\" />"
|
|
19
|
+
},
|
|
20
|
+
"subcomponents": [
|
|
21
|
+
{
|
|
22
|
+
"slug": "thumbnail",
|
|
23
|
+
"spec": "thumbnail.spec.json",
|
|
24
|
+
"md": "thumbnail.md",
|
|
25
|
+
"default": true
|
|
26
|
+
}
|
|
27
|
+
]
|
|
28
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# Thumbnail
|
|
2
|
+
|
|
3
|
+
A circular image — the unit that identifies a channel, a feed author, or any small-rung image. Two optional badges ride without changing footprint: an **update dot** top-right and a **logo badge** bottom-right. A pure visual primitive — no label of its own.
|
|
4
|
+
|
|
5
|
+
**Reach for this when** the host needs to identify an entity at a glance — channel avatar in a list row, feed author block, AvatarRail item, NavigationBar leading. **Skip when** the surface is a labelled icon glyph (use [Icon Button](../button/icon.md)) or a sub-brand identity at fixed inline scale ([NavCard](../nav-card/nav-card.md)'s logo slot).
|
|
6
|
+
|
|
7
|
+
**Layout inset.** inline — slot atom. Lives inside another component's leading slot (List row leading, Feed author block, AvatarRail item, SuggestionList row, NavigationBar leading); the host picks the rung (16 / 20 / 24 / 32 / 40 / 48 / 56) and places it. Never a sibling of `full-bleed` page rows.
|
|
8
|
+
|
|
9
|
+
## Default
|
|
10
|
+
|
|
11
|
+
Image only, no badges. Thumbnail is **image-first** — `src` expects a real image asset URL; the slot resolves to an `<img>`, not a glyph or text. When mocking up with no real image, fill `src` with `/placeholder.png` rather than omitting it — the empty-surface fallback is for runtime load failures, not design-time scaffolding.
|
|
12
|
+
|
|
13
|
+
```preview
|
|
14
|
+
thumbnail/default
|
|
15
|
+
---
|
|
16
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
17
|
+
|
|
18
|
+
<Thumbnail size={48} alt="Channel" src="/placeholder.png" />
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
## Use cases
|
|
22
|
+
|
|
23
|
+
### With update dot
|
|
24
|
+
|
|
25
|
+
A `brand`-tone dot at the top-right flags new activity. Decorative; the row carries the count in a sibling text slot.
|
|
26
|
+
|
|
27
|
+
```preview
|
|
28
|
+
thumbnail/with-update-dot
|
|
29
|
+
---
|
|
30
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
31
|
+
|
|
32
|
+
<Thumbnail size={48} alt="Channel" src="/placeholder.png" updateDot />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
### With logo badge
|
|
36
|
+
|
|
37
|
+
A 16 × 16 sub-brand mark at the bottom-right on its own surface halo.
|
|
38
|
+
|
|
39
|
+
```preview
|
|
40
|
+
thumbnail/with-logo-badge
|
|
41
|
+
---
|
|
42
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
43
|
+
|
|
44
|
+
<Thumbnail
|
|
45
|
+
size={48}
|
|
46
|
+
alt="Channel"
|
|
47
|
+
src="/placeholder.png"
|
|
48
|
+
logoBadge={{ src: '…', alt: 'Workspace' }}
|
|
49
|
+
/>
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### With both badges
|
|
53
|
+
|
|
54
|
+
Top-right and bottom-right corners are independent and never collide.
|
|
55
|
+
|
|
56
|
+
```preview
|
|
57
|
+
thumbnail/with-both
|
|
58
|
+
---
|
|
59
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
60
|
+
|
|
61
|
+
<Thumbnail
|
|
62
|
+
size={48}
|
|
63
|
+
alt="Channel"
|
|
64
|
+
src="/placeholder.png"
|
|
65
|
+
updateDot
|
|
66
|
+
logoBadge={{ src: '…', alt: 'Workspace' }}
|
|
67
|
+
/>
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
### With surface outline
|
|
71
|
+
|
|
72
|
+
`outlined` paints a 2-token (`sys.borderWidth.thin`) `sys.color.surface` halo as an outset `box-shadow` around the container. The ring blends into the host's `surface*` tier and separates the circle's edge from anything visually noisy underneath. Painted as a shadow, not a `border:` — the rung's diameter never reflows.
|
|
73
|
+
|
|
74
|
+
```preview
|
|
75
|
+
thumbnail/with-outline
|
|
76
|
+
---
|
|
77
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
78
|
+
|
|
79
|
+
<Thumbnail size={56} alt="Channel" src="/placeholder.png" outlined />
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Reach for `outlined` when** the Thumbnail half-overlaps or sits over an image ([ProfileHeader](../profile-header/profile-header.md) avatar on cover, [Profile carousel](../carousel/profile.md) avatar on card cover), the backdrop is a brand-tonal strip or gradient ([Banner](../banner/banner.md) inside a colour-tinted host, a Section painted with a `*Container` fill), the Thumbnail sits over a dark photo / pattern / video frame, or two adjacent Thumbnails partially overlap (avatar stack, cluster).
|
|
83
|
+
|
|
84
|
+
**Skip `outlined` when** the host is a plain `surface*` row (List / Feed / SuggestionList / Navigation bar leading) with no imagery underneath, the host's chrome already provides the separator (outlined card, hairline divider under the avatar), or the Thumbnail is `size={16}` / `size={20}` where the halo would dominate.
|
|
85
|
+
|
|
86
|
+
The two corner badges (`updateDot`, `logoBadge`) carry their own 1-token surface halos and compose cleanly over the outlined ring — order is image → outlined ring → badge halos → badge fills, all painted as `box-shadow` so footprint never changes.
|
|
87
|
+
|
|
88
|
+
### Size ladder
|
|
89
|
+
|
|
90
|
+
The full ladder side-by-side; update-dot steps down at the 32-rung boundary.
|
|
91
|
+
|
|
92
|
+
```preview
|
|
93
|
+
thumbnail/size-ladder
|
|
94
|
+
---
|
|
95
|
+
import { Thumbnail } from '@teamblind-chorus/ui';
|
|
96
|
+
|
|
97
|
+
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
|
98
|
+
<Thumbnail size={56} alt="A" updateDot />
|
|
99
|
+
<Thumbnail size={48} alt="B" updateDot />
|
|
100
|
+
<Thumbnail size={40} alt="C" updateDot />
|
|
101
|
+
<Thumbnail size={32} alt="D" updateDot />
|
|
102
|
+
<Thumbnail size={24} alt="E" updateDot />
|
|
103
|
+
<Thumbnail size={20} alt="F" updateDot />
|
|
104
|
+
<Thumbnail size={16} alt="G" updateDot />
|
|
105
|
+
</div>
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
## Slots
|
|
109
|
+
|
|
110
|
+
- **image** — required circular image at `radius.full`. When the asset hasn't loaded, the container holds `surfaceContainerHigh` and AT reads `alt`.
|
|
111
|
+
- **updateDot** *(optional)* — `brand`-tone dot at the top-right. Decorative (`aria-hidden`); pair with a text affordance for the count. Rendered by [Badge](../badge/badge.md)'s `dot-md` / `dot-sm` rungs — `dot-md` at 32 / 40 / 48 / 56, `dot-sm` at 16 / 20 / 24.
|
|
112
|
+
- **logoBadge** *(optional)* — 16 × 16 circular badge at the bottom-right, for a sub-brand glyph.
|
|
113
|
+
|
|
114
|
+
Both corner overlays sit *above* the image and carry a 1px (`borderWidth.hairline`) `surface`-color halo as a `box-shadow` — no change to bounding footprint. With `outlined={true}` the container takes a 2px (`borderWidth.thin`) `surface`-color outset halo via `box-shadow`, composing cleanly with the two badge halos above.
|
|
115
|
+
|
|
116
|
+
## Anatomy
|
|
117
|
+
|
|
118
|
+
| Slot | Token bindings |
|
|
119
|
+
|--------------|----------------|
|
|
120
|
+
| container | `radius.full`, no fill of its own. With `outlined={true}` adds a 2px (`borderWidth.thin`) `surface`-color outset halo via `box-shadow` |
|
|
121
|
+
| image | `surfaceContainerHigh` fallback, `onSurfaceVariant` fallback glyph |
|
|
122
|
+
| updateDot | Rendered by [Badge](../badge/badge.md) `dot-md` / `dot-sm` (`brand` fill, `radius.full`, 2px `surface`-color outline); top-right flush |
|
|
123
|
+
| logoBadge | 16 × 16, `radius.full`, `surface` halo at 1px, bottom-right flush |
|
|
124
|
+
|
|
125
|
+
## Sizes
|
|
126
|
+
|
|
127
|
+
Seven rungs. Diameter binds to a raw `ref.space.*` step so Thumbnail can sit verbatim inside a fixed-footprint row.
|
|
128
|
+
|
|
129
|
+
| Size | Diameter | Update-dot | Logo badge | Token (diameter) |
|
|
130
|
+
|-------|----------|------------|------------|------------------------------|
|
|
131
|
+
| 56 | 56px | 8 × 8 | 16 × 16 | `ref.space.700` |
|
|
132
|
+
| 48 | 48px | 8 × 8 | 16 × 16 | `ref.space.600` |
|
|
133
|
+
| 40 | 40px | 8 × 8 | 16 × 16 | `ref.space.500` |
|
|
134
|
+
| 32 | 32px | 8 × 8 | 16 × 16 | `ref.space.400` |
|
|
135
|
+
| 24 | 24px | 6 × 6 | 16 × 16 | `ref.space.300` |
|
|
136
|
+
| 20 | 20px | 6 × 6 | 16 × 16 | `ref.space.250` |
|
|
137
|
+
| 16 | 16px | 6 × 6 | 16 × 16 | `ref.space.200` |
|
|
138
|
+
|
|
139
|
+
Update-dot steps down at 32 so it stays a *highlight*, not an *occluder*; logo badge pins at 16 because the glyph it carries is illegible below that. The 56 rung is reserved for the canonical follow-suggestion / xlarge directory-row leading slot (see [list/entry](../list/entry.md) `size="xlarge"`).
|
|
140
|
+
|
|
141
|
+
## States
|
|
142
|
+
|
|
143
|
+
Not interactive — no hover / pressed / focused / disabled. When wrapped in an interactive row, the row owns state and focus.
|
|
144
|
+
|
|
145
|
+
When the image fails to load or `src` is omitted, the slot enters its **fallback** form — `/placeholder.png` paints as `background-image` (centered, covered) over a `surfaceContainerHigh` base. The fallback is a runtime safety net; design-time scaffolds should pass `/placeholder.png` through `src` explicitly.
|
|
146
|
+
|
|
147
|
+
## Behavior
|
|
148
|
+
|
|
149
|
+
- **Slot omission collapses without a gap.** Both badges drop out entirely when absent.
|
|
150
|
+
- **Badges overlay; they never reflow the image.** Absolutely positioned; the 1px halo is a `box-shadow`.
|
|
151
|
+
- **Image clipped to a perfect circle.** `radius.full` + `overflow: hidden`; hand in a square-or-larger source.
|
|
152
|
+
- **Image fallback, not text fallback.** When `src` is omitted or the server fails to deliver, the slot's `background-image` paints `/placeholder.png` over a `surfaceContainerHigh` base. No text/initial fallback.
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "../../spec.schema.json",
|
|
3
|
+
"name": "Thumbnail",
|
|
4
|
+
"family": "thumbnail",
|
|
5
|
+
"exportAlias": "Avatar",
|
|
6
|
+
"description": "Circular image — the unit used to identify a channel, a feed item's author, or any small-rung image that needs to live inside a denser composition (lists, feed rows, comment threads, mention rails). The image fills the whole footprint at full radius; two optional badges ride on the container without changing its footprint: an update dot at the top-right, and a logo badge at the bottom-right. A pure visual primitive — it never carries a label of its own.",
|
|
7
|
+
"element": "div",
|
|
8
|
+
"props": {
|
|
9
|
+
"size": {
|
|
10
|
+
"type": "enum",
|
|
11
|
+
"values": [
|
|
12
|
+
56,
|
|
13
|
+
48,
|
|
14
|
+
40,
|
|
15
|
+
32,
|
|
16
|
+
24,
|
|
17
|
+
20,
|
|
18
|
+
16
|
|
19
|
+
],
|
|
20
|
+
"default": 48
|
|
21
|
+
},
|
|
22
|
+
"src": {
|
|
23
|
+
"type": "string",
|
|
24
|
+
"assetType": "image",
|
|
25
|
+
"optional": true,
|
|
26
|
+
"description": "URL of the image asset that fills the circular slot — a raster (PNG / JPG / WebP) or vector (SVG) source. Required for the rendered form; the prop is technically optional because the Thumbnail also has a defined `surfaceContainerHigh` fallback, but any production composition is expected to pass a real asset. When generating mock or scaffold compositions without a real channel / author image, use the bundled placeholder asset at `/placeholder.png` rather than omitting `src` — the empty-surface fallback is for runtime load failures, not for design-time scaffolding.",
|
|
27
|
+
"placeholder": "/placeholder.png",
|
|
28
|
+
"example": "/placeholder.png"
|
|
29
|
+
},
|
|
30
|
+
"alt": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"required": true
|
|
33
|
+
},
|
|
34
|
+
"updateDot": {
|
|
35
|
+
"type": "boolean",
|
|
36
|
+
"default": false
|
|
37
|
+
},
|
|
38
|
+
"logoBadge": {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"optional": true,
|
|
41
|
+
"description": "{ src, alt } — 16×16 circular badge at the bottom-right."
|
|
42
|
+
},
|
|
43
|
+
"outlined": {
|
|
44
|
+
"type": "boolean",
|
|
45
|
+
"default": false,
|
|
46
|
+
"description": "When `true`, paints a 2-token-wide `sys.color.surface` outline around the Thumbnail container as an outset halo. The outline reads as an isolation ring that separates the Thumbnail's circular edge from anything visually noisy underneath it. Painted as `box-shadow: 0 0 0 sys.borderWidth.thin sys.color.surface` so it never reflows the slot's intrinsic diameter — same no-layout-stroke idiom the rest of the system uses. Pair with hosts whose chrome reads as a `surface*` tier (so the halo blends in) and whose backdrop differs from that tier (so the halo actually separates).",
|
|
47
|
+
"whenToReachForIt": [
|
|
48
|
+
"The Thumbnail half-overlaps or sits over an image — [ProfileHeader](../profile-header/profile-header.md) avatar (56-rung on cover band), [Profile carousel](../carousel/profile.md) avatar (64-rung on card cover), any avatar pulled onto a Hero / Cover photo.",
|
|
49
|
+
"The backdrop is a brand-tonal strip, a `*Container` fill, or a gradient band ([Banner](../banner/banner.md) inside a colour-tinted host, a Section painted with a `successContainer` / `errorContainer` / `brandContainer` fill).",
|
|
50
|
+
"The Thumbnail sits over a dark photo / pattern / video frame where the image's own edge tones can collide with the avatar's edge tones.",
|
|
51
|
+
"Two adjacent Thumbnails partially overlap (avatar stack, cluster) and need a clean separator between them."
|
|
52
|
+
],
|
|
53
|
+
"whenToSkipIt": [
|
|
54
|
+
"Plain `surface*` row hosts (List / Feed / SuggestionList / NavigationBar leading) with no imagery underneath — `surface`-on-`surface` halo is invisible and costs render work for zero gain.",
|
|
55
|
+
"The host's own chrome already provides the separator (outlined card, hairline divider directly under the avatar).",
|
|
56
|
+
"Tiny rungs (`size={16}` / `size={20}`) where the halo would dominate the visible glyph."
|
|
57
|
+
],
|
|
58
|
+
"canonicalHosts": [
|
|
59
|
+
"ProfileHeader avatar (always `outlined={true}` — half-overlaps cover band)",
|
|
60
|
+
"ProfileCarousel avatar (always `outlined={true}` — half-overlaps card cover image)"
|
|
61
|
+
]
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
"slots": {
|
|
65
|
+
"image": {
|
|
66
|
+
"required": true,
|
|
67
|
+
"description": "Circular image asset (raster PNG / JPG / WebP or vector SVG). Fills the whole container at radius.full. Image-only — no text fallback. When no real asset is available, fill with the bundled placeholder `/placeholder.png` so the slot still resolves to an image.",
|
|
68
|
+
"accepts": [
|
|
69
|
+
"image"
|
|
70
|
+
],
|
|
71
|
+
"assetType": "image",
|
|
72
|
+
"placeholder": "/placeholder.png"
|
|
73
|
+
},
|
|
74
|
+
"updateDot": {
|
|
75
|
+
"required": false,
|
|
76
|
+
"description": "Brand-tone dot at the top-right. aria-hidden — decorative. Rendered by the [Badge](../badge/badge.md) component's `dot-md` / `dot-sm` rungs so the dot, its halo, and its rung names stay in lockstep with the Badge family. Thumbnail picks `dot-md` (8×8) at the 32 / 40 / 48 / 56 rungs and `dot-sm` (4×4) at the 16 / 20 / 24 rungs.",
|
|
77
|
+
"intrinsic": true,
|
|
78
|
+
"rendersAs": "badge:dot-md|dot-sm"
|
|
79
|
+
},
|
|
80
|
+
"logoBadge": {
|
|
81
|
+
"required": false,
|
|
82
|
+
"description": "16×16 circular badge at the bottom-right, used to attach a sub-brand glyph.",
|
|
83
|
+
"accepts": [
|
|
84
|
+
"icon",
|
|
85
|
+
"image"
|
|
86
|
+
]
|
|
87
|
+
}
|
|
88
|
+
},
|
|
89
|
+
"appearance": {
|
|
90
|
+
"containerRadius": "sys.radius.full",
|
|
91
|
+
"containerOutline": {
|
|
92
|
+
"appliesWhen": "props.outlined === true",
|
|
93
|
+
"color": "sys.color.surface",
|
|
94
|
+
"width": "sys.borderWidth.thin",
|
|
95
|
+
"rendering": "box-shadow (outset, 0 0 0 width color) so the halo never reflows the slot's intrinsic diameter — same no-layout-stroke contract every other Chorus surface follows. Pairs the halo with the host's `surface*` tier so the ring blends into the chrome around it while separating the Thumbnail from a contrasting backdrop (cover image, brand tonal strip, gradient band)."
|
|
96
|
+
},
|
|
97
|
+
"imageFallbackFill": "sys.color.surfaceContainerHigh",
|
|
98
|
+
"imageFallbackImage": "/placeholder.png",
|
|
99
|
+
"imageFallbackImageRendering": "background-image, cover, center — paints under the runtime <img>; visible only when the inline image is missing or fails to load, so the slot still resolves to an image rather than an empty surface tone.",
|
|
100
|
+
"imageFallbackGlyphColor": "sys.color.onSurfaceVariant",
|
|
101
|
+
"updateDotFill": "sys.color.brand",
|
|
102
|
+
"updateDotRadius": "sys.radius.full",
|
|
103
|
+
"updateDotHalo": {
|
|
104
|
+
"color": "sys.color.surface",
|
|
105
|
+
"width": "sys.borderWidth.hairline",
|
|
106
|
+
"rendering": "box-shadow"
|
|
107
|
+
},
|
|
108
|
+
"logoBadgeRadius": "sys.radius.full",
|
|
109
|
+
"logoBadgeHalo": {
|
|
110
|
+
"color": "sys.color.surface",
|
|
111
|
+
"width": "sys.borderWidth.hairline",
|
|
112
|
+
"rendering": "box-shadow"
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
"sizes": {
|
|
116
|
+
"16": {
|
|
117
|
+
"diameter": "ref.space.200",
|
|
118
|
+
"updateDot": "6 × 6",
|
|
119
|
+
"logoBadge": "16 × 16"
|
|
120
|
+
},
|
|
121
|
+
"20": {
|
|
122
|
+
"diameter": "ref.space.250",
|
|
123
|
+
"updateDot": "6 × 6",
|
|
124
|
+
"logoBadge": "16 × 16"
|
|
125
|
+
},
|
|
126
|
+
"24": {
|
|
127
|
+
"diameter": "ref.space.300",
|
|
128
|
+
"updateDot": "6 × 6",
|
|
129
|
+
"logoBadge": "16 × 16"
|
|
130
|
+
},
|
|
131
|
+
"32": {
|
|
132
|
+
"diameter": "ref.space.400",
|
|
133
|
+
"updateDot": "8 × 8",
|
|
134
|
+
"logoBadge": "16 × 16"
|
|
135
|
+
},
|
|
136
|
+
"40": {
|
|
137
|
+
"diameter": "ref.space.500",
|
|
138
|
+
"updateDot": "8 × 8",
|
|
139
|
+
"logoBadge": "16 × 16"
|
|
140
|
+
},
|
|
141
|
+
"48": {
|
|
142
|
+
"diameter": "ref.space.600",
|
|
143
|
+
"updateDot": "8 × 8",
|
|
144
|
+
"logoBadge": "16 × 16"
|
|
145
|
+
},
|
|
146
|
+
"56": {
|
|
147
|
+
"diameter": "ref.space.700",
|
|
148
|
+
"updateDot": "8 × 8",
|
|
149
|
+
"logoBadge": "16 × 16"
|
|
150
|
+
}
|
|
151
|
+
},
|
|
152
|
+
"states": {
|
|
153
|
+
"note": "Thumbnail is not an interactive primitive — no hover / pressed / focused / disabled of its own. When inside an interactive row, the row owns state and the focus ring paints around the row's bounds. Fallback form: when `src` is missing or the server fails to deliver the image, the slot paints the bundled `/placeholder.png` as a background-image over a `surfaceContainerHigh` base so the slot still resolves to an image rather than an empty surface."
|
|
154
|
+
},
|
|
155
|
+
"behavior": {
|
|
156
|
+
"slotOmissionCollapses": "Both badges (updateDot, logoBadge) drop out of the layout entirely when absent — no reserved corner whitespace.",
|
|
157
|
+
"badgesOverlay": "Both badges are absolutely positioned over the image; the container's overall footprint is the image's diameter regardless of whether the badges are present. The 1px surface halo is rendered as a box-shadow.",
|
|
158
|
+
"outlinedHalo": "When `outlined={true}`, the container paints a 2-token (`sys.borderWidth.thin`) `sys.color.surface` halo via `box-shadow` — outset of the diameter, never reflowing the slot. The corner badges' own 1-token halos are layered above it. Composes cleanly with `updateDot` and `logoBadge`.",
|
|
159
|
+
"imageClip": "Container's radius.full plus overflow: hidden clips the image to a perfect circle even when the source is rectangular.",
|
|
160
|
+
"noTextFallback": "Slot only renders <img> content — there is no text fallback. When `src` is omitted or the server fails to deliver the image, the slot's background paints the bundled `/placeholder.png` over a `surfaceContainerHigh` base. The placeholder is the image-area's runtime safety net for load failures; design-time scaffolds should pass the same URL through `src` explicitly so the contract is visible in the composition rather than hidden in the CSS layer.",
|
|
161
|
+
"updateDotBreakpoint": "Dot drops from 8×8 (at and above rung 32, including 56) to 4×4 (rungs 24 / 20 / 16) so it reads as highlight, not occluder, on the smaller diameters.",
|
|
162
|
+
"logoBadgeFixed": "Pinned at 16×16 at every rung — at the 16px Thumbnail the badge intentionally overlaps so the host image still reads through."
|
|
163
|
+
},
|
|
164
|
+
"forbidden": [
|
|
165
|
+
"thumbnail rendered as a raw <img> outside the chorus-thumbnail wrapper — the wrapper owns the placeholder fallback and the surface halo",
|
|
166
|
+
"border-radius override on a thumbnail — the radius comes from the variant (full for avatar, sys.radius.md for media) and is non-negotiable",
|
|
167
|
+
"size override outside the declared rung — 16 / 20 / 24 / 32 / 40 / 48 / 56 are the canonical sizes",
|
|
168
|
+
"alt prop omitted or set to a generic 'thumbnail' string — alt names the subject of the image, not the slot role",
|
|
169
|
+
"Thumbnail half-overlapping a cover image (ProfileHeader, ProfileCarousel, any Hero) WITHOUT `outlined={true}` — the avatar's edge tones can collide with the image's edge tones, costing the circle its perceived boundary. Forward `outlined` to the Thumbnail; don't paint a halo on the host's wrapper",
|
|
170
|
+
"hand-rolled `border:` or `outline:` on the Thumbnail container to fake the halo — reflows the slot's diameter and breaks the rung's intrinsic geometry. Use the `outlined` prop, which paints via outset `box-shadow`"
|
|
171
|
+
]
|
|
172
|
+
}
|