@nocoo/basalt 3.2.1 → 3.2.4

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 (192) hide show
  1. package/README.md +25 -19
  2. package/ai/COMPATIBILITY.md +41 -36
  3. package/ai/DESIGN.md +53 -47
  4. package/ai/INTEGRATION.md +814 -820
  5. package/ai/RECIPES.md +334 -135
  6. package/ai/USAGE.md +2 -2
  7. package/ai/registry.json +384 -27
  8. package/ai/sources.json +2 -2
  9. package/dist/assets/brand-mark.js +1 -2
  10. package/dist/charts/area.js +1 -2
  11. package/dist/charts/bar.js +1 -2
  12. package/dist/charts/bullet.js +1 -2
  13. package/dist/charts/chart-colors.js +1 -2
  14. package/dist/charts/charts.js +1 -2
  15. package/dist/charts/config.js +1 -2
  16. package/dist/charts/custom-chart.js +1 -2
  17. package/dist/charts/date-navigation.js +1 -2
  18. package/dist/charts/donut.js +1 -2
  19. package/dist/charts/frame.js +1 -2
  20. package/dist/charts/frame.js.map +1 -1
  21. package/dist/charts/funnel.js +1 -2
  22. package/dist/charts/gauge.js +1 -2
  23. package/dist/charts/grouped-bar.js +1 -2
  24. package/dist/charts/heatmap-calendar.js +3 -7
  25. package/dist/charts/heatmap-calendar.js.map +1 -1
  26. package/dist/charts/heatmap-matrix.js +1 -2
  27. package/dist/charts/item-list.js +1 -2
  28. package/dist/charts/legend.js +1 -2
  29. package/dist/charts/line.js +1 -2
  30. package/dist/charts/palette.js +1 -2
  31. package/dist/charts/radar.js +1 -2
  32. package/dist/charts/sample.js +1 -2
  33. package/dist/charts/sankey.js +1 -2
  34. package/dist/charts/series.js +1 -2
  35. package/dist/charts/slot-bar.js +1 -2
  36. package/dist/charts/sparkline.js +1 -2
  37. package/dist/charts/stacked-bar.js +1 -2
  38. package/dist/charts/stat-card.js +1 -2
  39. package/dist/charts/timeline.js +1 -2
  40. package/dist/charts/timeseries.js +1 -2
  41. package/dist/charts/tooltip.js +31 -27
  42. package/dist/charts/tooltip.js.map +1 -1
  43. package/dist/components/accordion.js +1 -2
  44. package/dist/components/alert-dialog.js +1 -2
  45. package/dist/components/app-header.js +1 -2
  46. package/dist/components/app-shell.js +1 -2
  47. package/dist/components/approval-card.js +1 -2
  48. package/dist/components/autocomplete.js +1 -2
  49. package/dist/components/avatar.js +1 -2
  50. package/dist/components/badge.js +1 -2
  51. package/dist/components/banner.js +1 -2
  52. package/dist/components/basalt-mark.js +1 -2
  53. package/dist/components/battery-meter.js +1 -2
  54. package/dist/components/breadcrumbs.js +1 -2
  55. package/dist/components/button.d.ts +1 -1
  56. package/dist/components/button.js +3 -3
  57. package/dist/components/button.js.map +1 -1
  58. package/dist/components/chat-bubble.js +1 -2
  59. package/dist/components/chat-composer.js +1 -2
  60. package/dist/components/chat-header.js +1 -2
  61. package/dist/components/chat-inbox.js +1 -2
  62. package/dist/components/chat-markdown.js +1 -2
  63. package/dist/components/chat-message.js +1 -2
  64. package/dist/components/checkbox.js +1 -2
  65. package/dist/components/clipboard-text.js +1 -2
  66. package/dist/components/code.js +1 -2
  67. package/dist/components/collapsible.js +1 -2
  68. package/dist/components/collapsible.js.map +1 -1
  69. package/dist/components/combobox.js +1 -2
  70. package/dist/components/command-palette.js +1 -2
  71. package/dist/components/confirm-dialog.js +3 -12
  72. package/dist/components/confirm-dialog.js.map +1 -1
  73. package/dist/components/context-cards.js +1 -2
  74. package/dist/components/context-menu.js +1 -2
  75. package/dist/components/data-table.js +2 -7
  76. package/dist/components/data-table.js.map +1 -1
  77. package/dist/components/date-picker.js +7 -18
  78. package/dist/components/date-picker.js.map +1 -1
  79. package/dist/components/delete-resource.js +1 -2
  80. package/dist/components/description-list.js +1 -2
  81. package/dist/components/dialog.js +1 -2
  82. package/dist/components/diff-table.js +1 -2
  83. package/dist/components/dock.js +1 -2
  84. package/dist/components/dock.js.map +1 -1
  85. package/dist/components/dropdown-menu.js +1 -2
  86. package/dist/components/editable-nav-item.js +1 -2
  87. package/dist/components/empty.js +1 -2
  88. package/dist/components/fab.js +1 -2
  89. package/dist/components/field.js +1 -2
  90. package/dist/components/file-dropzone.js +1 -2
  91. package/dist/components/filter-bar.js +1 -2
  92. package/dist/components/flow.js +1 -2
  93. package/dist/components/grid.js +1 -2
  94. package/dist/components/hover-card.js +1 -2
  95. package/dist/components/icon-picker.js +1 -2
  96. package/dist/components/inline-editable.js +1 -2
  97. package/dist/components/input-area.js +1 -2
  98. package/dist/components/input-group.js +2 -3
  99. package/dist/components/input-group.js.map +1 -1
  100. package/dist/components/input.js +1 -2
  101. package/dist/components/label.js +1 -2
  102. package/dist/components/layer-card.js +1 -2
  103. package/dist/components/link.js +1 -2
  104. package/dist/components/loader.js +1 -2
  105. package/dist/components/loading-screen.js +1 -2
  106. package/dist/components/menu-bar.js +1 -2
  107. package/dist/components/meter.js +1 -2
  108. package/dist/components/multi-select.js +1 -2
  109. package/dist/components/navigation-menu.js +1 -2
  110. package/dist/components/overlay.js +1 -2
  111. package/dist/components/page-header.js +1 -2
  112. package/dist/components/pagination.js +1 -2
  113. package/dist/components/popover.js +1 -2
  114. package/dist/components/prompt-bar.js +1 -2
  115. package/dist/components/radio.js +2 -7
  116. package/dist/components/radio.js.map +1 -1
  117. package/dist/components/recommendation-card.js +1 -2
  118. package/dist/components/resource-list.js +1 -2
  119. package/dist/components/responsive-master-detail.js +1 -2
  120. package/dist/components/scroll-area.js +1 -2
  121. package/dist/components/section-rule.js +1 -2
  122. package/dist/components/segment-control.js +1 -2
  123. package/dist/components/select.js +1 -2
  124. package/dist/components/sensitive-input.js +1 -2
  125. package/dist/components/separator.js +1 -2
  126. package/dist/components/sheet.js +1 -2
  127. package/dist/components/sidebar.d.ts +53 -19
  128. package/dist/components/sidebar.js +201 -100
  129. package/dist/components/sidebar.js.map +1 -1
  130. package/dist/components/skeleton-line.js +1 -2
  131. package/dist/components/slider.js +1 -2
  132. package/dist/components/stat-strip.js +1 -2
  133. package/dist/components/switch.js +1 -2
  134. package/dist/components/table-of-contents.js +1 -2
  135. package/dist/components/table-pager.js +1 -2
  136. package/dist/components/table.js +1 -2
  137. package/dist/components/tabs.js +1 -2
  138. package/dist/components/tag-badge.js +1 -2
  139. package/dist/components/tag-color-picker.js +1 -2
  140. package/dist/components/text.js +1 -2
  141. package/dist/components/theme-toggle.js +1 -2
  142. package/dist/components/thinking.js +1 -2
  143. package/dist/components/toast.js +1 -2
  144. package/dist/components/toggle-group.js +1 -2
  145. package/dist/components/toggle.js +1 -2
  146. package/dist/components/tool-chips.js +1 -2
  147. package/dist/components/toolbar.js +1 -2
  148. package/dist/components/tooltip.js +1 -2
  149. package/dist/components/typeahead-field.js +6 -21
  150. package/dist/components/typeahead-field.js.map +1 -1
  151. package/dist/components/upload-queue.js +1 -2
  152. package/dist/index.d.ts +1 -1
  153. package/dist/index.js +11 -11
  154. package/dist/models/agent-feedback.js +1 -2
  155. package/dist/models/avatar.js +1 -2
  156. package/dist/models/code.js +1 -2
  157. package/dist/models/context-cards.js +1 -2
  158. package/dist/models/diff-table.js +1 -2
  159. package/dist/models/recommendation.js +1 -2
  160. package/dist/providers/accent.js +2 -7
  161. package/dist/providers/accent.js.map +1 -1
  162. package/dist/providers/link.js +1 -2
  163. package/dist/providers/theme.js +2 -7
  164. package/dist/providers/theme.js.map +1 -1
  165. package/dist/styles/standalone.css +55 -11
  166. package/dist/styles/tailwind.css +2 -0
  167. package/dist/styles/tokens.css +51 -6
  168. package/dist/utils/cn.js +1 -2
  169. package/dist/utils/control-surface.js +1 -2
  170. package/dist/utils/navigation.d.ts +1 -1
  171. package/dist/utils/navigation.js +2 -3
  172. package/dist/utils/navigation.js.map +1 -1
  173. package/dist/utils/preference-store.js +1 -2
  174. package/dist/utils/selection-indicator.js +1 -2
  175. package/dist/utils/use-checkable-group.js +1 -2
  176. package/dist/utils/use-hover-highlight.js +2 -3
  177. package/dist/utils/use-hover-highlight.js.map +1 -1
  178. package/dist/viewmodels/use-approval-card.js +11 -13
  179. package/dist/viewmodels/use-approval-card.js.map +1 -1
  180. package/dist/viewmodels/use-chat-composer.js +1 -2
  181. package/dist/viewmodels/use-chat-message.js +1 -2
  182. package/dist/viewmodels/use-code-block.js +1 -2
  183. package/dist/viewmodels/use-code-block.js.map +1 -1
  184. package/dist/viewmodels/use-context-cards.js +1 -2
  185. package/dist/viewmodels/use-diff-table.js +1 -2
  186. package/dist/viewmodels/use-diff-table.js.map +1 -1
  187. package/dist/viewmodels/use-loader.js +1 -2
  188. package/dist/viewmodels/use-recommendation-card.js +1 -2
  189. package/dist/viewmodels/use-recommendation-card.js.map +1 -1
  190. package/dist/viewmodels/use-thinking.js +1 -2
  191. package/dist/viewmodels/use-tool-chips.js +1 -2
  192. package/package.json +1 -1
package/ai/INTEGRATION.md CHANGED
@@ -23,16 +23,16 @@ Ship in this order. Each step has a section below. Do not skip ahead to cards.
23
23
 
24
24
  ### Who owns what
25
25
 
26
- | Layer | Component | Owns | Does not own |
27
- |---|---|---|---|
28
- | Landing | public marketing route | full-width sections and document scrolling | dashboard width or scroll rules |
29
- | Login | app page, not `AppShell` | identity badge | rail, header, island |
30
- | Rail | `Sidebar` | product title, version pill, nav, user | page body |
31
- | Framework bar | `AppHeader` | ancestor breadcrumbs, current page name, top-right actions (`ThemeToggle`, …) | create, filters, cards |
32
- | Island | `ContentIsland` | L1 surface and scroll | page chrome |
33
- | Page heading | `PageHeader` | content title, subtitle, create last, filters | `AppHeader` crumbs |
34
- | Region | `SectionRule` | dashed title rule, optional hint, optional region actions | `LayerCard.Header` |
35
- | Card | `LayerCard` | nested surface | page title |
26
+ | Layer | Component | Owns | Does not own |
27
+ | ------------- | ------------------------ | ----------------------------------------------------------------------------- | ------------------------------- |
28
+ | Landing | public marketing route | full-width sections and document scrolling | dashboard width or scroll rules |
29
+ | Login | app page, not `AppShell` | identity badge | rail, header, island |
30
+ | Rail | `Sidebar` | product title, version pill, nav, user | page body |
31
+ | Framework bar | `AppHeader` | ancestor breadcrumbs, current page name, top-right actions (`ThemeToggle`, …) | create, filters, cards |
32
+ | Island | `ContentIsland` | L1 surface and scroll | page chrome |
33
+ | Page heading | `PageHeader` | content title, subtitle, create last, filters | `AppHeader` crumbs |
34
+ | Region | `SectionRule` | dashed title rule, optional hint, optional region actions | `LayerCard.Header` |
35
+ | Card | `LayerCard` | nested surface | page title |
36
36
 
37
37
  ### Tree
38
38
 
@@ -61,11 +61,11 @@ Resolve the mobile breakpoint on the first client render. Do not briefly reserve
61
61
 
62
62
  Prerender non-root routes as `route.html` (for example, `ui/button.html`). Cloudflare's `auto-trailing-slash` handling and Vite preview serve these at the extensionless canonical URL `/ui/button`; directory index files would imply a different trailing-slash URL.
63
63
 
64
- Landing previews are real captures at a 1600 × 1000 viewport and 2× device scale, with 1200px variants selected through `srcset`. The large corner-tower artwork derives from the original `logo.png`; its subtle perspective motion pauses outside the viewport, in background tabs, and for reduced motion. Device tilt is off by default on mobile. Only the explicit Enable tilt button requests motion permission and starts listening; ordinary touches never request access. Horizontal dragging remains available while preserving vertical page scrolling. Calibrate tilt to the current grip and screen orientation, and keep its movement bounded. The brand card uses one rounded clip around a single painted backdrop; avoid separate inner radii, outlines, or a divider touching its bottom edge.
64
+ Landing previews are real captures at a 1600 × 1000 viewport and 2× device scale, with 1200px variants selected through `srcset`; the hero uses a separate 390px mobile capture on narrow screens. The large corner-tower artwork derives from the original `logo.png`; its subtle perspective motion pauses outside the viewport, in background tabs, and for reduced motion. Device tilt is off by default on mobile. Only the explicit Enable tilt button requests motion permission and starts listening; ordinary touches never request access. Horizontal dragging remains available while preserving vertical page scrolling. Calibrate tilt to the current grip and screen orientation, and keep its movement bounded. The brand card uses one rounded clip around a single painted backdrop; avoid separate inner radii, outlines, or a divider touching its bottom edge.
65
65
 
66
- The hero and elevated Forbidden City backgrounds each use a matched daylight/twilight pair. Generate each pair together on one 2880 × 2880 canvas, then crop at the exact horizontal midpoint into two 2880 × 1440 images. Preserve the same camera and scenery between themes. Native masters, references, prompts, and sanitized generation records live under `assets/landing/generated/`; `assets/landing/backgrounds.json` records the crop coordinates and export hashes. Keep the ambient light in a flat stacking context behind the artwork so perspective motion cannot bring it in front of the mark.
66
+ The hero and elevated Forbidden City backgrounds each use a matched daylight/twilight pair. Generate each pair together on one 2880 × 2880 canvas, then crop at the exact horizontal midpoint into two 2880 × 1440 images. Preserve the same camera and scenery between themes. Native masters, references, prompts, and sanitized generation records live under `assets/landing/generated/`; `assets/landing/backgrounds.json` records the crop coordinates and export hashes.
67
67
 
68
- The floating glass header uses an icon-only GitHub link and one light/dark theme button. Resolve the system preference until the visitor chooses a theme, then persist that explicit choice through the shared provider. The landing button switches from the currently resolved mode directly to its opposite.
68
+ The matte floating header uses an icon-only GitHub link and one light/dark theme button. Resolve the system preference until the visitor chooses a theme, then persist that explicit choice through the shared provider. The landing button switches from the currently resolved mode directly to its opposite.
69
69
 
70
70
  When `AppHeader` already has breadcrumbs, **omit** `PageHeader.breadcrumbs`.
71
71
 
@@ -95,7 +95,7 @@ import react from "@vitejs/plugin-react";
95
95
  import { defineConfig } from "vite";
96
96
 
97
97
  export default defineConfig({
98
- plugins: [tailwindcss(), react()],
98
+ plugins: [tailwindcss(), react()],
99
99
  });
100
100
  ```
101
101
 
@@ -107,14 +107,14 @@ In the app stylesheet (e.g. `src/index.css`), the exact 3-line import order is r
107
107
  @import "tailwindcss";
108
108
 
109
109
  @layer base {
110
- html,
111
- body,
112
- #root {
113
- height: 100%;
114
- }
115
- body {
116
- @apply bg-basalt-background text-basalt-foreground antialiased;
117
- }
110
+ html,
111
+ body,
112
+ #root {
113
+ height: 100%;
114
+ }
115
+ body {
116
+ @apply bg-basalt-background text-basalt-foreground antialiased;
117
+ }
118
118
  }
119
119
  ```
120
120
 
@@ -137,9 +137,9 @@ Apply theme on the document **before** React paints:
137
137
  ```ts
138
138
  let stored: string | null = null;
139
139
  try {
140
- stored = window.localStorage.getItem("theme");
140
+ stored = window.localStorage.getItem("theme");
141
141
  } catch {
142
- // Continue with the system theme when browser storage is denied.
142
+ // Continue with the system theme when browser storage is denied.
143
143
  }
144
144
  const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
145
145
  const isDark = stored === "dark" || (stored !== "light" && prefersDark);
@@ -154,19 +154,19 @@ document.documentElement.dataset.mode = isDark ? "dark" : "light";
154
154
 
155
155
  Root barrel is small leaves and providers. Shell chrome is granular.
156
156
 
157
- | From | Import |
158
- |---|---|
159
- | `@nocoo/basalt` | `Button`, `Input`, `LayerCard`, `DescriptionList`, `Sidebar`, `SidebarHeader`, `SidebarNav`, `SidebarFooter`, `SidebarItem`, `SidebarIconItem`, `SidebarPartition`, `SidebarGroup`, `SidebarSearch`, `SidebarUser`, `ContentIsland`, `Sheet`, `SheetContent`, `SheetTitle`, `Avatar`, `AvatarFallback`, `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider`, `ThemeProvider`, `ThemeToggle`, `LinkProvider`, `Toaster`, `CommandPalette`, … |
160
- | `@nocoo/basalt/components/app-shell` | `AppShell`, `AppMain`, `AppSkipLink` |
161
- | `@nocoo/basalt/components/app-header` | `AppHeader` |
162
- | `@nocoo/basalt/components/page-header` | `PageHeader` |
163
- | `@nocoo/basalt/components/section-rule` | `SectionRule` |
164
- | `@nocoo/basalt/components/loading-screen` | `LoadingScreen` |
165
- | `@nocoo/basalt/components/basalt-mark` | `BasaltMark` |
166
- | `@nocoo/basalt/providers/theme` | `useTheme` (if not taking `ThemeProvider` from the root) |
167
- | `@nocoo/basalt/charts/*` | charts |
168
- | `@nocoo/basalt/components/date-picker` | DatePicker |
169
- | `@nocoo/basalt/components/data-table` | DataTable |
157
+ | From | Import |
158
+ | ----------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
159
+ | `@nocoo/basalt` | `Button`, `Input`, `LayerCard`, `DescriptionList`, `Sidebar`, `SidebarProvider`, `SidebarHeader`, `SidebarBrand`, `SidebarToggle`, `SidebarNav`, `SidebarFooter`, `SidebarItem`, `SidebarIconItem`, `SidebarPartition`, `SidebarGroup`, `SidebarSearch`, `SidebarUser`, `ContentIsland`, `Sheet`, `SheetContent`, `SheetTitle`, `Avatar`, `AvatarFallback`, `Tooltip`, `TooltipTrigger`, `TooltipContent`, `TooltipProvider`, `ThemeProvider`, `ThemeToggle`, `LinkProvider`, `Toaster`, `CommandPalette`, … |
160
+ | `@nocoo/basalt/components/app-shell` | `AppShell`, `AppMain`, `AppSkipLink` |
161
+ | `@nocoo/basalt/components/app-header` | `AppHeader` |
162
+ | `@nocoo/basalt/components/page-header` | `PageHeader` |
163
+ | `@nocoo/basalt/components/section-rule` | `SectionRule` |
164
+ | `@nocoo/basalt/components/loading-screen` | `LoadingScreen` |
165
+ | `@nocoo/basalt/components/basalt-mark` | `BasaltMark` |
166
+ | `@nocoo/basalt/providers/theme` | `useTheme` (if not taking `ThemeProvider` from the root) |
167
+ | `@nocoo/basalt/charts/*` | charts |
168
+ | `@nocoo/basalt/components/date-picker` | DatePicker |
169
+ | `@nocoo/basalt/components/data-table` | DataTable |
170
170
 
171
171
  Off the root barrel: `AppShell`, `AppMain`, `AppSkipLink`, `AppHeader`, `PageHeader`, `SectionRule`, `LoadingScreen`, `BasaltMark`, charts, DatePicker, DataTable. Import those from the granular paths above. `LayerCard` is on the root barrel **and** `@nocoo/basalt/components/layer-card`.
172
172
 
@@ -178,20 +178,18 @@ One tree for the whole app. Login and the shell both sit under it.
178
178
 
179
179
  ```tsx excerpt:app-provider-tree
180
180
  <ThemeProvider>
181
- <LinkProvider render={AppLink}>
182
- <TooltipProvider>
183
- <BrowserRouter>
184
- <Toaster />
185
- <Routes>
186
- <Route path="/login" element={<LoginPage />} />
187
- <Route element={<AppFrame />}>
188
- {/* authenticated pages */}
189
- </Route>
190
- </Routes>
191
- </BrowserRouter>
192
- </TooltipProvider>
193
- </LinkProvider>
194
- </ThemeProvider>
181
+ <LinkProvider render={AppLink}>
182
+ <TooltipProvider>
183
+ <BrowserRouter>
184
+ <Toaster />
185
+ <Routes>
186
+ <Route path="/login" element={<LoginPage />} />
187
+ <Route element={<AppFrame />}>{/* authenticated pages */}</Route>
188
+ </Routes>
189
+ </BrowserRouter>
190
+ </TooltipProvider>
191
+ </LinkProvider>
192
+ </ThemeProvider>;
195
193
  ```
196
194
 
197
195
  `LinkProvider` `render` must map `href` to the app router (React Router `Link`, etc.). External `http(s):` / `mailto:` / `tel:` stay as `<a>`.
@@ -202,16 +200,15 @@ One tree for the whole app. Login and the shell both sit under it.
202
200
 
203
201
  `ThemeProvider` manages color scheme mode (`"light" | "dark" | "system"`). It synchronizes active classes (`light`/`dark`) and attributes (`data-mode="dark"|"light"`) onto `document.documentElement`, supports cross-tab and same-page synchronization, and provides graceful fallback when storage is restricted.
204
202
 
205
- | Prop | Type | Default | Description |
206
- |---|---|---|---|
207
- | `children` | `ReactNode` | *(required)* | Application components wrapped by the theme context. |
208
- | `storageKey` | `string` | `"theme"` | Key used for `localStorage` persistence. |
209
- | `defaultTheme` | `BasaltTheme` (`"light"` \| `"dark"` \| `"system"`) | `"system"` | Initial fallback theme during SSR or when no stored preference exists. |
210
- | `persist` | `boolean` | `true` | When `false`, completely isolates the provider from `localStorage` (never reads or writes storage, does not broadcast or listen to external storage events). |
211
- | `theme` | `BasaltTheme` | `undefined` | Controlled theme value. When supplied, internal state is driven by this prop. |
212
- | `onThemeChange` | `(theme: BasaltTheme) => void` | `undefined` | Callback fired when a theme change is requested. In controlled mode, callers update `theme`. |
213
- | `applyToDocument` | `boolean` | `true` | When `false`, disables mutating classes or dataset on `document.documentElement`, allowing a host theme system to manage root DOM state. |
214
-
203
+ | Prop | Type | Default | Description |
204
+ | ----------------- | --------------------------------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
205
+ | `children` | `ReactNode` | _(required)_ | Application components wrapped by the theme context. |
206
+ | `storageKey` | `string` | `"theme"` | Key used for `localStorage` persistence. |
207
+ | `defaultTheme` | `BasaltTheme` (`"light"` \| `"dark"` \| `"system"`) | `"system"` | Initial fallback theme during SSR or when no stored preference exists. |
208
+ | `persist` | `boolean` | `true` | When `false`, completely isolates the provider from `localStorage` (never reads or writes storage, does not broadcast or listen to external storage events). |
209
+ | `theme` | `BasaltTheme` | `undefined` | Controlled theme value. When supplied, internal state is driven by this prop. |
210
+ | `onThemeChange` | `(theme: BasaltTheme) => void` | `undefined` | Callback fired when a theme change is requested. In controlled mode, callers update `theme`. |
211
+ | `applyToDocument` | `boolean` | `true` | When `false`, disables mutating classes or dataset on `document.documentElement`, allowing a host theme system to manage root DOM state. |
215
212
  - **Hook `useTheme()`**: Returns `{ theme: BasaltTheme, setTheme: (theme: BasaltTheme) => void }`. Throws an error when invoked outside a `ThemeProvider`.
216
213
  - **Storage failure resilience**: If reading or writing `localStorage` throws (e.g. quota exceeded or sandboxed iframe security error), `ThemeProvider` catches the error, retains the explicit selection in memory, and suppresses stale bare storage events from rolling back state.
217
214
  - **SSR & Hydration**: During SSR, `ThemeProvider` returns controlled `theme` (if provided) or `defaultTheme`. On the client, it hydrates from `localStorage` if `persist=true`. When custom `storageKey` or `defaultTheme` is configured, pre-hydration inline scripts must match the same key and default.
@@ -225,17 +222,16 @@ One tree for the whole app. Login and the shell both sit under it.
225
222
 
226
223
  #### AccentProvider Configuration
227
224
 
228
- | Prop | Type | Default | Description |
229
- |---|---|---|---|
230
- | `children` | `ReactNode` | *(required)* | Application components wrapped by the accent context. |
231
- | `storageKey` | `string` | `"basalt-accent"` | Key used for `localStorage` persistence. |
232
- | `defaultAccent` | `string` | `"primary"` | Initial accent swatch ID during SSR or when no stored preference exists. |
233
- | `persist` | `boolean` | `true` | When `false`, isolates the provider from `localStorage` (no storage read/write, no cross-tab sync). |
234
- | `accent` | `string` | `undefined` | Controlled accent value. When supplied, internal state is driven by this prop. |
235
- | `onAccentChange` | `(accent: string) => void` | `undefined` | Callback fired when an accent change is requested. |
236
- | `applyToDocument` | `boolean` | `true` | When `false`, disables writing CSS custom properties and `data-accent` to `document.documentElement`. |
237
- | `paletteOverrides` | `Readonly<Record<string, { light: string; dark: string }>>` | `undefined` | Optional HSL-channel pairs for the twelve preset IDs. Invalid pairs and unknown IDs are ignored. The application owns persistence. |
238
-
225
+ | Prop | Type | Default | Description |
226
+ | ------------------ | ----------------------------------------------------------- | ----------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
227
+ | `children` | `ReactNode` | _(required)_ | Application components wrapped by the accent context. |
228
+ | `storageKey` | `string` | `"basalt-accent"` | Key used for `localStorage` persistence. |
229
+ | `defaultAccent` | `string` | `"primary"` | Initial accent swatch ID during SSR or when no stored preference exists. |
230
+ | `persist` | `boolean` | `true` | When `false`, isolates the provider from `localStorage` (no storage read/write, no cross-tab sync). |
231
+ | `accent` | `string` | `undefined` | Controlled accent value. When supplied, internal state is driven by this prop. |
232
+ | `onAccentChange` | `(accent: string) => void` | `undefined` | Callback fired when an accent change is requested. |
233
+ | `applyToDocument` | `boolean` | `true` | When `false`, disables writing CSS custom properties and `data-accent` to `document.documentElement`. |
234
+ | `paletteOverrides` | `Readonly<Record<string, { light: string; dark: string }>>` | `undefined` | Optional HSL-channel pairs for the twelve preset IDs. Invalid pairs and unknown IDs are ignored. The application owns persistence. |
239
235
  - **Hook `useAccent()`**: Returns `{ accent: string, setAccent: (id: string) => void, swatches: readonly AccentSwatch[] }`. Throws an error when invoked outside an `AccentProvider`.
240
236
  - **`AccentSwatch` Type**:
241
237
  - `id: string`: Unique swatch identifier (e.g., `"primary"`, `"teal"`, `"rose"`).
@@ -260,15 +256,15 @@ import { Button } from "@nocoo/basalt/components/button";
260
256
  import { AccentProvider } from "@nocoo/basalt/providers/accent";
261
257
 
262
258
  export function CustomBrand() {
263
- return (
264
- <AccentProvider
265
- defaultAccent="primary"
266
- persist={false}
267
- paletteOverrides={{ primary: { light: "204 88% 62%", dark: "204 90% 70%" } }}
268
- >
269
- <Button>Save changes</Button>
270
- </AccentProvider>
271
- );
259
+ return (
260
+ <AccentProvider
261
+ defaultAccent="primary"
262
+ persist={false}
263
+ paletteOverrides={{ primary: { light: "204 88% 62%", dark: "204 90% 70%" } }}
264
+ >
265
+ <Button>Save changes</Button>
266
+ </AccentProvider>
267
+ );
272
268
  }
273
269
  ```
274
270
 
@@ -289,66 +285,59 @@ import { AccentProvider, useAccent } from "@nocoo/basalt/providers/accent";
289
285
  import { type BasaltTheme, ThemeProvider, useTheme } from "@nocoo/basalt/providers/theme";
290
286
 
291
287
  function PreferencesControls() {
292
- const { theme, setTheme } = useTheme();
293
- const { accent, setAccent } = useAccent();
294
-
295
- return (
296
- <div className="flex flex-wrap items-center gap-basalt-space-lg">
297
- <span className="text-sm font-medium">Theme: {theme}</span>
298
- <Button
299
- variant="outline"
300
- size="sm"
301
- onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
302
- >
303
- Toggle {theme === "dark" ? "Light" : "Dark"}
304
- </Button>
305
- <span className="text-sm font-medium ml-basalt-space-lg">Accent: {accent}</span>
306
- <Button
307
- variant="outline"
308
- size="sm"
309
- onClick={() => {
310
- const next = accent === "rose" ? "sky" : "rose";
311
- setAccent(next);
312
- }}
313
- >
314
- Toggle Accent
315
- </Button>
316
- </div>
317
- );
288
+ const { theme, setTheme } = useTheme();
289
+ const { accent, setAccent } = useAccent();
290
+
291
+ return (
292
+ <div className="flex flex-wrap items-center gap-basalt-space-lg">
293
+ <span className="text-sm font-medium">Theme: {theme}</span>
294
+ <Button
295
+ variant="outline"
296
+ size="sm"
297
+ onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
298
+ >
299
+ Toggle {theme === "dark" ? "Light" : "Dark"}
300
+ </Button>
301
+ <span className="text-sm font-medium ml-basalt-space-lg">Accent: {accent}</span>
302
+ <Button
303
+ variant="outline"
304
+ size="sm"
305
+ onClick={() => {
306
+ const next = accent === "rose" ? "sky" : "rose";
307
+ setAccent(next);
308
+ }}
309
+ >
310
+ Toggle Accent
311
+ </Button>
312
+ </div>
313
+ );
318
314
  }
319
315
 
320
316
  export function HostPreferencesApp() {
321
- const [theme, setTheme] = useState<BasaltTheme>("light");
322
- const [accent, setAccent] = useState("rose");
323
-
324
- return (
325
- <ThemeProvider
326
- persist={false}
327
- applyToDocument={false}
328
- theme={theme}
329
- onThemeChange={setTheme}
330
- >
331
- <AccentProvider
332
- persist={false}
333
- applyToDocument={false}
334
- accent={accent}
335
- onAccentChange={setAccent}
336
- >
337
- <div className="p-basalt-space-lg border rounded-lg space-y-basalt-space-lg">
338
- <p className="text-xs text-basalt-muted-foreground">
339
- Host owns document root DOM and persistence. Basalt contexts operate safely without side-effects.
340
- </p>
341
- <PreferencesControls />
342
- </div>
343
- </AccentProvider>
344
- </ThemeProvider>
345
- );
317
+ const [theme, setTheme] = useState<BasaltTheme>("light");
318
+ const [accent, setAccent] = useState("rose");
319
+
320
+ return (
321
+ <ThemeProvider persist={false} applyToDocument={false} theme={theme} onThemeChange={setTheme}>
322
+ <AccentProvider
323
+ persist={false}
324
+ applyToDocument={false}
325
+ accent={accent}
326
+ onAccentChange={setAccent}
327
+ >
328
+ <div className="p-basalt-space-lg border rounded-lg space-y-basalt-space-lg">
329
+ <p className="text-xs text-basalt-muted-foreground">
330
+ Host owns document root DOM and persistence. Basalt contexts operate safely without
331
+ side-effects.
332
+ </p>
333
+ <PreferencesControls />
334
+ </div>
335
+ </AccentProvider>
336
+ </ThemeProvider>
337
+ );
346
338
  }
347
339
  ```
348
340
 
349
-
350
-
351
-
352
341
  ---
353
342
 
354
343
  ## 5. Color and type
@@ -369,11 +358,11 @@ Icons: `lucide-react`, `strokeWidth={1.5}`, nav size `h-4 w-4 shrink-0`.
369
358
 
370
359
  `AppShell` owns the viewport and safe area; `AppMain` and `ContentIsland` derive their scroll behavior from it. Choose deliberately:
371
360
 
372
- | `layout` | Below 768px | 768px and above |
373
- | --- | --- | --- |
374
- | `workspace` (default) | Bounded panes | Bounded panes |
375
- | `responsive` | Natural document scrolling | Bounded panes |
376
- | `document` | Natural document scrolling | Natural document scrolling |
361
+ | `layout` | Below 768px | 768px and above |
362
+ | --------------------- | -------------------------- | -------------------------- |
363
+ | `workspace` (default) | Bounded panes | Bounded panes |
364
+ | `responsive` | Natural document scrolling | Bounded panes |
365
+ | `document` | Natural document scrolling | Natural document scrolling |
377
366
 
378
367
  Bounded shells use border-box `height/max-height: 100dvh`. Document shells use `min-height: 100dvh`, automatic height, visible overflow and ordinary root scrolling. Main and island release their height/overflow constraints together; no UA detection, innerHeight workaround, fixed reader viewport or nested mobile scroller is needed. Desktop workspaces keep the island as the vertical scroll owner.
379
368
 
@@ -387,16 +376,18 @@ Bounded shells use border-box `height/max-height: 100dvh`. Document shells use `
387
376
 
388
377
  ```tsx excerpt:responsive-shell
389
378
  <AppShell layout="responsive">
390
- <AppSkipLink>Skip to content</AppSkipLink>
391
- <AppMain>
392
- <AppHeader title="Field notes" actions={actions} />
393
- <div className="flex min-h-0 min-w-0 flex-1 flex-col md:px-basalt-layout-sm md:pb-basalt-layout-sm">
394
- <ContentIsland mobileSurface="edge-to-edge">
395
- <div className="mx-auto max-w-prose px-basalt-space-lg py-basalt-space-lg md:p-0">{content}</div>
396
- </ContentIsland>
397
- </div>
398
- </AppMain>
399
- </AppShell>
379
+ <AppSkipLink>Skip to content</AppSkipLink>
380
+ <AppMain>
381
+ <AppHeader title="Field notes" actions={actions} />
382
+ <div className="flex min-h-0 min-w-0 flex-1 flex-col md:px-basalt-layout-sm md:pb-basalt-layout-sm">
383
+ <ContentIsland mobileSurface="edge-to-edge">
384
+ <div className="mx-auto max-w-prose px-basalt-space-lg py-basalt-space-lg md:p-0">
385
+ {content}
386
+ </div>
387
+ </ContentIsland>
388
+ </div>
389
+ </AppMain>
390
+ </AppShell>;
400
391
  ```
401
392
 
402
393
  The wrapper intentionally has **no mobile `px-basalt-space-lg` / `pb-basalt-space-lg`**. ContentIsland cannot remove spacing owned by its parent. Remove consumer `h-screen`, `h-full`, `overflow-hidden`, fixed heights and body scroll locks from the document path; all intermediate wrappers must be natural-height columns. Do not target private descendants to undo the library.
@@ -428,128 +419,97 @@ Do not wrap those regions in another full-viewport column. Do not set `h-screen`
428
419
 
429
420
  ### Shared navigation geometry
430
421
 
431
- `SidebarNav` and `NavigationMenuList` own one `--basalt-space-nav-inset` (8px) boundary and `--basalt-space-nav-gap` (4px) between rows. Sidebar groups do not add another horizontal inset. Sidebar destinations and search use 8px horizontal/vertical padding, an 8px icon gap and a 22px line box for a 38px reference row. This is local to Sidebar: editable/folder rows, horizontal navigation links and menus retain 6px vertical padding and 34px rows. Header and search wrapper use the navigation inset. SidebarFooter instead owns 16px horizontal and bottom padding plus 8px top padding; keep the same 68px rail centering and expanded layout without duplicating that inset. Do not add caller-owned padding to compensate for missing package geometry.
422
+ `SidebarNav` uses a 16px horizontal inset plus the row's 8px inset, placing row content on a 24px column. `NavigationMenuList` retains its 8px navigation inset; both use a 4px row gap. Sidebar groups do not add another horizontal inset. Sidebar destinations and search use 8px horizontal/vertical padding, an 8px icon gap and a 22px line box for a 38px reference row. Editable/folder rows, horizontal navigation links and menus retain 6px vertical padding and 34px rows. Header and footer use a 24px horizontal inset; footer keeps 8px top and 16px bottom padding. Wrap search in `px-basalt-layout` so its row inset reaches the same column.
432
423
 
433
424
  Use `SidebarNav` for vertical lists, including standalone lists of `EditableNavItem` / `FolderNavItem`; `NavigationMenuList` owns horizontal/vertical Radix navigation. Both compose the existing list-local hover adapter. One background moves with transform between enabled rows. Sidebar hover appears at the actual pointer row and disappears there on leave or over a gap/disclosure label; it never launches from or returns to the active route or stale pointer focus. Only continuous row-to-row hover animates. Keyboard focus remains independent. NavigationMenuList retains its focus/selection-following behavior. Nested lists own separate highlights. Hidden, disabled and actively renamed rows are excluded. Geometry reads are batched per animation frame, same-row pointer motion causes no updates, and reduced motion disables transitions. Navigation state, routes and rename operations remain application-owned; the adapter is View-only.
434
425
 
435
- `SidebarHeader` pads horizontally by the navigation inset; `SidebarFooter` owns its larger footer boundary. Brand, version pill, and collapse control go directly in the header. The version string is read from the app `package.json` at build time. Never hardcode it. `SidebarPartition` aligns with row text. `SidebarGroup` adds only section spacing, never a second item gutter.
426
+ Place `SidebarBrand` in `SidebarHeader` with a 20px mark and `SidebarToggle` in its action slot. The fixed mark slot starts at 24px and ends at 44px, keeping its center on the 34px rail axis throughout the width transition. The version uses the 12px auxiliary tier and comes from the app `package.json` at build time. `SidebarPartition` aligns with row text. `SidebarGroup` adds only section spacing, never a second item gutter.
436
427
 
437
428
  ```tsx excerpt:sidebar-expanded-structure
438
- <Sidebar collapsed={collapsed}>
439
- <SidebarHeader>
440
- <div className="flex w-full items-center justify-between">
441
- <div className="flex min-w-0 items-center gap-basalt-space-lg">
442
- <BasaltMark className="h-5 w-5 shrink-0" />
443
- <span className="truncate text-lg font-semibold text-basalt-foreground md:text-xl">
444
- Acme
445
- </span>
446
- <span className="shrink-0 rounded-md bg-basalt-secondary px-basalt-space-md py-basalt-space-xs text-[10px] leading-none font-medium text-basalt-muted-foreground">
447
- v{version}
448
- </span>
449
- </div>
450
- <Button
451
- variant="ghost"
452
- size="icon"
453
- className="h-7 w-7 shrink-0"
454
- onClick={onToggle}
455
- aria-label="Collapse sidebar"
456
- >
457
- <PanelLeft aria-hidden="true" />
458
- </Button>
459
- </div>
460
- </SidebarHeader>
461
- <div className="px-basalt-nav-inset">
462
- <SidebarSearch onClick={() => setSearchOpen(true)}>Search</SidebarSearch>
463
- </div>
464
- <SidebarNav>
465
- {groups.map((group) => (
466
- <div key={group.label}>
467
- <SidebarPartition>{group.label}</SidebarPartition>
468
- <div className="flex flex-col gap-basalt-nav-gap">
469
- {group.items.map((item) => (
470
- <SidebarItem
471
- key={item.href}
472
- active={active(item.href)}
473
- onClick={() => navigate(item.href)}
474
- >
475
- <item.icon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
476
- <span className="flex-1 truncate text-left">{item.label}</span>
477
- </SidebarItem>
478
- ))}
479
- </div>
480
- </div>
481
- ))}
482
- </SidebarNav>
483
- <SidebarFooter>
484
- <SidebarUser name={name} email={email} avatar={avatar} />
485
- </SidebarFooter>
486
- </Sidebar>
429
+ <SidebarProvider collapsed={collapsed} onCollapsedChange={setCollapsed}>
430
+ <Sidebar>
431
+ <SidebarHeader>
432
+ <SidebarBrand
433
+ logo={<BasaltMark className="size-basalt-sidebar-brand-slot" />}
434
+ name="Acme"
435
+ version={`v${version}`}
436
+ action={<SidebarToggle />}
437
+ />
438
+ </SidebarHeader>
439
+ <div className="px-basalt-layout">
440
+ <SidebarSearch onClick={() => setSearchOpen(true)}>Search</SidebarSearch>
441
+ </div>
442
+ <SidebarNav>
443
+ {groups.map((group) => (
444
+ <div key={group.label}>
445
+ <SidebarPartition>{group.label}</SidebarPartition>
446
+ <div className="flex flex-col gap-basalt-nav-gap">
447
+ {group.items.map((item) => (
448
+ <SidebarItem key={item.href} active={active(item.href)} href={item.href}>
449
+ <item.icon className="h-4 w-4 shrink-0" strokeWidth={1.5} />
450
+ <span className="flex-1 truncate text-left">{item.label}</span>
451
+ </SidebarItem>
452
+ ))}
453
+ </div>
454
+ </div>
455
+ ))}
456
+ </SidebarNav>
457
+ <SidebarFooter>
458
+ <SidebarUser name={name} email={email} avatar={avatar} />
459
+ </SidebarFooter>
460
+ </Sidebar>
461
+ </SidebarProvider>;
487
462
  ```
488
463
 
489
464
  SidebarPartition marks a major section; SidebarGroup is a smaller uppercase, semibold disclosure label with a 22px line box, 8px insets and 12px group separation. Group labels do not participate in the destination hover layer. Do not restyle them as ordinary SidebarItem rows or add per-page indentation repairs.
490
465
 
491
466
  ### Collapsed tree
492
467
 
493
- Pass `collapsed` on `Sidebar`. The element becomes 68px. Children are icon-sized and centered. Still no inner width/height wrapper.
468
+ Control `collapsed` through `SidebarProvider`. The element becomes 68px. Children are icon-sized and centered. Still no inner width/height wrapper.
494
469
 
495
- The 68px rail uses `--basalt-size-rail`. Header and footer center their content automatically; SidebarIconItem keeps the 34px control width and derives its 38px minimum height from the sidebar line box and padding. The navigation region centers its icon rows on that same rail axis without caller margin overrides. Render icon-only content with an accessible name and tooltip when collapsed; the library does not hide arbitrary application text. Keep a brand mark's expanded inset consistent with the collapsed axis instead of adding collapse-only offsets.
470
+ The 68px rail uses `--basalt-size-rail`. `SidebarBrand` keeps the mark stationary and moves its action below the header; the footer centers its content. SidebarIconItem keeps the 34px control width and derives its 38px minimum height from the sidebar line box and padding. The navigation region centers its icon rows on that same rail axis without caller margin overrides. Render icon-only content with an accessible name and tooltip when collapsed; the library does not hide arbitrary application text.
496
471
 
497
472
  ```tsx excerpt:sidebar-collapsed-structure
498
- <Sidebar collapsed={collapsed}>
499
- <SidebarHeader>
500
- <BasaltMark className="h-5 w-5" />
501
- </SidebarHeader>
502
- <Button
503
- variant="ghost"
504
- size="icon"
505
- className="mb-basalt-space-sm self-center"
506
- onClick={onToggle}
507
- aria-label="Expand sidebar"
508
- >
509
- <PanelLeft aria-hidden="true" />
510
- </Button>
511
- <SidebarNav>
512
- <Tooltip delayDuration={0}>
513
- <TooltipTrigger asChild>
514
- <SidebarIconItem
515
- onClick={() => setSearchOpen(true)}
516
- aria-label="Search (⌘K)"
517
- >
518
- <Search className="h-4 w-4" strokeWidth={1.5} aria-hidden="true" />
519
- </SidebarIconItem>
520
- </TooltipTrigger>
521
- <TooltipContent side="right" sideOffset={8}>
522
- Search (⌘K)
523
- </TooltipContent>
524
- </Tooltip>
525
- {items.map((item) => (
526
- <Tooltip key={item.href} delayDuration={0}>
527
- <TooltipTrigger asChild>
528
- <SidebarIconItem
529
- active={active(item.href)}
530
- aria-label={item.label}
531
- onClick={() => navigate(item.href)}
532
- >
533
- <item.icon className="h-4 w-4" strokeWidth={1.5} />
534
- </SidebarIconItem>
535
- </TooltipTrigger>
536
- <TooltipContent side="right" sideOffset={8}>
537
- {item.label}
538
- </TooltipContent>
539
- </Tooltip>
540
- ))}
541
- </SidebarNav>
542
- <SidebarFooter>
543
- <Tooltip delayDuration={0}>
544
- <TooltipTrigger asChild>
545
- <span className="inline-flex">{avatar}</span>
546
- </TooltipTrigger>
547
- <TooltipContent side="right" sideOffset={8}>
548
- {name}
549
- </TooltipContent>
550
- </Tooltip>
551
- </SidebarFooter>
552
- </Sidebar>
473
+ <SidebarProvider collapsed={collapsed} onCollapsedChange={setCollapsed}>
474
+ <Sidebar>
475
+ <SidebarHeader>
476
+ <SidebarBrand
477
+ logo={<BasaltMark className="size-basalt-sidebar-brand-slot" />}
478
+ name="Acme"
479
+ version={`v${version}`}
480
+ action={<SidebarToggle />}
481
+ />
482
+ </SidebarHeader>
483
+ <SidebarNav>
484
+ <Tooltip delayDuration={0}>
485
+ <TooltipTrigger asChild>
486
+ <SidebarIconItem onClick={() => setSearchOpen(true)} aria-label="Search (⌘K)">
487
+ <Search className="h-4 w-4" strokeWidth={1.5} aria-hidden="true" />
488
+ </SidebarIconItem>
489
+ </TooltipTrigger>
490
+ <TooltipContent side="right" sideOffset={8}>
491
+ Search (⌘K)
492
+ </TooltipContent>
493
+ </Tooltip>
494
+ {items.map((item) => (
495
+ <Tooltip key={item.href} delayDuration={0}>
496
+ <TooltipTrigger asChild>
497
+ <SidebarIconItem active={active(item.href)} aria-label={item.label} href={item.href}>
498
+ <item.icon className="h-4 w-4" strokeWidth={1.5} />
499
+ </SidebarIconItem>
500
+ </TooltipTrigger>
501
+ <TooltipContent side="right" sideOffset={8}>{item.label}</TooltipContent>
502
+ </Tooltip>
503
+ ))}
504
+ </SidebarNav>
505
+ <SidebarFooter>
506
+ <Tooltip delayDuration={0}>
507
+ <TooltipTrigger asChild><span className="inline-flex">{avatar}</span></TooltipTrigger>
508
+ <TooltipContent side="right" sideOffset={8}>{name}</TooltipContent>
509
+ </Tooltip>
510
+ </SidebarFooter>
511
+ </Sidebar>
512
+ </SidebarProvider>;
553
513
  ```
554
514
 
555
515
  Render expanded **or** collapsed regions from the same `collapsed` flag. One `Sidebar`. The flag is the animation.
@@ -560,9 +520,7 @@ Portaled UI (`CommandPalette`, dialogs) may be a sibling of these regions inside
560
520
 
561
521
  ## 8. Collapse, peek, overlay
562
522
 
563
- Standard product shell: React state `collapsed` passed into `Sidebar`. A header button flips it. Motion is already on `Sidebar`.
564
-
565
- `SidebarProvider` is for peek-on-hover, overlay mode, and resize. Use it when those behaviors are required. A normal app shell does not need a second sidebar context.
523
+ Standard product shell: pass React state `collapsed` and `onCollapsedChange` to `SidebarProvider`; `SidebarToggle` updates that state. Motion is already on `Sidebar`. The provider also supports peek-on-hover, overlay mode and resize. Set `resizable={false}` on a Sidebar inside a fixed-width Sheet.
566
524
 
567
525
  ---
568
526
 
@@ -572,17 +530,30 @@ Breakpoint: `768px`. Below that, the in-flow rail is omitted. The same sidebar c
572
530
 
573
531
  ```tsx excerpt:mobile-sheet-sidebar
574
532
  <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
575
- <SheetContent
576
- side="left"
577
- className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
578
- >
579
- <SheetTitle className="sr-only">Navigation</SheetTitle>
580
- <AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} />
581
- </SheetContent>
582
- </Sheet>
533
+ <SheetContent
534
+ side="left"
535
+ tabIndex={-1}
536
+ className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
537
+ onOpenAutoFocus={(event) => {
538
+ if (event.target instanceof HTMLElement) {
539
+ event.preventDefault();
540
+ event.target.focus();
541
+ }
542
+ }}
543
+ onCloseAutoFocus={(event) => {
544
+ if (mobileTriggerRef.current) {
545
+ event.preventDefault();
546
+ mobileTriggerRef.current.focus();
547
+ }
548
+ }}
549
+ >
550
+ <SheetTitle className="sr-only">Navigation</SheetTitle>
551
+ <AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} resizable={false} />
552
+ </SheetContent>
553
+ </Sheet>;
583
554
  ```
584
555
 
585
- `SheetContent` bounds its Sidebar to the available height. Sidebar owns the 260px column and the portal safe area. Close the sheet on pathname change. Radix Sheet owns modal scroll locking and focus restoration; do not set body overflow separately.
556
+ `SheetContent` bounds its Sidebar to the available height. Sidebar owns the 260px column and the portal safe area. Close the sheet on pathname change. Focus the sheet itself on open so the focused toggle's tooltip does not consume the first Escape; restore focus to the menu button referenced by `mobileTriggerRef`. Radix Sheet owns modal scroll locking; do not set body overflow separately.
586
557
 
587
558
  Sheets slide from their declared edge in 350ms and return in 250ms, while the backdrop fades and blurs to 6px. DropdownMenu, Select, ContextMenu, MenuBar, Popover, HoverCard and Tooltip scale from their trigger origin and fade in/out in 200/150ms. Shared motion tokens live in the package stylesheet; both CSS entrypoints include them. Reduced motion disables these animations. Keep content mounted through Radix's closing state; do not conditionally remove the root before its exit completes.
588
559
 
@@ -596,15 +567,15 @@ Local hook (not in the package):
596
567
  const MOBILE_BREAKPOINT = 768;
597
568
 
598
569
  export function useIsMobile() {
599
- const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
600
- useEffect(() => {
601
- const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
602
- const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
603
- mql.addEventListener("change", onChange);
604
- setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
605
- return () => mql.removeEventListener("change", onChange);
606
- }, []);
607
- return !!isMobile;
570
+ const [isMobile, setIsMobile] = useState<boolean | undefined>(undefined);
571
+ useEffect(() => {
572
+ const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`);
573
+ const onChange = () => setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
574
+ mql.addEventListener("change", onChange);
575
+ setIsMobile(window.innerWidth < MOBILE_BREAKPOINT);
576
+ return () => mql.removeEventListener("change", onChange);
577
+ }, []);
578
+ return !!isMobile;
608
579
  }
609
580
  ```
610
581
 
@@ -614,62 +585,75 @@ export function useIsMobile() {
614
585
 
615
586
  ```tsx excerpt:app-frame-component
616
587
  export function AppFrame() {
617
- const [collapsed, setCollapsed] = useState(false);
618
- const isMobile = useIsMobile();
619
- const [mobileOpen, setMobileOpen] = useState(false);
620
- const location = useLocation();
621
- const { theme } = useTheme();
622
- const crumbs = [{ href: "/", label: "Home" }];
623
- const title = "Projects";
624
-
625
- useEffect(() => {
626
- setMobileOpen(false);
627
- }, [location.pathname]);
628
-
629
-
630
- return (
631
- <AppShell>
632
- <AppSkipLink>Skip to main content</AppSkipLink>
633
- {!isMobile ? (
634
- <AppSidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
635
- ) : (
636
- <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
637
- <SheetContent
638
- side="left"
639
- className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
640
- >
641
- <SheetTitle className="sr-only">Navigation</SheetTitle>
642
- <AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} />
643
- </SheetContent>
644
- </Sheet>
645
- )}
646
- <AppMain>
647
- <AppHeader
648
- leading={
649
- isMobile ? (
650
- <Button
651
- variant="ghost"
652
- size="icon"
653
- className="h-8 w-8"
654
- onClick={() => setMobileOpen(true)}
655
- aria-label="Open navigation"
656
- >
657
- <Menu aria-hidden="true" />
658
- </Button>
659
- ) : null
660
- }
661
- breadcrumbs={crumbs}
662
- title={title}
663
- actions={<ThemeToggle aria-label={`Toggle theme (now ${theme})`} />}
664
- />
665
- <div className="flex min-h-0 flex-1 flex-col px-basalt-space-lg pb-basalt-space-lg md:px-basalt-layout-sm md:pb-basalt-layout-sm">
666
- <ContentIsland>
667
- <Outlet />
668
- </ContentIsland>
669
- </div>
670
- </AppMain>
671
- </AppShell>
672
- );
588
+ const [collapsed, setCollapsed] = useState(false);
589
+ const isMobile = useIsMobile();
590
+ const [mobileOpen, setMobileOpen] = useState(false);
591
+ const mobileTriggerRef = useRef<HTMLButtonElement>(null);
592
+ const location = useLocation();
593
+ const { theme } = useTheme();
594
+ const crumbs = [{ href: "/", label: "Home" }];
595
+ const title = "Projects";
596
+
597
+ useEffect(() => {
598
+ setMobileOpen(false);
599
+ }, [location.pathname]);
600
+
601
+ return (
602
+ <AppShell>
603
+ <AppSkipLink>Skip to main content</AppSkipLink>
604
+ {!isMobile ? (
605
+ <AppSidebar collapsed={collapsed} onToggle={() => setCollapsed((v) => !v)} />
606
+ ) : (
607
+ <Sheet open={mobileOpen} onOpenChange={setMobileOpen}>
608
+ <SheetContent
609
+ side="left"
610
+ tabIndex={-1}
611
+ className="w-[260px] max-w-[260px] border-0 bg-basalt-background p-0"
612
+ onOpenAutoFocus={(event) => {
613
+ if (event.target instanceof HTMLElement) {
614
+ event.preventDefault();
615
+ event.target.focus();
616
+ }
617
+ }}
618
+ onCloseAutoFocus={(event) => {
619
+ if (mobileTriggerRef.current) {
620
+ event.preventDefault();
621
+ mobileTriggerRef.current.focus();
622
+ }
623
+ }}
624
+ >
625
+ <SheetTitle className="sr-only">Navigation</SheetTitle>
626
+ <AppSidebar collapsed={false} onToggle={() => setMobileOpen(false)} resizable={false} />
627
+ </SheetContent>
628
+ </Sheet>
629
+ )}
630
+ <AppMain>
631
+ <AppHeader
632
+ leading={
633
+ isMobile ? (
634
+ <Button
635
+ ref={mobileTriggerRef}
636
+ variant="ghost"
637
+ size="icon-sm"
638
+ onClick={() => setMobileOpen(true)}
639
+ aria-label="Open navigation"
640
+ >
641
+ <Menu aria-hidden="true" />
642
+ </Button>
643
+ ) : null
644
+ }
645
+ breadcrumbs={crumbs}
646
+ title={title}
647
+ actions={<ThemeToggle aria-label={`Toggle theme (now ${theme})`} />}
648
+ />
649
+ <div className="flex min-h-0 flex-1 flex-col px-basalt-space-lg pb-basalt-space-lg md:px-basalt-layout-sm md:pb-basalt-layout-sm">
650
+ <ContentIsland>
651
+ <Outlet />
652
+ </ContentIsland>
653
+ </div>
654
+ </AppMain>
655
+ </AppShell>
656
+ );
673
657
  }
674
658
  ```
675
659
 
@@ -699,29 +683,31 @@ Shape: a fixed badge width, `rounded-basalt-lg`, `bg-basalt-card` and a hairline
699
683
 
700
684
  ```tsx excerpt:login-badge-page
701
685
  export function LoginPage() {
702
- return (
703
- <div className="basalt-ui relative flex min-h-dvh items-center justify-center bg-basalt-background p-basalt-space-lg">
704
- <div
705
- data-basalt-surface-root=""
706
- className="flex w-72 max-w-full flex-col overflow-hidden rounded-basalt-lg bg-basalt-card ring-1 ring-basalt-border"
707
- >
708
- <div className="flex items-center justify-between gap-basalt-space-lg bg-basalt-primary px-basalt-space-lg py-basalt-space-lg">
709
- <span className="text-basalt-base font-semibold text-basalt-primary-foreground">Acme</span>
710
- <span className="text-basalt-xs font-medium uppercase tracking-widest text-basalt-primary-foreground/80">
711
- Visitor
712
- </span>
713
- </div>
714
- <div className="flex flex-1 flex-col items-center gap-basalt-space-lg px-basalt-space-lg py-basalt-space-lg">
715
- <p className="text-basalt-xl font-semibold text-basalt-foreground">Welcome</p>
716
- <p className="text-basalt-sm text-basalt-muted-foreground">Sign in to continue</p>
717
- <Button className="w-full">Continue</Button>
718
- </div>
719
- <div className="border-t border-basalt-border px-basalt-space-lg py-basalt-space-lg">
720
- <p className="text-basalt-xs text-basalt-muted-foreground">Secure sign-in</p>
721
- </div>
722
- </div>
723
- </div>
724
- );
686
+ return (
687
+ <div className="basalt-ui relative flex min-h-dvh items-center justify-center bg-basalt-background p-basalt-space-lg">
688
+ <div
689
+ data-basalt-surface-root=""
690
+ className="flex w-72 max-w-full flex-col overflow-hidden rounded-basalt-lg bg-basalt-card ring-1 ring-basalt-border"
691
+ >
692
+ <div className="flex items-center justify-between gap-basalt-space-lg bg-basalt-primary px-basalt-space-lg py-basalt-space-lg">
693
+ <span className="text-basalt-base font-semibold text-basalt-primary-foreground">
694
+ Acme
695
+ </span>
696
+ <span className="text-basalt-xs font-medium uppercase tracking-widest text-basalt-primary-foreground/80">
697
+ Visitor
698
+ </span>
699
+ </div>
700
+ <div className="flex flex-1 flex-col items-center gap-basalt-space-lg px-basalt-space-lg py-basalt-space-lg">
701
+ <p className="text-basalt-xl font-semibold text-basalt-foreground">Welcome</p>
702
+ <p className="text-basalt-sm text-basalt-muted-foreground">Sign in to continue</p>
703
+ <Button className="w-full">Continue</Button>
704
+ </div>
705
+ <div className="border-t border-basalt-border px-basalt-space-lg py-basalt-space-lg">
706
+ <p className="text-basalt-xs text-basalt-muted-foreground">Secure sign-in</p>
707
+ </div>
708
+ </div>
709
+ </div>
710
+ );
725
711
  }
726
712
  ```
727
713
 
@@ -742,7 +728,7 @@ Boot and route gates use `LoadingScreen` — a centered mark and a 6rem shimmer
742
728
  - **Native & Ref Boundary**: Inherits standard `HTMLAttributes<HTMLDivElement>`. Overlays full viewport (`fixed inset-0 z-50 flex items-center justify-center bg-basalt-background`). Does not forward ref.
743
729
 
744
730
  ```tsx excerpt:loading-screen-snippet
745
- <LoadingScreen label="Loading" />
731
+ <LoadingScreen label="Loading" />;
746
732
  ```
747
733
 
748
734
  ---
@@ -779,23 +765,23 @@ import { LayerCard } from "@nocoo/basalt/components/layer-card";
779
765
  import { PageHeader } from "@nocoo/basalt/components/page-header";
780
766
 
781
767
  export default function ProjectsPage() {
782
- return (
783
- <div className="space-y-basalt-layout-lg">
784
- <PageHeader
785
- title="Projects"
786
- description="Active work in this workspace."
787
- actions={
788
- <>
789
- <Input placeholder="Search" className="max-w-48" />
790
- <Button>New project</Button>
791
- </>
792
- }
793
- />
794
- <LayerCard>
795
- <p className="text-sm text-basalt-muted-foreground">List or board.</p>
796
- </LayerCard>
797
- </div>
798
- );
768
+ return (
769
+ <div className="space-y-basalt-layout-lg">
770
+ <PageHeader
771
+ title="Projects"
772
+ description="Active work in this workspace."
773
+ actions={
774
+ <>
775
+ <Input placeholder="Search" className="max-w-48" />
776
+ <Button>New project</Button>
777
+ </>
778
+ }
779
+ />
780
+ <LayerCard>
781
+ <p className="text-sm text-basalt-muted-foreground">List or board.</p>
782
+ </LayerCard>
783
+ </div>
784
+ );
799
785
  }
800
786
  ```
801
787
 
@@ -809,56 +795,56 @@ import { PageHeader } from "@nocoo/basalt/components/page-header";
809
795
  import { SectionRule } from "@nocoo/basalt/components/section-rule";
810
796
 
811
797
  export default function ProjectsPage() {
812
- return (
813
- <div className="space-y-basalt-layout-lg">
814
- <PageHeader
815
- title="Projects"
816
- description="Active work in this workspace."
817
- actions={
818
- <>
819
- <Button variant="outline">Export</Button>
820
- <Button>New project</Button>
821
- </>
822
- }
823
- filters={
824
- <>
825
- <Input placeholder="Owner" className="max-w-48" />
826
- <Input placeholder="Region" className="max-w-48" />
827
- <Input placeholder="Risk" className="max-w-48" />
828
- </>
829
- }
830
- />
831
- <SectionRule title="Overview" hint="Live totals for the current workspace.">
832
- <div className="grid grid-cols-2 gap-basalt-layout">
833
- <LayerCard>
834
- <p className="text-xs text-basalt-muted-foreground">Projects</p>
835
- <p className="text-2xl font-semibold">24</p>
836
- </LayerCard>
837
- <LayerCard>
838
- <p className="text-xs text-basalt-muted-foreground">Ready</p>
839
- <p className="text-2xl font-semibold">18</p>
840
- </LayerCard>
841
- </div>
842
- </SectionRule>
843
- <SectionRule
844
- title="Catalog"
845
- actions={
846
- <Button variant="outline" size="sm">
847
- Filter
848
- </Button>
849
- }
850
- >
851
- <LayerCard>
852
- <LayerCard.Header>Items</LayerCard.Header>
853
- <LayerCard.Body>
854
- <p className="text-sm text-basalt-muted-foreground">
855
- Later work is only more cards in this region.
856
- </p>
857
- </LayerCard.Body>
858
- </LayerCard>
859
- </SectionRule>
860
- </div>
861
- );
798
+ return (
799
+ <div className="space-y-basalt-layout-lg">
800
+ <PageHeader
801
+ title="Projects"
802
+ description="Active work in this workspace."
803
+ actions={
804
+ <>
805
+ <Button variant="outline">Export</Button>
806
+ <Button>New project</Button>
807
+ </>
808
+ }
809
+ filters={
810
+ <>
811
+ <Input placeholder="Owner" className="max-w-48" />
812
+ <Input placeholder="Region" className="max-w-48" />
813
+ <Input placeholder="Risk" className="max-w-48" />
814
+ </>
815
+ }
816
+ />
817
+ <SectionRule title="Overview" hint="Live totals for the current workspace.">
818
+ <div className="grid grid-cols-2 gap-basalt-layout">
819
+ <LayerCard>
820
+ <p className="text-xs text-basalt-muted-foreground">Projects</p>
821
+ <p className="text-2xl font-semibold">24</p>
822
+ </LayerCard>
823
+ <LayerCard>
824
+ <p className="text-xs text-basalt-muted-foreground">Ready</p>
825
+ <p className="text-2xl font-semibold">18</p>
826
+ </LayerCard>
827
+ </div>
828
+ </SectionRule>
829
+ <SectionRule
830
+ title="Catalog"
831
+ actions={
832
+ <Button variant="outline" size="sm">
833
+ Filter
834
+ </Button>
835
+ }
836
+ >
837
+ <LayerCard>
838
+ <LayerCard.Header>Items</LayerCard.Header>
839
+ <LayerCard.Body>
840
+ <p className="text-sm text-basalt-muted-foreground">
841
+ Later work is only more cards in this region.
842
+ </p>
843
+ </LayerCard.Body>
844
+ </LayerCard>
845
+ </SectionRule>
846
+ </div>
847
+ );
862
848
  }
863
849
  ```
864
850
 
@@ -874,14 +860,14 @@ Pages render **inside** `ContentIsland`. That island is already L1. Paint is CSS
874
860
 
875
861
  ### Levels
876
862
 
877
- | Where | Mark | Paint |
878
- |---|---|---|
879
- | L0 | `AppShell`, login viewport | `--basalt-background` 94% / 9% |
880
- | L1 | `ContentIsland`, Dialog, Sheet, AlertDialog, login badge | `--basalt-card` 97% / 10.6% |
881
- | L2 | first `LayerCard` under a root | `--basalt-secondary` 99% / 12.2% |
882
- | L3 | `LayerCard.Well` or a nested `LayerCard` | `--basalt-bright` 100% / 14% |
883
- | L3+ | deeper nested surface | transparent, no ring by default |
884
- | Overlay | Tooltip, Popover, Dropdown, HoverCard | `--basalt-popover` — not a stack step |
863
+ | Where | Mark | Paint |
864
+ | ------- | -------------------------------------------------------- | ------------------------------------- |
865
+ | L0 | `AppShell`, login viewport | `--basalt-background` 94% / 9% |
866
+ | L1 | `ContentIsland`, Dialog, Sheet, AlertDialog, login badge | `--basalt-card` 97% / 10.6% |
867
+ | L2 | first `LayerCard` under a root | `--basalt-secondary` 99% / 12.2% |
868
+ | L3 | `LayerCard.Well` or a nested `LayerCard` | `--basalt-bright` 100% / 14% |
869
+ | L3+ | deeper nested surface | transparent, no ring by default |
870
+ | Overlay | Tooltip, Popover, Dropdown, HoverCard | `--basalt-popover` — not a stack step |
885
871
 
886
872
  `--basalt-muted` is not a content step. Isolated (no root): the outermost `LayerCard` paints as L1; its Well is L2.
887
873
 
@@ -893,31 +879,29 @@ Unstructured card (KPI, metric, tile) — root padding, one surface:
893
879
 
894
880
  ```tsx excerpt:layercard-unstructured
895
881
  <LayerCard>
896
- <p className="text-xs text-basalt-muted-foreground">Sessions</p>
897
- <p className="text-2xl font-semibold">1,284</p>
898
- </LayerCard>
882
+ <p className="text-xs text-basalt-muted-foreground">Sessions</p>
883
+ <p className="text-2xl font-semibold">1,284</p>
884
+ </LayerCard>;
899
885
  ```
900
886
 
901
887
  Structured card — root padding drops. `Body` stays on L2. `Well` raises to L3:
902
888
 
903
889
  ```tsx excerpt:layercard-structured-body
904
890
  <LayerCard>
905
- <LayerCard.Header>Account</LayerCard.Header>
906
- <LayerCard.Body>
907
- <DescriptionList columns={1}>
908
- <DescriptionList.Item term="Status">Active</DescriptionList.Item>
909
- </DescriptionList>
910
- </LayerCard.Body>
911
- </LayerCard>
891
+ <LayerCard.Header>Account</LayerCard.Header>
892
+ <LayerCard.Body>
893
+ <DescriptionList columns={1}>
894
+ <DescriptionList.Item term="Status">Active</DescriptionList.Item>
895
+ </DescriptionList>
896
+ </LayerCard.Body>
897
+ </LayerCard>;
912
898
  ```
913
899
 
914
900
  ```tsx excerpt:layercard-structured-well
915
901
  <LayerCard>
916
- <LayerCard.Header>Activity</LayerCard.Header>
917
- <LayerCard.Well>
918
- {/* lists, nested groups, inner frames */}
919
- </LayerCard.Well>
920
- </LayerCard>
902
+ <LayerCard.Header>Activity</LayerCard.Header>
903
+ <LayerCard.Well>{/* lists, nested groups, inner frames */}</LayerCard.Well>
904
+ </LayerCard>;
921
905
  ```
922
906
 
923
907
  Place cards with Grid or flex. Those classes do not paint. A grid of `LayerCard` on the island is L2. A `LayerCard` inside a Well is L3+.
@@ -972,16 +956,16 @@ Flush heading. Title left. Create last in `actions`. Short filters in `actions`.
972
956
  import { PageHeader } from "@nocoo/basalt/components/page-header";
973
957
 
974
958
  <PageHeader
975
- title="Projects"
976
- description="Active work in this workspace."
977
- actions={
978
- <>
979
- <Button variant="outline">Export</Button>
980
- <Button>New project</Button>
981
- </>
982
- }
983
- filters={<Input placeholder="Owner" className="max-w-48" />}
984
- />
959
+ title="Projects"
960
+ description="Active work in this workspace."
961
+ actions={
962
+ <>
963
+ <Button variant="outline">Export</Button>
964
+ <Button>New project</Button>
965
+ </>
966
+ }
967
+ filters={<Input placeholder="Owner" className="max-w-48" />}
968
+ />;
985
969
  ```
986
970
 
987
971
  ### SectionRule
@@ -1036,30 +1020,34 @@ import type React from "react";
1036
1020
  import { Button, Field, Input, Switch } from "@nocoo/basalt";
1037
1021
 
1038
1022
  export function ProfileForm() {
1039
- const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
1040
- event.preventDefault();
1041
- const data = new FormData(event.currentTarget);
1042
- const email = data.get("email");
1043
- const notifications = data.get("notifications") === "on";
1044
- console.log({ email, notifications });
1045
- };
1046
-
1047
- return (
1048
- <form onSubmit={handleSubmit} className="space-y-basalt-layout">
1049
- <Field label="Email" hint="Your primary email address">
1050
- <Input name="email" type="email" required />
1051
- </Field>
1052
-
1053
- <Field label="Notifications">
1054
- <Switch name="notifications" defaultChecked />
1055
- </Field>
1056
-
1057
- <div className="flex gap-basalt-space-lg">
1058
- <Button type="reset" variant="secondary">Reset</Button>
1059
- <Button type="submit" variant="default">Save Changes</Button>
1060
- </div>
1061
- </form>
1062
- );
1023
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
1024
+ event.preventDefault();
1025
+ const data = new FormData(event.currentTarget);
1026
+ const email = data.get("email");
1027
+ const notifications = data.get("notifications") === "on";
1028
+ console.log({ email, notifications });
1029
+ };
1030
+
1031
+ return (
1032
+ <form onSubmit={handleSubmit} className="space-y-basalt-layout">
1033
+ <Field label="Email" hint="Your primary email address">
1034
+ <Input name="email" type="email" required />
1035
+ </Field>
1036
+
1037
+ <Field label="Notifications">
1038
+ <Switch name="notifications" defaultChecked />
1039
+ </Field>
1040
+
1041
+ <div className="flex gap-basalt-space-lg">
1042
+ <Button type="reset" variant="secondary">
1043
+ Reset
1044
+ </Button>
1045
+ <Button type="submit" variant="default">
1046
+ Save Changes
1047
+ </Button>
1048
+ </div>
1049
+ </form>
1050
+ );
1063
1051
  }
1064
1052
  ```
1065
1053
 
@@ -1072,18 +1060,18 @@ import { useState } from "react";
1072
1060
  import { DatePicker } from "@nocoo/basalt/components/date-picker";
1073
1061
 
1074
1062
  export function ControlledDatePickerField() {
1075
- const [selectedDate, setSelectedDate] = useState<string>("2026-09-01");
1076
- const [activeMonth, setActiveMonth] = useState<string>("2026-09");
1077
-
1078
- return (
1079
- <DatePicker
1080
- value={selectedDate}
1081
- onChange={setSelectedDate}
1082
- month={activeMonth}
1083
- onMonthChange={setActiveMonth}
1084
- aria-label="Target Date"
1085
- />
1086
- );
1063
+ const [selectedDate, setSelectedDate] = useState<string>("2026-09-01");
1064
+ const [activeMonth, setActiveMonth] = useState<string>("2026-09");
1065
+
1066
+ return (
1067
+ <DatePicker
1068
+ value={selectedDate}
1069
+ onChange={setSelectedDate}
1070
+ month={activeMonth}
1071
+ onMonthChange={setActiveMonth}
1072
+ aria-label="Target Date"
1073
+ />
1074
+ );
1087
1075
  }
1088
1076
  ```
1089
1077
 
@@ -1111,21 +1099,20 @@ For internationalization, `locale` formats month titles and trigger values, whil
1111
1099
  import { DatePicker } from "@nocoo/basalt/components/date-picker";
1112
1100
 
1113
1101
  export function LocalizedDatePickerExample() {
1114
- return (
1115
- <DatePicker
1116
- locale="fr-FR"
1117
- weekStartsOn={1}
1118
- labels={{
1119
- calendar: "Calendrier des dates",
1120
- previousMonth: "Mois précédent",
1121
- nextMonth: "Mois suivant",
1122
- placeholder: "Sélectionnez une date",
1123
- validationMessage: "Veuillez sélectionner une date valide.",
1124
- keyboardInstructions:
1125
- "Utilisez les flèches pour naviguer et Entrée pour valider.",
1126
- }}
1127
- />
1128
- );
1102
+ return (
1103
+ <DatePicker
1104
+ locale="fr-FR"
1105
+ weekStartsOn={1}
1106
+ labels={{
1107
+ calendar: "Calendrier des dates",
1108
+ previousMonth: "Mois précédent",
1109
+ nextMonth: "Mois suivant",
1110
+ placeholder: "Sélectionnez une date",
1111
+ validationMessage: "Veuillez sélectionner une date valide.",
1112
+ keyboardInstructions: "Utilisez les flèches pour naviguer et Entrée pour valider.",
1113
+ }}
1114
+ />
1115
+ );
1129
1116
  }
1130
1117
  ```
1131
1118
 
@@ -1140,29 +1127,29 @@ import { Empty } from "@nocoo/basalt/components/empty";
1140
1127
  import { LayerCard } from "@nocoo/basalt/components/layer-card";
1141
1128
 
1142
1129
  export function EmptyActionExample() {
1143
- const [created, setCreated] = useState(false);
1144
-
1145
- return (
1146
- <LayerCard className="max-w-md">
1147
- {created ? (
1148
- <p className="text-sm text-basalt-foreground">Project created successfully.</p>
1149
- ) : (
1150
- <Empty
1151
- title="No projects found"
1152
- description="You haven't created any workspace projects yet."
1153
- action={
1154
- <Button size="sm" onClick={() => setCreated(true)}>
1155
- Create project
1156
- </Button>
1157
- }
1158
- >
1159
- <span className="text-xs text-basalt-muted-foreground">
1160
- Get started by launching a new workspace.
1161
- </span>
1162
- </Empty>
1163
- )}
1164
- </LayerCard>
1165
- );
1130
+ const [created, setCreated] = useState(false);
1131
+
1132
+ return (
1133
+ <LayerCard className="max-w-md">
1134
+ {created ? (
1135
+ <p className="text-sm text-basalt-foreground">Project created successfully.</p>
1136
+ ) : (
1137
+ <Empty
1138
+ title="No projects found"
1139
+ description="You haven't created any workspace projects yet."
1140
+ action={
1141
+ <Button size="sm" onClick={() => setCreated(true)}>
1142
+ Create project
1143
+ </Button>
1144
+ }
1145
+ >
1146
+ <span className="text-xs text-basalt-muted-foreground">
1147
+ Get started by launching a new workspace.
1148
+ </span>
1149
+ </Empty>
1150
+ )}
1151
+ </LayerCard>
1152
+ );
1166
1153
  }
1167
1154
  ```
1168
1155
 
@@ -1177,99 +1164,99 @@ import { Button, Field, Input } from "@nocoo/basalt";
1177
1164
  import { DatePicker } from "@nocoo/basalt/components/date-picker";
1178
1165
 
1179
1166
  interface ProjectFormValues {
1180
- projectName: string;
1181
- startDate: string;
1167
+ projectName: string;
1168
+ startDate: string;
1182
1169
  }
1183
1170
 
1184
1171
  const defaultValues: ProjectFormValues = {
1185
- projectName: "",
1186
- startDate: "2026-09-01",
1172
+ projectName: "",
1173
+ startDate: "2026-09-01",
1187
1174
  };
1188
1175
 
1189
1176
  export function ProjectSettingsForm() {
1190
- const [submittedData, setSubmittedData] = useState<ProjectFormValues | null>(null);
1191
-
1192
- const {
1193
- register,
1194
- control,
1195
- handleSubmit,
1196
- reset,
1197
- formState: { errors, isSubmitting },
1198
- } = useForm<ProjectFormValues>({
1199
- defaultValues,
1200
- });
1201
-
1202
- const onSubmit = (values: ProjectFormValues) => {
1203
- setSubmittedData(values);
1204
- };
1205
-
1206
- const handleControlledReset = () => {
1207
- reset(defaultValues);
1208
- setSubmittedData(null);
1209
- };
1210
-
1211
- return (
1212
- <form onSubmit={handleSubmit(onSubmit)} className="space-y-basalt-layout">
1213
- <Field
1214
- label="Project Name"
1215
- hint="Unique name for your project workspace"
1216
- error={errors.projectName?.message}
1217
- >
1218
- <Input
1219
- {...register("projectName", {
1220
- required: "Project name is required",
1221
- minLength: { value: 3, message: "Minimum 3 characters required" },
1222
- })}
1223
- placeholder="e.g. Acme Dashboard"
1224
- aria-label="Project Name"
1225
- />
1226
- </Field>
1227
-
1228
- <Controller
1229
- name="startDate"
1230
- control={control}
1231
- rules={{ required: "Start date is required" }}
1232
- render={({ field }) => (
1233
- <Field
1234
- label="Start Date"
1235
- hint="Initial project milestone date (YYYY-MM-DD)"
1236
- error={errors.startDate?.message}
1237
- >
1238
- <DatePicker
1239
- name={field.name}
1240
- value={field.value}
1241
- onChange={field.onChange}
1242
- onBlur={field.onBlur}
1243
- aria-label="Start Date"
1244
- />
1245
- </Field>
1246
- )}
1247
- />
1248
-
1249
- {submittedData && (
1250
- <div data-testid="submission-output" className="text-sm font-mono p-basalt-space-lg border rounded">
1251
- Submitted: {JSON.stringify(submittedData)}
1252
- </div>
1253
- )}
1254
-
1255
- <div className="flex gap-basalt-space-lg">
1256
- <Button
1257
- type="button"
1258
- variant="secondary"
1259
- onClick={handleControlledReset}
1260
- >
1261
- Reset
1262
- </Button>
1263
- <Button type="submit" variant="default" disabled={isSubmitting}>
1264
- Save Project
1265
- </Button>
1266
- </div>
1267
- </form>
1268
- );
1177
+ const [submittedData, setSubmittedData] = useState<ProjectFormValues | null>(null);
1178
+
1179
+ const {
1180
+ register,
1181
+ control,
1182
+ handleSubmit,
1183
+ reset,
1184
+ formState: { errors, isSubmitting },
1185
+ } = useForm<ProjectFormValues>({
1186
+ defaultValues,
1187
+ });
1188
+
1189
+ const onSubmit = (values: ProjectFormValues) => {
1190
+ setSubmittedData(values);
1191
+ };
1192
+
1193
+ const handleControlledReset = () => {
1194
+ reset(defaultValues);
1195
+ setSubmittedData(null);
1196
+ };
1197
+
1198
+ return (
1199
+ <form onSubmit={handleSubmit(onSubmit)} className="space-y-basalt-layout">
1200
+ <Field
1201
+ label="Project Name"
1202
+ hint="Unique name for your project workspace"
1203
+ error={errors.projectName?.message}
1204
+ >
1205
+ <Input
1206
+ {...register("projectName", {
1207
+ required: "Project name is required",
1208
+ minLength: { value: 3, message: "Minimum 3 characters required" },
1209
+ })}
1210
+ placeholder="e.g. Acme Dashboard"
1211
+ aria-label="Project Name"
1212
+ />
1213
+ </Field>
1214
+
1215
+ <Controller
1216
+ name="startDate"
1217
+ control={control}
1218
+ rules={{ required: "Start date is required" }}
1219
+ render={({ field }) => (
1220
+ <Field
1221
+ label="Start Date"
1222
+ hint="Initial project milestone date (YYYY-MM-DD)"
1223
+ error={errors.startDate?.message}
1224
+ >
1225
+ <DatePicker
1226
+ name={field.name}
1227
+ value={field.value}
1228
+ onChange={field.onChange}
1229
+ onBlur={field.onBlur}
1230
+ aria-label="Start Date"
1231
+ />
1232
+ </Field>
1233
+ )}
1234
+ />
1235
+
1236
+ {submittedData && (
1237
+ <div
1238
+ data-testid="submission-output"
1239
+ className="text-sm font-mono p-basalt-space-lg border rounded"
1240
+ >
1241
+ Submitted: {JSON.stringify(submittedData)}
1242
+ </div>
1243
+ )}
1244
+
1245
+ <div className="flex gap-basalt-space-lg">
1246
+ <Button type="button" variant="secondary" onClick={handleControlledReset}>
1247
+ Reset
1248
+ </Button>
1249
+ <Button type="submit" variant="default" disabled={isSubmitting}>
1250
+ Save Project
1251
+ </Button>
1252
+ </div>
1253
+ </form>
1254
+ );
1269
1255
  }
1270
1256
  ```
1271
1257
 
1272
1258
  ### Known Form Limitations
1259
+
1273
1260
  1. `DatePicker` external ref merging & focus target: forwarding a custom `ref` correctly targets the underlying native `input` element with full object/callback/React 19 cleanup support and preserves native form association and reset behavior; note that the forwarded `ref` points to the native input element (not the visual trigger button).
1274
1261
  2. `Field` composition with Form Library Controllers: `Field` clones its immediate child element to inject accessibility IDs (`id`, `aria-describedby`). When wrapping controls with an abstraction like `react-hook-form`'s `<Controller>`, render `<Field>` **inside** the `Controller`'s `render` prop (wrapping the actual input control) rather than nesting `Controller` inside `Field`.
1275
1262
  3. `Autocomplete` free-text commit on blur: unselected free text and matching suggestions commit on blur without reclaiming focus, preserving natural Tab / Shift+Tab navigation and pointer departures.
@@ -1282,11 +1269,13 @@ export function ProjectSettingsForm() {
1282
1269
  ### Migrating to `@nocoo/basalt`
1283
1270
 
1284
1271
  1. **Step 1: Install Package & Peer Dependencies**
1272
+
1285
1273
  ```bash
1286
1274
  bun add @nocoo/basalt lucide-react
1287
1275
  ```
1288
1276
  2. **Step 2: Configure Stylesheet & Scan Sources**
1289
1277
  - In Tailwind v4 setups, configure CSS imports with `@source` scanning `dist`:
1278
+
1290
1279
  ```css
1291
1280
  @source "../node_modules/@nocoo/basalt/dist/**/*.{js,jsx,ts,tsx}";
1292
1281
  @import "@nocoo/basalt/styles/tailwind";
@@ -1305,6 +1294,7 @@ export function ProjectSettingsForm() {
1305
1294
  5. **Step 5: Verify Contrast, Surface Tokens & Form Handling**
1306
1295
  - Verify all content cards use `LayerCard` or surface classes rather than manual border/background combinations.
1307
1296
  - For composite forms (`DatePicker`, `Autocomplete`), implement controlled state or wrap in form library Controllers (e.g. `react-hook-form`).
1297
+
1308
1298
  ---
1309
1299
 
1310
1300
  ## 18. Chart Subsystem Primitives
@@ -1508,37 +1498,37 @@ import { Button, Input, LayerCard, ThemeProvider, ThemeToggle } from "@nocoo/bas
1508
1498
  import { PageHeader } from "@nocoo/basalt/components/page-header";
1509
1499
 
1510
1500
  export function StandaloneViteApp() {
1511
- const [query, setQuery] = useState("");
1512
-
1513
- return (
1514
- <ThemeProvider>
1515
- <div className="min-h-screen bg-basalt-background text-basalt-foreground p-basalt-space-lg space-y-basalt-space-lg">
1516
- <PageHeader
1517
- title="Standalone Workspace"
1518
- description="Rendered with @nocoo/basalt/styles/standalone without Tailwind preflight"
1519
- actions={<ThemeToggle aria-label="Toggle visual theme" />}
1520
- />
1521
- <LayerCard>
1522
- <LayerCard.Header>
1523
- <span className="font-semibold text-basalt-foreground">Project Search</span>
1524
- </LayerCard.Header>
1525
- <LayerCard.Body>
1526
- <div className="flex gap-basalt-space-lg max-w-md">
1527
- <Input
1528
- value={query}
1529
- onChange={(e) => setQuery(e.target.value)}
1530
- placeholder="Search resources..."
1531
- aria-label="Search resources"
1532
- />
1533
- <Button variant="default" onClick={() => console.log(query)}>
1534
- Search
1535
- </Button>
1536
- </div>
1537
- </LayerCard.Body>
1538
- </LayerCard>
1539
- </div>
1540
- </ThemeProvider>
1541
- );
1501
+ const [query, setQuery] = useState("");
1502
+
1503
+ return (
1504
+ <ThemeProvider>
1505
+ <div className="min-h-screen bg-basalt-background text-basalt-foreground p-basalt-space-lg space-y-basalt-space-lg">
1506
+ <PageHeader
1507
+ title="Standalone Workspace"
1508
+ description="Rendered with @nocoo/basalt/styles/standalone without Tailwind preflight"
1509
+ actions={<ThemeToggle aria-label="Toggle visual theme" />}
1510
+ />
1511
+ <LayerCard>
1512
+ <LayerCard.Header>
1513
+ <span className="font-semibold text-basalt-foreground">Project Search</span>
1514
+ </LayerCard.Header>
1515
+ <LayerCard.Body>
1516
+ <div className="flex gap-basalt-space-lg max-w-md">
1517
+ <Input
1518
+ value={query}
1519
+ onChange={(e) => setQuery(e.target.value)}
1520
+ placeholder="Search resources..."
1521
+ aria-label="Search resources"
1522
+ />
1523
+ <Button variant="default" onClick={() => console.log(query)}>
1524
+ Search
1525
+ </Button>
1526
+ </div>
1527
+ </LayerCard.Body>
1528
+ </LayerCard>
1529
+ </div>
1530
+ </ThemeProvider>
1531
+ );
1542
1532
  }
1543
1533
  ```
1544
1534
 
@@ -1563,25 +1553,21 @@ import "@nocoo/basalt/styles/standalone";
1563
1553
 
1564
1554
  const themeInitScript = `(function(){try{var s=localStorage.getItem("theme");var d=window.matchMedia("(prefers-color-scheme: dark)").matches;var isDark=s==="dark"||(s!=="light"&&d);document.documentElement.classList.toggle("dark",isDark);document.documentElement.classList.toggle("light",!isDark);document.documentElement.dataset.mode=isDark?"dark":"light";}catch(e){}})();`;
1565
1555
 
1566
- export default function RootLayout({
1567
- children,
1568
- }: {
1569
- children: ReactNode;
1570
- }) {
1571
- return (
1572
- <html lang="en" suppressHydrationWarning>
1573
- <head>
1574
- <script
1575
- dangerouslySetInnerHTML={{
1576
- __html: themeInitScript,
1577
- }}
1578
- />
1579
- </head>
1580
- <body className="min-h-screen bg-basalt-background text-basalt-foreground antialiased">
1581
- {children}
1582
- </body>
1583
- </html>
1584
- );
1556
+ export default function RootLayout({ children }: { children: ReactNode }) {
1557
+ return (
1558
+ <html lang="en" suppressHydrationWarning>
1559
+ <head>
1560
+ <script
1561
+ dangerouslySetInnerHTML={{
1562
+ __html: themeInitScript,
1563
+ }}
1564
+ />
1565
+ </head>
1566
+ <body className="min-h-screen bg-basalt-background text-basalt-foreground antialiased">
1567
+ {children}
1568
+ </body>
1569
+ </html>
1570
+ );
1585
1571
  }
1586
1572
  ```
1587
1573
 
@@ -1596,33 +1582,33 @@ import React, { useState } from "react";
1596
1582
  import { Button, Input, LayerCard, ThemeProvider, ThemeToggle } from "@nocoo/basalt";
1597
1583
 
1598
1584
  export default function NextClientDashboardPage() {
1599
- const [metricName, setMetricName] = useState("Daily Active Users");
1600
-
1601
- return (
1602
- <ThemeProvider>
1603
- <div className="p-basalt-space-lg space-y-basalt-space-lg">
1604
- <div className="flex justify-between items-center">
1605
- <h2 className="text-xl font-bold text-basalt-foreground">Next.js Client Dashboard</h2>
1606
- <ThemeToggle aria-label="Toggle theme mode" />
1607
- </div>
1608
- <LayerCard>
1609
- <LayerCard.Header>Metric Details</LayerCard.Header>
1610
- <LayerCard.Body>
1611
- <Input
1612
- value={metricName}
1613
- onChange={(e) => setMetricName(e.target.value)}
1614
- aria-label="Metric Name"
1615
- />
1616
- <div className="mt-basalt-space-lg">
1617
- <Button variant="outline" onClick={() => setMetricName("Default Metric")}>
1618
- Reset Metric
1619
- </Button>
1620
- </div>
1621
- </LayerCard.Body>
1622
- </LayerCard>
1623
- </div>
1624
- </ThemeProvider>
1625
- );
1585
+ const [metricName, setMetricName] = useState("Daily Active Users");
1586
+
1587
+ return (
1588
+ <ThemeProvider>
1589
+ <div className="p-basalt-space-lg space-y-basalt-space-lg">
1590
+ <div className="flex justify-between items-center">
1591
+ <h2 className="text-xl font-bold text-basalt-foreground">Next.js Client Dashboard</h2>
1592
+ <ThemeToggle aria-label="Toggle theme mode" />
1593
+ </div>
1594
+ <LayerCard>
1595
+ <LayerCard.Header>Metric Details</LayerCard.Header>
1596
+ <LayerCard.Body>
1597
+ <Input
1598
+ value={metricName}
1599
+ onChange={(e) => setMetricName(e.target.value)}
1600
+ aria-label="Metric Name"
1601
+ />
1602
+ <div className="mt-basalt-space-lg">
1603
+ <Button variant="outline" onClick={() => setMetricName("Default Metric")}>
1604
+ Reset Metric
1605
+ </Button>
1606
+ </div>
1607
+ </LayerCard.Body>
1608
+ </LayerCard>
1609
+ </div>
1610
+ </ThemeProvider>
1611
+ );
1626
1612
  }
1627
1613
  ```
1628
1614
 
@@ -1643,69 +1629,72 @@ import { useLinkComponent } from "@nocoo/basalt/providers/link";
1643
1629
 
1644
1630
  // Real router adapter mapping Basalt href/className/children to React Router Link
1645
1631
  export const ReactRouterLinkAdapter: ComponentType<{
1646
- href: string;
1647
- className?: string;
1648
- children?: ReactNode;
1632
+ href: string;
1633
+ className?: string;
1634
+ children?: ReactNode;
1649
1635
  }> = ({ href, className, children }) => {
1650
- const isExternal =
1651
- href.startsWith("http://") ||
1652
- href.startsWith("https://") ||
1653
- href.startsWith("//") ||
1654
- href.startsWith("mailto:") ||
1655
- href.startsWith("tel:");
1656
-
1657
- if (isExternal) {
1658
- return (
1659
- <a href={href} className={className} rel="noopener noreferrer" target="_blank">
1660
- {children}
1661
- </a>
1662
- );
1663
- }
1664
-
1665
- return (
1666
- <RouterLink to={href} className={className}>
1667
- {children}
1668
- </RouterLink>
1669
- );
1636
+ const isExternal =
1637
+ href.startsWith("http://") ||
1638
+ href.startsWith("https://") ||
1639
+ href.startsWith("//") ||
1640
+ href.startsWith("mailto:") ||
1641
+ href.startsWith("tel:");
1642
+
1643
+ if (isExternal) {
1644
+ return (
1645
+ <a href={href} className={className} rel="noopener noreferrer" target="_blank">
1646
+ {children}
1647
+ </a>
1648
+ );
1649
+ }
1650
+
1651
+ return (
1652
+ <RouterLink to={href} className={className}>
1653
+ {children}
1654
+ </RouterLink>
1655
+ );
1670
1656
  };
1671
1657
 
1672
1658
  export function RoutedNavigationSection() {
1673
- return (
1674
- <MemoryRouter initialEntries={["/dashboard"]}>
1675
- <LinkProvider render={ReactRouterLinkAdapter}>
1676
- <nav className="flex gap-basalt-space-lg p-basalt-space-lg border-b border-basalt-border">
1677
- <NavigationItem href="/dashboard">Dashboard</NavigationItem>
1678
- <NavigationItem href="/settings">Settings</NavigationItem>
1679
- <NavigationItem href="https://docs.hexly.ai">External Docs</NavigationItem>
1680
- </nav>
1681
- <div className="p-basalt-space-lg">
1682
- <Routes>
1683
- <Route path="/dashboard" element={<DashboardPage />} />
1684
- <Route path="/settings" element={<SettingsPage />} />
1685
- </Routes>
1686
- </div>
1687
- </LinkProvider>
1688
- </MemoryRouter>
1689
- );
1659
+ return (
1660
+ <MemoryRouter initialEntries={["/dashboard"]}>
1661
+ <LinkProvider render={ReactRouterLinkAdapter}>
1662
+ <nav className="flex gap-basalt-space-lg p-basalt-space-lg border-b border-basalt-border">
1663
+ <NavigationItem href="/dashboard">Dashboard</NavigationItem>
1664
+ <NavigationItem href="/settings">Settings</NavigationItem>
1665
+ <NavigationItem href="https://docs.hexly.ai">External Docs</NavigationItem>
1666
+ </nav>
1667
+ <div className="p-basalt-space-lg">
1668
+ <Routes>
1669
+ <Route path="/dashboard" element={<DashboardPage />} />
1670
+ <Route path="/settings" element={<SettingsPage />} />
1671
+ </Routes>
1672
+ </div>
1673
+ </LinkProvider>
1674
+ </MemoryRouter>
1675
+ );
1690
1676
  }
1691
1677
 
1692
1678
  function NavigationItem({ href, children }: { href: string; children: ReactNode }) {
1693
- const LinkComponent = useLinkComponent();
1694
- return (
1695
- <LinkComponent href={href} className="text-sm font-medium hover:underline text-basalt-foreground">
1696
- {children}
1697
- </LinkComponent>
1698
- );
1679
+ const LinkComponent = useLinkComponent();
1680
+ return (
1681
+ <LinkComponent
1682
+ href={href}
1683
+ className="text-sm font-medium hover:underline text-basalt-foreground"
1684
+ >
1685
+ {children}
1686
+ </LinkComponent>
1687
+ );
1699
1688
  }
1700
1689
 
1701
1690
  function DashboardPage() {
1702
- const location = useLocation();
1703
- return <div data-testid="active-route">Active Location: {location.pathname}</div>;
1691
+ const location = useLocation();
1692
+ return <div data-testid="active-route">Active Location: {location.pathname}</div>;
1704
1693
  }
1705
1694
 
1706
1695
  function SettingsPage() {
1707
- const location = useLocation();
1708
- return <div data-testid="active-route">Settings Location: {location.pathname}</div>;
1696
+ const location = useLocation();
1697
+ return <div data-testid="active-route">Settings Location: {location.pathname}</div>;
1709
1698
  }
1710
1699
  ```
1711
1700
 
@@ -1719,46 +1708,48 @@ import { useState } from "react";
1719
1708
  import { Button, Field, Input, Switch } from "@nocoo/basalt";
1720
1709
 
1721
1710
  export function UserPreferencesForm() {
1722
- const [feedback, setFeedback] = useState<string>("");
1723
-
1724
- const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
1725
- event.preventDefault();
1726
- const data = new FormData(event.currentTarget);
1727
- const username = String(data.get("username") ?? "");
1728
- const emailAlerts = data.get("emailAlerts") === "on";
1729
- setFeedback(`Saved preferences for ${username} (alerts: ${emailAlerts ? "enabled" : "disabled"})`);
1730
- };
1731
-
1732
- const handleReset = (event: React.FormEvent<HTMLFormElement>) => {
1733
- setFeedback("Form reset to default values");
1734
- };
1735
-
1736
- return (
1737
- <form onSubmit={handleSubmit} onReset={handleReset} className="space-y-basalt-layout max-w-sm">
1738
- <Field label="Username" hint="Public profile handle">
1739
- <Input name="username" defaultValue="johndoe" required />
1740
- </Field>
1741
-
1742
- <Field label="Email Alerts" hint="Receive daily system digests">
1743
- <Switch name="emailAlerts" defaultChecked />
1744
- </Field>
1745
-
1746
- {feedback && (
1747
- <div role="status" className="text-sm text-basalt-muted-foreground">
1748
- {feedback}
1749
- </div>
1750
- )}
1751
-
1752
- <div className="flex gap-basalt-space-lg">
1753
- <Button type="reset" variant="secondary">
1754
- Reset
1755
- </Button>
1756
- <Button type="submit" variant="default">
1757
- Save
1758
- </Button>
1759
- </div>
1760
- </form>
1761
- );
1711
+ const [feedback, setFeedback] = useState<string>("");
1712
+
1713
+ const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
1714
+ event.preventDefault();
1715
+ const data = new FormData(event.currentTarget);
1716
+ const username = String(data.get("username") ?? "");
1717
+ const emailAlerts = data.get("emailAlerts") === "on";
1718
+ setFeedback(
1719
+ `Saved preferences for ${username} (alerts: ${emailAlerts ? "enabled" : "disabled"})`,
1720
+ );
1721
+ };
1722
+
1723
+ const handleReset = (event: React.FormEvent<HTMLFormElement>) => {
1724
+ setFeedback("Form reset to default values");
1725
+ };
1726
+
1727
+ return (
1728
+ <form onSubmit={handleSubmit} onReset={handleReset} className="space-y-basalt-layout max-w-sm">
1729
+ <Field label="Username" hint="Public profile handle">
1730
+ <Input name="username" defaultValue="johndoe" required />
1731
+ </Field>
1732
+
1733
+ <Field label="Email Alerts" hint="Receive daily system digests">
1734
+ <Switch name="emailAlerts" defaultChecked />
1735
+ </Field>
1736
+
1737
+ {feedback && (
1738
+ <div role="status" className="text-sm text-basalt-muted-foreground">
1739
+ {feedback}
1740
+ </div>
1741
+ )}
1742
+
1743
+ <div className="flex gap-basalt-space-lg">
1744
+ <Button type="reset" variant="secondary">
1745
+ Reset
1746
+ </Button>
1747
+ <Button type="submit" variant="default">
1748
+ Save
1749
+ </Button>
1750
+ </div>
1751
+ </form>
1752
+ );
1762
1753
  }
1763
1754
  ```
1764
1755
 
@@ -1768,16 +1759,16 @@ export function UserPreferencesForm() {
1768
1759
 
1769
1760
  ```tsx excerpt:confirmation-focus-replacement
1770
1761
  <ConfirmDialog
1771
- open={confirmOpen}
1772
- onOpenChange={setConfirmOpen}
1773
- title="Remove item?"
1774
- description="This action cannot be undone."
1775
- onConfirm={removeItem}
1776
- onCloseAutoFocus={(event) => {
1777
- event.preventDefault();
1778
- currentActionRef.current?.focus();
1779
- }}
1780
- />
1762
+ open={confirmOpen}
1763
+ onOpenChange={setConfirmOpen}
1764
+ title="Remove item?"
1765
+ description="This action cannot be undone."
1766
+ onConfirm={removeItem}
1767
+ onCloseAutoFocus={(event) => {
1768
+ event.preventDefault();
1769
+ currentActionRef.current?.focus();
1770
+ }}
1771
+ />;
1781
1772
  ```
1782
1773
 
1783
1774
  ## Agent feedback
@@ -1801,19 +1792,19 @@ Loader uses eight perimeter cells with an empty center, moving clockwise continu
1801
1792
 
1802
1793
  Basalt geometry no longer depends on a host's Tailwind `--spacing`. Both CSS entrypoints expose the same `--basalt-space-*` and `--basalt-size-*` variables. Override semantic roles on a shared ancestor; portaled overlays inherit from their portal ancestor, so application-wide settings belong on `:root`. Theme switches do not reset dimensions.
1803
1794
 
1804
- | Role | Token | Default |
1805
- | --- | --- | --- |
1806
- | Small / default / large control | `--basalt-size-control-sm`, `--basalt-size-control`, `--basalt-size-control-lg` | 28 / 34 / 40px |
1807
- | Compact / standard / display icon | `--basalt-size-icon-sm`, `--basalt-size-icon`, `--basalt-size-icon-lg` | 12 / 14 / 16px |
1808
- | Nested action | `--basalt-size-control-inset` | 28px |
1809
- | Minimum touch target | `--basalt-size-touch` | 44px |
1810
- | Menu option | `--basalt-size-menu-row`, `--basalt-space-menu-inset`, `--basalt-space-menu-x`, `--basalt-space-menu-y` | 34px / 8px / 8px / 6px |
1811
- | Card padding | `--basalt-space-card-sm`, `--basalt-space-card`, `--basalt-space-card-lg`, `--basalt-space-card-xl` | 12 / 16 / 24 / 32px; default 16px |
1812
- | Layout spacing | `--basalt-space-layout-sm`, `--basalt-space-layout`, `--basalt-space-layout-lg`, `--basalt-space-layout-xl` | 12 / 16 / 24 / 32px; grids 16px, page sections 24px |
1813
- | Field label gap | `--basalt-space-field-gap` | 8px |
1814
- | Textarea minimum height | `--basalt-size-textarea-sm`, `--basalt-size-textarea`, `--basalt-size-textarea-lg` | 64 / 80 / 96px |
1815
- | Table row / horizontal / vertical inset | `--basalt-size-table-row`, `--basalt-space-table-x`, `--basalt-space-table-y` | 36 / 8 / 6px |
1816
- | Attached table outer edge | `--basalt-space-table-edge` | Panel horizontal inset, 16px |
1795
+ | Role | Token | Default |
1796
+ | --------------------------------------- | ----------------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
1797
+ | Small / default / large control | `--basalt-size-control-sm`, `--basalt-size-control`, `--basalt-size-control-lg` | 28 / 34 / 40px |
1798
+ | Compact / standard / display icon | `--basalt-size-icon-sm`, `--basalt-size-icon`, `--basalt-size-icon-lg` | 12 / 14 / 16px |
1799
+ | Nested action | `--basalt-size-control-inset` | 28px |
1800
+ | Minimum touch target | `--basalt-size-touch` | 44px |
1801
+ | Menu option | `--basalt-size-menu-row`, `--basalt-space-menu-inset`, `--basalt-space-menu-x`, `--basalt-space-menu-y` | 34px / 8px / 8px / 6px |
1802
+ | Card padding | `--basalt-space-card-sm`, `--basalt-space-card`, `--basalt-space-card-lg`, `--basalt-space-card-xl` | 12 / 16 / 24 / 32px; default 16px |
1803
+ | Layout spacing | `--basalt-space-layout-sm`, `--basalt-space-layout`, `--basalt-space-layout-lg`, `--basalt-space-layout-xl` | 12 / 16 / 24 / 32px; grids 16px, page sections 24px |
1804
+ | Field label gap | `--basalt-space-field-gap` | 8px |
1805
+ | Textarea minimum height | `--basalt-size-textarea-sm`, `--basalt-size-textarea`, `--basalt-size-textarea-lg` | 64 / 80 / 96px |
1806
+ | Table row / horizontal / vertical inset | `--basalt-size-table-row`, `--basalt-space-table-x`, `--basalt-space-table-y` | 36 / 8 / 6px |
1807
+ | Attached table outer edge | `--basalt-space-table-edge` | Panel horizontal inset, 16px |
1817
1808
 
1818
1809
  Defaults use rem and unitless line-height, preserving browser font-size preferences. [DESIGN.md](DESIGN.md) defines compact 2/4/6/8px control spacing, separate 12/16/24/32px card and layout spacing, and natural 22/34/46px inline/action/banner categories. The size tokens describe reference geometry, not fixed text-control heights. Use the semantic spacing role for the boundary being laid out; numbered geometry tokens are not padding alternatives. LayerCard accepts `padding="sm|md|lg|xl"` for unstructured content; Grid accepts `gap="sm|md|lg|xl"`. Both default to `md`; 32px `xl` is opt-in. ContentIsland owns a 16px mobile, 24px tablet, and 32px horizontal / 24px vertical desktop inset. Examples must use component size props rather than fixed-height overrides. Textareas, avatars and panels retain content-specific geometry.
1819
1810
 
@@ -1847,23 +1838,26 @@ import { LayerCard } from "@nocoo/basalt/components/layer-card";
1847
1838
 
1848
1839
  type Invoice = { id: string; amount: number };
1849
1840
  const columns: DataTableColumn<Invoice>[] = [
1850
- { id: "id", header: "Invoice", accessor: (row) => row.id },
1851
- { id: "amount", header: "Amount", accessor: (row) => row.amount, textAlign: "end" },
1841
+ { id: "id", header: "Invoice", accessor: (row) => row.id },
1842
+ { id: "amount", header: "Amount", accessor: (row) => row.amount, textAlign: "end" },
1852
1843
  ];
1853
1844
 
1854
1845
  export function InvoicePanel() {
1855
- return (
1856
- <LayerCard>
1857
- <LayerCard.Header>Invoices</LayerCard.Header>
1858
- <DataTable
1859
- aria-label="Invoices"
1860
- edge="attached"
1861
- columns={columns}
1862
- data={[{ id: "INV-01", amount: 1240 }, { id: "INV-02", amount: 80 }]}
1863
- getRowId={(row) => row.id}
1864
- />
1865
- </LayerCard>
1866
- );
1846
+ return (
1847
+ <LayerCard>
1848
+ <LayerCard.Header>Invoices</LayerCard.Header>
1849
+ <DataTable
1850
+ aria-label="Invoices"
1851
+ edge="attached"
1852
+ columns={columns}
1853
+ data={[
1854
+ { id: "INV-01", amount: 1240 },
1855
+ { id: "INV-02", amount: 80 },
1856
+ ]}
1857
+ getRowId={(row) => row.id}
1858
+ />
1859
+ </LayerCard>
1860
+ );
1867
1861
  }
1868
1862
  ```
1869
1863