@poodle64/ui 2026.9.22 → 2026.10.0
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 +5 -5
- package/dist/components/feedback/report-widget.svelte +3 -1
- package/dist/components/settings/index.d.ts +3 -0
- package/dist/components/settings/index.js +3 -0
- package/dist/components/settings/nest.d.ts +15 -0
- package/dist/components/settings/nest.js +58 -0
- package/dist/components/settings/settings-page.svelte +159 -0
- package/dist/components/settings/settings-page.svelte.d.ts +17 -0
- package/dist/components/ui/input-group/input-group-input.svelte.d.ts +1 -1
- package/package.json +5 -1
- package/registry/component-map.json +1 -1
- package/registry/component-map.md +1 -1
package/README.md
CHANGED
|
@@ -568,7 +568,7 @@ remembered, not what the house looks like.
|
|
|
568
568
|
was before the feature existed — gated, not assumed:
|
|
569
569
|
|
|
570
570
|
```sh
|
|
571
|
-
node harness/additivity.mjs ui
|
|
571
|
+
node harness/additivity.mjs ee0db12 # ui 2026.8.3
|
|
572
572
|
# 15 surface/viewport pairs, 105 compared fields (including the screenshot hash)
|
|
573
573
|
# IDENTICAL on every field and every pixel against an explicit texture="none".
|
|
574
574
|
```
|
|
@@ -1379,7 +1379,7 @@ pnpm run check # svelte-check
|
|
|
1379
1379
|
pnpm run test:browser # build the harness and drive it in a real browser
|
|
1380
1380
|
|
|
1381
1381
|
# Before releasing a change that claims to be additive (minutes, not seconds):
|
|
1382
|
-
node harness/additivity.mjs ui
|
|
1382
|
+
node harness/additivity.mjs ee0db12 # ui 2026.8.3
|
|
1383
1383
|
```
|
|
1384
1384
|
|
|
1385
1385
|
`pnpm test` includes the gates that compile the built package through the real
|
|
@@ -1410,7 +1410,7 @@ non-zero and naming the field on any difference.
|
|
|
1410
1410
|
|
|
1411
1411
|
1. Change a component; bump `version` in `package.json` (CalVer).
|
|
1412
1412
|
2. `pnpm build`, which runs `svelte-package` then `publint` (package.json/exports sanity).
|
|
1413
|
-
3.
|
|
1414
|
-
4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes
|
|
1415
|
-
OIDC trusted publishing: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`.
|
|
1413
|
+
3. Merge to main. No tag.
|
|
1414
|
+
4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes the new
|
|
1415
|
+
version via npm OIDC trusted publishing: `gh run list -R radar-hooves/app-factory --workflow=publish-kit-typescript.yaml`.
|
|
1416
1416
|
Confirm with `npm view @poodle64/ui version`.
|
|
@@ -24,6 +24,8 @@
|
|
|
24
24
|
// components/ui — it is app machinery the layout mounts once (alongside
|
|
25
25
|
// initBrowserTelemetry), not page chrome a route composes from, so it does
|
|
26
26
|
// not sit in the situation→component registry either.
|
|
27
|
+
// A page control that shares its corner sets `--ds-report-clearance` on the
|
|
28
|
+
// document to lift the trigger clear of it (the librarian composer does).
|
|
27
29
|
|
|
28
30
|
let {
|
|
29
31
|
/** Where the report POSTs. The app's own route, defaulting to the one every household app serves. */
|
|
@@ -105,7 +107,7 @@
|
|
|
105
107
|
<button
|
|
106
108
|
type="button"
|
|
107
109
|
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"
|
|
110
|
+
class="border-border bg-card text-muted-foreground hover:text-foreground ds-edge fixed right-4 bottom-[calc(1rem+var(--ds-report-clearance,0px)+env(safe-area-inset-bottom))] z-50 grid size-11 flex-none place-items-center rounded-full border transition-colors"
|
|
109
111
|
aria-label="Report a problem"
|
|
110
112
|
>
|
|
111
113
|
<MessageSquarePlus class="size-4.5" />
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings API is flat, keyed by dotted names (`intelligence.extraction_model`),
|
|
3
|
+
* and `<SchemaForm>` reads a `.` in a property name as a path. These nest the flat
|
|
4
|
+
* document along those paths, so every key is addressable, the change it reports
|
|
5
|
+
* carries the key back as `change.path`, and settings group by their domain. The
|
|
6
|
+
* form's generated layout does not descend into an object, so `settingsLayout`
|
|
7
|
+
* gives it one: a Group per domain, a Control per setting.
|
|
8
|
+
*/
|
|
9
|
+
import type { JsonSchema, UISchemaElement } from '../ui/schema-form/index.js';
|
|
10
|
+
/** `{ 'a.b': 1, c: 2 }` → `{ a: { b: 1 }, c: 2 }`. */
|
|
11
|
+
export declare function nestValues(flat: Record<string, unknown>): Record<string, unknown>;
|
|
12
|
+
/** The same nesting for a flat object schema: each path segment becomes an object group. */
|
|
13
|
+
export declare function nestSchema(schema: JsonSchema): JsonSchema;
|
|
14
|
+
/** The layout `<SchemaForm>` renders a nested settings schema with; a group's label is its segment in sentence case. */
|
|
15
|
+
export declare function settingsLayout(schema: JsonSchema): UISchemaElement;
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The settings API is flat, keyed by dotted names (`intelligence.extraction_model`),
|
|
3
|
+
* and `<SchemaForm>` reads a `.` in a property name as a path. These nest the flat
|
|
4
|
+
* document along those paths, so every key is addressable, the change it reports
|
|
5
|
+
* carries the key back as `change.path`, and settings group by their domain. The
|
|
6
|
+
* form's generated layout does not descend into an object, so `settingsLayout`
|
|
7
|
+
* gives it one: a Group per domain, a Control per setting.
|
|
8
|
+
*/
|
|
9
|
+
/** `{ 'a.b': 1, c: 2 }` → `{ a: { b: 1 }, c: 2 }`. */
|
|
10
|
+
export function nestValues(flat) {
|
|
11
|
+
const root = {};
|
|
12
|
+
for (const [key, value] of Object.entries(flat)) {
|
|
13
|
+
const parts = key.split('.');
|
|
14
|
+
const leaf = parts.pop() ?? key;
|
|
15
|
+
let node = root;
|
|
16
|
+
for (const part of parts) {
|
|
17
|
+
node = (node[part] ??= {});
|
|
18
|
+
}
|
|
19
|
+
node[leaf] = value;
|
|
20
|
+
}
|
|
21
|
+
return root;
|
|
22
|
+
}
|
|
23
|
+
/** The same nesting for a flat object schema: each path segment becomes an object group. */
|
|
24
|
+
export function nestSchema(schema) {
|
|
25
|
+
const properties = {};
|
|
26
|
+
for (const [key, property] of Object.entries(schema.properties ?? {})) {
|
|
27
|
+
const parts = key.split('.');
|
|
28
|
+
const leaf = parts.pop() ?? key;
|
|
29
|
+
let node = properties;
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
node = (node[part] ??= { type: 'object', properties: {} }).properties ??= {};
|
|
32
|
+
}
|
|
33
|
+
node[leaf] = property;
|
|
34
|
+
}
|
|
35
|
+
return { ...schema, properties };
|
|
36
|
+
}
|
|
37
|
+
/** The layout `<SchemaForm>` renders a nested settings schema with; a group's label is its segment in sentence case. */
|
|
38
|
+
export function settingsLayout(schema) {
|
|
39
|
+
const layout = {
|
|
40
|
+
type: 'VerticalLayout',
|
|
41
|
+
elements: layoutElements(schema, '#')
|
|
42
|
+
};
|
|
43
|
+
return layout;
|
|
44
|
+
}
|
|
45
|
+
function layoutElements(schema, scope) {
|
|
46
|
+
return Object.entries(schema.properties ?? {}).map(([key, child]) => {
|
|
47
|
+
const childScope = `${scope}/properties/${key}`;
|
|
48
|
+
if (child.type === 'object' && child.properties) {
|
|
49
|
+
const label = key.replaceAll('_', ' ');
|
|
50
|
+
return {
|
|
51
|
+
type: 'Group',
|
|
52
|
+
label: label.charAt(0).toUpperCase() + label.slice(1),
|
|
53
|
+
elements: layoutElements(child, childScope)
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
return { type: 'Control', scope: childScope };
|
|
57
|
+
});
|
|
58
|
+
}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
The runtime settings page for app-slices' settings routes: every declared
|
|
3
|
+
Setting, rendered from the JSON Schema `GET <endpoint>/` returns, nested by
|
|
4
|
+
dot path and laid out one group per domain (`nest.ts`), so an app that
|
|
5
|
+
declares a setting adds no code here. `<SchemaForm>` has no per-field reset,
|
|
6
|
+
so "default versus override, reset in one act" (the operator's own ask) is
|
|
7
|
+
the panel below it. An app mounts it as its settings route's whole body.
|
|
8
|
+
-->
|
|
9
|
+
<script lang="ts" module>
|
|
10
|
+
import type { JsonSchema } from '../ui/schema-form/index.js';
|
|
11
|
+
|
|
12
|
+
export type SettingValue = boolean | number | string;
|
|
13
|
+
|
|
14
|
+
/** What `GET <endpoint>/` answers: app-slices' `SettingsDocument`. */
|
|
15
|
+
export type SettingsDocument = {
|
|
16
|
+
schema: JsonSchema;
|
|
17
|
+
value: Record<string, SettingValue>;
|
|
18
|
+
defaults: Record<string, SettingValue>;
|
|
19
|
+
};
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<script lang="ts">
|
|
23
|
+
import { onMount } from 'svelte';
|
|
24
|
+
import { toast } from 'svelte-sonner';
|
|
25
|
+
import { Button } from '../ui/button/index.js';
|
|
26
|
+
import { LoadingState } from '../ui/loading-state/index.js';
|
|
27
|
+
import { PageHeader } from '../ui/page-header/index.js';
|
|
28
|
+
import { Panel } from '../ui/panel/index.js';
|
|
29
|
+
import { SchemaForm, type SchemaFormChange } from '../ui/schema-form/index.js';
|
|
30
|
+
import { nestSchema, nestValues, settingsLayout } from './nest.js';
|
|
31
|
+
|
|
32
|
+
let {
|
|
33
|
+
/** The app's settings routes, defaulting to where the factory mounts them. */
|
|
34
|
+
endpoint = '/api/settings',
|
|
35
|
+
title = 'Application',
|
|
36
|
+
subtitle = "Runtime settings this deployment's operator can change without a code change or a deploy.",
|
|
37
|
+
/** What a 401 does: the app's own re-authentication (its API client's redirect), since only the app knows its sign-in. */
|
|
38
|
+
onunauthorized
|
|
39
|
+
}: {
|
|
40
|
+
endpoint?: string;
|
|
41
|
+
title?: string;
|
|
42
|
+
subtitle?: string;
|
|
43
|
+
onunauthorized?: () => void;
|
|
44
|
+
} = $props();
|
|
45
|
+
|
|
46
|
+
const TIMEOUT_MS = 30_000;
|
|
47
|
+
|
|
48
|
+
let doc = $state<SettingsDocument | null>(null);
|
|
49
|
+
let loadError = $state<string | null>(null);
|
|
50
|
+
/** What `<SchemaForm>` renders: the flat document nested by dot path; the flat one stays the state. */
|
|
51
|
+
let formSchema = $derived(doc ? nestSchema(doc.schema) : null);
|
|
52
|
+
let formValue = $derived(doc ? nestValues(doc.value) : {});
|
|
53
|
+
let formLayout = $derived(formSchema ? settingsLayout(formSchema) : undefined);
|
|
54
|
+
/** Keys whose effective value differs from its default — derived, never a second copy to disagree with the API. */
|
|
55
|
+
let overridden = $derived(
|
|
56
|
+
doc ? Object.keys(doc.value).filter((key) => doc!.value[key] !== doc!.defaults[key]) : []
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
/** The body on success, or the household error body's `message` (`{"error", "message"}`). */
|
|
60
|
+
async function call<T>(
|
|
61
|
+
path: string,
|
|
62
|
+
init: RequestInit = {}
|
|
63
|
+
): Promise<{ data: T } | { failure: string }> {
|
|
64
|
+
let response: Response;
|
|
65
|
+
try {
|
|
66
|
+
response = await fetch(`${endpoint}/${path}`, {
|
|
67
|
+
...init,
|
|
68
|
+
credentials: 'include',
|
|
69
|
+
signal: AbortSignal.timeout(TIMEOUT_MS)
|
|
70
|
+
});
|
|
71
|
+
} catch {
|
|
72
|
+
return { failure: 'The server could not be reached.' };
|
|
73
|
+
}
|
|
74
|
+
if (response.status === 401) onunauthorized?.();
|
|
75
|
+
const body = await response.json().catch(() => null);
|
|
76
|
+
if (response.ok && body !== null) return { data: body as T };
|
|
77
|
+
if (response.ok) return { failure: 'The server did not answer with settings.' };
|
|
78
|
+
return { failure: body?.message ?? `The server answered ${response.status}.` };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function load() {
|
|
82
|
+
const result = await call<SettingsDocument>('');
|
|
83
|
+
if ('failure' in result) {
|
|
84
|
+
loadError = result.failure;
|
|
85
|
+
return;
|
|
86
|
+
}
|
|
87
|
+
loadError = null;
|
|
88
|
+
doc = result.data;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
onMount(load);
|
|
92
|
+
|
|
93
|
+
async function save(key: string, path: string, init: RequestInit, failure: string) {
|
|
94
|
+
const result = await call<{ value: SettingValue }>(path, init);
|
|
95
|
+
if ('failure' in result) {
|
|
96
|
+
toast.error(failure, { description: result.failure });
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
doc!.value[key] = result.data.value;
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function handleChange(_next: Record<string, unknown>, change: SchemaFormChange) {
|
|
104
|
+
if (!doc) return;
|
|
105
|
+
const key = change.path;
|
|
106
|
+
const previous = doc.value[key];
|
|
107
|
+
doc.value[key] = change.value as SettingValue;
|
|
108
|
+
const saved = await save(
|
|
109
|
+
key,
|
|
110
|
+
encodeURIComponent(key),
|
|
111
|
+
{
|
|
112
|
+
method: 'PATCH',
|
|
113
|
+
headers: { 'content-type': 'application/json' },
|
|
114
|
+
body: JSON.stringify({ value: change.value })
|
|
115
|
+
},
|
|
116
|
+
'Could not save the setting'
|
|
117
|
+
);
|
|
118
|
+
if (!saved && previous !== undefined) doc.value[key] = previous;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function reset(key: string) {
|
|
122
|
+
void save(key, `${encodeURIComponent(key)}/reset`, { method: 'POST' }, 'Could not reset the setting');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function label(key: string): string {
|
|
126
|
+
const properties = (doc!.schema as { properties?: Record<string, { title?: string }> }).properties;
|
|
127
|
+
return properties?.[key]?.title ?? key;
|
|
128
|
+
}
|
|
129
|
+
</script>
|
|
130
|
+
|
|
131
|
+
<PageHeader {title} {subtitle} />
|
|
132
|
+
|
|
133
|
+
{#if loadError}
|
|
134
|
+
<Panel title="Could not load settings" tone="destructive">{loadError}</Panel>
|
|
135
|
+
{:else if !doc}
|
|
136
|
+
<LoadingState message="Loading…" class="mt-16" />
|
|
137
|
+
{:else}
|
|
138
|
+
<Panel title="Settings">
|
|
139
|
+
<SchemaForm schema={formSchema!} uischema={formLayout} value={formValue} onChange={handleChange} />
|
|
140
|
+
</Panel>
|
|
141
|
+
|
|
142
|
+
{#if overridden.length}
|
|
143
|
+
<Panel title="Overridden from default" subtitle="{overridden.length} changed">
|
|
144
|
+
<ul class="grid gap-2">
|
|
145
|
+
{#each overridden as key (key)}
|
|
146
|
+
<li class="flex items-center justify-between gap-4">
|
|
147
|
+
<span class="text-sm">
|
|
148
|
+
{label(key)}
|
|
149
|
+
<span class="text-muted-foreground font-mono text-xs">
|
|
150
|
+
default: {doc.defaults[key]}
|
|
151
|
+
</span>
|
|
152
|
+
</span>
|
|
153
|
+
<Button variant="outline" size="sm" onclick={() => reset(key)}>Reset</Button>
|
|
154
|
+
</li>
|
|
155
|
+
{/each}
|
|
156
|
+
</ul>
|
|
157
|
+
</Panel>
|
|
158
|
+
{/if}
|
|
159
|
+
{/if}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { JsonSchema } from '../ui/schema-form/index.js';
|
|
2
|
+
export type SettingValue = boolean | number | string;
|
|
3
|
+
/** What `GET <endpoint>/` answers: app-slices' `SettingsDocument`. */
|
|
4
|
+
export type SettingsDocument = {
|
|
5
|
+
schema: JsonSchema;
|
|
6
|
+
value: Record<string, SettingValue>;
|
|
7
|
+
defaults: Record<string, SettingValue>;
|
|
8
|
+
};
|
|
9
|
+
type $$ComponentProps = {
|
|
10
|
+
endpoint?: string;
|
|
11
|
+
title?: string;
|
|
12
|
+
subtitle?: string;
|
|
13
|
+
onunauthorized?: () => void;
|
|
14
|
+
};
|
|
15
|
+
declare const SettingsPage: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
16
|
+
type SettingsPage = ReturnType<typeof SettingsPage>;
|
|
17
|
+
export default SettingsPage;
|
|
@@ -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" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "week" | "date" | "time" | "
|
|
5
|
+
type?: "number" | "month" | "url" | "hidden" | "color" | "submit" | "reset" | "button" | "search" | "checkbox" | "radio" | (string & {}) | "text" | "tel" | "email" | "week" | "date" | "time" | "datetime-local" | "image" | "password" | "range";
|
|
6
6
|
files?: undefined;
|
|
7
7
|
})) & {
|
|
8
8
|
ref?: HTMLElement | null | undefined;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@poodle64/ui",
|
|
3
|
-
"version": "2026.
|
|
3
|
+
"version": "2026.10.0",
|
|
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",
|
|
@@ -42,6 +42,10 @@
|
|
|
42
42
|
"types": "./dist/components/alerts/index.d.ts",
|
|
43
43
|
"svelte": "./dist/components/alerts/index.js"
|
|
44
44
|
},
|
|
45
|
+
"./settings": {
|
|
46
|
+
"types": "./dist/components/settings/index.d.ts",
|
|
47
|
+
"svelte": "./dist/components/settings/index.js"
|
|
48
|
+
},
|
|
45
49
|
"./*": {
|
|
46
50
|
"types": "./dist/components/ui/*/index.d.ts",
|
|
47
51
|
"svelte": "./dist/components/ui/*/index.js"
|
|
@@ -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.
|
|
4
|
+
Generated from `@poodle64/ui@2026.10.0`. 61 components, 17 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
|
|