@kumikijs/runtime 0.2.1 → 0.4.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,36 @@
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
+ /**
28
+ * Register `app` as the custom element `tagName`. Idempotent: if the tag is
29
+ * already defined this is a no-op (so re-imports / HMR don't throw). Requires a
30
+ * DOM environment.
31
+ */
32
+ declare function defineKumikiElement(tagName: string, app: AppShape | (() => AppShape), options?: KumikiElementOptions): void;
33
+ //#endregion
1
34
  //#region src/scenario.d.ts
2
35
  /** One thing to do to the app. Exactly one field should be set. */
3
36
  type Action = {
@@ -86,6 +119,19 @@ declare function smoke(app: AppShape, root: HTMLElement, opts?: SmokeOptions): P
86
119
  //#region src/index.d.ts
87
120
  type RefinementCheck = (v: unknown) => boolean;
88
121
  type EventHandler = (el: Record<string, unknown>) => void;
122
+ /**
123
+ * A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
124
+ * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
125
+ * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
126
+ * back (no partial slot writes) and an error boundary / top-level fallback is
127
+ * shown — instead of escaping the DOM event handler / render uncaught. The
128
+ * reducer-test harness already catches it to power `expect = {panic: ...}`.
129
+ */
130
+ declare class KumikiPanic extends Error {
131
+ readonly isKumikiPanic: true;
132
+ readonly location: string | undefined;
133
+ constructor(message: string, location?: string);
134
+ }
89
135
  type TileNode = {
90
136
  kind: "page" | "column" | "row" | "card" | "box";
91
137
  children: TileNode[];
@@ -251,8 +297,33 @@ type EffectResult = {
251
297
  kind: "err";
252
298
  value: unknown;
253
299
  };
300
+ /**
301
+ * A host-supplied implementation for a custom capability (one registered via
302
+ * `kumiki.caps.json`). This is Kumiki's inbound ecosystem seam: arbitrary JS /
303
+ * npm libraries live here, behind a typed, mockable capability boundary, so the
304
+ * Kumiki core stays pure (no language-level FFI). `input` is the effect's
305
+ * (already `map-request`-mapped) request; the return may be sync or async.
306
+ */
307
+ type CapabilityProvider = (input: unknown, caps: CapabilityRegistry) => Promise<EffectResult> | EffectResult;
254
308
  type CapabilityRegistry = {
255
- has(cap: string): boolean;
309
+ has(cap: string): boolean; /** The host provider registered for `cap` at mount, or undefined. */
310
+ provider(cap: string): CapabilityProvider | undefined;
311
+ };
312
+ /** Options accepted by `mount`. */
313
+ type MountOptions = {
314
+ /** Host implementations for custom capabilities, keyed by capability name. */providers?: Record<string, CapabilityProvider>;
315
+ /**
316
+ * Where Kumiki injects its `<style>` nodes (motion / theme / state styles).
317
+ * Defaults to `document` (styles go in `<head>`). Pass a `ShadowRoot` to keep
318
+ * them encapsulated — used by `defineKumikiElement({ shadow: true })`.
319
+ */
320
+ styleRoot?: Document | ShadowRoot;
321
+ /**
322
+ * The element whose inline style carries theme background/foreground/font
323
+ * (the `<body>` equivalent). Defaults to `document.body`; the shadow element
324
+ * passes its in-shadow container so theming stays encapsulated.
325
+ */
326
+ styleHost?: HTMLElement;
256
327
  };
257
328
  type RouteEntry = {
258
329
  pattern: string; /** Returns the TileNode for this route given the current state. */
@@ -288,7 +359,7 @@ type AppShape = {
288
359
  live?: Record<string, unknown>;
289
360
  _rerender?: () => void;
290
361
  };
291
- declare function mount(app: AppShape, target: HTMLElement): {
362
+ declare function mount(app: AppShape, target: HTMLElement, options?: MountOptions): {
292
363
  dispose: () => void;
293
364
  };
294
365
  type TestResult = {
@@ -372,7 +443,15 @@ declare const _stdlib: {
372
443
  freshId(): string;
373
444
  now(): number;
374
445
  recordCopy(rec: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
375
- unwrap(opt: unknown): unknown;
446
+ /**
447
+ * `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
448
+ * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
449
+ * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
450
+ * v0.3 this returned the value unchanged on the empty case, so `.get` and
451
+ * `.get-err` behaved oppositely — #24.)
452
+ */
453
+ unwrap(opt: unknown): unknown; /** `panic(message)` — raise Kumiki's controlled stop-the-program signal. */
454
+ panic(message: unknown): never;
376
455
  optionGetOr(opt: unknown, def: unknown): unknown;
377
456
  Some(v: unknown): {
378
457
  _tag: "Some";
@@ -407,7 +486,7 @@ declare const _stdlib: {
407
486
  listHead(xs: unknown[] | undefined | null): unknown; /** List(T).tail → List(T) (all but the first; empty list stays empty). */
408
487
  listTail(xs: unknown[] | undefined | null): unknown[]; /** List(T).last → Option(T). */
409
488
  listLast(xs: unknown[] | undefined | null): unknown; /** Set(T).to-list / Option(T).to-list → List(T). */
410
- toList(v: unknown): unknown[]; /** Result(T,E).get-err → E; panics if the value is Ok. */
489
+ toList(v: unknown): unknown[]; /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
411
490
  getErr(r: unknown): unknown; /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
412
491
  toOption(r: unknown): unknown; /** Text.parse-int → Option(Int) (truncates; mirrors `Int.parse`). */
413
492
  parseIntOpt(s: unknown): unknown; /** Text.parse-float → Option(Float) (mirrors `Float.parse`). */
@@ -419,4 +498,4 @@ declare const builtinEffects: {
419
498
  httpFetch(method: string, input: unknown, baseUrl: string): Promise<EffectResult>;
420
499
  };
421
500
  //#endregion
422
- export { type Action, AppShape, CapabilityRegistry, EffectResult, type EffectScript, EffectSpec, EmitSpec, EventHandler, type Expect, 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 };
501
+ 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,85 @@
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.shadow) {
40
+ const root = this.shadowRoot ?? this.attachShadow({ mode: "open" });
41
+ root.replaceChildren();
42
+ const container = document.createElement("div");
43
+ root.appendChild(container);
44
+ target = container;
45
+ mountOpts.styleRoot = root;
46
+ mountOpts.styleHost = container;
47
+ }
48
+ this.handle = mount(this.app, target, mountOpts);
49
+ for (const attr of observed) if (this.hasAttribute(attr)) this.applyAttr(attr, this.getAttribute(attr));
50
+ }
51
+ disconnectedCallback() {
52
+ this.handle?.dispose();
53
+ this.handle = null;
54
+ }
55
+ attributeChangedCallback(name, _old, value) {
56
+ if (this.handle) this.applyAttr(name, value);
57
+ }
58
+ applyAttr(name, raw) {
59
+ const binding = attributeSlots[name];
60
+ if (!binding) return;
61
+ this.setSlot(binding.slot, binding.parse ? binding.parse(raw) : raw);
62
+ }
63
+ /** Write a live slot (respects its refinement) and re-render. */
64
+ setSlot(name, value) {
65
+ this.app?._setSlot?.(name, value);
66
+ }
67
+ /** Write several live slots at once. */
68
+ setSlots(values) {
69
+ for (const [name, value] of Object.entries(values)) this.setSlot(name, value);
70
+ }
71
+ /** Read a single live slot value. */
72
+ getSlot(name) {
73
+ return this.app?.live?.[name];
74
+ }
75
+ /** A snapshot of the current live slot values. */
76
+ get slots() {
77
+ return { ...this.app?.live ?? {} };
78
+ }
79
+ }
80
+ customElements.define(tagName, KumikiAppElement);
81
+ }
82
+ //#endregion
1
83
  //#region src/scenario.ts
2
84
  const settle$1 = (ms) => new Promise((r) => setTimeout(r, ms));
3
85
  async function runScenario(app, root, scenario, opts = {}) {
@@ -361,6 +443,27 @@ function errStr(e) {
361
443
  }
362
444
  //#endregion
363
445
  //#region src/index.ts
446
+ /**
447
+ * A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
448
+ * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
449
+ * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
450
+ * back (no partial slot writes) and an error boundary / top-level fallback is
451
+ * shown — instead of escaping the DOM event handler / render uncaught. The
452
+ * reducer-test harness already catches it to power `expect = {panic: ...}`.
453
+ */
454
+ var KumikiPanic = class extends Error {
455
+ isKumikiPanic = true;
456
+ location;
457
+ constructor(message, location) {
458
+ super(message);
459
+ this.name = "KumikiPanic";
460
+ this.location = location;
461
+ }
462
+ };
463
+ /** True for a KumikiPanic — also matches across realms where `instanceof` fails. */
464
+ function isPanic(e) {
465
+ return e instanceof KumikiPanic || typeof e === "object" && e !== null && e.isKumikiPanic === true;
466
+ }
364
467
  function parseLocation(routes, loc) {
365
468
  const path = loc.pathname || "/";
366
469
  const query = {};
@@ -418,15 +521,18 @@ function emptyRoute() {
418
521
  hash: null
419
522
  };
420
523
  }
421
- function mount(app, target) {
524
+ function mount(app, target, options = {}) {
422
525
  if (!app.live) {
423
526
  app.live = {};
424
527
  for (const [k, v] of Object.entries(app.slots)) app.live[k] = v.value;
425
528
  }
426
529
  if (!("route" in app.live)) app.live.route = emptyRoute();
530
+ currentStyleRoot = options.styleRoot ?? document;
531
+ currentStyleHost = options.styleHost ?? null;
532
+ stateStylesEl = null;
427
533
  ensureMotionStyles(app);
428
534
  const slotValues = app.live;
429
- const dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps), (effect, outcome, value, key) => {
535
+ const dispatcher = makeEffectDispatcher(app, makeCapabilityRegistry(app.caps, options.providers), (effect, outcome, value, key) => {
430
536
  handleEffectResult(effect, outcome, value, key);
431
537
  });
432
538
  let currentRoot = null;
@@ -448,7 +554,13 @@ function mount(app, target) {
448
554
  };
449
555
  }
450
556
  maybeReapplyTheme(app);
451
- const dom = renderTile(pickRootTile(app));
557
+ let dom;
558
+ try {
559
+ dom = renderTile(pickRootTile(app));
560
+ } catch (e) {
561
+ reportPanic("render", e);
562
+ dom = renderPanicFallback(e);
563
+ }
452
564
  if (currentRoot) target.replaceChild(dom, currentRoot);
453
565
  else target.appendChild(dom);
454
566
  currentRoot = dom;
@@ -475,9 +587,38 @@ function mount(app, target) {
475
587
  text: "(no root)"
476
588
  };
477
589
  }
590
+ let inPanicHandler = false;
591
+ /**
592
+ * Handle a caught live panic per docs/spec/lifecycle.md §7.2: the dispatch episode
593
+ * is already rolled back (the caller never applied the failed result), so we
594
+ * surface it (console.error → smoke/scenario see it) and fire the `app.error`
595
+ * reducer(s) with `$event = PanicInfo`, exactly as §7.2.3 specifies.
596
+ */
597
+ function handleLivePanic(location, e) {
598
+ reportPanic(location, e);
599
+ if (inPanicHandler) return;
600
+ const handlers = app.reducers.filter((h) => h.event.kind === "lifecycle" && h.event.name === "app.error");
601
+ if (handlers.length === 0) return;
602
+ const info = {
603
+ message: panicInfo(e).message,
604
+ location
605
+ };
606
+ inPanicHandler = true;
607
+ try {
608
+ for (const h of handlers) applyReducer(h, { $event: info });
609
+ } finally {
610
+ inPanicHandler = false;
611
+ }
612
+ }
478
613
  function applyReducer(r, payload) {
479
614
  if (disposed) return;
480
- const result = r.apply(slotValues, payload);
615
+ let result;
616
+ try {
617
+ result = r.apply(slotValues, payload);
618
+ } catch (e) {
619
+ handleLivePanic(`reducer "${r.name}"`, e);
620
+ return;
621
+ }
481
622
  for (const [k, v] of Object.entries(result.slots)) {
482
623
  const meta = app.slots[k];
483
624
  if (meta?.refine && !meta.refine(v)) continue;
@@ -565,9 +706,12 @@ function mount(app, target) {
565
706
  dispatcher.dispose();
566
707
  } };
567
708
  }
568
- function makeCapabilityRegistry(allowed) {
709
+ function makeCapabilityRegistry(allowed, providers) {
569
710
  const ok = new Set(allowed);
570
- return { has: (c) => ok.has(c) };
711
+ return {
712
+ has: (c) => ok.has(c),
713
+ provider: (c) => providers?.[c]
714
+ };
571
715
  }
572
716
  function makeEffectDispatcher(app, caps, onResult) {
573
717
  const state = {
@@ -644,43 +788,50 @@ function makeEffectDispatcher(app, caps, onResult) {
644
788
  };
645
789
  }
646
790
  function registerBuiltinEffects(app, navigate, getLive, rerender) {
791
+ const overridable = (cap, fn) => {
792
+ return async (input, caps) => {
793
+ const p = caps.provider(cap);
794
+ if (p) return p(input, caps);
795
+ return fn(input);
796
+ };
797
+ };
647
798
  app.effects.navigate = {
648
799
  name: "navigate",
649
800
  cap: "nav.push",
650
- invoke: async (input) => {
801
+ invoke: overridable("nav.push", async (input) => {
651
802
  navigate(buildPath(input), false);
652
803
  return {
653
804
  kind: "ok",
654
805
  value: null
655
806
  };
656
- }
807
+ })
657
808
  };
658
809
  app.effects["navigate-replace"] = {
659
810
  name: "navigate-replace",
660
811
  cap: "nav.replace",
661
- invoke: async (input) => {
812
+ invoke: overridable("nav.replace", async (input) => {
662
813
  navigate(buildPath(input), true);
663
814
  return {
664
815
  kind: "ok",
665
816
  value: null
666
817
  };
667
- }
818
+ })
668
819
  };
669
820
  app.effects["navigate-back"] = {
670
821
  name: "navigate-back",
671
822
  cap: "nav.back",
672
- invoke: async () => {
823
+ invoke: overridable("nav.back", async () => {
673
824
  history.back();
674
825
  return {
675
826
  kind: "ok",
676
827
  value: null
677
828
  };
678
- }
829
+ })
679
830
  };
680
831
  app.effects.toast = {
681
832
  name: "toast",
682
833
  cap: "notification.show",
683
- invoke: async (input) => {
834
+ invoke: overridable("notification.show", async (input) => {
684
835
  const t = input;
685
836
  const banner = document.createElement("div");
686
837
  banner.style.cssText = "position:fixed;bottom:24px;right:24px;padding:8px 16px;background:#1a1a1a;color:#fff;border-radius:8px;z-index:9999;";
@@ -691,18 +842,18 @@ function registerBuiltinEffects(app, navigate, getLive, rerender) {
691
842
  kind: "ok",
692
843
  value: null
693
844
  };
694
- }
845
+ })
695
846
  };
696
847
  app.effects.log = {
697
848
  name: "log",
698
849
  cap: "log.write",
699
- invoke: async (input) => {
850
+ invoke: overridable("log.write", async (input) => {
700
851
  console.log("[kumiki]", input);
701
852
  return {
702
853
  kind: "ok",
703
854
  value: null
704
855
  };
705
- }
856
+ })
706
857
  };
707
858
  }
708
859
  function buildPath(x) {
@@ -747,6 +898,39 @@ function _setPathHelper(obj, path, value) {
747
898
  [head]: _setPathHelper(cur[head], rest, value)
748
899
  };
749
900
  }
901
+ /** Message + optional source location for a caught throw (panic or otherwise). */
902
+ function panicInfo(e) {
903
+ if (isPanic(e)) return {
904
+ message: e.message,
905
+ location: e.location
906
+ };
907
+ if (e instanceof Error) return {
908
+ message: e.message,
909
+ location: void 0
910
+ };
911
+ return {
912
+ message: String(e),
913
+ location: void 0
914
+ };
915
+ }
916
+ /**
917
+ * Surface a caught live panic so the verification tiers still see it: smoke()
918
+ * and runScenario() both patch console.error into their issue/error buffers, so
919
+ * a controlled panic is reported as a failure rather than silently swallowed.
920
+ */
921
+ function reportPanic(where, e) {
922
+ const { message } = panicInfo(e);
923
+ console.error(`[kumiki] ${isPanic(e) ? "panic" : "error"} in ${where}: ${message}`);
924
+ }
925
+ /** A minimal top-level fallback for a render panic with no enclosing boundary. */
926
+ function renderPanicFallback(e) {
927
+ const { message, location } = panicInfo(e);
928
+ const div = document.createElement("div");
929
+ div.dataset.kumikiPanic = location ?? "";
930
+ div.setAttribute("role", "alert");
931
+ div.textContent = `Something went wrong: ${message}`;
932
+ return div;
933
+ }
750
934
  function renderTile(node) {
751
935
  const el = renderTileNode(node);
752
936
  applyMotion(el, node.props);
@@ -1126,10 +1310,8 @@ function applyResponsive(_el, raw, set) {
1126
1310
  return;
1127
1311
  }
1128
1312
  }
1129
- let animationStylesInjected = false;
1130
1313
  function ensureAnimationStyles() {
1131
- if (animationStylesInjected) return;
1132
- animationStylesInjected = true;
1314
+ if (findStyleNode("kumiki-animations")) return;
1133
1315
  const css = `
1134
1316
  @keyframes kumiki-fade { from { opacity: 0 } to { opacity: 1 } }
1135
1317
  @keyframes kumiki-slide-up { from { transform: translateY(8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
@@ -1145,7 +1327,7 @@ function ensureAnimationStyles() {
1145
1327
  const style = document.createElement("style");
1146
1328
  style.id = "kumiki-animations";
1147
1329
  style.appendChild(document.createTextNode(css));
1148
- document.head.appendChild(style);
1330
+ appendStyleNode(style);
1149
1331
  }
1150
1332
  function applyTransition(el, props) {
1151
1333
  if (!props) return;
@@ -1189,15 +1371,32 @@ function motionCss(name, spec) {
1189
1371
  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");
1190
1372
  }
1191
1373
  /** Inject the app's motion keyframes + a `prefers-reduced-motion` guard at mount. */
1374
+ let currentStyleRoot = null;
1375
+ let currentStyleHost = null;
1376
+ /** Find a Kumiki style node by id within the active style root. */
1377
+ function findStyleNode(id) {
1378
+ return (currentStyleRoot ?? document).getElementById(id);
1379
+ }
1380
+ /** Append a style node to the active style root (document head, or a shadow root). */
1381
+ function appendStyleNode(style) {
1382
+ const root = currentStyleRoot ?? document;
1383
+ const head = root.head;
1384
+ if (head) head.appendChild(style);
1385
+ else root.appendChild(style);
1386
+ }
1387
+ /** The element that carries body-level theme styles (background/fg/font). */
1388
+ function styleHostEl() {
1389
+ return currentStyleHost ?? document.body;
1390
+ }
1192
1391
  function ensureMotionStyles(app) {
1193
1392
  const motions = app.motions ?? {};
1194
1393
  const rules = Object.entries(motions).map(([name, spec]) => motionCss(name, spec));
1195
1394
  rules.push(`@media (prefers-reduced-motion: reduce) { .kumiki-motion, .kumiki-anim { animation: none !important } }`);
1196
- let style = document.getElementById("kumiki-motions");
1395
+ let style = findStyleNode("kumiki-motions");
1197
1396
  if (!style) {
1198
1397
  style = document.createElement("style");
1199
1398
  style.id = "kumiki-motions";
1200
- document.head.appendChild(style);
1399
+ appendStyleNode(style);
1201
1400
  }
1202
1401
  style.textContent = rules.join("\n");
1203
1402
  }
@@ -1224,9 +1423,12 @@ function applyStateStyles(el, props) {
1224
1423
  el.dataset.kumikiState = el.dataset.kumikiState ? `${el.dataset.kumikiState} ${id}` : id;
1225
1424
  const decls = stateStyleDecls(sub);
1226
1425
  if (!stateStylesEl) {
1227
- stateStylesEl = document.createElement("style");
1228
- stateStylesEl.id = "kumiki-state-styles";
1229
- document.head.appendChild(stateStylesEl);
1426
+ stateStylesEl = findStyleNode("kumiki-state-styles");
1427
+ if (!stateStylesEl) {
1428
+ stateStylesEl = document.createElement("style");
1429
+ stateStylesEl.id = "kumiki-state-styles";
1430
+ appendStyleNode(stateStylesEl);
1431
+ }
1230
1432
  }
1231
1433
  const selector = state === "hover" ? ":hover" : state === "focus" ? ":focus" : state === "active" ? ":active" : state === "disabled" ? ":disabled" : "[data-kumiki-selected]";
1232
1434
  stateStylesEl.appendChild(document.createTextNode(`[data-kumiki-state~="${id}"]${selector} { ${decls} }\n`));
@@ -1262,12 +1464,13 @@ function applyThemeDefaults(app) {
1262
1464
  const colors = theme.colors ?? {};
1263
1465
  const typography = theme.typography ?? {};
1264
1466
  const sizes = typography.size ?? {};
1265
- if (typeof colors.bg === "string") document.body.style.background = colors.bg;
1266
- if (typeof colors.fg === "string") document.body.style.color = colors.fg;
1267
- if (typeof typography.family === "string") document.body.style.fontFamily = typography.family;
1268
- if (typeof sizes.md === "string") document.body.style.fontSize = sizes.md;
1269
- if (typeof typography["line-height"] === "string") document.body.style.lineHeight = String(typography["line-height"]);
1270
- const prior = document.getElementById("kumiki-theme-base");
1467
+ const host = styleHostEl();
1468
+ if (typeof colors.bg === "string") host.style.background = colors.bg;
1469
+ if (typeof colors.fg === "string") host.style.color = colors.fg;
1470
+ if (typeof typography.family === "string") host.style.fontFamily = typography.family;
1471
+ if (typeof sizes.md === "string") host.style.fontSize = sizes.md;
1472
+ if (typeof typography["line-height"] === "string") host.style.lineHeight = String(typography["line-height"]);
1473
+ const prior = findStyleNode("kumiki-theme-base");
1271
1474
  if (prior) prior.remove();
1272
1475
  const css = document.createElement("style");
1273
1476
  css.id = "kumiki-theme-base";
@@ -1310,7 +1513,7 @@ function applyThemeDefaults(app) {
1310
1513
  }
1311
1514
  [data-kumiki-tile="markdown"] p { margin: 0 0 12px; }
1312
1515
  `));
1313
- document.head.appendChild(css);
1516
+ appendStyleNode(css);
1314
1517
  }
1315
1518
  function themeShadow(theme, key) {
1316
1519
  const shadow = theme.shadow;
@@ -1726,13 +1929,26 @@ const _stdlib = {
1726
1929
  ...patch
1727
1930
  };
1728
1931
  },
1932
+ /**
1933
+ * `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
1934
+ * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
1935
+ * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
1936
+ * v0.3 this returned the value unchanged on the empty case, so `.get` and
1937
+ * `.get-err` behaved oppositely — #24.)
1938
+ */
1729
1939
  unwrap(opt) {
1730
1940
  if (opt && typeof opt === "object" && "_tag" in opt) {
1731
1941
  const o = opt;
1732
- if (o._tag === "Some") return o._0;
1942
+ if (o._tag === "Some" || o._tag === "Ok") return o._0;
1943
+ if (o._tag === "None") throw new KumikiPanic("get called on None");
1944
+ if (o._tag === "Err") throw new KumikiPanic("get called on an Err value");
1733
1945
  }
1734
1946
  return opt;
1735
1947
  },
1948
+ /** `panic(message)` — raise Kumiki's controlled stop-the-program signal. */
1949
+ panic(message) {
1950
+ throw new KumikiPanic(String(message));
1951
+ },
1736
1952
  optionGetOr(opt, def) {
1737
1953
  if (opt && typeof opt === "object" && "_tag" in opt) {
1738
1954
  const o = opt;
@@ -1873,13 +2089,13 @@ const _stdlib = {
1873
2089
  if (v && typeof v === "object") return Object.keys(v);
1874
2090
  return [];
1875
2091
  },
1876
- /** Result(T,E).get-err → E; panics if the value is Ok. */
2092
+ /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
1877
2093
  getErr(r) {
1878
2094
  if (r && typeof r === "object" && "_tag" in r) {
1879
2095
  const t = r;
1880
2096
  if (t._tag === "Err") return t._0;
1881
2097
  }
1882
- throw new Error("get-err called on a non-Err value");
2098
+ throw new KumikiPanic("get-err called on a non-Err value");
1883
2099
  },
1884
2100
  /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
1885
2101
  toOption(r) {
@@ -1992,4 +2208,4 @@ const builtinEffects = {
1992
2208
  }
1993
2209
  };
1994
2210
  //#endregion
1995
- export { _stdlib, builtinEffects, mount, runScenario, smoke };
2211
+ 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.2.1",
3
+ "version": "0.4.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",