@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 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 dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps), (effect, outcome, value, key) => {
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
- for (const r of app.reducers) if (r.event.kind === "effect" && r.event.effect === effect && r.event.outcome === outcome) applyReducer(r, {
554
- $1: value,
555
- $2: key
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) history.replaceState(null, "", newPath);
560
- else history.pushState(null, "", newPath);
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, location);
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, location.pathname)) {
579
- history.replaceState(null, "", r.redirectTo);
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, location);
583
- window.addEventListener("popstate", () => syncRouteFromLocation());
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 { has: (c) => ok.has(c) };
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
- history.back();
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 (animationStylesInjected) return;
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
- document.head.appendChild(style);
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 = document.getElementById("kumiki-motions");
1484
+ let style = findStyleNode("kumiki-motions");
1286
1485
  if (!style) {
1287
1486
  style = document.createElement("style");
1288
1487
  style.id = "kumiki-motions";
1289
- document.head.appendChild(style);
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 = document.createElement("style");
1317
- stateStylesEl.id = "kumiki-state-styles";
1318
- document.head.appendChild(stateStylesEl);
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
- if (typeof colors.bg === "string") document.body.style.background = colors.bg;
1355
- if (typeof colors.fg === "string") document.body.style.color = colors.fg;
1356
- if (typeof typography.family === "string") document.body.style.fontFamily = typography.family;
1357
- if (typeof sizes.md === "string") document.body.style.fontSize = sizes.md;
1358
- if (typeof typography["line-height"] === "string") document.body.style.lineHeight = String(typography["line-height"]);
1359
- const prior = document.getElementById("kumiki-theme-base");
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
- document.head.appendChild(css);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kumikijs/runtime",
3
- "version": "0.3.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "description": "Kumiki DOM runtime — mounts compiled Kumiki apps, dispatches effects, manages the signal graph.",
6
6
  "license": "Apache-2.0",