@aiaiai-pt/design-system 0.33.0 → 0.34.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.
@@ -0,0 +1,77 @@
1
+ <script lang="ts">
2
+ import ResultsChart from "../ResultsChart.svelte";
3
+ import type { WidgetProps } from "./types";
4
+ import { toRankedItems } from "./aggregate";
5
+
6
+ // `results-chart` widget (#105 Phase 4) — a bar chart over a public aggregate
7
+ // VIEW that SHIPS its data table (ARTE #3/#6: a chart is never the only
8
+ // encoding). Type-ranked on `kind: "aggregate"` (out-ranks the board default)
9
+ // so a `type: "results-chart"` block renders the chart while the bare kind
10
+ // renders the board. Vertical-agnostic: `props.label_field` / `value_field`
11
+ // name the view columns; `props.label_header` / `value_header` localise the
12
+ // table headers. Soft-empty on no rows (optional slot). Flush band.
13
+ let { data, props, ownsH1, locale }: WidgetProps = $props();
14
+
15
+ const rows = $derived(
16
+ data && typeof data === "object" && Array.isArray((data as { items?: unknown }).items)
17
+ ? ((data as { items: Record<string, unknown>[] }).items)
18
+ : [],
19
+ );
20
+
21
+ const labelField = $derived(
22
+ typeof props.label_field === "string" && props.label_field
23
+ ? props.label_field
24
+ : "label",
25
+ );
26
+ const valueField = $derived(
27
+ typeof props.value_field === "string" && props.value_field
28
+ ? props.value_field
29
+ : "value",
30
+ );
31
+ const limit = $derived(
32
+ typeof props.limit === "number" && props.limit > 0 ? props.limit : 0,
33
+ );
34
+
35
+ const items = $derived(
36
+ toRankedItems(rows, { labelField, valueField, limit }),
37
+ );
38
+
39
+ const heading = $derived(
40
+ typeof props.title === "string" && props.title ? props.title : "",
41
+ );
42
+ const caption = $derived(heading || "Results");
43
+ const labelHeader = $derived(
44
+ typeof props.label_header === "string" && props.label_header
45
+ ? props.label_header
46
+ : "Item",
47
+ );
48
+ const valueHeader = $derived(
49
+ typeof props.value_header === "string" && props.value_header
50
+ ? props.value_header
51
+ : "Value",
52
+ );
53
+ </script>
54
+
55
+ {#if items.length > 0}
56
+ <section class="widget-band">
57
+ {#if heading}
58
+ {#if ownsH1}<h1>{heading}</h1>{:else}<h2>{heading}</h2>{/if}
59
+ {/if}
60
+ <ResultsChart
61
+ {items}
62
+ {caption}
63
+ {labelHeader}
64
+ {valueHeader}
65
+ locale={locale ?? "en"}
66
+ />
67
+ </section>
68
+ {/if}
69
+
70
+ <style>
71
+ .widget-band {
72
+ width: 100%;
73
+ max-width: var(--content-width-narrow);
74
+ margin-inline: auto;
75
+ padding-inline: var(--content-padding-x);
76
+ }
77
+ </style>
@@ -0,0 +1,4 @@
1
+ import type { WidgetProps } from "./types";
2
+ declare const ResultsChartWidget: import("svelte").Component<WidgetProps, {}, "">;
3
+ type ResultsChartWidget = ReturnType<typeof ResultsChartWidget>;
4
+ export default ResultsChartWidget;
@@ -0,0 +1,33 @@
1
+ <script lang="ts">
2
+ import StatGrid from "../StatGrid.svelte";
3
+ import StatCard from "../StatCard.svelte";
4
+ import type { WidgetProps } from "./types";
5
+
6
+ // `kpi` widget — public stat grid (counts, KPIs). Adapts a list of stat
7
+ // items into DS StatCards inside a StatGrid. The public KPI read endpoint is
8
+ // not wired yet (resolveData fails closed for `kind: "kpi"`); registered here
9
+ // and fed once a slice adds the surface. Defensive over `data` (live) /
10
+ // `props` (seeded preview).
11
+ let { data, props }: WidgetProps = $props();
12
+
13
+ interface Stat {
14
+ label?: string;
15
+ value?: string;
16
+ variant?: string;
17
+ }
18
+
19
+ const view = $derived((data ?? {}) as Record<string, unknown>);
20
+ const stats = $derived<Stat[]>(
21
+ (Array.isArray(view.stats)
22
+ ? view.stats
23
+ : Array.isArray(props.stats)
24
+ ? props.stats
25
+ : []) as Stat[],
26
+ );
27
+ </script>
28
+
29
+ <StatGrid>
30
+ {#each stats as stat (stat.label)}
31
+ <StatCard label={stat.label} value={stat.value} variant={stat.variant} />
32
+ {/each}
33
+ </StatGrid>
@@ -0,0 +1,4 @@
1
+ import type { WidgetProps } from "./types";
2
+ declare const StatGridWidget: import("svelte").Component<WidgetProps, {}, "">;
3
+ type StatGridWidget = ReturnType<typeof StatGridWidget>;
4
+ export default StatGridWidget;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
3
+ *
4
+ * Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
5
+ * #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
6
+ * `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
7
+ * by value descending and optionally capped. Nothing here is civic-specific:
8
+ * the label/value columns are config (`label_field` / `value_field`), so any
9
+ * `group_by + count/sum` view (votes per proposal, reports per category,
10
+ * consumption per meter) becomes a chart/board from config alone.
11
+ *
12
+ * Coercion is deliberate and fail-soft: a missing/blank label falls back to the
13
+ * empty string (the widget decides whether to drop it), a non-numeric value
14
+ * coerces to 0 — an aggregate read never throws on a stray row.
15
+ */
16
+ export interface RankedItem {
17
+ label: string;
18
+ value: number;
19
+ }
20
+ export interface ToRankedOptions {
21
+ /** Key in each row holding the category label. Default `label`. */
22
+ labelField?: string;
23
+ /** Key holding the numeric aggregate. Default `value`. */
24
+ valueField?: string;
25
+ /** Cap the result to the top-N after sorting. 0/absent → no cap. */
26
+ limit?: number;
27
+ /** Drop rows whose label coerces to empty. Default true. */
28
+ dropBlankLabels?: boolean;
29
+ }
30
+ /**
31
+ * Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
32
+ * (ties keep input order); rows are never mutated.
33
+ */
34
+ export declare function toRankedItems(rows: ReadonlyArray<Record<string, unknown>>, opts?: ToRankedOptions): RankedItem[];
@@ -0,0 +1,72 @@
1
+ /**
2
+ * Aggregate-view row shaping (#105 Phase 4) — PURE, vertical-agnostic.
3
+ *
4
+ * Turns the rows of a public aggregate VIEW (served by the BFF public-view lane,
5
+ * #250 M3 — `{ items: [{ <label_field>, <value_field>, … }] }`) into the
6
+ * `{ label, value }` shape the DS `RankingBoard` / `ResultsChart` render, sorted
7
+ * by value descending and optionally capped. Nothing here is civic-specific:
8
+ * the label/value columns are config (`label_field` / `value_field`), so any
9
+ * `group_by + count/sum` view (votes per proposal, reports per category,
10
+ * consumption per meter) becomes a chart/board from config alone.
11
+ *
12
+ * Coercion is deliberate and fail-soft: a missing/blank label falls back to the
13
+ * empty string (the widget decides whether to drop it), a non-numeric value
14
+ * coerces to 0 — an aggregate read never throws on a stray row.
15
+ */
16
+
17
+ export interface RankedItem {
18
+ label: string;
19
+ value: number;
20
+ }
21
+
22
+ export interface ToRankedOptions {
23
+ /** Key in each row holding the category label. Default `label`. */
24
+ labelField?: string;
25
+ /** Key holding the numeric aggregate. Default `value`. */
26
+ valueField?: string;
27
+ /** Cap the result to the top-N after sorting. 0/absent → no cap. */
28
+ limit?: number;
29
+ /** Drop rows whose label coerces to empty. Default true. */
30
+ dropBlankLabels?: boolean;
31
+ }
32
+
33
+ function toNumber(value: unknown): number {
34
+ if (typeof value === "number") return Number.isFinite(value) ? value : 0;
35
+ if (typeof value === "string" && value.trim() !== "") {
36
+ const n = Number(value);
37
+ return Number.isFinite(n) ? n : 0;
38
+ }
39
+ return 0;
40
+ }
41
+
42
+ /**
43
+ * Map raw view rows → sorted `{ label, value }`. Stable sort by value desc
44
+ * (ties keep input order); rows are never mutated.
45
+ */
46
+ export function toRankedItems(
47
+ rows: ReadonlyArray<Record<string, unknown>>,
48
+ opts: ToRankedOptions = {},
49
+ ): RankedItem[] {
50
+ const labelField = opts.labelField || "label";
51
+ const valueField = opts.valueField || "value";
52
+ const dropBlank = opts.dropBlankLabels !== false;
53
+
54
+ const mapped = rows.map((row) => ({
55
+ label: row[labelField] == null ? "" : String(row[labelField]),
56
+ value: toNumber(row[valueField]),
57
+ }));
58
+
59
+ const kept = dropBlank ? mapped.filter((it) => it.label !== "") : mapped;
60
+
61
+ // Stable desc sort: decorate with the original index so equal values keep
62
+ // their input order (Array.prototype.sort is spec-stable, but the index tie-
63
+ // break documents the intent and is robust to value-equality edge cases).
64
+ const sorted = kept
65
+ .map((it, i) => ({ it, i }))
66
+ .sort((a, b) => b.it.value - a.it.value || a.i - b.i)
67
+ .map(({ it }) => it);
68
+
69
+ return opts.limit && opts.limit > 0
70
+ ? sorted.slice(0, Math.floor(opts.limit))
71
+ : sorted;
72
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * DataProvider — the host-injected data transport seam (#492 P0).
3
+ *
4
+ * Each host (portal, admin) owns its own DataProvider. The portal constructs a
5
+ * `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
6
+ * A future admin host constructs its own provider over the RLS-authenticated
7
+ * entity-proxy lane. `resolveData` depends only on this interface, never on
8
+ * the proxy prefix or the tenant/app segment directly.
9
+ */
10
+ import type { Binding } from "./types";
11
+ import type { FetchLike } from "./resolve-data";
12
+ import type { VoteReadConfig } from "./vote";
13
+ export interface DataUrlOpts {
14
+ /** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
15
+ calendarWindow?: {
16
+ start: string;
17
+ end: string;
18
+ };
19
+ /** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
20
+ filterParams?: Record<string, string>;
21
+ }
22
+ /**
23
+ * SECURITY INVARIANT (never remove this comment):
24
+ * Each host owns its own DataProvider and its own security boundary. The
25
+ * portal's DataProvider routes through the public /bff proxy (auth-optional,
26
+ * SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
27
+ * the RLS-authenticated entity-proxy lane. NEVER share one transport across
28
+ * both hosts. NEVER let a public widget read through an authed token, nor
29
+ * expose the authed lane through the public proxy. A "simplification" that
30
+ * collapses the two providers is a tenant/RLS leak.
31
+ */
32
+ export interface DataProvider {
33
+ /**
34
+ * The raw data path for the binding, with calendar window and filter params
35
+ * applied. Returns `null` for unmappable bindings (caller fails closed).
36
+ * The path is the PAGEABLE surface: widgets re-fetch it client-side with
37
+ * `?after=` for "load more". It does NOT include the proxy prefix — the
38
+ * provider's `fetch` adds the prefix on the outbound call.
39
+ */
40
+ dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
41
+ /**
42
+ * The entity schema path for the binding. Returns `null` for kinds that
43
+ * carry no schema (content, map, calendar, …). The path does NOT include
44
+ * the proxy prefix.
45
+ */
46
+ schemaUrl(binding: Binding): string | null;
47
+ /**
48
+ * The authed account lane URL for the given path segment.
49
+ * Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
50
+ * boundary (require_auth + OWN-scoped) from the public `/bff` surface.
51
+ */
52
+ accountUrl(segment: string): string;
53
+ /**
54
+ * The option-view read URL (for subscription scope dropdowns, #252).
55
+ * Returns the FULL URL including the proxy prefix.
56
+ */
57
+ optionViewUrl(view: string, limit: number): string;
58
+ /** Edition read path for the `vote` ballot composer. Full URL. */
59
+ voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
60
+ /** Options read path for the `vote` ballot composer. Full URL. */
61
+ voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
62
+ /** The fetch transport. Accepts any URL returned by the methods above. */
63
+ fetch: FetchLike;
64
+ }
65
+ /**
66
+ * Construct the portal's public DataProvider.
67
+ *
68
+ * Reproduces today's `resolveData` URL behavior byte-for-byte:
69
+ * - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
70
+ * `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
71
+ * so the raw path is also the pageable `dataPath` the widgets store.
72
+ * - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
73
+ * through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
74
+ * - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
75
+ * already including `/bff`. The provider's `fetch` passes them through.
76
+ *
77
+ * The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
78
+ * through unchanged; anything else (raw `/{app}/public/...` paths) gets
79
+ * `/bff` prepended. This keeps the `fetch` self-contained in the provider —
80
+ * `resolveData` never sees or constructs a proxy prefix.
81
+ */
82
+ export declare function createPublicDataProvider(app: string, fetchImpl: FetchLike): DataProvider;
@@ -0,0 +1,162 @@
1
+ /**
2
+ * DataProvider — the host-injected data transport seam (#492 P0).
3
+ *
4
+ * Each host (portal, admin) owns its own DataProvider. The portal constructs a
5
+ * `createPublicDataProvider` that routes through the auth-optional `/bff` proxy.
6
+ * A future admin host constructs its own provider over the RLS-authenticated
7
+ * entity-proxy lane. `resolveData` depends only on this interface, never on
8
+ * the proxy prefix or the tenant/app segment directly.
9
+ */
10
+
11
+ import type { Binding } from "./types";
12
+ import type { FetchLike } from "./resolve-data";
13
+ import type { VoteReadConfig } from "./vote";
14
+ import {
15
+ bffPathForBinding,
16
+ schemaPathForBinding,
17
+ appendQuery,
18
+ } from "./resolve-data";
19
+ import { editionReadPath, optionsReadPath } from "./vote";
20
+
21
+ export interface DataUrlOpts {
22
+ /** Calendar window injected from `now` (73e) — applied only for the `calendar` kind. */
23
+ calendarWindow?: { start: string; end: string };
24
+ /** URL facet/search params forwarded from the page URL (#308) — applied for `list`/`map`. */
25
+ filterParams?: Record<string, string>;
26
+ }
27
+
28
+ /**
29
+ * SECURITY INVARIANT (never remove this comment):
30
+ * Each host owns its own DataProvider and its own security boundary. The
31
+ * portal's DataProvider routes through the public /bff proxy (auth-optional,
32
+ * SSRF-pinned to /{app}/public/). A future admin DataProvider routes through
33
+ * the RLS-authenticated entity-proxy lane. NEVER share one transport across
34
+ * both hosts. NEVER let a public widget read through an authed token, nor
35
+ * expose the authed lane through the public proxy. A "simplification" that
36
+ * collapses the two providers is a tenant/RLS leak.
37
+ */
38
+ export interface DataProvider {
39
+ /**
40
+ * The raw data path for the binding, with calendar window and filter params
41
+ * applied. Returns `null` for unmappable bindings (caller fails closed).
42
+ * The path is the PAGEABLE surface: widgets re-fetch it client-side with
43
+ * `?after=` for "load more". It does NOT include the proxy prefix — the
44
+ * provider's `fetch` adds the prefix on the outbound call.
45
+ */
46
+ dataUrl(binding: Binding, opts?: DataUrlOpts): string | null;
47
+
48
+ /**
49
+ * The entity schema path for the binding. Returns `null` for kinds that
50
+ * carry no schema (content, map, calendar, …). The path does NOT include
51
+ * the proxy prefix.
52
+ */
53
+ schemaUrl(binding: Binding): string | null;
54
+
55
+ /**
56
+ * The authed account lane URL for the given path segment.
57
+ * Returns `/me/{segment}` — NO `/bff` prefix. This is a different security
58
+ * boundary (require_auth + OWN-scoped) from the public `/bff` surface.
59
+ */
60
+ accountUrl(segment: string): string;
61
+
62
+ /**
63
+ * The option-view read URL (for subscription scope dropdowns, #252).
64
+ * Returns the FULL URL including the proxy prefix.
65
+ */
66
+ optionViewUrl(view: string, limit: number): string;
67
+
68
+ /** Edition read path for the `vote` ballot composer. Full URL. */
69
+ voteEditionUrl(cfg: VoteReadConfig, editionId: string): string;
70
+
71
+ /** Options read path for the `vote` ballot composer. Full URL. */
72
+ voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string;
73
+
74
+ /** The fetch transport. Accepts any URL returned by the methods above. */
75
+ fetch: FetchLike;
76
+ }
77
+
78
+ function enc(value: string): string {
79
+ return encodeURIComponent(value);
80
+ }
81
+
82
+ /**
83
+ * Construct the portal's public DataProvider.
84
+ *
85
+ * Reproduces today's `resolveData` URL behavior byte-for-byte:
86
+ * - `dataUrl` / `schemaUrl` return the raw `/{app}/public/...` path (no
87
+ * `/bff` prefix). The provider's `fetch` adds `/bff` on the outbound call
88
+ * so the raw path is also the pageable `dataPath` the widgets store.
89
+ * - `accountUrl` returns `/me/{segment}` — the authed lane, never proxied
90
+ * through `/bff`. The provider's `fetch` passes `/me/...` through as-is.
91
+ * - `optionViewUrl`, `voteEditionUrl`, `voteOptionsUrl` return the full URL
92
+ * already including `/bff`. The provider's `fetch` passes them through.
93
+ *
94
+ * The `fetch` wrapper decides by prefix: `/bff/` and `/me/` are passed
95
+ * through unchanged; anything else (raw `/{app}/public/...` paths) gets
96
+ * `/bff` prepended. This keeps the `fetch` self-contained in the provider —
97
+ * `resolveData` never sees or constructs a proxy prefix.
98
+ */
99
+ export function createPublicDataProvider(
100
+ app: string,
101
+ fetchImpl: FetchLike,
102
+ ): DataProvider {
103
+ /**
104
+ * Portal fetch transport. Paths that already carry their proxy prefix
105
+ * (`/bff/...` for the public lane, `/me/...` for the authed account lane)
106
+ * are forwarded unchanged. Raw `/{app}/public/...` paths (returned by
107
+ * `dataUrl` and `schemaUrl`) are proxied through `/bff`.
108
+ */
109
+ const fetch: FetchLike = (url: string) => {
110
+ if (url.startsWith("/bff/") || url.startsWith("/me/")) {
111
+ return fetchImpl(url);
112
+ }
113
+ return fetchImpl(`/bff${url}`);
114
+ };
115
+
116
+ return {
117
+ dataUrl(binding: Binding, opts?: DataUrlOpts): string | null {
118
+ const path = bffPathForBinding(binding, app);
119
+ if (path === null) return null;
120
+ let url = path;
121
+ // Calendar requires start/end (73e); the window is pre-computed in
122
+ // resolveData from `now` and passed in via opts.
123
+ if (binding.kind === "calendar" && opts?.calendarWindow) {
124
+ const { start, end } = opts.calendarWindow;
125
+ url = `${path}?start=${start}&end=${end}`;
126
+ }
127
+ // Forward URL facet/search params (#308) for list + map feeds so the
128
+ // SSR result is server-FILTERED. The BFF drops undeclared params.
129
+ if (
130
+ (binding.kind === "list" || binding.kind === "map") &&
131
+ opts?.filterParams
132
+ ) {
133
+ url = appendQuery(url, opts.filterParams);
134
+ }
135
+ return url;
136
+ },
137
+
138
+ schemaUrl(binding: Binding): string | null {
139
+ return schemaPathForBinding(binding, app);
140
+ },
141
+
142
+ accountUrl(segment: string): string {
143
+ return `/me/${segment}`;
144
+ },
145
+
146
+ optionViewUrl(view: string, limit: number): string {
147
+ return `/bff/${enc(app)}/public/${enc(view)}?limit=${limit}`;
148
+ },
149
+
150
+ voteEditionUrl(cfg: VoteReadConfig, editionId: string): string {
151
+ // Delegates to the unchanged vote.ts helper — guarantees byte-identical
152
+ // vote URLs (vote.ts must NOT be modified, spec §P0).
153
+ return editionReadPath(app, cfg, editionId);
154
+ },
155
+
156
+ voteOptionsUrl(cfg: VoteReadConfig, editionId: string): string {
157
+ return optionsReadPath(app, cfg, editionId);
158
+ },
159
+
160
+ fetch,
161
+ };
162
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
3
+ *
4
+ * Generalises the admin app's XSS-hardened layout registry
5
+ * (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
6
+ * widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
7
+ * highest score wins. A more specific tester (e.g. "list of `occurrence`")
8
+ * outranks the generic "any list" and overrides one widget for one entity
9
+ * WITHOUT editing the catalog (open/closed).
10
+ *
11
+ * The ranking core here is generic over the payload `P` so it stays
12
+ * component-free and fully unit/mutation-testable; the real registry (73b)
13
+ * instantiates it with Svelte `Component`s.
14
+ *
15
+ * TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
16
+ * the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
17
+ * is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
18
+ */
19
+ import type { Binding, Block, OntologySchema } from "./types";
20
+ /** Tester sentinel — "this widget does not apply to this binding". */
21
+ export declare const NOT_APPLICABLE = -1;
22
+ /**
23
+ * A tester ranks a registry entry against a binding (+ optional schema and the
24
+ * block's authoring `type` hint). JSONForms model: testers see the full
25
+ * element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
26
+ * generic kind widget (`entity-list`) for the SAME `binding.kind` without
27
+ * editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
28
+ * resolved `key` still comes from the matched entry (TH-08), never from `type`.
29
+ */
30
+ export type WidgetTester = (binding: Binding, schema: OntologySchema | null, type?: string) => number;
31
+ export interface RegistryEntry<P> {
32
+ /** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
33
+ key: string;
34
+ payload: P;
35
+ tester: WidgetTester;
36
+ }
37
+ export interface Match<P> {
38
+ key: string;
39
+ payload: P;
40
+ }
41
+ /**
42
+ * Run every tester against the binding; the highest applicable score wins.
43
+ * Ties resolve to the first-registered entry (stable, deterministic). Returns
44
+ * `null` when no tester is applicable — the caller fails closed per blast
45
+ * radius; nothing about the binding is interpolated.
46
+ */
47
+ export declare function selectEntry<P>(entries: ReadonlyArray<RegistryEntry<P>>, binding: Binding, schema: OntologySchema | null, type?: string): Match<P> | null;
48
+ export type RenderDecision = {
49
+ render: "widget";
50
+ } | {
51
+ render: "empty";
52
+ } | {
53
+ render: "error";
54
+ };
55
+ /**
56
+ * Fail-closed per blast radius (§14.8). Given whether a widget matched AND
57
+ * whether its data resolved, decide how the slot renders:
58
+ * - matched + data ok → render the widget
59
+ * - failed, optional slot → soft-empty (render nothing)
60
+ * - failed, structural slot → visible error (MUST NOT silently vanish)
61
+ * The operator's raw `type`/`kind` is never interpolated in any branch.
62
+ */
63
+ export declare function decideRender(block: Pick<Block, "importance">, matched: boolean, dataOk: boolean): RenderDecision;
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Widget dispatcher — tester/priority, not a flat map (D4, JSONForms model).
3
+ *
4
+ * Generalises the admin app's XSS-hardened layout registry
5
+ * (`admin/src/lib/renderer-layouts/resolve.ts`) from form-row LayoutKeys to
6
+ * widget `kind`s. Each entry carries a `tester(binding, schema) → score`; the
7
+ * highest score wins. A more specific tester (e.g. "list of `occurrence`")
8
+ * outranks the generic "any list" and overrides one widget for one entity
9
+ * WITHOUT editing the catalog (open/closed).
10
+ *
11
+ * The ranking core here is generic over the payload `P` so it stays
12
+ * component-free and fully unit/mutation-testable; the real registry (73b)
13
+ * instantiates it with Svelte `Component`s.
14
+ *
15
+ * TH-08 (R-SEC-07) preserved: the resolved `key` is a known-good literal from
16
+ * the matched entry — the operator's raw `binding.kind`/`entity`/block `type`
17
+ * is NEVER returned as the key, so it never reaches `class`/`data-*`/`style`.
18
+ */
19
+ import type { Binding, Block, OntologySchema } from "./types";
20
+
21
+ /** Tester sentinel — "this widget does not apply to this binding". */
22
+ export const NOT_APPLICABLE = -1;
23
+
24
+ /**
25
+ * A tester ranks a registry entry against a binding (+ optional schema and the
26
+ * block's authoring `type` hint). JSONForms model: testers see the full
27
+ * element, so a type-specific widget (e.g. `filter-bar`) can out-rank the
28
+ * generic kind widget (`entity-list`) for the SAME `binding.kind` without
29
+ * editing the kind entry. `type` is UNTRUSTED — it only influences ranking; the
30
+ * resolved `key` still comes from the matched entry (TH-08), never from `type`.
31
+ */
32
+ export type WidgetTester = (
33
+ binding: Binding,
34
+ schema: OntologySchema | null,
35
+ type?: string,
36
+ ) => number;
37
+
38
+ export interface RegistryEntry<P> {
39
+ /** Known-good literal. Safe to render as `data-widget={key}` (TH-08). */
40
+ key: string;
41
+ payload: P;
42
+ tester: WidgetTester;
43
+ }
44
+
45
+ export interface Match<P> {
46
+ key: string;
47
+ payload: P;
48
+ }
49
+
50
+ /**
51
+ * Run every tester against the binding; the highest applicable score wins.
52
+ * Ties resolve to the first-registered entry (stable, deterministic). Returns
53
+ * `null` when no tester is applicable — the caller fails closed per blast
54
+ * radius; nothing about the binding is interpolated.
55
+ */
56
+ export function selectEntry<P>(
57
+ entries: ReadonlyArray<RegistryEntry<P>>,
58
+ binding: Binding,
59
+ schema: OntologySchema | null,
60
+ type?: string,
61
+ ): Match<P> | null {
62
+ let best: RegistryEntry<P> | null = null;
63
+ let bestScore: number = NOT_APPLICABLE;
64
+ for (const entry of entries) {
65
+ const score = entry.tester(binding, schema, type);
66
+ // Strictly greater → first registered wins ties.
67
+ if (score > bestScore) {
68
+ bestScore = score;
69
+ best = entry;
70
+ }
71
+ }
72
+ if (best === null) return null;
73
+ // TH-08: key from the matched entry, never from the binding/block.
74
+ return { key: best.key, payload: best.payload };
75
+ }
76
+
77
+ export type RenderDecision =
78
+ | { render: "widget" }
79
+ | { render: "empty" }
80
+ | { render: "error" };
81
+
82
+ /**
83
+ * Fail-closed per blast radius (§14.8). Given whether a widget matched AND
84
+ * whether its data resolved, decide how the slot renders:
85
+ * - matched + data ok → render the widget
86
+ * - failed, optional slot → soft-empty (render nothing)
87
+ * - failed, structural slot → visible error (MUST NOT silently vanish)
88
+ * The operator's raw `type`/`kind` is never interpolated in any branch.
89
+ */
90
+ export function decideRender(
91
+ block: Pick<Block, "importance">,
92
+ matched: boolean,
93
+ dataOk: boolean,
94
+ ): RenderDecision {
95
+ if (matched && dataOk) return { render: "widget" };
96
+ return block.importance === "structural"
97
+ ? { render: "error" }
98
+ : { render: "empty" };
99
+ }