@poodle64/ui 2026.9.5 → 2026.9.7

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
 
@@ -1089,6 +1093,67 @@ one is bound to Xero tax codes besides. Relative time ("2 hours ago") is out
1089
1093
  too — it rides on `date-fns` in the one app that has it, and a display formatter
1090
1094
  is not worth making that a dependency of every consumer.
1091
1095
 
1096
+ ## Browser telemetry and the feedback widget
1097
+
1098
+ Two exports are app machinery rather than page chrome: `@poodle64/ui/telemetry`
1099
+ (Faro) and `@poodle64/ui/feedback` (`ReportWidget`). A household app mounts both
1100
+ once, in its client entry and root layout, and every app mounts the same pair.
1101
+
1102
+ ```ts
1103
+ // +layout.ts or the client entry — the browser leg only
1104
+ import { initBrowserTelemetry } from '@poodle64/ui/telemetry';
1105
+
1106
+ initBrowserTelemetry({
1107
+ app: 'cadmus',
1108
+ version: APP_VERSION,
1109
+ url: PUBLIC_FARO_URL
1110
+ });
1111
+ ```
1112
+
1113
+ `initBrowserTelemetry` is inert without a `url` — a private or unconfigured
1114
+ deployment ships no beacons and gets null from `getSessionId()`, with no branch
1115
+ at the call site. It is idempotent: a second call returns the first instance,
1116
+ so an HMR leg or a remounted layout cannot produce a second Faro global. The
1117
+ configuration is the house decision, not an app decision: session tracking on,
1118
+ the console captured nowhere (the console stays local by rule), and the
1119
+ tracing instrumentation on so every same-origin `fetch`/XHR carries a
1120
+ `traceparent` — the feedback POST and the page's server-side traces share one
1121
+ trace id. The faro packages are `dependencies`, not peers, for the same reason
1122
+ `bits-ui` is a peer: a duplicated Faro global would strand the session id the
1123
+ feedback widget reports against.
1124
+
1125
+ `ReportWidget` is the user-facing other half: a fixed bottom-right trigger
1126
+ opening the one report dialogue — a labelled "What happened?" textarea, and a
1127
+ screenshot checkbox (on by default) that captures `document.body` with
1128
+ `html-to-image`. A capture the page cannot produce is tolerated silently: the
1129
+ report sends with a null image rather than not at all. Submitting POSTs one
1130
+ JSON body to the `endpoint` prop (default `/api/feedback`):
1131
+
1132
+ ```json
1133
+ {
1134
+ "message": "string — the textarea, required",
1135
+ "route": "string — location.pathname at send time",
1136
+ "user_agent": "string — navigator.userAgent",
1137
+ "viewport": { "width": 1024, "height": 768 },
1138
+ "session_id": "string | null — Faro's session id",
1139
+ "screenshot": "string | null — a PNG data URL, or null"
1140
+ }
1141
+ ```
1142
+
1143
+ That body is the interface the app's own route is written against, and
1144
+ `src/test/report-widget.test.ts` asserts it field for field. The endpoint
1145
+ answers `{ "id": "…" }`; the dialogue shows the id with a thank-you (quote it
1146
+ to follow up) and fires `onSubmitted(id)` so the app can log or route on it.
1147
+ A failure keeps the draft and offers Retry — the message the user typed is
1148
+ the hard half to reproduce, and losing it to a flaky connection would make
1149
+ the retry cost more than the first attempt.
1150
+
1151
+ The widget lives under `components/feedback/`, not `components/ui/`: it is
1152
+ mounted by the layout, once, alongside the telemetry init — not composed into
1153
+ routes by situation, so it stands outside the situation→component registry
1154
+ for the same reason. `getSessionId()` reading null when telemetry is off is
1155
+ ordinary, not an error: the report sends with a null `session_id`.
1156
+
1092
1157
  ## The gate: `ds-check-colour-surface`
1093
1158
 
1094
1159
  The package ships the gate that proves its own surface actually resolves in the
@@ -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;
@@ -30,7 +30,14 @@
30
30
  * instead (operator ruling, 07/09/2026, master-project#291's org-of-one
31
31
  * case). Wire it whenever the app has a members route, whether or not it
32
32
  * also renders a switcher.
33
+ *
34
+ * `links` is the escape valve for everything the fixed entries have no room
35
+ * for — an Admin route, a support link — as a LIST, so every app's extra
36
+ * destinations render as the same row. Without it an app puts its own
37
+ * destination in AppShell's `actions` slot as an icon button (cadmus did,
38
+ * twice), and two apps do it two ways in the same bar.
33
39
  */
40
+ import type { Component } from 'svelte';
34
41
  import { toggleMode } from 'mode-watcher';
35
42
  import * as DropdownMenu from '../dropdown-menu/index.js';
36
43
  import * as Avatar from '../avatar/index.js';
@@ -43,7 +50,8 @@
43
50
  onSignOut,
44
51
  onAccountSettings,
45
52
  onManageMembers,
46
- onSwitchTheme
53
+ onSwitchTheme,
54
+ links = []
47
55
  }: {
48
56
  user: { username: string; display_name: string | null; email: string | null };
49
57
  /** The active workspace's name. Omit while none is chosen yet — the block hides rather than rendering empty. */
@@ -70,6 +78,22 @@
70
78
  onManageMembers?: () => void;
71
79
  /** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
72
80
  onSwitchTheme?: () => void;
81
+ /**
82
+ * App destinations the fixed entries have no room for, rendered in their
83
+ * own group.
84
+ *
85
+ * A list of items, never a snippet: a snippet lets each app invent its own
86
+ * row shape, and two apps inventing two shapes in the same top bar is the
87
+ * drift this package exists to end. Cadmus is the measured case — its
88
+ * Admin entry had nowhere to go and became an icon button in AppShell's
89
+ * `actions` slot, and a second one (a quiet support link) was about to
90
+ * follow it there.
91
+ *
92
+ * No `href`: the callback shape matches every other action on this
93
+ * component, and a consumer routes with its own `goto`. The icon is
94
+ * optional — a label-only row stays aligned with any iconed row beside it.
95
+ */
96
+ links?: { label: string; icon?: Component<{ class?: string }>; onSelect: () => void }[];
73
97
  } = $props();
74
98
 
75
99
  const shown = $derived(user.display_name ?? user.username);
@@ -110,6 +134,11 @@
110
134
  user.display_name ? initialsOf(user.display_name) : handleInitials(user.username)
111
135
  );
112
136
 
137
+ // Spacers only where the group actually has an icon to align to: a group of
138
+ // label-only rows sits flush with the fixed entries below it, exactly as it
139
+ // would if this prop had never existed.
140
+ const linksHaveIcons = $derived(links.some((l) => l.icon));
141
+
113
142
  function switchTheme() {
114
143
  if (onSwitchTheme) onSwitchTheme();
115
144
  else toggleMode();
@@ -177,6 +206,26 @@
177
206
  <DropdownMenu.Item onSelect={onManageMembers}>Members</DropdownMenu.Item>
178
207
  {/if}
179
208
  {/if}
209
+ {#if links.length}
210
+ <DropdownMenu.Separator />
211
+ <DropdownMenu.Group>
212
+ <!-- Keyed by position, not by label: nothing in the type makes a label
213
+ unique, and two apps' configs colliding on one would be a
214
+ duplicate-key crash rather than a duplicated row. This list is
215
+ app configuration, not data that reorders. -->
216
+ {#each links as link, i (i)}
217
+ {@const Icon = link.icon}
218
+ <DropdownMenu.Item onSelect={link.onSelect}>
219
+ {#if Icon}
220
+ <Icon class="size-4" />
221
+ {:else if linksHaveIcons}
222
+ <span class="size-4 flex-none" aria-hidden="true"></span>
223
+ {/if}
224
+ {link.label}
225
+ </DropdownMenu.Item>
226
+ {/each}
227
+ </DropdownMenu.Group>
228
+ {/if}
180
229
  <DropdownMenu.Separator />
181
230
  <DropdownMenu.Item onSelect={switchTheme}>Switch theme</DropdownMenu.Item>
182
231
  <DropdownMenu.Item onSelect={onAccountSettings}>Account settings</DropdownMenu.Item>
@@ -1,3 +1,42 @@
1
+ /**
2
+ * The signed-in user surface for AppShell's `identity` slot.
3
+ *
4
+ * Graduated from the shell lab (`packages/console/src/routes/lab/+page.svelte`,
5
+ * the `identity === 'avatar'` shape) — operator ruling 04/09/2026: avatar
6
+ * only at rest, no name, no chevron, and no prop reintroduces a variant.
7
+ * Opening it answers who is signed in, which workspace they act in, and the
8
+ * ways out — identity first, because a menu that opens on a list of actions
9
+ * makes you infer who you are from the avatar you just clicked.
10
+ *
11
+ * Prop names match the stamped template's auth store
12
+ * (`frontend/src/lib/auth.svelte.ts` — `User`, `Membership`) exactly, so a
13
+ * consumer wires this straight from `auth.user`, `auth.entitlements`,
14
+ * `auth.activeWorkspace` and `auth.activeRole` with no reshaping. A guessed
15
+ * shape needs rewriting the day it meets the real store, which is the whole
16
+ * reason this one didn't. `display_name` and `email` are both nullable
17
+ * there, and `activeWorkspace` is genuinely null until a caller with several
18
+ * memberships chooses one — this component is built against that, not a
19
+ * happy path.
20
+ *
21
+ * Sign-out and settings are the consuming app's concerns, never behaviour
22
+ * baked in here: `onSignOut` and `onAccountSettings` are callbacks. Theme
23
+ * follows AppShell's own `onToggleTheme` pattern — override it, or accept
24
+ * mode-watcher's `toggleMode` by default.
25
+ *
26
+ * `onManageMembers` exists for the caller a workspace switcher has nothing
27
+ * to show: `WorkspaceMenu` (the factory template) renders nothing in the
28
+ * bar for a single-member workspace, and this is where Members lands
29
+ * instead (operator ruling, 07/09/2026, master-project#291's org-of-one
30
+ * case). Wire it whenever the app has a members route, whether or not it
31
+ * also renders a switcher.
32
+ *
33
+ * `links` is the escape valve for everything the fixed entries have no room
34
+ * for — an Admin route, a support link — as a LIST, so every app's extra
35
+ * destinations render as the same row. Without it an app puts its own
36
+ * destination in AppShell's `actions` slot as an icon button (cadmus did,
37
+ * twice), and two apps do it two ways in the same bar.
38
+ */
39
+ import type { Component } from 'svelte';
1
40
  type $$ComponentProps = {
2
41
  user: {
3
42
  username: string;
@@ -28,7 +67,29 @@ type $$ComponentProps = {
28
67
  onManageMembers?: () => void;
29
68
  /** Override the theme action. Defaults to mode-watcher's toggleMode, matching AppShell. */
30
69
  onSwitchTheme?: () => void;
70
+ /**
71
+ * App destinations the fixed entries have no room for, rendered in their
72
+ * own group.
73
+ *
74
+ * A list of items, never a snippet: a snippet lets each app invent its own
75
+ * row shape, and two apps inventing two shapes in the same top bar is the
76
+ * drift this package exists to end. Cadmus is the measured case — its
77
+ * Admin entry had nowhere to go and became an icon button in AppShell's
78
+ * `actions` slot, and a second one (a quiet support link) was about to
79
+ * follow it there.
80
+ *
81
+ * No `href`: the callback shape matches every other action on this
82
+ * component, and a consumer routes with its own `goto`. The icon is
83
+ * optional — a label-only row stays aligned with any iconed row beside it.
84
+ */
85
+ links?: {
86
+ label: string;
87
+ icon?: Component<{
88
+ class?: string;
89
+ }>;
90
+ onSelect: () => void;
91
+ }[];
31
92
  };
32
- declare const AppIdentity: import("svelte").Component<$$ComponentProps, {}, "">;
93
+ declare const AppIdentity: Component<$$ComponentProps, {}, "">;
33
94
  type AppIdentity = ReturnType<typeof AppIdentity>;
34
95
  export default AppIdentity;
@@ -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;
@@ -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;
@@ -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.5",
3
+ "version": "2026.9.7",
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,7 +1,7 @@
1
1
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.5",
4
+ "version": "2026.9.7",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 56,
@@ -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.5`. 56 components, 15 situations.
4
+ Generated from `@poodle64/ui@2026.9.7`. 56 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