@nocoo/basalt 3.2.0 → 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.
- package/README.md +25 -19
- package/ai/COMPATIBILITY.md +41 -36
- package/ai/DESIGN.md +73 -56
- package/ai/INTEGRATION.md +819 -825
- package/ai/RECIPES.md +334 -135
- package/ai/USAGE.md +2 -2
- package/ai/registry.json +454 -97
- package/ai/sources.json +2 -2
- package/dist/assets/brand-mark.js +1 -2
- package/dist/charts/area.js +1 -2
- package/dist/charts/bar.js +1 -2
- package/dist/charts/bullet.js +1 -2
- package/dist/charts/chart-colors.js +1 -2
- package/dist/charts/charts.js +1 -2
- package/dist/charts/config.js +1 -2
- package/dist/charts/custom-chart.js +1 -2
- package/dist/charts/date-navigation.js +1 -2
- package/dist/charts/donut.js +1 -2
- package/dist/charts/frame.js +3 -4
- package/dist/charts/frame.js.map +1 -1
- package/dist/charts/funnel.js +1 -2
- package/dist/charts/gauge.js +1 -2
- package/dist/charts/grouped-bar.js +1 -2
- package/dist/charts/heatmap-calendar.js +7 -11
- package/dist/charts/heatmap-calendar.js.map +1 -1
- package/dist/charts/heatmap-matrix.js +4 -5
- package/dist/charts/heatmap-matrix.js.map +1 -1
- package/dist/charts/item-list.js +1 -2
- package/dist/charts/legend.js +1 -2
- package/dist/charts/line.js +1 -2
- package/dist/charts/palette.js +1 -2
- package/dist/charts/radar.js +1 -2
- package/dist/charts/sample.js +1 -2
- package/dist/charts/sankey.js +1 -2
- package/dist/charts/series.js +1 -2
- package/dist/charts/slot-bar.js +1 -2
- package/dist/charts/sparkline.js +1 -2
- package/dist/charts/stacked-bar.js +1 -2
- package/dist/charts/stat-card.js +1 -2
- package/dist/charts/timeline.js +1 -2
- package/dist/charts/timeseries.js +1 -2
- package/dist/charts/tooltip.js +31 -27
- package/dist/charts/tooltip.js.map +1 -1
- package/dist/components/accordion.js +1 -2
- package/dist/components/alert-dialog.js +1 -2
- package/dist/components/app-header.js +2 -3
- package/dist/components/app-header.js.map +1 -1
- package/dist/components/app-shell.js +2 -3
- package/dist/components/app-shell.js.map +1 -1
- package/dist/components/approval-card.js +1 -2
- package/dist/components/autocomplete.js +1 -2
- package/dist/components/avatar.js +1 -2
- package/dist/components/badge.js +1 -2
- package/dist/components/banner.js +1 -2
- package/dist/components/basalt-mark.js +1 -2
- package/dist/components/battery-meter.js +1 -2
- package/dist/components/breadcrumbs.js +1 -2
- package/dist/components/button.d.ts +1 -1
- package/dist/components/button.js +3 -3
- package/dist/components/button.js.map +1 -1
- package/dist/components/chat-bubble.js +1 -2
- package/dist/components/chat-composer.js +2 -3
- package/dist/components/chat-composer.js.map +1 -1
- package/dist/components/chat-header.js +1 -2
- package/dist/components/chat-inbox.js +1 -2
- package/dist/components/chat-markdown.js +1 -2
- package/dist/components/chat-message.js +2 -3
- package/dist/components/chat-message.js.map +1 -1
- package/dist/components/checkbox.js +1 -2
- package/dist/components/clipboard-text.js +1 -2
- package/dist/components/code.js +2 -3
- package/dist/components/code.js.map +1 -1
- package/dist/components/collapsible.js +1 -2
- package/dist/components/collapsible.js.map +1 -1
- package/dist/components/combobox.js +1 -2
- package/dist/components/command-palette.js +3 -4
- package/dist/components/command-palette.js.map +1 -1
- package/dist/components/confirm-dialog.js +3 -12
- package/dist/components/confirm-dialog.js.map +1 -1
- package/dist/components/context-cards.js +2 -3
- package/dist/components/context-cards.js.map +1 -1
- package/dist/components/context-menu.js +2 -3
- package/dist/components/context-menu.js.map +1 -1
- package/dist/components/data-table.js +129 -131
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +7 -18
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/delete-resource.js +1 -2
- package/dist/components/description-list.js +1 -2
- package/dist/components/dialog.js +1 -2
- package/dist/components/diff-table.js +2 -3
- package/dist/components/diff-table.js.map +1 -1
- package/dist/components/dock.js +1 -2
- package/dist/components/dock.js.map +1 -1
- package/dist/components/dropdown-menu.js +2 -3
- package/dist/components/dropdown-menu.js.map +1 -1
- package/dist/components/editable-nav-item.js +3 -4
- package/dist/components/editable-nav-item.js.map +1 -1
- package/dist/components/empty.js +1 -2
- package/dist/components/fab.js +1 -2
- package/dist/components/field.js +1 -2
- package/dist/components/file-dropzone.js +2 -3
- package/dist/components/file-dropzone.js.map +1 -1
- package/dist/components/filter-bar.js +2 -3
- package/dist/components/filter-bar.js.map +1 -1
- package/dist/components/flow.js +1 -2
- package/dist/components/grid.js +1 -2
- package/dist/components/hover-card.js +1 -2
- package/dist/components/icon-picker.js +4 -5
- package/dist/components/icon-picker.js.map +1 -1
- package/dist/components/inline-editable.js +2 -3
- package/dist/components/inline-editable.js.map +1 -1
- package/dist/components/input-area.js +1 -2
- package/dist/components/input-group.js +4 -5
- package/dist/components/input-group.js.map +1 -1
- package/dist/components/input.js +1 -2
- package/dist/components/label.js +1 -2
- package/dist/components/layer-card.js +3 -3
- package/dist/components/layer-card.js.map +1 -1
- package/dist/components/link.js +2 -3
- package/dist/components/link.js.map +1 -1
- package/dist/components/loader.js +1 -2
- package/dist/components/loading-screen.js +1 -2
- package/dist/components/menu-bar.js +3 -4
- package/dist/components/menu-bar.js.map +1 -1
- package/dist/components/meter.js +1 -2
- package/dist/components/multi-select.js +2 -3
- package/dist/components/multi-select.js.map +1 -1
- package/dist/components/navigation-menu.js +1 -2
- package/dist/components/overlay.d.ts +3 -6
- package/dist/components/overlay.js +4 -5
- package/dist/components/overlay.js.map +1 -1
- package/dist/components/page-header.js +1 -2
- package/dist/components/pagination.js +1 -2
- package/dist/components/popover.js +1 -2
- package/dist/components/prompt-bar.js +1 -2
- package/dist/components/radio.js +2 -7
- package/dist/components/radio.js.map +1 -1
- package/dist/components/recommendation-card.js +1 -2
- package/dist/components/resource-list.js +1 -2
- package/dist/components/responsive-master-detail.js +3 -4
- package/dist/components/responsive-master-detail.js.map +1 -1
- package/dist/components/scroll-area.js +2 -3
- package/dist/components/scroll-area.js.map +1 -1
- package/dist/components/section-rule.js +1 -2
- package/dist/components/segment-control.js +2 -3
- package/dist/components/segment-control.js.map +1 -1
- package/dist/components/select.js +2 -3
- package/dist/components/select.js.map +1 -1
- package/dist/components/sensitive-input.js +2 -3
- package/dist/components/sensitive-input.js.map +1 -1
- package/dist/components/separator.js +1 -2
- package/dist/components/sheet.js +1 -2
- package/dist/components/sidebar.d.ts +53 -19
- package/dist/components/sidebar.js +201 -100
- package/dist/components/sidebar.js.map +1 -1
- package/dist/components/skeleton-line.js +1 -2
- package/dist/components/slider.js +1 -2
- package/dist/components/stat-strip.js +1 -2
- package/dist/components/switch.js +1 -2
- package/dist/components/table-of-contents.js +1 -2
- package/dist/components/table-pager.js +1 -2
- package/dist/components/table.js +2 -3
- package/dist/components/table.js.map +1 -1
- package/dist/components/tabs.js +2 -3
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/tag-badge.js +1 -2
- package/dist/components/tag-color-picker.js +3 -4
- package/dist/components/tag-color-picker.js.map +1 -1
- package/dist/components/text.js +1 -2
- package/dist/components/theme-toggle.js +1 -2
- package/dist/components/thinking.js +1 -2
- package/dist/components/toast.js +2 -3
- package/dist/components/toast.js.map +1 -1
- package/dist/components/toggle-group.js +1 -2
- package/dist/components/toggle.js +1 -2
- package/dist/components/tool-chips.js +1 -2
- package/dist/components/toolbar.js +9 -10
- package/dist/components/toolbar.js.map +1 -1
- package/dist/components/tooltip.js +1 -2
- package/dist/components/typeahead-field.js +6 -21
- package/dist/components/typeahead-field.js.map +1 -1
- package/dist/components/upload-queue.js +1 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +11 -11
- package/dist/models/agent-feedback.js +1 -2
- package/dist/models/avatar.js +1 -2
- package/dist/models/code.js +1 -2
- package/dist/models/context-cards.js +1 -2
- package/dist/models/diff-table.js +1 -2
- package/dist/models/recommendation.js +1 -2
- package/dist/providers/accent.js +2 -7
- package/dist/providers/accent.js.map +1 -1
- package/dist/providers/link.js +1 -2
- package/dist/providers/theme.js +2 -7
- package/dist/providers/theme.js.map +1 -1
- package/dist/styles/base.css +71 -0
- package/dist/styles/standalone.css +127 -98
- package/dist/styles/tailwind.css +10 -2
- package/dist/styles/tokens.css +55 -7
- package/dist/utils/cn.js +1 -2
- package/dist/utils/control-surface.js +1 -2
- package/dist/utils/navigation.d.ts +2 -2
- package/dist/utils/navigation.js +2 -3
- package/dist/utils/navigation.js.map +1 -1
- package/dist/utils/preference-store.js +1 -2
- package/dist/utils/selection-indicator.js +1 -2
- package/dist/utils/use-checkable-group.js +1 -2
- package/dist/utils/use-hover-highlight.js +2 -3
- package/dist/utils/use-hover-highlight.js.map +1 -1
- package/dist/viewmodels/use-approval-card.js +11 -13
- package/dist/viewmodels/use-approval-card.js.map +1 -1
- package/dist/viewmodels/use-chat-composer.js +1 -2
- package/dist/viewmodels/use-chat-message.js +1 -2
- package/dist/viewmodels/use-code-block.js +1 -2
- package/dist/viewmodels/use-code-block.js.map +1 -1
- package/dist/viewmodels/use-context-cards.js +1 -2
- package/dist/viewmodels/use-diff-table.js +1 -2
- package/dist/viewmodels/use-diff-table.js.map +1 -1
- package/dist/viewmodels/use-loader.js +1 -2
- package/dist/viewmodels/use-recommendation-card.js +1 -2
- package/dist/viewmodels/use-recommendation-card.js.map +1 -1
- package/dist/viewmodels/use-thinking.js +1 -2
- package/dist/viewmodels/use-tool-chips.js +1 -2
- 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
|
|
27
|
-
|
|
28
|
-
| Landing
|
|
29
|
-
| Login
|
|
30
|
-
| Rail
|
|
31
|
-
| Framework bar | `AppHeader`
|
|
32
|
-
| Island
|
|
33
|
-
| Page heading
|
|
34
|
-
| Region
|
|
35
|
-
| Card
|
|
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
|
|
|
@@ -45,7 +45,7 @@ AppShell ← dashboard and catalog routes
|
|
|
45
45
|
├── Sidebar ← product title + version + nav
|
|
46
46
|
└── AppMain ← id="main-content"
|
|
47
47
|
├── AppHeader ← crumbs + current page | top-right
|
|
48
|
-
└── island wrap ← px-basalt-space-lg pb-basalt-space-lg md:px-basalt-
|
|
48
|
+
└── island wrap ← px-basalt-space-lg pb-basalt-space-lg md:px-basalt-layout-sm md:pb-basalt-layout-sm
|
|
49
49
|
└── ContentIsland
|
|
50
50
|
├── PageHeader ← title + subtitle | create / short filters
|
|
51
51
|
│ ← optional own-row complex filters
|
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
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
|
-
|
|
140
|
+
stored = window.localStorage.getItem("theme");
|
|
141
141
|
} catch {
|
|
142
|
-
|
|
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
|
|
158
|
-
|
|
159
|
-
| `@nocoo/basalt`
|
|
160
|
-
| `@nocoo/basalt/components/app-shell`
|
|
161
|
-
| `@nocoo/basalt/components/app-header`
|
|
162
|
-
| `@nocoo/basalt/components/page-header`
|
|
163
|
-
| `@nocoo/basalt/components/section-rule`
|
|
164
|
-
| `@nocoo/basalt/components/loading-screen` | `LoadingScreen`
|
|
165
|
-
| `@nocoo/basalt/components/basalt-mark`
|
|
166
|
-
| `@nocoo/basalt/providers/theme`
|
|
167
|
-
| `@nocoo/basalt/charts/*`
|
|
168
|
-
| `@nocoo/basalt/components/date-picker`
|
|
169
|
-
| `@nocoo/basalt/components/data-table`
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
|
206
|
-
|
|
207
|
-
| `children`
|
|
208
|
-
| `storageKey`
|
|
209
|
-
| `defaultTheme`
|
|
210
|
-
| `persist`
|
|
211
|
-
| `theme`
|
|
212
|
-
| `onThemeChange`
|
|
213
|
-
| `applyToDocument` | `boolean`
|
|
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
|
|
229
|
-
|
|
230
|
-
| `children`
|
|
231
|
-
| `storageKey`
|
|
232
|
-
| `defaultAccent`
|
|
233
|
-
| `persist`
|
|
234
|
-
| `accent`
|
|
235
|
-
| `onAccentChange`
|
|
236
|
-
| `applyToDocument`
|
|
237
|
-
| `paletteOverrides` | `Readonly<Record<string, { light: string; dark: string }>>` | `undefined`
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
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`
|
|
373
|
-
|
|
|
374
|
-
| `workspace` (default) | Bounded panes
|
|
375
|
-
| `responsive`
|
|
376
|
-
| `document`
|
|
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
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
</
|
|
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`
|
|
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` and `
|
|
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
|
-
<
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
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
|
-
|
|
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`.
|
|
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
|
-
<
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
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`
|
|
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
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
|
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
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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,68 +585,81 @@ export function useIsMobile() {
|
|
|
614
585
|
|
|
615
586
|
```tsx excerpt:app-frame-component
|
|
616
587
|
export function AppFrame() {
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
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
|
|
|
676
660
|
<a id="appheader-and-breadcrumbs"></a>
|
|
677
661
|
|
|
678
|
-
`AppHeader` defaults to `h-14`, matching `SidebarHeader`; `density="compact"` uses a 52px row with 44px minimum targets. `sticky` keeps it visible within the current scroll owner; see §6 for safe-area positioning.
|
|
662
|
+
`AppHeader` defaults to `h-14`, matching `SidebarHeader`; `density="compact"` uses a 52px row with 44px minimum targets. Its horizontal inset is 8px on mobile and 12px from 768px, aligning with the outer ContentIsland wrapper rather than the island's inner card edge. `sticky` keeps it visible within the current scroll owner; see §6 for safe-area positioning.
|
|
679
663
|
|
|
680
664
|
### AppHeader API & Contracts
|
|
681
665
|
|
|
@@ -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
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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
|
---
|
|
@@ -759,7 +745,7 @@ The catalog's app-local `RouteScrollReset` wraps each leaf page inside its Suspe
|
|
|
759
745
|
|
|
760
746
|
The `/forms`, `/settings`, `/data`, and `/chat` examples demonstrate local state and simulated requests, including failure/retry and cancellation. Their viewmodels own data and timers; Views own native FormData, focus, and responsive layout. Replace the local service adapter when integrating a backend. Theme selection uses the shared provider; local profile, uploads, and chat changes do not update a real account.
|
|
761
747
|
|
|
762
|
-
`PageHeader` is flush on `ContentIsland`. The island owns 16px mobile insets, 24px tablet insets, and 32px horizontal / 24px vertical desktop insets. Do not wrap the header in another card or add page padding. `PageHeader.size` is `"md" | "lg" | "xl"` (default `"md"`); larger headings use 16/24px descriptions, all title/description pairs use an 8px gap. `SectionRule variant="heading"` gives reading sections a 20px sentence-case title without the compact dashed rule. Controls and card slots keep their compact geometry.
|
|
748
|
+
`PageHeader` is flush on `ContentIsland`. The island owns 16px mobile insets, 24px tablet insets, and 32px horizontal / 24px vertical desktop insets. `AppHeader` remains aligned to the outer island boundary (8px mobile, 12px from 768px), not this inner content edge. Do not wrap the header in another card or add page padding. `PageHeader.size` is `"md" | "lg" | "xl"` (default `"md"`); larger headings use 16/24px descriptions, all title/description pairs use an 8px gap. `SectionRule variant="heading"` gives reading sections a 20px sentence-case title without the compact dashed rule. Controls and card slots keep their compact geometry.
|
|
763
749
|
|
|
764
750
|
### Create and filters
|
|
765
751
|
|
|
@@ -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
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
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
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
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
|
|
878
|
-
|
|
879
|
-
| L0
|
|
880
|
-
| L1
|
|
881
|
-
| L2
|
|
882
|
-
| L3
|
|
883
|
-
| L3+
|
|
884
|
-
| Overlay | Tooltip, Popover, Dropdown, HoverCard
|
|
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
|
-
|
|
897
|
-
|
|
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
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
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
|
-
|
|
917
|
-
|
|
918
|
-
|
|
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+.
|
|
@@ -930,7 +914,7 @@ Input, Select, secondary / outline Button use `bg-basalt-control` (`--basalt-con
|
|
|
930
914
|
|
|
931
915
|
`Table` sets `data-basalt-table`. Zebra, hover, and selected paint on `td`, from `--basalt-zebra-fill`, `--basalt-hover` and `--basalt-selected`. Selection adds no decorative side bar or frame; semantic add/remove diff tints are preserved.
|
|
932
916
|
|
|
933
|
-
Neutral selected states use the brighter `--basalt-selected` surface, readable `--basalt-selected-foreground` and navigation font weight, without decorative side bars or selected borders. Never use `accent` or `muted` to signal selection. Existing semantic checkmarks and tab underlines remain; do not add a decorative frame at the white ceiling. A moving hover layer must not erase the selected row. Sidebar, navigation menus, editable navigation and ChatInbox share this behavior and inset keyboard focus; neutral Button variants also honor `aria-pressed` and `aria-current`. Segments and tabs retain their existing measured indicator motion. Primary/destructive actions, check/radio/switch glyphs and semantic diffs keep their semantic colors. See [DESIGN.md](DESIGN.md#selection-and-navigation-hierarchy).
|
|
917
|
+
Neutral selected states use the brighter `--basalt-selected` surface, readable `--basalt-selected-foreground` and navigation font weight, without decorative side bars or selected borders. Never use `accent` or `muted` to signal selection. Existing semantic checkmarks and tab underlines remain; do not add a decorative frame at the white ceiling. A moving hover layer must not erase the selected row. Sidebar, navigation menus, editable navigation and ChatInbox share this behavior and inset keyboard focus; neutral Button variants also honor `aria-pressed` and `aria-current`. Focus is separate from selection: ordinary targets use the 1px theme `--basalt-ring` stroke with a 2px outer offset; flush targets use the same stroke inset with a 2px negative offset. External focus targets need at least 4px clearance inside their nearest clipping container; existing 4px padding counts and is not stacked. Focus does not shift layout, error indicators stay independent, and forced-colors mode may use a 2px system outline. Segments and tabs retain their existing measured indicator motion. Primary/destructive actions, check/radio/switch glyphs and semantic diffs keep their semantic colors. See [DESIGN.md](DESIGN.md#selection-and-navigation-hierarchy).
|
|
934
918
|
|
|
935
919
|
`DescriptionList` is layout and type only. It is not a surface.
|
|
936
920
|
|
|
@@ -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
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
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
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
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
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
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
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
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
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
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
|
-
|
|
1181
|
-
|
|
1167
|
+
projectName: string;
|
|
1168
|
+
startDate: string;
|
|
1182
1169
|
}
|
|
1183
1170
|
|
|
1184
1171
|
const defaultValues: ProjectFormValues = {
|
|
1185
|
-
|
|
1186
|
-
|
|
1172
|
+
projectName: "",
|
|
1173
|
+
startDate: "2026-09-01",
|
|
1187
1174
|
};
|
|
1188
1175
|
|
|
1189
1176
|
export function ProjectSettingsForm() {
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
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
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
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
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
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
|
-
|
|
1600
|
-
|
|
1601
|
-
|
|
1602
|
-
|
|
1603
|
-
|
|
1604
|
-
|
|
1605
|
-
|
|
1606
|
-
|
|
1607
|
-
|
|
1608
|
-
|
|
1609
|
-
|
|
1610
|
-
|
|
1611
|
-
|
|
1612
|
-
|
|
1613
|
-
|
|
1614
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
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
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1632
|
+
href: string;
|
|
1633
|
+
className?: string;
|
|
1634
|
+
children?: ReactNode;
|
|
1649
1635
|
}> = ({ href, className, children }) => {
|
|
1650
|
-
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
|
|
1656
|
-
|
|
1657
|
-
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
|
|
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
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
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
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
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
|
-
|
|
1703
|
-
|
|
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
|
-
|
|
1708
|
-
|
|
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
|
-
|
|
1723
|
-
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
1746
|
-
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
1758
|
-
|
|
1759
|
-
|
|
1760
|
-
|
|
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
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
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
|
|
1805
|
-
|
|
|
1806
|
-
| Small / default / large control
|
|
1807
|
-
| Compact / standard / display icon
|
|
1808
|
-
| Nested action
|
|
1809
|
-
| Minimum touch target
|
|
1810
|
-
| Menu option
|
|
1811
|
-
| Card padding
|
|
1812
|
-
| Layout spacing
|
|
1813
|
-
| Field label gap
|
|
1814
|
-
| Textarea minimum height
|
|
1815
|
-
| Table row / horizontal / vertical inset | `--basalt-size-table-row`, `--basalt-space-table-x`, `--basalt-space-table-y`
|
|
1816
|
-
| Attached table outer edge
|
|
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
|
-
|
|
1851
|
-
|
|
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
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
1858
|
-
|
|
1859
|
-
|
|
1860
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
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
|
|
|
@@ -1925,7 +1919,7 @@ Thinking and ToolChips share row padding; disclosures rotate only their own chev
|
|
|
1925
1919
|
|
|
1926
1920
|
The standalone build generates every Tailwind utility once in canonical order. Do not append handwritten copies of `.p-0`, `.bg-transparent` or other native utilities after generated axis/state rules: a later shorthand can silently erase semantic padding. Browser acceptance measures actual computed insets in both CSS entrypoints, under foreign host spacing and semantic overrides.
|
|
1927
1921
|
|
|
1928
|
-
Public Radio, Checkbox, Switch and SegmentControl group roots establish the scoped `.basalt-ui` reset. Fieldsets and legends have no browser-default padding/margin inside that boundary; standalone and Tailwind consumers must have identical group insets. Host form groups outside Basalt remain untouched.
|
|
1922
|
+
Public Radio, Checkbox, Switch and SegmentControl group roots establish the scoped `.basalt-ui` reset. Fieldsets and legends have no browser-default padding/margin inside that boundary; standalone and Tailwind consumers must have identical group insets. SegmentControl's horizontal scrolling viewport has 4px focus clearance on every edge and matching scroll-padding so keyboard navigation can reveal end items; the ToggleGroup track retains its 2px compact inset and 34px item geometry. Host form groups outside Basalt remain untouched.
|
|
1929
1923
|
|
|
1930
1924
|
Native form legends do not participate in a fieldset's flex gap. Radio, Checkbox and Switch legends therefore own `margin-bottom: var(--basalt-space-field-gap)` (8px) independently of the group's row layout. Do not replace the semantic legend or add wrapper divs to simulate this spacing. Override the existing field-gap token when a different group density is required.
|
|
1931
1925
|
|