@tilewise/workbench-client 0.0.0-stage → 0.1.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 (103) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/LICENSE +21 -0
  3. package/README.md +210 -2
  4. package/dist/blocks/ScreenScope.d.ts +11 -0
  5. package/dist/blocks/ScreenScope.js +11 -0
  6. package/dist/blocks/drawnBlocks.d.ts +11 -0
  7. package/dist/blocks/drawnBlocks.js +47 -0
  8. package/dist/blocks/screenContext.d.ts +2 -0
  9. package/dist/blocks/screenContext.js +3 -0
  10. package/dist/bridge/Bridge.d.ts +10 -0
  11. package/dist/bridge/Bridge.js +129 -0
  12. package/dist/bridge/device.d.ts +6 -0
  13. package/dist/bridge/device.js +28 -0
  14. package/dist/bridge/post.d.ts +5 -0
  15. package/dist/bridge/post.js +8 -0
  16. package/dist/bridge/readViewerMessage.d.ts +3 -0
  17. package/dist/bridge/readViewerMessage.js +46 -0
  18. package/dist/bridge/revealControl.d.ts +10 -0
  19. package/dist/bridge/revealControl.js +62 -0
  20. package/dist/bridge/snapshot.d.ts +7 -0
  21. package/dist/bridge/snapshot.js +47 -0
  22. package/dist/bridge/toneAtTop.d.ts +3 -0
  23. package/dist/bridge/toneAtTop.js +23 -0
  24. package/dist/bridge/watchScreen.d.ts +10 -0
  25. package/dist/bridge/watchScreen.js +50 -0
  26. package/dist/bridge/wholeHeight.d.ts +8 -0
  27. package/dist/bridge/wholeHeight.js +16 -0
  28. package/dist/controls/paint.d.ts +8 -0
  29. package/dist/controls/paint.js +16 -0
  30. package/dist/controls/parseValue.d.ts +7 -0
  31. package/dist/controls/parseValue.js +34 -0
  32. package/dist/controls/resetControl.d.ts +3 -0
  33. package/dist/controls/resetControl.js +6 -0
  34. package/dist/controls/setControl.d.ts +3 -0
  35. package/dist/controls/setControl.js +6 -0
  36. package/dist/controls/store.d.ts +12 -0
  37. package/dist/controls/store.js +125 -0
  38. package/dist/controls/types.d.ts +111 -0
  39. package/dist/controls/types.js +6 -0
  40. package/dist/controls/useControl.d.ts +7 -0
  41. package/dist/controls/useControl.js +16 -0
  42. package/dist/define/buildManifest.d.ts +8 -0
  43. package/dist/define/buildManifest.js +51 -0
  44. package/dist/define/checkDefaults.d.ts +3 -0
  45. package/dist/define/checkDefaults.js +29 -0
  46. package/dist/define/defineApp.d.ts +24 -0
  47. package/dist/define/defineApp.js +92 -0
  48. package/dist/define/defineBlock.d.ts +12 -0
  49. package/dist/define/defineBlock.js +10 -0
  50. package/dist/define/defineScreen.d.ts +11 -0
  51. package/dist/define/defineScreen.js +10 -0
  52. package/dist/define/part.d.ts +23 -0
  53. package/dist/define/part.js +22 -0
  54. package/dist/define/registeredApp.d.ts +12 -0
  55. package/dist/define/registeredApp.js +5 -0
  56. package/dist/draw/boolean.d.ts +6 -0
  57. package/dist/draw/boolean.js +5 -0
  58. package/dist/draw/choose.d.ts +4 -0
  59. package/dist/draw/choose.js +4 -0
  60. package/dist/draw/color.d.ts +2 -0
  61. package/dist/draw/color.js +4 -0
  62. package/dist/draw/number.d.ts +5 -0
  63. package/dist/draw/number.js +5 -0
  64. package/dist/draw/text.d.ts +5 -0
  65. package/dist/draw/text.js +5 -0
  66. package/dist/draw/variant.d.ts +17 -0
  67. package/dist/draw/variant.js +4 -0
  68. package/dist/index.d.ts +18 -0
  69. package/dist/index.js +14 -0
  70. package/dist/protocol/protocol.d.ts +8 -0
  71. package/dist/protocol/protocol.js +8 -0
  72. package/dist/protocol/types.d.ts +144 -0
  73. package/dist/protocol/types.js +1 -0
  74. package/dist/vite/apiFetch.d.ts +7 -0
  75. package/dist/vite/apiFetch.js +15 -0
  76. package/dist/vite/apiWorkers.d.ts +11 -0
  77. package/dist/vite/apiWorkers.js +111 -0
  78. package/dist/vite/appFolder.d.ts +6 -0
  79. package/dist/vite/appFolder.js +11 -0
  80. package/dist/vite/builds.d.ts +47 -0
  81. package/dist/vite/builds.js +250 -0
  82. package/dist/vite/bundleApi.d.ts +9 -0
  83. package/dist/vite/bundleApi.js +32 -0
  84. package/dist/vite/checkout.d.ts +11 -0
  85. package/dist/vite/checkout.js +43 -0
  86. package/dist/vite/environment.d.ts +9 -0
  87. package/dist/vite/environment.js +10 -0
  88. package/dist/vite/fetchBridge.d.ts +8 -0
  89. package/dist/vite/fetchBridge.js +42 -0
  90. package/dist/vite/git.d.ts +7 -0
  91. package/dist/vite/git.js +18 -0
  92. package/dist/vite/index.d.ts +1 -0
  93. package/dist/vite/index.js +1 -0
  94. package/dist/vite/isWorkspace.d.ts +5 -0
  95. package/dist/vite/isWorkspace.js +10 -0
  96. package/dist/vite/serveBuild.d.ts +7 -0
  97. package/dist/vite/serveBuild.js +45 -0
  98. package/dist/vite/versions.d.ts +44 -0
  99. package/dist/vite/versions.js +122 -0
  100. package/dist/vite/workbenchLocal.d.ts +32 -0
  101. package/dist/vite/workbenchLocal.js +251 -0
  102. package/docs/protocol.md +66 -0
  103. package/package.json +63 -3
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Shows what a control changes: after the viewer sets or resets one, the
3
+ * screen scrolls to the first part on show marked with it
4
+ * (`data-control="<id> …"`). A control with no marked part scrolls nothing.
5
+ * A mark may sit on a `display: contents` wrapper; then its first child with
6
+ * a box is what is shown.
7
+ */
8
+ const ROOM = 72;
9
+ function boxOf(element) {
10
+ if (getComputedStyle(element).display !== 'contents') {
11
+ return element.getClientRects().length > 0 ? element : null;
12
+ }
13
+ for (const child of Array.from(element.children)) {
14
+ const box = boxOf(child);
15
+ if (box) {
16
+ return box;
17
+ }
18
+ }
19
+ return null;
20
+ }
21
+ /** The first part marked with the control that is on show; a hidden tab's has no box. */
22
+ export function firstShown(id) {
23
+ for (const element of document.querySelectorAll(`[data-control~="${CSS.escape(id)}"]`)) {
24
+ const box = boxOf(element);
25
+ if (box) {
26
+ return box;
27
+ }
28
+ }
29
+ return null;
30
+ }
31
+ function scrollerOf(element) {
32
+ for (let parent = element.parentElement; parent; parent = parent.parentElement) {
33
+ const { overflowY } = getComputedStyle(parent);
34
+ if ((overflowY === 'auto' || overflowY === 'scroll') &&
35
+ parent.scrollHeight > parent.clientHeight) {
36
+ return parent;
37
+ }
38
+ }
39
+ return null;
40
+ }
41
+ /** A part can ask for more room above it with CSS `scroll-margin-top`. */
42
+ function bring(scroller, target) {
43
+ const room = Math.max(ROOM, parseFloat(getComputedStyle(target).scrollMarginTop) || 0);
44
+ const top = scroller.scrollTop +
45
+ target.getBoundingClientRect().top -
46
+ scroller.getBoundingClientRect().top -
47
+ room;
48
+ const behavior = matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth';
49
+ scroller.scrollTo({ top: Math.max(0, top), behavior });
50
+ }
51
+ export function revealControl(id) {
52
+ // Two frames: the change draws first, and a block may only now exist.
53
+ requestAnimationFrame(() => {
54
+ return requestAnimationFrame(() => {
55
+ const part = firstShown(id);
56
+ const scroller = part && scrollerOf(part);
57
+ if (part && scroller) {
58
+ bring(scroller, part);
59
+ }
60
+ });
61
+ });
62
+ }
@@ -0,0 +1,7 @@
1
+ /**
2
+ * A picture of the screen as it is now, for the viewer to show on the half of
3
+ * a foldable that swings; null if it can't be taken. The library that takes
4
+ * it loads with the first one asked for, so an app that is never folded never
5
+ * loads it.
6
+ */
7
+ export declare function snapshot(): Promise<ImageBitmap | null>;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * What the picture needs: the elements drawn, and not wholly below or right of
3
+ * the window. A hidden tab's are most of an app's, and the slowest to copy.
4
+ */
5
+ function inView(node) {
6
+ if (!(node instanceof HTMLElement)) {
7
+ return true;
8
+ }
9
+ if (getComputedStyle(node).display === 'none') {
10
+ return false;
11
+ }
12
+ const { top, left } = node.getBoundingClientRect();
13
+ return top < window.innerHeight && left < window.innerWidth;
14
+ }
15
+ /**
16
+ * A picture of the screen as it is now, for the viewer to show on the half of
17
+ * a foldable that swings; null if it can't be taken. The library that takes
18
+ * it loads with the first one asked for, so an app that is never folded never
19
+ * loads it.
20
+ */
21
+ export async function snapshot() {
22
+ try {
23
+ const { createContext, domToCanvas } = await import('modern-screenshot');
24
+ const context = await createContext(document.documentElement, {
25
+ width: window.innerWidth,
26
+ height: window.innerHeight,
27
+ scale: Math.min(2, window.devicePixelRatio),
28
+ filter: inView,
29
+ // Images that haven't loaded, such as lazy ones out of view, aren't waited for: the
30
+ // picture fetches each one it shows itself.
31
+ timeout: 1,
32
+ autoDestruct: true,
33
+ features: {
34
+ restoreScrollPosition: true,
35
+ copyScrollbar: false,
36
+ // Safari's fix draws the picture again for every image in it, a tenth of a second apart.
37
+ fixSvgXmlDecode: false,
38
+ },
39
+ });
40
+ // Fetching those images and drawing the picture get the time they need.
41
+ context.timeout = 3000;
42
+ return await createImageBitmap(await domToCanvas(context));
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
@@ -0,0 +1,3 @@
1
+ import type { Tone } from '../protocol/types.js';
2
+ /** Light or dark: the first solid colour under where the status bar's clock sits. */
3
+ export declare function toneAtTop(): Tone;
@@ -0,0 +1,23 @@
1
+ /** The red, green and blue of a computed colour, when it is mostly opaque. */
2
+ function solid(colour) {
3
+ const parts = colour.match(/[\d.]+/g);
4
+ if (!parts || (parts.length >= 4 && Number(parts[3]) <= 0.5)) {
5
+ return null;
6
+ }
7
+ return parts.slice(0, 3).map(Number);
8
+ }
9
+ /** Light or dark: the first solid colour under where the status bar's clock sits. */
10
+ export function toneAtTop() {
11
+ if (typeof document.elementsFromPoint !== 'function') {
12
+ return 'light';
13
+ }
14
+ const top = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--safe-top')) || 20;
15
+ for (const element of document.elementsFromPoint(24, top / 2)) {
16
+ const rgb = solid(getComputedStyle(element).backgroundColor);
17
+ if (rgb) {
18
+ const [red = 0, green = 0, blue = 0] = rgb;
19
+ return (0.2126 * red + 0.7152 * green + 0.0722 * blue) / 255 < 0.5 ? 'dark' : 'light';
20
+ }
21
+ }
22
+ return 'light';
23
+ }
@@ -0,0 +1,10 @@
1
+ import type { AppSays } from '../protocol/types.js';
2
+ type Report = (message: Extract<AppSays, {
3
+ type: 'tone' | 'height';
4
+ }>) => void;
5
+ /**
6
+ * Tells the viewer the tone at the top of the screen and, on Full page, the
7
+ * screen's height, whenever either changes. Returns how to stop.
8
+ */
9
+ export declare function watchScreen(report: Report): () => void;
10
+ export {};
@@ -0,0 +1,50 @@
1
+ import { isWhole, onDeviceChange } from './device.js';
2
+ import { toneAtTop } from './toneAtTop.js';
3
+ import { wholeHeight } from './wholeHeight.js';
4
+ /**
5
+ * Tells the viewer the tone at the top of the screen and, on Full page, the
6
+ * screen's height, whenever either changes. Returns how to stop.
7
+ */
8
+ export function watchScreen(report) {
9
+ let tone = null;
10
+ let height = 0;
11
+ // A timer, not a frame: a frame behind another window never draws, but the viewer still listens.
12
+ let timer;
13
+ const check = () => {
14
+ timer = undefined;
15
+ const now = toneAtTop();
16
+ if (now !== tone) {
17
+ tone = now;
18
+ report({ type: 'tone', top: now });
19
+ }
20
+ if (!isWhole()) {
21
+ height = 0;
22
+ return;
23
+ }
24
+ const whole = Math.ceil(wholeHeight());
25
+ if (whole !== height) {
26
+ height = whole;
27
+ report({ type: 'height', height: whole });
28
+ }
29
+ };
30
+ const soon = () => {
31
+ timer ??= setTimeout(check, 50);
32
+ };
33
+ onDeviceChange(soon);
34
+ const mutations = new MutationObserver(soon);
35
+ mutations.observe(document.body, {
36
+ subtree: true,
37
+ childList: true,
38
+ attributes: true,
39
+ attributeFilter: ['class', 'style', 'data-workbench-body'],
40
+ });
41
+ const resizes = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(soon);
42
+ resizes?.observe(document.documentElement);
43
+ soon();
44
+ return () => {
45
+ mutations.disconnect();
46
+ resizes?.disconnect();
47
+ clearTimeout(timer);
48
+ onDeviceChange(null);
49
+ };
50
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * How tall the window must be for the open screen to show all of it: its
3
+ * height plus what the open screen's scrolling body hides. The app marks each
4
+ * screen's scrolling body `data-workbench-body`, and takes the mark off a
5
+ * screen covered by another; a body with no height, as in a hidden tab, is
6
+ * skipped.
7
+ */
8
+ export declare function wholeHeight(): number;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * How tall the window must be for the open screen to show all of it: its
3
+ * height plus what the open screen's scrolling body hides. The app marks each
4
+ * screen's scrolling body `data-workbench-body`, and takes the mark off a
5
+ * screen covered by another; a body with no height, as in a hidden tab, is
6
+ * skipped.
7
+ */
8
+ export function wholeHeight() {
9
+ let more = 0;
10
+ for (const body of document.querySelectorAll('[data-workbench-body]')) {
11
+ if (body.clientHeight > 0) {
12
+ more = Math.max(more, body.scrollHeight - body.clientHeight);
13
+ }
14
+ }
15
+ return window.innerHeight + more;
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { Control, Value } from './types.js';
2
+ /**
3
+ * Writes a global control's value to the root, as `html[data-<id>]`, and a
4
+ * number or a color also as `--tweak-<id>`, so a pure-CSS tweak needs no code.
5
+ * Screen and block values are never written here: a component that styles by
6
+ * one puts it on its own root.
7
+ */
8
+ export declare function paint(control: Control, value: Value): void;
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Writes a global control's value to the root, as `html[data-<id>]`, and a
3
+ * number or a color also as `--tweak-<id>`, so a pure-CSS tweak needs no code.
4
+ * Screen and block values are never written here: a component that styles by
5
+ * one puts it on its own root.
6
+ */
7
+ export function paint(control, value) {
8
+ const root = document.documentElement;
9
+ root.setAttribute(`data-${control.id}`, String(value));
10
+ if (control.type === 'number') {
11
+ root.style.setProperty(`--tweak-${control.id}`, String(value));
12
+ }
13
+ else if (control.type === 'color') {
14
+ root.style.setProperty(`--tweak-${control.id}`, `var(--${String(value)})`);
15
+ }
16
+ }
@@ -0,0 +1,7 @@
1
+ import type { Control, Value } from './types.js';
2
+ /**
3
+ * A value as it arrives, as text, checked against its control. Anything that
4
+ * doesn't fit falls back to the default; a number outside its range is
5
+ * clamped, and text over `maxLength` is cut.
6
+ */
7
+ export declare function parseValue(control: Control, raw: string | null | undefined): Value;
@@ -0,0 +1,34 @@
1
+ /**
2
+ * A value as it arrives, as text, checked against its control. Anything that
3
+ * doesn't fit falls back to the default; a number outside its range is
4
+ * clamped, and text over `maxLength` is cut.
5
+ */
6
+ export function parseValue(control, raw) {
7
+ if (raw === null || raw === undefined) {
8
+ return control.default;
9
+ }
10
+ switch (control.type) {
11
+ case 'boolean': {
12
+ if (raw === 'true') {
13
+ return true;
14
+ }
15
+ return raw === 'false' ? false : control.default;
16
+ }
17
+ case 'number': {
18
+ const number = Number(raw);
19
+ if (raw.trim() === '' || !Number.isFinite(number)) {
20
+ return control.default;
21
+ }
22
+ return Math.min(control.max, Math.max(control.min, number));
23
+ }
24
+ case 'variant': {
25
+ return control.options.some((option) => option.value === raw) ? raw : control.default;
26
+ }
27
+ case 'color': {
28
+ return control.options.some((option) => option.token === raw) ? raw : control.default;
29
+ }
30
+ case 'text': {
31
+ return control.maxLength === undefined ? raw : raw.slice(0, control.maxLength);
32
+ }
33
+ }
34
+ }
@@ -0,0 +1,3 @@
1
+ import type { Control, Handle } from './types.js';
2
+ /** Puts a control back to its default, and forgets the value picked. */
3
+ export declare function resetControl<C extends Control>(handle: Handle<C>): void;
@@ -0,0 +1,6 @@
1
+ import { declare, resetValue } from './store.js';
2
+ /** Puts a control back to its default, and forgets the value picked. */
3
+ export function resetControl(handle) {
4
+ declare(handle);
5
+ resetValue(handle.id);
6
+ }
@@ -0,0 +1,3 @@
1
+ import type { Control, Handle, ValueOf } from './types.js';
2
+ /** Sets a control, as the viewer's `set` does: checked against its declaration first. */
3
+ export declare function setControl<C extends Control>(handle: Handle<C>, value: ValueOf<C>): void;
@@ -0,0 +1,6 @@
1
+ import { declare, setValue } from './store.js';
2
+ /** Sets a control, as the viewer's `set` does: checked against its declaration first. */
3
+ export function setControl(handle, value) {
4
+ declare(handle);
5
+ setValue(handle.id, value);
6
+ }
@@ -0,0 +1,12 @@
1
+ import type { Handle, Value } from './types.js';
2
+ /** Makes a control known, with its starting value, and returns its value now. */
3
+ export declare function declare(handle: Handle): Value;
4
+ /** Sets a control by its id, checked against its declaration. False for an id nobody declared. */
5
+ export declare function setValue(id: string, raw: Value): boolean;
6
+ /** Puts a control back to its default, by its id. False for an id nobody declared. */
7
+ export declare function resetValue(id: string): boolean;
8
+ /** Puts every control back. */
9
+ export declare function resetAll(): void;
10
+ /** Every control's value, now. The same object until one changes. */
11
+ export declare const valuesNow: () => Readonly<Record<string, Value>>;
12
+ export declare function subscribeValues(listener: () => void): () => void;
@@ -0,0 +1,125 @@
1
+ import { paint } from './paint.js';
2
+ import { parseValue } from './parseValue.js';
3
+ const entries = new Map();
4
+ const listeners = new Set();
5
+ let values = {};
6
+ let listening = false;
7
+ const storageKey = (id) => `control:${id}`;
8
+ const inPage = () => typeof window !== 'undefined';
9
+ function emit() {
10
+ for (const listener of listeners) {
11
+ listener();
12
+ }
13
+ }
14
+ function store(id, value) {
15
+ try {
16
+ if (value === null) {
17
+ localStorage.removeItem(storageKey(id));
18
+ }
19
+ else {
20
+ localStorage.setItem(storageKey(id), String(value));
21
+ }
22
+ }
23
+ catch {
24
+ // Storage is off, as in some private windows: the value lasts this page only.
25
+ }
26
+ }
27
+ function startingValue(control) {
28
+ if (!inPage()) {
29
+ return control.default;
30
+ }
31
+ const linked = new URLSearchParams(window.location.search).get(control.id);
32
+ if (linked !== null) {
33
+ const value = parseValue(control, linked);
34
+ store(control.id, value);
35
+ return value;
36
+ }
37
+ try {
38
+ return parseValue(control, localStorage.getItem(storageKey(control.id)));
39
+ }
40
+ catch {
41
+ return control.default;
42
+ }
43
+ }
44
+ function put(id, entry, value) {
45
+ values = { ...values, [id]: value };
46
+ if (entry.global && inPage()) {
47
+ paint(entry.control, value);
48
+ }
49
+ }
50
+ function followOtherTabs() {
51
+ if (listening || !inPage()) {
52
+ return;
53
+ }
54
+ listening = true;
55
+ window.addEventListener('storage', (event) => {
56
+ if (!event.key?.startsWith('control:')) {
57
+ return;
58
+ }
59
+ const id = event.key.slice('control:'.length);
60
+ const entry = entries.get(id);
61
+ if (!entry) {
62
+ return;
63
+ }
64
+ const value = parseValue(entry.control, event.newValue);
65
+ if (values[id] !== value) {
66
+ put(id, entry, value);
67
+ emit();
68
+ }
69
+ });
70
+ }
71
+ /** Makes a control known, with its starting value, and returns its value now. */
72
+ export function declare(handle) {
73
+ const known = values[handle.id];
74
+ if (known !== undefined) {
75
+ return known;
76
+ }
77
+ const entry = { control: handle.control, global: handle.place.level === 'global' };
78
+ entries.set(handle.id, entry);
79
+ put(handle.id, entry, startingValue(handle.control));
80
+ followOtherTabs();
81
+ // Known while drawing: everyone who reads every value hears of it next.
82
+ queueMicrotask(emit);
83
+ return values[handle.id];
84
+ }
85
+ /** Sets a control by its id, checked against its declaration. False for an id nobody declared. */
86
+ export function setValue(id, raw) {
87
+ const entry = entries.get(id);
88
+ if (!entry) {
89
+ return false;
90
+ }
91
+ const value = parseValue(entry.control, String(raw));
92
+ if (values[id] !== value) {
93
+ store(id, value);
94
+ put(id, entry, value);
95
+ emit();
96
+ }
97
+ return true;
98
+ }
99
+ /** Puts a control back to its default, by its id. False for an id nobody declared. */
100
+ export function resetValue(id) {
101
+ const entry = entries.get(id);
102
+ if (!entry) {
103
+ return false;
104
+ }
105
+ store(id, null);
106
+ if (values[id] !== entry.control.default) {
107
+ put(id, entry, entry.control.default);
108
+ emit();
109
+ }
110
+ return true;
111
+ }
112
+ /** Puts every control back. */
113
+ export function resetAll() {
114
+ for (const id of entries.keys()) {
115
+ resetValue(id);
116
+ }
117
+ }
118
+ /** Every control's value, now. The same object until one changes. */
119
+ export const valuesNow = () => values;
120
+ export function subscribeValues(listener) {
121
+ listeners.add(listener);
122
+ return () => {
123
+ listeners.delete(listener);
124
+ };
125
+ }
@@ -0,0 +1,111 @@
1
+ /**
2
+ * A control: one value a reviewer can change from the viewer. There are five
3
+ * types and no others; the viewer draws one fixed row for each, so a new
4
+ * control is one more declaration, never new UI.
5
+ */
6
+ export interface Option {
7
+ value: string;
8
+ label: string;
9
+ }
10
+ export interface ColorOption {
11
+ /** A name from the app's tokens, such as `lime` for `--lime`. */
12
+ token: string;
13
+ label: string;
14
+ }
15
+ interface Base {
16
+ /** Its name in links, storage and messages. Unique across the app, and never renamed. */
17
+ id: string;
18
+ /** Its name in the form. */
19
+ label: string;
20
+ /** A line the form shows quietly under its row, for a rule its label can't hold. */
21
+ note?: string;
22
+ }
23
+ export type VariantControl = Base & {
24
+ type: 'variant';
25
+ options: readonly Option[];
26
+ /** One of the options' values. */
27
+ default: string;
28
+ };
29
+ export type BooleanControl = Base & {
30
+ type: 'boolean';
31
+ default: boolean;
32
+ };
33
+ export type NumberControl = Base & {
34
+ type: 'number';
35
+ min: number;
36
+ max: number;
37
+ step?: number;
38
+ default: number;
39
+ };
40
+ export type ColorControl = Base & {
41
+ type: 'color';
42
+ options: readonly ColorOption[];
43
+ /** One of the options' tokens. */
44
+ default: string;
45
+ };
46
+ export type TextControl = Base & {
47
+ type: 'text';
48
+ maxLength?: number;
49
+ default: string;
50
+ };
51
+ export type Control = VariantControl | BooleanControl | NumberControl | ColorControl | TextControl;
52
+ /** A control's value: a string, a number or a boolean. */
53
+ export type Value = string | number | boolean;
54
+ /** The value a control holds, from its declaration: a variant's values as a union. */
55
+ export type ValueOf<C> = C extends {
56
+ type: 'boolean';
57
+ } ? boolean : C extends {
58
+ type: 'number';
59
+ } ? number : C extends {
60
+ type: 'variant';
61
+ options: readonly (infer O)[];
62
+ } ? O extends {
63
+ value: infer V extends string;
64
+ } ? V : never : C extends {
65
+ type: 'color';
66
+ options: readonly (infer O)[];
67
+ } ? O extends {
68
+ token: infer T extends string;
69
+ } ? T : never : string;
70
+ /** Where a control shows in the form. */
71
+ export type Level = 'global' | 'screen' | 'block';
72
+ /** Where a control sits: the whole app, one screen, or one block. */
73
+ export type Place = {
74
+ level: 'global';
75
+ } | {
76
+ level: 'screen';
77
+ screen: string;
78
+ } | {
79
+ level: 'block';
80
+ block: string;
81
+ };
82
+ /** What a part hands its code for one control: read it with `useControl(handle)`. */
83
+ export interface Handle<C extends Control = Control> {
84
+ readonly id: string;
85
+ readonly control: C;
86
+ readonly place: Place;
87
+ }
88
+ /** The declared shape of a control of the same type as `C`. */
89
+ type ShapeOf<C> = C extends {
90
+ type: infer T;
91
+ } ? Extract<Control, {
92
+ type: T;
93
+ }> : Control;
94
+ /** Refuses keys a control's type doesn't have, as `screens`. */
95
+ type NoExtraKeys<C> = Record<Exclude<keyof C, keyof ShapeOf<C>>, never>;
96
+ type ExactOptions<C> = C extends {
97
+ options: readonly (infer O)[];
98
+ } ? ShapeOf<C> extends {
99
+ options: readonly (infer S)[];
100
+ } ? {
101
+ options: readonly (O & Record<Exclude<keyof O, keyof S>, never>)[];
102
+ } : unknown : unknown;
103
+ /**
104
+ * Each control as declared, held to its type: no keys it doesn't have, and no
105
+ * option's `short`. A default that isn't one of its values is caught as the
106
+ * app starts.
107
+ */
108
+ export type Declared<C extends readonly Control[]> = {
109
+ [K in keyof C]: C[K] & NoExtraKeys<C[K]> & ExactOptions<C[K]>;
110
+ };
111
+ export {};
@@ -0,0 +1,6 @@
1
+ /**
2
+ * A control: one value a reviewer can change from the viewer. There are five
3
+ * types and no others; the viewer draws one fixed row for each, so a new
4
+ * control is one more declaration, never new UI.
5
+ */
6
+ export {};
@@ -0,0 +1,7 @@
1
+ import type { Control, Handle, ValueOf } from './types.js';
2
+ /**
3
+ * A control's value, typed by its declaration, drawn again whenever it
4
+ * changes. A block's control also reports the block as drawn on the screen
5
+ * of the nearest `<ScreenScope>`, until it unmounts.
6
+ */
7
+ export declare function useControl<C extends Control>(handle: Handle<C>): ValueOf<C>;
@@ -0,0 +1,16 @@
1
+ import { useContext, useEffect, useSyncExternalStore } from 'react';
2
+ import { reportBlock } from '../blocks/drawnBlocks.js';
3
+ import { ScreenContext } from '../blocks/screenContext.js';
4
+ import { declare, subscribeValues } from './store.js';
5
+ /**
6
+ * A control's value, typed by its declaration, drawn again whenever it
7
+ * changes. A block's control also reports the block as drawn on the screen
8
+ * of the nearest `<ScreenScope>`, until it unmounts.
9
+ */
10
+ export function useControl(handle) {
11
+ const value = useSyncExternalStore(subscribeValues, () => declare(handle), () => handle.control.default);
12
+ const screen = useContext(ScreenContext);
13
+ const block = handle.place.level === 'block' ? handle.place.block : null;
14
+ useEffect(() => (screen && block ? reportBlock(screen, block) : undefined), [screen, block]);
15
+ return value;
16
+ }
@@ -0,0 +1,8 @@
1
+ import type { Manifest, Screen } from '../protocol/types.js';
2
+ import { type Part } from './part.js';
3
+ /**
4
+ * Everything the app has, for the viewer: its screens and states, every
5
+ * control with where it sits, and every block. A state goes without its jump,
6
+ * so only the fields the protocol names are copied.
7
+ */
8
+ export declare function buildManifest(app: string, screens: readonly Screen[], parts: readonly Part[]): Manifest;