@kumikijs/runtime 0.2.1 → 0.3.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
@@ -86,6 +86,19 @@ declare function smoke(app: AppShape, root: HTMLElement, opts?: SmokeOptions): P
86
86
  //#region src/index.d.ts
87
87
  type RefinementCheck = (v: unknown) => boolean;
88
88
  type EventHandler = (el: Record<string, unknown>) => void;
89
+ /**
90
+ * A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
91
+ * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
92
+ * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
93
+ * back (no partial slot writes) and an error boundary / top-level fallback is
94
+ * shown — instead of escaping the DOM event handler / render uncaught. The
95
+ * reducer-test harness already catches it to power `expect = {panic: ...}`.
96
+ */
97
+ declare class KumikiPanic extends Error {
98
+ readonly isKumikiPanic: true;
99
+ readonly location: string | undefined;
100
+ constructor(message: string, location?: string);
101
+ }
89
102
  type TileNode = {
90
103
  kind: "page" | "column" | "row" | "card" | "box";
91
104
  children: TileNode[];
@@ -372,7 +385,15 @@ declare const _stdlib: {
372
385
  freshId(): string;
373
386
  now(): number;
374
387
  recordCopy(rec: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
375
- unwrap(opt: unknown): unknown;
388
+ /**
389
+ * `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
390
+ * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
391
+ * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
392
+ * v0.3 this returned the value unchanged on the empty case, so `.get` and
393
+ * `.get-err` behaved oppositely — #24.)
394
+ */
395
+ unwrap(opt: unknown): unknown; /** `panic(message)` — raise Kumiki's controlled stop-the-program signal. */
396
+ panic(message: unknown): never;
376
397
  optionGetOr(opt: unknown, def: unknown): unknown;
377
398
  Some(v: unknown): {
378
399
  _tag: "Some";
@@ -407,7 +428,7 @@ declare const _stdlib: {
407
428
  listHead(xs: unknown[] | undefined | null): unknown; /** List(T).tail → List(T) (all but the first; empty list stays empty). */
408
429
  listTail(xs: unknown[] | undefined | null): unknown[]; /** List(T).last → Option(T). */
409
430
  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. */
431
+ toList(v: unknown): unknown[]; /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
411
432
  getErr(r: unknown): unknown; /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
412
433
  toOption(r: unknown): unknown; /** Text.parse-int → Option(Int) (truncates; mirrors `Int.parse`). */
413
434
  parseIntOpt(s: unknown): unknown; /** Text.parse-float → Option(Float) (mirrors `Float.parse`). */
@@ -419,4 +440,4 @@ declare const builtinEffects: {
419
440
  httpFetch(method: string, input: unknown, baseUrl: string): Promise<EffectResult>;
420
441
  };
421
442
  //#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 };
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 };
package/dist/index.js CHANGED
@@ -361,6 +361,27 @@ function errStr(e) {
361
361
  }
362
362
  //#endregion
363
363
  //#region src/index.ts
364
+ /**
365
+ * A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
366
+ * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
367
+ * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
368
+ * back (no partial slot writes) and an error boundary / top-level fallback is
369
+ * shown — instead of escaping the DOM event handler / render uncaught. The
370
+ * reducer-test harness already catches it to power `expect = {panic: ...}`.
371
+ */
372
+ var KumikiPanic = class extends Error {
373
+ isKumikiPanic = true;
374
+ location;
375
+ constructor(message, location) {
376
+ super(message);
377
+ this.name = "KumikiPanic";
378
+ this.location = location;
379
+ }
380
+ };
381
+ /** True for a KumikiPanic — also matches across realms where `instanceof` fails. */
382
+ function isPanic(e) {
383
+ return e instanceof KumikiPanic || typeof e === "object" && e !== null && e.isKumikiPanic === true;
384
+ }
364
385
  function parseLocation(routes, loc) {
365
386
  const path = loc.pathname || "/";
366
387
  const query = {};
@@ -448,7 +469,13 @@ function mount(app, target) {
448
469
  };
449
470
  }
450
471
  maybeReapplyTheme(app);
451
- const dom = renderTile(pickRootTile(app));
472
+ let dom;
473
+ try {
474
+ dom = renderTile(pickRootTile(app));
475
+ } catch (e) {
476
+ reportPanic("render", e);
477
+ dom = renderPanicFallback(e);
478
+ }
452
479
  if (currentRoot) target.replaceChild(dom, currentRoot);
453
480
  else target.appendChild(dom);
454
481
  currentRoot = dom;
@@ -475,9 +502,38 @@ function mount(app, target) {
475
502
  text: "(no root)"
476
503
  };
477
504
  }
505
+ let inPanicHandler = false;
506
+ /**
507
+ * Handle a caught live panic per spec/lifecycle.md §7.2: the dispatch episode
508
+ * is already rolled back (the caller never applied the failed result), so we
509
+ * surface it (console.error → smoke/scenario see it) and fire the `app.error`
510
+ * reducer(s) with `$event = PanicInfo`, exactly as §7.2.3 specifies.
511
+ */
512
+ function handleLivePanic(location, e) {
513
+ reportPanic(location, e);
514
+ if (inPanicHandler) return;
515
+ const handlers = app.reducers.filter((h) => h.event.kind === "lifecycle" && h.event.name === "app.error");
516
+ if (handlers.length === 0) return;
517
+ const info = {
518
+ message: panicInfo(e).message,
519
+ location
520
+ };
521
+ inPanicHandler = true;
522
+ try {
523
+ for (const h of handlers) applyReducer(h, { $event: info });
524
+ } finally {
525
+ inPanicHandler = false;
526
+ }
527
+ }
478
528
  function applyReducer(r, payload) {
479
529
  if (disposed) return;
480
- const result = r.apply(slotValues, payload);
530
+ let result;
531
+ try {
532
+ result = r.apply(slotValues, payload);
533
+ } catch (e) {
534
+ handleLivePanic(`reducer "${r.name}"`, e);
535
+ return;
536
+ }
481
537
  for (const [k, v] of Object.entries(result.slots)) {
482
538
  const meta = app.slots[k];
483
539
  if (meta?.refine && !meta.refine(v)) continue;
@@ -747,6 +803,39 @@ function _setPathHelper(obj, path, value) {
747
803
  [head]: _setPathHelper(cur[head], rest, value)
748
804
  };
749
805
  }
806
+ /** Message + optional source location for a caught throw (panic or otherwise). */
807
+ function panicInfo(e) {
808
+ if (isPanic(e)) return {
809
+ message: e.message,
810
+ location: e.location
811
+ };
812
+ if (e instanceof Error) return {
813
+ message: e.message,
814
+ location: void 0
815
+ };
816
+ return {
817
+ message: String(e),
818
+ location: void 0
819
+ };
820
+ }
821
+ /**
822
+ * Surface a caught live panic so the verification tiers still see it: smoke()
823
+ * and runScenario() both patch console.error into their issue/error buffers, so
824
+ * a controlled panic is reported as a failure rather than silently swallowed.
825
+ */
826
+ function reportPanic(where, e) {
827
+ const { message } = panicInfo(e);
828
+ console.error(`[kumiki] ${isPanic(e) ? "panic" : "error"} in ${where}: ${message}`);
829
+ }
830
+ /** A minimal top-level fallback for a render panic with no enclosing boundary. */
831
+ function renderPanicFallback(e) {
832
+ const { message, location } = panicInfo(e);
833
+ const div = document.createElement("div");
834
+ div.dataset.kumikiPanic = location ?? "";
835
+ div.setAttribute("role", "alert");
836
+ div.textContent = `Something went wrong: ${message}`;
837
+ return div;
838
+ }
750
839
  function renderTile(node) {
751
840
  const el = renderTileNode(node);
752
841
  applyMotion(el, node.props);
@@ -1726,13 +1815,26 @@ const _stdlib = {
1726
1815
  ...patch
1727
1816
  };
1728
1817
  },
1818
+ /**
1819
+ * `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
1820
+ * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
1821
+ * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
1822
+ * v0.3 this returned the value unchanged on the empty case, so `.get` and
1823
+ * `.get-err` behaved oppositely — #24.)
1824
+ */
1729
1825
  unwrap(opt) {
1730
1826
  if (opt && typeof opt === "object" && "_tag" in opt) {
1731
1827
  const o = opt;
1732
- if (o._tag === "Some") return o._0;
1828
+ if (o._tag === "Some" || o._tag === "Ok") return o._0;
1829
+ if (o._tag === "None") throw new KumikiPanic("get called on None");
1830
+ if (o._tag === "Err") throw new KumikiPanic("get called on an Err value");
1733
1831
  }
1734
1832
  return opt;
1735
1833
  },
1834
+ /** `panic(message)` — raise Kumiki's controlled stop-the-program signal. */
1835
+ panic(message) {
1836
+ throw new KumikiPanic(String(message));
1837
+ },
1736
1838
  optionGetOr(opt, def) {
1737
1839
  if (opt && typeof opt === "object" && "_tag" in opt) {
1738
1840
  const o = opt;
@@ -1873,13 +1975,13 @@ const _stdlib = {
1873
1975
  if (v && typeof v === "object") return Object.keys(v);
1874
1976
  return [];
1875
1977
  },
1876
- /** Result(T,E).get-err → E; panics if the value is Ok. */
1978
+ /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
1877
1979
  getErr(r) {
1878
1980
  if (r && typeof r === "object" && "_tag" in r) {
1879
1981
  const t = r;
1880
1982
  if (t._tag === "Err") return t._0;
1881
1983
  }
1882
- throw new Error("get-err called on a non-Err value");
1984
+ throw new KumikiPanic("get-err called on a non-Err value");
1883
1985
  },
1884
1986
  /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
1885
1987
  toOption(r) {
@@ -1992,4 +2094,4 @@ const builtinEffects = {
1992
2094
  }
1993
2095
  };
1994
2096
  //#endregion
1995
- export { _stdlib, builtinEffects, mount, runScenario, smoke };
2097
+ export { KumikiPanic, _stdlib, builtinEffects, 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.3.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",