@calliopelabs/ui 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (105) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +332 -2
  3. package/dist/appearance/AppearancePicker.d.ts +10 -0
  4. package/dist/appearance/AppearancePicker.js +75 -0
  5. package/dist/appearance/AppearanceProvider.d.ts +53 -0
  6. package/dist/appearance/AppearanceProvider.js +113 -0
  7. package/dist/appearance/AppearanceScript.d.ts +16 -0
  8. package/dist/appearance/AppearanceScript.js +14 -0
  9. package/dist/appearance/appearance.d.ts +90 -0
  10. package/dist/appearance/appearance.js +151 -0
  11. package/dist/appearance/boot.d.ts +30 -0
  12. package/dist/appearance/boot.js +91 -0
  13. package/dist/appearance/brands.d.ts +94 -0
  14. package/dist/appearance/brands.js +61 -0
  15. package/dist/appearance/product.d.ts +89 -0
  16. package/dist/appearance/product.js +96 -0
  17. package/dist/data/DataTable.d.ts +66 -0
  18. package/dist/data/DataTable.js +148 -0
  19. package/dist/data/List.d.ts +55 -0
  20. package/dist/data/List.js +41 -0
  21. package/dist/data/types.d.ts +92 -0
  22. package/dist/data/types.js +2 -0
  23. package/dist/data/use-row-selection.d.ts +17 -0
  24. package/dist/data/use-row-selection.js +51 -0
  25. package/dist/index.d.ts +50 -0
  26. package/dist/index.js +61 -0
  27. package/dist/lib/cn.d.ts +3 -0
  28. package/dist/lib/cn.js +6 -0
  29. package/dist/lib/local-store.d.ts +12 -0
  30. package/dist/lib/local-store.js +54 -0
  31. package/dist/nav/Nav.d.ts +21 -0
  32. package/dist/nav/Nav.js +97 -0
  33. package/dist/nav/model.d.ts +77 -0
  34. package/dist/nav/model.js +72 -0
  35. package/dist/overlays/ConfirmDialog.d.ts +37 -0
  36. package/dist/overlays/ConfirmDialog.js +70 -0
  37. package/dist/overlays/Dialog.d.ts +18 -0
  38. package/dist/overlays/Dialog.js +39 -0
  39. package/dist/overlays/Drawer.d.ts +24 -0
  40. package/dist/overlays/Drawer.js +42 -0
  41. package/dist/page/Breadcrumbs.d.ts +20 -0
  42. package/dist/page/Breadcrumbs.js +25 -0
  43. package/dist/page/PageFrame.d.ts +17 -0
  44. package/dist/page/PageFrame.js +14 -0
  45. package/dist/page/PageHeader.d.ts +28 -0
  46. package/dist/page/PageHeader.js +13 -0
  47. package/dist/page/Section.d.ts +18 -0
  48. package/dist/page/Section.js +12 -0
  49. package/dist/page/TabNav.d.ts +19 -0
  50. package/dist/page/TabNav.js +16 -0
  51. package/dist/primitives/Button.d.ts +16 -0
  52. package/dist/primitives/Button.js +36 -0
  53. package/dist/primitives/Checkbox.d.ts +8 -0
  54. package/dist/primitives/Checkbox.js +13 -0
  55. package/dist/primitives/DropdownMenu.d.ts +11 -0
  56. package/dist/primitives/DropdownMenu.js +25 -0
  57. package/dist/primitives/Input.d.ts +2 -0
  58. package/dist/primitives/Input.js +5 -0
  59. package/dist/primitives/Popover.d.ts +5 -0
  60. package/dist/primitives/Popover.js +13 -0
  61. package/dist/primitives/Skeleton.d.ts +3 -0
  62. package/dist/primitives/Skeleton.js +6 -0
  63. package/dist/primitives/Tabs.d.ts +12 -0
  64. package/dist/primitives/Tabs.js +22 -0
  65. package/dist/provider/UIProvider.d.ts +29 -0
  66. package/dist/provider/UIProvider.js +21 -0
  67. package/dist/provider/messages.d.ts +106 -0
  68. package/dist/provider/messages.js +104 -0
  69. package/dist/shell/AppShell.d.ts +43 -0
  70. package/dist/shell/AppShell.js +62 -0
  71. package/dist/shell/BrandMark.d.ts +14 -0
  72. package/dist/shell/BrandMark.js +14 -0
  73. package/dist/shell/RailPanel.d.ts +39 -0
  74. package/dist/shell/RailPanel.js +62 -0
  75. package/dist/shell/RailTree.d.ts +35 -0
  76. package/dist/shell/RailTree.js +32 -0
  77. package/dist/shell/Sidebar.d.ts +26 -0
  78. package/dist/shell/Sidebar.js +21 -0
  79. package/dist/shell/UserMenu.d.ts +22 -0
  80. package/dist/shell/UserMenu.js +31 -0
  81. package/dist/shell/placement.d.ts +10 -0
  82. package/dist/shell/placement.js +6 -0
  83. package/dist/shell/rail-state.d.ts +13 -0
  84. package/dist/shell/rail-state.js +28 -0
  85. package/dist/states/EmptyState.d.ts +20 -0
  86. package/dist/states/EmptyState.js +15 -0
  87. package/dist/states/ErrorState.d.ts +23 -0
  88. package/dist/states/ErrorState.js +16 -0
  89. package/dist/states/LoadingState.d.ts +14 -0
  90. package/dist/states/LoadingState.js +14 -0
  91. package/dist/status/StatusBadge.d.ts +19 -0
  92. package/dist/status/StatusBadge.js +13 -0
  93. package/dist/status/StatusDot.d.ts +14 -0
  94. package/dist/status/StatusDot.js +7 -0
  95. package/dist/status/tones.d.ts +17 -0
  96. package/dist/status/tones.js +29 -0
  97. package/dist/styles/preset.css +175 -0
  98. package/dist/styles/theme.css +339 -0
  99. package/dist/theme/contrast.d.ts +8 -0
  100. package/dist/theme/contrast.js +18 -0
  101. package/dist/theme/css.d.ts +23 -0
  102. package/dist/theme/css.js +116 -0
  103. package/dist/theme/tokens.d.ts +195 -0
  104. package/dist/theme/tokens.js +294 -0
  105. package/package.json +112 -4
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Calliope Labs Inc
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,333 @@
1
- # Temporary Holding Version
1
+ # @calliopelabs/ui
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ The shared operator UI of Calliope AI: the app shell, navigation, page frame, data table and list patterns, status colours, design tokens and appearance settings that Astrolift AI and Zentinelle AI both run on. Extracted from the Astrolift AI frontend and generalized with a per-product config, so each console keeps its own name, accent, sidebar colour and navigation while sharing everything else.
4
+
5
+ It is built Storybook first: every component and every screen pattern is built and reviewed in the catalog before an app uses it. The catalog is published at **https://calliopeai.github.io/calliope-ui/**.
6
+
7
+ - React 19 and TypeScript, shipped as per-file ESM with types. No Next-only APIs: the app hands the package its link component.
8
+ - Tailwind CSS 4: the theme tokens as CSS, plus a preset.
9
+ - Radix underneath the overlays and menus; lucide icons.
10
+
11
+ ## Install
12
+
13
+ ```sh
14
+ npm i @calliopelabs/ui
15
+ ```
16
+
17
+ Peer dependencies: `react` and `react-dom` 19, and `tailwindcss` 4 for styling.
18
+
19
+ ## Styles
20
+
21
+ In the app's global stylesheet, after Tailwind:
22
+
23
+ ```css
24
+ @import "tailwindcss";
25
+ @import "@calliopelabs/ui/preset.css";
26
+ ```
27
+
28
+ The preset brings the theme tokens (`@calliopelabs/ui/theme.css`), maps them onto Tailwind's colour, radius, font, type and motion scales (`bg-primary`, `text-success-fg`, `bg-sidebar`, `rounded-lg`, `text-2xs`, `font-mono`), adds the family's base layer, and points Tailwind at the package's components so their classes are generated with the app's. `dark:` follows the ground (`data-mode` on `<html>`), not the operating system.
29
+
30
+ The fonts are the app's to load: IBM Plex Sans for the interface, IBM Plex Mono for anything technical, DM Sans for headings. Set `--font-plex-sans`, `--font-plex-mono` and `--font-dm-sans` (with `next/font`, as their `variable`), or install the families under those names.
31
+
32
+ A non-Tailwind app can import `@calliopelabs/ui/theme.css` alone for the CSS variables.
33
+
34
+ ## Define the product
35
+
36
+ A product is a brand plus a navigation model. The two consoles' brands ship with the package; each app adds its navigation:
37
+
38
+ ```ts
39
+ // lib/product.ts
40
+ import { astroliftBrand, defineProduct, type NavModel } from "@calliopelabs/ui";
41
+ import { BotIcon, HomeIcon, PackageIcon, RocketIcon } from "lucide-react";
42
+
43
+ const NAV: NavModel = [
44
+ {
45
+ key: "home",
46
+ label: "Home",
47
+ icon: HomeIcon,
48
+ flat: true,
49
+ groups: [{ functions: [{ key: "home", label: "Home", href: "/dashboard", icon: HomeIcon }] }],
50
+ },
51
+ {
52
+ key: "apps",
53
+ label: "Apps",
54
+ icon: PackageIcon,
55
+ groups: [
56
+ {
57
+ functions: [
58
+ { key: "apps", label: "Apps", href: "/apps", icon: PackageIcon, requires: "apps" },
59
+ {
60
+ key: "deployments",
61
+ label: "Deployments",
62
+ href: "/deployments",
63
+ icon: RocketIcon,
64
+ requires: "apps",
65
+ },
66
+ ],
67
+ },
68
+ ],
69
+ },
70
+ // agents, admin, ...
71
+ ];
72
+
73
+ export const astrolift = defineProduct({ ...astroliftBrand, nav: NAV });
74
+ ```
75
+
76
+ Astrolift AI: leaf green on the deep planet green sidebar (`#04231a`), all six grounds, graphite by default (mist when the system runs light), and its eight named themes.
77
+
78
+ Zentinelle AI: teal (`#37efed`, `#0d7470` on light grounds) on the navy sidebar (`#0b0b19`), a true black ground by default, the family accents beside teal, and no emerald ground. Its seven areas gate rows by role:
79
+
80
+ ```ts
81
+ import { defineProduct, zentinelleBrand, type NavModel } from "@calliopelabs/ui";
82
+ import { LayoutDashboardIcon, ScaleIcon, ShieldCheckIcon } from "lucide-react";
83
+
84
+ const NAV: NavModel = [
85
+ {
86
+ key: "overview",
87
+ label: "Overview",
88
+ icon: LayoutDashboardIcon,
89
+ flat: true,
90
+ groups: [{ functions: [{ key: "overview", label: "Overview", href: "/dashboard" }] }],
91
+ },
92
+ {
93
+ key: "policies",
94
+ label: "Policies",
95
+ icon: ShieldCheckIcon,
96
+ groups: [{ functions: [{ key: "policies", label: "Policies", href: "/policies" }] }],
97
+ },
98
+ {
99
+ key: "risk",
100
+ label: "Risk & compliance",
101
+ icon: ScaleIcon,
102
+ groups: [
103
+ {
104
+ functions: [
105
+ { key: "risks", label: "Risks", href: "/risks" },
106
+ { key: "evidence", label: "Evidence", href: "/evidence", requires: "admin" },
107
+ ],
108
+ },
109
+ ],
110
+ },
111
+ // agents, activity, models & usage, settings
112
+ ];
113
+
114
+ export const zentinelle = defineProduct({ ...zentinelleBrand, nav: NAV });
115
+ ```
116
+
117
+ `defineProduct` validates the config and throws on anything that would paint wrong: a colour that is not lowercase `#rrggbb`, a default ground the product does not offer, a brand accent key the family already uses.
118
+
119
+ ## Wire the shell
120
+
121
+ The example is a Next.js App Router app; any React app works the same way with its own router's link.
122
+
123
+ The root layout renders the appearance script in `<head>`, so the stored ground, accent, density and corners are on `<html>` before first paint:
124
+
125
+ ```tsx
126
+ // app/layout.tsx (a Server Component)
127
+ import { AppearanceScript } from "@calliopelabs/ui";
128
+
129
+ import { astrolift } from "@/lib/product";
130
+ import { Providers } from "./providers";
131
+ import "./globals.css";
132
+
133
+ export default function RootLayout({ children }: { children: React.ReactNode }) {
134
+ return (
135
+ // The script stamps <html> before React hydrates.
136
+ <html lang="en" suppressHydrationWarning>
137
+ <head>
138
+ <AppearanceScript product={astrolift} />
139
+ </head>
140
+ <body>
141
+ <Providers>{children}</Providers>
142
+ </body>
143
+ </html>
144
+ );
145
+ }
146
+ ```
147
+
148
+ The providers live in a client module, because the product holds component references (its navigation icons) that cannot cross from a Server Component into a Client Component as props:
149
+
150
+ ```tsx
151
+ // app/providers.tsx
152
+ "use client";
153
+
154
+ import { AppearanceProvider, UIProvider } from "@calliopelabs/ui";
155
+ import Link from "next/link";
156
+
157
+ import { astrolift } from "@/lib/product";
158
+
159
+ export function Providers({ children }: { children: React.ReactNode }) {
160
+ return (
161
+ <UIProvider link={Link}>
162
+ <AppearanceProvider product={astrolift}>{children}</AppearanceProvider>
163
+ </UIProvider>
164
+ );
165
+ }
166
+ ```
167
+
168
+ The signed-in layout draws the shell. Everything it shows comes from the app; the components are pure:
169
+
170
+ ```tsx
171
+ // app/(app)/shell.tsx
172
+ "use client";
173
+
174
+ import {
175
+ AppShell,
176
+ BrandMark,
177
+ RailPanel,
178
+ RailTree,
179
+ Sidebar,
180
+ UserMenu,
181
+ activeFor,
182
+ useRailState,
183
+ visibleNav,
184
+ } from "@calliopelabs/ui";
185
+ import { usePathname } from "next/navigation";
186
+ import * as React from "react";
187
+
188
+ import { astrolift } from "@/lib/product";
189
+
190
+ export function Shell({ children, can, projects, me }: ShellProps) {
191
+ const pathname = usePathname() ?? "/";
192
+ const nav = React.useMemo(() => visibleNav(astrolift.nav, can), [can]);
193
+ const [sidebarCollapsed, setSidebarCollapsed] = useRailState("astrolift.shell.main");
194
+ const [railCollapsed, setRailCollapsed] = useRailState(
195
+ "astrolift.shell.projects",
196
+ () => window.innerWidth < 1280
197
+ );
198
+
199
+ return (
200
+ <AppShell
201
+ onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
202
+ onToggleRightRail={() => setRailCollapsed(!railCollapsed)}
203
+ sidebar={
204
+ <Sidebar
205
+ nav={nav}
206
+ active={activeFor(nav, pathname)}
207
+ collapsed={sidebarCollapsed}
208
+ onCollapsedChange={setSidebarCollapsed}
209
+ header={<BrandMark href="/dashboard" mark={<img src="/logo.svg" alt="" />} />}
210
+ headerCollapsed={
211
+ <BrandMark href="/dashboard" mark={<img src="/logo.svg" alt="" />} collapsed />
212
+ }
213
+ footer={<UserMenu name={me.name} email={me.email} onSignOut={signOut} />}
214
+ />
215
+ }
216
+ rightRail={
217
+ <RailPanel
218
+ label="Projects"
219
+ title={me.orgName}
220
+ collapsed={railCollapsed}
221
+ onCollapsedChange={setRailCollapsed}
222
+ >
223
+ <RailTree
224
+ label="My projects"
225
+ groups={projects}
226
+ activeHref={pathname}
227
+ emptyText="You are not in a project yet."
228
+ />
229
+ </RailPanel>
230
+ }
231
+ rightRailLabel="Projects"
232
+ >
233
+ {children}
234
+ </AppShell>
235
+ );
236
+ }
237
+ ```
238
+
239
+ `[` folds the sidebar and `]` the right rail. Below `md` both rails open as drawers from a top bar. Zentinelle docks its assistant in the same slot: a `RailPanel` with `collapsedMode="hidden"`, `width` from `useRailWidth(key, 400, { min: 320, max: 560 })` with `onWidthChange`, and `expanded` with `onExpandedChange` for the full-width view. Keep the conversation in state above the shell: on narrow screens a rail renders in its drawer as well as its hidden desktop place.
240
+
241
+ A page is a `PageFrame`: breadcrumbs from the navigation model (`areaSwitcher`), the title row with its status, context and one primary action, at most one row of tabs, then the body.
242
+
243
+ ```tsx
244
+ <PageFrame
245
+ crumbs={[areaSwitcher(nav, "apps", "deployments")]}
246
+ title="Deployments"
247
+ primaryAction={<Button size="sm">New deployment</Button>}
248
+ tabs={views}
249
+ tabsLabel="Views"
250
+ >
251
+ <DataTable
252
+ label="Deployments"
253
+ controller={table}
254
+ columns={columns}
255
+ getRowId={(d) => d.id}
256
+ rowHref={(d) => `/deployments/${d.id}`}
257
+ empty={{ title: "No deployments yet", actionHref: "/apps", actionLabel: "Deploy an app" }}
258
+ />
259
+ </PageFrame>
260
+ ```
261
+
262
+ The package fetches nothing. `DataTable` reads a `TableController`: rows, state, paging, search and sort, all done by the server. An app implements it over its own data layer; Astrolift's `useCursorTable` already returns one.
263
+
264
+ ## Appearance
265
+
266
+ The person sets four axes, per product: the **ground** (black, charcoal, graphite, emerald, paper, mist; it decides light or dark), the **accent** (the product's own, a family accent, or any colour that stands 3:1 off the ground), the **density** (`compact` hairline rows or boxed `cards`) and the **corners** (0, 2, 4 or 10 px). `AppearancePicker` is the settings UI for all four.
267
+
268
+ - Precedence: a locked org theme, then the person's choice, then the org default, then the product default. Until someone picks a ground, light or dark follows the system.
269
+ - An org policy that arrives after sign-in goes in through `useAppearance().setPolicy`. An app that keeps the preference on its server passes `onChange` and hands the server's copy back through `applyServerAppearance`.
270
+ - Status colours never move: lime ok, amber warn, red error, green info, whatever the accent or ground. Tests hold the generated CSS to that, and check WCAG contrast for every ground with every accent.
271
+
272
+ ## Copy and languages
273
+
274
+ Every string the package renders is English by default and overridable, one group at a time:
275
+
276
+ ```tsx
277
+ <UIProvider link={Link} messages={{ table: { retry: t("retry"), loadFailed: (label) => t("loadFailed", { label }) } }}>
278
+ ```
279
+
280
+ Messages that take a value are functions, so the app's own i18n formats them. Navigation labels come from the model; `localizeNav(nav, translate)` maps them through a catalog.
281
+
282
+ ## Develop
283
+
284
+ Node 24 (`.nvmrc`).
285
+
286
+ | Command | Does |
287
+ | ----------------------------------------------------------- | -------------------------------------------------------------------------------- |
288
+ | `npm run storybook` | The catalog, on port 6006 |
289
+ | `npm test` | Every test, including one that renders every story and runs the a11y addon on it |
290
+ | `npm run lint`, `npm run typecheck`, `npm run format:check` | The gates CI runs |
291
+ | `npm run theme` | Regenerates `src/styles/theme.css` after a change to `src/theme/tokens.ts` |
292
+ | `npm run build`, `npm run pack:check` | Builds `dist/` and checks the package a consumer receives |
293
+ | `npm run build-storybook` | The static catalog |
294
+
295
+ Conventions for contributors and agents are in [`bootstrap.md`](bootstrap.md).
296
+
297
+ ## Releasing
298
+
299
+ `publish.yml` publishes to npm as `@calliopelabs/ui` through npm trusted publishing: there is no npm token, the job's GitHub OIDC identity stands in for one, and the package carries provenance. It runs when a GitHub release is published, or by hand on an existing version tag; it refuses a branch. Before anything is published, `release-checks.yml` checks that the tag names the `package.json` version and points at the commit being built, then runs every CI gate on that commit. A prerelease version (`0.2.0-alpha.1`) goes out under the `alpha` dist-tag, and a version already on npm is left alone. Nothing in the workflows bumps a version or creates a tag.
300
+
301
+ ### The first publish
302
+
303
+ A brand new package cannot use trusted publishing: npm attaches a trusted publisher to a package that already exists. So `0.1.0` is published once by hand, by an npm account in the `calliopelabs` organization with two-factor authentication on. In a fresh clone of `main`, tag it (a tag push starts no workflow) and publish that clean checkout of the tag:
304
+
305
+ ```sh
306
+ git clone https://github.com/calliopeai/calliope-ui.git calliope-ui-v0.1.0 && cd calliope-ui-v0.1.0
307
+ git tag v0.1.0 && git push origin v0.1.0
308
+ nvm use
309
+ npm login
310
+ npm ci && npm run build && npm publish --access public
311
+ ```
312
+
313
+ Then add the trusted publisher on npmjs.com, in the package's settings under Trusted publishing: GitHub Actions, organization or user `calliopeai`, repository `calliope-ui`, workflow filename `publish.yml`, no environment. Under allowed actions, allow `npm publish`: a configuration created after 2026-09-03 allows only staged publishing (`npm stage publish`) by default, and this workflow publishes directly. The same from the command line (npm 11.15 or later):
314
+
315
+ ```sh
316
+ npm trust github @calliopelabs/ui --repo calliopeai/calliope-ui --file publish.yml --allow-publish
317
+ ```
318
+
319
+ A new trusted publisher expires unless a publish through it succeeds within 2 days, so add it shortly before the next release; if it expired, delete it and add it again. From then on, a published GitHub release publishes automatically. Once a release has gone out that way, setting Publishing access to "Require two-factor authentication and disallow tokens" turns token publishing off.
320
+
321
+ ### Every release after
322
+
323
+ Bump the version in a pull request (`npm version 0.2.0 --no-git-tag-version` changes `package.json` and `package-lock.json`). Once it merges, publish a release whose tag names that version:
324
+
325
+ ```sh
326
+ gh release create v0.2.0 --target main --generate-notes
327
+ ```
328
+
329
+ Add `--prerelease` for a prerelease version. A failed run can be re-run, or dispatched again on the tag with `gh workflow run publish.yml --ref v0.2.0`.
330
+
331
+ ## Licence
332
+
333
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,10 @@
1
+ import * as React from "react";
2
+ /**
3
+ * The person's appearance for the product in context: theme presets, accent
4
+ * (including any colour that stands 3:1 off the ground), ground, density and
5
+ * corners. Read-only while the org locks the theme. Choosing a ground also
6
+ * chooses light or dark. Status colours are not offered: they never change.
7
+ */
8
+ export declare function AppearancePicker({ className }: {
9
+ className?: string;
10
+ }): React.JSX.Element;
@@ -0,0 +1,75 @@
1
+ "use client";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { LockIcon } from "lucide-react";
4
+ import * as React from "react";
5
+ import { cn } from "../lib/cn.js";
6
+ import { Button } from "../primitives/Button.js";
7
+ import { useUI } from "../provider/UIProvider.js";
8
+ import { MIN_NON_TEXT_CONTRAST } from "../theme/contrast.js";
9
+ import { DENSITY_KEYS, GROUNDS, SURFACES } from "../theme/tokens.js";
10
+ import { accentContrast, isCustomAccent, modeFor, parseCustomAccent, } from "./appearance.js";
11
+ import { useAppearance } from "./AppearanceProvider.js";
12
+ import { CORNERS } from "./product.js";
13
+ /** The sidebar a ground paints for this product, for the theme previews. */
14
+ function sidebarFor(product, ground) {
15
+ const sidebar = SURFACES[ground].sidebar;
16
+ return sidebar === "brand" ? product.sidebar : sidebar;
17
+ }
18
+ function accentOn(accent, ground) {
19
+ if (!accent)
20
+ return "transparent";
21
+ return modeFor(ground) === "dark" ? accent.dark : accent.light;
22
+ }
23
+ /** A miniature of the chrome: sidebar, ground and accent, in the exact colours. */
24
+ function ThemePreview({ product, ground, accent, }) {
25
+ return (_jsxs("span", { "aria-hidden": true, className: "flex h-9 overflow-hidden rounded-lg border", style: { background: SURFACES[ground].background }, children: [_jsx("span", { style: { background: sidebarFor(product, ground), width: "26%" } }), _jsx("span", { className: "flex flex-1 items-center px-1.5", children: _jsx("span", { style: { background: accentOn(accent, ground), height: 4, width: "58%", borderRadius: 1 } }) })] }));
26
+ }
27
+ const choice = "relative flex cursor-pointer items-center gap-2 rounded-lg border px-2.5 py-1.5 text-sm transition-colors hover:border-primary has-checked:border-primary has-checked:ring-1 has-checked:ring-primary has-disabled:cursor-not-allowed has-disabled:opacity-60 has-focus-visible:ring-2 has-focus-visible:ring-ring";
28
+ function Choice({ name, checked, disabled, onSelect, className, children, }) {
29
+ return (_jsxs("label", { className: cn(choice, className), children: [_jsx("input", { type: "radio", name: name, checked: checked, disabled: disabled, onChange: onSelect, className: "sr-only" }), children] }));
30
+ }
31
+ function Group({ legend, children }) {
32
+ return (_jsxs("fieldset", { className: "flex min-w-0 flex-col gap-2", children: [_jsx("legend", { className: "mb-2 text-2xs font-semibold tracking-[0.08em] text-muted-foreground uppercase", children: legend }), children] }));
33
+ }
34
+ /**
35
+ * The person's appearance for the product in context: theme presets, accent
36
+ * (including any colour that stands 3:1 off the ground), ground, density and
37
+ * corners. Read-only while the org locks the theme. Choosing a ground also
38
+ * chooses light or dark. Status colours are not offered: they never change.
39
+ */
40
+ export function AppearancePicker({ className }) {
41
+ const { product, appearance, locked, setAppearance, reset } = useAppearance();
42
+ const { messages } = useUI();
43
+ const t = messages.appearance;
44
+ const id = React.useId();
45
+ const accents = product.accents;
46
+ const activeTheme = product.themes.find((th) => th.ground === appearance.ground && th.accent === appearance.accent);
47
+ return (_jsxs("div", { "data-slot": "appearance-picker", className: cn("flex min-w-0 flex-col gap-6", className), children: [locked && (_jsxs("p", { className: "flex items-center gap-2 rounded-lg border border-info-border bg-info-bg px-3 py-2 text-sm text-info-fg", children: [_jsx(LockIcon, { className: "size-4 shrink-0", "aria-hidden": true }), t.locked] })), product.themes.length > 0 && (_jsx(Group, { legend: t.themes, children: _jsx("div", { className: "grid grid-cols-[repeat(auto-fill,minmax(9rem,1fr))] gap-2", children: product.themes.map((theme) => (_jsxs(Choice, { name: `${id}-theme`, checked: activeTheme?.id === theme.id, disabled: locked, onSelect: () => setAppearance({ ground: theme.ground, accent: theme.accent }), className: "flex-col items-stretch gap-2 p-2", children: [_jsx(ThemePreview, { product: product, ground: theme.ground, accent: accents.find((a) => a.key === theme.accent) }), _jsxs("span", { children: [_jsx("span", { className: "block text-sm font-semibold", children: theme.name }), _jsx("span", { className: "block text-xs text-muted-foreground", children: theme.hint })] })] }, theme.id))) }) })), _jsxs(Group, { legend: t.accent, children: [_jsx("div", { className: "flex flex-wrap gap-2", children: accents.map((accent) => (_jsxs(Choice, { name: `${id}-accent`, checked: appearance.accent === accent.key, disabled: locked, onSelect: () => setAppearance({ accent: accent.key }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full", style: { background: accentOn(accent, appearance.ground) } }), t.accentLabel(accent.key, accent.label)] }, accent.key))) }), _jsx(CustomAccent, { id: `${id}-custom`, value: isCustomAccent(appearance.accent) ? appearance.accent : null, ground: appearance.ground, disabled: locked, onChange: (accent) => setAppearance({ accent }) })] }), _jsx(Group, { legend: t.ground, children: _jsx("div", { className: "flex flex-wrap gap-2", children: product.grounds.map((ground) => (_jsxs(Choice, { name: `${id}-ground`, checked: appearance.ground === ground, disabled: locked, onSelect: () => setAppearance({ ground }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 rounded-full border", style: { background: SURFACES[ground].background } }), t.groundLabel(ground, GROUNDS[ground].label), _jsx("span", { className: "text-xs text-muted-foreground", children: t.mode(GROUNDS[ground].mode) })] }, ground))) }) }), _jsx(Group, { legend: t.density, children: _jsx("div", { className: "grid gap-2 sm:grid-cols-2", children: DENSITY_KEYS.map((density) => (_jsxs(Choice, { name: `${id}-density`, checked: appearance.density === density, disabled: locked, onSelect: () => setAppearance({ density }), className: "flex-col items-start gap-0.5 px-3 py-2", children: [_jsx("span", { className: "text-sm font-semibold", children: t.densityLabel(density) }), _jsx("span", { className: "text-xs text-muted-foreground", children: t.densityHint(density) })] }, density))) }) }), _jsx(Group, { legend: t.corners, children: _jsx("div", { className: "flex flex-wrap gap-2", children: CORNERS.map((corners) => (_jsxs(Choice, { name: `${id}-corners`, checked: appearance.corners === corners, disabled: locked, onSelect: () => setAppearance({ corners }), children: [_jsx("span", { "aria-hidden": true, className: "size-3.5 border-t-2 border-l-2 border-current", style: { borderTopLeftRadius: corners } }), t.cornersLabel(corners)] }, corners))) }) }), !locked && (_jsx("div", { children: _jsx(Button, { variant: "outline", size: "sm", onClick: reset, children: t.reset }) }))] }));
48
+ }
49
+ /**
50
+ * Any colour as the accent (Astrolift palette option A). It applies only once
51
+ * it stands 3:1 off the ground (WCAG 2.2 §1.4.11); until then the draft stays
52
+ * in the field with the reason.
53
+ */
54
+ function CustomAccent({ id, value, ground, disabled, onChange, }) {
55
+ const { messages } = useUI();
56
+ const t = messages.appearance;
57
+ const [draft, setDraft] = React.useState(value ?? "");
58
+ const parsed = parseCustomAccent(draft);
59
+ const check = parsed ? accentContrast(parsed, ground) : null;
60
+ const helpId = `${id}-help`;
61
+ function update(next) {
62
+ setDraft(next);
63
+ const hex = parseCustomAccent(next);
64
+ if (hex && accentContrast(hex, ground).ok)
65
+ onChange(hex);
66
+ }
67
+ const help = !draft
68
+ ? t.customAccentHelp
69
+ : !parsed
70
+ ? t.customAccentInvalid
71
+ : check?.ok
72
+ ? t.customAccentOk(check.ratio.toFixed(1))
73
+ : t.customAccentFaint((check?.ratio ?? 1).toFixed(1), MIN_NON_TEXT_CONTRAST, modeFor(ground));
74
+ return (_jsxs("div", { className: "mt-1 flex min-w-0 flex-col gap-1.5", children: [_jsx("label", { htmlFor: id, className: "text-sm font-medium", children: t.customAccent }), _jsxs("div", { className: "flex min-w-0 flex-wrap items-center gap-2", children: [_jsx("input", { type: "color", "aria-label": t.customAccentPick, disabled: disabled, value: parsed ?? "#2f9e52", onChange: (event) => update(event.target.value), className: "size-8 shrink-0 cursor-pointer rounded-lg border bg-transparent disabled:cursor-not-allowed" }), _jsx("input", { id: id, type: "text", inputMode: "text", spellCheck: false, disabled: disabled, value: draft, placeholder: "#2f9e52", onChange: (event) => update(event.target.value), "aria-invalid": Boolean(draft) && !check?.ok, "aria-describedby": helpId, className: "h-8 w-32 rounded-lg border border-input bg-transparent px-2.5 font-mono text-sm outline-none placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 disabled:opacity-50 aria-invalid:border-destructive" }), value && _jsx("span", { className: "text-xs text-muted-foreground", children: t.customAccentInUse })] }), _jsx("p", { id: helpId, "aria-live": "polite", className: cn("text-xs [overflow-wrap:anywhere]", draft && !check?.ok ? "text-danger-fg" : "text-muted-foreground"), children: help })] }));
75
+ }
@@ -0,0 +1,53 @@
1
+ import * as React from "react";
2
+ import type { Density, Mode } from "../theme/tokens.js";
3
+ import { type Appearance, type OrgAppearancePolicy } from "./appearance.js";
4
+ import type { Product } from "./product.js";
5
+ export interface AppearanceContextValue {
6
+ product: Product;
7
+ /** What applies now: the org lock, the person's choice, the org default or the product default. */
8
+ appearance: Appearance;
9
+ mode: Mode;
10
+ /** True when an org policy locks the theme; the picker renders read-only. */
11
+ locked: boolean;
12
+ /** The person's own choice; null when they follow the org or the product. */
13
+ personal: Partial<Appearance> | null;
14
+ setAppearance: (patch: Partial<Appearance>) => void;
15
+ /** Forget the person's choice ("use the default"), not "choose the defaults". */
16
+ reset: () => void;
17
+ /** Supply the org policy once it is known, from inside the signed-in layout. */
18
+ setPolicy: (policy: OrgAppearancePolicy | null) => void;
19
+ /** Take the person's preference from the app's server, without echoing it back through onChange. */
20
+ applyServerAppearance: (personal: unknown) => void;
21
+ }
22
+ export interface AppearanceProviderProps {
23
+ product: Product;
24
+ /** The org policy, when the caller already has it; `setPolicy` supplies it later otherwise. */
25
+ policy?: OrgAppearancePolicy | null;
26
+ /** Called with the person's new preference (null on reset), for an app that keeps it on its server. */
27
+ onChange?: (personal: Partial<Appearance> | null) => void;
28
+ /**
29
+ * Controlled: the person's preference comes from here instead of
30
+ * localStorage, and changes only reach `onChange`. For a catalog toolbar
31
+ * or a test; an app leaves it out.
32
+ */
33
+ value?: Partial<Appearance> | null;
34
+ children: React.ReactNode;
35
+ }
36
+ /**
37
+ * Holds the person's appearance for one product and keeps <html> in step.
38
+ * The preference is read from localStorage as an external store, so the
39
+ * server render and hydration use the product default (AppearanceScript has
40
+ * already painted the stored choice), other tabs follow through the storage
41
+ * event, and nothing is set from an effect.
42
+ *
43
+ * The product config carries component references (navigation icons), so
44
+ * create this provider in a client module rather than passing the product
45
+ * across from a Server Component.
46
+ */
47
+ export declare function AppearanceProvider({ product, policy, onChange, value: controlled, children, }: AppearanceProviderProps): React.JSX.Element;
48
+ /** The appearance context. Throws outside an AppearanceProvider. */
49
+ export declare function useAppearance(): AppearanceContextValue;
50
+ /** The product in context, or null outside an AppearanceProvider. */
51
+ export declare function useProduct(): Product | null;
52
+ /** The density in context, or compact outside an AppearanceProvider. */
53
+ export declare function useDensity(): Density;
@@ -0,0 +1,113 @@
1
+ "use client";
2
+ import { jsx as _jsx } from "react/jsx-runtime";
3
+ import * as React from "react";
4
+ import { subscribeLocal } from "../lib/local-store.js";
5
+ import { appearanceFromServer, applyAppearance, clearAppearance, normalizePartial, parseStoredAppearance, readAppearance, readStoredAppearance, resolveAppearance, writeAppearance, } from "./appearance.js";
6
+ const AppearanceContext = React.createContext(null);
7
+ const LIGHT_QUERY = "(prefers-color-scheme: light)";
8
+ function subscribeScheme(onChange) {
9
+ if (typeof window.matchMedia !== "function")
10
+ return () => { };
11
+ const query = window.matchMedia(LIGHT_QUERY);
12
+ query.addEventListener("change", onChange);
13
+ return () => query.removeEventListener("change", onChange);
14
+ }
15
+ function systemMode() {
16
+ return typeof window.matchMedia === "function" && window.matchMedia(LIGHT_QUERY).matches
17
+ ? "light"
18
+ : "dark";
19
+ }
20
+ const noSubscription = () => () => { };
21
+ /**
22
+ * Holds the person's appearance for one product and keeps <html> in step.
23
+ * The preference is read from localStorage as an external store, so the
24
+ * server render and hydration use the product default (AppearanceScript has
25
+ * already painted the stored choice), other tabs follow through the storage
26
+ * event, and nothing is set from an effect.
27
+ *
28
+ * The product config carries component references (navigation icons), so
29
+ * create this provider in a client module rather than passing the product
30
+ * across from a Server Component.
31
+ */
32
+ export function AppearanceProvider({ product, policy = null, onChange, value: controlled, children, }) {
33
+ const isControlled = controlled !== undefined;
34
+ const raw = React.useSyncExternalStore(subscribeLocal, () => readStoredAppearance(product), () => null);
35
+ const scheme = React.useSyncExternalStore(subscribeScheme, systemMode, () => "dark");
36
+ const isClient = React.useSyncExternalStore(noSubscription, () => true, () => false);
37
+ const [orgPolicy, setOrgPolicy] = React.useState(policy);
38
+ const personal = React.useMemo(() => isControlled
39
+ ? controlled && Object.keys(normalizePartial(controlled, product)).length > 0
40
+ ? normalizePartial(controlled, product)
41
+ : null
42
+ : parseStoredAppearance(raw, product), [isControlled, controlled, raw, product]);
43
+ const { value, mode, locked } = React.useMemo(() => resolveAppearance(personal, orgPolicy, product, scheme), [personal, orgPolicy, product, scheme]);
44
+ React.useEffect(() => {
45
+ // While hydrating, the store reads above return their server snapshots,
46
+ // so `value` is the product default rather than what the boot script
47
+ // applied from storage; stamping it would flash. The client render that
48
+ // follows hydration applies the real value.
49
+ if (isClient)
50
+ applyAppearance(value, product);
51
+ }, [isClient, value, product]);
52
+ const onChangeRef = React.useRef(onChange);
53
+ React.useEffect(() => {
54
+ onChangeRef.current = onChange;
55
+ }, [onChange]);
56
+ const setAppearance = React.useCallback((patch) => {
57
+ if (locked)
58
+ return;
59
+ if (isControlled) {
60
+ onChangeRef.current?.(normalizePartial({ ...(personal ?? {}), ...patch }, product));
61
+ return;
62
+ }
63
+ // Read the stored value rather than the rendered one, so two changes
64
+ // in one tick both land.
65
+ const next = normalizePartial({ ...(readAppearance(product) ?? {}), ...patch }, product);
66
+ writeAppearance(product, next);
67
+ onChangeRef.current?.(next);
68
+ }, [locked, isControlled, personal, product]);
69
+ const reset = React.useCallback(() => {
70
+ if (locked)
71
+ return;
72
+ if (!isControlled)
73
+ clearAppearance(product);
74
+ onChangeRef.current?.(null);
75
+ }, [locked, isControlled, product]);
76
+ const applyServerAppearance = React.useCallback((next) => {
77
+ if (isControlled)
78
+ return;
79
+ const parsed = appearanceFromServer(next, product);
80
+ if (parsed)
81
+ writeAppearance(product, parsed);
82
+ else
83
+ clearAppearance(product);
84
+ }, [isControlled, product]);
85
+ const setPolicy = React.useCallback((next) => setOrgPolicy(next), []);
86
+ const context = React.useMemo(() => ({
87
+ product,
88
+ appearance: value,
89
+ mode,
90
+ locked,
91
+ personal,
92
+ setAppearance,
93
+ reset,
94
+ setPolicy,
95
+ applyServerAppearance,
96
+ }), [product, value, mode, locked, personal, setAppearance, reset, setPolicy, applyServerAppearance]);
97
+ return _jsx(AppearanceContext.Provider, { value: context, children: children });
98
+ }
99
+ /** The appearance context. Throws outside an AppearanceProvider. */
100
+ export function useAppearance() {
101
+ const context = React.useContext(AppearanceContext);
102
+ if (!context)
103
+ throw new Error("useAppearance must be used inside <AppearanceProvider>");
104
+ return context;
105
+ }
106
+ /** The product in context, or null outside an AppearanceProvider. */
107
+ export function useProduct() {
108
+ return React.useContext(AppearanceContext)?.product ?? null;
109
+ }
110
+ /** The density in context, or compact outside an AppearanceProvider. */
111
+ export function useDensity() {
112
+ return React.useContext(AppearanceContext)?.appearance.density ?? "compact";
113
+ }
@@ -0,0 +1,16 @@
1
+ import type { Product } from "./product.js";
2
+ export interface AppearanceScriptProps {
3
+ product: Product;
4
+ /** The page's CSP nonce, when inline style and script need one. */
5
+ nonce?: string;
6
+ }
7
+ /**
8
+ * Render in <head>, before anything paints: the product's brand tokens (its
9
+ * sidebar and its own accent) and the script that stamps the person's ground,
10
+ * accent, density and corners on <html> from localStorage. Without it the page
11
+ * paints the defaults and swaps once React hydrates, which reads as a flash.
12
+ *
13
+ * Not a client component: it renders the same markup on the server and needs
14
+ * no hooks, so a Next root layout can render it directly.
15
+ */
16
+ export declare function AppearanceScript({ product, nonce }: AppearanceScriptProps): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { appearanceScript, productStyles } from "./boot.js";
3
+ /**
4
+ * Render in <head>, before anything paints: the product's brand tokens (its
5
+ * sidebar and its own accent) and the script that stamps the person's ground,
6
+ * accent, density and corners on <html> from localStorage. Without it the page
7
+ * paints the defaults and swaps once React hydrates, which reads as a flash.
8
+ *
9
+ * Not a client component: it renders the same markup on the server and needs
10
+ * no hooks, so a Next root layout can render it directly.
11
+ */
12
+ export function AppearanceScript({ product, nonce }) {
13
+ return (_jsxs(_Fragment, { children: [_jsx("style", { nonce: nonce, "data-calliope-ui": "product", dangerouslySetInnerHTML: { __html: productStyles(product) } }), _jsx("script", { nonce: nonce, "data-calliope-ui": "appearance", dangerouslySetInnerHTML: { __html: appearanceScript(product) } })] }));
14
+ }