@aiaiai-pt/design-system 0.33.0 → 0.35.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,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
+ }
@@ -0,0 +1,48 @@
1
+ /**
2
+ * DS renderer widget registry — the extensible dispatcher layer (#492).
3
+ *
4
+ * Ships a BASE registry with the two clean DS widgets (stat-grid + results-chart)
5
+ * and exposes a `registerWidget` hook so host apps (the portal, the admin) can
6
+ * append their own coupled widgets at startup without modifying this module
7
+ * (open/closed principle, JSONForms model).
8
+ *
9
+ * `dispatch.ts` (the tester/priority core) is the single dispatcher both the DS
10
+ * base registry and host-extended registries share; the registry is generic over
11
+ * the payload so `selectEntry` stays component-free and unit-testable.
12
+ *
13
+ * TH-08 preserved: `resolveWidget` returns the matched entry's known-good `key`
14
+ * literal — the operator's raw block `type`/`binding.kind` is never the key.
15
+ */
16
+ import type { Component } from "svelte";
17
+ import { type Match, type RegistryEntry } from "./dispatch";
18
+ import type { Block, OntologySchema, WidgetKind, WidgetProps } from "./types";
19
+ export type WidgetComponent = Component<WidgetProps>;
20
+ /**
21
+ * A TYPE-specific widget over a shared kind (JSONForms model): the authoring
22
+ * `type` hint ranks it ABOVE the kind-generic widget (score 20 > 10) for the
23
+ * same binding. `type` is UNTRUSTED — it only influences ranking; the resolved
24
+ * key is the entry literal (TH-08).
25
+ */
26
+ export declare function byTypeOnKind(key: string, kind: WidgetKind, component: unknown): RegistryEntry<WidgetComponent>;
27
+ /** A widget whose tester is the generic "binding.kind === K" check (score 10). */
28
+ export declare function byKind(key: string, kind: WidgetKind, component: unknown): RegistryEntry<WidgetComponent>;
29
+ /**
30
+ * Register a widget in the host-extensible registry. Call at app startup
31
+ * (before any `resolveWidget` calls) to append host-specific widgets. Each
32
+ * entry is appended in call order; ties within the same score resolve to the
33
+ * first-registered entry (stable, deterministic — base entries win ties).
34
+ *
35
+ * Signature mirrors `byKind` / `byTypeOnKind` so the caller writes:
36
+ * `registerWidget(byKind("entity-list", "list", EntityListWidget))`
37
+ *
38
+ * @param entry - A registry entry built with `byKind`, `byTypeOnKind`, or a
39
+ * hand-crafted `{ key, payload, tester }` for a custom tester.
40
+ */
41
+ export declare function registerWidget(entry: RegistryEntry<WidgetComponent>): void;
42
+ /**
43
+ * Resolve a block to its widget via the tester/priority dispatcher. Searches
44
+ * the full registry (base DS entries + host-registered). Returns `null` when
45
+ * no widget applies — the caller renders per blast radius (`decideRender`),
46
+ * never interpolating the block's raw `type`/`kind`.
47
+ */
48
+ export declare function resolveWidget(block: Block, schema?: OntologySchema | null): Match<WidgetComponent> | null;
@@ -0,0 +1,101 @@
1
+ /**
2
+ * DS renderer widget registry — the extensible dispatcher layer (#492).
3
+ *
4
+ * Ships a BASE registry with the two clean DS widgets (stat-grid + results-chart)
5
+ * and exposes a `registerWidget` hook so host apps (the portal, the admin) can
6
+ * append their own coupled widgets at startup without modifying this module
7
+ * (open/closed principle, JSONForms model).
8
+ *
9
+ * `dispatch.ts` (the tester/priority core) is the single dispatcher both the DS
10
+ * base registry and host-extended registries share; the registry is generic over
11
+ * the payload so `selectEntry` stays component-free and unit-testable.
12
+ *
13
+ * TH-08 preserved: `resolveWidget` returns the matched entry's known-good `key`
14
+ * literal — the operator's raw block `type`/`binding.kind` is never the key.
15
+ */
16
+ import type { Component } from "svelte";
17
+ import {
18
+ NOT_APPLICABLE,
19
+ selectEntry,
20
+ type Match,
21
+ type RegistryEntry,
22
+ } from "./dispatch";
23
+ import type { Block, OntologySchema, WidgetKind, WidgetProps } from "./types";
24
+
25
+ import EChartWidget from "./EChartWidget.svelte";
26
+ import ResultsChartWidget from "./ResultsChartWidget.svelte";
27
+ import StatGridWidget from "./StatGridWidget.svelte";
28
+
29
+ export type WidgetComponent = Component<WidgetProps>;
30
+
31
+ /**
32
+ * A TYPE-specific widget over a shared kind (JSONForms model): the authoring
33
+ * `type` hint ranks it ABOVE the kind-generic widget (score 20 > 10) for the
34
+ * same binding. `type` is UNTRUSTED — it only influences ranking; the resolved
35
+ * key is the entry literal (TH-08).
36
+ */
37
+ export function byTypeOnKind(
38
+ key: string,
39
+ kind: WidgetKind,
40
+ component: unknown,
41
+ ): RegistryEntry<WidgetComponent> {
42
+ return {
43
+ key,
44
+ payload: component as WidgetComponent,
45
+ tester: (binding, _schema, type) =>
46
+ binding.kind === kind && type === key ? 20 : NOT_APPLICABLE,
47
+ };
48
+ }
49
+
50
+ /** A widget whose tester is the generic "binding.kind === K" check (score 10). */
51
+ export function byKind(
52
+ key: string,
53
+ kind: WidgetKind,
54
+ component: unknown,
55
+ ): RegistryEntry<WidgetComponent> {
56
+ return {
57
+ key,
58
+ payload: component as WidgetComponent,
59
+ tester: (binding) => (binding.kind === kind ? 10 : NOT_APPLICABLE),
60
+ };
61
+ }
62
+
63
+ /**
64
+ * Mutable internal registry — base DS entries first, host-registered entries
65
+ * appended in call order. Exported (readonly view) for introspection / testing.
66
+ */
67
+ const _entries: RegistryEntry<WidgetComponent>[] = [
68
+ // DS base: the two clean widgets shipped by this package.
69
+ byKind("stat-grid", "kpi", StatGridWidget),
70
+ byTypeOnKind("results-chart", "aggregate", ResultsChartWidget),
71
+ byTypeOnKind("chart", "aggregate", EChartWidget),
72
+ ];
73
+
74
+ /**
75
+ * Register a widget in the host-extensible registry. Call at app startup
76
+ * (before any `resolveWidget` calls) to append host-specific widgets. Each
77
+ * entry is appended in call order; ties within the same score resolve to the
78
+ * first-registered entry (stable, deterministic — base entries win ties).
79
+ *
80
+ * Signature mirrors `byKind` / `byTypeOnKind` so the caller writes:
81
+ * `registerWidget(byKind("entity-list", "list", EntityListWidget))`
82
+ *
83
+ * @param entry - A registry entry built with `byKind`, `byTypeOnKind`, or a
84
+ * hand-crafted `{ key, payload, tester }` for a custom tester.
85
+ */
86
+ export function registerWidget(entry: RegistryEntry<WidgetComponent>): void {
87
+ _entries.push(entry);
88
+ }
89
+
90
+ /**
91
+ * Resolve a block to its widget via the tester/priority dispatcher. Searches
92
+ * the full registry (base DS entries + host-registered). Returns `null` when
93
+ * no widget applies — the caller renders per blast radius (`decideRender`),
94
+ * never interpolating the block's raw `type`/`kind`.
95
+ */
96
+ export function resolveWidget(
97
+ block: Block,
98
+ schema: OntologySchema | null = null,
99
+ ): Match<WidgetComponent> | null {
100
+ return selectEntry(_entries, block.binding, schema, block.type);
101
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * `resolveData(block)` — the single data path between a block's pointer and a
3
+ * widget (#73, spec §5). Reads the `binding`, fetches through the host-injected
4
+ * `DataProvider` (#492 P0), and returns normalised `{ schema, data, actionDef }`.
5
+ * The block stores ONLY the pointer — rows are never baked in.
6
+ *
7
+ * Pure + provider-injectable so the security-critical decisions (which paths are
8
+ * reachable, fail-closed on misconfig/upstream error) are unit/mutation-
9
+ * testable without SvelteKit. The route loader is a thin wrapper that passes a
10
+ * `createPublicDataProvider` wrapping its server `fetch` (which carries the
11
+ * tenant + graduated principal).
12
+ *
13
+ * Fail-closed (§14.8): an unmappable binding never fetches; a non-OK upstream
14
+ * yields `{ ok: false }` and the caller renders per blast radius (see
15
+ * `decideRender` in `./dispatch.ts`).
16
+ */
17
+ import type { Binding, Block, FeedView, OntologySchema, WidgetKind } from "./types";
18
+ import type { DataProvider } from "./data-provider";
19
+ /**
20
+ * Presentational kinds (#75 M5 hero/lookup + #105 Phase 7 landing/content) that
21
+ * render PURELY from their block props — no data path, no fetch. resolveData
22
+ * short-circuits them with `{ data: null }`, which still satisfies decideRender's
23
+ * "matched + dataOk" contract (the widget owns its own props). Keeping them in
24
+ * one set means a new content section never accidentally tries to fetch.
25
+ */
26
+ export declare const PRESENTATIONAL_KINDS: ReadonlySet<WidgetKind>;
27
+ export interface ResolvedData {
28
+ data: unknown;
29
+ /** Ontology schema for the bound entity — wired in 73d. */
30
+ schema: OntologySchema | null;
31
+ /** Action definition for form bindings — wired in 73f. */
32
+ actionDef: unknown | null;
33
+ /** The BFF path the data came from (#75 M5 slice 4) — widgets that page
34
+ * (list "load more") re-fetch it client-side with `?after=`. */
35
+ dataPath?: string;
36
+ }
37
+ export type ResolveDataResult = {
38
+ ok: true;
39
+ value: ResolvedData;
40
+ } | {
41
+ ok: false;
42
+ status: number;
43
+ reason: string;
44
+ };
45
+ /** The minimal fetch shape we depend on (injectable; SvelteKit `fetch` fits). */
46
+ export type FetchLike = (path: string) => Promise<{
47
+ ok: boolean;
48
+ status: number;
49
+ json: () => Promise<unknown>;
50
+ }>;
51
+ export interface ResolveDataDeps {
52
+ /** Host-injected data transport seam (#492 P0). */
53
+ provider: DataProvider;
54
+ /**
55
+ * "Now", injected by the route loader (`new Date()`), used to compute the
56
+ * calendar's required `start`/`end` window (73e). Optional — only the
57
+ * `calendar` kind reads it. Injected (not read here) so the path stays
58
+ * deterministic in tests.
59
+ */
60
+ now?: Date;
61
+ /**
62
+ * The active feed view (`?view=` — #308), used only by the `feed` kind to
63
+ * pick which underlying feed (list/map) to resolve. Absent → the block's
64
+ * first configured view.
65
+ */
66
+ view?: string;
67
+ /**
68
+ * The URL's facet/search params (#308), forwarded to a feed read so the SSR
69
+ * feed is server-FILTERED (and paging rides `dataPath`). The BFF drops any
70
+ * param it doesn't declare (its security contract), so the whole set is safe
71
+ * to pass. Reserved keys (`view`, `reference`) are stripped by the loader.
72
+ */
73
+ filterParams?: Record<string, string>;
74
+ }
75
+ export declare function feedViews(props: Record<string, unknown> | undefined): FeedView[];
76
+ /** The active view = the requested one when it's an available view, else the
77
+ * default (first). Keeps `?view=` shareable + an unknown value harmless. */
78
+ export declare function activeFeedView(views: FeedView[], requested: string | undefined): FeedView;
79
+ /** Merge facet/search params into a BFF path (handles an existing `?`). Empty
80
+ * values drop; an empty param set returns the path unchanged. */
81
+ export declare function appendQuery(path: string, params: Record<string, string> | undefined): string;
82
+ /**
83
+ * Pure mapping: pointer → BFF public path (#70 surface, app-first). Returns
84
+ * `null` for any binding that doesn't map to a wired read endpoint — the
85
+ * caller fails closed (no fetch, no DOM). `binding.filter` carries the
86
+ * per-route selector (ref/key/id) for the single-resource kinds.
87
+ */
88
+ export declare function bffPathForBinding(binding: Binding, app: string): string | null;
89
+ /**
90
+ * Pure mapping: pointer → BFF public *schema* path (#73 / 73d). Only the
91
+ * schema-driven kinds (`list`, `detail`) have one; the entity's field shape
92
+ * (key/type/label) lets EntityList/DetailView render columns/fields without a
93
+ * hardcoded entity-type union (§14.6). Returns `null` for every other kind, and
94
+ * for list/detail without an entity. `binding.filter` (the detail row id) is
95
+ * NOT part of the schema path — the schema is per *type*, not per row.
96
+ */
97
+ export declare function schemaPathForBinding(binding: Binding, app: string): string | null;
98
+ /**
99
+ * The date window the `/public/calendar` fetch requires (73e). The endpoint
100
+ * 422s without `start`/`end`, so the calendar block fetches a window computed
101
+ * from the injected "now": the current month through two months out (covering
102
+ * the default month view plus a buffer for next-month navigation). Pure given
103
+ * `now`, computed in UTC so it doesn't drift with the server timezone.
104
+ */
105
+ export declare function calendarWindow(now: Date): {
106
+ start: string;
107
+ end: string;
108
+ };
109
+ export declare function resolveData(block: Block, deps: ResolveDataDeps): Promise<ResolveDataResult>;