@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.
Files changed (191) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +112 -0
  3. package/agents/AGENTS.md +143 -0
  4. package/agents/DESIGN.md +1311 -0
  5. package/agents/LOVABLE.md +472 -0
  6. package/agents/anti-patterns.md +533 -0
  7. package/agents/catalog.md +232 -0
  8. package/agents/components/avatar-rail/avatar-rail.family.json +46 -0
  9. package/agents/components/avatar-rail/avatar-rail.md +103 -0
  10. package/agents/components/avatar-rail/avatar-rail.spec.json +160 -0
  11. package/agents/components/badge/badge.family.json +45 -0
  12. package/agents/components/badge/badge.md +10 -0
  13. package/agents/components/badge/role.md +100 -0
  14. package/agents/components/badge/role.spec.json +75 -0
  15. package/agents/components/badge/update.md +132 -0
  16. package/agents/components/badge/update.spec.json +114 -0
  17. package/agents/components/banner/banner.family.json +28 -0
  18. package/agents/components/banner/banner.md +136 -0
  19. package/agents/components/banner/banner.spec.json +136 -0
  20. package/agents/components/bottom-sheet/bottom-sheet.family.json +29 -0
  21. package/agents/components/bottom-sheet/bottom-sheet.md +176 -0
  22. package/agents/components/bottom-sheet/bottom-sheet.spec.json +168 -0
  23. package/agents/components/bubble/bubble.family.json +29 -0
  24. package/agents/components/bubble/bubble.md +134 -0
  25. package/agents/components/bubble/bubble.spec.json +91 -0
  26. package/agents/components/button/button.family.json +76 -0
  27. package/agents/components/button/button.md +31 -0
  28. package/agents/components/button/check.md +138 -0
  29. package/agents/components/button/check.spec.json +161 -0
  30. package/agents/components/button/fab.md +161 -0
  31. package/agents/components/button/fab.spec.json +106 -0
  32. package/agents/components/button/icon.md +141 -0
  33. package/agents/components/button/icon.spec.json +164 -0
  34. package/agents/components/button/standard.md +219 -0
  35. package/agents/components/button/standard.spec.json +205 -0
  36. package/agents/components/button/text.md +186 -0
  37. package/agents/components/button/text.spec.json +215 -0
  38. package/agents/components/button/toggle.md +108 -0
  39. package/agents/components/button/toggle.spec.json +124 -0
  40. package/agents/components/button/toolbar.md +189 -0
  41. package/agents/components/button/toolbar.spec.json +109 -0
  42. package/agents/components/carousel/carousel.family.json +41 -0
  43. package/agents/components/carousel/carousel.md +40 -0
  44. package/agents/components/carousel/post.md +148 -0
  45. package/agents/components/carousel/post.spec.json +229 -0
  46. package/agents/components/carousel/profile.md +184 -0
  47. package/agents/components/carousel/profile.spec.json +219 -0
  48. package/agents/components/chip/chip.family.json +37 -0
  49. package/agents/components/chip/chip.md +10 -0
  50. package/agents/components/chip/filter.md +212 -0
  51. package/agents/components/chip/filter.spec.json +124 -0
  52. package/agents/components/chip/tag.md +137 -0
  53. package/agents/components/chip/tag.spec.json +104 -0
  54. package/agents/components/dialog/dialog.family.json +29 -0
  55. package/agents/components/dialog/dialog.md +113 -0
  56. package/agents/components/dialog/dialog.spec.json +156 -0
  57. package/agents/components/directory-list/directory-list.family.json +46 -0
  58. package/agents/components/directory-list/directory-list.md +87 -0
  59. package/agents/components/directory-list/directory-list.spec.json +104 -0
  60. package/agents/components/divider/divider.family.json +28 -0
  61. package/agents/components/divider/divider.md +78 -0
  62. package/agents/components/divider/divider.spec.json +51 -0
  63. package/agents/components/feed/ad.md +108 -0
  64. package/agents/components/feed/ad.spec.json +187 -0
  65. package/agents/components/feed/feed.family.json +48 -0
  66. package/agents/components/feed/feed.md +30 -0
  67. package/agents/components/feed/post.md +240 -0
  68. package/agents/components/feed/post.spec.json +361 -0
  69. package/agents/components/form-field/form-field.family.json +50 -0
  70. package/agents/components/form-field/form-field.md +11 -0
  71. package/agents/components/form-field/input.md +198 -0
  72. package/agents/components/form-field/input.spec.json +202 -0
  73. package/agents/components/form-field/search.md +81 -0
  74. package/agents/components/form-field/search.spec.json +135 -0
  75. package/agents/components/form-field/select.md +101 -0
  76. package/agents/components/form-field/select.spec.json +194 -0
  77. package/agents/components/form-field/textarea.md +89 -0
  78. package/agents/components/form-field/textarea.spec.json +176 -0
  79. package/agents/components/header/header.family.json +43 -0
  80. package/agents/components/header/header.md +18 -0
  81. package/agents/components/header/main.md +101 -0
  82. package/agents/components/header/main.spec.json +117 -0
  83. package/agents/components/header/sub.md +129 -0
  84. package/agents/components/header/sub.spec.json +81 -0
  85. package/agents/components/list/accordion.md +183 -0
  86. package/agents/components/list/accordion.spec.json +201 -0
  87. package/agents/components/list/entry.md +280 -0
  88. package/agents/components/list/entry.spec.json +237 -0
  89. package/agents/components/list/list.family.json +75 -0
  90. package/agents/components/list/list.md +24 -0
  91. package/agents/components/list/radio.md +144 -0
  92. package/agents/components/list/radio.spec.json +186 -0
  93. package/agents/components/list/standard.md +262 -0
  94. package/agents/components/list/standard.spec.json +221 -0
  95. package/agents/components/metadata/compact.md +69 -0
  96. package/agents/components/metadata/compact.spec.json +69 -0
  97. package/agents/components/metadata/metadata.family.json +42 -0
  98. package/agents/components/metadata/metadata.md +26 -0
  99. package/agents/components/metadata/standard.md +104 -0
  100. package/agents/components/metadata/standard.spec.json +152 -0
  101. package/agents/components/nav-card/nav-card.family.json +29 -0
  102. package/agents/components/nav-card/nav-card.md +179 -0
  103. package/agents/components/nav-card/nav-card.spec.json +161 -0
  104. package/agents/components/nav-list/nav-list.family.json +46 -0
  105. package/agents/components/nav-list/nav-list.md +91 -0
  106. package/agents/components/nav-list/nav-list.spec.json +107 -0
  107. package/agents/components/navigation-bar/main.md +201 -0
  108. package/agents/components/navigation-bar/main.spec.json +109 -0
  109. package/agents/components/navigation-bar/navigation-bar.family.json +44 -0
  110. package/agents/components/navigation-bar/navigation-bar.md +21 -0
  111. package/agents/components/navigation-bar/search.md +96 -0
  112. package/agents/components/navigation-bar/search.spec.json +142 -0
  113. package/agents/components/navigation-bar/sub.md +174 -0
  114. package/agents/components/navigation-bar/sub.spec.json +123 -0
  115. package/agents/components/page-shell/page-shell.family.json +22 -0
  116. package/agents/components/page-shell/page-shell.md +51 -0
  117. package/agents/components/profile-header/profile-header.family.json +29 -0
  118. package/agents/components/profile-header/profile-header.md +149 -0
  119. package/agents/components/profile-header/profile-header.spec.json +200 -0
  120. package/agents/components/progress/progress.family.json +27 -0
  121. package/agents/components/progress/progress.md +38 -0
  122. package/agents/components/progress/progress.spec.json +67 -0
  123. package/agents/components/side-sheet/side-sheet.family.json +30 -0
  124. package/agents/components/side-sheet/side-sheet.md +154 -0
  125. package/agents/components/side-sheet/side-sheet.spec.json +109 -0
  126. package/agents/components/skeleton/skeleton.family.json +28 -0
  127. package/agents/components/skeleton/skeleton.md +123 -0
  128. package/agents/components/skeleton/skeleton.spec.json +73 -0
  129. package/agents/components/status-tag/status-tag.family.json +26 -0
  130. package/agents/components/status-tag/status-tag.md +114 -0
  131. package/agents/components/status-tag/status-tag.spec.json +69 -0
  132. package/agents/components/suggestion-list/suggestion-list.family.json +46 -0
  133. package/agents/components/suggestion-list/suggestion-list.md +91 -0
  134. package/agents/components/suggestion-list/suggestion-list.spec.json +178 -0
  135. package/agents/components/switch/switch.family.json +27 -0
  136. package/agents/components/switch/switch.md +114 -0
  137. package/agents/components/switch/switch.spec.json +123 -0
  138. package/agents/components/tab-bar/tab-bar.family.json +27 -0
  139. package/agents/components/tab-bar/tab-bar.md +178 -0
  140. package/agents/components/tab-bar/tab-bar.spec.json +184 -0
  141. package/agents/components/tabs/rounded.md +150 -0
  142. package/agents/components/tabs/rounded.spec.json +140 -0
  143. package/agents/components/tabs/segmented.md +114 -0
  144. package/agents/components/tabs/segmented.spec.json +100 -0
  145. package/agents/components/tabs/tabs.family.json +59 -0
  146. package/agents/components/tabs/tabs.md +18 -0
  147. package/agents/components/tabs/underline.md +147 -0
  148. package/agents/components/tabs/underline.spec.json +139 -0
  149. package/agents/components/thumbnail/thumbnail.family.json +28 -0
  150. package/agents/components/thumbnail/thumbnail.md +152 -0
  151. package/agents/components/thumbnail/thumbnail.spec.json +172 -0
  152. package/agents/components/toast/toast.family.json +28 -0
  153. package/agents/components/toast/toast.md +133 -0
  154. package/agents/components/toast/toast.spec.json +89 -0
  155. package/agents/components/tooltip/tooltip.family.json +29 -0
  156. package/agents/components/tooltip/tooltip.md +139 -0
  157. package/agents/components/tooltip/tooltip.spec.json +110 -0
  158. package/agents/compose.md +240 -0
  159. package/agents/icons.json +831 -0
  160. package/agents/images.md +66 -0
  161. package/agents/manifest.json +87 -0
  162. package/agents/patterns/README.md +59 -0
  163. package/agents/patterns/actions.md +50 -0
  164. package/agents/patterns/browsing.md +52 -0
  165. package/agents/patterns/communications.md +56 -0
  166. package/agents/patterns/layout.md +72 -0
  167. package/agents/patterns/modals.md +50 -0
  168. package/agents/patterns/visual.md +55 -0
  169. package/agents/reconstruct.md +55 -0
  170. package/agents/scoped-adoption.md +111 -0
  171. package/agents/tokens.usage.json +1657 -0
  172. package/agents/usage.json +422 -0
  173. package/dist/icons/index.cjs +1332 -0
  174. package/dist/icons/index.cjs.map +1 -0
  175. package/dist/icons/index.d.cts +228 -0
  176. package/dist/icons/index.d.ts +228 -0
  177. package/dist/icons/index.js +1114 -0
  178. package/dist/icons/index.js.map +1 -0
  179. package/dist/index.cjs +5905 -0
  180. package/dist/index.cjs.map +1 -0
  181. package/dist/index.d.cts +896 -0
  182. package/dist/index.d.ts +896 -0
  183. package/dist/index.js +5847 -0
  184. package/dist/index.js.map +1 -0
  185. package/dist/styles.css +5765 -0
  186. package/eslint/README.md +79 -0
  187. package/eslint/index.js +78 -0
  188. package/eslint/rules.js +472 -0
  189. package/eslint/test.mjs +135 -0
  190. package/package.json +96 -0
  191. package/placeholder.png +0 -0
@@ -0,0 +1,123 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "Switch",
4
+ "family": "switch",
5
+ "description": "Pill-shaped track (52 × 32) with a circular thumb (28 × 28) that translates between the two ends. Inactive paints a `scrimSubtle` track (inverse-tone ~8% tint that stays distinct on any host surface) with an `outlineVariant` hairline stroke and a fixed-white thumb (`ref.palette.white.1000`, identical in light and dark); active paints the track in `primary` and the thumb in `onPrimary` so the active state reads chromatically. Whole control is the click target — the thumb is decorative. Instant commit: `onCheckedChange` fires the moment the thumb moves.",
6
+ "element": "button",
7
+ "props": {
8
+ "checked": {
9
+ "type": "boolean",
10
+ "optional": true,
11
+ "description": "Controlled value. When provided, the consumer owns the state; pair with `onCheckedChange`."
12
+ },
13
+ "defaultChecked": {
14
+ "type": "boolean",
15
+ "default": false,
16
+ "description": "Uncontrolled initial value. Ignored when `checked` is also passed."
17
+ },
18
+ "onCheckedChange": {
19
+ "type": "function",
20
+ "optional": true,
21
+ "description": "Called with `(nextChecked, event)` the moment the user flips the switch."
22
+ },
23
+ "disabled": {
24
+ "type": "boolean",
25
+ "default": false
26
+ },
27
+ "aria-label": {
28
+ "type": "string",
29
+ "optional": true,
30
+ "description": "Accessible label. Required when the Switch is not paired with a visible label via `aria-labelledby` or a wrapping `<label>`."
31
+ },
32
+ "aria-labelledby": {
33
+ "type": "string",
34
+ "optional": true,
35
+ "description": "ID of the element that names this Switch. Mutually exclusive with `aria-label`."
36
+ },
37
+ "forcedState": {
38
+ "type": "literal",
39
+ "values": ["hovered", "pressed", "focused"],
40
+ "optional": true,
41
+ "description": "Docs-only — pins the switch to a single visual state via `data-force-state`. Not for production use."
42
+ }
43
+ },
44
+ "slots": {
45
+ "track": {
46
+ "required": true,
47
+ "description": "Pill-shaped container. 52 × 32, fully rounded. Carries the click handler, the focus ring, and the state-overlay paint. `role='switch'`, `aria-checked` reflects active/inactive.",
48
+ "intrinsic": true
49
+ },
50
+ "thumb": {
51
+ "required": true,
52
+ "description": "Circular knob inside the track. 28 × 28, fully rounded. Translates between the leading and trailing ends on toggle. Decorative — never a separate hit target.",
53
+ "intrinsic": true
54
+ }
55
+ },
56
+ "sizing": {
57
+ "trackWidth": "52px",
58
+ "trackHeight": "ref.space.400",
59
+ "trackRadius": "sys.radius.full",
60
+ "thumbSize": "28px",
61
+ "thumbInset": "ref.space.25",
62
+ "thumbTravel": "ref.space.250",
63
+ "thumbRadius": "sys.radius.full",
64
+ "outlineWidth": "sys.borderWidth.hairline",
65
+ "outlineColor": "sys.color.outlineVariant",
66
+ "transitionDuration": "120ms",
67
+ "transitionTiming": "ease-out"
68
+ },
69
+ "appearances": {
70
+ "inactive": {
71
+ "trackBackground": "sys.color.scrimSubtle",
72
+ "trackOutline": "sys.color.outlineVariant",
73
+ "thumbBackground": "ref.palette.white.1000",
74
+ "note": "Resting state. The track fills with `scrimSubtle` — an inverse-tone ~8% tint (black in light, white in dark) — so it stays distinct on every host surface tier without binding to a fixed neutral step, reinforced by the hairline outline. The thumb is fixed white (`ref.palette.white.1000`) so it reads identically in light and dark rather than dimming to a surface tone."
75
+ },
76
+ "active": {
77
+ "trackBackground": "sys.color.primary",
78
+ "trackOutline": "transparent",
79
+ "thumbBackground": "sys.color.onPrimary",
80
+ "note": "Chromatic active state. The outline disappears so the filled track reads as one solid block. Thumb steps to `onPrimary` so the contrast against the filled track stays legible."
81
+ }
82
+ },
83
+ "states": {
84
+ "default": { "overlay": null },
85
+ "hovered": {
86
+ "overlay": { "color": "label", "opacity": "sys.state.hover" }
87
+ },
88
+ "pressed": {
89
+ "overlay": { "color": "label", "opacity": "sys.state.pressed" }
90
+ },
91
+ "disabled": {
92
+ "containerOpacity": "sys.state.disabled",
93
+ "pointerEvents": "none",
94
+ "note": "Both the active and inactive appearance fade to `sys.state.disabled`. The thumb stays at the position dictated by `checked`."
95
+ }
96
+ },
97
+ "focusIndicator": {
98
+ "description": "Keyboard-focus visual painted as a three-layer outward ring on the track's outer edge.",
99
+ "composition": "outward",
100
+ "compositionReason": "Switch sits inline next to siblings with whitespace around it (form rows, settings list trailing slots); an outward ring reads cleanly without colliding with neighbouring affordances.",
101
+ "ring": {
102
+ "outerWidth": "sys.borderWidth.thin",
103
+ "outerColor": "sys.color.focus",
104
+ "insetWidth": "sys.borderWidth.hairline",
105
+ "insetColor": "sys.color.focusInset",
106
+ "implementation": "outset box-shadow on the track's `::after` overlay."
107
+ },
108
+ "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
109
+ },
110
+ "behavior": {
111
+ "instantCommit": "`onCheckedChange` fires the moment the user releases the click / Space / Enter. No confirmation step.",
112
+ "clickTarget": "Whole track is clickable. The thumb is decorative — never a separate hit target.",
113
+ "keyboardActivation": "Space and Enter both toggle the value when the switch holds focus."
114
+ },
115
+ "forbidden": [
116
+ "switch used for an action that needs confirmation (a destructive commit, an action with an undo) — wrap that in a Button + Dialog instead",
117
+ "track outline removed in the inactive state — the `scrimSubtle` fill and the hairline outline work together to hold the affordance on any host surface; dropping the outline weakens the edge on tiers where the tint reads faint",
118
+ "inactive thumb dimmed to a surface tone — it stays fixed white (`ref.palette.white.1000`) so it reads the same in light and dark",
119
+ "thumb rendered as a separate focusable / clickable element — only the track carries focus and click",
120
+ "trailing label or icon painted inside the track — the active/inactive contract is the whole control; any label sits outside the Switch (in a List row label, a FormField label, etc.)",
121
+ "switch laid out next to its label without a 12px / `sys.layout.inline.md` gap — the gap stops the label from reading as if it were part of the control"
122
+ ]
123
+ }
@@ -0,0 +1,27 @@
1
+ {
2
+ "$schema": "../../family.schema.json",
3
+ "family": "tab-bar",
4
+ "name": "TabBar",
5
+ "description": "Bottom-anchored primary navigation \u2014 a horizontal row of icon + label items, one per top-level destination (Home / Company / Explore / Jobs / Notifications), pinned to the bottom of the app surface. Single-spec family. The row carries the system's standard 56 min-height with 8 block padding; items are distributed with optical alignment (start padding = inter-item gap = end padding) so the icons read as evenly placed across the row.",
6
+ "useCases": [
7
+ "bottom primary navigation",
8
+ "top-level destination switcher",
9
+ "app-shell chrome \u2014 bottom"
10
+ ],
11
+ "visualReuse": "open",
12
+ "layoutInset": "full-bleed",
13
+ "wrapperGuidance": "Owns its inline padding internally. Place as a direct child of the page-shell <main> (or any host that pays the gutter once). Do NOT wrap in a padding-inline div, className=\"px-*\", or style={{ padding }} \u2014 the page rail is paid once at the shell, never on the full-bleed child. Inside a bounded surface (Dialog / BottomSheet / SideSheet), apply the negative-margin opt-out \u2014 see AGENTS.md \u00a7 Composition rules.",
14
+ "spec": "tab-bar.md",
15
+ "usage": {
16
+ "note": "Export is `TabBar`; destinations thread through the `items` array (each `{ value, label, icon, activeIcon }`) — there is NO `<Tab>` child element.",
17
+ "example": "<TabBar aria-label=\"…\" value={value} onChange={setValue} items={[{ value, label, icon, activeIcon }]} />"
18
+ },
19
+ "subcomponents": [
20
+ {
21
+ "slug": "tab-bar",
22
+ "spec": "tab-bar.spec.json",
23
+ "md": "tab-bar.md",
24
+ "default": true
25
+ }
26
+ ]
27
+ }
@@ -0,0 +1,178 @@
1
+ # Tab bar
2
+
3
+ The bottom tab bar — a horizontal strip pinned to the bottom of the app exposing top-level destinations (Home / Company / Explore / Jobs / Notifications) in one tap. Each item stacks a 24px glyph above a 10/Regular label; active items show the filled companion glyph at `onSurface`, inactive render the outline at `onSurfaceVariant`. An item may opt into `appearance="primary"` to render a tile-shaped commit affordance — the conventional **Create** entry at the trailing end.
4
+
5
+ **Reach for this when** you need persistent top-level navigation on a mobile-shaped viewport. **Skip when** the destinations belong inside a single surface — use [Tabs](../tabs/underline.md) — or when the chrome must float — use [FAB](../button/fab.md).
6
+
7
+ **Layout inset.** full-bleed — pinned chrome at the bottom of the page shell, stretching edge-to-edge. The bar owns its internal padding and tile geometry; do **not** wrap in another padding div. Sits *outside* the `<main>` that pays `sys.layout.page.*` — TabBar is a sibling of NavigationBar in the page shell skeleton, not a `<main>` child. See [`AGENTS.md` § Composition rules](../../../AGENTS.md#composition-rules).
8
+
9
+ **Viewport safe area.** The bar's block-bottom padding pays `env(safe-area-inset-bottom)` so its `surface` background extends through the device home-indicator / gesture zone below the 56-tall content row; on non-mobile viewports `env()` resolves to 0 and the bar collapses to its original 56-tall footprint. The page shell MUST NOT add its own `padding-bottom: env(safe-area-inset-bottom)` when a TabBar is rendered — the bar already pays it, and stacking double-insets by the gesture-zone height.
10
+
11
+ ## Default
12
+
13
+ The canonical five-destination bar; first item is active.
14
+
15
+ ```preview
16
+ tab-bar/default
17
+ ---
18
+ import { TabBar } from '@teamblind-chorus/ui';
19
+ import { HomeIcon, HomeFillIcon, BuildingIcon, BuildingFillIcon, SearchIcon, BriefcaseIcon, BriefcaseFillIcon, BellIcon, BellFillIcon } from '@teamblind-chorus/ui/icons';
20
+
21
+ <TabBar
22
+ aria-label="Primary"
23
+ value="home"
24
+ items={[
25
+ { value: 'home', label: 'Home', icon: <HomeIcon />, activeIcon: <HomeFillIcon /> },
26
+ { value: 'company', label: 'Company', icon: <BuildingIcon />, activeIcon: <BuildingFillIcon /> },
27
+ { value: 'explore', label: 'Explore', icon: <SearchIcon />, activeIcon: <SearchFillIcon /> },
28
+ { value: 'jobs', label: 'Jobs', icon: <BriefcaseIcon />, activeIcon: <BriefcaseFillIcon /> },
29
+ { value: 'notifications', label: 'Notifications', icon: <BellIcon />, activeIcon: <BellFillIcon /> },
30
+ ]}
31
+ />
32
+ ```
33
+
34
+ ## Use cases
35
+
36
+ ### With a primary "Create" item
37
+
38
+ A bar including a primary-coloured Create affordance at the trailing end. The icon ([`PlusSquareFillIcon`](../../icons/svg/PlusSquareFill.svg)) is painted in `sys.color.brand` via `appearance="primary"` and still occupies one equal-width slot.
39
+
40
+ ```preview
41
+ tab-bar/with-primary
42
+ ---
43
+ import { TabBar } from '@teamblind-chorus/ui';
44
+ import { HomeIcon, HomeFillIcon, BuildingIcon, BuildingFillIcon, SearchIcon, SearchFillIcon, BriefcaseIcon, BriefcaseFillIcon, BellIcon, BellFillIcon, PlusSquareFillIcon } from '@teamblind-chorus/ui/icons';
45
+
46
+ <TabBar
47
+ aria-label="Primary"
48
+ value="home"
49
+ items={[
50
+ { value: 'home', label: 'Home', icon: <HomeIcon />, activeIcon: <HomeFillIcon /> },
51
+ { value: 'company', label: 'Company', icon: <BuildingIcon />, activeIcon: <BuildingFillIcon /> },
52
+ { value: 'explore', label: 'Explore', icon: <SearchIcon />, activeIcon: <SearchFillIcon /> },
53
+ { value: 'jobs', label: 'Jobs', icon: <BriefcaseIcon />, activeIcon: <BriefcaseFillIcon /> },
54
+ { value: 'notifications', label: 'Notifications', icon: <BellIcon />, activeIcon: <BellFillIcon /> },
55
+ { value: 'create', label: 'Create', icon: <PlusSquareFillIcon />, appearance: 'primary' },
56
+ ]}
57
+ />
58
+ ```
59
+
60
+ ### Three-destination bar
61
+
62
+ A bar with a smaller destination set — `space-evenly` distribution scales to any item count.
63
+
64
+ ```preview
65
+ tab-bar/three-destinations
66
+ ---
67
+ import { TabBar } from '@teamblind-chorus/ui';
68
+ import { HomeIcon, HomeFillIcon, SearchIcon, SearchFillIcon, ProfileIcon, ProfileFillIcon } from '@teamblind-chorus/ui/icons';
69
+
70
+ <TabBar
71
+ aria-label="Primary"
72
+ value="explore"
73
+ items={[
74
+ { value: 'home', label: 'Home', icon: <HomeIcon />, activeIcon: <HomeFillIcon /> },
75
+ { value: 'explore', label: 'Explore', icon: <SearchIcon />, activeIcon: <SearchFillIcon /> },
76
+ { value: 'profile', label: 'Profile', icon: <ProfileIcon />, activeIcon: <ProfileFillIcon /> },
77
+ ]}
78
+ />
79
+ ```
80
+
81
+ ### Truncation
82
+
83
+ Labels exceeding their slot truncate with a single-line ellipsis — a safety net for long i18n strings.
84
+
85
+ ```preview
86
+ tab-bar/truncation
87
+ ---
88
+ import { TabBar } from '@teamblind-chorus/ui';
89
+ import { HomeIcon, HomeFillIcon, BuildingIcon, BuildingFillIcon, SearchIcon, SearchFillIcon, ChatIcon, ChatFillIcon, BellIcon, BellFillIcon } from '@teamblind-chorus/ui/icons';
90
+
91
+ <TabBar
92
+ aria-label="Primary"
93
+ value="messages"
94
+ items={[
95
+ { value: 'home', label: 'Home', icon: <HomeIcon />, activeIcon: <HomeFillIcon /> },
96
+ { value: 'company', label: 'My organization', icon: <BuildingIcon />, activeIcon: <BuildingFillIcon /> },
97
+ { value: 'explore', label: 'Explore communities', icon: <SearchIcon />, activeIcon: <SearchFillIcon /> },
98
+ { value: 'messages', label: 'Direct messages', icon: <ChatIcon />, activeIcon: <ChatFillIcon /> },
99
+ { value: 'notifications', label: 'All notifications', icon: <BellIcon />, activeIcon: <BellFillIcon /> },
100
+ ]}
101
+ />
102
+ ```
103
+
104
+ ### Focus indicator
105
+
106
+ Static specimen — pins the keyboard-focus ring to a single destination. See top-level [Focus indicator](#focus-indicator).
107
+
108
+ ```preview
109
+ tab-bar/focused
110
+ ---
111
+ import { TabBar } from '@teamblind-chorus/ui';
112
+ import { HomeIcon, HomeFillIcon, BuildingIcon, BuildingFillIcon, SearchIcon, SearchFillIcon, BriefcaseIcon, BriefcaseFillIcon, BellIcon, BellFillIcon } from '@teamblind-chorus/ui/icons';
113
+
114
+ <TabBar
115
+ aria-label="Primary"
116
+ value="home"
117
+ items={[
118
+ { value: 'home', label: 'Home', icon: <HomeIcon />, activeIcon: <HomeFillIcon /> },
119
+ { value: 'company', label: 'Company', icon: <BuildingIcon />, activeIcon: <BuildingFillIcon /> },
120
+ { value: 'explore', label: 'Explore', icon: <SearchIcon />, activeIcon: <SearchFillIcon />, forcedState: 'focused' },
121
+ { value: 'jobs', label: 'Jobs', icon: <BriefcaseIcon />, activeIcon: <BriefcaseFillIcon /> },
122
+ { value: 'notifications', label: 'Notifications', icon: <BellIcon />, activeIcon: <BellFillIcon /> },
123
+ ]}
124
+ />
125
+ ```
126
+
127
+ ## Slots
128
+
129
+ - **container** — horizontal `<nav>` landmark.
130
+ - **item** — a destination. `<a href>` when an `href` is supplied, else `<button>`.
131
+ - **icon** — outline glyph at rest; filled `activeIcon` when active (falls back to `icon`).
132
+ - **label** — single line; truncates with ellipsis.
133
+
134
+ ## Anatomy
135
+
136
+ | Slot | Token bindings |
137
+ |-----------|------------------------------------------------------------------------------------------------------|
138
+ | container | `surface` fill; top hairline `outlineVariant` divider (inset shadow); `display: flex` + `justify-content: space-evenly` |
139
+ | item | Flex column, icon over label; `flex: 1 1 0` with `max-width: 80px`; tap target is the full slot. State layer is a `sys.radius.md` rounded rectangle filling the slot |
140
+ | icon | `sys.color.onSurfaceVariant` → `sys.color.onSurface` (active) |
141
+ | label | `sys.typo.caption` (10 / Semibold); `onSurfaceVariant` → `onSurface` (active) |
142
+ | primary | When `appearance="primary"`, only the icon paints in `sys.color.brand`; the label stays in the bar's default `sys.color.onSurfaceVariant` so every label across the row reads as one rung. Pair with a filled-tile glyph (e.g. [`PlusSquareFillIcon`](../../icons/svg/PlusSquareFill.svg)) |
143
+
144
+ ## Sizes
145
+
146
+ A single fixed rung.
147
+
148
+ | Property | Value | Token |
149
+ |----------------------|----------------|-------------------------------------|
150
+ | Min-height | 56px | raw — system bar floor |
151
+ | Item block padding | 4px | `sys.layout.container.2xs` |
152
+ | Item max-width | 80px | per-slot cap |
153
+ | Icon size | 24px | `sys.icon.lg` |
154
+ | Icon ↔ label gap | 2px | `sys.layout.stack.3xs` |
155
+
156
+ ## States
157
+
158
+ The container has no interactive state. Each item carries the lifecycle below — hover/pressed paint a `sys.radius.md` rounded rectangle filling the slot. Focus is separate — see [Focus indicator](#focus-indicator).
159
+
160
+ | State | Treatment |
161
+ |------------|----------------------------------------------------------------------------------------------------|
162
+ | `default` | Outline `icon`, label at `onSurfaceVariant`. Cursor `pointer`. |
163
+ | `hovered` | State layer fills the slot, painted with `onSurface` at `sys.state.hover` (8%). |
164
+ | `pressed` | State layer fills the slot, painted with `onSurface` at `sys.state.pressed` (12%). |
165
+ | `active` | Filled `activeIcon`, label at `onSurface`. Item carries `aria-current="page"`. No persistent state layer. |
166
+ | `disabled` | Item at `sys.state.disabled` (40%) opacity; pointer events suppressed. |
167
+
168
+ ## Focus indicator
169
+
170
+ **Composition: Inward** — adjacent items are flush under `flex: 1 1 0`, so outward would overlap the neighbour. Ring paints as inset shadows flush at the slot edge; state layer beneath is `onSurface`. Single-focus — at most one item holds the ring. Trigger: `:focus-visible`. See [Focus ring composition](../../DESIGN.md#focus-ring-composition).
171
+
172
+ ## Behavior
173
+
174
+ - **Tap an item → navigate.** Selected item swaps to filled `activeIcon`; icon + label flip to `onSurface`. No reflow on selection.
175
+ - **`appearance="primary"` items don't swap.** Primary invokes a screen-covering overlay rather than navigating; never receives `aria-current="page"`.
176
+ - **Single-select.** `value` names the active item; `onChange` fires after the item's `onClick`. Component owns no internal state.
177
+ - **Items are anchors or buttons.** `href` → `<a>`; no `href` → `<button>`. Routing belongs to the host framework.
178
+ - **Fixed row.** Never wraps, never scrolls. Author to a five- or six-item ceiling. Bar pays its own `env(safe-area-inset-bottom)` via block-bottom padding so the surface fill extends through the gesture zone; pinning (`position: fixed` / `sticky`) is still the host's job.
@@ -0,0 +1,184 @@
1
+ {
2
+ "$schema": "../../spec.schema.json",
3
+ "name": "TabBar",
4
+ "family": "tab-bar",
5
+ "exportAlias": "BottomNav",
6
+ "description": "Bottom tab bar — a horizontal strip pinned to the bottom of the app surface that exposes the top-level destinations (Home / Company / Explore / Jobs / Notifications) in one tap. Each item stacks a 24px glyph above a 10/Regular label; the active item is signalled by the filled companion glyph at `onSurface` while inactive items render the outline glyph at `onSurfaceVariant`. Distribution is a capped equal-grow split plus optical alignment: items grow from a zero basis at the same rate up to an 80-wide cap, then the container's `justify-content: space-evenly` paints start padding, inter-item gap, and end padding as the same visible whitespace. Labels that exceed their slot truncate with an ellipsis. Hover / pressed / focus paint a `sys.radius.md` rounded rectangle filling the slot. An item may opt into `appearance='primary'` to render a tile-shaped commit affordance (the conventional 'Create' / 'Compose' entry).",
7
+ "element": "nav",
8
+ "props": {
9
+ "items": {
10
+ "type": "node",
11
+ "required": true,
12
+ "description": "Array of destinations: { value, label, icon, activeIcon?, href?, onClick?, appearance? }."
13
+ },
14
+ "value": {
15
+ "type": "string",
16
+ "optional": true,
17
+ "description": "Currently active destination's `value`. The matching item renders with the active treatment (filled icon + onSurface)."
18
+ },
19
+ "onChange": {
20
+ "type": "function",
21
+ "optional": true,
22
+ "description": "(value) => void. Fires when an item is tapped, after the item's own onClick (suppressed if the click was default-prevented)."
23
+ },
24
+ "aria-label": {
25
+ "type": "string",
26
+ "optional": true,
27
+ "description": "Accessible name for the nav landmark. Defaults to 'Primary'."
28
+ }
29
+ },
30
+ "slots": {
31
+ "container": {
32
+ "required": true,
33
+ "intrinsic": true,
34
+ "description": "Horizontal nav landmark over a `surface` fill. 56 min-height; no padding (block and inline); items distributed via `justify-content: space-evenly`."
35
+ },
36
+ "item": {
37
+ "required": true,
38
+ "intrinsic": true,
39
+ "description": "One destination. Rendered as `<a href>` when an href is supplied, otherwise as a `<button>`. `flex: 1 1 0` with `max-width: 80px`; the whole slot is the click target."
40
+ },
41
+ "icon": {
42
+ "required": true,
43
+ "description": "24px glyph centred above the label. Outline form when inactive; falls back to `activeIcon` (the filled companion) when active.",
44
+ "accepts": [
45
+ "icon"
46
+ ]
47
+ },
48
+ "label": {
49
+ "required": true,
50
+ "description": "Destination name. caption / Semibold. Single line; truncates with an ellipsis when the slot is narrower than the label.",
51
+ "accepts": [
52
+ "text"
53
+ ]
54
+ }
55
+ },
56
+ "sizing": {
57
+ "containerFill": "sys.color.surface",
58
+ "containerMinHeight": "56px",
59
+ "containerPaddingBlock": "0",
60
+ "containerPaddingBlockEnd": "env(safe-area-inset-bottom, 0px)",
61
+ "containerPaddingInline": "0",
62
+ "viewportSafeArea": "Bar's block-bottom padding pays env(safe-area-inset-bottom) so its surface fill extends through the device home-indicator / gesture zone below the 56-tall content row. Bar is the owner of the viewport-bottom inset; page shell MUST NOT re-pay it via its own padding-bottom.",
63
+ "containerJustifyContent": "space-evenly",
64
+ "containerTopDivider": {
65
+ "width": "sys.borderWidth.hairline",
66
+ "color": "sys.color.outlineVariant",
67
+ "implementation": "inset box-shadow on the container (does not contribute to layout)"
68
+ },
69
+ "itemFlex": "1 1 0",
70
+ "itemMaxWidth": "ref.space.1000",
71
+ "itemPaddingBlock": "sys.layout.container.2xs",
72
+ "itemPaddingInline": "0",
73
+ "itemIconLabelGap": "sys.layout.stack.3xs",
74
+ "iconSize": "sys.icon.lg",
75
+ "labelTypo": "sys.typo.caption",
76
+ "labelOverflow": "ellipsis (overflow: hidden; text-overflow: ellipsis; white-space: nowrap)",
77
+ "stateLayerInset": "0",
78
+ "stateLayerRadius": "sys.radius.md"
79
+ },
80
+ "appearance": {
81
+ "itemInactiveColor": "sys.color.onSurfaceVariant",
82
+ "itemActiveColor": "sys.color.onSurface",
83
+ "itemPrimaryIconColor": "sys.color.brand",
84
+ "itemPrimaryLabelColor": "sys.color.onSurfaceVariant",
85
+ "note": "Primary (`appearance='primary'`) items paint only the icon in `sys.color.brand`; the label stays in the bar's default `onSurfaceVariant` so every label across the row reads as one rung."
86
+ },
87
+ "itemProps": {
88
+ "value": {
89
+ "type": "string",
90
+ "required": true
91
+ },
92
+ "label": {
93
+ "type": "string",
94
+ "required": true
95
+ },
96
+ "icon": {
97
+ "type": "node",
98
+ "required": true,
99
+ "description": "Outline glyph used in the inactive state."
100
+ },
101
+ "activeIcon": {
102
+ "type": "node",
103
+ "optional": true,
104
+ "description": "Filled companion glyph used when the item is active. Falls back to `icon` when absent."
105
+ },
106
+ "href": {
107
+ "type": "string",
108
+ "optional": true
109
+ },
110
+ "onClick": {
111
+ "type": "function",
112
+ "optional": true
113
+ },
114
+ "appearance": {
115
+ "type": "literal",
116
+ "values": [
117
+ "primary"
118
+ ],
119
+ "optional": true,
120
+ "description": "Paint the icon in `sys.color.brand` — the 'Create' / 'Compose' commit affordance. Pair with a filled-tile glyph (e.g. PlusSquareFillIcon) so the icon's own shape provides the tile and the brand fill paints it as the commit colour. The label stays in the bar's default `onSurfaceVariant` so the row's labels read as one rung — only the icon carries the brand emphasis. Primary items invoke a screen-covering overlay, NOT a sibling destination — they render in the fill glyph by default, never receive `aria-current='page'`, and never animate outline → fill (no resting/active states inside the bar)."
121
+ },
122
+ "forcedState": {
123
+ "type": "literal",
124
+ "values": [
125
+ "hovered",
126
+ "pressed",
127
+ "focused"
128
+ ],
129
+ "optional": true,
130
+ "description": "Docs-only — pins the item to a single visual state via `data-force-state` so the contract can be inspected in a static preview. Not for production use."
131
+ }
132
+ },
133
+ "states": {
134
+ "default": {
135
+ "description": "Inactive destination. Outline glyph + label, both at `onSurfaceVariant`."
136
+ },
137
+ "hovered": {
138
+ "description": "Pointer over the item. State layer fills the slot, painted with `onSurface` at `sys.state.hover` (8%).",
139
+ "stateLayerFill": "color-mix(sys.color.onSurface, sys.state.hover)"
140
+ },
141
+ "pressed": {
142
+ "description": "Active press on the item. State layer fills the slot, painted with `onSurface` at `sys.state.pressed` (12%).",
143
+ "stateLayerFill": "color-mix(sys.color.onSurface, sys.state.pressed)"
144
+ },
145
+ "active": {
146
+ "description": "Currently selected destination. Filled glyph (`activeIcon`) + label, both at `onSurface`. Carries `aria-current='page'`. No persistent state layer — only hover / pressed / focus paint."
147
+ },
148
+ "disabled": {
149
+ "containerOpacity": "sys.state.disabled",
150
+ "pointerEvents": "none"
151
+ }
152
+ },
153
+ "focusIndicator": {
154
+ "description": "Keyboard-focus visual — an accessibility indicator, not a lifecycle state. Composes over whichever lifecycle state the item is in. The state layer beneath is filled with `onSurface` at `sys.state.focus` (12%). Single-focus: at most one item holds the ring at a time, arriving via `:focus-visible` (keyboard `Tab` / programmatic focus).",
155
+ "composition": "inward",
156
+ "compositionReason": "Adjacent items are flush under `flex: 1 1 0`; an outward ring would overlap the neighbouring slot. The ring is constrained strictly inside the slot's bounding box and never exceeds it.",
157
+ "stateLayerFill": "color-mix(sys.color.onSurface, sys.state.focus)",
158
+ "ring": {
159
+ "outerWidth": "sys.borderWidth.thin",
160
+ "outerColor": "sys.color.focus",
161
+ "outerLayerPosition": "depth 0..2px from the slot edge (the outer stroke)",
162
+ "insetWidth": "sys.borderWidth.hairline",
163
+ "insetColor": "sys.color.focusInset",
164
+ "insetLayerPosition": "depth 2..3px from the slot edge (the counter-ring just inside the outer stroke)",
165
+ "composition": "inward — inset box-shadow on the ::after at `inset: 0`. Adjacent items are flush under `flex: 1 1 0`, so an outward ring would overlap a neighbour; the ring is forced inside the slot bounds without exceeding them."
166
+ },
167
+ "trigger": ":focus-visible (keyboard / programmatic focus, never plain mouse click)"
168
+ },
169
+ "behavior": {
170
+ "selectionNavigates": "Tapping a destination item routes the app to that destination's page; `value` updates to mark the new active item. The selected item swaps its glyph from the outline form to the filled companion (`activeIcon`) to signal 'you are here' — the icon fill, paired with the `onSurface` colour swap on icon + label, is the bar's primary current-location indicator.",
171
+ "primaryItemIsAnOverlayAction": "Items with `appearance='primary'` (the conventional Create / Compose affordance) invoke a screen-covering overlay rather than navigating to a sibling destination, so they have no resting/active distinction inside the bar. They render in the fill-type glyph by default (painted in `sys.color.brand`) and never receive `aria-current='page'` or the outline→fill transition. Activation dismisses or replaces the current view via the host framework's overlay/modal route, not via tab-bar selection.",
172
+ "distribution": "Capped equal-grow plus optical alignment. Every item uses `flex: 1 1 0` with `max-width: 80px`, so items grow at the same rate up to an 80-wide cap. Once every item has hit the cap, the row's leftover inline space is handed to the container's `justify-content: space-evenly`, which paints start padding, inter-item gap, and end padding as the same visible whitespace. `min-width: 0` on the item lets the label's ellipsis truncation actually take effect inside the slot.",
173
+ "labelTruncation": "Labels that exceed their slot truncate with a single-line ellipsis (`overflow: hidden; text-overflow: ellipsis; white-space: nowrap`). The row never wraps or scrolls.",
174
+ "fixedRow": "Author destinations to a five- or six-item ceiling so the per-slot width stays wide enough for the destination's short name to read without ellipsis at the system's narrowest mobile breakpoint.",
175
+ "selectionModel": "Single-select. `value` is the active item's `value`; `onChange` fires after the item's own `onClick`. The component owns no internal state — host page is the source of truth.",
176
+ "noNativeHomeBar": "The component renders just the bar itself; it does not draw or reserve space for an OS-level home indicator. The host shell handles safe-area inset and pinning."
177
+ },
178
+ "forbidden": [
179
+ "Create item not styled with sys.color.brand fill — the Create entry is the single brand-marked instance per screen",
180
+ "more than one Create / brand-marked item — brand instance cap on this row is exactly 1",
181
+ "tab-bar height different from the spec'd geometry — the row geometry is fixed across all routes",
182
+ "active state for non-Create items painted with brand color — non-Create items use sys.color.onSurface for both rest and active (active darkens via state overlay, not by color swap)"
183
+ ]
184
+ }