@kumikijs/runtime 0.3.0 → 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 = {
@@ -87,7 +120,7 @@ declare function smoke(app: AppShape, root: HTMLElement, opts?: SmokeOptions): P
87
120
  type RefinementCheck = (v: unknown) => boolean;
88
121
  type EventHandler = (el: Record<string, unknown>) => void;
89
122
  /**
90
- * A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
123
+ * A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
91
124
  * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
92
125
  * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
93
126
  * back (no partial slot writes) and an error boundary / top-level fallback is
@@ -264,8 +297,33 @@ type EffectResult = {
264
297
  kind: "err";
265
298
  value: unknown;
266
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;
267
308
  type CapabilityRegistry = {
268
- 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;
269
327
  };
270
328
  type RouteEntry = {
271
329
  pattern: string; /** Returns the TileNode for this route given the current state. */
@@ -301,7 +359,7 @@ type AppShape = {
301
359
  live?: Record<string, unknown>;
302
360
  _rerender?: () => void;
303
361
  };
304
- declare function mount(app: AppShape, target: HTMLElement): {
362
+ declare function mount(app: AppShape, target: HTMLElement, options?: MountOptions): {
305
363
  dispose: () => void;
306
364
  };
307
365
  type TestResult = {
@@ -386,7 +444,7 @@ declare const _stdlib: {
386
444
  now(): number;
387
445
  recordCopy(rec: Record<string, unknown>, patch: Record<string, unknown>): Record<string, unknown>;
388
446
  /**
389
- * `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
447
+ * `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
390
448
  * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
391
449
  * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
392
450
  * v0.3 this returned the value unchanged on the empty case, so `.get` and
@@ -440,4 +498,4 @@ declare const builtinEffects: {
440
498
  httpFetch(method: string, input: unknown, baseUrl: string): Promise<EffectResult>;
441
499
  };
442
500
  //#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 };
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 = {}) {
@@ -362,7 +444,7 @@ function errStr(e) {
362
444
  //#endregion
363
445
  //#region src/index.ts
364
446
  /**
365
- * A controlled panic — Kumiki's "stop the program" signal (spec/stdlib.md §2.2:
447
+ * A controlled panic — Kumiki's "stop the program" signal (docs/spec/stdlib.md §2.2:
366
448
  * `panic(message)`; `Option/Result.get` on the empty case; `Result.get-err` on
367
449
  * `Ok`). On the live path a panic is caught — the dispatch episode is rolled
368
450
  * back (no partial slot writes) and an error boundary / top-level fallback is
@@ -439,15 +521,18 @@ function emptyRoute() {
439
521
  hash: null
440
522
  };
441
523
  }
442
- function mount(app, target) {
524
+ function mount(app, target, options = {}) {
443
525
  if (!app.live) {
444
526
  app.live = {};
445
527
  for (const [k, v] of Object.entries(app.slots)) app.live[k] = v.value;
446
528
  }
447
529
  if (!("route" in app.live)) app.live.route = emptyRoute();
530
+ currentStyleRoot = options.styleRoot ?? document;
531
+ currentStyleHost = options.styleHost ?? null;
532
+ stateStylesEl = null;
448
533
  ensureMotionStyles(app);
449
534
  const slotValues = app.live;
450
- 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) => {
451
536
  handleEffectResult(effect, outcome, value, key);
452
537
  });
453
538
  let currentRoot = null;
@@ -504,7 +589,7 @@ function mount(app, target) {
504
589
  }
505
590
  let inPanicHandler = false;
506
591
  /**
507
- * Handle a caught live panic per spec/lifecycle.md §7.2: the dispatch episode
592
+ * Handle a caught live panic per docs/spec/lifecycle.md §7.2: the dispatch episode
508
593
  * is already rolled back (the caller never applied the failed result), so we
509
594
  * surface it (console.error → smoke/scenario see it) and fire the `app.error`
510
595
  * reducer(s) with `$event = PanicInfo`, exactly as §7.2.3 specifies.
@@ -621,9 +706,12 @@ function mount(app, target) {
621
706
  dispatcher.dispose();
622
707
  } };
623
708
  }
624
- function makeCapabilityRegistry(allowed) {
709
+ function makeCapabilityRegistry(allowed, providers) {
625
710
  const ok = new Set(allowed);
626
- return { has: (c) => ok.has(c) };
711
+ return {
712
+ has: (c) => ok.has(c),
713
+ provider: (c) => providers?.[c]
714
+ };
627
715
  }
628
716
  function makeEffectDispatcher(app, caps, onResult) {
629
717
  const state = {
@@ -700,43 +788,50 @@ function makeEffectDispatcher(app, caps, onResult) {
700
788
  };
701
789
  }
702
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
+ };
703
798
  app.effects.navigate = {
704
799
  name: "navigate",
705
800
  cap: "nav.push",
706
- invoke: async (input) => {
801
+ invoke: overridable("nav.push", async (input) => {
707
802
  navigate(buildPath(input), false);
708
803
  return {
709
804
  kind: "ok",
710
805
  value: null
711
806
  };
712
- }
807
+ })
713
808
  };
714
809
  app.effects["navigate-replace"] = {
715
810
  name: "navigate-replace",
716
811
  cap: "nav.replace",
717
- invoke: async (input) => {
812
+ invoke: overridable("nav.replace", async (input) => {
718
813
  navigate(buildPath(input), true);
719
814
  return {
720
815
  kind: "ok",
721
816
  value: null
722
817
  };
723
- }
818
+ })
724
819
  };
725
820
  app.effects["navigate-back"] = {
726
821
  name: "navigate-back",
727
822
  cap: "nav.back",
728
- invoke: async () => {
823
+ invoke: overridable("nav.back", async () => {
729
824
  history.back();
730
825
  return {
731
826
  kind: "ok",
732
827
  value: null
733
828
  };
734
- }
829
+ })
735
830
  };
736
831
  app.effects.toast = {
737
832
  name: "toast",
738
833
  cap: "notification.show",
739
- invoke: async (input) => {
834
+ invoke: overridable("notification.show", async (input) => {
740
835
  const t = input;
741
836
  const banner = document.createElement("div");
742
837
  banner.style.cssText = "position:fixed;bottom:24px;right:24px;padding:8px 16px;background:#1a1a1a;color:#fff;border-radius:8px;z-index:9999;";
@@ -747,18 +842,18 @@ function registerBuiltinEffects(app, navigate, getLive, rerender) {
747
842
  kind: "ok",
748
843
  value: null
749
844
  };
750
- }
845
+ })
751
846
  };
752
847
  app.effects.log = {
753
848
  name: "log",
754
849
  cap: "log.write",
755
- invoke: async (input) => {
850
+ invoke: overridable("log.write", async (input) => {
756
851
  console.log("[kumiki]", input);
757
852
  return {
758
853
  kind: "ok",
759
854
  value: null
760
855
  };
761
- }
856
+ })
762
857
  };
763
858
  }
764
859
  function buildPath(x) {
@@ -1215,10 +1310,8 @@ function applyResponsive(_el, raw, set) {
1215
1310
  return;
1216
1311
  }
1217
1312
  }
1218
- let animationStylesInjected = false;
1219
1313
  function ensureAnimationStyles() {
1220
- if (animationStylesInjected) return;
1221
- animationStylesInjected = true;
1314
+ if (findStyleNode("kumiki-animations")) return;
1222
1315
  const css = `
1223
1316
  @keyframes kumiki-fade { from { opacity: 0 } to { opacity: 1 } }
1224
1317
  @keyframes kumiki-slide-up { from { transform: translateY(8px); opacity: 0 } to { transform: translateY(0); opacity: 1 } }
@@ -1234,7 +1327,7 @@ function ensureAnimationStyles() {
1234
1327
  const style = document.createElement("style");
1235
1328
  style.id = "kumiki-animations";
1236
1329
  style.appendChild(document.createTextNode(css));
1237
- document.head.appendChild(style);
1330
+ appendStyleNode(style);
1238
1331
  }
1239
1332
  function applyTransition(el, props) {
1240
1333
  if (!props) return;
@@ -1278,15 +1371,32 @@ function motionCss(name, spec) {
1278
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");
1279
1372
  }
1280
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
+ }
1281
1391
  function ensureMotionStyles(app) {
1282
1392
  const motions = app.motions ?? {};
1283
1393
  const rules = Object.entries(motions).map(([name, spec]) => motionCss(name, spec));
1284
1394
  rules.push(`@media (prefers-reduced-motion: reduce) { .kumiki-motion, .kumiki-anim { animation: none !important } }`);
1285
- let style = document.getElementById("kumiki-motions");
1395
+ let style = findStyleNode("kumiki-motions");
1286
1396
  if (!style) {
1287
1397
  style = document.createElement("style");
1288
1398
  style.id = "kumiki-motions";
1289
- document.head.appendChild(style);
1399
+ appendStyleNode(style);
1290
1400
  }
1291
1401
  style.textContent = rules.join("\n");
1292
1402
  }
@@ -1313,9 +1423,12 @@ function applyStateStyles(el, props) {
1313
1423
  el.dataset.kumikiState = el.dataset.kumikiState ? `${el.dataset.kumikiState} ${id}` : id;
1314
1424
  const decls = stateStyleDecls(sub);
1315
1425
  if (!stateStylesEl) {
1316
- stateStylesEl = document.createElement("style");
1317
- stateStylesEl.id = "kumiki-state-styles";
1318
- 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
+ }
1319
1432
  }
1320
1433
  const selector = state === "hover" ? ":hover" : state === "focus" ? ":focus" : state === "active" ? ":active" : state === "disabled" ? ":disabled" : "[data-kumiki-selected]";
1321
1434
  stateStylesEl.appendChild(document.createTextNode(`[data-kumiki-state~="${id}"]${selector} { ${decls} }\n`));
@@ -1351,12 +1464,13 @@ function applyThemeDefaults(app) {
1351
1464
  const colors = theme.colors ?? {};
1352
1465
  const typography = theme.typography ?? {};
1353
1466
  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");
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");
1360
1474
  if (prior) prior.remove();
1361
1475
  const css = document.createElement("style");
1362
1476
  css.id = "kumiki-theme-base";
@@ -1399,7 +1513,7 @@ function applyThemeDefaults(app) {
1399
1513
  }
1400
1514
  [data-kumiki-tile="markdown"] p { margin: 0 0 12px; }
1401
1515
  `));
1402
- document.head.appendChild(css);
1516
+ appendStyleNode(css);
1403
1517
  }
1404
1518
  function themeShadow(theme, key) {
1405
1519
  const shadow = theme.shadow;
@@ -1816,7 +1930,7 @@ const _stdlib = {
1816
1930
  };
1817
1931
  },
1818
1932
  /**
1819
- * `.get` — the polymorphic unwrap for Option AND Result. Per spec/stdlib.md
1933
+ * `.get` — the polymorphic unwrap for Option AND Result. Per docs/spec/stdlib.md
1820
1934
  * §2.2 it PANICS on the empty case (`None` / `Err`); `Some(v)` / `Ok(v)`
1821
1935
  * unwrap to `v`. A plain (non-variant) value passes through unchanged. (Before
1822
1936
  * v0.3 this returned the value unchanged on the empty case, so `.get` and
@@ -2094,4 +2208,4 @@ const builtinEffects = {
2094
2208
  }
2095
2209
  };
2096
2210
  //#endregion
2097
- export { KumikiPanic, _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.3.0",
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",