@poodle64/ui 2026.9.23 → 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 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-v2026.8.3
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-v2026.8.3
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. Commit, tag `ui-v<version>`, push the tag.
1414
- 4. `.github/workflows/publish-kit-typescript.yaml` (repo root) runs on that push and publishes via npm
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`.
@@ -0,0 +1,3 @@
1
+ import SettingsPage, { type SettingsDocument, type SettingValue } from './settings-page.svelte';
2
+ export { nestSchema, nestValues, settingsLayout } from './nest.js';
3
+ export { SettingsPage, SettingsPage as default, type SettingsDocument, type SettingValue };
@@ -0,0 +1,3 @@
1
+ import SettingsPage, {} from './settings-page.svelte';
2
+ export { nestSchema, nestValues, settingsLayout } from './nest.js';
3
+ export { SettingsPage, SettingsPage as default };
@@ -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" | "image" | "datetime-local" | "password" | "range";
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.9.23",
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
  {
2
2
  "meta": {
3
3
  "package": "@poodle64/ui",
4
- "version": "2026.9.23",
4
+ "version": "2026.10.0",
5
5
  "generatedBy": "scripts/generate-registry.mjs",
6
6
  "source": "scripts/situations.json + package source (DO NOT EDIT the outputs by hand)",
7
7
  "componentCount": 61,
@@ -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.23`. 61 components, 17 situations.
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