@iyulab/u-board-designer 0.0.0-stage → 0.2.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.
Files changed (82) hide show
  1. package/LICENSE +661 -0
  2. package/README.md +127 -2
  3. package/dist/lib/authoring/AnchorFields.d.ts +12 -0
  4. package/dist/lib/authoring/AnchorFields.js +64 -0
  5. package/dist/lib/authoring/BackgroundField.d.ts +13 -0
  6. package/dist/lib/authoring/BackgroundField.js +38 -0
  7. package/dist/lib/authoring/BoardAuthoring.d.ts +37 -0
  8. package/dist/lib/authoring/BoardAuthoring.js +288 -0
  9. package/dist/lib/authoring/BoardElementsPanel.d.ts +14 -0
  10. package/dist/lib/authoring/BoardElementsPanel.js +49 -0
  11. package/dist/lib/authoring/BoardPanel.d.ts +40 -0
  12. package/dist/lib/authoring/BoardPanel.js +22 -0
  13. package/dist/lib/authoring/CanvasAuthoring.d.ts +13 -0
  14. package/dist/lib/authoring/CanvasAuthoring.js +161 -0
  15. package/dist/lib/authoring/DataPanel.d.ts +29 -0
  16. package/dist/lib/authoring/DataPanel.js +98 -0
  17. package/dist/lib/authoring/GridAuthoring.d.ts +13 -0
  18. package/dist/lib/authoring/GridAuthoring.js +135 -0
  19. package/dist/lib/authoring/GridItemPanel.d.ts +22 -0
  20. package/dist/lib/authoring/GridItemPanel.js +21 -0
  21. package/dist/lib/authoring/LinkPanel.d.ts +19 -0
  22. package/dist/lib/authoring/LinkPanel.js +24 -0
  23. package/dist/lib/authoring/MappingEditor.d.ts +23 -0
  24. package/dist/lib/authoring/MappingEditor.js +83 -0
  25. package/dist/lib/authoring/PartPanel.d.ts +24 -0
  26. package/dist/lib/authoring/PartPanel.js +78 -0
  27. package/dist/lib/authoring/ShapePanel.d.ts +20 -0
  28. package/dist/lib/authoring/ShapePanel.js +54 -0
  29. package/dist/lib/authoring/StatesEditor.d.ts +18 -0
  30. package/dist/lib/authoring/StatesEditor.js +79 -0
  31. package/dist/lib/authoring/StatusesPanel.d.ts +14 -0
  32. package/dist/lib/authoring/StatusesPanel.js +32 -0
  33. package/dist/lib/authoring/TriggersEditor.d.ts +30 -0
  34. package/dist/lib/authoring/TriggersEditor.js +137 -0
  35. package/dist/lib/authoring/ValueMapFields.d.ts +64 -0
  36. package/dist/lib/authoring/ValueMapFields.js +68 -0
  37. package/dist/lib/authoring/WidgetPropsForm.d.ts +27 -0
  38. package/dist/lib/authoring/WidgetPropsForm.js +56 -0
  39. package/dist/lib/authoring/background-image.d.ts +25 -0
  40. package/dist/lib/authoring/background-image.js +63 -0
  41. package/dist/lib/authoring/board-layout.d.ts +64 -0
  42. package/dist/lib/authoring/board-layout.js +326 -0
  43. package/dist/lib/authoring/board-triggers.d.ts +5 -0
  44. package/dist/lib/authoring/board-triggers.js +55 -0
  45. package/dist/lib/authoring/document-history.d.ts +23 -0
  46. package/dist/lib/authoring/document-history.js +37 -0
  47. package/dist/lib/authoring/editor-parts.d.ts +46 -0
  48. package/dist/lib/authoring/editor-parts.js +6 -0
  49. package/dist/lib/authoring/field-style.d.ts +3 -0
  50. package/dist/lib/authoring/field-style.js +2 -0
  51. package/dist/lib/authoring/part-names.d.ts +6 -0
  52. package/dist/lib/authoring/part-names.js +9 -0
  53. package/dist/lib/authoring/scene-mapping.d.ts +22 -0
  54. package/dist/lib/authoring/scene-mapping.js +124 -0
  55. package/dist/lib/authoring/use-edit-keys.d.ts +13 -0
  56. package/dist/lib/authoring/use-edit-keys.js +57 -0
  57. package/dist/lib/authoring/widget-catalog.d.ts +4 -0
  58. package/dist/lib/authoring/widget-catalog.js +9 -0
  59. package/dist/lib/authoring/widget-fields.d.ts +45 -0
  60. package/dist/lib/authoring/widget-fields.js +129 -0
  61. package/dist/lib/board/check.d.ts +27 -0
  62. package/dist/lib/board/check.js +48 -0
  63. package/dist/lib/board/rulebook.d.ts +7 -0
  64. package/dist/lib/board/rulebook.js +148 -0
  65. package/dist/lib/check-entry.d.ts +3 -0
  66. package/dist/lib/check-entry.js +4 -0
  67. package/dist/lib/editing/grid-editing.d.ts +19 -0
  68. package/dist/lib/editing/grid-editing.js +68 -0
  69. package/dist/lib/editing/part-removal.d.ts +4 -0
  70. package/dist/lib/editing/part-removal.js +11 -0
  71. package/dist/lib/editing/trigger-editing.d.ts +16 -0
  72. package/dist/lib/editing/trigger-editing.js +36 -0
  73. package/dist/lib/index.d.ts +7 -0
  74. package/dist/lib/index.js +5 -0
  75. package/dist/lib/labels-ko.d.ts +4 -0
  76. package/dist/lib/labels-ko.js +270 -0
  77. package/dist/lib/labels.d.ts +368 -0
  78. package/dist/lib/labels.js +273 -0
  79. package/dist/lib/use-labels.d.ts +7 -0
  80. package/dist/lib/use-labels.js +12 -0
  81. package/package.json +75 -4
  82. package/styles.css +277 -0
@@ -0,0 +1,124 @@
1
+ import { Scene } from '@canvas-kit/core';
2
+ import { mayLoadBackground, sizedNodes } from '@iyulab/u-board/toolkit';
3
+ import { decorationObjects } from '@iyulab/u-board/toolkit';
4
+ import { captionObjects, connectorObjects } from '@iyulab/u-board/toolkit';
5
+ /** The stroke a rect decoration is drawn with in the designer when the document does not set its own — distinct
6
+ * from a node placeholder's blue (`NODE_PLACEHOLDER_STROKE` below) so a structural frame reads apart from a widget
7
+ * footprint at a glance. */
8
+ const DECORATION_STROKE = '#f59e0b';
9
+ /** A node's placeholder fill/stroke in the designer canvas — the box being positioned, not the
10
+ * widget itself. The real widget renders in the live preview pane (`resolveDocument` +
11
+ * `toCanvasKit`), which is the render path the renderer/editor separation already owns; the
12
+ * designer only needs a draggable, identifiable footprint (docs/principles.md). */
13
+ const NODE_PLACEHOLDER_FILL = 'rgba(59, 130, 246, 0.15)';
14
+ const NODE_PLACEHOLDER_STROKE = '#3b82f6';
15
+ /** A rect decoration's placeholder fill in the designer canvas only — Konva hit-tests a shape by
16
+ * what it actually draws, so an unfilled rect (the usual authored look for a "frame" that
17
+ * shouldn't obscure the nodes it groups) is only clickable along its thin stroke line, not its
18
+ * interior. This translucent fill exists purely to make the whole footprint selectable/draggable
19
+ * in the designer; it is never written back to the document (`applySceneToDocument` below only
20
+ * folds back position/size) — the live preview pane and the real renderer still draw the
21
+ * decoration's own authored `fill` (or none), same as `NODE_PLACEHOLDER_FILL` never leaks into a
22
+ * node's actual widget appearance. */
23
+ const DECORATION_PLACEHOLDER_FILL = 'rgba(245, 158, 11, 0.08)';
24
+ /**
25
+ * Projects a ViewDocument into a canvas-kit Scene the designer can render and let the author
26
+ * drag: the background image (locked, drawn but not editable), the connectors (fixed: selected to edit
27
+ * them, never dragged — they follow their nodes), and one `rect` per node, id'd by node id, standing in
28
+ * for its widget's footprint. A scene object's id names its document part, after a `#` for a part's
29
+ * pieces (`partOf`).
30
+ */
31
+ export function documentToScene(doc, movable, selectable) {
32
+ const scene = new Scene();
33
+ // Only the nodes `movable` names — and the connectors and decorations `selectable` names — when a layout is all that
34
+ // is edited; else every part of the board's own.
35
+ const own = new Set((movable
36
+ ? [...doc.nodes.filter(node => movable(node.id)), ...[...doc.connectors, ...(doc.decorations ?? [])].filter(piece => selectable?.(piece.id))]
37
+ : [...doc.nodes, ...doc.connectors, ...(doc.decorations ?? [])]).map(part => part.id));
38
+ // A part not edited here, and every piece of it: drawn, never selected or moved.
39
+ const add = (object) => scene.add(object.id && !own.has(partOf(object.id)) ? { ...object, locked: true } : object);
40
+ // A picture outside the origins the host allowed is not requested here either: the board is drawn on an empty background.
41
+ if (doc.background.image && mayLoadBackground(doc.background.image.src)) {
42
+ add({
43
+ type: 'image',
44
+ x: 0,
45
+ y: 0,
46
+ width: doc.background.image.width,
47
+ height: doc.background.image.height,
48
+ src: doc.background.image.src,
49
+ // The picture the board is built on: drawn under the nodes, never grabbed and moved with them.
50
+ locked: true,
51
+ });
52
+ }
53
+ for (const shape of doc.decorations ?? []) {
54
+ const [drawn, ...name] = decorationObjects(shape, doc.appearance, doc.accent);
55
+ if (drawn.type === 'rect') {
56
+ // Konva's Text hit-tests its full bounding box regardless of fill, so a text decoration is
57
+ // added as drawn below; a rect decoration needs the designer-only placeholder fill above to
58
+ // stay clickable across its interior, not just its stroke.
59
+ add({ ...drawn, fill: DECORATION_PLACEHOLDER_FILL, stroke: drawn.stroke ?? DECORATION_STROKE });
60
+ // Its name follows the frame — drawn again where the frame lands — and a press on it reaches the frame.
61
+ for (const object of name)
62
+ add({ ...object, locked: true });
63
+ }
64
+ else {
65
+ add(drawn);
66
+ }
67
+ }
68
+ // Follows its two nodes: selected to be edited, never dragged — moving a line or its label would move nothing.
69
+ const nodes = sizedNodes(doc);
70
+ for (const object of connectorObjects(nodes, doc.connectors, doc.appearance, { fixed: true }, doc.accent))
71
+ add(object);
72
+ for (const object of captionObjects(nodes, doc.appearance, true))
73
+ add(object);
74
+ for (const node of nodes) {
75
+ add({
76
+ type: 'rect',
77
+ id: node.id,
78
+ x: node.x,
79
+ y: node.y,
80
+ width: node.width,
81
+ height: node.height,
82
+ fill: NODE_PLACEHOLDER_FILL,
83
+ stroke: NODE_PLACEHOLDER_STROKE,
84
+ strokeWidth: 1,
85
+ });
86
+ }
87
+ return scene;
88
+ }
89
+ /**
90
+ * Folds a designer Scene's node-rect and decoration-shape positions back into a ViewDocument.
91
+ * Reads every scene object whose id matches a node or decoration — not just the one the author
92
+ * just dragged — so it stays correct regardless of how the designer reports the change (single
93
+ * move, batched drag, resize). A node and a decoration can both be scene `rect`s with the same
94
+ * shape; they're told apart by id membership in `doc.nodes`/`doc.decorations`, not by scanning
95
+ * order, so a decoration id never gets folded onto a node or vice versa.
96
+ */
97
+ export function applySceneToDocument(doc, scene) {
98
+ const objectsById = new Map(scene.getObjects().filter(obj => !!obj.id).map(obj => [obj.id, obj]));
99
+ const nodes = doc.nodes.map(node => {
100
+ const obj = objectsById.get(node.id);
101
+ if (!obj || obj.type !== 'rect')
102
+ return node;
103
+ return { ...node, x: obj.x, y: obj.y, width: obj.width, height: obj.height };
104
+ });
105
+ const decorations = doc.decorations?.map((shape) => {
106
+ const obj = objectsById.get(shape.id);
107
+ if (!obj)
108
+ return shape;
109
+ if (shape.type === 'rect' && obj.type === 'rect') {
110
+ return { ...shape, x: obj.x, y: obj.y, width: obj.width, height: obj.height };
111
+ }
112
+ if (shape.type === 'text' && obj.type === 'text') {
113
+ return { ...shape, x: obj.x, y: obj.y };
114
+ }
115
+ return shape;
116
+ });
117
+ return { ...doc, nodes, ...(decorations ? { decorations } : {}) };
118
+ }
119
+ /** The document part a scene object draws: its id, or a part's own id before the `#` of one of its pieces (a
120
+ * connector's `#chip`, `#arrow0`). */
121
+ export function partOf(objectId) {
122
+ const at = objectId.indexOf('#');
123
+ return at === -1 ? objectId : objectId.slice(0, at);
124
+ }
@@ -0,0 +1,13 @@
1
+ import { type Dispatch, type SetStateAction } from 'react';
2
+ import { type DocumentHistory } from './document-history.js';
3
+ /**
4
+ * An editor's keys and typing, heard anywhere in its root (its board, its panels): Ctrl+Z (⌘Z) undoes, Ctrl+Shift+Z or
5
+ * Ctrl+Y redoes — but in a field being typed into, the field's own undo — and focus moving on ends a run of typing.
6
+ * Returns the ref for the editor's root, and `typing`: the field an input is on its way through now — an edit made then
7
+ * is one with those typed in the same field before it (`recordEdit`'s `typingIn`), so it is read when the edit is made,
8
+ * not when its update runs.
9
+ */
10
+ export declare function useEditKeys<D>(setHistory: Dispatch<SetStateAction<DocumentHistory<D>>>, canUndo: boolean, canRedo: boolean): {
11
+ rootRef: import("react").RefObject<HTMLDivElement | null>;
12
+ typing: import("react").RefObject<HTMLElement | null>;
13
+ };
@@ -0,0 +1,57 @@
1
+ import { useEffect, useEffectEvent, useRef } from 'react';
2
+ import { endTyping, redo, textEntry, undo } from './document-history.js';
3
+ /**
4
+ * An editor's keys and typing, heard anywhere in its root (its board, its panels): Ctrl+Z (⌘Z) undoes, Ctrl+Shift+Z or
5
+ * Ctrl+Y redoes — but in a field being typed into, the field's own undo — and focus moving on ends a run of typing.
6
+ * Returns the ref for the editor's root, and `typing`: the field an input is on its way through now — an edit made then
7
+ * is one with those typed in the same field before it (`recordEdit`'s `typingIn`), so it is read when the edit is made,
8
+ * not when its update runs.
9
+ */
10
+ export function useEditKeys(setHistory, canUndo, canRedo) {
11
+ const rootRef = useRef(null);
12
+ const typing = useRef(null);
13
+ const handleKey = useEffectEvent((e) => {
14
+ if (!(e.ctrlKey || e.metaKey) || e.altKey || textEntry(e.target))
15
+ return;
16
+ const key = e.key.toLowerCase();
17
+ const back = key === 'z' && !e.shiftKey;
18
+ const forward = (key === 'z' && e.shiftKey) || (key === 'y' && !e.shiftKey);
19
+ if (!back && !forward)
20
+ return;
21
+ e.preventDefault();
22
+ if (back && canUndo)
23
+ setHistory(undo);
24
+ if (forward && canRedo)
25
+ setHistory(redo);
26
+ });
27
+ useEffect(() => {
28
+ const root = rootRef.current;
29
+ if (!root)
30
+ return;
31
+ const keyDown = (e) => handleKey(e);
32
+ const focusMoved = () => setHistory(endTyping);
33
+ // A field's input is caught on its way down, before the editor's handlers answer it, and let go once it has risen
34
+ // past them all (to the window).
35
+ const typed = (e) => {
36
+ typing.current = textEntry(e.target);
37
+ };
38
+ const answered = () => {
39
+ typing.current = null;
40
+ };
41
+ root.addEventListener('keydown', keyDown);
42
+ root.addEventListener('focusin', focusMoved);
43
+ for (const type of ['input', 'change']) {
44
+ root.addEventListener(type, typed, true);
45
+ window.addEventListener(type, answered);
46
+ }
47
+ return () => {
48
+ root.removeEventListener('keydown', keyDown);
49
+ root.removeEventListener('focusin', focusMoved);
50
+ for (const type of ['input', 'change']) {
51
+ root.removeEventListener(type, typed, true);
52
+ window.removeEventListener(type, answered);
53
+ }
54
+ };
55
+ }, [setHistory]);
56
+ return { rootRef, typing };
57
+ }
@@ -0,0 +1,4 @@
1
+ import '@iyulab/u-widgets/tools/locales/ko';
2
+ /** What a widget is called where the editor names it: a prepared widget's name from the widget library, in `locale`,
3
+ * or the name of the board's element it places. */
4
+ export declare function widgetTypeName(type: string, locale?: string): string;
@@ -0,0 +1,9 @@
1
+ import { getWidgetLabel } from '@iyulab/u-widgets/tools';
2
+ // The widget library's names in Korean, for `widgetTypeName` — authoring code only, as in widget-fields.ts.
3
+ import '@iyulab/u-widgets/tools/locales/ko';
4
+ import { elementNameOf } from '@iyulab/u-board/toolkit';
5
+ /** What a widget is called where the editor names it: a prepared widget's name from the widget library, in `locale`,
6
+ * or the name of the board's element it places. */
7
+ export function widgetTypeName(type, locale) {
8
+ return elementNameOf(type) ?? getWidgetLabel(type, locale);
9
+ }
@@ -0,0 +1,45 @@
1
+ import '@iyulab/u-widgets/tools/locales/ko';
2
+ import type { ElementDefinition, PropsSchema, Widget } from '@iyulab/u-board/picture';
3
+ /** A value of a widget's props the property panel offers its own control for. */
4
+ export interface WidgetField {
5
+ /** Which group it is shown in: a prepared widget's `props.data` or `props.options`, an element's own props, or a
6
+ * component instance's parameter values. */
7
+ section: 'data' | 'options' | 'element' | 'params';
8
+ /** Where it lives in the widget's props — also the path a binding that fills it names (`data.value`, `level`). */
9
+ path: readonly string[];
10
+ key: string;
11
+ /** Its name, in the requested language, from the widget library — or an element's `title`. */
12
+ label: string;
13
+ kind: 'text' | 'number' | 'boolean' | 'choice';
14
+ /** The values a `choice` field takes. */
15
+ choices?: readonly string[];
16
+ /** Bounds of a `number` field, and whether it takes whole numbers only. */
17
+ min?: number;
18
+ max?: number;
19
+ integer?: boolean;
20
+ /** What the widget uses when the value is left out, when the widget library says. */
21
+ defaultValue?: unknown;
22
+ /** What the widget library says the value is. */
23
+ description?: string;
24
+ }
25
+ export interface WidgetFields {
26
+ fields: WidgetField[];
27
+ /** Options the widget reads that have no control here (lists, objects) — edited as JSON instead. */
28
+ otherOptions: number;
29
+ }
30
+ /** The fields of an object schema — an element's props, a component's parameters: one per property it lists, in the
31
+ * schema's order; a property no control edits (a list, an object) is counted in `otherOptions`. */
32
+ export declare function schemaFields(schema: PropsSchema | undefined, section: WidgetField['section']): WidgetFields;
33
+ /**
34
+ * The fields of `widget` the panel can edit with a control. For a prepared widget, as the widget library describes
35
+ * them (`@iyulab/u-widgets/tools`): its known data fields — when `props.data` is a single object; data given as an
36
+ * array of rows is edited as JSON — and the options it reads whose value is a text, a number, a yes/no or a choice.
37
+ * For an element (`element:<name>`), the props its definition in `elements` lists. Anything else stays in the JSON
38
+ * editor. Names are in `locale` (the labels' language).
39
+ */
40
+ export declare function widgetFields(widget: Widget, locale?: string, elements?: Readonly<Record<string, ElementDefinition>>): WidgetFields;
41
+ /** The value of `field` in `widget`'s props, or `undefined` when it is not set. */
42
+ export declare function readField(widget: Widget, field: Pick<WidgetField, 'path'>): unknown;
43
+ /** `widget` with one field set — or removed, when `value` is `undefined`. Everything else in its props (mapping,
44
+ * other options, keys this panel does not know) is kept as it was; an `options` object left empty goes too. */
45
+ export declare function withField(widget: Widget, field: Pick<WidgetField, 'path'>, value: unknown): Widget;
@@ -0,0 +1,129 @@
1
+ import { getWidgetDataFields, getWidgetOptions } from '@iyulab/u-widgets/tools';
2
+ // The widget library's names in Korean — the language U-Board ships labels for besides English
3
+ // (`KO_LABELS`). Registered here, with the authoring code, so a viewer never loads them.
4
+ import '@iyulab/u-widgets/tools/locales/ko';
5
+ import { elementNameOf } from '@iyulab/u-board/toolkit';
6
+ /** The control for a value of `type` (in the widget library's notation), or none for a list or an object. */
7
+ function kindOf(type, choices) {
8
+ if (choices)
9
+ return { kind: 'choice', choices };
10
+ if (type === 'string')
11
+ return { kind: 'text' };
12
+ if (type === 'number')
13
+ return { kind: 'number' };
14
+ if (type === 'boolean')
15
+ return { kind: 'boolean' };
16
+ return undefined;
17
+ }
18
+ const isRecord = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
19
+ /** The control for a prop an element's schema describes, or none for one edited as JSON (a list, an object, a
20
+ * value of several types, a choice that is not all text). */
21
+ function schemaKind(schema) {
22
+ if (schema.enum !== undefined) {
23
+ return schema.enum.every(value => typeof value === 'string') ? { kind: 'choice', choices: schema.enum } : undefined;
24
+ }
25
+ switch (schema.type) {
26
+ case 'string':
27
+ return { kind: 'text' };
28
+ case 'number':
29
+ case 'integer':
30
+ return {
31
+ kind: 'number',
32
+ ...(schema.minimum !== undefined && { min: schema.minimum }),
33
+ ...(schema.maximum !== undefined && { max: schema.maximum }),
34
+ ...(schema.type === 'integer' && { integer: true }),
35
+ };
36
+ case 'boolean':
37
+ return { kind: 'boolean' };
38
+ default:
39
+ return undefined;
40
+ }
41
+ }
42
+ /** The fields of an object schema — an element's props, a component's parameters: one per property it lists, in the
43
+ * schema's order; a property no control edits (a list, an object) is counted in `otherOptions`. */
44
+ export function schemaFields(schema, section) {
45
+ const fields = [];
46
+ let otherOptions = 0;
47
+ for (const [key, property] of Object.entries(schema?.properties ?? {})) {
48
+ const kind = schemaKind(property);
49
+ if (!kind) {
50
+ otherOptions++;
51
+ continue;
52
+ }
53
+ fields.push({
54
+ section,
55
+ path: [key],
56
+ key,
57
+ label: property.title ?? key,
58
+ ...kind,
59
+ ...(property.default !== undefined && { defaultValue: property.default }),
60
+ ...(property.description !== undefined && { description: property.description }),
61
+ });
62
+ }
63
+ return { fields, otherOptions };
64
+ }
65
+ /** The fields of a placed element: one per prop its definition's schema lists, in the schema's order. */
66
+ function elementFields(definition) {
67
+ return schemaFields(definition?.props, 'element');
68
+ }
69
+ /**
70
+ * The fields of `widget` the panel can edit with a control. For a prepared widget, as the widget library describes
71
+ * them (`@iyulab/u-widgets/tools`): its known data fields — when `props.data` is a single object; data given as an
72
+ * array of rows is edited as JSON — and the options it reads whose value is a text, a number, a yes/no or a choice.
73
+ * For an element (`element:<name>`), the props its definition in `elements` lists. Anything else stays in the JSON
74
+ * editor. Names are in `locale` (the labels' language).
75
+ */
76
+ export function widgetFields(widget, locale, elements) {
77
+ const element = elementNameOf(widget.type);
78
+ if (element !== undefined)
79
+ return elementFields(elements && Object.hasOwn(elements, element) ? elements[element] : undefined);
80
+ const props = isRecord(widget.props) ? widget.props : {};
81
+ const fields = [];
82
+ if (props.data === undefined || isRecord(props.data)) {
83
+ for (const field of getWidgetDataFields(widget.type, locale)) {
84
+ const kind = kindOf(field.type, field.enum);
85
+ if (kind)
86
+ fields.push({ section: 'data', path: ['data', field.key], key: field.key, label: field.label, ...kind, description: field.desc });
87
+ }
88
+ }
89
+ let otherOptions = 0;
90
+ for (const option of getWidgetOptions(widget.type, locale)) {
91
+ const kind = kindOf(option.type, option.enum);
92
+ if (!kind) {
93
+ otherOptions++;
94
+ continue;
95
+ }
96
+ fields.push({
97
+ section: 'options',
98
+ path: ['options', option.key],
99
+ key: option.key,
100
+ label: option.label,
101
+ ...kind,
102
+ ...(option.default !== undefined && { defaultValue: option.default }),
103
+ description: option.desc,
104
+ });
105
+ }
106
+ return { fields, otherOptions };
107
+ }
108
+ /** The value of `field` in `widget`'s props, or `undefined` when it is not set. */
109
+ export function readField(widget, field) {
110
+ let value = widget.props;
111
+ for (const segment of field.path)
112
+ value = isRecord(value) ? value[segment] : undefined;
113
+ return value;
114
+ }
115
+ /** `widget` with one field set — or removed, when `value` is `undefined`. Everything else in its props (mapping,
116
+ * other options, keys this panel does not know) is kept as it was; an `options` object left empty goes too. */
117
+ export function withField(widget, field, value) {
118
+ const set = (container, path) => {
119
+ const next = isRecord(container) ? { ...container } : {};
120
+ const [head, ...rest] = path;
121
+ const child = rest.length === 0 ? value : set(next[head], rest);
122
+ if (child === undefined || (head === 'options' && isRecord(child) && Object.keys(child).length === 0))
123
+ delete next[head];
124
+ else
125
+ next[head] = child;
126
+ return next;
127
+ };
128
+ return { ...widget, props: set(widget.props, field.path) };
129
+ }
@@ -0,0 +1,27 @@
1
+ import type { ViewKind } from '@iyulab/u-board/picture';
2
+ import type { Board } from '@iyulab/u-board/domain';
3
+ /** A problem or a piece of advice, at a JSON Pointer into the board. */
4
+ export interface BoardFinding {
5
+ path: string;
6
+ message: string;
7
+ }
8
+ /** What a board is refused for (`issues`), and — when there is nothing to refuse — what could be written better
9
+ * (`advice`). */
10
+ export interface BoardCheck {
11
+ issues: BoardFinding[];
12
+ advice: BoardFinding[];
13
+ }
14
+ /** A board with no parts yet, laying out `kind` — what a new board starts as. A canvas is 1280 × 720 until it is resized. */
15
+ export declare function emptyBoard(kind: ViewKind): Board;
16
+ export declare function checkBoard(board: unknown): BoardCheck;
17
+ /** A data source of the workspace a board is saved in, as a board names it: by its id. */
18
+ export interface KnownSource {
19
+ id: string;
20
+ name: string;
21
+ }
22
+ /**
23
+ * The data reads whose `from` names no data source of the workspace — read by a connector's request (`path`), so it is
24
+ * one of the workspace's sources or nothing reads it — each at its `from`, saying which id to write when it names a
25
+ * source by its name. Advice, not a refusal: a source may be removed after a board reads it, and the board still saves.
26
+ */
27
+ export declare function sourceAdvice(board: Board, sources: readonly KnownSource[]): BoardFinding[];
@@ -0,0 +1,48 @@
1
+ // The check a board meets before it is stored: its language (`validateBoard`), and
2
+ // then that it draws — a part the renderer cannot draw yet is refused when it is written, not on the page that shows it.
3
+ // The server's save and an assistant's tools check with this, so both refuse the same boards in the same words.
4
+ import { PictureError, toViewDocument } from '@iyulab/u-board/domain';
5
+ import { validateBoard } from '@iyulab/u-board/domain';
6
+ /** The items a part made per item stands for when a board is checked: none — a board draws before its data has any
7
+ * items, and the items it has later do not change what it is. */
8
+ const NO_ITEMS = { keysOf: () => [], rowsOf: () => [] };
9
+ /** A board with no parts yet, laying out `kind` — what a new board starts as. A canvas is 1280 × 720 until it is resized. */
10
+ export function emptyBoard(kind) {
11
+ return {
12
+ parts: {},
13
+ layout: kind === 'grid' ? { kind: 'grid', items: [] } : { kind: 'canvas', size: [1280, 720], place: {} },
14
+ };
15
+ }
16
+ export function checkBoard(board) {
17
+ const found = validateBoard(board);
18
+ const issues = found.filter(f => !f.hint).map(({ at, message }) => ({ path: at, message }));
19
+ if (issues.length === 0) {
20
+ try {
21
+ toViewDocument(board, NO_ITEMS);
22
+ }
23
+ catch (error) {
24
+ if (!(error instanceof PictureError))
25
+ throw error;
26
+ issues.push({ path: error.at, message: error.message.slice(error.at.length + 2) });
27
+ }
28
+ }
29
+ return { issues, advice: issues.length > 0 ? [] : found.filter(f => f.hint).map(({ at, message }) => ({ path: at, message })) };
30
+ }
31
+ /**
32
+ * The data reads whose `from` names no data source of the workspace — read by a connector's request (`path`), so it is
33
+ * one of the workspace's sources or nothing reads it — each at its `from`, saying which id to write when it names a
34
+ * source by its name. Advice, not a refusal: a source may be removed after a board reads it, and the board still saves.
35
+ */
36
+ export function sourceAdvice(board, sources) {
37
+ const ids = new Set(sources.map(s => s.id));
38
+ return Object.entries(board.data ?? {}).flatMap(([name, query]) => {
39
+ if (query.path === undefined || typeof query.from !== 'string' || ids.has(query.from))
40
+ return [];
41
+ const said = query.from.trim().toLowerCase();
42
+ const named = sources.filter(s => s.name.trim().toLowerCase() === said);
43
+ const message = named.length === 1
44
+ ? `"${query.from}" is the name of a data source — "from" takes its id: "${named[0].id}". Nothing is read until it does`
45
+ : `"${query.from}" is no data source of this workspace — "from" takes a source's id (list_sources). Nothing is read until it does`;
46
+ return [{ path: `/data/${name.replace(/~/g, '~0').replace(/\//g, '~1')}/from`, message }];
47
+ });
48
+ }
@@ -0,0 +1,7 @@
1
+ import { type Board } from '@iyulab/u-board/domain';
2
+ /** A worked board: one read of a list, statuses, a status card per item, a chart and a link — valid as written. */
3
+ export declare const RULEBOOK_EXAMPLE: Board;
4
+ /** The rulebook, as Markdown. */
5
+ export declare function boardRulebook(): string;
6
+ /** The options every `chart.*` kind takes — the data columns and the like — said once under the table, not on each chart's row. */
7
+ export declare function sharedChartOptions(): string[];
@@ -0,0 +1,148 @@
1
+ // The rulebook an assistant reads to write a board. What the code knows — the kinds a
2
+ // part may be and their options, the tones, the emphases, the frames, the keys of a data read and of a link — is
3
+ // generated from the same lists the schema and the check read, so the rulebook never lists a kind the check refuses or
4
+ // leaves one out. Only what the code cannot say — why a board is written this way — is prose.
5
+ import { PART_KINDS } from '@iyulab/u-board/domain';
6
+ import { boardSchema } from '@iyulab/u-board/domain';
7
+ import { EMPHASES, FRAMES, TONES } from '@iyulab/u-board/domain';
8
+ /** A worked board: one read of a list, statuses, a status card per item, a chart and a link — valid as written. */
9
+ export const RULEBOOK_EXAMPLE = {
10
+ statuses: { running: { tone: 'ok', label: '가동' }, down: { tone: 'alarm', label: '정지', emphasis: 'blink' } },
11
+ data: {
12
+ pumps: {
13
+ from: 'plant', path: '/plant/pumps', list: '/value', key: 'id', time: 'updatedAt', fresh: 60,
14
+ fields: { state: { state: { run: 'running', trip: 'down', '*': 'off' } }, flow: { label: '유량', unit: 'm³/h', digits: 1 } },
15
+ },
16
+ hourly: { from: 'plant', path: '/plant/flow/hourly', list: '/value' },
17
+ },
18
+ parts: {
19
+ P1: { as: 'status-card', of: 'pumps[P1]', title: '펌프 1', show: ['state', 'flow'] },
20
+ P2: { as: 'status-card', of: 'pumps[P2]', title: '펌프 2', show: ['state', 'flow'] },
21
+ flow: { as: 'chart.line', of: 'hourly', title: '시간대별 유량', x: 'hour', y: 'flow' },
22
+ },
23
+ layout: {
24
+ kind: 'canvas', size: [720, 360],
25
+ place: { P1: [40, 40, 200, 120], P2: [440, 40, 200, 120], flow: [40, 200, 640, 140] },
26
+ shapes: { title: { text: '펌프장', at: [40, 24] } },
27
+ links: { line: { from: 'P1', to: 'P2', of: 'pumps[P1]', show: 'state', label: '{{flow}}', arrow: 'end' } },
28
+ },
29
+ };
30
+ /** The rulebook, as Markdown. */
31
+ export function boardRulebook() {
32
+ const schema = boardSchema();
33
+ const defs = schema.$defs ?? {};
34
+ const keys = (def, skip = []) => Object.entries(defs[def]?.properties ?? {})
35
+ .filter(([key]) => !skip.includes(key))
36
+ .map(([key, p]) => `- \`${key}\`${p.description ? ` — ${p.description}` : ''}`)
37
+ .join('\n');
38
+ const code = (words) => words.map(w => `\`${w}\``).join(' · ');
39
+ return [
40
+ `# Writing a board
41
+
42
+ A board is one JSON object. Say once where values come from and what they mean (\`data\`), what is shown (\`parts\`) and
43
+ where (\`layout\`); statuses and the look are said once for the board. Everything is addressed by id; there is one way
44
+ to write each thing.
45
+
46
+ ${['board', 'statuses', 'symbols', 'elements', 'data', 'presets', 'parts', 'layout', 'triggers'].filter(k => k in (schema.properties ?? {})).map(k => `- \`${k}\`${schema.properties[k].description ? ` — ${schema.properties[k].description}` : ''}`).join('\n')}`,
47
+ `## data — read once, give meaning once
48
+
49
+ A data read asks an HTTP connector a \`path\` — the usual read. Only an adapter the host names itself is given its own
50
+ \`ref\` instead (one item, its \`fields\` only, never with \`path\`).
51
+ Parts name a read (\`"pumps"\`, the whole list) or one item of it (\`"pumps[P1]"\`, by its \`key\`). Data stays raw —
52
+ names, units, places and statuses live in \`fields\`, never in the data. A field's \`at\` points into one item, so with a \`list\` what sits beside it (a count, a total) is out of reach: read that
53
+ with a read of its own and no \`list\` — \`{ from: 'plant', path: '/plant/pumps?limit=1', fields: { total: { at: '/total' } } }\`. With a \`list\`, \`time\` is a field of each item (an item lacking it has no value); the answer's own is \`answerTime\`.
54
+
55
+ ${keys('query')}
56
+
57
+ A field is \`{ at?, label?, unit?, digits?, format?, names?, state? }\`; it reads \`/<its name>\` of the item unless \`at\`
58
+ says where. \`state\` says how the value is doing, as a status: \`"raw"\` (the value is a status name), a map
59
+ (\`{ "trip": "down", "*": "off" }\`, \`*\` for the rest) or bands (\`["ok", 1, "warn", 5, "alarm"]\` — the status below the
60
+ first number, then from each number up); a time's \`state\` can follow the clock: \`{ "relative": ["past", -2700, "now", 1, "next", 2700, "later"] }\` — bands over the seconds from now to it (negative: gone by). \`names\` takes the same two forms for what a value is shown as (a word, a count of marks). A field can also be computed from the items of the list: \`derive: { op: count | sum | min | max | avg, field?, where? }\` — a part that reads the list (no item) shows it, a \`state\` on it can raise a status. A field that holds a list of objects says what its items' fields mean with \`items\`.`,
61
+ `## statuses — how each status looks
62
+
63
+ Tones (always there): ${code(TONES)}. A board names its own statuses, each with a \`tone\`, a \`label\`, and optionally a
64
+ \`glyph\` and an \`emphasis\` (${code(EMPHASES)}). Anything showing a field with a \`state\` takes its status — colour,
65
+ label, glyph. Never write colours for statuses.`,
66
+ `## parts — what, of what, which fields
67
+
68
+ \`{ "as": <kind>, "of": "<data>" | "<data>[<key>]", "show": "<field>" | ["<field>", …], "title"?, …options }\`. \`{{field}}\`
69
+ in a title or a label reads a field of \`of\` (a status card's \`subtitle\` · \`note\` too; header \`kicker\` · \`subtitle\` are fixed text, \`clock\` is \`true\`); \`{{key}}\` is the item's key. \`presets\` hold shared settings a part takes
70
+ with \`use\`; \`each: "<data>"\` makes one part per item (ids \`<part>/<key>\`) — it takes no \`of\`, and is placed once. A \`group\` is several parts on a small
71
+ canvas of their own — its parts and links read the group's \`of\` unless they name their own.
72
+
73
+ The board's own options take fields of what the part reads: a \`status-card\`'s \`meter\` is \`"<field>"\` or
74
+ \`{ "field", "min"?, "max"? (a number, or a field of the item), "form"?: "bar" | "ring" }\`, its \`state\` (and a \`stat-list\`'s) the field whose status colours
75
+ it, a \`stat\`'s \`trend\` a field holding its recent values, a \`list\`'s \`primary\` · \`secondary\` · \`trailing\` ·
76
+ \`badge\` · \`time\` fields of each row. A part that reads one item takes \`highlight: "<boolean field>"\`: a ring while it is true, apart from the status. Any other option is the widget's own — only those listed.
77
+
78
+ | kind | shows | reads data | options |
79
+ |---|---|---|---|
80
+ ${PART_KINDS.map(kindRow).join('\n')}
81
+
82
+ Every \`chart.*\` also takes ${code(sharedChartOptions())}.`,
83
+ `## layout — where, per view kind
84
+
85
+ - canvas: \`{ "kind": "canvas", "size": [w, h], "background"?, "place": { "<part>": [x, y, w?, h?] }, "shapes"?, "links"? }\` —
86
+ an \`each\` part is placed item by item inside its own entry (\`{ "P1": [x, y, w, h], … }\`), or — when the items change and cannot be
87
+ named — by their own fields: \`"quake": { "at": ["lon", "lat"], "frame": [-180, 90, 180, -90], "size": [14, 14] }\` puts each item
88
+ at the point its two fields say; \`frame\` is the values at the canvas's left, top, right and bottom edges (the canvas's own
89
+ coordinates when left out), \`size\` the box centred on the point (give one for a marker: a dot alone is [14, 14]; one with a name or note draws
90
+ it right of the dot — about [150, 40] — and it is the box, not the dot, that is centred). An item with no number in a field, or
91
+ outside the frame, is not drawn — \`look\` counts them. The data needs a \`key\`; the projection is linear (right for a plan or a flat map). A part is placed one way, not both. Or by a rule: \`"tile": { "flow": { "cell": [w, h], "columns": n, "gap"?, "at"? }, "by"?: "<field>", "boxes"?: { "columns": n, "label"?: "{{key}}" } }\` — items flow in columns, or with \`by\` are grouped into labelled boxes (\`flow.pad\` [sides, top] kept clear inside). A shape is a \`box\` (with \`label\`), a \`text\` at a point,
92
+ a \`line\` through points or a \`path\` (absolute M · L · C · Q · Z).
93
+ - grid: \`{ "kind": "grid", "items": ["<part>", ["<part>", span], { "grid": [ … ], "span": n }, { "canvas": { … }, "span": n }] }\` —
94
+ 12 columns; an \`each\` part's items are cells (\`["pumps", 2]\`: six to a row); a grid in an item stacks what it holds.
95
+
96
+ A link joins two parts:
97
+
98
+ ${keys('groupLink')}`,
99
+ `## elements and the look
100
+
101
+ Use a built-in whenever one shows the thing — a title, a status and a few values are a \`status-card\`, \`stat\` or
102
+ \`stat-list\`. Write an element only for a drawing no built-in makes:
103
+ \`"elements": { "tank": { "template": "<svg …>{{level}}</svg>", "style"?: "…", "props": { "level": "number", "tone": "string" } } }\`
104
+ — every \`{{hole}}\` is a declared prop (\`number\` · \`string\` · \`boolean\` · \`list\` · \`object\`). A part places it with
105
+ \`"as": "element", "element": "tank"\`, fixed \`props\` and \`bind\` (\`{ "level": "<field>", "tone": "<field>.tone" }\`). What markup cannot draw runs as a
106
+ \`"script"\` in place of the template — in a sandboxed frame with no network, its props through \`ub.onProps\`, \`"resources"\` the
107
+ https:// origins it loads from. The look is \`board.skin\`, once for the board; parts never set fonts or colours:
108
+ \`{ "palette"?: { "paper", "ink", "muted", "rule" }, "font"? · "display"? · "mono"? (font stacks), "frame"?, "radius"? (px), "shadow"?: "none" | "soft" | "hard" }\`
109
+ — a frame is ${code(FRAMES)}; paper is the ground the widgets sit on, ink their text, muted the quieter text, rule their lines; an element's CSS reads them as
110
+ \`var(--u-widget-bg)\` · \`var(--u-widget-text)\` · \`var(--u-widget-text-secondary)\` · \`var(--u-widget-border)\`; a background picture follows the skin when it writes a colour as \`var(--u-widget-bg, #f6f4ef)\` (its own colour is the fallback).`,
111
+ `## triggers — what the board does
112
+
113
+ A trigger is \`{ "on", "when"?, "do": [ … ] }\`. \`on\` a part clicked (\`{ "event": "click", "part" }\`), an event its element
114
+ raises (\`"element"\` with \`name\`), a part entering or leaving a status (\`"enter"\` · \`"leave"\` with \`status\`, and the
115
+ \`field\` whose state it is unless the part has its own — a status card's, a \`state\` option's) or the board opened
116
+ (\`"open"\`); \`when\` keeps it to a part in one of the statuses; \`do\` opens a link (\`open-link\`, \`url\` with \`{{part}}\` ·
117
+ \`{{status}}\` · \`{{board}}\` filled in), brings a part into view (\`focus\`), hands the host an event (\`emit\`) or asks for
118
+ another board (\`go-to-board\`). A part made per item is named per item (\`"pumps/P1"\`) — or, in \`on\`, by its own id for every
119
+ item at once (\`{ "event": "click", "part": "pumps" }\`): the event is then about that item, and \`open-link\`'s \`url\` may hold
120
+ \`{{key}}\` (the item's key) and \`{{field}}\` (a field the data declares), each URL-encoded: \`"/orders/{{key}}?line={{line}}"\`.
121
+ The address (scheme, host, port) is written out; the holes go in the path, query or fragment. An item with no value for a field
122
+ gets no link. Triggers only show, move and tell — they write to nothing the board reads.`,
123
+ `## Rules
124
+
125
+ - Ids are the keys of each map and unique within it. Never address by index.
126
+ - Every part is placed by the layout; a part placed nowhere is an error.
127
+ - Coordinates appear only in a canvas layout.
128
+
129
+ ## Example
130
+
131
+ \`\`\`json
132
+ ${JSON.stringify(RULEBOOK_EXAMPLE, null, 1)}
133
+ \`\`\``,
134
+ ].join('\n\n');
135
+ }
136
+ const optionsOf = (k) => [...k.own, ...k.options];
137
+ /** The options every `chart.*` kind takes — the data columns and the like — said once under the table, not on each chart's row. */
138
+ export function sharedChartOptions() {
139
+ const charts = PART_KINDS.filter(k => k.kind.startsWith('chart.'));
140
+ return charts.length === 0 ? [] : optionsOf(charts[0]).filter(option => charts.every(k => optionsOf(k).includes(option)));
141
+ }
142
+ /** A kind's row: its name, what it shows, whether it reads data, and its options — the board's own, then the widget's. */
143
+ function kindRow(k) {
144
+ const reads = { needs: 'yes', may: 'may', no: 'no' }[k.of];
145
+ const shared = k.kind.startsWith('chart.') ? sharedChartOptions() : [];
146
+ const options = optionsOf(k).filter(option => !shared.includes(option));
147
+ return `| \`${k.kind}\` | ${k.shows.replace(/\|/g, '/')} | ${reads} | ${options.length > 0 ? options.map(o => `\`${o}\``).join(' ') : '—'} |`;
148
+ }
@@ -0,0 +1,3 @@
1
+ export { checkBoard, emptyBoard, sourceAdvice } from './board/check.js';
2
+ export type { BoardCheck, BoardFinding, KnownSource } from './board/check.js';
3
+ export { boardRulebook, RULEBOOK_EXAMPLE } from './board/rulebook.js';
@@ -0,0 +1,4 @@
1
+ // What helps a board get written well — the check an author or an assistant reads, and the rulebook an assistant
2
+ // writes by. Reaches no UI package at runtime, so a server or an MCP process loads it.
3
+ export { checkBoard, emptyBoard, sourceAdvice } from './board/check.js';
4
+ export { boardRulebook, RULEBOOK_EXAMPLE } from './board/rulebook.js';