@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.
- package/CHANGELOG.md +25 -0
- package/LICENSE +21 -0
- package/README.md +210 -2
- package/dist/blocks/ScreenScope.d.ts +11 -0
- package/dist/blocks/ScreenScope.js +11 -0
- package/dist/blocks/drawnBlocks.d.ts +11 -0
- package/dist/blocks/drawnBlocks.js +47 -0
- package/dist/blocks/screenContext.d.ts +2 -0
- package/dist/blocks/screenContext.js +3 -0
- package/dist/bridge/Bridge.d.ts +10 -0
- package/dist/bridge/Bridge.js +129 -0
- package/dist/bridge/device.d.ts +6 -0
- package/dist/bridge/device.js +28 -0
- package/dist/bridge/post.d.ts +5 -0
- package/dist/bridge/post.js +8 -0
- package/dist/bridge/readViewerMessage.d.ts +3 -0
- package/dist/bridge/readViewerMessage.js +46 -0
- package/dist/bridge/revealControl.d.ts +10 -0
- package/dist/bridge/revealControl.js +62 -0
- package/dist/bridge/snapshot.d.ts +7 -0
- package/dist/bridge/snapshot.js +47 -0
- package/dist/bridge/toneAtTop.d.ts +3 -0
- package/dist/bridge/toneAtTop.js +23 -0
- package/dist/bridge/watchScreen.d.ts +10 -0
- package/dist/bridge/watchScreen.js +50 -0
- package/dist/bridge/wholeHeight.d.ts +8 -0
- package/dist/bridge/wholeHeight.js +16 -0
- package/dist/controls/paint.d.ts +8 -0
- package/dist/controls/paint.js +16 -0
- package/dist/controls/parseValue.d.ts +7 -0
- package/dist/controls/parseValue.js +34 -0
- package/dist/controls/resetControl.d.ts +3 -0
- package/dist/controls/resetControl.js +6 -0
- package/dist/controls/setControl.d.ts +3 -0
- package/dist/controls/setControl.js +6 -0
- package/dist/controls/store.d.ts +12 -0
- package/dist/controls/store.js +125 -0
- package/dist/controls/types.d.ts +111 -0
- package/dist/controls/types.js +6 -0
- package/dist/controls/useControl.d.ts +7 -0
- package/dist/controls/useControl.js +16 -0
- package/dist/define/buildManifest.d.ts +8 -0
- package/dist/define/buildManifest.js +51 -0
- package/dist/define/checkDefaults.d.ts +3 -0
- package/dist/define/checkDefaults.js +29 -0
- package/dist/define/defineApp.d.ts +24 -0
- package/dist/define/defineApp.js +92 -0
- package/dist/define/defineBlock.d.ts +12 -0
- package/dist/define/defineBlock.js +10 -0
- package/dist/define/defineScreen.d.ts +11 -0
- package/dist/define/defineScreen.js +10 -0
- package/dist/define/part.d.ts +23 -0
- package/dist/define/part.js +22 -0
- package/dist/define/registeredApp.d.ts +12 -0
- package/dist/define/registeredApp.js +5 -0
- package/dist/draw/boolean.d.ts +6 -0
- package/dist/draw/boolean.js +5 -0
- package/dist/draw/choose.d.ts +4 -0
- package/dist/draw/choose.js +4 -0
- package/dist/draw/color.d.ts +2 -0
- package/dist/draw/color.js +4 -0
- package/dist/draw/number.d.ts +5 -0
- package/dist/draw/number.js +5 -0
- package/dist/draw/text.d.ts +5 -0
- package/dist/draw/text.js +5 -0
- package/dist/draw/variant.d.ts +17 -0
- package/dist/draw/variant.js +4 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.js +14 -0
- package/dist/protocol/protocol.d.ts +8 -0
- package/dist/protocol/protocol.js +8 -0
- package/dist/protocol/types.d.ts +144 -0
- package/dist/protocol/types.js +1 -0
- package/dist/vite/apiFetch.d.ts +7 -0
- package/dist/vite/apiFetch.js +15 -0
- package/dist/vite/apiWorkers.d.ts +11 -0
- package/dist/vite/apiWorkers.js +111 -0
- package/dist/vite/appFolder.d.ts +6 -0
- package/dist/vite/appFolder.js +11 -0
- package/dist/vite/builds.d.ts +47 -0
- package/dist/vite/builds.js +250 -0
- package/dist/vite/bundleApi.d.ts +9 -0
- package/dist/vite/bundleApi.js +32 -0
- package/dist/vite/checkout.d.ts +11 -0
- package/dist/vite/checkout.js +43 -0
- package/dist/vite/environment.d.ts +9 -0
- package/dist/vite/environment.js +10 -0
- package/dist/vite/fetchBridge.d.ts +8 -0
- package/dist/vite/fetchBridge.js +42 -0
- package/dist/vite/git.d.ts +7 -0
- package/dist/vite/git.js +18 -0
- package/dist/vite/index.d.ts +1 -0
- package/dist/vite/index.js +1 -0
- package/dist/vite/isWorkspace.d.ts +5 -0
- package/dist/vite/isWorkspace.js +10 -0
- package/dist/vite/serveBuild.d.ts +7 -0
- package/dist/vite/serveBuild.js +45 -0
- package/dist/vite/versions.d.ts +44 -0
- package/dist/vite/versions.js +122 -0
- package/dist/vite/workbenchLocal.d.ts +32 -0
- package/dist/vite/workbenchLocal.js +251 -0
- package/docs/protocol.md +66 -0
- package/package.json +63 -3
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.1 · 2026-11-06 01:07 +04:00
|
|
4
|
+
|
|
5
|
+
- feat(workbench-client): build apps in a workspace folder and answer each build's fake API (fc6dba7)
|
|
6
|
+
- Build each commit from the app's folder in the repository, or from the top for a commit from before the app moved there
|
|
7
|
+
- Install a workspace commit's own packages, so they link to that commit's and not the checkout's
|
|
8
|
+
- Keep a commit with no workspace file of its own from taking the checkout's
|
|
9
|
+
- Run the commit's own Vite, or this plugin's when the commit has none installed
|
|
10
|
+
- Add the api option: a fake API's fetch handler, answered at /api for Live from its source
|
|
11
|
+
- Bundle each commit's fake API with its build and answer it under the build, in a worker of its own with no environment, stopped after 30 minutes idle
|
|
12
|
+
- Never serve the bundle as a file of the build
|
|
13
|
+
- Describe workspaces and the fake API in README and CLAUDE.md
|
|
14
|
+
- feat(proto,workbench-client): show the app on the iPhone Duo's halves as it folds (e34be11)
|
|
15
|
+
- Answer the viewer's snapshot with a picture of the screen, handed over as a bitmap, in protocol 4
|
|
16
|
+
- Take the picture with modern-screenshot, loaded with the first one asked for, leaving out hidden tabs and what's out of view
|
|
17
|
+
- Ask for a picture before a fold and another once the app is laid out the new way, waiting up to 0.7 s
|
|
18
|
+
- Show the app as it was on the half that lifts, and laid out the new way on the half that lands
|
|
19
|
+
- Let the app lay itself out closed under the picture while the Duo shuts, so the closed phone lands lit
|
|
20
|
+
- Fold dark as before when a build doesn't answer
|
|
21
|
+
- Read protocol 4 builds as protocol 3's
|
|
22
|
+
|
|
23
|
+
## 0.1.0 · 2026-11-03 17:34 +04:00
|
|
24
|
+
|
|
25
|
+
- 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,211 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @tilewise/workbench-client
|
|
2
2
|
|
|
3
|
-
|
|
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. When the viewer folds a foldable it asks
|
|
157
|
+
for a picture of the screen, to show on the half that swings: the bridge
|
|
158
|
+
takes it with [modern-screenshot](https://github.com/qq15725/modern-screenshot),
|
|
159
|
+
loaded only then. Outside a frame it only opens the state `?at=` names. [docs/protocol.md](docs/protocol.md) has the protocol.
|
|
160
|
+
|
|
161
|
+
## Local builds
|
|
162
|
+
|
|
163
|
+
```ts
|
|
164
|
+
// vite.config.ts
|
|
165
|
+
import { workbenchLocal } from '@tilewise/workbench-client/vite';
|
|
166
|
+
|
|
167
|
+
export default defineConfig({ plugins: [react(), workbenchLocal({ base: 'develop' })] });
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
With it, the app's dev server also answers the viewer's Local source: it
|
|
171
|
+
lists this machine's branches and commits against `base` (the repository's
|
|
172
|
+
default branch unless set), builds any commit in a git worktree into the
|
|
173
|
+
git-ignored `.builds/<sha>/`, and checks a branch out, refused while
|
|
174
|
+
anything is uncommitted. Builds are served under `/__workbench/b/<sha>/`, so
|
|
175
|
+
the app's router takes `basepath: import.meta.env.BASE_URL`.
|
|
176
|
+
|
|
177
|
+
Only the viewer may ask: from its origin (`WORKBENCH_URL`, or a viewer on
|
|
178
|
+
localhost) with the token in the link the dev server logs. `pnpm dev` opens
|
|
179
|
+
that link; without `WORKBENCH_URL` the dev server opens no viewer.
|
|
180
|
+
|
|
181
|
+
### An app in a workspace
|
|
182
|
+
|
|
183
|
+
The app may live in a folder of a pnpm workspace, such as `apps/app/`, with
|
|
184
|
+
its dev server's root there. Each commit is built from that folder, or from
|
|
185
|
+
the top for a commit from before the app moved there. A workspace commit
|
|
186
|
+
installs its own packages (`pnpm install --frozen-lockfile --prefer-offline`),
|
|
187
|
+
so its packages link to that commit's, never the checkout's.
|
|
188
|
+
|
|
189
|
+
### A fake API
|
|
190
|
+
|
|
191
|
+
```ts
|
|
192
|
+
workbenchLocal({ base: 'develop', api: { entry: '../fake-api/src/index.ts' } });
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`entry` is the fake API's module, from the app's folder. Its default export
|
|
196
|
+
is a fetch handler: a function from `Request` to `Response`, or an object
|
|
197
|
+
with `fetch`, as a Hono app is. `path` is where the app asks it, `/api` unless
|
|
198
|
+
set. The handler is asked without that path, so `/api/bags/b1` reaches it as
|
|
199
|
+
`/bags/b1`.
|
|
200
|
+
|
|
201
|
+
- **Live:** the dev server answers it at `path`, from its source. Vite loads
|
|
202
|
+
it again when it changes.
|
|
203
|
+
- **Builds:** each build bundles its commit's fake API, with every package
|
|
204
|
+
inside it, into `.builds/<sha>/__api/`, and answers it under the build, at
|
|
205
|
+
`/__workbench/b/<sha>/api/…`. An old build asks the API it was built with,
|
|
206
|
+
whatever the checkout's has become. The app asks
|
|
207
|
+
`` `${import.meta.env.BASE_URL}api/…` ``, which is right in both.
|
|
208
|
+
- Each build's API runs in a worker of its own: loaded on its first request
|
|
209
|
+
and stopped after 30 minutes with nothing asked, so a visit's data lasts
|
|
210
|
+
until then. The worker gets no environment variables, and the bundle
|
|
211
|
+
itself is never served.
|
|
@@ -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,10 @@
|
|
|
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`; lays the app out for the `device`; and answers `snapshot`
|
|
7
|
+
* with a picture of the screen. Outside a frame it only takes `?at=` from the
|
|
8
|
+
* address.
|
|
9
|
+
*/
|
|
10
|
+
export declare function Bridge(): null;
|
|
@@ -0,0 +1,129 @@
|
|
|
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 { snapshot } from './snapshot.js';
|
|
11
|
+
import { watchScreen } from './watchScreen.js';
|
|
12
|
+
const nothingSent = () => ({ at: null, values: null, blocks: new Map() });
|
|
13
|
+
/** Tells the viewer what changed since it was last told; `answer` sends `values` even when it didn't. */
|
|
14
|
+
function sync(sent, now, answer = false) {
|
|
15
|
+
const at = JSON.stringify([now.at.screen, now.at.state]);
|
|
16
|
+
if (at !== sent.at) {
|
|
17
|
+
sent.at = at;
|
|
18
|
+
post({ type: 'at', screen: now.at.screen, state: now.at.state });
|
|
19
|
+
}
|
|
20
|
+
const values = JSON.stringify(now.values);
|
|
21
|
+
if (answer || values !== sent.values) {
|
|
22
|
+
sent.values = values;
|
|
23
|
+
post({ type: 'values', values: { ...now.values } });
|
|
24
|
+
}
|
|
25
|
+
for (const [screen, blocks] of now.blocks) {
|
|
26
|
+
const list = JSON.stringify(blocks);
|
|
27
|
+
if (list !== sent.blocks.get(screen)) {
|
|
28
|
+
sent.blocks.set(screen, list);
|
|
29
|
+
post({ type: 'blocks', screen, blocks: [...blocks] });
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function useApp() {
|
|
34
|
+
const app = registeredApp();
|
|
35
|
+
if (!app) {
|
|
36
|
+
throw new Error('workbench-client: <Bridge /> needs the app declared with defineApp first.');
|
|
37
|
+
}
|
|
38
|
+
return app;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Talks to the viewer; mounted once at the app's root. In the viewer's frame
|
|
42
|
+
* it says `hello` with the manifest, and again whenever asked; sends `at`,
|
|
43
|
+
* `blocks` and `values` as they change, and `tone` and `height`; does what
|
|
44
|
+
* the viewer asks (`go`, `set`, `reset`), scrolling to the marked part after
|
|
45
|
+
* `set` or `reset`; lays the app out for the `device`; and answers `snapshot`
|
|
46
|
+
* with a picture of the screen. Outside a frame it only takes `?at=` from the
|
|
47
|
+
* address.
|
|
48
|
+
*/
|
|
49
|
+
export function Bridge() {
|
|
50
|
+
const app = useApp();
|
|
51
|
+
const navigation = app.useNavigation();
|
|
52
|
+
const latest = useRef(navigation);
|
|
53
|
+
const values = useSyncExternalStore(subscribeValues, valuesNow, valuesNow);
|
|
54
|
+
const blocks = useSyncExternalStore(subscribeBlocks, blocksNow, blocksNow);
|
|
55
|
+
const sent = useRef(nothingSent());
|
|
56
|
+
const { screen, state } = navigation.at;
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
latest.current = navigation;
|
|
59
|
+
});
|
|
60
|
+
useEffect(() => {
|
|
61
|
+
const at = new URLSearchParams(window.location.search).get('at');
|
|
62
|
+
if (at) {
|
|
63
|
+
latest.current.go(at);
|
|
64
|
+
}
|
|
65
|
+
}, []);
|
|
66
|
+
useEffect(() => {
|
|
67
|
+
if (!isFramed()) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const now = () => ({ at: latest.current.at, values: valuesNow(), blocks: blocksNow() });
|
|
71
|
+
const hello = () => {
|
|
72
|
+
post({ type: 'hello', protocol: PROTOCOL, manifest: app.manifest });
|
|
73
|
+
sent.current = nothingSent();
|
|
74
|
+
sync(sent.current, now());
|
|
75
|
+
};
|
|
76
|
+
const onMessage = (event) => {
|
|
77
|
+
if (event.source !== window.parent) {
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
const message = readViewerMessage(event.data);
|
|
81
|
+
switch (message?.type) {
|
|
82
|
+
case 'hello':
|
|
83
|
+
hello();
|
|
84
|
+
break;
|
|
85
|
+
case 'go':
|
|
86
|
+
latest.current.go(message.state);
|
|
87
|
+
break;
|
|
88
|
+
case 'set':
|
|
89
|
+
if (setValue(message.id, message.value)) {
|
|
90
|
+
sync(sent.current, now(), true);
|
|
91
|
+
revealControl(message.id);
|
|
92
|
+
}
|
|
93
|
+
break;
|
|
94
|
+
case 'reset':
|
|
95
|
+
if (message.id === undefined) {
|
|
96
|
+
resetAll();
|
|
97
|
+
sync(sent.current, now(), true);
|
|
98
|
+
}
|
|
99
|
+
else if (resetValue(message.id)) {
|
|
100
|
+
sync(sent.current, now(), true);
|
|
101
|
+
revealControl(message.id);
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
case 'device':
|
|
105
|
+
applyDevice(message);
|
|
106
|
+
break;
|
|
107
|
+
case 'snapshot': {
|
|
108
|
+
const { id } = message;
|
|
109
|
+
void snapshot().then((image) => {
|
|
110
|
+
post({ type: 'snapshot', id, image }, image ? [image] : []);
|
|
111
|
+
});
|
|
112
|
+
break;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
window.addEventListener('message', onMessage);
|
|
117
|
+
hello();
|
|
118
|
+
return () => {
|
|
119
|
+
window.removeEventListener('message', onMessage);
|
|
120
|
+
};
|
|
121
|
+
}, [app]);
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
if (isFramed()) {
|
|
124
|
+
sync(sent.current, { at: { screen, state }, values, blocks });
|
|
125
|
+
}
|
|
126
|
+
}, [screen, state, values, blocks]);
|
|
127
|
+
useEffect(() => (isFramed() ? watchScreen(post) : undefined), []);
|
|
128
|
+
return null;
|
|
129
|
+
}
|
|
@@ -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; `transfer` is handed over, not copied. */
|
|
3
|
+
export declare function post(message: AppSays, transfer?: Transferable[]): 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; `transfer` is handed over, not copied. */
|
|
3
|
+
export function post(message, transfer = []) {
|
|
4
|
+
const tagged = { source: FROM_APP, ...message };
|
|
5
|
+
window.parent.postMessage(tagged, '*', transfer);
|
|
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,46 @@
|
|
|
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
|
+
case 'snapshot':
|
|
29
|
+
return typeof message['id'] === 'number' ? { type: 'snapshot', id: message['id'] } : null;
|
|
30
|
+
default:
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
function readDevice(message) {
|
|
35
|
+
const insets = message['insets'];
|
|
36
|
+
const platform = message['platform'];
|
|
37
|
+
if (typeof insets?.top !== 'number' || typeof insets.bottom !== 'number') {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
type: 'device',
|
|
42
|
+
insets: { top: insets.top, bottom: insets.bottom },
|
|
43
|
+
platform: platform === 'ios' || platform === 'android' ? platform : null,
|
|
44
|
+
whole: message['whole'] === true,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -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;
|