@kumikijs/runtime 0.3.0 → 0.5.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/dist/index.d.ts +82 -5
- package/dist/index.js +252 -49
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1,3 +1,44 @@
|
|
|
1
|
+
//#region src/element.d.ts
|
|
2
|
+
/** Maps one observed attribute to a slot, with an optional parser (default: raw string). */
|
|
3
|
+
type AttributeSlotBinding = {
|
|
4
|
+
slot: string;
|
|
5
|
+
parse?: (raw: string | null) => unknown;
|
|
6
|
+
};
|
|
7
|
+
type KumikiElementOptions = {
|
|
8
|
+
/** Host implementations for custom capabilities (the inbound seam), forwarded to mount. */providers?: Record<string, CapabilityProvider>;
|
|
9
|
+
/**
|
|
10
|
+
* Custom-capability names to surface as DOM CustomEvents on the element. For
|
|
11
|
+
* each, emitting the effect dispatches `CustomEvent(cap, { detail: input })`
|
|
12
|
+
* (bubbling, composed) and resolves ok — so a host can bind via
|
|
13
|
+
* `addEventListener(cap, …)` / framework `@cap`. A `providers[cap]` entry
|
|
14
|
+
* takes precedence over the passthrough for the same capability.
|
|
15
|
+
*/
|
|
16
|
+
events?: string[]; /** Observed attributes mapped to slots; updates flow in on connect and on change. */
|
|
17
|
+
attributeSlots?: Record<string, AttributeSlotBinding>;
|
|
18
|
+
/**
|
|
19
|
+
* Render into an open shadow root for full style isolation: the app's
|
|
20
|
+
* theme/motion/state `<style>` nodes are injected into the shadow root (not the
|
|
21
|
+
* document head), so host-page CSS does not bleed in and Kumiki's CSS does not
|
|
22
|
+
* leak out. Default: false (light DOM — the runtime's document-level styles
|
|
23
|
+
* apply, matching a standalone Kumiki page).
|
|
24
|
+
*/
|
|
25
|
+
shadow?: boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Routing source forwarded to mount (#36). An embedded element rarely owns the
|
|
28
|
+
* top-level URL, so a routed Kumiki app inside it should usually use
|
|
29
|
+
* `"memory"` (an in-memory path) rather than the default `"history"`, which
|
|
30
|
+
* reads/writes the host page's location/history.
|
|
31
|
+
*/
|
|
32
|
+
router?: "history" | "memory"; /** Initial path for the memory router (default `"/"`). */
|
|
33
|
+
initialPath?: string;
|
|
34
|
+
};
|
|
35
|
+
/**
|
|
36
|
+
* Register `app` as the custom element `tagName`. Idempotent: if the tag is
|
|
37
|
+
* already defined this is a no-op (so re-imports / HMR don't throw). Requires a
|
|
38
|
+
* DOM environment.
|
|
39
|
+
*/
|
|
40
|
+
declare function defineKumikiElement(tagName: string, app: AppShape | (() => AppShape), options?: KumikiElementOptions): void;
|
|
41
|
+
//#endregion
|
|
1
42
|
//#region src/scenario.d.ts
|
|
2
43
|
/** One thing to do to the app. Exactly one field should be set. */
|
|
3
44
|
type Action = {
|
|
@@ -56,6 +97,8 @@ type ScenarioReport = {
|
|
|
56
97
|
};
|
|
57
98
|
declare function runScenario(app: AppShape, root: HTMLElement, scenario: Scenario, opts?: {
|
|
58
99
|
settleMs?: number;
|
|
100
|
+
router?: "history" | "memory";
|
|
101
|
+
initialPath?: string;
|
|
59
102
|
}): Promise<ScenarioReport>;
|
|
60
103
|
//#endregion
|
|
61
104
|
//#region src/smoke.d.ts
|
|
@@ -87,7 +130,7 @@ declare function smoke(app: AppShape, root: HTMLElement, opts?: SmokeOptions): P
|
|
|
87
130
|
type RefinementCheck = (v: unknown) => boolean;
|
|
88
131
|
type EventHandler = (el: Record<string, unknown>) => void;
|
|
89
132
|
/**
|
|
90
|
-
* A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
|
|
133
|
+
* A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
|
|
91
134
|
* `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
|
|
92
135
|
* `Ok`). On the live path a panic is caught — the dispatch episode is rolled
|
|
93
136
|
* back (no partial slot writes) and an error boundary / top-level fallback is
|
|
@@ -264,8 +307,42 @@ type EffectResult = {
|
|
|
264
307
|
kind: "err";
|
|
265
308
|
value: unknown;
|
|
266
309
|
};
|
|
310
|
+
/**
|
|
311
|
+
* A host-supplied implementation for a custom capability (one registered via
|
|
312
|
+
* `kumiki.caps.json`). This is Kumiki's inbound ecosystem seam: arbitrary JS /
|
|
313
|
+
* npm libraries live here, behind a typed, mockable capability boundary, so the
|
|
314
|
+
* Kumiki core stays pure (no language-level FFI). `input` is the effect's
|
|
315
|
+
* (already `map-request`-mapped) request; the return may be sync or async.
|
|
316
|
+
*/
|
|
317
|
+
type CapabilityProvider = (input: unknown, caps: CapabilityRegistry) => Promise<EffectResult> | EffectResult;
|
|
267
318
|
type CapabilityRegistry = {
|
|
268
|
-
has(cap: string): boolean;
|
|
319
|
+
has(cap: string): boolean; /** The host provider registered for `cap` at mount, or undefined. */
|
|
320
|
+
provider(cap: string): CapabilityProvider | undefined;
|
|
321
|
+
};
|
|
322
|
+
/** Options accepted by `mount`. */
|
|
323
|
+
type MountOptions = {
|
|
324
|
+
/** Host implementations for custom capabilities, keyed by capability name. */providers?: Record<string, CapabilityProvider>;
|
|
325
|
+
/**
|
|
326
|
+
* Where Kumiki injects its `<style>` nodes (motion / theme / state styles).
|
|
327
|
+
* Defaults to `document` (styles go in `<head>`). Pass a `ShadowRoot` to keep
|
|
328
|
+
* them encapsulated — used by `defineKumikiElement({ shadow: true })`.
|
|
329
|
+
*/
|
|
330
|
+
styleRoot?: Document | ShadowRoot;
|
|
331
|
+
/**
|
|
332
|
+
* The element whose inline style carries theme background/foreground/font
|
|
333
|
+
* (the `<body>` equivalent). Defaults to `document.body`; the shadow element
|
|
334
|
+
* passes its in-shadow container so theming stays encapsulated.
|
|
335
|
+
*/
|
|
336
|
+
styleHost?: HTMLElement;
|
|
337
|
+
/**
|
|
338
|
+
* Routing source (#36). `"history"` (default) reads/writes the ambient
|
|
339
|
+
* document `location` / `history`. `"memory"` holds the current path in
|
|
340
|
+
* memory and never touches `history.*` — for embedded / sandboxed hosts (the
|
|
341
|
+
* docs playground `srcdoc`, a Web Component) where the Kumiki app does not own
|
|
342
|
+
* the top-level URL and `history.pushState` throws in an opaque origin.
|
|
343
|
+
*/
|
|
344
|
+
router?: "history" | "memory"; /** Initial path for the memory router (default `"/"`). Ignored in history mode. */
|
|
345
|
+
initialPath?: string;
|
|
269
346
|
};
|
|
270
347
|
type RouteEntry = {
|
|
271
348
|
pattern: string; /** Returns the TileNode for this route given the current state. */
|
|
@@ -301,7 +378,7 @@ type AppShape = {
|
|
|
301
378
|
live?: Record<string, unknown>;
|
|
302
379
|
_rerender?: () => void;
|
|
303
380
|
};
|
|
304
|
-
declare function mount(app: AppShape, target: HTMLElement): {
|
|
381
|
+
declare function mount(app: AppShape, target: HTMLElement, options?: MountOptions): {
|
|
305
382
|
dispose: () => void;
|
|
306
383
|
};
|
|
307
384
|
type TestResult = {
|
|
@@ -386,7 +463,7 @@ declare const _stdlib: {
|
|
|
386
463
|
now(): number;
|
|
387
464
|
recordCopy(rec: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
|
|
388
465
|
/**
|
|
389
|
-
* `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
|
|
466
|
+
* `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
|
|
390
467
|
* §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
|
|
391
468
|
* unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
|
|
392
469
|
* v0.3 this returned the value unchanged on the empty case, so `.get` and
|
|
@@ -440,4 +517,4 @@ declare const builtinEffects: {
|
|
|
440
517
|
httpFetch(method: string, input: unknown, baseUrl: string): Promise<EffectResult>;
|
|
441
518
|
};
|
|
442
519
|
//#endregion
|
|
443
|
-
export { type Action, AppShape, CapabilityRegistry, EffectResult, type EffectScript, EffectSpec, EmitSpec, EventHandler, type Expect, KumikiPanic, RedirectEntry, ReducerSpec, RefinementCheck, RouteEntry, type Scenario, type ScenarioReport, type ScenarioStep, SlotMeta, type SmokeIssue, type SmokeOptions, type SmokePhase, type SmokeReport, type StepResult, TestResult, Theme, ThemeValue, TileNode, TileProps, _stdlib, builtinEffects, mount, runScenario, smoke };
|
|
520
|
+
export { type Action, AppShape, type AttributeSlotBinding, CapabilityProvider, CapabilityRegistry, EffectResult, type EffectScript, EffectSpec, EmitSpec, EventHandler, type Expect, type KumikiElementOptions, KumikiPanic, MountOptions, RedirectEntry, ReducerSpec, RefinementCheck, RouteEntry, type Scenario, type ScenarioReport, type ScenarioStep, SlotMeta, type SmokeIssue, type SmokeOptions, type SmokePhase, type SmokeReport, type StepResult, TestResult, Theme, ThemeValue, TileNode, TileProps, _stdlib, builtinEffects, defineKumikiElement, mount, runScenario, smoke };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,87 @@
|
|
|
1
|
+
//#region src/element.ts
|
|
2
|
+
/**
|
|
3
|
+
* Register `app` as the custom element `tagName`. Idempotent: if the tag is
|
|
4
|
+
* already defined this is a no-op (so re-imports / HMR don't throw). Requires a
|
|
5
|
+
* DOM environment.
|
|
6
|
+
*/
|
|
7
|
+
function defineKumikiElement(tagName, app, options = {}) {
|
|
8
|
+
if (typeof customElements === "undefined") throw new Error("defineKumikiElement requires a DOM environment (customElements is undefined).");
|
|
9
|
+
if (customElements.get(tagName)) return;
|
|
10
|
+
const makeApp = typeof app === "function" ? app : () => app;
|
|
11
|
+
const attributeSlots = options.attributeSlots ?? {};
|
|
12
|
+
const observed = Object.keys(attributeSlots);
|
|
13
|
+
const buildProviders = (el) => {
|
|
14
|
+
const merged = {};
|
|
15
|
+
for (const cap of options.events ?? []) merged[cap] = (input) => {
|
|
16
|
+
el.dispatchEvent(new CustomEvent(cap, {
|
|
17
|
+
detail: input,
|
|
18
|
+
bubbles: true,
|
|
19
|
+
composed: true
|
|
20
|
+
}));
|
|
21
|
+
return {
|
|
22
|
+
kind: "ok",
|
|
23
|
+
value: null
|
|
24
|
+
};
|
|
25
|
+
};
|
|
26
|
+
return Object.assign(merged, options.providers ?? {});
|
|
27
|
+
};
|
|
28
|
+
class KumikiAppElement extends HTMLElement {
|
|
29
|
+
static get observedAttributes() {
|
|
30
|
+
return observed;
|
|
31
|
+
}
|
|
32
|
+
handle = null;
|
|
33
|
+
app = null;
|
|
34
|
+
connectedCallback() {
|
|
35
|
+
if (this.handle) return;
|
|
36
|
+
this.app = makeApp();
|
|
37
|
+
let target = this;
|
|
38
|
+
const mountOpts = { providers: buildProviders(this) };
|
|
39
|
+
if (options.router) mountOpts.router = options.router;
|
|
40
|
+
if (options.initialPath !== void 0) mountOpts.initialPath = options.initialPath;
|
|
41
|
+
if (options.shadow) {
|
|
42
|
+
const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
|
|
43
|
+
root.replaceChildren();
|
|
44
|
+
const container = document.createElement("div");
|
|
45
|
+
root.appendChild(container);
|
|
46
|
+
target = container;
|
|
47
|
+
mountOpts.styleRoot = root;
|
|
48
|
+
mountOpts.styleHost = container;
|
|
49
|
+
}
|
|
50
|
+
this.handle = mount(this.app, target, mountOpts);
|
|
51
|
+
for (const attr of observed) if (this.hasAttribute(attr)) this.applyAttr(attr, this.getAttribute(attr));
|
|
52
|
+
}
|
|
53
|
+
disconnectedCallback() {
|
|
54
|
+
this.handle?.dispose();
|
|
55
|
+
this.handle = null;
|
|
56
|
+
}
|
|
57
|
+
attributeChangedCallback(name, _old, value) {
|
|
58
|
+
if (this.handle) this.applyAttr(name, value);
|
|
59
|
+
}
|
|
60
|
+
applyAttr(name, raw) {
|
|
61
|
+
const binding = attributeSlots[name];
|
|
62
|
+
if (!binding) return;
|
|
63
|
+
this.setSlot(binding.slot, binding.parse ? binding.parse(raw) : raw);
|
|
64
|
+
}
|
|
65
|
+
/** Write a live slot (respects its refinement) and re-render. */
|
|
66
|
+
setSlot(name, value) {
|
|
67
|
+
this.app?._setSlot?.(name, value);
|
|
68
|
+
}
|
|
69
|
+
/** Write several live slots at once. */
|
|
70
|
+
setSlots(values) {
|
|
71
|
+
for (const [name, value] of Object.entries(values)) this.setSlot(name, value);
|
|
72
|
+
}
|
|
73
|
+
/** Read a single live slot value. */
|
|
74
|
+
getSlot(name) {
|
|
75
|
+
return this.app?.live?.[name];
|
|
76
|
+
}
|
|
77
|
+
/** A snapshot of the current live slot values. */
|
|
78
|
+
get slots() {
|
|
79
|
+
return { ...this.app?.live ?? {} };
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
customElements.define(tagName, KumikiAppElement);
|
|
83
|
+
}
|
|
84
|
+
//#endregion
|
|
1
85
|
//#region src/scenario.ts
|
|
2
86
|
const settle$1 = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
3
87
|
async function runScenario(app, root, scenario, opts = {}) {
|
|
@@ -45,9 +129,12 @@ async function runScenario(app, root, scenario, opts = {}) {
|
|
|
45
129
|
};
|
|
46
130
|
};
|
|
47
131
|
const dispatchable = app;
|
|
132
|
+
const mountOpts = {};
|
|
133
|
+
if (opts.router) mountOpts.router = opts.router;
|
|
134
|
+
if (opts.initialPath !== void 0) mountOpts.initialPath = opts.initialPath;
|
|
48
135
|
try {
|
|
49
136
|
try {
|
|
50
|
-
mount(app, root);
|
|
137
|
+
mount(app, root, mountOpts);
|
|
51
138
|
} catch (e) {
|
|
52
139
|
steps.push(mkStep(void 0, "mount", [`mount threw: ${errStr$1(e)}`], [], app, root, []));
|
|
53
140
|
return finish();
|
|
@@ -362,7 +449,7 @@ function errStr(e) {
|
|
|
362
449
|
//#endregion
|
|
363
450
|
//#region src/index.ts
|
|
364
451
|
/**
|
|
365
|
-
* A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
|
|
452
|
+
* A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
|
|
366
453
|
* `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
|
|
367
454
|
* `Ok`). On the live path a panic is caught — the dispatch episode is rolled
|
|
368
455
|
* back (no partial slot writes) and an error boundary / top-level fallback is
|
|
@@ -382,6 +469,69 @@ var KumikiPanic = class extends Error {
|
|
|
382
469
|
function isPanic(e) {
|
|
383
470
|
return e instanceof KumikiPanic || typeof e === "object" && e !== null && e.isKumikiPanic === true;
|
|
384
471
|
}
|
|
472
|
+
function historyRouter() {
|
|
473
|
+
return {
|
|
474
|
+
read: () => ({
|
|
475
|
+
pathname: location.pathname,
|
|
476
|
+
search: location.search,
|
|
477
|
+
hash: location.hash
|
|
478
|
+
}),
|
|
479
|
+
push: (p) => history.pushState(null, "", p),
|
|
480
|
+
replace: (p) => history.replaceState(null, "", p),
|
|
481
|
+
back: () => history.back(),
|
|
482
|
+
subscribe: (cb) => {
|
|
483
|
+
const h = () => cb();
|
|
484
|
+
window.addEventListener("popstate", h);
|
|
485
|
+
return () => window.removeEventListener("popstate", h);
|
|
486
|
+
}
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** Split a raw path into the `{ pathname, search, hash }` parseLocation reads. */
|
|
490
|
+
function splitPath(p) {
|
|
491
|
+
let rest = p || "/";
|
|
492
|
+
let hash = "";
|
|
493
|
+
const hi = rest.indexOf("#");
|
|
494
|
+
if (hi !== -1) {
|
|
495
|
+
hash = rest.slice(hi);
|
|
496
|
+
rest = rest.slice(0, hi);
|
|
497
|
+
}
|
|
498
|
+
let search = "";
|
|
499
|
+
const qi = rest.indexOf("?");
|
|
500
|
+
if (qi !== -1) {
|
|
501
|
+
search = rest.slice(qi);
|
|
502
|
+
rest = rest.slice(0, qi);
|
|
503
|
+
}
|
|
504
|
+
return {
|
|
505
|
+
pathname: rest || "/",
|
|
506
|
+
search,
|
|
507
|
+
hash
|
|
508
|
+
};
|
|
509
|
+
}
|
|
510
|
+
function memoryRouter(initialPath = "/") {
|
|
511
|
+
const stack = [initialPath || "/"];
|
|
512
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
513
|
+
return {
|
|
514
|
+
read: () => splitPath(stack[stack.length - 1] ?? "/"),
|
|
515
|
+
push: (p) => {
|
|
516
|
+
stack.push(p);
|
|
517
|
+
},
|
|
518
|
+
replace: (p) => {
|
|
519
|
+
stack[stack.length - 1] = p;
|
|
520
|
+
},
|
|
521
|
+
back: () => {
|
|
522
|
+
if (stack.length > 1) {
|
|
523
|
+
stack.pop();
|
|
524
|
+
for (const l of listeners) l();
|
|
525
|
+
}
|
|
526
|
+
},
|
|
527
|
+
subscribe: (cb) => {
|
|
528
|
+
listeners.add(cb);
|
|
529
|
+
return () => {
|
|
530
|
+
listeners.delete(cb);
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
};
|
|
534
|
+
}
|
|
385
535
|
function parseLocation(routes, loc) {
|
|
386
536
|
const path = loc.pathname || "/";
|
|
387
537
|
const query = {};
|
|
@@ -439,15 +589,20 @@ function emptyRoute() {
|
|
|
439
589
|
hash: null
|
|
440
590
|
};
|
|
441
591
|
}
|
|
442
|
-
function mount(app, target) {
|
|
592
|
+
function mount(app, target, options = {}) {
|
|
443
593
|
if (!app.live) {
|
|
444
594
|
app.live = {};
|
|
445
595
|
for (const [k, v] of Object.entries(app.slots)) app.live[k] = v.value;
|
|
446
596
|
}
|
|
447
597
|
if (!("route" in app.live)) app.live.route = emptyRoute();
|
|
598
|
+
currentStyleRoot = options.styleRoot ?? document;
|
|
599
|
+
currentStyleHost = options.styleHost ?? null;
|
|
600
|
+
stateStylesEl = null;
|
|
448
601
|
ensureMotionStyles(app);
|
|
449
602
|
const slotValues = app.live;
|
|
450
|
-
const
|
|
603
|
+
const router = options.router === "memory" ? memoryRouter(options.initialPath) : historyRouter();
|
|
604
|
+
let routerUnsub;
|
|
605
|
+
const dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps, options.providers), (effect, outcome, value, key) => {
|
|
451
606
|
handleEffectResult(effect, outcome, value, key);
|
|
452
607
|
});
|
|
453
608
|
let currentRoot = null;
|
|
@@ -504,7 +659,7 @@ function mount(app, target) {
|
|
|
504
659
|
}
|
|
505
660
|
let inPanicHandler = false;
|
|
506
661
|
/**
|
|
507
|
-
* Handle a caught live panic per spec/lifecycle.md §7.2: the dispatch episode
|
|
662
|
+
* Handle a caught live panic per docs/spec/lifecycle.md §7.2: the dispatch episode
|
|
508
663
|
* is already rolled back (the caller never applied the failed result), so we
|
|
509
664
|
* surface it (console.error → smoke/scenario see it) and fire the `app.error`
|
|
510
665
|
* reducer(s) with `$event = PanicInfo`, exactly as §7.2.3 specifies.
|
|
@@ -550,19 +705,24 @@ function mount(app, target) {
|
|
|
550
705
|
render();
|
|
551
706
|
}
|
|
552
707
|
function handleEffectResult(effect, outcome, value, key) {
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
708
|
+
let matched = 0;
|
|
709
|
+
for (const r of app.reducers) if (r.event.kind === "effect" && r.event.effect === effect && r.event.outcome === outcome) {
|
|
710
|
+
applyReducer(r, {
|
|
711
|
+
$1: value,
|
|
712
|
+
$2: key
|
|
713
|
+
});
|
|
714
|
+
matched++;
|
|
715
|
+
}
|
|
716
|
+
if (outcome === "err" && matched === 0) reportUnhandledEffectError(effect, value);
|
|
557
717
|
}
|
|
558
718
|
function updateRoute(newPath, replace) {
|
|
559
|
-
if (replace)
|
|
560
|
-
else
|
|
719
|
+
if (replace) router.replace(newPath);
|
|
720
|
+
else router.push(newPath);
|
|
561
721
|
syncRouteFromLocation();
|
|
562
722
|
}
|
|
563
723
|
function syncRouteFromLocation() {
|
|
564
724
|
const oldRoute = slotValues.route;
|
|
565
|
-
const newRoute = parseLocation(app.routes,
|
|
725
|
+
const newRoute = parseLocation(app.routes, router.read());
|
|
566
726
|
slotValues.route = newRoute;
|
|
567
727
|
if (oldRoute && oldRoute.pattern !== newRoute.pattern) {
|
|
568
728
|
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.leave(${JSON.stringify(oldRoute.pattern)})`) applyReducer(r, { $route: oldRoute });
|
|
@@ -570,17 +730,17 @@ function mount(app, target) {
|
|
|
570
730
|
for (const r of app.reducers) if (r.event.kind === "lifecycle" && r.event.name === `route.enter(${JSON.stringify(newRoute.pattern)})`) applyReducer(r, { $route: newRoute });
|
|
571
731
|
render();
|
|
572
732
|
}
|
|
573
|
-
registerBuiltinEffects(app, updateRoute, () => slotValues, () => render());
|
|
733
|
+
registerBuiltinEffects(app, updateRoute, () => router.back(), () => slotValues, () => render());
|
|
574
734
|
lastAppliedThemeName = null;
|
|
575
735
|
applyThemeDefaults(app);
|
|
576
736
|
lastAppliedThemeName = app.live?.[app.themeName ?? ""] ?? app.themeName ?? null;
|
|
577
737
|
if (app.routes && app.routes.length > 0) {
|
|
578
|
-
for (const r of app.routes) if ("redirectTo" in r && matchPattern(r.pattern,
|
|
579
|
-
|
|
738
|
+
for (const r of app.routes) if ("redirectTo" in r && matchPattern(r.pattern, router.read().pathname)) {
|
|
739
|
+
router.replace(r.redirectTo);
|
|
580
740
|
break;
|
|
581
741
|
}
|
|
582
|
-
slotValues.route = parseLocation(app.routes,
|
|
583
|
-
|
|
742
|
+
slotValues.route = parseLocation(app.routes, router.read());
|
|
743
|
+
routerUnsub = router.subscribe(syncRouteFromLocation);
|
|
584
744
|
}
|
|
585
745
|
app._rerender = render;
|
|
586
746
|
app._dispatch = (reducerName, el) => {
|
|
@@ -617,13 +777,17 @@ function mount(app, target) {
|
|
|
617
777
|
for (const h of anonTimers) clearInterval(h);
|
|
618
778
|
for (const h of namedTimers.values()) clearInterval(h);
|
|
619
779
|
namedTimers.clear();
|
|
780
|
+
routerUnsub?.();
|
|
620
781
|
target.replaceChildren();
|
|
621
782
|
dispatcher.dispose();
|
|
622
783
|
} };
|
|
623
784
|
}
|
|
624
|
-
function makeCapabilityRegistry(allowed) {
|
|
785
|
+
function makeCapabilityRegistry(allowed, providers) {
|
|
625
786
|
const ok = new Set(allowed);
|
|
626
|
-
return {
|
|
787
|
+
return {
|
|
788
|
+
has: (c) => ok.has(c),
|
|
789
|
+
provider: (c) => providers?.[c]
|
|
790
|
+
};
|
|
627
791
|
}
|
|
628
792
|
function makeEffectDispatcher(app, caps, onResult) {
|
|
629
793
|
const state = {
|
|
@@ -699,44 +863,51 @@ function makeEffectDispatcher(app, caps, onResult) {
|
|
|
699
863
|
}
|
|
700
864
|
};
|
|
701
865
|
}
|
|
702
|
-
function registerBuiltinEffects(app, navigate, getLive, rerender) {
|
|
866
|
+
function registerBuiltinEffects(app, navigate, back, getLive, rerender) {
|
|
867
|
+
const overridable = (cap, fn) => {
|
|
868
|
+
return async (input, caps) => {
|
|
869
|
+
const p = caps.provider(cap);
|
|
870
|
+
if (p) return p(input, caps);
|
|
871
|
+
return fn(input);
|
|
872
|
+
};
|
|
873
|
+
};
|
|
703
874
|
app.effects.navigate = {
|
|
704
875
|
name: "navigate",
|
|
705
876
|
cap: "nav.push",
|
|
706
|
-
invoke: async (input) => {
|
|
877
|
+
invoke: overridable("nav.push", async (input) => {
|
|
707
878
|
navigate(buildPath(input), false);
|
|
708
879
|
return {
|
|
709
880
|
kind: "ok",
|
|
710
881
|
value: null
|
|
711
882
|
};
|
|
712
|
-
}
|
|
883
|
+
})
|
|
713
884
|
};
|
|
714
885
|
app.effects["navigate-replace"] = {
|
|
715
886
|
name: "navigate-replace",
|
|
716
887
|
cap: "nav.replace",
|
|
717
|
-
invoke: async (input) => {
|
|
888
|
+
invoke: overridable("nav.replace", async (input) => {
|
|
718
889
|
navigate(buildPath(input), true);
|
|
719
890
|
return {
|
|
720
891
|
kind: "ok",
|
|
721
892
|
value: null
|
|
722
893
|
};
|
|
723
|
-
}
|
|
894
|
+
})
|
|
724
895
|
};
|
|
725
896
|
app.effects["navigate-back"] = {
|
|
726
897
|
name: "navigate-back",
|
|
727
898
|
cap: "nav.back",
|
|
728
|
-
invoke: async () => {
|
|
729
|
-
|
|
899
|
+
invoke: overridable("nav.back", async () => {
|
|
900
|
+
back();
|
|
730
901
|
return {
|
|
731
902
|
kind: "ok",
|
|
732
903
|
value: null
|
|
733
904
|
};
|
|
734
|
-
}
|
|
905
|
+
})
|
|
735
906
|
};
|
|
736
907
|
app.effects.toast = {
|
|
737
908
|
name: "toast",
|
|
738
909
|
cap: "notification.show",
|
|
739
|
-
invoke: async (input) => {
|
|
910
|
+
invoke: overridable("notification.show", async (input) => {
|
|
740
911
|
const t = input;
|
|
741
912
|
const banner = document.createElement("div");
|
|
742
913
|
banner.style.cssText = "position:fixed;bottom:24px;right:24px;padding:8px 16px;background:#1a1a1a;color:#fff;border-radius:8px;z-index:9999;";
|
|
@@ -747,18 +918,18 @@ function registerBuiltinEffects(app, navigate, getLive, rerender) {
|
|
|
747
918
|
kind: "ok",
|
|
748
919
|
value: null
|
|
749
920
|
};
|
|
750
|
-
}
|
|
921
|
+
})
|
|
751
922
|
};
|
|
752
923
|
app.effects.log = {
|
|
753
924
|
name: "log",
|
|
754
925
|
cap: "log.write",
|
|
755
|
-
invoke: async (input) => {
|
|
926
|
+
invoke: overridable("log.write", async (input) => {
|
|
756
927
|
console.log("[kumiki]", input);
|
|
757
928
|
return {
|
|
758
929
|
kind: "ok",
|
|
759
930
|
value: null
|
|
760
931
|
};
|
|
761
|
-
}
|
|
932
|
+
})
|
|
762
933
|
};
|
|
763
934
|
}
|
|
764
935
|
function buildPath(x) {
|
|
@@ -827,6 +998,19 @@ function reportPanic(where, e) {
|
|
|
827
998
|
const { message } = panicInfo(e);
|
|
828
999
|
console.error(`[kumiki] ${isPanic(e) ? "panic" : "error"} in ${where}: ${message}`);
|
|
829
1000
|
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Surface an effect `err` result that no `.err` reducer consumes (#37). A failed
|
|
1003
|
+
* capability must never fail silently — the storage-unavailable case (sandbox /
|
|
1004
|
+
* private mode) otherwise looks like the app does nothing. Reported via
|
|
1005
|
+
* console.error so the verification tiers (smoke / runScenario, which patch
|
|
1006
|
+
* console.error) flag it, consistent with the v0.3 panic model. Production noise
|
|
1007
|
+
* is the app's own choice: wire an `.err` reducer to handle (or deliberately
|
|
1008
|
+
* ignore) the error.
|
|
1009
|
+
*/
|
|
1010
|
+
function reportUnhandledEffectError(effect, value) {
|
|
1011
|
+
const message = value && typeof value === "object" && "message" in value ? String(value.message) : String(value);
|
|
1012
|
+
console.error(`[kumiki] effect "${effect}" returned an error with no .err reducer: ${message}`);
|
|
1013
|
+
}
|
|
830
1014
|
/** A minimal top-level fallback for a render panic with no enclosing boundary. */
|
|
831
1015
|
function renderPanicFallback(e) {
|
|
832
1016
|
const { message, location } = panicInfo(e);
|
|
@@ -1215,10 +1399,8 @@ function applyResponsive(_el, raw, set) {
|
|
|
1215
1399
|
return;
|
|
1216
1400
|
}
|
|
1217
1401
|
}
|
|
1218
|
-
let animationStylesInjected = false;
|
|
1219
1402
|
function ensureAnimationStyles() {
|
|
1220
|
-
if (
|
|
1221
|
-
animationStylesInjected = true;
|
|
1403
|
+
if (findStyleNode("kumiki-animations")) return;
|
|
1222
1404
|
const css = `
|
|
1223
1405
|
@keyframes kumiki-fade { from { opacity: 0 } to { opacity: 1 } }
|
|
1224
1406
|
@keyframes kumiki-slide-up { from { transform: translateY(8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
|
|
@@ -1234,7 +1416,7 @@ function ensureAnimationStyles() {
|
|
|
1234
1416
|
const style = document.createElement("style");
|
|
1235
1417
|
style.id = "kumiki-animations";
|
|
1236
1418
|
style.appendChild(document.createTextNode(css));
|
|
1237
|
-
|
|
1419
|
+
appendStyleNode(style);
|
|
1238
1420
|
}
|
|
1239
1421
|
function applyTransition(el, props) {
|
|
1240
1422
|
if (!props) return;
|
|
@@ -1278,15 +1460,32 @@ function motionCss(name, spec) {
|
|
|
1278
1460
|
return [`@keyframes ${cls} { from { ${from} } to { ${to} } }`, `.${cls} { animation-name: ${cls}; animation-duration: ${motionDuration(s.duration)}; animation-timing-function: ${easing}; animation-iteration-count: ${iteration}; animation-direction: ${direction}; animation-fill-mode: both; }`].join("\n");
|
|
1279
1461
|
}
|
|
1280
1462
|
/** Inject the app's motion keyframes + a `prefers-reduced-motion` guard at mount. */
|
|
1463
|
+
let currentStyleRoot = null;
|
|
1464
|
+
let currentStyleHost = null;
|
|
1465
|
+
/** Find a Kumiki style node by id within the active style root. */
|
|
1466
|
+
function findStyleNode(id) {
|
|
1467
|
+
return (currentStyleRoot ?? document).getElementById(id);
|
|
1468
|
+
}
|
|
1469
|
+
/** Append a style node to the active style root (document head, or a shadow root). */
|
|
1470
|
+
function appendStyleNode(style) {
|
|
1471
|
+
const root = currentStyleRoot ?? document;
|
|
1472
|
+
const head = root.head;
|
|
1473
|
+
if (head) head.appendChild(style);
|
|
1474
|
+
else root.appendChild(style);
|
|
1475
|
+
}
|
|
1476
|
+
/** The element that carries body-level theme styles (background/fg/font). */
|
|
1477
|
+
function styleHostEl() {
|
|
1478
|
+
return currentStyleHost ?? document.body;
|
|
1479
|
+
}
|
|
1281
1480
|
function ensureMotionStyles(app) {
|
|
1282
1481
|
const motions = app.motions ?? {};
|
|
1283
1482
|
const rules = Object.entries(motions).map(([name, spec]) => motionCss(name, spec));
|
|
1284
1483
|
rules.push(`@media (prefers-reduced-motion: reduce) { .kumiki-motion, .kumiki-anim { animation: none !important } }`);
|
|
1285
|
-
let style =
|
|
1484
|
+
let style = findStyleNode("kumiki-motions");
|
|
1286
1485
|
if (!style) {
|
|
1287
1486
|
style = document.createElement("style");
|
|
1288
1487
|
style.id = "kumiki-motions";
|
|
1289
|
-
|
|
1488
|
+
appendStyleNode(style);
|
|
1290
1489
|
}
|
|
1291
1490
|
style.textContent = rules.join("\n");
|
|
1292
1491
|
}
|
|
@@ -1313,9 +1512,12 @@ function applyStateStyles(el, props) {
|
|
|
1313
1512
|
el.dataset.kumikiState = el.dataset.kumikiState ? `${el.dataset.kumikiState} ${id}` : id;
|
|
1314
1513
|
const decls = stateStyleDecls(sub);
|
|
1315
1514
|
if (!stateStylesEl) {
|
|
1316
|
-
stateStylesEl =
|
|
1317
|
-
stateStylesEl
|
|
1318
|
-
|
|
1515
|
+
stateStylesEl = findStyleNode("kumiki-state-styles");
|
|
1516
|
+
if (!stateStylesEl) {
|
|
1517
|
+
stateStylesEl = document.createElement("style");
|
|
1518
|
+
stateStylesEl.id = "kumiki-state-styles";
|
|
1519
|
+
appendStyleNode(stateStylesEl);
|
|
1520
|
+
}
|
|
1319
1521
|
}
|
|
1320
1522
|
const selector = state === "hover" ? ":hover" : state === "focus" ? ":focus" : state === "active" ? ":active" : state === "disabled" ? ":disabled" : "[data-kumiki-selected]";
|
|
1321
1523
|
stateStylesEl.appendChild(document.createTextNode(`[data-kumiki-state~="${id}"]${selector} { ${decls} }\n`));
|
|
@@ -1351,12 +1553,13 @@ function applyThemeDefaults(app) {
|
|
|
1351
1553
|
const colors = theme.colors ?? {};
|
|
1352
1554
|
const typography = theme.typography ?? {};
|
|
1353
1555
|
const sizes = typography.size ?? {};
|
|
1354
|
-
|
|
1355
|
-
if (typeof colors.
|
|
1356
|
-
if (typeof
|
|
1357
|
-
if (typeof
|
|
1358
|
-
if (typeof
|
|
1359
|
-
|
|
1556
|
+
const host = styleHostEl();
|
|
1557
|
+
if (typeof colors.bg === "string") host.style.background = colors.bg;
|
|
1558
|
+
if (typeof colors.fg === "string") host.style.color = colors.fg;
|
|
1559
|
+
if (typeof typography.family === "string") host.style.fontFamily = typography.family;
|
|
1560
|
+
if (typeof sizes.md === "string") host.style.fontSize = sizes.md;
|
|
1561
|
+
if (typeof typography["line-height"] === "string") host.style.lineHeight = String(typography["line-height"]);
|
|
1562
|
+
const prior = findStyleNode("kumiki-theme-base");
|
|
1360
1563
|
if (prior) prior.remove();
|
|
1361
1564
|
const css = document.createElement("style");
|
|
1362
1565
|
css.id = "kumiki-theme-base";
|
|
@@ -1399,7 +1602,7 @@ function applyThemeDefaults(app) {
|
|
|
1399
1602
|
}
|
|
1400
1603
|
[data-kumiki-tile="markdown"] p { margin: 0 0 12px; }
|
|
1401
1604
|
`));
|
|
1402
|
-
|
|
1605
|
+
appendStyleNode(css);
|
|
1403
1606
|
}
|
|
1404
1607
|
function themeShadow(theme, key) {
|
|
1405
1608
|
const shadow = theme.shadow;
|
|
@@ -1816,7 +2019,7 @@ const _stdlib = {
|
|
|
1816
2019
|
};
|
|
1817
2020
|
},
|
|
1818
2021
|
/**
|
|
1819
|
-
* `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
|
|
2022
|
+
* `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
|
|
1820
2023
|
* §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
|
|
1821
2024
|
* unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
|
|
1822
2025
|
* v0.3 this returned the value unchanged on the empty case, so `.get` and
|
|
@@ -2094,4 +2297,4 @@ const builtinEffects = {
|
|
|
2094
2297
|
}
|
|
2095
2298
|
};
|
|
2096
2299
|
//#endregion
|
|
2097
|
-
export { KumikiPanic, _stdlib, builtinEffects, mount, runScenario, smoke };
|
|
2300
|
+
export { KumikiPanic, _stdlib, builtinEffects, defineKumikiElement, mount, runScenario, smoke };
|
package/package.json
CHANGED