@kiosko/core 0.0.0-stage → 0.2.1

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.
Files changed (77) hide show
  1. package/dist/animation.d.ts +60 -0
  2. package/dist/animation.js +111 -0
  3. package/dist/compositor.d.ts +140 -0
  4. package/dist/compositor.js +322 -0
  5. package/dist/config/check.d.ts +20 -0
  6. package/dist/config/check.js +163 -0
  7. package/dist/config/diff.d.ts +18 -0
  8. package/dist/config/diff.js +153 -0
  9. package/dist/config/jsonschema.d.ts +73 -0
  10. package/dist/config/jsonschema.js +364 -0
  11. package/dist/config/model.d.ts +210 -0
  12. package/dist/config/model.js +90 -0
  13. package/dist/config/normalize.d.ts +7 -0
  14. package/dist/config/normalize.js +62 -0
  15. package/dist/config/review.d.ts +115 -0
  16. package/dist/config/review.js +333 -0
  17. package/dist/config/schema.d.ts +31 -0
  18. package/dist/config/schema.js +363 -0
  19. package/dist/config/sources.d.ts +44 -0
  20. package/dist/config/sources.js +90 -0
  21. package/dist/config/store.d.ts +9 -0
  22. package/dist/config/store.js +42 -0
  23. package/dist/config/tools.d.ts +12 -0
  24. package/dist/config/tools.js +12 -0
  25. package/dist/data.d.ts +72 -0
  26. package/dist/data.js +293 -0
  27. package/dist/devices.d.ts +95 -0
  28. package/dist/devices.js +299 -0
  29. package/dist/env.d.ts +10 -0
  30. package/dist/env.js +59 -0
  31. package/dist/fonts.d.ts +8 -0
  32. package/dist/fonts.js +50 -0
  33. package/dist/fsperm.d.ts +7 -0
  34. package/dist/fsperm.js +23 -0
  35. package/dist/index.d.ts +18 -0
  36. package/dist/index.js +18 -0
  37. package/dist/layout/grid.d.ts +105 -0
  38. package/dist/layout/grid.js +229 -0
  39. package/dist/layout/index.d.ts +2 -0
  40. package/dist/layout/index.js +2 -0
  41. package/dist/layout/stack.d.ts +25 -0
  42. package/dist/layout/stack.js +50 -0
  43. package/dist/paths.d.ts +18 -0
  44. package/dist/paths.js +53 -0
  45. package/dist/registry.d.ts +81 -0
  46. package/dist/registry.js +175 -0
  47. package/dist/runtime.d.ts +147 -0
  48. package/dist/runtime.js +601 -0
  49. package/dist/sdk-resolver.d.ts +1 -0
  50. package/dist/sdk-resolver.js +45 -0
  51. package/package.json +56 -4
  52. package/src/animation.ts +140 -0
  53. package/src/compositor.ts +414 -0
  54. package/src/config/check.ts +158 -0
  55. package/src/config/diff.ts +139 -0
  56. package/src/config/jsonschema.ts +373 -0
  57. package/src/config/model.ts +241 -0
  58. package/src/config/normalize.ts +64 -0
  59. package/src/config/review.ts +388 -0
  60. package/src/config/schema.ts +382 -0
  61. package/src/config/sources.ts +95 -0
  62. package/src/config/store.ts +42 -0
  63. package/src/config/tools.ts +12 -0
  64. package/src/data.ts +330 -0
  65. package/src/devices.ts +313 -0
  66. package/src/env.ts +62 -0
  67. package/src/fonts.ts +49 -0
  68. package/src/fsperm.ts +23 -0
  69. package/src/index.ts +18 -0
  70. package/src/layout/grid.ts +258 -0
  71. package/src/layout/index.ts +2 -0
  72. package/src/layout/stack.ts +52 -0
  73. package/src/paths.ts +54 -0
  74. package/src/registry.ts +174 -0
  75. package/src/runtime.ts +629 -0
  76. package/src/sdk-resolver.ts +43 -0
  77. package/README.md +0 -3
@@ -0,0 +1,60 @@
1
+ /**
2
+ * When a widget's animation plays. The planning is here, apart from the
3
+ * painting and the sending (runtime.ts), so it can be tested without a
4
+ * screen: which shown widget is asked, and why (`idle`, now and then, or
5
+ * `data`, because what it paints with changed), and what the answer is worth.
6
+ *
7
+ * One animation at a time per screen is the caller's business (it asks
8
+ * only while nothing plays); what is kept here is per widget instance: when
9
+ * it was last asked, when its last animation ended and what it had then.
10
+ */
11
+ import type { AnimationPlan } from '@kiosko/sdk';
12
+ import type { Animatable } from './compositor.ts';
13
+ /** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
14
+ export declare const MAX_FRAMES = 600;
15
+ /**
16
+ * How many animations the runtime plays together on a screen. Widgets don't
17
+ * know: every one asks as if it were alone, and what doesn't fit waits its
18
+ * turn. The whole chain (planner, compositor, player) works for any number;
19
+ * one is what the panel's USB budget is sized for.
20
+ */
21
+ export declare const MAX_CONCURRENT = 1;
22
+ /** Default time between the `idle` questions of a widget. */
23
+ export declare const DEFAULT_GAP: {
24
+ minMs: number;
25
+ maxMs: number;
26
+ };
27
+ /** A plan as the runtime plays it: checked, with the screen's fps ceiling applied. */
28
+ export interface PlayablePlan {
29
+ plan: AnimationPlan;
30
+ frames: number;
31
+ fps: number;
32
+ interruptible: boolean;
33
+ /** Index (into 0..frames-1) where it holds, and for how long. */
34
+ hold?: {
35
+ index: number;
36
+ ms: number;
37
+ };
38
+ }
39
+ /** Why a plan isn't playable, or the plan ready to play. */
40
+ export declare function checkPlan(plan: unknown, maxFps: number): PlayablePlan | string;
41
+ export declare class AnimationScheduler {
42
+ private memory;
43
+ private readonly rnd;
44
+ private readonly onInvalid;
45
+ constructor(rnd?: () => number, onInvalid?: (type: string, why: string) => void);
46
+ private gapAfter;
47
+ /**
48
+ * The animations to play now, up to `limit`, among the shown widgets that
49
+ * have one. Call it only while nothing is playing. A widget seen for the
50
+ * first time isn't asked: starting up must not set off a gesture. When more
51
+ * are due than fit, the one that has waited longest goes first (the order
52
+ * of the page doesn't decide), so none starves.
53
+ */
54
+ next(list: Animatable[], now: number, maxFps: number, limit?: number): {
55
+ animatable: Animatable;
56
+ play: PlayablePlan;
57
+ }[];
58
+ /** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
59
+ ended(instanceId: string, now: number, a?: Animatable): void;
60
+ }
@@ -0,0 +1,111 @@
1
+ /** The most frames one animation may have: 30 s at 20 fps. A wrong plan must not eat the CPU. */
2
+ export const MAX_FRAMES = 600;
3
+ /**
4
+ * How many animations the runtime plays together on a screen. Widgets don't
5
+ * know: every one asks as if it were alone, and what doesn't fit waits its
6
+ * turn. The whole chain (planner, compositor, player) works for any number;
7
+ * one is what the panel's USB budget is sized for.
8
+ */
9
+ export const MAX_CONCURRENT = 1;
10
+ /** Default time between the `idle` questions of a widget. */
11
+ export const DEFAULT_GAP = { minMs: 20_000, maxMs: 60_000 };
12
+ /** Why a plan isn't playable, or the plan ready to play. */
13
+ export function checkPlan(plan, maxFps) {
14
+ const p = plan;
15
+ if (!p || typeof p !== 'object')
16
+ return 'not an object';
17
+ if (typeof p.id !== 'string' || !p.id)
18
+ return 'no id';
19
+ if (!Number.isInteger(p.frames) || p.frames < 3)
20
+ return `frames must be an integer of at least 3 (got ${String(p.frames)})`;
21
+ if (p.frames > MAX_FRAMES)
22
+ return `too many frames (${p.frames}, at most ${MAX_FRAMES})`;
23
+ if (typeof p.fps !== 'number' || !(p.fps > 0))
24
+ return `fps must be positive (got ${String(p.fps)})`;
25
+ const frames = p.frames;
26
+ const hold = p.hold && Number.isFinite(p.hold.at) && p.hold.ms > 0 ? { index: Math.max(1, Math.min(frames - 2, Math.round(p.hold.at * (frames - 1)))), ms: p.hold.ms } : undefined;
27
+ return { plan: p, frames, fps: Math.max(1, Math.min(p.fps, maxFps)), interruptible: p.interruptible !== false, ...(hold ? { hold } : {}) };
28
+ }
29
+ export class AnimationScheduler {
30
+ memory = new Map();
31
+ rnd;
32
+ onInvalid;
33
+ constructor(rnd = Math.random, onInvalid = () => undefined) {
34
+ this.rnd = rnd;
35
+ this.onInvalid = onInvalid;
36
+ }
37
+ gapAfter(a, now) {
38
+ const g = a.def.animationGap ?? DEFAULT_GAP;
39
+ const min = Math.max(0, g.minMs), max = Math.max(min, g.maxMs);
40
+ return now + min + this.rnd() * (max - min);
41
+ }
42
+ /**
43
+ * The animations to play now, up to `limit`, among the shown widgets that
44
+ * have one. Call it only while nothing is playing. A widget seen for the
45
+ * first time isn't asked: starting up must not set off a gesture. When more
46
+ * are due than fit, the one that has waited longest goes first (the order
47
+ * of the page doesn't decide), so none starves.
48
+ */
49
+ next(list, now, maxFps, limit = MAX_CONCURRENT) {
50
+ const seen = new Set();
51
+ const due = [];
52
+ for (const a of list) {
53
+ seen.add(a.instanceId);
54
+ const m = this.memory.get(a.instanceId);
55
+ if (!m) {
56
+ this.memory.set(a.instanceId, { data: a.data, sources: a.sources, nextIdleAt: this.gapAfter(a, now), endedAt: -Infinity, dueSince: Infinity });
57
+ continue;
58
+ }
59
+ const changed = a.data !== m.data || Object.keys(a.sources).some((k) => a.sources[k] !== m.sources[k]);
60
+ const prev = m.pending ?? { data: m.data, sources: m.sources };
61
+ m.data = a.data;
62
+ m.sources = a.sources;
63
+ if ((changed || m.pending) && now - m.endedAt >= (a.def.animationCooldownMs ?? 0)) {
64
+ m.pending = prev;
65
+ due.push({ a, m, trigger: 'data', prev });
66
+ }
67
+ else if (now >= m.nextIdleAt)
68
+ due.push({ a, m, trigger: 'idle', prev });
69
+ }
70
+ // Forget the instances that are gone (removed, or no longer shown).
71
+ for (const id of this.memory.keys())
72
+ if (!seen.has(id))
73
+ this.memory.delete(id);
74
+ // Who has waited longest first; a tie keeps the page's order.
75
+ for (const d of due)
76
+ d.m.dueSince = Math.min(d.m.dueSince, d.trigger === 'data' ? now : d.m.nextIdleAt);
77
+ due.sort((x, y) => x.m.dueSince - y.m.dueSince);
78
+ const out = [];
79
+ for (const { a, m, trigger, prev } of due) {
80
+ if (out.length >= limit)
81
+ break;
82
+ if (trigger === 'idle')
83
+ m.nextIdleAt = this.gapAfter(a, now);
84
+ else
85
+ m.pending = undefined;
86
+ const plan = a.ask(trigger, this.rnd, trigger === 'data' ? prev : undefined);
87
+ if (plan === null) {
88
+ m.dueSince = Infinity;
89
+ continue;
90
+ }
91
+ const ok = checkPlan(plan, maxFps);
92
+ if (typeof ok === 'string') {
93
+ this.onInvalid(a.type, ok);
94
+ m.dueSince = Infinity;
95
+ continue;
96
+ }
97
+ out.push({ animatable: a, play: ok });
98
+ }
99
+ return out;
100
+ }
101
+ /** An animation of `instanceId` has just ended (or was cut): its cooldown starts. */
102
+ ended(instanceId, now, a) {
103
+ const m = this.memory.get(instanceId);
104
+ if (!m)
105
+ return;
106
+ m.endedAt = now;
107
+ m.dueSince = Infinity;
108
+ if (a)
109
+ m.nextIdleAt = Math.max(m.nextIdleAt, this.gapAfter(a, now));
110
+ }
111
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * Paints one page of a screen: background, then every tile with its card
3
+ * and the widget whose turn it is. Pure function of its input (plus the
4
+ * widgets' own code), so the panel, the editor preview and the gallery all
5
+ * go through here and can't disagree.
6
+ */
7
+ import { type Canvas, type SKRSContext2D } from '@napi-rs/canvas';
8
+ import { type AnimationFrame, type AnimationPlan, type Box, type Theme, type WidgetDefinition } from '@kiosko/sdk';
9
+ import { type PxRect } from './layout/grid.ts';
10
+ import { type Config, type Page, type Screen, type Tile, type WidgetInstance } from './config/model.ts';
11
+ import { type LogFn } from './env.ts';
12
+ import type { Registry } from './registry.ts';
13
+ export interface ComposeInput {
14
+ cfg: Config;
15
+ screen: Pick<Screen, 'width' | 'height' | 'pixelShift'>;
16
+ page: Page;
17
+ theme: Theme;
18
+ registry: Registry;
19
+ now: number;
20
+ working: boolean;
21
+ /** Data of an instance's own fetch. */
22
+ dataOf: (inst: WidgetInstance) => unknown;
23
+ /** Latest data of shared sources. */
24
+ sourcesOf: (types: readonly string[] | undefined) => Record<string, unknown>;
25
+ /** Painted for the editor, not for the panel. */
26
+ preview?: boolean;
27
+ /** Use each widget's demo data where there's no real data yet. */
28
+ demo?: boolean;
29
+ /** Also its demo options (gallery thumbnails only). */
30
+ demoOptions?: boolean;
31
+ /** Force which widget a tile shows (editor: the one selected in its stack). */
32
+ forceShown?: Record<string, string>;
33
+ log?: LogFn;
34
+ /** Disable pixel shift (editor, gallery). */
35
+ noShift?: boolean;
36
+ /** Reuse tiles whose inputs didn't change (the panel; not previews). */
37
+ cache?: TileCache;
38
+ /**
39
+ * Paint these frames of animations, each in its own tile (see
40
+ * `AnimationFrame`); every other tile as usual. Any number at once: how
41
+ * many the runtime plays together is its decision (`MAX_CONCURRENT`).
42
+ */
43
+ animate?: {
44
+ tileId: string;
45
+ instanceId: string;
46
+ frame: AnimationFrame;
47
+ }[];
48
+ }
49
+ /**
50
+ * A widget on screen that has an `animation` hook: asks it, with the state
51
+ * the page was painted with. The runtime decides when to ask.
52
+ */
53
+ export interface Animatable {
54
+ tileId: string;
55
+ instanceId: string;
56
+ type: string;
57
+ def: Pick<WidgetDefinition<any, any>, 'animationGap' | 'animationCooldownMs'>;
58
+ /** What the widget painted with, by reference (replaced, never mutated), to tell when it changed. */
59
+ data: unknown;
60
+ sources: Record<string, unknown>;
61
+ ask: (trigger: 'idle' | 'data', rnd: () => number, prev?: {
62
+ data: unknown;
63
+ sources: Record<string, unknown>;
64
+ }) => AnimationPlan | null;
65
+ /** Tells the widget an animation of its has ended (see `WidgetDefinition.animationEnded`). */
66
+ ended: (plan: AnimationPlan, completed: boolean) => void;
67
+ }
68
+ export interface TileResult {
69
+ id: string;
70
+ rect: PxRect;
71
+ /** Id of the widget instance shown, or null if none is visible. */
72
+ shown: string | null;
73
+ error?: string;
74
+ }
75
+ export interface ComposeResult {
76
+ canvas: Canvas;
77
+ tiles: TileResult[];
78
+ /** Every widget shown says it's idle (and there's at least one). */
79
+ idle: boolean;
80
+ /** When something will change by itself (clock, rotation): next repaint. */
81
+ nextAt: number;
82
+ /** The shown widgets that can animate. */
83
+ animatable: Animatable[];
84
+ }
85
+ /**
86
+ * The instance's switches over the widget's hooks: `takeOver` (keep the tile
87
+ * while urgent) and `hideWhenEmpty` (skip it in the rotation while `empty`).
88
+ * Each defaults to what the widget declares.
89
+ */
90
+ export declare function tileState(def: WidgetDefinition<any, any>, inst: Pick<WidgetInstance, 'takeOver' | 'hideWhenEmpty'>, state: Parameters<NonNullable<WidgetDefinition['visible']>>[0]): {
91
+ visible: boolean;
92
+ urgent: boolean;
93
+ };
94
+ /** Cuts `text` with an ellipsis to fit `maxW` with the current font (plain fillText, no tracking). */
95
+ export declare function fitText(g: SKRSContext2D, text: string, maxW: number): string;
96
+ export declare function roundRect(g: SKRSContext2D, x: number, y: number, w: number, h: number, r: number): void;
97
+ /** Card padding and title size scale with the panel: a 320×240 can't afford 16 px margins. */
98
+ export declare function chromeMetrics(theme: Theme, screenH: number, rect: PxRect): {
99
+ pad: number;
100
+ title: number;
101
+ titleBar: number;
102
+ };
103
+ /** Rendered tiles kept between frames of one screen, so a frame only repaints what changed. */
104
+ export type TileCache = Map<string, {
105
+ key: string;
106
+ refs: unknown[];
107
+ canvas: Canvas;
108
+ error?: string;
109
+ }>;
110
+ /**
111
+ * Where a widget paints inside its tile: `outer` is the area left by the
112
+ * card's padding and title, and `box` is what the widget sees: the same area
113
+ * `zoom` times smaller when zoomed (it keeps its top-left corner).
114
+ */
115
+ export declare function tileBoxes(theme: Theme, screenH: number, inst: WidgetInstance, rect: PxRect, chrome: 'card' | 'bare'): {
116
+ outer: Box;
117
+ box: Box;
118
+ zoom: number;
119
+ };
120
+ /** Paints `page` on a new canvas of the screen size. */
121
+ export declare function composePage(input: ComposeInput): ComposeResult;
122
+ /** A canvas rotated for a panel mounted at `rotate` degrees. */
123
+ export declare function rotateCanvas(src: Canvas, rotate: number): Canvas;
124
+ /** Renders a single widget alone (gallery thumbnails, plugin tests), with demo data unless given. */
125
+ export declare function composeWidget(o: {
126
+ cfg: Config;
127
+ registry: Registry;
128
+ theme: Theme;
129
+ type: string;
130
+ width: number;
131
+ height: number;
132
+ options?: Record<string, unknown>;
133
+ title?: string;
134
+ data?: unknown;
135
+ sources?: Record<string, unknown>;
136
+ now?: number;
137
+ chrome?: Tile['chrome'];
138
+ /** The instance's zoom (see `WidgetInstance.zoom`). */
139
+ zoom?: number;
140
+ }): ComposeResult;
@@ -0,0 +1,322 @@
1
+ /**
2
+ * Paints one page of a screen: background, then every tile with its card
3
+ * and the widget whose turn it is. Pure function of its input (plus the
4
+ * widgets' own code), so the panel, the editor preview and the gallery all
5
+ * go through here and can't disagree.
6
+ */
7
+ import { createCanvas, loadImage } from '@napi-rs/canvas';
8
+ import { pixelShift } from '@loksly/lcd-canvas-kit';
9
+ import { localize, validate } from '@kiosko/sdk';
10
+ import { toPx } from "./layout/grid.js";
11
+ import { nextTurnAt, pickInStack } from "./layout/stack.js";
12
+ import { whenMatches, zoomOf } from "./config/model.js";
13
+ import { CANVAS, baseContext, kit, resolveLocale } from "./env.js";
14
+ import { registerFonts } from "./fonts.js";
15
+ const images = new Map();
16
+ /** Background images are loaded once, in the background; the first frames go without. */
17
+ function backgroundImage(path) {
18
+ if (!images.has(path)) {
19
+ images.set(path, null);
20
+ loadImage(path).then((img) => images.set(path, img)).catch(() => undefined);
21
+ }
22
+ return images.get(path) ?? null;
23
+ }
24
+ function resolve(input, inst) {
25
+ const def = input.registry.widget(inst.type);
26
+ if (!def)
27
+ return null;
28
+ let options = def.options ? validate(def.options, inst.options).value : {};
29
+ let data = input.dataOf(inst);
30
+ let sources = input.sourcesOf(def.sources);
31
+ if (input.demo && def.demo) {
32
+ const needData = data === undefined;
33
+ const needSources = (def.sources ?? []).some((s) => sources[s] === undefined);
34
+ if (needData || needSources || input.demoOptions) {
35
+ try {
36
+ const d = def.demo({ options, now: input.now, locale: resolveLocale(input.cfg) });
37
+ if (input.demoOptions && d.options && def.options)
38
+ options = validate(def.options, { ...d.options, ...inst.options }).value;
39
+ if (needData)
40
+ data = d.data;
41
+ if (needSources)
42
+ sources = { ...(d.sources ?? {}), ...Object.fromEntries(Object.entries(sources).filter(([, v]) => v !== undefined)) };
43
+ }
44
+ catch {
45
+ /* a broken demo shouldn't break the editor */
46
+ }
47
+ }
48
+ }
49
+ return { inst, def, options, data, sources };
50
+ }
51
+ function stateCtx(r, now, working, cfg) {
52
+ return { options: r.options, data: r.data, sources: r.sources, now, working, workday: cfg.workday, ...(cfg.location ? { location: cfg.location } : {}) };
53
+ }
54
+ /**
55
+ * The instance's switches over the widget's hooks: `takeOver` (keep the tile
56
+ * while urgent) and `hideWhenEmpty` (skip it in the rotation while `empty`).
57
+ * Each defaults to what the widget declares.
58
+ */
59
+ export function tileState(def, inst, state) {
60
+ const takeOver = inst.takeOver ?? def.takeOver ?? true;
61
+ const hideEmpty = inst.hideWhenEmpty ?? def.hideWhenEmpty ?? false;
62
+ const visible = safe(() => def.visible?.(state) ?? true, true) && !(hideEmpty && def.empty && safe(() => def.empty(state), false));
63
+ const urgent = takeOver && safe(() => def.urgent?.(state) ?? false, false);
64
+ return { visible, urgent };
65
+ }
66
+ function safe(fn, fallback) {
67
+ if (!fn)
68
+ return fallback;
69
+ try {
70
+ return fn();
71
+ }
72
+ catch {
73
+ return fallback;
74
+ }
75
+ }
76
+ /** How often a widget wants to be repainted on its own, in ms (0 = only when its data changes). */
77
+ function redrawStep(def, state) {
78
+ const r = (typeof def.redraw === 'function' ? safe(() => def.redraw(state), 'never') : def.redraw) ?? 'never';
79
+ if (r === 'never')
80
+ return 0;
81
+ return r === 'second' ? 1000 : r === 'minute' ? 60_000 : Math.max(250, r.everyMs);
82
+ }
83
+ /** Cuts `text` with an ellipsis to fit `maxW` with the current font (plain fillText, no tracking). */
84
+ export function fitText(g, text, maxW) {
85
+ if (g.measureText(text).width <= maxW)
86
+ return text;
87
+ const chars = [...text];
88
+ let lo = 0;
89
+ let hi = chars.length;
90
+ while (lo < hi) {
91
+ const mid = Math.ceil((lo + hi) / 2);
92
+ if (g.measureText(`${chars.slice(0, mid).join('').trimEnd()}…`).width <= maxW)
93
+ lo = mid;
94
+ else
95
+ hi = mid - 1;
96
+ }
97
+ return lo > 0 ? `${chars.slice(0, lo).join('').trimEnd()}…` : '…';
98
+ }
99
+ export function roundRect(g, x, y, w, h, r) {
100
+ g.beginPath();
101
+ g.roundRect(x, y, w, h, Math.max(0, Math.min(r, w / 2, h / 2)));
102
+ }
103
+ /** Card padding and title size scale with the panel: a 320×240 can't afford 16 px margins. */
104
+ export function chromeMetrics(theme, screenH, rect) {
105
+ const scale = Math.max(0.45, Math.min(1, screenH / 462, rect.h / 180));
106
+ const pad = Math.round(16 * scale);
107
+ const title = Math.round(theme.titleSize * scale);
108
+ return { pad, title, titleBar: Math.round(title * 1.55) };
109
+ }
110
+ function drawCard(g, rect, theme) {
111
+ roundRect(g, rect.x, rect.y, rect.w, rect.h, theme.radius);
112
+ g.fillStyle = theme.colors.surface;
113
+ g.fill();
114
+ g.strokeStyle = theme.colors.surfaceEdge;
115
+ g.lineWidth = 1;
116
+ roundRect(g, rect.x + 0.5, rect.y + 0.5, rect.w - 1, rect.h - 1, theme.radius);
117
+ g.stroke();
118
+ }
119
+ function paintTile(g, input, tile, rect, out, nextAt, idles, animatable) {
120
+ const { theme, now, working } = input;
121
+ const candidates = tile.widgets
122
+ .filter((w) => !w.disabled && whenMatches(w.when, working))
123
+ .map((w) => resolve(input, w))
124
+ .filter((r) => !!r);
125
+ const entries = candidates.map((r) => ({ id: r.inst.id, ...tileState(r.def, r.inst, stateCtx(r, now, working, input.cfg)) }));
126
+ const forced = input.forceShown?.[tile.id];
127
+ const shownId = forced && candidates.some((c) => c.inst.id === forced) ? forced : pickInStack(entries, now, tile.rotateMs);
128
+ if (entries.filter((e) => e.visible).length > 1 && !forced)
129
+ nextAt.v = Math.min(nextAt.v, nextTurnAt(now, tile.rotateMs));
130
+ out.shown = shownId;
131
+ const r = candidates.find((c) => c.inst.id === shownId);
132
+ const chrome = tile.chrome === 'auto' ? (r?.def.chrome ?? 'card') : tile.chrome;
133
+ if (!r) {
134
+ if (chrome === 'card')
135
+ drawCard(g, rect, theme);
136
+ // An empty tile is invisible on the panel; in the editor it's a hint.
137
+ if (input.preview && !tile.widgets.length) {
138
+ g.setLineDash([6, 6]);
139
+ g.strokeStyle = theme.colors.inkFaint;
140
+ roundRect(g, rect.x + 1, rect.y + 1, rect.w - 2, rect.h - 2, theme.radius);
141
+ g.stroke();
142
+ g.setLineDash([]);
143
+ }
144
+ return;
145
+ }
146
+ const state = stateCtx(r, now, working, input.cfg);
147
+ idles.push(safe(() => r.def.idle?.(state) ?? false, false));
148
+ const step = redrawStep(r.def, state);
149
+ if (step)
150
+ nextAt.v = Math.min(nextAt.v, (Math.floor(now / step) + 1) * step);
151
+ const animation = r.def.animation;
152
+ if (animation && !input.preview) {
153
+ animatable.push({
154
+ tileId: tile.id,
155
+ instanceId: r.inst.id,
156
+ type: r.inst.type,
157
+ def: r.def,
158
+ data: r.data,
159
+ sources: r.sources,
160
+ ended: (plan, completed) => {
161
+ const [plugin] = r.inst.type.split('/');
162
+ safe(() => r.def.animationEnded?.({ ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), input.now), options: r.options, plan, completed, data: r.data }), undefined);
163
+ },
164
+ ask: (trigger, rnd, prev) => safe(() => animation({ ...state, box: tileBoxes(theme, input.screen.height, r.inst, rect, chrome).box, trigger, rnd, ...(prev !== undefined ? { prev } : {}) }), null),
165
+ });
166
+ }
167
+ // The tile whose animation is being painted: this frame, never from or into the cache.
168
+ const frame = input.animate?.find((a) => a.tileId === tile.id && a.instanceId === r.inst.id)?.frame;
169
+ const draw = (tg) => drawTileContent(tg, input, r, rect, chrome, out, frame);
170
+ if (frame || !input.cache) {
171
+ draw(g);
172
+ return;
173
+ }
174
+ // Same widget, options, size, data and sources (by reference: they're
175
+ // replaced, never mutated, when they change) and same redraw slot: reuse.
176
+ const cfg = input.cfg;
177
+ const key = JSON.stringify([r.inst.id, r.inst.type, r.inst.options, r.inst.title ?? '', zoomOf(r.inst), chrome, rect, theme.id, step ? Math.floor(now / step) : 0, cfg.locale, cfg.timeFormat, cfg.workday, cfg.location ?? null, working]);
178
+ const refs = [r.data, ...(r.def.sources ?? []).map((s) => r.sources[s])];
179
+ const hit = input.cache.get(tile.id);
180
+ if (hit && hit.key === key && hit.refs.length === refs.length && hit.refs.every((x, i) => x === refs[i])) {
181
+ g.drawImage(hit.canvas, rect.x, rect.y);
182
+ if (hit.error)
183
+ out.error = hit.error;
184
+ return;
185
+ }
186
+ const canvas = createCanvas(rect.w, rect.h);
187
+ const tg = canvas.getContext('2d');
188
+ tg.translate(-rect.x, -rect.y);
189
+ draw(tg);
190
+ g.drawImage(canvas, rect.x, rect.y);
191
+ input.cache.set(tile.id, { key, refs, canvas, ...(out.error ? { error: out.error } : {}) });
192
+ }
193
+ /**
194
+ * Where a widget paints inside its tile: `outer` is the area left by the
195
+ * card's padding and title, and `box` is what the widget sees: the same area
196
+ * `zoom` times smaller when zoomed (it keeps its top-left corner).
197
+ */
198
+ export function tileBoxes(theme, screenH, inst, rect, chrome) {
199
+ const m = chromeMetrics(theme, screenH, rect);
200
+ const pad = chrome === 'card' ? m.pad : 0;
201
+ const top = rect.y + pad + (inst.title?.trim() && chrome === 'card' ? m.titleBar : 0);
202
+ const outer = { x: rect.x + pad, y: top, w: Math.max(1, rect.w - 2 * pad), h: Math.max(1, rect.y + rect.h - pad - top) };
203
+ // Zoom: the widget gets a box `zoom` times smaller and is painted `zoom`
204
+ // times bigger, so it fills the same space with bigger (or smaller) type.
205
+ // Its box keeps its top-left corner, so widgets see nothing unusual.
206
+ const zoom = zoomOf(inst);
207
+ const box = zoom === 1 ? outer : { x: outer.x, y: outer.y, w: Math.max(1, outer.w / zoom), h: Math.max(1, outer.h / zoom) };
208
+ return { outer, box, zoom };
209
+ }
210
+ function drawTileContent(g, input, r, rect, chrome, out, anim) {
211
+ const { theme, now, working } = input;
212
+ const m = chromeMetrics(theme, input.screen.height, rect);
213
+ if (chrome === 'card')
214
+ drawCard(g, rect, theme);
215
+ const pad = chrome === 'card' ? m.pad : 0;
216
+ const title = r.inst.title?.trim();
217
+ if (title && chrome === 'card') {
218
+ g.font = `${m.title}px "${theme.fonts.sans}"`;
219
+ g.fillStyle = theme.colors.inkDim;
220
+ g.textBaseline = 'alphabetic';
221
+ g.fillText(fitText(g, title, rect.w - 2 * pad), rect.x + pad, rect.y + pad + m.title * 0.8);
222
+ }
223
+ const { outer, box, zoom } = tileBoxes(theme, input.screen.height, r.inst, rect, chrome);
224
+ const [plugin] = r.inst.type.split('/');
225
+ const ctx = {
226
+ ...baseContext(input.cfg, plugin, input.log ?? (() => undefined), now),
227
+ ...stateCtx(r, now, working, input.cfg),
228
+ box,
229
+ theme,
230
+ fonts: theme.fonts,
231
+ kit,
232
+ canvas: CANVAS,
233
+ preview: !!input.preview,
234
+ ...(anim ? { anim, frame: anim.i } : {}),
235
+ };
236
+ g.save();
237
+ roundRect(g, rect.x, rect.y, rect.w, rect.h, chrome === 'card' ? theme.radius : 0);
238
+ g.clip();
239
+ if (zoom !== 1) {
240
+ g.translate(outer.x * (1 - zoom), outer.y * (1 - zoom));
241
+ g.scale(zoom, zoom);
242
+ }
243
+ try {
244
+ r.def.render(g, ctx);
245
+ }
246
+ catch (err) {
247
+ out.error = err instanceof Error ? err.message : String(err);
248
+ input.log?.(r.inst.type, `render failed: ${out.error}`);
249
+ const size = Math.max(12, Math.min(20, box.h / 4));
250
+ kit.drawTracked(g, kit.truncate(g, `⚠ ${localize(r.def.name, ctx.locale)}: ${out.error}`, box.w, { size, family: theme.fonts.mono }), box.x, box.y + size * 1.2, { size, family: theme.fonts.mono, color: theme.colors.alert });
251
+ }
252
+ g.restore();
253
+ }
254
+ /** Paints `page` on a new canvas of the screen size. */
255
+ export function composePage(input) {
256
+ registerFonts();
257
+ const { screen, page, theme, now } = input;
258
+ const canvas = createCanvas(screen.width, screen.height);
259
+ const g = canvas.getContext('2d');
260
+ g.fillStyle = page.background.color || theme.colors.bg;
261
+ g.fillRect(0, 0, screen.width, screen.height);
262
+ if (page.background.image) {
263
+ const img = backgroundImage(page.background.image);
264
+ if (img) {
265
+ const s = Math.max(screen.width / img.width, screen.height / img.height);
266
+ g.drawImage(img, (screen.width - img.width * s) / 2, (screen.height - img.height * s) / 2, img.width * s, img.height * s);
267
+ }
268
+ }
269
+ const shift = input.noShift || input.preview ? { x: 0, y: 0 } : pixelShift(now, screen.pixelShift);
270
+ g.save();
271
+ g.translate(shift.x, shift.y);
272
+ const nextAt = { v: Infinity };
273
+ const idles = [];
274
+ const tiles = [];
275
+ const animatable = [];
276
+ for (const tile of page.tiles) {
277
+ const rect = toPx(tile, page.grid, screen.width, screen.height);
278
+ const out = { id: tile.id, rect, shown: null };
279
+ tiles.push(out);
280
+ paintTile(g, input, tile, rect, out, nextAt, idles, animatable);
281
+ }
282
+ if (input.cache)
283
+ for (const id of input.cache.keys())
284
+ if (!page.tiles.some((t) => t.id === id))
285
+ input.cache.delete(id);
286
+ g.restore();
287
+ if (screen.pixelShift.enabled && !input.preview)
288
+ nextAt.v = Math.min(nextAt.v, (Math.floor(now / screen.pixelShift.periodMs) + 1) * screen.pixelShift.periodMs);
289
+ return { canvas, tiles, idle: idles.length > 0 && idles.every(Boolean), nextAt: nextAt.v, animatable };
290
+ }
291
+ /** A canvas rotated for a panel mounted at `rotate` degrees. */
292
+ export function rotateCanvas(src, rotate) {
293
+ if (!rotate)
294
+ return src;
295
+ const swap = rotate === 90 || rotate === 270;
296
+ const out = createCanvas(swap ? src.height : src.width, swap ? src.width : src.height);
297
+ const g = out.getContext('2d');
298
+ g.translate(out.width / 2, out.height / 2);
299
+ g.rotate((rotate * Math.PI) / 180);
300
+ g.drawImage(src, -src.width / 2, -src.height / 2);
301
+ return out;
302
+ }
303
+ /** Renders a single widget alone (gallery thumbnails, plugin tests), with demo data unless given. */
304
+ export function composeWidget(o) {
305
+ const inst = { id: 'w', type: o.type, options: o.options ?? {}, ...(o.title ? { title: o.title } : {}), ...(o.zoom !== undefined && o.zoom !== 1 ? { zoom: o.zoom } : {}) };
306
+ const page = { id: 'p', name: 'p', grid: { cols: 1, rows: 1, gap: 0, padding: 0 }, gravity: 'none', background: { color: o.theme.colors.bg }, tiles: [{ id: 't', x: 0, y: 0, w: 1, h: 1, widgets: [inst], rotateMs: 0, chrome: o.chrome ?? 'auto' }] };
307
+ return composePage({
308
+ cfg: o.cfg,
309
+ screen: { width: o.width, height: o.height, pixelShift: { enabled: false, amplitude: 0, periodMs: 1 } },
310
+ page,
311
+ theme: o.theme,
312
+ registry: o.registry,
313
+ now: o.now ?? Date.now(),
314
+ working: true,
315
+ dataOf: () => o.data,
316
+ sourcesOf: (types) => Object.fromEntries((types ?? []).map((t) => [t, o.sources?.[t]])),
317
+ preview: true,
318
+ demo: o.data === undefined,
319
+ demoOptions: !o.options || !Object.keys(o.options).length,
320
+ noShift: true,
321
+ });
322
+ }
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Validating a config: against its JSON Schema (shape, types, known widget
3
+ * types and option values) and against what `normalize` makes of it (ids,
4
+ * tiles that fit the grid and don't overlap, sizes widgets need, option
5
+ * names no plugin knows). Errors make Kiosko misbehave or lose something;
6
+ * warnings are worth a look but harmless.
7
+ *
8
+ * The messages carry a JSON path and say what to change, so a user can paste
9
+ * them back to the AI that wrote the file. Pure (no Node).
10
+ */
11
+ import type { PluginDefinition } from '@kiosko/sdk';
12
+ import { type JsonIssue, type JsonSchema } from './jsonschema.ts';
13
+ export interface ConfigReport {
14
+ errors: JsonIssue[];
15
+ warnings: JsonIssue[];
16
+ }
17
+ /** What the schema can't say: checked on `normalize(raw)`, with paths into `raw` (normalize keeps every list's order). */
18
+ export declare function checkConfig(raw: unknown, plugins: readonly PluginDefinition[]): ConfigReport;
19
+ /** Schema errors, then what only `normalize` reveals. `schema` defaults to the one of `plugins`. */
20
+ export declare function validateConfig(raw: unknown, plugins: readonly PluginDefinition[], schema?: JsonSchema): ConfigReport;