@kumikijs/runtime 0.2.0 → 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";
@@ -403,7 +424,15 @@ declare const _stdlib: {
403
424
  setDiff(a: Record<string, true> | undefined | null, b: Record<string, true> | undefined | null): Record<string, true>; /** Option(T).or / Result(T,E).or — receiver when Some/Ok, else `other`. */
404
425
  or(v: unknown, other: unknown): unknown; /** Result(T,E).map-err(fn) — maps the Err payload, passes Ok through unchanged. */
405
426
  mapErr(r: unknown, fn: (e: unknown) => unknown): unknown; /** Polymorphic `.diff`: numeric magnitude (Time/Duration) or Set difference. */
406
- diff(a: unknown, b: unknown): unknown;
427
+ diff(a: unknown, b: unknown): unknown; /** List(T).head → Option(T). */
428
+ listHead(xs: unknown[] | undefined | null): unknown; /** List(T).tail → List(T) (all but the first; empty list stays empty). */
429
+ listTail(xs: unknown[] | undefined | null): unknown[]; /** List(T).last → Option(T). */
430
+ listLast(xs: unknown[] | undefined | null): unknown; /** Set(T).to-list / Option(T).to-list → List(T). */
431
+ toList(v: unknown): unknown[]; /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
432
+ getErr(r: unknown): unknown; /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
433
+ toOption(r: unknown): unknown; /** Text.parse-int → Option(Int) (truncates; mirrors `Int.parse`). */
434
+ parseIntOpt(s: unknown): unknown; /** Text.parse-float → Option(Float) (mirrors `Float.parse`). */
435
+ parseFloatOpt(s: unknown): unknown;
407
436
  };
408
437
  declare const builtinEffects: {
409
438
  storageRead(input: unknown): Promise<EffectResult>;
@@ -411,4 +440,4 @@ declare const builtinEffects: {
411
440
  httpFetch(method: string, input: unknown, baseUrl: string): Promise<EffectResult>;
412
441
  };
413
442
  //#endregion
414
- 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;
@@ -1848,6 +1950,56 @@ const _stdlib = {
1848
1950
  diff(a, b) {
1849
1951
  if (typeof a === "number" || typeof b === "number") return Math.abs(a - b);
1850
1952
  return _stdlib.setDiff(a, b);
1953
+ },
1954
+ /** List(T).head → Option(T). */
1955
+ listHead(xs) {
1956
+ const a = xs ?? [];
1957
+ return a.length > 0 ? _stdlib.Some(a[0]) : _stdlib.None;
1958
+ },
1959
+ /** List(T).tail → List(T) (all but the first; empty list stays empty). */
1960
+ listTail(xs) {
1961
+ return (xs ?? []).slice(1);
1962
+ },
1963
+ /** List(T).last → Option(T). */
1964
+ listLast(xs) {
1965
+ const a = xs ?? [];
1966
+ return a.length > 0 ? _stdlib.Some(a[a.length - 1]) : _stdlib.None;
1967
+ },
1968
+ /** Set(T).to-list / Option(T).to-list → List(T). */
1969
+ toList(v) {
1970
+ if (v && typeof v === "object" && "_tag" in v) {
1971
+ const o = v;
1972
+ return o._tag === "Some" ? [o._0] : [];
1973
+ }
1974
+ if (Array.isArray(v)) return [...v];
1975
+ if (v && typeof v === "object") return Object.keys(v);
1976
+ return [];
1977
+ },
1978
+ /** Result(T,E).get-err → E; panics (KumikiPanic) if the value is Ok. */
1979
+ getErr(r) {
1980
+ if (r && typeof r === "object" && "_tag" in r) {
1981
+ const t = r;
1982
+ if (t._tag === "Err") return t._0;
1983
+ }
1984
+ throw new KumikiPanic("get-err called on a non-Err value");
1985
+ },
1986
+ /** Result(T,E).to-option → Option(T): Ok(v) → Some(v), Err(_) → None. */
1987
+ toOption(r) {
1988
+ if (r && typeof r === "object" && "_tag" in r) {
1989
+ const t = r;
1990
+ if (t._tag === "Ok") return _stdlib.Some(t._0);
1991
+ }
1992
+ return _stdlib.None;
1993
+ },
1994
+ /** Text.parse-int → Option(Int) (truncates; mirrors `Int.parse`). */
1995
+ parseIntOpt(s) {
1996
+ const n = Number(s);
1997
+ return String(s).trim() !== "" && Number.isFinite(n) ? _stdlib.Some(Math.trunc(n)) : _stdlib.None;
1998
+ },
1999
+ /** Text.parse-float → Option(Float) (mirrors `Float.parse`). */
2000
+ parseFloatOpt(s) {
2001
+ const n = Number(s);
2002
+ return String(s).trim() !== "" && Number.isFinite(n) ? _stdlib.Some(n) : _stdlib.None;
1851
2003
  }
1852
2004
  };
1853
2005
  const builtinEffects = {
@@ -1942,4 +2094,4 @@ const builtinEffects = {
1942
2094
  }
1943
2095
  };
1944
2096
  //#endregion
1945
- 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.0",
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",