@poodle64/ui 2026.9.6 → 2026.9.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,11 +33,15 @@ src/lib/
33
33
  utils.ts cn() (clsx + tailwind-merge) and the shared TS helper types
34
34
  format.ts the Australian value formatters: money, dates, percentages,
35
35
  numbers — en-AU, AUD, Australia/Brisbane
36
+ telemetry.ts browser telemetry: one initBrowserTelemetry() call, one Faro
37
+ instance, one session id (see below)
36
38
  styles.css the component stylesheet: scale keys, .ds-edge, .ds-chip/.ds-dot,
37
39
  the dialogue-section divider rule
38
40
  components/ui/ one directory per component: the shadcn-svelte primitives
39
41
  (bits-ui behaviour + shadcn markup/variants) and the composed
40
42
  page-chrome components built on top of them
43
+ components/feedback/ ReportWidget: the fixed bottom-right "report a problem"
44
+ trigger and dialogue every app mounts (see below)
41
45
  dist/ generated by `pnpm build` (@sveltejs/package); never edit
42
46
  ```
43
47
 
@@ -160,6 +164,28 @@ That is the whole minimum. Everything below is optional.
160
164
  | `texture` | The house atmosphere on the content region. Defaults to `grid`; `none` is the opt-out. |
161
165
  | `padded`, `mainClass` | Padding, and extra classes on the scrolling content container. |
162
166
 
167
+ The bar also names where you are — the active nav label, read off `nav` and
168
+ `currentPath` — and carries the page's scope controls. A page registers them
169
+ with `ShellControls`; they render after the location, inline from `xl` and on
170
+ a second row of the bar below that:
171
+
172
+ ```svelte
173
+ <script lang="ts">
174
+ import { ShellControls } from '@poodle64/ui/app-shell';
175
+ import { Segmented } from '@poodle64/ui/segmented';
176
+ let year = $state('FY26');
177
+ </script>
178
+
179
+ <ShellControls>
180
+ <Segmented bind:value={year} options={YEARS} label="Financial year" size="sm" />
181
+ </ShellControls>
182
+ ```
183
+
184
+ A scope control changes the view of the page you are on and never
185
+ navigates; sub-routes are `NavItem.children`. The rail's right edge is a drag
186
+ handle (200–420px, arrows nudge, Home resets, a drag below 170px collapses);
187
+ the width persists in `localStorage` as `ds-shell-rail-width`.
188
+
163
189
  ### Nested navigation
164
190
 
165
191
  A section with its own navigation puts it on the item, as `children`, and the
@@ -1089,6 +1115,67 @@ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
1089
1115
  too — it rides on `date-fns` in the one app that has it, and a display formatter
1090
1116
  is not worth making that a dependency of every consumer.
1091
1117
 
1118
+ ## Browser telemetry and the feedback widget
1119
+
1120
+ Two exports are app machinery rather than page chrome: `@poodle64/ui/telemetry`
1121
+ (Faro) and `@poodle64/ui/feedback` (`ReportWidget`). A household app mounts both
1122
+ once, in its client entry and root layout, and every app mounts the same pair.
1123
+
1124
+ ```ts
1125
+ // +layout.ts or the client entry — the browser leg only
1126
+ import { initBrowserTelemetry } from '@poodle64/ui/telemetry';
1127
+
1128
+ initBrowserTelemetry({
1129
+ app: 'cadmus',
1130
+ version: APP_VERSION,
1131
+ url: PUBLIC_FARO_URL
1132
+ });
1133
+ ```
1134
+
1135
+ `initBrowserTelemetry` is inert without a `url` — a private or unconfigured
1136
+ deployment ships no beacons and gets null from `getSessionId()`, with no branch
1137
+ at the call site. It is idempotent: a second call returns the first instance,
1138
+ so an HMR leg or a remounted layout cannot produce a second Faro global. The
1139
+ configuration is the house decision, not an app decision: session tracking on,
1140
+ the console captured nowhere (the console stays local by rule), and the
1141
+ tracing instrumentation on so every same-origin `fetch`/XHR carries a
1142
+ `traceparent` — the feedback POST and the page's server-side traces share one
1143
+ trace id. The faro packages are `dependencies`, not peers, for the same reason
1144
+ `bits-ui` is a peer: a duplicated Faro global would strand the session id the
1145
+ feedback widget reports against.
1146
+
1147
+ `ReportWidget` is the user-facing other half: a fixed bottom-right trigger
1148
+ opening the one report dialogue — a labelled "What happened?" textarea, and a
1149
+ screenshot checkbox (on by default) that captures `document.body` with
1150
+ `html-to-image`. A capture the page cannot produce is tolerated silently: the
1151
+ report sends with a null image rather than not at all. Submitting POSTs one
1152
+ JSON body to the `endpoint` prop (default `/api/feedback`):
1153
+
1154
+ ```json
1155
+ {
1156
+ "message": "string — the textarea, required",
1157
+ "route": "string — location.pathname at send time",
1158
+ "user_agent": "string — navigator.userAgent",
1159
+ "viewport": { "width": 1024, "height": 768 },
1160
+ "session_id": "string | null — Faro's session id",
1161
+ "screenshot": "string | null — a PNG data URL, or null"
1162
+ }
1163
+ ```
1164
+
1165
+ That body is the interface the app's own route is written against, and
1166
+ `src/test/report-widget.test.ts` asserts it field for field. The endpoint
1167
+ answers `{ "id": "…" }`; the dialogue shows the id with a thank-you (quote it
1168
+ to follow up) and fires `onSubmitted(id)` so the app can log or route on it.
1169
+ A failure keeps the draft and offers Retry — the message the user typed is
1170
+ the hard half to reproduce, and losing it to a flaky connection would make
1171
+ the retry cost more than the first attempt.
1172
+
1173
+ The widget lives under `components/feedback/`, not `components/ui/`: it is
1174
+ mounted by the layout, once, alongside the telemetry init — not composed into
1175
+ routes by situation, so it stands outside the situation→component registry
1176
+ for the same reason. `getSessionId()` reading null when telemetry is off is
1177
+ ordinary, not an error: the report sends with a null `session_id`.
1178
+
1092
1179
  ## The gate: `ds-check-colour-surface`
1093
1180
 
1094
1181
  The package ships the gate that proves its own surface actually resolves in the
@@ -1165,17 +1252,6 @@ non-zero and naming the field on any difference.
1165
1252
  1. Change a component; bump `version` in `package.json` (CalVer).
1166
1253
  2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
1167
1254
  3. Commit, tag `ui-v<version>`, push the tag.
1168
- 4. `.github/workflows/publish.yaml` runs on that push and should publish via
1169
- npm OIDC trusted publishing: check it with
1170
- `gh run list --workflow=publish.yaml`. As of 2026.9.4 every run since
1171
- `ui-v2026.9.2` fails at the `npm publish` step with
1172
- `E404 Not Found - PUT .../@poodle64%2fui` (an OIDC/trusted-publisher
1173
- binding issue, since `Build and test package` passes first). Until that
1174
- is diagnosed and fixed, publish manually from `packages/ui/`:
1175
- ```bash
1176
- printf '//registry.npmjs.org/:_authToken=${NODE_AUTH_TOKEN}\n' > .npmrc
1177
- signet exec --identity huginn-claude --broker https://portcullis.godswood.au \
1178
- --credential npm-publish-token --env-var NODE_AUTH_TOKEN -- npm publish
1179
- rm -f .npmrc
1180
- ```
1255
+ 4. `.github/workflows/publish.yaml` runs on that push and publishes via npm
1256
+ OIDC trusted publishing: `gh run list --workflow=publish.yaml`.
1181
1257
  Confirm with `npm view @poodle64/ui version`.
@@ -0,0 +1,2 @@
1
+ import ReportWidget from './report-widget.svelte';
2
+ export { ReportWidget, ReportWidget as default };
@@ -0,0 +1,2 @@
1
+ import ReportWidget from './report-widget.svelte';
2
+ export { ReportWidget, ReportWidget as default };
@@ -0,0 +1,174 @@
1
+ <script lang="ts" module>
2
+ // In the module script so the instance script's typed state can reference
3
+ // it: svelte2tsx emits a component's instance-script type declarations
4
+ // below its `let`s, so a type both declared and referenced there turns
5
+ // into "used before its declaration".
6
+ type WidgetState = 'idle' | 'submitting' | 'done' | 'error';
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { toPng } from 'html-to-image';
11
+ import Check from '@lucide/svelte/icons/check';
12
+ import CircleAlert from '@lucide/svelte/icons/circle-alert';
13
+ import LoaderCircle from '@lucide/svelte/icons/loader-circle';
14
+ import MessageSquarePlus from '@lucide/svelte/icons/message-square-plus';
15
+ import AppDialog from '../ui/app-dialog/app-dialog.svelte';
16
+ import { Button } from '../ui/button/index.js';
17
+ import { Checkbox } from '../ui/checkbox/index.js';
18
+ import { Label } from '../ui/label/index.js';
19
+ import Textarea from '../ui/textarea/textarea.svelte';
20
+ import { getSessionId } from '../../telemetry';
21
+
22
+ // The feedback widget every household app mounts: a fixed bottom-right
23
+ // trigger opening the one report dialogue. It is deliberately NOT under
24
+ // components/ui — it is app machinery the layout mounts once (alongside
25
+ // initBrowserTelemetry), not page chrome a route composes from, so it does
26
+ // not sit in the situation→component registry either.
27
+
28
+ let {
29
+ /** Where the report POSTs. The app's own route, defaulting to the one every household app serves. */
30
+ endpoint = '/api/feedback',
31
+ /** Fired with the id the endpoint returned, once, on the success leg. */
32
+ onSubmitted
33
+ }: {
34
+ endpoint?: string;
35
+ onSubmitted?: (id: string) => void;
36
+ } = $props();
37
+
38
+ let open = $state(false);
39
+ let message = $state('');
40
+ let includeScreenshot = $state(true);
41
+ let phase: WidgetState = $state('idle');
42
+ let submittedId: string | null = $state(null);
43
+
44
+ // Instance-scoped element ids: a label's `for` has to reach its control,
45
+ // and a page that mounts two widgets must not cross-wire their fields.
46
+ const uid = Math.random().toString(36).slice(2, 10);
47
+ const formId = `feedback-form-${uid}`;
48
+ const messageId = `feedback-message-${uid}`;
49
+ const screenshotId = `feedback-screenshot-${uid}`;
50
+
51
+ async function submit() {
52
+ phase = 'submitting';
53
+
54
+ // A screenshot the page cannot produce (canvas-tainted content, a
55
+ // browser without the API) is tolerated silently: the report still
56
+ // sends, with a null image rather than no report at all.
57
+ let screenshot: string | null = null;
58
+ if (includeScreenshot) {
59
+ screenshot = await toPng(document.body).catch(() => null);
60
+ }
61
+
62
+ try {
63
+ const response = await fetch(endpoint, {
64
+ method: 'POST',
65
+ headers: { 'Content-Type': 'application/json' },
66
+ body: JSON.stringify({
67
+ message,
68
+ route: location.pathname,
69
+ user_agent: navigator.userAgent,
70
+ viewport: { width: window.innerWidth, height: window.innerHeight },
71
+ session_id: getSessionId(),
72
+ screenshot
73
+ })
74
+ });
75
+
76
+ if (!response.ok) throw new Error(`the feedback endpoint answered ${response.status}`);
77
+
78
+ const { id } = (await response.json()) as { id: string };
79
+ submittedId = id;
80
+ phase = 'done';
81
+ onSubmitted?.(id);
82
+ } catch {
83
+ // The draft survives the failure — the message the user typed is
84
+ // the hard half to reproduce, and losing it on a flaky connection
85
+ // would make the retry cost more than the first attempt.
86
+ phase = 'error';
87
+ }
88
+ }
89
+
90
+ function reset() {
91
+ // A sent report clears the dialogue for the next one; an abandoned
92
+ // draft is kept, so an accidental close is not lost work.
93
+ if (phase === 'done') {
94
+ message = '';
95
+ submittedId = null;
96
+ }
97
+ phase = 'idle';
98
+ }
99
+ </script>
100
+
101
+ <!-- The floating trigger is the same shape the context column's is: a raw
102
+ button in the house chrome rather than the Button primitive, because a
103
+ Button's padding and text rhythm are for a row of actions, not a
104
+ 44px disc pinned over the page. -->
105
+ <button
106
+ type="button"
107
+ onclick={() => (open = true)}
108
+ class="border-border bg-card text-muted-foreground hover:text-foreground ds-edge fixed right-4 bottom-[calc(1rem+env(safe-area-inset-bottom))] z-50 grid size-11 flex-none place-items-center rounded-full border transition-colors"
109
+ aria-label="Report a problem"
110
+ >
111
+ <MessageSquarePlus class="size-4.5" />
112
+ <span class="sr-only">Report a problem</span>
113
+ </button>
114
+
115
+ <AppDialog
116
+ bind:open
117
+ title="Report a problem"
118
+ subtitle="Describe what happened and the household team will take a look."
119
+ size="sm"
120
+ onOpenChange={(next) => !next && reset()}
121
+ >
122
+ {#if phase === 'done'}
123
+ <div class="flex flex-col items-start gap-3" data-testid="feedback-done">
124
+ <span class="bg-primary/15 text-primary grid size-8 place-items-center rounded-md">
125
+ <Check class="size-4" />
126
+ </span>
127
+ <p class="font-medium">Thank you — your report was received.</p>
128
+ <p class="text-muted-foreground text-sm">
129
+ Its reference is <span class="text-foreground font-medium" data-testid="feedback-id">{submittedId}</span
130
+ >. Quote it if you follow it up.
131
+ </p>
132
+ </div>
133
+ {:else}
134
+ <form id={formId} onsubmit={(e) => { e.preventDefault(); submit(); }}>
135
+ <div class="space-y-2">
136
+ <Label for={messageId}>What happened?</Label>
137
+ <Textarea
138
+ id={messageId}
139
+ bind:value={message}
140
+ placeholder="What did you see, and what did you expect?"
141
+ />
142
+ </div>
143
+ <div class="mt-4 flex items-center gap-2">
144
+ <Checkbox id={screenshotId} bind:checked={includeScreenshot} />
145
+ <Label for={screenshotId}>Include a screenshot of this page</Label>
146
+ </div>
147
+ {#if phase === 'error'}
148
+ <p class="text-destructive mt-4 flex items-center gap-2 text-sm" role="alert" data-testid="feedback-error">
149
+ <CircleAlert class="size-4 flex-none" />
150
+ Your report could not be sent. Check your connection and try again.
151
+ </p>
152
+ {/if}
153
+ </form>
154
+ {/if}
155
+
156
+ {#snippet footer()}
157
+ {#if phase === 'done'}
158
+ <Button variant="outline" onclick={() => (open = false)}>Close</Button>
159
+ {:else if phase === 'error'}
160
+ <Button variant="outline" onclick={() => (open = false)}>Cancel</Button>
161
+ <Button type="submit" form={formId} disabled={!message.trim()}>Retry</Button>
162
+ {:else}
163
+ <Button variant="outline" disabled={phase === 'submitting'} onclick={() => (open = false)}>
164
+ Cancel
165
+ </Button>
166
+ <Button type="submit" form={formId} disabled={phase === 'submitting' || !message.trim()}>
167
+ {#if phase === 'submitting'}
168
+ <LoaderCircle class="size-4 animate-spin" />
169
+ {/if}
170
+ {phase === 'submitting' ? 'Sending…' : 'Send report'}
171
+ </Button>
172
+ {/if}
173
+ {/snippet}
174
+ </AppDialog>
@@ -0,0 +1,7 @@
1
+ type $$ComponentProps = {
2
+ endpoint?: string;
3
+ onSubmitted?: (id: string) => void;
4
+ };
5
+ declare const ReportWidget: import("svelte").Component<$$ComponentProps, {}, "">;
6
+ type ReportWidget = ReturnType<typeof ReportWidget>;
7
+ export default ReportWidget;
@@ -89,7 +89,8 @@
89
89
  import PanelLeftClose from '@lucide/svelte/icons/panel-left-close';
90
90
  import PanelLeftOpen from '@lucide/svelte/icons/panel-left-open';
91
91
  import AppNav from './app-nav.svelte';
92
- import { toItems, type NavSource } from './types.js';
92
+ import { activeNavLabel, toItems, type NavSource } from './types.js';
93
+ import { provideShellControls, type ShellControlsSlot } from './controls.js';
93
94
  import { cn } from '../../../utils.js';
94
95
 
95
96
  let {
@@ -214,6 +215,92 @@
214
215
 
215
216
  const hasNav = $derived(toItems(nav).length > 0);
216
217
 
218
+ // Where the user is, in words: the rail is icons when collapsed and a closed
219
+ // drawer on a phone, so the bar names the section.
220
+ const location = $derived(activeNavLabel(nav, currentPath));
221
+
222
+ // The page's scope controls, registered by `<ShellControls>` from the route.
223
+ const controls: ShellControlsSlot = $state({ content: null });
224
+ provideShellControls(controls);
225
+
226
+ // Rail width is a personal preference: localStorage, one key across apps.
227
+ // A drag below RAIL_SNAP collapses; the width survives and returns on expand.
228
+ const RAIL_KEY = 'ds-shell-rail-width';
229
+ const RAIL_MIN = 200;
230
+ const RAIL_MAX = 420;
231
+ const RAIL_SNAP = 170;
232
+ const RAIL_STEP = 16;
233
+ let railWidth = $state<number | null>(null);
234
+ let resizing = $state(false);
235
+ $effect(() => {
236
+ try {
237
+ const stored = Number(localStorage.getItem(RAIL_KEY));
238
+ if (stored >= RAIL_MIN && stored <= RAIL_MAX) railWidth = stored;
239
+ } catch {
240
+ /* storage unavailable: the default width stands */
241
+ }
242
+ });
243
+ function clampRail(next: number) {
244
+ return Math.min(RAIL_MAX, Math.max(RAIL_MIN, Math.round(next)));
245
+ }
246
+ function setRailWidth(next: number) {
247
+ railWidth = clampRail(next);
248
+ try {
249
+ localStorage.setItem(RAIL_KEY, String(railWidth));
250
+ } catch {
251
+ /* storage unavailable: the width lasts the session */
252
+ }
253
+ }
254
+ function startResize(event: PointerEvent) {
255
+ const handle = event.currentTarget as HTMLElement;
256
+ const rail = handle.parentElement as HTMLElement;
257
+ const startX = event.clientX;
258
+ const startWidth = rail.getBoundingClientRect().width;
259
+ const before = railWidth;
260
+ resizing = true;
261
+ handle.setPointerCapture(event.pointerId);
262
+ const move = (e: PointerEvent) => {
263
+ const next = startWidth + (e.clientX - startX);
264
+ if (next < RAIL_SNAP) {
265
+ // A collapse keeps the width it had; only a resize stores one.
266
+ railWidth = before;
267
+ collapsed = true;
268
+ stop();
269
+ } else {
270
+ railWidth = clampRail(next);
271
+ }
272
+ };
273
+ const stop = () => {
274
+ resizing = false;
275
+ if (!collapsed && railWidth !== null) setRailWidth(railWidth);
276
+ handle.removeEventListener('pointermove', move);
277
+ handle.removeEventListener('pointerup', stop);
278
+ handle.removeEventListener('pointercancel', stop);
279
+ if (handle.hasPointerCapture(event.pointerId)) handle.releasePointerCapture(event.pointerId);
280
+ };
281
+ handle.addEventListener('pointermove', move);
282
+ handle.addEventListener('pointerup', stop);
283
+ handle.addEventListener('pointercancel', stop);
284
+ }
285
+ function resizeByKey(event: KeyboardEvent) {
286
+ const current = railWidth ?? RAIL_MIN + (RAIL_MAX - RAIL_MIN) / 4;
287
+ if (event.key === 'ArrowLeft') setRailWidth(current - RAIL_STEP);
288
+ else if (event.key === 'ArrowRight') setRailWidth(current + RAIL_STEP);
289
+ else if (event.key === 'Home') resetRailWidth();
290
+ else if (event.key === 'End') setRailWidth(RAIL_MAX);
291
+ else return;
292
+ event.preventDefault();
293
+ }
294
+ /** Back to the package default (Home, or a double-click on the handle). */
295
+ function resetRailWidth() {
296
+ railWidth = null;
297
+ try {
298
+ localStorage.removeItem(RAIL_KEY);
299
+ } catch {
300
+ /* storage unavailable */
301
+ }
302
+ }
303
+
217
304
  // The measure is resolved ONCE, and the class and the attribute both read
218
305
  // that one answer. Two independently-worded conditionals looked symmetric
219
306
  // and were not: Svelte omits an attribute whose value is `null`, while a
@@ -478,8 +565,10 @@
478
565
  -->
479
566
  <aside
480
567
  id={navRegionId}
481
- class="ds-shell-rail bg-shell text-shell-foreground border-border flex-none flex-col border-r"
568
+ class="ds-shell-rail bg-shell text-shell-foreground border-border relative flex-none flex-col border-r"
569
+ style={railWidth ? `--ds-shell-rail-width: ${railWidth}px` : undefined}
482
570
  data-collapsed={railCollapsed ? 'true' : undefined}
571
+ data-resizing={resizing ? 'true' : undefined}
483
572
  data-drawer={mobileNavOpen ? 'true' : undefined}
484
573
  data-testid={mobileNavOpen ? 'ds-shell-drawer' : undefined}
485
574
  role={mobileNavOpen ? 'dialog' : undefined}
@@ -540,12 +629,33 @@
540
629
  label={navLabel}
541
630
  onNavigate={() => (mobileNavOpen = false)}
542
631
  />
632
+ {#if collapsible && !railCollapsed && !mobileNavOpen}
633
+ <!-- The rail's right edge: drag to resize, drag past the snap to
634
+ collapse, arrows to nudge, Home to reset, double-click to reset.
635
+ A focusable separator is the ARIA window-splitter pattern. -->
636
+ <!-- svelte-ignore a11y_no_noninteractive_tabindex -->
637
+ <!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
638
+ <div
639
+ role="separator"
640
+ aria-orientation="vertical"
641
+ aria-label="Resize sidebar"
642
+ aria-valuenow={railWidth ?? undefined}
643
+ aria-valuemin={RAIL_MIN}
644
+ aria-valuemax={RAIL_MAX}
645
+ tabindex="0"
646
+ class="ds-shell-rail-handle"
647
+ onpointerdown={startResize}
648
+ onkeydown={resizeByKey}
649
+ ondblclick={resetRailWidth}
650
+ data-testid="ds-rail-resize"
651
+ ></div>
652
+ {/if}
543
653
  </aside>
544
654
  {/if}
545
655
 
546
656
  <div class="flex min-h-0 min-w-0 flex-1 flex-col">
547
657
  <header
548
- class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5"
658
+ class="ds-shell-bar bg-shell/80 text-shell-foreground border-border sticky top-0 z-20 flex min-h-14 flex-none flex-wrap items-center gap-2 border-b px-3 pt-[env(safe-area-inset-top)] backdrop-blur sm:gap-3 sm:px-5 xl:flex-nowrap"
549
659
  >
550
660
  {#if hasNav}
551
661
  <!-- `aria-controls` names the region this toggle opens. It is present
@@ -589,6 +699,28 @@
589
699
  leading slot that never had to. -->
590
700
  {#if context}<div class="min-w-0">{@render context()}</div>{/if}
591
701
 
702
+ <!-- Where you are, then the page's scope controls. Inline with the
703
+ rest of the bar from xl; below that, controls take a second row
704
+ of the bar, and a bare label shows from md and not on a phone. -->
705
+ {#if location || controls.content}
706
+ <div
707
+ class={cn(
708
+ 'flex min-w-0 items-center gap-2 sm:gap-3',
709
+ controls.content
710
+ ? 'order-last basis-full pb-2 xl:order-none xl:basis-auto xl:pb-0'
711
+ : 'hidden md:flex'
712
+ )}
713
+ data-testid="ds-shell-location"
714
+ >
715
+ {#if location}
716
+ <span class="text-shell-muted-foreground max-w-48 flex-none truncate text-sm"
717
+ >{location}</span
718
+ >
719
+ {/if}
720
+ {#if controls.content}{@render controls.content()}{/if}
721
+ </div>
722
+ {/if}
723
+
592
724
  {#if onSearch && searchPlacement === 'leading'}
593
725
  {@render searchButton(
594
726
  'h-9 w-9 flex-none justify-center px-0 sm:h-auto sm:w-[clamp(200px,32vw,560px)] sm:flex-1 sm:justify-start sm:gap-2 sm:px-3 sm:py-1.5'
@@ -0,0 +1,17 @@
1
+ /**
2
+ * The page's scope controls in the shell's top bar: what changes the view of
3
+ * the page you are on (an entity, a financial year, chart/table) and never
4
+ * navigates. Sub-routes are the rail's (`NavItem.children`), never here.
5
+ *
6
+ * Controls belong to a route and the shell is composed once above every
7
+ * route, so the shell holds a mutable slot in context; `<ShellControls>` in a
8
+ * page registers its children on mount and withdraws them on unmount. One
9
+ * registration at a time; a later one replaces an earlier one.
10
+ */
11
+ import { type Snippet } from 'svelte';
12
+ export interface ShellControlsSlot {
13
+ content: Snippet | null;
14
+ }
15
+ export declare function provideShellControls(slot: ShellControlsSlot): void;
16
+ /** Undefined outside a shell: `ShellControls` then renders nothing. */
17
+ export declare function getShellControls(): ShellControlsSlot | undefined;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * The page's scope controls in the shell's top bar: what changes the view of
3
+ * the page you are on (an entity, a financial year, chart/table) and never
4
+ * navigates. Sub-routes are the rail's (`NavItem.children`), never here.
5
+ *
6
+ * Controls belong to a route and the shell is composed once above every
7
+ * route, so the shell holds a mutable slot in context; `<ShellControls>` in a
8
+ * page registers its children on mount and withdraws them on unmount. One
9
+ * registration at a time; a later one replaces an earlier one.
10
+ */
11
+ import { getContext, setContext } from 'svelte';
12
+ const KEY = Symbol('ds-shell-controls');
13
+ export function provideShellControls(slot) {
14
+ setContext(KEY, slot);
15
+ }
16
+ /** Undefined outside a shell: `ShellControls` then renders nothing. */
17
+ export function getShellControls() {
18
+ return getContext(KEY);
19
+ }
@@ -1,6 +1,7 @@
1
1
  export { default as AppShell } from './app-shell.svelte';
2
2
  export { default as AppNav } from './app-nav.svelte';
3
+ export { default as ShellControls } from './shell-controls.svelte';
3
4
  export { default } from './app-shell.svelte';
4
5
  export { SHELL_MEASURES, type ShellMeasure } from './measure.js';
5
6
  export { SHELL_TEXTURES, type ShellTexture } from './texture.js';
6
- export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
7
+ export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems, type IconComponent, type NavChildItem, type NavEntry, type NavGroup, type NavItem, type NavSource } from './types.js';
@@ -1,6 +1,7 @@
1
1
  export { default as AppShell } from './app-shell.svelte';
2
2
  export { default as AppNav } from './app-nav.svelte';
3
+ export { default as ShellControls } from './shell-controls.svelte';
3
4
  export { default } from './app-shell.svelte';
4
5
  export { SHELL_MEASURES } from './measure.js';
5
6
  export { SHELL_TEXTURES } from './texture.js';
6
- export { hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
7
+ export { activeNavLabel, hasActiveNavChild, isNavGroup, isNavItemActive, navChildren, toGroups, toItems } from './types.js';
@@ -0,0 +1,24 @@
1
+ <script lang="ts">
2
+ /**
3
+ * Registers this page's controls in the shell's top bar. Renders nothing
4
+ * where it stands.
5
+ *
6
+ * <ShellControls>
7
+ * <Segmented bind:value={fy} options={years} label="Financial year" />
8
+ * </ShellControls>
9
+ */
10
+ import type { Snippet } from 'svelte';
11
+ import { getShellControls } from './controls.js';
12
+
13
+ let { children }: { children: Snippet } = $props();
14
+
15
+ const slot = getShellControls();
16
+
17
+ $effect(() => {
18
+ if (!slot) return;
19
+ slot.content = children;
20
+ return () => {
21
+ if (slot.content === children) slot.content = null;
22
+ };
23
+ });
24
+ </script>
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Registers this page's controls in the shell's top bar. Renders nothing
3
+ * where it stands.
4
+ *
5
+ * <ShellControls>
6
+ * <Segmented bind:value={fy} options={years} label="Financial year" />
7
+ * </ShellControls>
8
+ */
9
+ import type { Snippet } from 'svelte';
10
+ type $$ComponentProps = {
11
+ children: Snippet;
12
+ };
13
+ declare const ShellControls: import("svelte").Component<$$ComponentProps, {}, "">;
14
+ type ShellControls = ReturnType<typeof ShellControls>;
15
+ export default ShellControls;
@@ -149,3 +149,9 @@ export declare function isNavItemActive(item: NavItem, currentPath: string | und
149
149
  * when the children live at their own top-level routes.
150
150
  */
151
151
  export declare function hasActiveNavChild(item: NavItem, currentPath: string | undefined): boolean;
152
+ /**
153
+ * The label of where the user is: the active child's where one matches, else
154
+ * the active item's. Undefined when nothing matches, so an unlisted route
155
+ * renders no label rather than a wrong one.
156
+ */
157
+ export declare function activeNavLabel(nav: NavSource | undefined, currentPath: string | undefined): string | undefined;
@@ -110,3 +110,20 @@ export function hasActiveNavChild(item, currentPath) {
110
110
  return false;
111
111
  return navChildren(item).some((child) => isNavItemActive(child, currentPath));
112
112
  }
113
+ /**
114
+ * The label of where the user is: the active child's where one matches, else
115
+ * the active item's. Undefined when nothing matches, so an unlisted route
116
+ * renders no label rather than a wrong one.
117
+ */
118
+ export function activeNavLabel(nav, currentPath) {
119
+ if (!currentPath)
120
+ return undefined;
121
+ for (const item of toItems(nav)) {
122
+ const child = navChildren(item).find((c) => isNavItemActive(c, currentPath));
123
+ if (child)
124
+ return child.label;
125
+ if (isNavItemActive(item, currentPath))
126
+ return item.label;
127
+ }
128
+ return undefined;
129
+ }
@@ -9,6 +9,6 @@ type $$ComponentProps = WithoutChildrenOrChild<DialogPrimitive.RootProps> & With
9
9
  showCloseButton?: boolean;
10
10
  class?: string;
11
11
  };
12
- declare const CommandDialog: import("svelte").Component<$$ComponentProps, {}, "ref" | "value" | "open">;
12
+ declare const CommandDialog: import("svelte").Component<$$ComponentProps, {}, "open" | "ref" | "value">;
13
13
  type CommandDialog = ReturnType<typeof CommandDialog>;
14
14
  export default CommandDialog;
@@ -2,7 +2,7 @@ declare const InputGroupInput: import("svelte").Component<(Omit<import("svelte/e
2
2
  type: "file";
3
3
  files?: FileList;
4
4
  } | {
5
- type?: "number" | "month" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "url" | "email" | "date" | "time" | "image" | "datetime-local" | "password" | "range" | "week";
5
+ type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "date" | "time" | "image" | "datetime-local" | "password" | "range" | "week";
6
6
  files?: undefined;
7
7
  })) & {
8
8
  ref?: HTMLElement | null | undefined;
@@ -0,0 +1,2 @@
1
+ import Root, { type SegmentedOption } from './segmented.svelte';
2
+ export { Root, Root as Segmented, type SegmentedOption };
@@ -0,0 +1,4 @@
1
+ import Root, {} from './segmented.svelte';
2
+ export { Root,
3
+ //
4
+ Root as Segmented };
@@ -0,0 +1,62 @@
1
+ <script lang="ts" module>
2
+ export interface SegmentedOption<T extends string = string> {
3
+ value: T;
4
+ label: string;
5
+ }
6
+ </script>
7
+
8
+ <script lang="ts" generics="T extends string">
9
+ /**
10
+ * One value from a small fixed set, every option visible: the scope toggle
11
+ * in the shell's top bar. Past about five options, use Select. A scope
12
+ * always has one answer, so the value cannot be cleared.
13
+ */
14
+ import { ToggleGroup } from 'bits-ui';
15
+ import { cn } from '../../../utils.js';
16
+
17
+ let {
18
+ value = $bindable(),
19
+ options,
20
+ label,
21
+ size = 'default',
22
+ class: className
23
+ }: {
24
+ value: T;
25
+ options: readonly SegmentedOption<T>[];
26
+ /** The control's accessible name: what the set is a choice of. */
27
+ label: string;
28
+ /** `sm` is the top-bar height; `default` sits in a page's own control row. */
29
+ size?: 'default' | 'sm';
30
+ class?: string;
31
+ } = $props();
32
+
33
+ const ITEM = {
34
+ default: 'h-8 px-3 text-sm',
35
+ sm: 'h-6 px-2 text-xs'
36
+ } as const;
37
+ </script>
38
+
39
+ <ToggleGroup.Root
40
+ type="single"
41
+ bind:value={() => value, (v) => { if (v) value = v as T; }}
42
+ aria-label={label}
43
+ class={cn(
44
+ 'border-border bg-surface-1 inline-flex flex-none items-center gap-0.5 rounded-md border p-0.5',
45
+ className
46
+ )}
47
+ data-slot="segmented"
48
+ >
49
+ {#each options as option (option.value)}
50
+ <ToggleGroup.Item
51
+ value={option.value}
52
+ class={cn(
53
+ 'text-muted-foreground hover:text-foreground rounded-sm font-medium whitespace-nowrap transition-colors',
54
+ 'focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none',
55
+ 'data-[state=on]:bg-surface-3 data-[state=on]:text-foreground data-[state=on]:font-semibold',
56
+ ITEM[size]
57
+ )}
58
+ >
59
+ {option.label}
60
+ </ToggleGroup.Item>
61
+ {/each}
62
+ </ToggleGroup.Root>
@@ -0,0 +1,36 @@
1
+ export interface SegmentedOption<T extends string = string> {
2
+ value: T;
3
+ label: string;
4
+ }
5
+ declare function $$render<T extends string>(): {
6
+ props: {
7
+ value: T;
8
+ options: readonly SegmentedOption<T>[];
9
+ /** The control's accessible name: what the set is a choice of. */
10
+ label: string;
11
+ /** `sm` is the top-bar height; `default` sits in a page's own control row. */
12
+ size?: "default" | "sm";
13
+ class?: string;
14
+ };
15
+ exports: {};
16
+ bindings: "value";
17
+ slots: {};
18
+ events: {};
19
+ };
20
+ declare class __sveltets_Render<T extends string> {
21
+ props(): ReturnType<typeof $$render<T>>['props'];
22
+ events(): ReturnType<typeof $$render<T>>['events'];
23
+ slots(): ReturnType<typeof $$render<T>>['slots'];
24
+ bindings(): "value";
25
+ exports(): {};
26
+ }
27
+ interface $$IsomorphicComponent {
28
+ new <T extends string>(options: import('svelte').ComponentConstructorOptions<ReturnType<__sveltets_Render<T>['props']>>): import('svelte').SvelteComponent<ReturnType<__sveltets_Render<T>['props']>, ReturnType<__sveltets_Render<T>['events']>, ReturnType<__sveltets_Render<T>['slots']>> & {
29
+ $$bindings?: ReturnType<__sveltets_Render<T>['bindings']>;
30
+ } & ReturnType<__sveltets_Render<T>['exports']>;
31
+ <T extends string>(internal: unknown, props: ReturnType<__sveltets_Render<T>['props']> & {}): ReturnType<__sveltets_Render<T>['exports']>;
32
+ z_$$bindings?: ReturnType<__sveltets_Render<any>['bindings']>;
33
+ }
34
+ declare const Segmented: $$IsomorphicComponent;
35
+ type Segmented<T extends string> = InstanceType<typeof Segmented<T>>;
36
+ export default Segmented;
@@ -1,3 +1,3 @@
1
- declare const Select: import("svelte").Component<import("bits-ui").SelectRootPropsWithoutHTML, {}, "value" | "open">;
1
+ declare const Select: import("svelte").Component<import("bits-ui").SelectRootPropsWithoutHTML, {}, "open" | "value">;
2
2
  type Select = ReturnType<typeof Select>;
3
3
  export default Select;
package/dist/styles.css CHANGED
@@ -952,6 +952,36 @@
952
952
  width: var(--ds-shell-rail-width-collapsed);
953
953
  }
954
954
 
955
+ /* Width follows the pointer during a drag; the transition is for the
956
+ collapse, not for every pixel of a resize. */
957
+ .ds-shell-rail[data-resizing='true'] {
958
+ transition: none;
959
+ }
960
+
961
+ /* The resize handle: a 6px grab strip astride the rail's right edge, md+
962
+ only, showing the border colour under the pointer and keyboard focus. */
963
+ .ds-shell-rail-handle {
964
+ display: none;
965
+ }
966
+ @media (min-width: 48rem) {
967
+ .ds-shell-rail-handle {
968
+ display: block;
969
+ position: absolute;
970
+ inset-block: 0;
971
+ right: -3px;
972
+ width: 6px;
973
+ cursor: col-resize;
974
+ z-index: 1;
975
+ touch-action: none;
976
+ }
977
+ .ds-shell-rail-handle:hover,
978
+ .ds-shell-rail-handle:focus-visible {
979
+ background: var(--ds-color-primary);
980
+ opacity: 0.4;
981
+ outline: none;
982
+ }
983
+ }
984
+
955
985
  /* The content measure: the shell's content box is ONE element in four
956
986
  states, the same shape as the rail above it.
957
987
 
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Browser telemetry for a household frontend: one `initBrowserTelemetry()`
3
+ * call in the client entry, one Faro instance for the app's lifetime.
4
+ *
5
+ * Both faro packages are ordinary `dependencies`, not peers: telemetry is not
6
+ * an optional flavour a consumer chooses per route, it is part of what every
7
+ * household app mounts, and a duplicated faro global would strand the session
8
+ * id the feedback widget reports against.
9
+ */
10
+ import { type Faro } from '@grafana/faro-web-sdk';
11
+ /** What an app supplies to switch telemetry on. */
12
+ export interface BrowserTelemetryOptions {
13
+ /** The app's name, as it should appear in Faro's app metadata. */
14
+ app: string;
15
+ /** The app's version — the package's own CalVer, ordinarily. */
16
+ version: string;
17
+ /**
18
+ * The Faro collector endpoint. Empty means "no collector configured" and
19
+ * the whole module stays inert rather than shipping beacons that error.
20
+ */
21
+ url: string;
22
+ }
23
+ /**
24
+ * Start browser telemetry. Returns the Faro instance, or null when there is
25
+ * no collector to send to — the caller has nothing to clean up in that case,
26
+ * because nothing was created.
27
+ *
28
+ * Idempotent: a second call returns the first instance and initialises
29
+ * nothing. A layout remount, a dev-mode HMR leg or two init calls racing in
30
+ * different modules must not produce a second Faro global (faro-core refuses
31
+ * a second registration outright, and the session id a second instance would
32
+ * hand out would not be the one the first instance's events carry).
33
+ */
34
+ export declare function initBrowserTelemetry({ app, version, url }: BrowserTelemetryOptions): Faro | null;
35
+ /**
36
+ * Faro's current session id, or null when telemetry is not running (no URL, or
37
+ * init not yet called). The null leg is ordinary, not an error: the feedback
38
+ * widget's `session_id` field carries it as null and the report still sends.
39
+ */
40
+ export declare function getSessionId(): string | null;
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Browser telemetry for a household frontend: one `initBrowserTelemetry()`
3
+ * call in the client entry, one Faro instance for the app's lifetime.
4
+ *
5
+ * Both faro packages are ordinary `dependencies`, not peers: telemetry is not
6
+ * an optional flavour a consumer chooses per route, it is part of what every
7
+ * household app mounts, and a duplicated faro global would strand the session
8
+ * id the feedback widget reports against.
9
+ */
10
+ import { initializeFaro, getWebInstrumentations } from '@grafana/faro-web-sdk';
11
+ import { TracingInstrumentation } from '@grafana/faro-web-tracing';
12
+ /** The one Faro instance this browser tab holds, once initialised. */
13
+ let faro = null;
14
+ /**
15
+ * Start browser telemetry. Returns the Faro instance, or null when there is
16
+ * no collector to send to — the caller has nothing to clean up in that case,
17
+ * because nothing was created.
18
+ *
19
+ * Idempotent: a second call returns the first instance and initialises
20
+ * nothing. A layout remount, a dev-mode HMR leg or two init calls racing in
21
+ * different modules must not produce a second Faro global (faro-core refuses
22
+ * a second registration outright, and the session id a second instance would
23
+ * hand out would not be the one the first instance's events carry).
24
+ */
25
+ export function initBrowserTelemetry({ app, version, url }) {
26
+ // No URL, no telemetry. An app that was never told where the collector
27
+ // lives ships a silent no-op rather than a transport that fails on every
28
+ // send — the call sites never branch on this; `getSessionId()` reads the
29
+ // same null either way.
30
+ if (!url)
31
+ return null;
32
+ if (faro)
33
+ return faro;
34
+ faro = initializeFaro({
35
+ url,
36
+ app: { name: app, version },
37
+ // Session tracking is Faro's default too, but the feedback widget's
38
+ // payload carries the session id, so the guarantee is stated here
39
+ // rather than inherited from a default that could change upstream.
40
+ sessionTracking: { enabled: true },
41
+ instrumentations: [
42
+ ...getWebInstrumentations({
43
+ // The console stays local by rule: a household app's logs are not
44
+ // shipped to the collector, only errors, events and measurements.
45
+ captureConsole: false
46
+ }),
47
+ // W3C trace context on same-origin fetch and XHR: every request the
48
+ // app's own backend serves carries a `traceparent`, so a feedback
49
+ // report can be correlated with the server-side trace of the page
50
+ // it was sent from.
51
+ new TracingInstrumentation()
52
+ ]
53
+ });
54
+ return faro;
55
+ }
56
+ /**
57
+ * Faro's current session id, or null when telemetry is not running (no URL, or
58
+ * init not yet called). The null leg is ordinary, not an error: the feedback
59
+ * widget's `session_id` field carries it as null and the report still sends.
60
+ */
61
+ export function getSessionId() {
62
+ return faro?.api.getSession()?.id ?? null;
63
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@poodle64/ui",
3
- "version": "2026.9.6",
3
+ "version": "2026.9.8",
4
4
  "description": "Household shared component layer: shadcn-svelte primitives (bits-ui) plus the composed page chrome every app builds its routes from, restyled by each app's @poodle64/design-tokens alias layer. One fix reaches every app.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -29,6 +29,14 @@
29
29
  "types": "./dist/format.d.ts",
30
30
  "svelte": "./dist/format.js"
31
31
  },
32
+ "./telemetry": {
33
+ "types": "./dist/telemetry.d.ts",
34
+ "svelte": "./dist/telemetry.js"
35
+ },
36
+ "./feedback": {
37
+ "types": "./dist/components/feedback/index.d.ts",
38
+ "svelte": "./dist/components/feedback/index.js"
39
+ },
32
40
  "./*": {
33
41
  "types": "./dist/components/ui/*/index.d.ts",
34
42
  "svelte": "./dist/components/ui/*/index.js"
@@ -60,10 +68,13 @@
60
68
  }
61
69
  },
62
70
  "dependencies": {
71
+ "@grafana/faro-web-sdk": "^2.11.0",
72
+ "@grafana/faro-web-tracing": "^2.11.0",
63
73
  "@jsonforms/core": "^3.8.0",
64
74
  "@lucide/svelte": "^1.25.0",
65
75
  "@tanstack/table-core": "^8.21.3",
66
76
  "clsx": "^2.1.1",
77
+ "html-to-image": "^1.11.13",
67
78
  "tailwind-merge": "^3.6.0",
68
79
  "tailwind-variants": "^3.2.2",
69
80
  "tw-animate-css": "^1.4.0"
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.6",
4
+ "version": "2026.9.8",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
- "componentCount": 56,
7
+ "componentCount": 57,
8
8
  "situationCount": 15
9
9
  },
10
10
  "situations": [
@@ -286,6 +286,20 @@
286
286
  "props": "nav, currentPath, brandTitle?, actions?, context?, children",
287
287
  "insteadOf": "a bespoke nav rail + header layout per app"
288
288
  },
289
+ {
290
+ "name": "ShellControls",
291
+ "dir": "app-shell",
292
+ "import": "@poodle64/ui/app-shell",
293
+ "props": "children",
294
+ "insteadOf": "a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it"
295
+ },
296
+ {
297
+ "name": "Segmented",
298
+ "dir": "segmented",
299
+ "import": "@poodle64/ui/segmented",
300
+ "props": "value, options, label, size?",
301
+ "insteadOf": "a hand-built row of aria-pressed buttons for a small fixed choice"
302
+ },
289
303
  {
290
304
  "name": "PageHeader",
291
305
  "dir": "page-header",
@@ -1,7 +1,7 @@
1
1
  # @poodle64/ui — situation → component map
2
2
 
3
3
  <!-- GENERATED by scripts/generate-registry.mjs from scripts/situations.json + package source. DO NOT EDIT. -->
4
- Generated from `@poodle64/ui@2026.9.6`. 56 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.8`. 57 components, 15 situations.
5
5
 
6
6
  **Read this before writing a `<div>`.** Find the SITUATION you are in below, then compose the component named for it — do not hand-build it from raw `Card` or utility classes. Import is `import { Name } from '<import path>'`. Props marked `?` are optional. This map is the retrieval step the [`frontend-design` skill] makes mandatory; the [CHI 2026 study] measured composing-from-a-registry at 95% design-system compliance against 71% for writing the CSS from a prose style guide.
7
7
 
@@ -125,6 +125,8 @@ _The standing chrome — the app shell and its nav, the one page-title treatment
125
125
  | Component | Import | Key props | Reach for it instead of |
126
126
  | --- | --- | --- | --- |
127
127
  | `AppShell` | `@poodle64/ui/app-shell` | `nav, currentPath, brandTitle?, actions?, context?, children` | a bespoke nav rail + header layout per app |
128
+ | `ShellControls` | `@poodle64/ui/app-shell` | `children` | a scope row (entity, financial year, chart/table) hand-placed above the page body; the shell's top bar carries it |
129
+ | `Segmented` | `@poodle64/ui/segmented` | `value, options, label, size?` | a hand-built row of aria-pressed buttons for a small fixed choice |
128
130
  | `PageHeader` | `@poodle64/ui/page-header` | `title?, eyebrow?, breadcrumbs?, icon?, subtitle?, info?, meta?, actions?` | a hand-written <h1> and title bar (the drift gate fails this) |
129
131
  | `ContextColumn` | `@poodle64/ui/context-column` | `stats, statsTitle?, statsInfo?, detail?, ariaLabel?` | a hand-built right-hand <aside> with a stat card and a detail pane |
130
132
  | `AppIdentity` | `@poodle64/ui/app-identity` | `user, workspace?, role?, entitlements?, onSignOut, onAccountSettings?, onSwitchTheme?` | a hand-rolled account/avatar dropdown wired to raw DropdownMenu.* + Avatar.* |