@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 +65 -0
- package/dist/components/feedback/index.d.ts +2 -0
- package/dist/components/feedback/index.js +2 -0
- package/dist/components/feedback/report-widget.svelte +174 -0
- package/dist/components/feedback/report-widget.svelte.d.ts +7 -0
- package/dist/components/ui/app-identity/app-identity.svelte +50 -1
- package/dist/components/ui/app-identity/app-identity.svelte.d.ts +62 -1
- package/dist/components/ui/command/command-dialog.svelte.d.ts +1 -1
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/dist/components/ui/select/select.svelte.d.ts +1 -1
- package/dist/telemetry.d.ts +40 -0
- package/dist/telemetry.js +63 -0
- package/package.json +12 -1
- package/registry/component-map.json +1 -1
- package/registry/component-map.md +1 -1
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,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>
|
|
@@ -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:
|
|
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, {}, "
|
|
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" | "
|
|
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, {}, "
|
|
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.
|
|
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
|
# @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.
|
|
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
|
|