@poodle64/ui 2026.9.6 → 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;
@@ -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.6",
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.6",
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.6`. 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