@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 +89 -13
- 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-shell/app-shell.svelte +135 -3
- package/dist/components/ui/app-shell/controls.d.ts +17 -0
- package/dist/components/ui/app-shell/controls.js +19 -0
- package/dist/components/ui/app-shell/index.d.ts +2 -1
- package/dist/components/ui/app-shell/index.js +2 -1
- package/dist/components/ui/app-shell/shell-controls.svelte +24 -0
- package/dist/components/ui/app-shell/shell-controls.svelte.d.ts +15 -0
- package/dist/components/ui/app-shell/types.d.ts +6 -0
- package/dist/components/ui/app-shell/types.js +17 -0
- 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/segmented/index.d.ts +2 -0
- package/dist/components/ui/segmented/index.js +4 -0
- package/dist/components/ui/segmented/segmented.svelte +62 -0
- package/dist/components/ui/segmented/segmented.svelte.d.ts +36 -0
- package/dist/components/ui/select/select.svelte.d.ts +1 -1
- package/dist/styles.css +30 -0
- package/dist/telemetry.d.ts +40 -0
- package/dist/telemetry.js +63 -0
- package/package.json +12 -1
- package/registry/component-map.json +16 -2
- package/registry/component-map.md +3 -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
|
|
|
@@ -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
|
|
1169
|
-
|
|
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,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>
|
|
@@ -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, {}, "
|
|
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;
|
|
@@ -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, {}, "
|
|
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.
|
|
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.
|
|
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":
|
|
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.
|
|
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.* |
|