@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 +84 -5
- package/dist/index.js +252 -36
- package/package.json +1 -1
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 (
|
|
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
|
-
|
|
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 =
|
|
1395
|
+
let style = findStyleNode("kumiki-motions");
|
|
1197
1396
|
if (!style) {
|
|
1198
1397
|
style = document.createElement("style");
|
|
1199
1398
|
style.id = "kumiki-motions";
|
|
1200
|
-
|
|
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 =
|
|
1228
|
-
stateStylesEl
|
|
1229
|
-
|
|
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
|
-
|
|
1266
|
-
if (typeof colors.
|
|
1267
|
-
if (typeof
|
|
1268
|
-
if (typeof
|
|
1269
|
-
if (typeof
|
|
1270
|
-
|
|
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
|
-
|
|
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
|
|
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