@tilewise/workbench-client 0.0.0-stage → 0.1.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 (89) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/LICENSE +21 -0
  3. package/README.md +176 -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 +9 -0
  11. package/dist/bridge/Bridge.js +120 -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 +44 -0
  18. package/dist/bridge/revealControl.d.ts +10 -0
  19. package/dist/bridge/revealControl.js +62 -0
  20. package/dist/bridge/toneAtTop.d.ts +3 -0
  21. package/dist/bridge/toneAtTop.js +23 -0
  22. package/dist/bridge/watchScreen.d.ts +10 -0
  23. package/dist/bridge/watchScreen.js +50 -0
  24. package/dist/bridge/wholeHeight.d.ts +8 -0
  25. package/dist/bridge/wholeHeight.js +16 -0
  26. package/dist/controls/paint.d.ts +8 -0
  27. package/dist/controls/paint.js +16 -0
  28. package/dist/controls/parseValue.d.ts +7 -0
  29. package/dist/controls/parseValue.js +34 -0
  30. package/dist/controls/resetControl.d.ts +3 -0
  31. package/dist/controls/resetControl.js +6 -0
  32. package/dist/controls/setControl.d.ts +3 -0
  33. package/dist/controls/setControl.js +6 -0
  34. package/dist/controls/store.d.ts +12 -0
  35. package/dist/controls/store.js +125 -0
  36. package/dist/controls/types.d.ts +111 -0
  37. package/dist/controls/types.js +6 -0
  38. package/dist/controls/useControl.d.ts +7 -0
  39. package/dist/controls/useControl.js +16 -0
  40. package/dist/define/buildManifest.d.ts +8 -0
  41. package/dist/define/buildManifest.js +51 -0
  42. package/dist/define/checkDefaults.d.ts +3 -0
  43. package/dist/define/checkDefaults.js +29 -0
  44. package/dist/define/defineApp.d.ts +24 -0
  45. package/dist/define/defineApp.js +92 -0
  46. package/dist/define/defineBlock.d.ts +12 -0
  47. package/dist/define/defineBlock.js +10 -0
  48. package/dist/define/defineScreen.d.ts +11 -0
  49. package/dist/define/defineScreen.js +10 -0
  50. package/dist/define/part.d.ts +23 -0
  51. package/dist/define/part.js +22 -0
  52. package/dist/define/registeredApp.d.ts +12 -0
  53. package/dist/define/registeredApp.js +5 -0
  54. package/dist/draw/boolean.d.ts +6 -0
  55. package/dist/draw/boolean.js +5 -0
  56. package/dist/draw/choose.d.ts +4 -0
  57. package/dist/draw/choose.js +4 -0
  58. package/dist/draw/color.d.ts +2 -0
  59. package/dist/draw/color.js +4 -0
  60. package/dist/draw/number.d.ts +5 -0
  61. package/dist/draw/number.js +5 -0
  62. package/dist/draw/text.d.ts +5 -0
  63. package/dist/draw/text.js +5 -0
  64. package/dist/draw/variant.d.ts +17 -0
  65. package/dist/draw/variant.js +4 -0
  66. package/dist/index.d.ts +18 -0
  67. package/dist/index.js +14 -0
  68. package/dist/protocol/protocol.d.ts +8 -0
  69. package/dist/protocol/protocol.js +8 -0
  70. package/dist/protocol/types.d.ts +133 -0
  71. package/dist/protocol/types.js +1 -0
  72. package/dist/vite/builds.d.ts +29 -0
  73. package/dist/vite/builds.js +196 -0
  74. package/dist/vite/checkout.d.ts +11 -0
  75. package/dist/vite/checkout.js +43 -0
  76. package/dist/vite/environment.d.ts +9 -0
  77. package/dist/vite/environment.js +10 -0
  78. package/dist/vite/git.d.ts +7 -0
  79. package/dist/vite/git.js +18 -0
  80. package/dist/vite/index.d.ts +1 -0
  81. package/dist/vite/index.js +1 -0
  82. package/dist/vite/serveBuild.d.ts +4 -0
  83. package/dist/vite/serveBuild.js +39 -0
  84. package/dist/vite/versions.d.ts +44 -0
  85. package/dist/vite/versions.js +122 -0
  86. package/dist/vite/workbenchLocal.d.ts +21 -0
  87. package/dist/vite/workbenchLocal.js +186 -0
  88. package/docs/protocol.md +58 -0
  89. package/package.json +60 -3
package/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 · 2026-11-03 17:34 +04:00
4
+
5
+ - Start keeping versions and this changelog
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Beka Bendeliani
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,177 @@
1
- # Temporary Holding Version
1
+ # @tilewise/workbench-client
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connects a React prototype to the workbench's Proto viewer. The viewer shows
4
+ the prototype in a frame, draws a device round it, lists its screens and
5
+ states, and draws a form of controls beside it for the page on screen. This
6
+ package holds everything the prototype needs for that; the prototype only
7
+ declares what it has.
8
+
9
+ ```bash
10
+ pnpm add @tilewise/workbench-client
11
+ ```
12
+
13
+ React 19 is a peer dependency, so the app's own React is the one used.
14
+
15
+ ## Controls
16
+
17
+ A control is one value a reviewer can change from the viewer. There are five
18
+ types, and the viewer draws one fixed row for each:
19
+
20
+ | Type | For | Its own fields | Value |
21
+ | --- | --- | --- | --- |
22
+ | `variant` | Another version of a screen or of one part of it | `options`, each `{ value, label }` | One of the values |
23
+ | `boolean` | Show or hide one thing | None | `true` or `false` |
24
+ | `number` | A count between a min and a max | `min`, `max`, `step` | A number in range |
25
+ | `color` | A color from the app's tokens, never a free hex | `options`, each `{ token, label }` | One of the tokens |
26
+ | `text` | One line of text, to try other wording | `maxLength` | A string |
27
+
28
+ Every control also has an `id` (unique across the app, never renamed), a
29
+ `label`, a `default`, and an optional `note`, shown quietly under its row.
30
+
31
+ ### Declaring
32
+
33
+ Every component lives in a folder named after it, beside its test. A
34
+ component with controls holds its part there too, a `.workbench.ts` file:
35
+
36
+ ```ts
37
+ // HomeScreen/HomeScreen.workbench.ts
38
+ import { defineScreen } from '@tilewise/workbench-client';
39
+
40
+ export const home = defineScreen({
41
+ screen: 'home',
42
+ controls: [
43
+ {
44
+ id: 'homepage',
45
+ type: 'variant',
46
+ label: 'Page',
47
+ options: [
48
+ { value: 'today', label: 'Original design' },
49
+ { value: 'feed', label: 'Feed (old)' },
50
+ ],
51
+ default: 'today',
52
+ },
53
+ { id: 'bottom', type: 'boolean', label: 'Bottom section', default: true },
54
+ ],
55
+ });
56
+ ```
57
+
58
+ | Level | Declared with | Shows in the form |
59
+ | --- | --- | --- |
60
+ | Global | `defineApp`, beside the app's root component | On every page |
61
+ | Screen | `defineScreen({ screen, controls })` | On that screen |
62
+ | Block | `defineBlock({ block, label, controls })` | On any screen, while the block is drawn there |
63
+
64
+ Each returns one handle per control, under its id: `home.homepage`. The
65
+ values' types come from the declaration, so `home.homepage` reads as
66
+ `'today' | 'feed'` with no `as const`.
67
+
68
+ The app has exactly one `defineApp`, beside its root component:
69
+
70
+ ```ts
71
+ // App/App.workbench.ts
72
+ import { defineApp } from '@tilewise/workbench-client';
73
+
74
+ export const app = defineApp({
75
+ app: 'aitems',
76
+ screens: allScreens(), // every screen and its states, each state with its jump
77
+ useNavigation, // a hook: { go(state), at: { screen, state } }
78
+ parts: import.meta.glob(['../**/*.workbench.ts', '!./App.workbench.ts'], { eager: true }),
79
+ controls: [{ id: 'seeall', type: 'variant', label: 'See all', options: [/* … */], default: 'ink' }],
80
+ });
81
+ ```
82
+
83
+ `parts` loads every part as the app starts. As it starts, the app throws on
84
+ two controls with one id, two blocks with one name, a screen id that isn't in
85
+ `screens`, a default that isn't one of the options or is outside `min` and
86
+ `max`, and a second `defineApp`.
87
+
88
+ ### Values
89
+
90
+ A value comes from `?<id>=<value>` in the address first (and is stored, so a
91
+ link can hand a variant round), then from `control:<id>` in the browser's
92
+ storage, then from the default. Every value is checked against its
93
+ declaration: one that doesn't fit falls back to the default, a number is
94
+ clamped and text is cut at `maxLength`. Only a picked value is stored, so a
95
+ new default reaches everyone who left that control alone. Another tab of the
96
+ app follows a change through storage.
97
+
98
+ ## In components
99
+
100
+ ```tsx
101
+ // HomeScreen/HomeScreen.tsx
102
+ import { boolean, ScreenScope, useControl, variant } from '@tilewise/workbench-client';
103
+ import { home } from './HomeScreen.workbench';
104
+
105
+ export function HomeScreen() {
106
+ const page = useControl(home.homepage);
107
+ const bottom = useControl(home.bottom);
108
+
109
+ return (
110
+ <ScreenScope screen="home">
111
+ <StatusRow />
112
+ {variant(page, { today: <TodayPage />, feed: null })}
113
+ {boolean(bottom, { on: <BelowFeed />, off: null })}
114
+ </ScreenScope>
115
+ );
116
+ }
117
+ ```
118
+
119
+ - `useControl(handle)` reads a value and draws again when it changes;
120
+ `setControl(handle, value)` and `resetControl(handle)` change it. A test
121
+ sets the values it needs with `setControl` before it renders.
122
+ - A component never compares a value to choose what to draw. It hands the
123
+ value to the helper for its type, so the type checker sees every case:
124
+
125
+ | Helper | Cases |
126
+ | --- | --- |
127
+ | `variant(value, cases)` | Every option, or some and `other` for the rest |
128
+ | `boolean(value, { on, off })` | Both |
129
+ | `number(value, cases)` | Any exact numbers, and `other` |
130
+ | `text(value, cases)` | Any exact lines, and `other` |
131
+ | `color(token)` | None: `color('lime')` is `'var(--lime)'` |
132
+
133
+ A case is something to draw, or a function that gets the value. `other`'s
134
+ function gets it typed without the options listed beside it. The helpers
135
+ take values, not handles, so they work anywhere.
136
+ - A block's `useControl` reports the block as drawn on the screen of the
137
+ nearest `<ScreenScope>`, until it unmounts, so the form shows a section for
138
+ it. Wrap each screen's content in one. A control that decides whether a
139
+ block is mounted at all lives with the parent that mounts it.
140
+ - Global values are written to the root as `html[data-<id>]`, and a number or
141
+ color also as `--tweak-<id>`, for pure-CSS tweaks. A screen's or block's
142
+ value goes on the component's own root, as `data-<id>`, for its CSS.
143
+ - Mark the part a control changes `data-control="<id> …"`: after the viewer
144
+ sets or resets it, the screen scrolls there.
145
+ - Mark each screen's scrolling body `data-workbench-body` while it is the open
146
+ one: on Full page, the app reports how tall the open screen is laid out
147
+ whole from it.
148
+
149
+ ## The bridge
150
+
151
+ Mount `<Bridge />` once at the app's root. In the viewer's frame it says
152
+ `hello` with the manifest, sends where the app is, the blocks drawn on each
153
+ screen and every value as they change, and does what the viewer asks. It lays
154
+ the app out for the device the viewer draws round it: `--safe-top`,
155
+ `--safe-bottom` and `html[data-platform]`; until a device arrives, the app's
156
+ own CSS holds the first phone's. Outside a frame it only opens the state
157
+ `?at=` names. [docs/protocol.md](docs/protocol.md) has the protocol.
158
+
159
+ ## Local builds
160
+
161
+ ```ts
162
+ // vite.config.ts
163
+ import { workbenchLocal } from '@tilewise/workbench-client/vite';
164
+
165
+ export default defineConfig({ plugins: [react(), workbenchLocal({ base: 'develop' })] });
166
+ ```
167
+
168
+ With it, the app's dev server also answers the viewer's Local source: it
169
+ lists this machine's branches and commits against `base` (the repository's
170
+ default branch unless set), builds any commit in a git worktree into the
171
+ git-ignored `.builds/<sha>/`, and checks a branch out, refused while
172
+ anything is uncommitted. Builds are served under `/__workbench/b/<sha>/`, so
173
+ the app's router takes `basepath: import.meta.env.BASE_URL`.
174
+
175
+ Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
176
+ localhost) with the token in the link the dev server logs. `pnpm dev` opens
177
+ that link; without `WORKBENCH_URL` the dev server opens no viewer.
@@ -0,0 +1,11 @@
1
+ import type { ReactNode } from 'react';
2
+ /**
3
+ * Tells the blocks inside it which screen they are on. Each screen component
4
+ * wraps its content in one, so a block counts for the screen it sits in: tab
5
+ * roots stay mounted while hidden, and a block in a hidden tab never counts
6
+ * for the screen on show.
7
+ */
8
+ export declare function ScreenScope({ screen, children }: {
9
+ screen: string;
10
+ children?: ReactNode;
11
+ }): import("react").JSX.Element;
@@ -0,0 +1,11 @@
1
+ import { jsx as _jsx } from "react/jsx-runtime";
2
+ import { ScreenContext } from './screenContext.js';
3
+ /**
4
+ * Tells the blocks inside it which screen they are on. Each screen component
5
+ * wraps its content in one, so a block counts for the screen it sits in: tab
6
+ * roots stay mounted while hidden, and a block in a hidden tab never counts
7
+ * for the screen on show.
8
+ */
9
+ export function ScreenScope({ screen, children }) {
10
+ return _jsx(ScreenContext, { value: screen, children: children });
11
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Which blocks are drawn on which screen now. A block's `useControl` reports
3
+ * it while mounted, and the bridge tells the viewer, so the form shows a
4
+ * section for each. A block drawn twice on one screen counts once; a screen
5
+ * keeps its blocks in the order they were first reported.
6
+ */
7
+ /** Reports a block as drawn on a screen; the returned function takes it out. */
8
+ export declare function reportBlock(screen: string, block: string): () => void;
9
+ /** Every screen that has had blocks, with the ones drawn there now. The same object until one changes. */
10
+ export declare const blocksNow: () => ReadonlyMap<string, readonly string[]>;
11
+ export declare function subscribeBlocks(listener: () => void): () => void;
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Which blocks are drawn on which screen now. A block's `useControl` reports
3
+ * it while mounted, and the bridge tells the viewer, so the form shows a
4
+ * section for each. A block drawn twice on one screen counts once; a screen
5
+ * keeps its blocks in the order they were first reported.
6
+ */
7
+ const drawn = new Map();
8
+ const listeners = new Set();
9
+ let snapshot = new Map();
10
+ function changed() {
11
+ snapshot = new Map([...drawn].map(([screen, blocks]) => [screen, [...blocks.keys()]]));
12
+ for (const listener of listeners) {
13
+ listener();
14
+ }
15
+ }
16
+ /** Reports a block as drawn on a screen; the returned function takes it out. */
17
+ export function reportBlock(screen, block) {
18
+ let blocks = drawn.get(screen);
19
+ if (!blocks) {
20
+ blocks = new Map();
21
+ drawn.set(screen, blocks);
22
+ }
23
+ const count = blocks.get(block) ?? 0;
24
+ blocks.set(block, count + 1);
25
+ if (count === 0) {
26
+ changed();
27
+ }
28
+ const on = blocks;
29
+ return () => {
30
+ const now = on.get(block) ?? 0;
31
+ if (now > 1) {
32
+ on.set(block, now - 1);
33
+ }
34
+ else if (now === 1) {
35
+ on.delete(block);
36
+ changed();
37
+ }
38
+ };
39
+ }
40
+ /** Every screen that has had blocks, with the ones drawn there now. The same object until one changes. */
41
+ export const blocksNow = () => snapshot;
42
+ export function subscribeBlocks(listener) {
43
+ listeners.add(listener);
44
+ return () => {
45
+ listeners.delete(listener);
46
+ };
47
+ }
@@ -0,0 +1,2 @@
1
+ /** The screen a component is drawn on, from the nearest `<ScreenScope>`. */
2
+ export declare const ScreenContext: import("react").Context<string | null>;
@@ -0,0 +1,3 @@
1
+ import { createContext } from 'react';
2
+ /** The screen a component is drawn on, from the nearest `<ScreenScope>`. */
3
+ export const ScreenContext = createContext(null);
@@ -0,0 +1,9 @@
1
+ /**
2
+ * Talks to the viewer; mounted once at the app's root. In the viewer's frame
3
+ * it says `hello` with the manifest, and again whenever asked; sends `at`,
4
+ * `blocks` and `values` as they change, and `tone` and `height`; does what
5
+ * the viewer asks (`go`, `set`, `reset`), scrolling to the marked part after
6
+ * `set` or `reset`; and lays the app out for the `device`. Outside a frame it
7
+ * only takes `?at=` from the address.
8
+ */
9
+ export declare function Bridge(): null;
@@ -0,0 +1,120 @@
1
+ import { useEffect, useRef, useSyncExternalStore } from 'react';
2
+ import { blocksNow, subscribeBlocks } from '../blocks/drawnBlocks.js';
3
+ import { resetAll, resetValue, setValue, subscribeValues, valuesNow } from '../controls/store.js';
4
+ import { registeredApp } from '../define/registeredApp.js';
5
+ import { PROTOCOL } from '../protocol/protocol.js';
6
+ import { applyDevice } from './device.js';
7
+ import { isFramed, post } from './post.js';
8
+ import { readViewerMessage } from './readViewerMessage.js';
9
+ import { revealControl } from './revealControl.js';
10
+ import { watchScreen } from './watchScreen.js';
11
+ const nothingSent = () => ({ at: null, values: null, blocks: new Map() });
12
+ /** Tells the viewer what changed since it was last told; `answer` sends `values` even when it didn't. */
13
+ function sync(sent, now, answer = false) {
14
+ const at = JSON.stringify([now.at.screen, now.at.state]);
15
+ if (at !== sent.at) {
16
+ sent.at = at;
17
+ post({ type: 'at', screen: now.at.screen, state: now.at.state });
18
+ }
19
+ const values = JSON.stringify(now.values);
20
+ if (answer || values !== sent.values) {
21
+ sent.values = values;
22
+ post({ type: 'values', values: { ...now.values } });
23
+ }
24
+ for (const [screen, blocks] of now.blocks) {
25
+ const list = JSON.stringify(blocks);
26
+ if (list !== sent.blocks.get(screen)) {
27
+ sent.blocks.set(screen, list);
28
+ post({ type: 'blocks', screen, blocks: [...blocks] });
29
+ }
30
+ }
31
+ }
32
+ function useApp() {
33
+ const app = registeredApp();
34
+ if (!app) {
35
+ throw new Error('workbench-client: <Bridge /> needs the app declared with defineApp first.');
36
+ }
37
+ return app;
38
+ }
39
+ /**
40
+ * Talks to the viewer; mounted once at the app's root. In the viewer's frame
41
+ * it says `hello` with the manifest, and again whenever asked; sends `at`,
42
+ * `blocks` and `values` as they change, and `tone` and `height`; does what
43
+ * the viewer asks (`go`, `set`, `reset`), scrolling to the marked part after
44
+ * `set` or `reset`; and lays the app out for the `device`. Outside a frame it
45
+ * only takes `?at=` from the address.
46
+ */
47
+ export function Bridge() {
48
+ const app = useApp();
49
+ const navigation = app.useNavigation();
50
+ const latest = useRef(navigation);
51
+ const values = useSyncExternalStore(subscribeValues, valuesNow, valuesNow);
52
+ const blocks = useSyncExternalStore(subscribeBlocks, blocksNow, blocksNow);
53
+ const sent = useRef(nothingSent());
54
+ const { screen, state } = navigation.at;
55
+ useEffect(() => {
56
+ latest.current = navigation;
57
+ });
58
+ useEffect(() => {
59
+ const at = new URLSearchParams(window.location.search).get('at');
60
+ if (at) {
61
+ latest.current.go(at);
62
+ }
63
+ }, []);
64
+ useEffect(() => {
65
+ if (!isFramed()) {
66
+ return;
67
+ }
68
+ const now = () => ({ at: latest.current.at, values: valuesNow(), blocks: blocksNow() });
69
+ const hello = () => {
70
+ post({ type: 'hello', protocol: PROTOCOL, manifest: app.manifest });
71
+ sent.current = nothingSent();
72
+ sync(sent.current, now());
73
+ };
74
+ const onMessage = (event) => {
75
+ if (event.source !== window.parent) {
76
+ return;
77
+ }
78
+ const message = readViewerMessage(event.data);
79
+ switch (message?.type) {
80
+ case 'hello':
81
+ hello();
82
+ break;
83
+ case 'go':
84
+ latest.current.go(message.state);
85
+ break;
86
+ case 'set':
87
+ if (setValue(message.id, message.value)) {
88
+ sync(sent.current, now(), true);
89
+ revealControl(message.id);
90
+ }
91
+ break;
92
+ case 'reset':
93
+ if (message.id === undefined) {
94
+ resetAll();
95
+ sync(sent.current, now(), true);
96
+ }
97
+ else if (resetValue(message.id)) {
98
+ sync(sent.current, now(), true);
99
+ revealControl(message.id);
100
+ }
101
+ break;
102
+ case 'device':
103
+ applyDevice(message);
104
+ break;
105
+ }
106
+ };
107
+ window.addEventListener('message', onMessage);
108
+ hello();
109
+ return () => {
110
+ window.removeEventListener('message', onMessage);
111
+ };
112
+ }, [app]);
113
+ useEffect(() => {
114
+ if (isFramed()) {
115
+ sync(sent.current, { at: { screen, state }, values, blocks });
116
+ }
117
+ }, [screen, state, values, blocks]);
118
+ useEffect(() => (isFramed() ? watchScreen(post) : undefined), []);
119
+ return null;
120
+ }
@@ -0,0 +1,6 @@
1
+ import type { DeviceMessage } from '../protocol/types.js';
2
+ export declare function applyDevice(message: DeviceMessage): void;
3
+ /** Whether the viewer shows the app on Full page. */
4
+ export declare const isWhole: () => boolean;
5
+ /** Who hears that the device changed: the screen watcher, while the bridge runs. */
6
+ export declare function onDeviceChange(listener: (() => void) | null): void;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The device the viewer draws round the app. Its insets become
3
+ * `--safe-top` and `--safe-bottom` in px; until a `device` arrives, the
4
+ * app's own CSS holds the first phone's. Its platform becomes
5
+ * `html[data-platform]`. On Full page (`whole`) there is no device, and the
6
+ * app reports how tall the open screen is laid out whole.
7
+ */
8
+ let whole = false;
9
+ let onChange = null;
10
+ export function applyDevice(message) {
11
+ const root = document.documentElement;
12
+ root.style.setProperty('--safe-top', `${Math.max(0, message.insets.top)}px`);
13
+ root.style.setProperty('--safe-bottom', `${Math.max(0, message.insets.bottom)}px`);
14
+ if (message.platform) {
15
+ root.setAttribute('data-platform', message.platform);
16
+ }
17
+ else {
18
+ root.removeAttribute('data-platform');
19
+ }
20
+ whole = message.whole;
21
+ onChange?.();
22
+ }
23
+ /** Whether the viewer shows the app on Full page. */
24
+ export const isWhole = () => whole;
25
+ /** Who hears that the device changed: the screen watcher, while the bridge runs. */
26
+ export function onDeviceChange(listener) {
27
+ onChange = listener;
28
+ }
@@ -0,0 +1,5 @@
1
+ import type { AppSays } from '../protocol/types.js';
2
+ /** Says something to the viewer around the frame, tagged as the app's. */
3
+ export declare function post(message: AppSays): void;
4
+ /** Whether the app is in the viewer's frame, rather than a page of its own. */
5
+ export declare const isFramed: () => boolean;
@@ -0,0 +1,8 @@
1
+ import { FROM_APP } from '../protocol/protocol.js';
2
+ /** Says something to the viewer around the frame, tagged as the app's. */
3
+ export function post(message) {
4
+ const tagged = { source: FROM_APP, ...message };
5
+ window.parent.postMessage(tagged, '*');
6
+ }
7
+ /** Whether the app is in the viewer's frame, rather than a page of its own. */
8
+ export const isFramed = () => window.parent !== window;
@@ -0,0 +1,3 @@
1
+ import type { ViewerSays } from '../protocol/types.js';
2
+ /** What the viewer said, or null for anything else posted to the window. */
3
+ export declare function readViewerMessage(data: unknown): ViewerSays | null;
@@ -0,0 +1,44 @@
1
+ import { FROM_VIEWER } from '../protocol/protocol.js';
2
+ const isValue = (value) => ['string', 'number', 'boolean'].includes(typeof value);
3
+ /** What the viewer said, or null for anything else posted to the window. */
4
+ export function readViewerMessage(data) {
5
+ if (typeof data !== 'object' || data === null) {
6
+ return null;
7
+ }
8
+ const message = data;
9
+ if (message['source'] !== FROM_VIEWER) {
10
+ return null;
11
+ }
12
+ switch (message['type']) {
13
+ case 'hello':
14
+ return { type: 'hello' };
15
+ case 'go':
16
+ return typeof message['state'] === 'string' ? { type: 'go', state: message['state'] } : null;
17
+ case 'set':
18
+ return typeof message['id'] === 'string' && isValue(message['value'])
19
+ ? { type: 'set', id: message['id'], value: message['value'] }
20
+ : null;
21
+ case 'reset':
22
+ if (message['id'] === undefined) {
23
+ return { type: 'reset' };
24
+ }
25
+ return typeof message['id'] === 'string' ? { type: 'reset', id: message['id'] } : null;
26
+ case 'device':
27
+ return readDevice(message);
28
+ default:
29
+ return null;
30
+ }
31
+ }
32
+ function readDevice(message) {
33
+ const insets = message['insets'];
34
+ const platform = message['platform'];
35
+ if (typeof insets?.top !== 'number' || typeof insets.bottom !== 'number') {
36
+ return null;
37
+ }
38
+ return {
39
+ type: 'device',
40
+ insets: { top: insets.top, bottom: insets.bottom },
41
+ platform: platform === 'ios' || platform === 'android' ? platform : null,
42
+ whole: message['whole'] === true,
43
+ };
44
+ }
@@ -0,0 +1,10 @@
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
+ /** The first part marked with the control that is on show; a hidden tab's has no box. */
9
+ export declare function firstShown(id: string): HTMLElement | null;
10
+ export declare function revealControl(id: string): void;
@@ -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,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 {};