@owlmeans/web-panel 0.1.18-rc.78 → 0.1.18-rc.79

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.
@@ -8,7 +8,7 @@ user-invocable: false
8
8
  # @owlmeans/web-panel
9
9
 
10
10
  **Layer:** Web (React)
11
- **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.78"` in `dependencies`
11
+ **Install:** `"@owlmeans/web-panel": "^0.1.18-rc.79"` in `dependencies`
12
12
 
13
13
  ## Choosing a component
14
14
 
@@ -17,9 +17,9 @@ root unless the import column names a subpath.
17
17
 
18
18
  | Job | Use | Import | Correct one-liner |
19
19
  |---|---|---|---|
20
- | The app's context | `makeContext` | root | `const context = makeContext<C, T>(cfg)` in `context.ts`, then `append*(context)` mixins |
20
+ | The app's context — the base web context factory (shadcn/Radix + default OwlMeans router) | `makeContext` | root | `const context = makeContext<C, T>(cfg)` in `context.ts`, then `append*(context)` mixins |
21
21
  | Mount the app | `render` (or `PanelApp` for a host that mounts itself) | root | `render(context)` |
22
- | The application shell | `NavLayout` | root | `<NavLayout nav={navConfig} title="App" mobileMenu themeToggle>{children}</NavLayout>` |
22
+ | The application shell — header, section menu, screen menu, content, footer | `NavLayout` | root | `<NavLayout nav={navConfig} title="App" mobileMenu themeToggle>{children}</NavLayout>` |
23
23
  | One piece of the shell | `TopNav` / `SideNav` / `MobileNav` / `Footer` / `ShellCredit` | root | `<Footer links={footerLinks} />` |
24
24
  | Toasts | `Toaster` + `toast` from `sonner` | root | `<Toaster />` once, in the layout; `toast.success('Saved')` where the action ends |
25
25
  | Light / dark switch | `ThemeToggle` / `useColorScheme`; head script from `./scheme` | root, `./scheme` | `<NavLayout themeToggle … />` |
@@ -59,23 +59,18 @@ root unless the import column names a subpath.
59
59
 
60
60
  ## Key Exports
61
61
 
62
+ Rows the chooser above already covers (`makeContext`, the shell pieces, `Toaster`, `Link`) are not
63
+ repeated here, and the rows of `ThemeToggle`, `SocketReloadDialog`, `LoginScreen` and the
64
+ `Form` family live in their parts.
65
+
62
66
  | Export | Description |
63
67
  |--------|-------------|
64
- | `makeContext<C, T>(cfg)` | Base web context factory (shadcn/Radix + default OwlMeans router) |
65
- | `NavLayout` | The application shell — header, section menu, screen menu, content, footer |
66
- | `TopNav` / `SideNav` / `MobileNav` / `Footer` | The shell's pieces, mountable on their own |
67
- | `Toaster` | The application's toast surface — mounted once, in the layout |
68
- | `ThemeToggle` / `useColorScheme` | The light/dark switcher (`ThemeToggleProps`, `ThemeToggleLabels`) and the hook behind it (`ColorSchemeModel`) — see *Light and dark* below |
69
- | `SocketReloadDialog` | The global "connection lost — try again / reload" prompt (opt in via `cfg.socket.reloadDialog`) `PanelApp` mounts automatically — see below. The hooks it reads, `useSocketStatus` and `useSocketRetry`, are imported from `@owlmeans/client-socket`, not from this package |
70
- | `Link` | An `<a>` addressing an entrypoint alias (or a literal `src`), with the label taken from i18n |
71
- | `LoginProviderNote` | The provider disclosure (`LoginProviderNoteProps`) — see *The provider disclosure* below |
72
- | `LoginScreen` / `LocalizedLoginScreen` / `appendLoginScreen` | The identity-provider choice screen — see `login-methods`. `LoginScreen` is pure w.r.t. `locale` too, exactly like `translate`: it is a prop, defaulting to nothing, never an implicit `useLanguage()` read, because a component that reaches for i18n context directly crashes an app mounted without one. `LocalizedLoginScreen` supplies `useLanguage()`'s value when the caller does not pass its own |
68
+ | `LoginProviderNote` | The provider disclosure (`LoginProviderNoteProps`) — see [parts/sign-in.md](parts/sign-in.md) |
73
69
  | `render(context, opts?)` | Mounts the tree inside `PanelApp`, with the browser language detector installed on the i18n instance. `opts` is `RenderOptions` plus `rootClassName` |
74
70
  | `PanelApp` | That wrapper on its own — the themed root `div` plus the i18n provider — for a host that mounts the tree itself |
75
71
  | `useContext<C, T>()` | The current context, from React. `AppContext` adds `context.flow()` over `@owlmeans/web-client`'s |
76
72
  | `entrypoints` | The base declaration list — `@owlmeans/web-client`'s plus `@owlmeans/api-config-client`'s. Compose the app's own over it |
77
73
  | `Block` / `Text` / `Status` | The shadcn `Card` panel with an optional `Actions` footer, a heading/paragraph whose copy comes from the panel namespace, and the shadcn `Alert` resolving a `ResilientError` to a translated message |
78
- | `Form` / `TextInput` / `Button` / `SubmitButton` / `ButtonSelector` | The web form family. `Form` builds its own `useForm` + `ajvResolver` and borrows only `FormContext` and `schemaToFormDefault` from `@owlmeans/client-panel`; `TextInput` drives `react-hook-form`'s `Controller` directly, and the buttons call `handleSubmit` directly — none of them wraps `ClientForm`, `InputCtrl` or `ActionCtrl` |
79
74
  | `ImageUploader` / `Layout` | The uploader in a drop target with a preview, and a plain content wrapper (the shell is `NavLayout`) |
80
75
  | `scalingToStyles(h, v)` | `BlockScaling` → the width/height utility classes the panels share |
81
76
  | `useBreakPoint()` | The current Tailwind breakpoint name, tracked on `resize` (`lg` when there is no `window`) |
@@ -90,19 +85,12 @@ root unless the import column names a subpath.
90
85
  - `./auth/entrypoints` — auth panel entrypoint declarations
91
86
  - `./consent` — the cookie consent dialog and policy, bound to this app's i18n, plus a menu-row
92
87
  widget and a presence service so a host's own collapsed menu can take over the floating button's
93
- job
94
- - `./scheme` — the React-free colour-scheme half: `COLOR_SCHEME_KEY`, `COLOR_SCHEME_EVENT`,
95
- `ColorSchemeChoice`, `colorSchemeHelper` (`readColorScheme`, `applyColorScheme`, `colorSchemeBootstrapScript`) — safe
96
- to import from a Node build script
97
- - `./picker` — the standard icon + colour picker and its parts (`IconColorPicker`, `ColorPicker`,
98
- `IconPicker`, `ColorSwatches`, `IconGrid`, `IconTile`, `ColorDot`, `usePickerTranslate`), the icon
99
- components (`PICKER_ICONS`, `pickerIconHelper`) and everything of `./picker/vocabulary` — see
100
- *Subpath: `./picker`* below. Not re-exported by the root, so only a bundle importing it carries
101
- the icon catalogue
102
- - `./picker/vocabulary` — the React-free half: the 24-colour palette (`PICKER_COLORS`,
103
- `PICKER_COLOR_KEYS`), the icon catalogue's names, categories and search words
104
- (`PICKER_ICON_ENTRIES`, `PICKER_ICON_NAMES`, `PICKER_ICON_CATEGORIES`), the fallbacks and
105
- `pickerColorHelper` — safe on a server, in a seed or a Node build script
88
+ job — see [parts/consent.md](parts/consent.md)
89
+ - `./scheme` — the React-free colour-scheme half, safe to import from a Node build script — see
90
+ [parts/theme.md](parts/theme.md)
91
+ - `./picker` — the standard icon + colour picker and its parts, not re-exported by the root; and
92
+ `./picker/vocabulary`, its React-free half (palette, icon names, `pickerColorHelper`), safe on a
93
+ server, in a seed or a Node build script — see [parts/picker.md](parts/picker.md)
106
94
 
107
95
  ## Usage
108
96
 
@@ -125,305 +113,6 @@ layer below it, applies idempotent `append*(context)` mixins, and returns that s
125
113
  whole shape of the file above. Nothing is stored for re-creation, and every service, resource and
126
114
  entrypoint binds to exactly the one context it was appended to.
127
115
 
128
- ### Navigation — `NavLayout`
129
-
130
- `NavLayout` is the application shell. A layout entrypoint binds a component that renders it and
131
- nothing else; the matched screen arrives as `children`. Keep the navigation as data in its own
132
- module (`src/nav.ts`) so screens, entrypoints and the shell all read the same aliases.
133
-
134
- ```tsx
135
- import { NavLayout, HOME } from '@owlmeans/web-panel'
136
- import type { PanelNavConfig, PanelNavLink } from '@owlmeans/web-panel'
137
-
138
- export const navConfig: PanelNavConfig = {
139
- sections: [
140
- { name: 'home', label: 'Home', items: [{ alias: HOME, label: 'Overview' }] },
141
- {
142
- name: 'demo', label: 'Demo', items: [
143
- { alias: web.session, label: 'Session' },
144
- { alias: web.about, label: 'About' },
145
- ]
146
- },
147
- ],
148
- }
149
-
150
- export const footerLinks: PanelNavLink[] = [
151
- { alias: HOME, label: 'My App' },
152
- { href: 'https://owlmeans.com', label: 'OwlMeans', open: true },
153
- ]
154
-
155
- export const MainLayout: FC<PropsWithChildren> = ({ children }) =>
156
- <NavLayout nav={navConfig} title="My App" footer={footerLinks}>{children}</NavLayout>
157
- ```
158
-
159
- **Navigation is two-layer.** The top menu lists **sections** — the first level; the side menu lists
160
- the **active section's screens** — the second. A section holding a single screen renders **no side
161
- menu at all**: the model owns that rule (`showSide`), so a section that later grows a second screen
162
- gains its menu with no layout change. `NavLayout` mounts `SideNav` twice — `variant="side"` as the
163
- `hidden md:block` column beside the content, `variant="bar"` as the `md:hidden` strip under the
164
- header. Both render the same items; only one is visible at a time.
165
-
166
- | Component | Props |
167
- |---|---|
168
- | `NavLayout` | `nav: PanelNavConfig`, `translate?`, `title?: ReactNode`, `home?: string` (brand target — defaults to the first section's first item), `actions?: ReactNode`, `mobileMenu?: boolean`, `skipLinkLabel?: string \| false`, `themeToggle?: boolean \| { labels? }`, `footer?: PanelNavLink[] \| ReactNode`, `headerClassName?`, `contentClassName?`, `containerClassName?`, `className?`, `style?` |
169
- | `TopNav` | `config: PanelNavConfig`, `translate?`, `ariaLabel?`, `className?`, `style?` |
170
- | `SideNav` | the same, plus `variant?: 'side' \| 'bar'` |
171
- | `MobileNav` | the same as `TopNav`; `ariaLabel` names the landmark inside the sheet, `className`/`style` land on the trigger button |
172
- | `Footer` | `links?: PanelNavLink[]`, `content?: ReactNode` (a full-width block — see below), `translate?`, `containerClassName?` (the shell's rhythm, so the footer row lines up with the header and content), `children?` (joins the centred link row), `themeToggle?: boolean \| { labels? }` (the switcher beside the credit), `className?`, `style?` |
173
- | `ShellCredit` / `useShellCredit` | `className?`; the platform/owner credit line `Footer` always renders — see below |
174
-
175
- **The narrow-viewport menu is opt-in: `mobileMenu`.** Off (the default) the shell is exactly the
176
- two-layer layout above at every width. On, below `md` (768px) the section menu is hidden
177
- (`hidden md:flex`) and `MobileNav` puts a menu button — accessible name "Menu", a 44px target, a
178
- solid focus ring — at the end of the actions row, so `actions` ("Get started", sign-in) stay in
179
- the header at every width. The button opens a shadcn `Sheet` from the right listing every section:
180
- a multi-screen section as its name over links to its screens, a single-screen section as one link
181
- carrying the section's own label, so no destination appears twice. Labels, keys and `translate`
182
- are the menus' own (`nav.<section>`, `modules.<alias>`); the button's name, the sheet title and
183
- its close button resolve `shell.menu` / `shell.close` — outside the `nav.` family, because an
184
- app may have a section called `menu`. Rules the component owns:
185
-
186
- - **The screen strip is not mounted when `mobileMenu` is on.** The sheet already lists the active
187
- section's screens; a strip under the header would be a second menu for the same level. The side
188
- column (`hidden md:block`) is untouched — wide viewports are the same shell either way.
189
- - **An entry navigates AND closes from its own click handler.** It is an `<a href>` (focusable,
190
- openable in a new tab) whose `onClick` calls `preventDefault()` for in-app navigation — which a
191
- dialog never notices, so a sheet left to close itself stays open over the screen it just
192
- navigated to.
193
- - **The sheet states both halves of its surface** (`bg-background text-foreground`): it is
194
- portalled to the document body and inherits nothing from the shell.
195
- - Pinned by `nav.spec.ts` → "the narrow-viewport menu", at 375px and 1280px, with and without
196
- the prop (`?mobileMenu=1` in the harness).
197
-
198
- **The skip link comes first, and `main` is its target.** `NavLayout` renders
199
- `<a href="#main" data-skip-link>` as the FIRST child of its root — before `<header>`, never inside
200
- it, because `header > div` (first) is the shared-rhythm row the layout pins locate. It is `sr-only`
201
- until focused, then a pill fixed in the top-left corner (`bg-foreground text-background`,
202
- `rounded-full`, `z-50`, the 3px `--ring` focus ring); its padding is `focus:`-only, since a plain
203
- `px-*` sorts after `sr-only` and leaves a padded box behind the clip. Activating it focuses
204
- `<main id="main" tabIndex={-1}>` by hand (`preventDefault` + `focus()`), because a fragment
205
- navigation changes the location and the router reads that as navigation. The label is
206
- `skipLinkLabel`, defaulting to `translate('shell.skip', 'Skip to content')`. `skipLinkLabel={false}`
207
- renders no link AND leaves `main` without the id: an application rendering its own skip link owns
208
- its own `#main`, and no two elements may share it. A screen never renders a second `<main>`.
209
- Pinned by `nav.spec.ts` → "the skip link" (Tab from the page start, Enter lands on `main#main`,
210
- `?skip=off`).
211
-
212
- **A node `footer` is a full-width block, never a replacement.** An array renders the centred link
213
- row. A node — an application's own footer layout: a brand, a description, link columns — goes to
214
- `Footer`'s `content` and renders as a `w-full self-stretch text-start` block (`data-footer-content`)
215
- inside the footer's container, above the credit. The container keeps `items-center` for the link
216
- row and the credit; only the block stretches. It used to land INSIDE the centred link row, where
217
- it shrink-wrapped to its content in the middle of the page. `ShellCredit` stays the last thing the
218
- footer renders in every case — the container's own last child, or, with `themeToggle`, first in the
219
- bottom row `[data-footer-bottom]` with the switcher after it. Pinned by `nav.spec.ts` → "a node footer" (`?footer=node`).
220
-
221
- **The style slots are REGIONS, and each region is its own SURFACE.** `className` is the root —
222
- the full-height page *behind* the header, side menu and footer. `headerClassName` is the sticky
223
- top bar. `contentClassName` is the content area. `containerClassName` is width and padding for
224
- all three at once, never colour.
225
-
226
- The header paints an opaque background of its own, because it is sticky and content scrolls
227
- beneath it. That makes it a **different surface from the root**, so it states `text-foreground`
228
- alongside its `bg-background` — not as decoration, and not redundantly. A root carrying a
229
- contrasting pair (`className="bg-primary text-primary-foreground"`, an ordinary dark shell)
230
- otherwise inherits its near-white foreground into a near-white bar, and every header child that
231
- states no colour of its own — the brand, a ghost-variant action button — is painted in the
232
- foreground of a surface it is not on. It type-checks, it builds, it renders, and the menu is
233
- invisible. Pinned by `tests/nav.spec.ts` → "the header is its own surface", which measures
234
- rendered lightness rather than class names; the harness root carries a dark shell permanently so
235
- every navigation test runs against that case.
236
-
237
- **The header is opaque even when `headerClassName` fails to be.** Behind the header's own visible
238
- content sits a `[data-nav-backdrop]` layer that always paints `bg-background`, at negative
239
- z-index inside the header's own `isolate` stacking context — so it never covers a caller's actual
240
- header content, only whatever the header's own background would have been. It exists because
241
- `headerClassName` is free text a design or restyle pass writes, and three shapes of it leave
242
- tailwind-merge with no background utility at all on the header: a bare `bg-transparent`, an alpha
243
- surface (`bg-<x>/50`), or Tailwind v3's dead syntax for referencing a CSS variable
244
- (`bg-[--my-var]` — square brackets; v4's real shorthand is `bg-(--my-var)`, parentheses). Any of
245
- those used to leave the sticky header fully see-through, with page content visibly scrolling
246
- through the menu. A caller that genuinely wants a coloured bar still gets it: `headerClassName`'s
247
- own background renders on top of the backdrop. Pinned by `nav.spec.ts` → "a broken headerClassName
248
- still leaves the backdrop layer opaque".
249
-
250
- **`Footer` always renders the platform/owner credit line, and there is no prop that hides it.**
251
- Below the content block and the links (or on its own, when a layout passes no `footer` at all — `NavLayout` renders
252
- `<Footer>` unconditionally now), `ShellCredit` shows "Powered by OwlMeans" and the owner's own
253
- copyright notice, resolved from `security.auth.login.credit` via `resolveCredit`
254
- (`@owlmeans/client-auth/login`) — the SAME resolver and the SAME config the sign-in screen's
255
- `LoginCredit` reads, so an owner who pays to drop the platform credit (`poweredBy: false`) drops
256
- it everywhere, not only on a screen a signed-in visitor may never see again. Order is the opposite
257
- of the sign-in screen's: the owner's own notice leads, the platform credit follows — a footer is
258
- read as "whose page is this, and who built it". The links row and the credit are both centred
259
- (`justify-center`), not left-aligned. `useShellCredit()` is exported separately so a caller that
260
- needs to know whether there is anything to show (without rendering it) can ask, exactly how
261
- `Footer` itself decides whether to render `null`. Pinned by `nav.spec.ts` → "the shell footer
262
- credit".
263
-
264
- **Every style slot is MERGED over its default — none of them substitutes.** `className`,
265
- `headerClassName`, `contentClassName` and `containerClassName` all go through `cn`, so a caller
266
- names only the utility it wants to move and tailwind-merge drops just the one it conflicts with.
267
- This matters most for `containerClassName`, whose default is a four-part rhythm
268
- (`mx-auto w-full max-w-6xl px-4`): a design asking for a wider page writes `max-w-[1280px]` and
269
- means *wider*, not *unpadded and uncentred*. Substituting there took `px-4` and `mx-auto` down
270
- with the width and left the header, content and footer all flush to the window edge. Pinned by
271
- `nav.spec.ts` → "a width-only rhythm override keeps the side padding"; the harness passes a
272
- width-only override permanently.
273
-
274
- **A dark top bar is asked for with `headerClassName`**, giving it both halves
275
- (`bg-secondary text-secondary-foreground`) — never by colouring the root and expecting the bar
276
- to follow.
277
-
278
- **The shell reads exactly these colour variables**: `--background` (page and top bar),
279
- `--foreground` (active section link), `--muted-foreground` (resting section links),
280
- `--accent`/`--accent-foreground` (active side-menu item), `--border` (the header, side-menu and
281
- footer rules) and `--primary` — the footer renders its entries through `Link`, which paints
282
- `text-primary`; `mobileMenu` adds `--ring` (the menu button's and the sheet entries' focus rings)
283
- and a `bg-black/50` overlay. It reads **no `--sidebar*` variable at all**. So `--muted-foreground` is not merely
284
- the text colour of the `--muted` surface — it is secondary text sitting directly on `--background`,
285
- and a theme that lightens it to suit a dark muted panel loses its top menu.
286
-
287
- Rules that make the shell behave:
288
-
289
- - **Labels never reach for i18n implicitly.** `translate` is a **prop** (`NavTranslate`), defaulting
290
- to `defaultNavTranslate`, which returns the fallback. An app mounted with `renderApp` from
291
- `@owlmeans/web-client` has no i18n provider, and the panel i18n hook dereferences `i18n.options`
292
- on the empty object `react-i18next` returns without an instance — a throw inside render that
293
- blanks the whole app. An app that does have i18n passes its own
294
- `(key, defaultValue) => string` resolver. Order: literal `label` → `translate(key, humanized)` →
295
- humanized alias. Default keys are
296
- `nav.<section>` for sections and `modules.<alias>` for items and footer links — the same family
297
- `Link` uses.
298
- - **Menu entries are real links.** `TopNav` puts the resolved path on `href` and calls
299
- `preventDefault()` in `onClick`, navigating in-app through `nav.press`. Never drop the `href`: an
300
- `<a>` without one is not focusable, does not answer the keyboard, cannot be opened in a new tab,
301
- and does not even carry the `link` role.
302
- - **A parent route needs a `default: true` child.** A frontend entrypoint that has children but no
303
- child declared `default: true` renders blank at its own path — give a grouping screen an index
304
- child at `'/'`.
305
- - **Vendor `navigation-menu` and `sheet`.** `SideNav` builds on the package-local `Button`; `TopNav`
306
- uses the package-local shadcn `navigation-menu` primitive and `MobileNav` the package-local
307
- `sheet` (over `@radix-ui/react-dialog`, a peer) — see the package-boundary rule below.
308
-
309
- ### Light and dark — `ThemeToggle`, `useColorScheme`, `./scheme`
310
-
311
- The standard switcher, with no theme library behind it. `NavLayout themeToggle` (or `Footer
312
- themeToggle`) puts it in the footer's bottom row beside the credit, whatever `footer` is; absent or
313
- `false`, the footer's DOM is what it is without one. It is also mountable on its own.
314
-
315
- **The contract is two classes on `<html>`.** `dark` = the visitor chose dark, `light` = chose light,
316
- neither = follow the operating system. The choice is stored under `COLOR_SCHEME_KEY`
317
- (`owlmeans:color-scheme`, `localStorage`, every access in `try`). `light` exists as a class of its
318
- own because a visitor choosing light on a dark system has no other way to say so to a stylesheet
319
- that paints dark under `prefers-color-scheme: dark`. So a consumer's CSS must read BOTH:
320
-
321
- ```css
322
- :root { color-scheme: light; /* light tokens */ }
323
- .dark { color-scheme: dark; /* dark tokens */ }
324
- @media (prefers-color-scheme: dark) {
325
- :root:not(.light) { color-scheme: dark; /* the same dark tokens */ }
326
- }
327
- /* Tailwind v4: a `dark:` variant that follows the same rule */
328
- @custom-variant dark {
329
- &:where(.dark, .dark *) { @slot; }
330
- @media (prefers-color-scheme: dark) { &:where(:root:not(.light), :root:not(.light) *) { @slot; } }
331
- }
332
- ```
333
-
334
- A theme with dark tokens under `.dark` alone never follows the OS; one with them under the media
335
- query alone ignores an explicit light choice.
336
-
337
- **The head bootstrap goes before any stylesheet paints.** `colorSchemeHelper.colorSchemeBootstrapScript()` returns a
338
- self-contained inline script that puts the stored choice's class on `<html>` before first paint —
339
- the only moment it can; a component doing it after mount shows a visitor who chose dark one frame
340
- of the light page. A build step inlines it into `index.html` (import it from
341
- `@owlmeans/web-panel/scheme` — that module imports nothing, React included) and a CSP that hashes
342
- inline scripts must hash it. Without the bootstrap, `useColorScheme` re-applies the stored class on
343
- mount: correct, one frame late.
344
-
345
- Rules the pieces own:
346
-
347
- - **The toggle flips the RESOLVED scheme** (`choice ?? system`), never the stored choice alone: on
348
- a dark system with nothing stored the first press means light. The icon is the scheme the page
349
- is in (sun while light, moon while dark), chosen in script, not by a `dark:` variant.
350
- - **The accessible name says what pressing does** — `labels.toLight` / `labels.toDark`, English
351
- defaults; `NavLayout` resolves them through `translate` (`shell.toLight`, `shell.toDark`).
352
- - **One choice per document.** `colorSchemeHelper.applyColorScheme(choice | null)` sets the class, stores or clears
353
- the key and dispatches `COLOR_SCHEME_EVENT`, so every mounted `useColorScheme` agrees at once;
354
- another tab's change arrives through `storage`, and an OS switch through `matchMedia` while
355
- nothing is stored. `setChoice(null)` hands the page back to the OS.
356
- - **Look:** a 44px round target, `text-muted-foreground` → `hover:text-foreground`, the 3px
357
- `--ring` focus ring, no fill, border, gradient or shadow.
358
-
359
- Pinned by `tests/scheme.spec.ts` (`?themeToggle=1`): the module's imports, the bootstrap string,
360
- the class flip and persistence, the resolved flip under an emulated dark system, a node footer,
361
- and the absent case.
362
-
363
- ### Toasts — `Toaster`
364
-
365
- An action that succeeded or failed says so in a toast. The surface is `Toaster`; the messages are
366
- `toast.success(...)` / `toast.error(...)` imported from **`sonner`** by whatever raised them.
367
-
368
- ```tsx
369
- import { NavLayout, Toaster } from '@owlmeans/web-panel'
370
-
371
- export const MainLayout: FC<PropsWithChildren> = ({ children }) => <>
372
- <NavLayout nav={navConfig} title="My App">{children}</NavLayout>
373
- <Toaster />
374
- </>
375
- ```
376
-
377
- - **Mount it exactly once, in the layout.** `toast()` writes to a module-global store, so two
378
- mounted `Toaster`s render every message twice and none renders any of them — both failures are
379
- silent, which is why `tests/toaster.spec.ts` asserts the count rather than the presence.
380
- - **Colours come from the app's own tokens** — `--popover`, `--popover-foreground`, `--border` —
381
- so a toast matches every other floating surface. A caller's `style` merges over them.
382
- - **The theme follows the `.dark` class on the document element**, not a theme provider: the
383
- package reads the class an app's `next-themes`, the owl theme provider, or a hand-written
384
- toggle all set, and depends on none of them. Pass `theme` explicitly to override.
385
- - Defaults are `richColors`, `closeButton`, `duration={5000}`, `position="top-right"`; every one
386
- of them, and every other `ToasterProps` field, is overridable per app.
387
- - `sonner` is a dependency of this package, so nothing is required of the consumer — but an app
388
- raising its own toasts should declare `sonner` too, at a range that resolves to the same copy.
389
-
390
- `appendPermissionDeniedToast(context)` plugs into the client entrypoint failure service and
391
- shows a localized access-denied toast for errors carrying the server IAM marker. Mount `Toaster`
392
- once as usual. The toast exposes the technical message and incident id in a separate details
393
- section. The app supplies its locale through its i18n context.
394
-
395
- ### Reload prompt — `SocketReloadDialog`
396
-
397
- `makeContext` calls `appendSocketStatus` from `@owlmeans/client-socket` unconditionally, and
398
- `PanelApp` mounts `SocketReloadDialog` as a sibling of the Router — exactly where
399
- `PanelCookieConsent` lives, and for the same reason: a dialog mounted inside a route is torn down
400
- on every navigation. Neither does anything unless an app opts in:
401
-
402
- ```typescript
403
- cfg.socket = { reloadDialog: true }
404
- ```
405
-
406
- Once any `socketClientHelper.ws()`/`useWs()` connection in the app has exhausted its own retry budget
407
- (`useSocketStatus() === 'lost'`), a global, blocking `AlertDialog` covers the screen — no Escape,
408
- no outside click, two actions:
409
-
410
- - **"Try again"** (`data-socket-retry-action`) — `useSocketRetry().retry()` revives every lost
411
- connection (see the `client-socket` skill). The dialog stays up with the button disabled and
412
- reading "Reconnecting…" while they retry, closes once the aggregate is `'online'`, and offers
413
- both buttons again if it falls back to `'lost'`.
414
- - **"Reload page"** (`data-socket-reload-action`) — `window.location.reload()`, the fallback that
415
- always works.
416
-
417
- The status service also retries by itself when the tab becomes visible, the window gains focus or
418
- the browser comes back online, so a socket that died in a background tab is usually back before
419
- anyone presses anything — an open dialog shows "Reconnecting…" through that too. Strings are
420
- lib-tier (`useI18nLib('socket', 'reload')`: `title`, `description`, `action`, `retry`,
421
- `retrying`), 8 languages, under `src/components/socket/i18n/`.
422
-
423
- Leave `cfg.socket.reloadDialog` unset (or `false`) for an app that would rather show its own
424
- inline "reconnecting…" state — `useSocketStatus()` from `@owlmeans/client-socket` serves that,
425
- independent of the dialog.
426
-
427
116
  ### Links — `Link`
428
117
 
429
118
  `Link` renders an `<a>` whose `href` is the entrypoint's own answer: it asks
@@ -444,342 +133,6 @@ known — so never key a test or a layout on the anchor having an `href` synchro
444
133
  falls back to `modules.<alias>` when neither `name` nor `children` is given, `open` adds
445
134
  `target="_blank"` with `rel="noopener noreferrer"`, and `center` centres the text.
446
135
 
447
- ### Forms — `Form`, `TextInput`, the buttons
448
-
449
- `WebFormProps` is `@owlmeans/client-panel`'s `FormProps` plus `className` and `style`. `Form` holds
450
- the whole model itself — `useForm` with `mode: 'all'`, `delayError: 300`, an `ajvResolver` over
451
- `validation` with `coerceTypes` and the `ajv-formats` formats — and publishes it through
452
- `FormProvider` plus `FormContext`, so every control below reads one form.
453
-
454
- ```tsx
455
- import { Form, TextInput, SubmitButton, Button } from '@owlmeans/web-panel'
456
-
457
- <Form decorate validation={schema} onSubmit={async (data, update) => { await save(data); update(data) }}>
458
- <TextInput name="email" label placeholder hint />
459
- <TextInput name="password" type="password" label="Password" />
460
- </Form>
461
- ```
462
-
463
- - **`decorate` switches the whole rendering.** With `decorate={true}` the fields go inside a shadcn
464
- `Card`, the root error surfaces through `Status`, and a `SubmitButton` is rendered in the
465
- `CardFooter` **whenever `onSubmit` is given** — the caller writes no action. Without it (the
466
- default) `Form` is a bare flex column: no card, no root-error surface, no submit button, so the
467
- caller renders its own action. `horizontal`/`vertical` scaling, `className` and `style` land on
468
- the `Card` when decorated and on that column otherwise.
469
- - **`formRef`** — a `useFormRef()` ref filled with `{ form, update, loader, error }`, which is how a
470
- caller drives the form, flips the loader, or plants a field/root error from outside.
471
- - **`TextInput` takes `label`, `placeholder` and `hint` as `string | boolean`.** `true` resolves
472
- `<name>.label` / `<name>.placeholder` / `<name>.hint` from the form namespace; a string is used
473
- verbatim; anything else renders nothing. It also takes `name`, `def`, `type` (any HTML input type,
474
- default `text`) and `disableAutocomplete`. A field error replaces the hint line.
475
- - **`Button`** takes a required `label`, `onClick`, `loader` (a `Toggleable` — open disables the
476
- button and shows the spinner), `size` (`small`/`medium`/`large`), `fullWidth`, and `variant`,
477
- which maps the MUI vocabulary onto shadcn (`contained` → `default`, `outlined` → `outline`,
478
- `text` → `ghost`) and forwards a shadcn variant name unchanged.
479
- - **`SubmitButton`** is that button bound to `handleSubmit`, taking `onSubmit` (or `onClick`) and a
480
- `label` defaulting to `submit`. It resolves the label with the form `t` itself and passes it down
481
- with `i18n.suppress` set, so the label is translated once.
482
- - **`ButtonSelector`** renders one `Button` per entry of `options`, the one equal to `current`
483
- `contained` and the rest `outlined`, calling `onSelect(option)`. `name` prefixes each option's
484
- label key as `<name>.<option>`.
485
-
486
- ### The terms confirmation — `LoginTerms`, `LoginPrivacyNotice`
487
-
488
- `components/login/terms.tsx` renders `LoginTermsModel` (`@owlmeans/client-panel/auth`) via
489
- `loginTermsHelper.termsSentence` + `.termsLabelResolver` (`@owlmeans/client-auth/login`) rather than re-deriving
490
- link/label pairs itself. **`[data-login-terms]` marks exactly one checkbox — or NONE, once
491
- `model.terms.deferred` is true** (`LoginScreen` renders `LoginTerms` when not deferred,
492
- `LoginPrivacyNotice` when it is): an e2e suite elsewhere in the platform treats the checkbox as a
493
- strict, at-most-one-match locator, so a change here must never add a second one, whatever
494
- billing/product/custom documents a configuration adds. `[data-login-privacy]` is a SIBLING
495
- paragraph of the checkbox's `<label>`, never nested inside it — a privacy disclosure is not
496
- something the checkbox consents to — and it is exported ON ITS OWN as `LoginPrivacyNotice`: it
497
- still renders while deferred, since the disclosure was never the checkbox's to remove. A linked
498
- document/notice fragment carries `data-login-document="<key>"`. `[data-login-revised]` renders only
499
- when NOT deferred and the resolved terms carry a `revisedAt` (i.e. the configuration set
500
- `showRevision: true` and at least one document has its own revision date) — deferred, that date
501
- belongs to the document the checkbox agreed to, and there is no checkbox on this screen to attach it
502
- to. `LoginTerms`/`LoginPrivacyNotice` take `locale?: string` — from `LoginScreen`'s own `locale`
503
- prop, never read from context directly — for `Intl.ListFormat` and a custom document's locale-keyed
504
- `labelMap`. Method buttons carry `aria-disabled={model.blocked || undefined}`, never a literal
505
- `"false"` — a deferred screen (which never sets `blocked`) then renders identically to an unblocked
506
- ordinary one, with no special case. Pinned by `tests/login.spec.ts` → the "with billing, product and
507
- custom documents configured" and "the Terms confirmation deferred to a step" blocks.
508
-
509
- ### The provider disclosure — `LoginProviderNote`
510
-
511
- `LoginScreen` renders `useLoginMethods().provider` (`@owlmeans/client-panel/auth`, resolution rules
512
- there) through `components/login/provider.tsx`:
513
- `<div role="note" data-login-provider data-placement="top|inline">` — a `ShieldCheck` icon, the
514
- sentence, and, when `info` resolved, `<a data-login-provider-info target="_blank"
515
- rel="noopener noreferrer">More information</a>`. Never a `header`/`nav`: it is a disclosure, not a
516
- landmark.
517
-
518
- - **No provider → the DOM is exactly the pre-provider screen** (no note, no stage, title
519
- `login.title`).
520
- - **`top`** (the platform's own hosts) — the strip is the FIRST child of `[data-login-screen]`,
521
- full width, at the screen's top edge; the card sits in `<div data-login-stage>`, which takes the
522
- remaining height and centres it. The screen's column layout, its zero padding, the stage's flex
523
- centring/padding and the strip's width are INLINE styles (same reason as the screen height —
524
- `login-methods` → Layout); classes there are cosmetic only.
525
- - **`inline`** (an owner's own domain) — the note is one line inside `CardContent`, directly under
526
- the methods and above the terms; the screen keeps its ordinary shape.
527
- - The card title becomes `login.provider.title` ("Sign in to {{product}}") whenever a provider
528
- resolves; `props.title` still wins.
529
- - Copy: `login.provider.{title,top,inline,more}` in all eight languages
530
- (`components/login/i18n/*.json`), registered by `components/login/i18n.ts` into the SAME `auth`
531
- library resource as `client-auth`'s login copy (deep-merged; this package owns only the
532
- `login.provider` subtree). Names are filled after translation by `loginProviderHelper.fill`.
533
- - Still exactly one `[data-login-terms]` in either placement. Pinned by `tests/login.spec.ts` →
534
- "the provider disclosure" (`?provider=top|inline` in the harness) and
535
- `tests/login-provider.spec.ts` (resolver, `fill`, eight-language key/placeholder parity).
536
-
537
- ## Subpath: `./consent`
538
-
539
- `PanelCookieConsent` and `PanelCookiePolicy` — `@owlmeans/web-consent`'s components bound to this
540
- app's language and translations, falling through to the package's own eight-language bundle for
541
- every key the app has not overridden — and to the app's cookie-consent configuration. See the
542
- `consent` and `web-consent` skills.
543
-
544
- **The configuration lives in `cfg.cookieConsent`** (`PanelCookieConsentConfig`, on `AppConfig`),
545
- set where the rest of the app's configuration is, before anything renders:
546
-
547
- ```typescript
548
- cfg.cookieConsent = { geo: { cloudflare: true } } // ask only in consent countries; mode: 'bar' default
549
- ```
550
-
551
- - `mode` (`'bar'` | `'window'`) and `geo` (`ConsentGeoOptions`) feed both components; a prop of the
552
- same name always wins. Not `cfg.consent` — "consent" also names the marketing-consent and sign-in
553
- terms options.
554
- - A file restored into apps of every age (a generated target's `config.ts`) writes it as
555
- `Object.assign(cfg, { cookieConsent: … })`, so a tree whose framework predates the key still
556
- type-checks; an older package ignores it.
557
- - **A locator of the app's own** is code, not configuration: `appendConsentGeoPlugin(context,
558
- plugin)` in `context.ts` registers a `ConsentGeoPlugin` (`{ alias, locate }` — resolve the country
559
- or throw) ahead of Cloudflare's and installs `context.consentGeo()`. Appending one IS asking for
560
- the gate: `PanelCookieConsent` turns it on (`geo: {}`) even where `cfg.cookieConsent.geo` says
561
- nothing. `PanelCookiePolicy` reads the same, so the policy states the regional rule exactly when
562
- the dialog applies it.
563
-
564
- A re-export does not move Tailwind class strings, so a consumer adds a second `@source` for
565
- `@owlmeans/web-consent` alongside this package's — pointing at **`src`**, for the reason spelled out
566
- under *Consumer setup* below. Without it the dialog renders half-styled.
567
-
568
- **A service wrapping a state resource is what lets a host's own menu take over the floating
569
- button's job.** `appendConsentWidgetService(context, alias?)` registers a ref-counted presence
570
- service (`@owlmeans/state`'s `appendStateResource` behind `@owlmeans/context`'s `createService`) —
571
- call it once, from the app's own `context.ts`, the same as any other `append*` mixin:
572
-
573
- ```ts
574
- import { appendConsentWidgetService } from '@owlmeans/web-panel/consent'
575
-
576
- appendConsentWidgetService<C, T>(context)
577
- ```
578
-
579
- Then call `useConsentMenuPresence()` from the host menu's own **always-mounted** shell component,
580
- never from inside the row. A dropdown's content (Radix `DropdownMenuContent` and most headless
581
- menu content primitives) mounts its children only while the menu is OPEN, so presence announced
582
- from inside the row hid the floating button only while the dropdown happened to be open, and
583
- showed it again the instant it closed. The row is also not a `DropdownMenuItem`: an item takes the
584
- inner button's focus and closes the menu on the click before the button's own handler runs.
585
-
586
- ```tsx
587
- import { PanelConsentMenuWidget, useConsentMenuPresence } from '@owlmeans/web-panel/consent'
588
-
589
- const MyMenu: FC = () => {
590
- useConsentMenuPresence() // declares the row reachable for as long as THIS component is mounted
591
- return <DropdownMenu>
592
- <DropdownMenuTrigger>…</DropdownMenuTrigger>
593
- <DropdownMenuContent>
594
- <div className="px-2 py-1.5"><PanelConsentMenuWidget /></div> {/* a plain row, not an item */}
595
- </DropdownMenuContent>
596
- </DropdownMenu>
597
- }
598
- ```
599
-
600
- `PanelCookieConsent` reads the same service (`useConsentWidgetPresent()`, internally) and computes
601
- `noReopenButton` from it whenever the caller has not passed one explicitly — an app that both
602
- mounts `PanelCookieConsent` at its root and calls `useConsentMenuPresence()` from its collapsed
603
- menu gets the floating button exactly while the menu is not, with no further wiring. Ref-counted
604
- rather than a boolean latch, because more than one menu shell can be mounted for one commit during
605
- a layout transition (a stale header still showing its own collapsed menu while a new screen's own
606
- menu has already mounted) and because React 18 StrictMode double-invokes mount/cleanup in dev.
607
-
608
- **The pair a consumer imports** — the dialog and the control that takes over its floating button:
609
-
610
- | Job | Import from `@owlmeans/web-panel/consent` | Where it goes |
611
- |---|---|---|
612
- | The consent UI — bar, window, spinner (and its floating re-open button) | `PanelCookieConsent` | Beside the router — a `PanelApp` child — once |
613
- | Asking only in consent countries | `cfg.cookieConsent.geo`, or `appendConsentGeoPlugin(context, plugin)` | The app's `config.ts` / `context.ts` |
614
- | The "Cookie settings" control in a footer or menu | `PanelConsentMenuWidget` (`label`, `className`, `onSelect?` — defaults to `consentStore.open('reopen')`) | Inside the host's own footer/menu |
615
- | Hiding the floating button while that control is reachable | `useConsentMenuPresence()` | Called by the always-mounted component that renders the control — mounted ONLY while the control can actually be reached: a component that is merely hidden by CSS (`xl:hidden`) is still mounted and still claims presence, so the floating button vanishes with nothing else to open the dialog; render it conditionally instead (viable's header mounts its hamburger menu only below the full-row breakpoint) |
616
- | Registering the presence service | `appendConsentWidgetService(context)` | The app's `context.ts` |
617
-
618
- A footer is the simplest host, because it is always mounted — the control and the presence call
619
- live in one component:
620
-
621
- ```tsx
622
- const CookieSettings: FC = () => {
623
- useConsentMenuPresence()
624
- return <PanelConsentMenuWidget label={t('footer.cookies')} className="w-auto text-muted-foreground" />
625
- }
626
- ```
627
-
628
- The widget renders a `<button data-consent-menu-widget>` styled as a menu row (`w-full`,
629
- `text-popover-foreground`, `hover:bg-accent`); a footer passes `className` to restyle it (merged
630
- through `cn`) and its own `label` — the packaged default reads "Cookie preferences".
631
-
632
- **The presence service is optional, and nothing throws without it.** `PanelCookieConsent` checks
633
- the context for `consentWidget` and, when `appendConsentWidgetService` never ran, renders the plain
634
- bound dialog with its floating button; `useConsentMenuPresence()` then has nothing to claim and
635
- does nothing. The presence read itself (`useConsentWidgetPresent`) still needs the service — its
636
- state resource is registered by it — so any other caller checks first. A dialog that read an
637
- unregistered state resource threw inside render ("Resource consent-widget-presence not found") and
638
- blanked the whole application. Pinned by `tests/consent.spec.ts` (`?consent=bare` / `?consent=menu`;
639
- the configuration by `?consentCfg=window|bar`, `?modeProp=` and `?consentGeo=<CC>|fail`).
640
-
641
- ## Subpath: `./picker`
642
-
643
- The standard editor and renderer of an icon and a colour **a person chooses** — a workcard type's
644
- or a status flow's mark, a label, a category. One component family, so every application offers
645
- the same 24 colours, the same curated icons and the same behaviour on a desktop and a phone.
646
-
647
- ```ts
648
- import {
649
- IconColorPicker, ColorPicker, IconPicker, ColorSwatches, IconGrid, IconTile, ColorDot, usePickerTranslate,
650
- pickerColorHelper, pickerIconHelper, PICKER_COLORS, PICKER_COLOR_KEYS,
651
- } from '@owlmeans/web-panel/picker'
652
- // Server, seed, build script — the data and the colour helper without React or icons:
653
- // import { pickerColorHelper, PICKER_COLOR_KEYS } from '@owlmeans/web-panel/picker/vocabulary'
654
- ```
655
-
656
- ### Which part
657
-
658
- | Part | When |
659
- |---|---|
660
- | `IconColorPicker` | Both halves of a mark. One trigger (the tile, "Rocket · Indigo", a chevron — or `trigger="tile"` for the tile alone) opens swatches above a searchable, categorized icon grid tinted in the chosen colour. Stays open for the other half; Esc, a click outside or (sheet) Done closes it |
661
- | `ColorPicker` | A colour alone (`trigger="button"` or `"dot"`). Closes on a swatch. `colors` takes an application palette — e.g. status tones |
662
- | `IconPicker` | An icon alone, tinted in `color`. Closes on a pick |
663
- | `ColorSwatches`, `IconGrid` | Inline, for a dialog or sheet the application already owns — no trigger, no surface of their own |
664
- | `IconTile` | A stored mark shown: the icon in the colour's ink on its ground (`variant="tile"`), or the ink alone (`"plain"`); `size` `xs`/`sm`/`md`/`lg`; `label` makes it an image, else decorative |
665
- | `ColorDot` | A stored colour as a dot in its ink (`size` `xs`/`sm`/`md`) |
666
- | `usePickerTranslate()` | The `translate` for an app rendered by `PanelApp` / `render()` — the packaged bundle in the current language |
667
-
668
- ### Values
669
-
670
- - **Colour**: a palette key — `red orange amber yellow lime green emerald teal cyan sky blue indigo
671
- violet purple fuchsia pink rose maroon brown olive navy slate stone graphite` (`PICKER_COLOR_KEYS`,
672
- hue order) — or, only with `customColors`, a lowercase `#rrggbb`. Never a label ("Indigo").
673
- - **Icon**: a kebab-case lucide name from the catalogue (`PICKER_ICON_NAMES`, ~250 in ten categories,
674
- a superset of the planning kits' icons). Never a component, never a label.
675
- - `IconColorPicker` emits `onChange({ icon, color })` — both halves as they now stand; a half
676
- never chosen stays `undefined`, never `null`. `value` tolerates `null`s and absent halves.
677
- - An unknown stored value renders the fallback (`slate`, `circle`) and never throws, so a value
678
- from an older palette or a typo never breaks a page.
679
- - `customColors` (default `false`) adds a colour wheel (react-colorful) and a hex field: a full
680
- `#rrggbb` commits while typing, `#rgb` / `abc` on Enter or blur, always normalized to lowercase six
681
- digits; anything else marks the field `aria-invalid` and changes nothing. A custom colour's ink
682
- and grounds are derived to keep 4.5:1 in both modes.
683
-
684
- ### Surfaces
685
-
686
- `surface="auto"` (default) opens a popover from 768px up and a bottom sheet below — decided when
687
- it OPENS, kept until it closes. `surface="popover" | "sheet"` forces one. The popover is modal, so
688
- inside an application's own Dialog or Sheet the wheel scrolls it and focus stays in it; it keeps
689
- 12px off the edges and scrolls inside rather than growing past the viewport. The sheet has a grab
690
- handle, a sticky header with the preview and a 44px Done button, `88dvh` at most and safe-area
691
- padding. Below 768px and on coarse pointers every cell, chip, field and button is at least 44px;
692
- the search field takes focus on open only for a fine pointer (no keyboard pops up on a phone).
693
- Keyboard: arrows move in two dimensions through swatches and icons (one tab stop each), Home/End,
694
- ArrowDown from the search into the grid and ArrowUp back, Enter/Space picks, Esc closes and focus
695
- returns to the trigger. Every control is a `<button type="button">` with an `aria-label`, so a
696
- pick never submits an enclosing form.
697
-
698
- ### Strings
699
-
700
- The parts never read i18n implicitly — rendered without an i18n instance they show English. Pass
701
- `translate={usePickerTranslate()}` in an app rendered by `PanelApp` / `render()`: chrome, colour
702
- names and category names come from the lib-tier `picker` bundle in eight languages (en, pl, ru, be,
703
- uk, es, de, fr); icon names stay English. An app overrides a string at the App tier in the `lib`
704
- namespace (`i18nHelper.addI18nApp('de', 'picker', { colors: { teal: 'Türkis' } }, { ns: LIB_NAMESPACE })`).
705
- Keys: `label`, `icon`, `color`, `placeholder.{both,icon,color}`, `nothing`, `done`, `search`,
706
- `searchPlaceholder`, `categoriesLabel`, `all`, `results` (`{count}`), `empty` (`{query}`), `clear`,
707
- `custom`, `hex`, `invalidHex`, `colors.<key>`, `categories.<category>`. `label` and `placeholder`
708
- props override per instance. Only the 24 palette names translate: an application palette passed as
709
- `colors` shows its `label`s as given, so translate them before passing them in.
710
-
711
- ### RIGHT and WRONG
712
-
713
- ```tsx
714
- import type { IconColorChange } from '@owlmeans/web-panel/picker'
715
-
716
- // RIGHT — local state
717
- const translate = usePickerTranslate()
718
- const [mark, setMark] = useState<IconColorChange>({ icon: type.icon, color: type.tone })
719
- <IconColorPicker label="Mark" value={mark} onChange={setMark} translate={translate} />
720
-
721
- // RIGHT — react-hook-form: the field holds { icon, color }; the picker is the whole control
722
- <Controller
723
- name="mark" control={form.control}
724
- render={({ field }) => <IconColorPicker
725
- id="mark" label="Mark" value={field.value} onChange={field.onChange} disabled={field.disabled} translate={translate}
726
- />}
727
- />
728
- // …and on save: { tone: values.mark.color, icon: values.mark.icon }
729
-
730
- // WRONG — a Select of colour names, a hand-made swatch row, or a label stored instead of the key
731
- <Select value={tone} onValueChange={setTone}>{PICKER_COLOR_KEYS.map(k => <SelectItem value={k}>{k}</SelectItem>)}</Select>
732
- {['red', 'blue'].map(c => <button className={`bg-${c}-500`} onClick={() => setTone(c)} />)}
733
- onChange={next => save({ tone: 'Indigo' })}
734
- // WRONG — the picker hands over a value, not an event
735
- <ColorPicker value={field.value} onChange={event => field.onChange(event.target.value)} />
736
- ```
737
-
738
- ### Rendering a stored mark
739
-
740
- ```tsx
741
- <IconTile icon={type.icon} color={type.tone} size="sm" /> // list row, chip, header
742
- <IconTile icon={flow.icon} color={flow.tone} variant="plain" /> // the ink alone, inline with text
743
- <ColorDot color={label.color} /> // a dot
744
- <span style={pickerColorHelper.styleOf(card.color)} className="text-(--pick-ink)">…</span> // custom markup
745
- ```
746
-
747
- `pickerColorHelper.styleOf(value, colors?)` returns the custom properties `--pick-swatch`,
748
- `--pick-ink`, `--pick-soft`, `--pick-ink-dark`, `--pick-soft-dark` (a plain string record — it goes
749
- straight into `style`). The parts switch to the dark values themselves, through explicit
750
- arbitrary variants on web-panel's scheme contract (`.dark` on `<html>`, or the system's dark
751
- unless `.light`) — never through the app's `dark:` variant. A hand-written element using
752
- `styleOf` reads `--pick-ink` / `--pick-ink-dark` itself. `pickerColorHelper.resolve(value)` gives
753
- every field (and `custom: true` for a hex); `hashKey(seed)` gives a stable palette key for a record
754
- that has none. Code-chosen tones (a status's meaning) stay the app's own classes; a colour a PERSON
755
- chose always renders through these parts.
756
-
757
- **DOM hooks** (stable, used by end-to-end suites): the trigger `data-picker-trigger`, the open
758
- surface `data-picker-surface="popover|sheet"`, a swatch `data-picker-color="<key>"` (the custom
759
- row `"custom"`, `data-selected` while a custom value is chosen), an icon cell
760
- `data-picker-icon="<name>"`, a chip `data-picker-category`, the search field `data-picker-search`,
761
- the hex field `data-picker-hex`, the sheet's Done `data-picker-done`; `IconTile` carries
762
- `data-icon-tile`, `data-icon`, `data-color`, `ColorDot` `data-color-dot`. Picker parts never carry
763
- `data-tone` or `data-slot="button"` — an application's own rules on those never reach them.
764
-
765
- ### Consumer setup
766
-
767
- - `@radix-ui/react-popover` is a peer, like the other Radix primitives: keep it on the SAME Radix
768
- generation as `@radix-ui/react-dialog` (update them together). The two share the dismissable-layer
769
- and focus-scope stacks; a popover from another generation inside a Dialog is treated as an outside
770
- click and loses focus to the dialog. `react-colorful` is a dependency of this package.
771
- - The `@source` line for this package's `src` (see *Consumer setup* below) is what brings the
772
- picker's classes — including its `[.dark_&]:` and `pointer-coarse:` variants — into the stylesheet.
773
- - Tokens read: `--popover`, `--popover-foreground`, `--border`, `--input`, `--ring`, `--accent`,
774
- `--accent-foreground`, `--primary`, `--primary-foreground`, `--muted-foreground`, `--foreground`,
775
- `--destructive`.
776
-
777
- Pinned by `tests/picker.spec.ts` (harness `/picker`: `?custom=1`, `?inSheet=1`, `?bare=1`,
778
- `?surface=…`, `?compact=1`, `?empty=1`, `?inline=1`, `?dark=1`) and
779
- `tests/picker-vocabulary.spec.ts` (palette contrast and OKLab distance, hex handling, catalogue ↔
780
- components). `PICKER_SHOTS_DIR=<dir> bun test tests/picker-shots.spec.ts` writes every scenario's
781
- screenshot at 1280 and 375, light and dark.
782
-
783
136
  ## Consumer setup — package boundary and Tailwind
784
137
 
785
138
  `web-panel` ships its shadcn primitives and `cn` helper as private implementation files under its
@@ -819,3 +172,17 @@ in a linked workspace, so it is the reliable scan target in both modes.
819
172
  - `ajv-formats` is imported at module scope by the form model but is declared in no dependency
820
173
  section of the manifest, which lists `ajv` alone. An install that does not otherwise pull it in
821
174
  fails at import time, so declare `ajv-formats` next to `ajv` in the consuming application.
175
+
176
+ ## Parts
177
+
178
+ Load a part on top of this core when its job is in play.
179
+
180
+ | Part | Load it when |
181
+ |---|---|
182
+ | [navigation](parts/navigation.md) | Building or restyling the application shell — `NavLayout`, `TopNav`/`SideNav`/`MobileNav`/`Footer`, the nav config, `mobileMenu`, the skip link, the footer credit, the header surface |
183
+ | [forms](parts/forms.md) | Writing a form (`Form`, `TextInput`, the buttons) or raising a toast (`Toaster`, `sonner`, the permission-denied toast) |
184
+ | [theme](parts/theme.md) | Adding the light/dark switch (`ThemeToggle`, `useColorScheme`), the head bootstrap from `./scheme`, or CSS that follows the scheme |
185
+ | [reload](parts/reload.md) | Opting into the "connection lost" prompt (`cfg.socket.reloadDialog`, `SocketReloadDialog`) |
186
+ | [sign-in](parts/sign-in.md) | Changing the sign-in screen's terms confirmation, privacy notice or provider disclosure |
187
+ | [consent](parts/consent.md) | Mounting cookie consent through `./consent`, its geo gate, or a host menu's cookie-settings row |
188
+ | [picker](parts/picker.md) | Letting a person choose an icon or a colour, or rendering a stored mark (`./picker`) |