@tio-canvas/canvas 0.1.3 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -91
- package/client/canvas.css +59 -10
- package/client/canvas.js +97 -11
- package/client/changelog.js +11 -0
- package/client/index.html +13 -2
- package/client/install-guide.js +73 -0
- package/client/templates/canvas-screens.mjs +25 -0
- package/client/templates/canvas-server.mjs +52 -0
- package/client/text.js +3 -3
- package/client/update-check.js +25 -0
- package/client/update-guide.js +45 -0
- package/client/version.js +2 -0
- package/docs/integration.md +103 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1,107 +1,95 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Tio Canvas
|
|
2
2
|
|
|
3
|
-
A private
|
|
4
|
-
as real,
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
visitor of the project downloads unless the project chooses to publish `/canvas`.
|
|
3
|
+
A private board for your website. It shows the pages of your site side by side,
|
|
4
|
+
as real, working pages, so you can look at them together, compare them, move
|
|
5
|
+
them around, and add notes. It runs only on your own computer while you work.
|
|
6
|
+
Visitors of your site never see it.
|
|
8
7
|
|
|
9
|
-
|
|
10
|
-
The only dependencies (`interface-kit`, `react`, `react-dom`, `esbuild`) exist to
|
|
11
|
-
build the optional development-only Interface Kit overlay and are never sent to
|
|
12
|
-
a browser except by the dev server, as one script, inside a framed screen.
|
|
8
|
+
## Install it
|
|
13
9
|
|
|
14
|
-
|
|
10
|
+
Pick one way.
|
|
15
11
|
|
|
16
|
-
|
|
12
|
+
### Option 1: Ask an AI agent (easiest)
|
|
17
13
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
```
|
|
21
|
-
|
|
22
|
-
## Use it in a project
|
|
23
|
-
|
|
24
|
-
### 1. List your screens
|
|
25
|
-
|
|
26
|
-
Create `dev/canvas/screens.js` (any path works; pass it as `config`). It is a
|
|
27
|
-
standalone ES module that the **browser loads as it stands**, so it must not
|
|
28
|
-
import anything.
|
|
29
|
-
|
|
30
|
-
```js
|
|
31
|
-
export const storageKey = 'my-project:canvas:v1'; // where the browser keeps the saved layout
|
|
32
|
-
export const screens = [
|
|
33
|
-
{ id: 'home', kind: 'production', title: 'Home', description: 'The live homepage.',
|
|
34
|
-
src: '/', x: 160, y: 160, width: 1440, height: 900, preset: 'laptop' },
|
|
35
|
-
];
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
- `id` is stable: saved layouts refer to it. `kind` is `production` or `prototype`.
|
|
39
|
-
`description` is required. Both are checked and a mistake throws.
|
|
40
|
-
- `preset` is a key of the device presets (`laptop`, `iphone-16`, `ipad-air`, …; see `client/registry.js`).
|
|
41
|
-
- `storageKey` is optional. Two projects served from one origin (for example
|
|
42
|
-
`localhost:4321`) must not share a key, or they overwrite each other's layout.
|
|
43
|
-
|
|
44
|
-
Page thumbnails live in `dev/canvas/thumbnails/<id>.webp` (see `tio-canvas thumbnails`).
|
|
45
|
-
|
|
46
|
-
### 2. Serve it from your dev server
|
|
47
|
-
|
|
48
|
-
The package does not own a server. Your dev server asks it where things are:
|
|
14
|
+
Open your project in a coding agent (Claude Code, Cursor, Codex, and so on),
|
|
15
|
+
paste this, and let it work:
|
|
49
16
|
|
|
50
|
-
```
|
|
51
|
-
|
|
52
|
-
const canvas = createCanvasHost({ root, config: 'dev/canvas/screens.js' });
|
|
17
|
+
```text
|
|
18
|
+
Install the @tio-canvas/canvas package in this project as a development tool.
|
|
53
19
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const script = await canvas.interfaceKitBundle();
|
|
61
|
-
// Reload the workspace when any of these change:
|
|
62
|
-
for (const dir of canvas.watchTargets()) watch(dir, { recursive: true }, notify);
|
|
20
|
+
1. Add it as a dev dependency with this project's package manager: @tio-canvas/canvas.
|
|
21
|
+
2. Read node_modules/@tio-canvas/canvas/docs/integration.md and follow its "Use it in a project" section.
|
|
22
|
+
3. Create dev/canvas/screens.js listing the real pages of this project (id, kind, title, description, src, x, y, width, height, preset). Give storageKey a name unique to this project. The file must not import anything.
|
|
23
|
+
4. Wire the dev server: serve the package client at /canvas with createCanvasHost from "@tio-canvas/canvas/server" (locate, projectModule, pageScripts, interfaceKitBundle, watchTargets). Do this for development only.
|
|
24
|
+
5. Do not link /canvas from any public page, and do not publish it unless I ask.
|
|
25
|
+
6. Start the dev server, open /canvas, and confirm every screen loads. Tell me the URL and anything you could not verify.
|
|
63
26
|
```
|
|
64
27
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
28
|
+
### Option 2: Install it yourself
|
|
29
|
+
|
|
30
|
+
This is for a website made of plain files (`index.html`, `about.html`, and so
|
|
31
|
+
on) in one folder. If your site is built with a tool like Next.js, Vite or
|
|
32
|
+
WordPress, use Option 1.
|
|
33
|
+
|
|
34
|
+
1. **Install Node.js.** It is a free program that runs the canvas on your
|
|
35
|
+
computer. Download the version marked **LTS** from [nodejs.org](https://nodejs.org)
|
|
36
|
+
and install it like any other app. You need version 22 or newer.
|
|
37
|
+
2. **Open the Terminal in your project folder.** The Terminal is a window where
|
|
38
|
+
you type commands. On a Mac, right-click the folder in Finder and choose
|
|
39
|
+
"New Terminal at Folder". On Windows, open the folder, click the address
|
|
40
|
+
bar, type `cmd`, and press Enter.
|
|
41
|
+
3. **Check that Node.js works.** Type this and press Enter. You should see a
|
|
42
|
+
number like `v22.12.0`.
|
|
43
|
+
```bash
|
|
44
|
+
node -v
|
|
45
|
+
```
|
|
46
|
+
4. **Install the canvas.** Type these one at a time and press Enter after each.
|
|
47
|
+
The first one creates a small list of what your project uses
|
|
48
|
+
(`package.json`). The second one adds the canvas.
|
|
49
|
+
```bash
|
|
50
|
+
npm init -y
|
|
51
|
+
npm install --save-dev @tio-canvas/canvas
|
|
52
|
+
```
|
|
53
|
+
5. **Download two files** and put them in the same folder as your `index.html`
|
|
54
|
+
(open each link, then save the page with the same name):
|
|
55
|
+
- [`canvas-server.mjs`](https://raw.githubusercontent.com/tiobedil/tio-canvas/main/client/templates/canvas-server.mjs) starts your site with the canvas.
|
|
56
|
+
- [`canvas-screens.mjs`](https://raw.githubusercontent.com/tiobedil/tio-canvas/main/client/templates/canvas-screens.mjs) lists the pages the canvas shows.
|
|
57
|
+
6. **Tell the canvas which pages to show.** Open `canvas-screens.mjs` in a text
|
|
58
|
+
editor (Notepad, or a free editor such as Visual Studio Code; not Word). Each
|
|
59
|
+
block between `{` and `}` is one page. Change `src` to the address of your
|
|
60
|
+
page, for example `/about.html`. Copy a whole block to add another page.
|
|
61
|
+
The notes at the top of the file explain every field. Save the file.
|
|
62
|
+
7. **Start it.** In the Terminal, type this and press Enter. Leave the window
|
|
63
|
+
open, because closing it stops the canvas.
|
|
64
|
+
```bash
|
|
65
|
+
node canvas-server.mjs
|
|
66
|
+
```
|
|
67
|
+
8. **Open the canvas.** Go to <http://localhost:4400/canvas/> in your browser.
|
|
68
|
+
To stop it, click the Terminal and press Ctrl+C.
|
|
69
|
+
|
|
70
|
+
If a page shows "Not found", its `src` in `canvas-screens.mjs` does not match a
|
|
71
|
+
real file name. If you see a red error in the Terminal, read the last line: it
|
|
72
|
+
names the file and the problem. Still stuck? Use Option 1.
|
|
73
|
+
|
|
74
|
+
The same steps, with copy and download buttons, are in the canvas itself under
|
|
75
|
+
**More → Install in a project**.
|
|
76
|
+
|
|
77
|
+
## Update it
|
|
78
|
+
|
|
79
|
+
The canvas shows its version at the bottom of the **More** menu. A magenta dot
|
|
80
|
+
appears there when a newer version is out. Click **Update** for steps, or run
|
|
81
|
+
this in the Terminal in your project folder, then start the canvas again:
|
|
80
82
|
|
|
81
83
|
```bash
|
|
82
|
-
|
|
83
|
-
npx tio-canvas thumbnails --check # fails when a screen has no thumbnail
|
|
84
|
+
npm install --save-dev @tio-canvas/canvas@latest
|
|
84
85
|
```
|
|
85
86
|
|
|
86
|
-
|
|
87
|
-
cache (`~/Library/Caches/ms-playwright`) or `CHROME_BIN`.
|
|
87
|
+
## Something more advanced?
|
|
88
88
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
Nothing else needs to change as long as the major version stays the same.
|
|
93
|
-
|
|
94
|
-
## Development
|
|
95
|
-
|
|
96
|
-
```bash
|
|
97
|
-
pnpm install
|
|
98
|
-
pnpm test # unit tests and a smoke test of the server and build helpers
|
|
99
|
-
```
|
|
89
|
+
Using your own dev server, publishing the canvas with your site, page
|
|
90
|
+
thumbnails, and working on the package itself are covered in
|
|
91
|
+
[`docs/integration.md`](docs/integration.md).
|
|
100
92
|
|
|
101
|
-
|
|
102
|
-
inside `tio-web` (the commit it was extracted from is in the first commit
|
|
103
|
-
message), with two changes: `screens.js` now reads `project.js` and
|
|
104
|
-
`registry.js`, and `canvas.js` takes its storage key from the project.
|
|
93
|
+
## License
|
|
105
94
|
|
|
106
|
-
|
|
107
|
-
`publishConfig`). Bump the version first and tag it `vX.Y.Z`.
|
|
95
|
+
MIT
|
package/client/canvas.css
CHANGED
|
@@ -86,10 +86,10 @@ body[data-space] #viewport { cursor: grab; } body[data-gesture="pan"] #viewport
|
|
|
86
86
|
.guide.gap[data-axis="vertical"]::before,.guide.gap[data-axis="vertical"]::after { width: var(--canvas-tick); height: var(--canvas-border); top: 0; left: 50%; transform: translateX(-50%); }
|
|
87
87
|
.guide.gap[data-axis="vertical"]::after { top: 100%; }
|
|
88
88
|
|
|
89
|
-
:is(.screen-refresh,.screen-orientation) { position: absolute; right: 0; bottom: 100%; width: 28px; height: 32px; display: grid; place-items: center; padding: 0; transform-origin: right bottom; border: 0; border-radius: var(--canvas-radius); background: transparent; color: var(--canvas-muted); cursor: pointer; }
|
|
90
|
-
:is(.screen-refresh,.screen-orientation):hover { color: var(--canvas-text); background: var(--canvas-button-hover); }
|
|
91
|
-
:is(.screen-refresh,.screen-orientation):focus-visible { outline: var(--canvas-ring) solid var(--canvas-focus); }
|
|
92
|
-
:is(.screen-refresh,.screen-orientation) svg { width: var(--canvas-icon-size); height: var(--canvas-icon-size); fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
89
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more) { position: absolute; right: 0; bottom: 100%; width: 28px; height: 32px; display: grid; place-items: center; padding: 0; transform-origin: right bottom; border: 0; border-radius: var(--canvas-radius); background: transparent; color: var(--canvas-muted); cursor: pointer; }
|
|
90
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more):hover { color: var(--canvas-text); background: var(--canvas-button-hover); }
|
|
91
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more):focus-visible { outline: var(--canvas-ring) solid var(--canvas-focus); }
|
|
92
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more) svg { width: var(--canvas-icon-size); height: var(--canvas-icon-size); fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; }
|
|
93
93
|
|
|
94
94
|
#canvas-contact { position: fixed; left: 0; top: 0; width: calc(var(--canvas-contact-size) * var(--contact-scale,1)); height: calc(var(--canvas-contact-size) * var(--contact-scale,1)); border-radius: 50%; background: var(--canvas-contact-fill); border: var(--canvas-border) solid var(--canvas-contact-line); pointer-events: none; z-index: var(--canvas-contact-layer); }
|
|
95
95
|
|
|
@@ -167,7 +167,7 @@ body[data-layer-drag] iframe { pointer-events: none; }
|
|
|
167
167
|
.screen-fullscreen svg,#fullscreen-toggle svg { width: var(--canvas-icon-size); height: var(--canvas-icon-size); fill: none; stroke: currentColor; stroke-width: 1.5; stroke-linecap: round; stroke-linejoin: round; pointer-events: none; }
|
|
168
168
|
body[data-fullscreen] #viewport { background: var(--canvas-surface); }
|
|
169
169
|
body[data-fullscreen] #world { position: fixed; inset: 0; transform: none !important; will-change: auto; }
|
|
170
|
-
body[data-fullscreen] :is(#selection,#layers,nav,#canvas-contact,#canvas-status),body[data-fullscreen] .screen:not([data-fullscreen]),.screen[data-fullscreen] :is(.screen-title,.screen-orientation,.screen-refresh,.screen-fullscreen) { display: none; }
|
|
170
|
+
body[data-fullscreen] :is(#selection,#layers,nav,#canvas-contact,#canvas-status),body[data-fullscreen] .screen:not([data-fullscreen]),.screen[data-fullscreen] :is(.screen-title,.screen-orientation,.screen-theme,.screen-more,.screen-refresh,.screen-fullscreen) { display: none; }
|
|
171
171
|
.screen[data-fullscreen] { position: absolute; left: 0 !important; top: 0 !important; width: 100% !important; height: 100% !important; outline: none; }
|
|
172
172
|
.screen[data-fullscreen] iframe { box-shadow: none; }
|
|
173
173
|
#fullscreen-tools { position: fixed; left: 0; top: 0; width: var(--canvas-control); height: var(--canvas-control); z-index: var(--canvas-preview-layer); transition: transform var(--canvas-snap-duration) var(--canvas-snap-ease); }
|
|
@@ -424,12 +424,12 @@ input:disabled { background: var(--canvas-well); }
|
|
|
424
424
|
:root[data-dark] { --canvas-inset: inset 0 1px 2px 0 oklch(0 0 0 / .5); }
|
|
425
425
|
.layers-drawer .layer-row[aria-pressed="true"],.page-option[data-checked="true"] { box-shadow: var(--canvas-inset); }
|
|
426
426
|
.icon-options button[aria-checked="true"],#edit-tools button[aria-pressed="true"],#text-inspector button[aria-pressed="true"] { box-shadow: var(--canvas-inset); }
|
|
427
|
-
.layers-drawer .layer-row,.page-option,:is(#screen-context,#layers-menu) button,#fullscreen-back,:is(.screen-refresh,.screen-orientation,.screen-fullscreen) {
|
|
427
|
+
.layers-drawer .layer-row,.page-option,:is(#screen-context,#layers-menu) button,#fullscreen-back,:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more,.screen-fullscreen) {
|
|
428
428
|
transition: background-color var(--tactile-press) var(--tactile-ease), color var(--tactile-press) var(--tactile-ease), transform var(--tactile-press) var(--tactile-ease), box-shadow var(--tactile-press) var(--tactile-ease);
|
|
429
429
|
}
|
|
430
430
|
.layers-drawer .layer-row:active,.page-option:active,:is(#screen-context,#layers-menu) button:active,#fullscreen-back:active { background-color: var(--canvas-button-pressed); }
|
|
431
431
|
.layers-drawer .layer-row[aria-pressed="true"]:active,.page-option[data-checked="true"]:active { background-color: var(--canvas-selected); }
|
|
432
|
-
:is(.screen-refresh,.screen-orientation,.screen-fullscreen):active { transform: scale(.92); background: var(--canvas-button-pressed); color: var(--canvas-text); }
|
|
432
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more,.screen-fullscreen):active { transform: scale(.92); background: var(--canvas-button-pressed); color: var(--canvas-text); }
|
|
433
433
|
.icon-options button:active { transform: scale(.97); background: var(--canvas-button-pressed); }
|
|
434
434
|
.icon-options button[aria-checked="true"]:active { background: var(--canvas-selected); }
|
|
435
435
|
.page-checkbox-target:active .page-checkbox { transform: scale(.92); }
|
|
@@ -438,7 +438,7 @@ input:disabled { background: var(--canvas-well); }
|
|
|
438
438
|
.handle:not([data-side]),.arrow-snap,.smart-handle::before { box-shadow: var(--canvas-shadow-button); }
|
|
439
439
|
.layers-header h2,.shortcut-section h3 { font-weight: 500; }
|
|
440
440
|
@media (prefers-reduced-motion: reduce) {
|
|
441
|
-
:is(.screen-refresh,.screen-orientation,.screen-fullscreen,.icon-options button):active { transform: none; }
|
|
441
|
+
:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more,.screen-fullscreen,.icon-options button):active { transform: none; }
|
|
442
442
|
}
|
|
443
443
|
|
|
444
444
|
/* Motion. One curve (ease-out-quint) for everything, kept short so it reads as
|
|
@@ -491,7 +491,7 @@ nav button,.layers-header button,.secondary-button,.select-field,.icon-options b
|
|
|
491
491
|
transition: transform var(--tactile-press) var(--tactile-ease), background-color var(--tactile-press) var(--tactile-ease),
|
|
492
492
|
box-shadow var(--tactile-press) var(--tactile-ease), opacity var(--tactile-press) var(--tactile-ease);
|
|
493
493
|
}
|
|
494
|
-
.layers-drawer .layer-row,.page-option,:is(#screen-context,#layers-menu) button,#fullscreen-back,:is(.screen-refresh,.screen-orientation,.screen-fullscreen),.page-checkbox,.page-checkbox-target {
|
|
494
|
+
.layers-drawer .layer-row,.page-option,:is(#screen-context,#layers-menu) button,#fullscreen-back,:is(.screen-refresh,.screen-orientation,.screen-theme,.screen-more,.screen-fullscreen),.page-checkbox,.page-checkbox-target {
|
|
495
495
|
transition-duration: var(--tactile-press);
|
|
496
496
|
}
|
|
497
497
|
.select-field .chevron { transition-duration: var(--tactile-in); }
|
|
@@ -707,7 +707,7 @@ body:not([data-ready]) :is(#layers,#edit-tools,#history-tools,#zoom-tools,#arrow
|
|
|
707
707
|
|
|
708
708
|
/* Text objects. Font and colour are fixed: the canvas face, the canvas ink. */
|
|
709
709
|
#text-layer { display: contents; }
|
|
710
|
-
.text-object { position: absolute; box-sizing: border-box; margin: 0; padding: 2px; min-height: 1.25em; overflow:
|
|
710
|
+
.text-object { position: absolute; box-sizing: border-box; margin: 0; padding: 2px; min-height: 1.25em; overflow: visible; line-height: 1.25; font-weight: 400; color: var(--canvas-text);
|
|
711
711
|
white-space: pre-wrap; overflow-wrap: break-word; outline: none; cursor: default; user-select: none; -webkit-user-select: none; }
|
|
712
712
|
.text-object[data-editing] { cursor: text; user-select: text; -webkit-user-select: text; }
|
|
713
713
|
body[data-text-drag] { user-select: none; }
|
|
@@ -740,8 +740,57 @@ body[data-gesture="reorder"] #selection #handles { visibility: hidden; }
|
|
|
740
740
|
.changelog-entry time { color: var(--canvas-muted); font-weight: 400; font-variant-numeric: tabular-nums; }
|
|
741
741
|
.changelog-items { display: grid; grid-template-columns: 4.5rem minmax(0,1fr); column-gap: var(--canvas-gap); row-gap: var(--canvas-padding); margin: 0; padding: 0; list-style: none; }
|
|
742
742
|
.changelog-items li { display: grid; grid-template-columns: subgrid; grid-column: 1 / -1; align-items: baseline; text-wrap: pretty; }
|
|
743
|
+
.install-section + .install-section { margin-top: var(--canvas-layers-heading-inset); padding-top: var(--canvas-layers-heading-inset); border-top: var(--canvas-border) solid var(--canvas-line); }
|
|
744
|
+
.install-section h3 { margin: 0 0 var(--canvas-padding); font-size: inherit; font-weight: 600; color: var(--canvas-text); }
|
|
745
|
+
.install-head { display: flex; align-items: center; justify-content: space-between; gap: var(--canvas-gap); }
|
|
746
|
+
.install-head h3 { margin: 0; }
|
|
747
|
+
.install-steps { margin: 0; padding-left: 1.4em; display: grid; gap: var(--canvas-gap); }
|
|
748
|
+
.install-steps p { margin: 2px 0 var(--canvas-padding); }
|
|
749
|
+
.install-code { margin: 0; padding: var(--canvas-padding) var(--canvas-gap); overflow: auto; max-height: 16rem; border: var(--canvas-border) solid var(--canvas-line); border-radius: var(--canvas-radius); background: var(--canvas-button); color: var(--canvas-text); font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; white-space: pre-wrap; overflow-wrap: anywhere; user-select: text; }
|
|
750
|
+
.install-command { display: flex; align-items: center; justify-content: space-between; gap: var(--canvas-gap); margin-bottom: var(--canvas-padding); padding: var(--canvas-padding) var(--canvas-gap); border: var(--canvas-border) solid var(--canvas-line); border-radius: var(--canvas-radius); background: var(--canvas-button); }
|
|
751
|
+
.install-command code { font: 12px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; user-select: text; }
|
|
752
|
+
.install-downloads { display: flex; flex-wrap: wrap; gap: var(--canvas-padding); margin-bottom: var(--canvas-padding); }
|
|
753
|
+
.install-copy { flex: none; text-decoration: none; font: inherit; padding: 2px var(--canvas-gap); border: var(--canvas-border) solid var(--canvas-line); border-radius: var(--canvas-radius); background: var(--canvas-button); color: var(--canvas-text); cursor: pointer; }
|
|
754
|
+
#install { height: fit-content; max-height: min(var(--canvas-picker-height),calc(100dvh - 2 * var(--canvas-panel-inset))); }
|
|
743
755
|
.changelog-tag { --tag-hue: 265; justify-self: start; padding: 1px var(--canvas-padding); border-radius: 999px; background: oklch(0.7 0.12 var(--tag-hue) / .16); color: color-mix(in oklch, oklch(0.5 0.15 var(--tag-hue)) 70%, var(--canvas-text)); font-size: 11px; font-weight: 600; line-height: 18px; }
|
|
744
756
|
.changelog-tag[data-type="new"] { --tag-hue: 150; }
|
|
745
757
|
.changelog-tag[data-type="improved"] { --tag-hue: 250; }
|
|
746
758
|
.changelog-tag[data-type="fixed"] { --tag-hue: 60; }
|
|
747
759
|
.changelog-tag[data-type="faster"] { --tag-hue: 330; }
|
|
760
|
+
|
|
761
|
+
/* Icons that do not fit the screen width fold behind a ... button and slide in on hover. */
|
|
762
|
+
.screen-more { display: none; transition: opacity .12s ease-out; }
|
|
763
|
+
.screen[data-folded] .screen-more { display: grid; }
|
|
764
|
+
.screen[data-expanded] .screen-more { opacity: 0; pointer-events: none; }
|
|
765
|
+
.screen [data-fold] { opacity: 0; visibility: hidden; pointer-events: none; translate: calc(var(--screen-outline) * 10) 0;
|
|
766
|
+
transition: opacity .12s ease-out, translate .12s ease-out, visibility 0s .12s; }
|
|
767
|
+
.screen[data-expanded] [data-fold] { opacity: 1; visibility: visible; pointer-events: auto; translate: 0 0;
|
|
768
|
+
background: var(--canvas-surface); transition: opacity .18s cubic-bezier(.23,1,.32,1) calc(var(--i) * 35ms), translate .18s cubic-bezier(.23,1,.32,1) calc(var(--i) * 35ms), visibility 0s; }
|
|
769
|
+
@media (prefers-reduced-motion: reduce) { .screen [data-fold],.screen[data-expanded] [data-fold],.screen-more { transition: none; } }
|
|
770
|
+
|
|
771
|
+
/* Version row at the top of the More menu, and the update dialog. */
|
|
772
|
+
#layers-more { position: relative; }
|
|
773
|
+
.update-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--canvas-accent); flex: none; }
|
|
774
|
+
.update-dot[hidden] { display: none; }
|
|
775
|
+
#layers-more .update-dot { position: absolute; top: 0; right: 0; translate: 50% -50%; box-shadow: 0 0 0 2px var(--canvas-panel); }
|
|
776
|
+
/* The footer runs edge to edge: its divider and tinted background cover the menu's padding and its 1px border, so no ring shows around them. The border stays on the menu itself. */
|
|
777
|
+
/* A popover clips its children to the padding box by default; the footer has to paint over the border. */
|
|
778
|
+
#layers-menu { --menu-bleed: calc(var(--canvas-padding) + var(--canvas-border)); overflow: visible; }
|
|
779
|
+
#layers-menu hr:has(+ .menu-version) { width: auto; margin: calc(var(--canvas-menu-gap) / 2) calc(-1 * var(--menu-bleed)) 0; }
|
|
780
|
+
.menu-version { display: flex; align-items: center; justify-content: space-between; gap: var(--canvas-padding); margin: calc(-1 * var(--canvas-menu-gap)) calc(-1 * var(--menu-bleed)) calc(-1 * var(--menu-bleed)); padding: 12px 13px 13px calc(2 * var(--canvas-padding) + var(--canvas-border)); border-radius: 0 0 var(--canvas-panel-radius) var(--canvas-panel-radius); background: color-mix(in oklch, var(--canvas-text) 4%, transparent); }
|
|
781
|
+
#version-label { color: var(--canvas-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
782
|
+
#layers-menu #update-open { height: 28px; padding-inline: var(--canvas-padding); background: var(--canvas-button); border: var(--canvas-border) solid var(--canvas-line); }
|
|
783
|
+
#layers-menu #update-open:hover { background: var(--canvas-button-hover); }
|
|
784
|
+
#layers-menu #update-open[data-primary] { background: var(--canvas-accent); border-color: var(--canvas-accent); color: var(--canvas-on-accent); }
|
|
785
|
+
#layers-menu #update-open[data-primary]:hover { background: color-mix(in oklch, var(--canvas-accent) 88%, black); border-color: transparent; }
|
|
786
|
+
#layers-menu #update-open[data-primary]:active { background: color-mix(in oklch, var(--canvas-accent) 78%, black); }
|
|
787
|
+
#update { height: fit-content; max-height: min(var(--canvas-picker-height),calc(100dvh - 2 * var(--canvas-panel-inset))); }
|
|
788
|
+
.shortcuts-body .update-summary { margin: 0 calc(-1 * var(--canvas-gap)) var(--canvas-layers-heading-inset); padding: var(--canvas-padding) var(--canvas-gap); border-radius: var(--canvas-radius); background: color-mix(in oklch, var(--canvas-accent) 14%, transparent); color: light-dark(oklch(0.42 0.2 330), oklch(0.82 0.12 330)); }
|
|
789
|
+
.update-summary strong { font-weight: 600; }
|
|
790
|
+
.update-summary + .install-section { margin-top: 0; padding-top: 0; border-top: 0; }
|
|
791
|
+
.update-state { display: grid; justify-items: center; gap: var(--canvas-padding); padding: var(--canvas-layers-heading-inset) 0; text-align: center; }
|
|
792
|
+
.update-state p { margin: 0; }
|
|
793
|
+
.update-state strong { color: var(--canvas-text); }
|
|
794
|
+
|
|
795
|
+
/* Modal content scrolls up and down only: no sideways scrolling or rubber-banding. */
|
|
796
|
+
.page-picker,.shortcuts-body,#page-options,.install-code { overflow-x: hidden; overscroll-behavior-x: none; }
|
package/client/canvas.js
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
import './tooltip.js';
|
|
2
2
|
import './shortcuts.js';
|
|
3
3
|
import {renderChangelog} from './changelog.js';
|
|
4
|
+
import {renderInstallGuide} from './install-guide.js';
|
|
5
|
+
import {version} from './version.js';
|
|
6
|
+
import {compareVersions,fetchLatestVersion,isLocalHost} from './update-check.js';
|
|
7
|
+
import {renderUpdate,renderUpdateMessage} from './update-guide.js';
|
|
4
8
|
import './device-gate.js';
|
|
5
9
|
import './onboarding.js';
|
|
6
10
|
import { setupSelectFields, closeSelectMenus, positionSelectMenus } from './select-field.js';
|
|
@@ -42,6 +46,8 @@ const smartHandles=createSmartHandles({selection,viewport,onDrag:startReorder});
|
|
|
42
46
|
// A host overlay paints above every iframe layer, including native popovers.
|
|
43
47
|
const contact=document.createElement('div');contact.id='canvas-contact';contact.hidden=true;contact.setAttribute('aria-hidden','true');document.body.append(contact);
|
|
44
48
|
let contactState=null;
|
|
49
|
+
// A screen's theme is its own: it sets the frame's colour scheme, which is what the page inside sees as prefers-color-scheme. It is not saved.
|
|
50
|
+
function applyScreenTheme(entry){if(entry.iframe)entry.iframe.style.colorScheme=entry.dark?'dark':'light';}
|
|
45
51
|
function clearContact(){contactState=null;contact.hidden=true;}
|
|
46
52
|
window.addEventListener('pointermove',clearContact);
|
|
47
53
|
function renderContact(){
|
|
@@ -68,13 +74,19 @@ function createEntry(definition, loadSaved = true) {
|
|
|
68
74
|
const refresh=document.createElement('button');refresh.className='screen-refresh';refresh.type='button';
|
|
69
75
|
refresh.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M16 7a6.5 6.5 0 1 0 .3 5M16 3v4h-4"/></svg>';
|
|
70
76
|
refresh.setAttribute('aria-label',`Refresh ${model.title}`);refresh.title=`Refresh ${model.title}`;
|
|
77
|
+
const theme=document.createElement('button');theme.className='screen-theme';theme.type='button';
|
|
78
|
+
theme.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M10 3a7 7 0 0 1 0 14Z" style="fill:currentColor;stroke:none"/></svg>';
|
|
79
|
+
theme.setAttribute('aria-label',`Toggle dark mode for ${model.title}`);theme.title='Toggle screen theme';theme.setAttribute('aria-pressed','false');
|
|
80
|
+
const more=document.createElement('button');more.className='screen-more';more.type='button';
|
|
81
|
+
more.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="4.5" cy="10" r="1" style="fill:currentColor;stroke:none"/><circle cx="10" cy="10" r="1" style="fill:currentColor;stroke:none"/><circle cx="15.5" cy="10" r="1" style="fill:currentColor;stroke:none"/></svg>';
|
|
82
|
+
more.setAttribute('aria-label',`More actions for ${model.title}`);more.title='More';
|
|
71
83
|
const fullscreen=document.createElement('button');fullscreen.className='screen-fullscreen';fullscreen.type='button';
|
|
72
84
|
fullscreen.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><path d="M7 3H3v4m10-4h4v4M3 13v4h4m10-4v4h-4"/></svg>';
|
|
73
85
|
fullscreen.setAttribute('aria-label',`Open ${model.title} fullscreen`);fullscreen.title='Open fullscreen';
|
|
74
86
|
const orientation=document.createElement('button');orientation.className='screen-orientation';orientation.type='button';
|
|
75
87
|
orientation.innerHTML='<svg viewBox="0 0 20 20" aria-hidden="true"><rect x="5" y="2" width="10" height="16" rx="2"/></svg>';
|
|
76
|
-
element.append(title,orientation,refresh,fullscreen); world.append(element);
|
|
77
|
-
const entry = { model, element, title, orientation, refresh, fullscreen, iframe: null, visible: false };
|
|
88
|
+
element.append(title,orientation,theme,refresh,fullscreen,more); world.append(element);
|
|
89
|
+
const entry = { model, element, title, orientation, theme, more, refresh, fullscreen, iframe: null, visible: false };
|
|
78
90
|
orientation.addEventListener('click',()=>{
|
|
79
91
|
stop();const before=history.checkpoint();
|
|
80
92
|
[model.width,model.height]=[model.height,model.width];
|
|
@@ -82,6 +94,15 @@ function createEntry(definition, loadSaved = true) {
|
|
|
82
94
|
updateSelection();schedule();commitEdit(before);
|
|
83
95
|
});
|
|
84
96
|
fullscreen.addEventListener('click',()=>preview.enter(entry));
|
|
97
|
+
// When the icon row is wider than the screen it folds into one ... button and opens while the pointer is on it.
|
|
98
|
+
const actions=[orientation,theme,refresh,fullscreen,more];
|
|
99
|
+
const expand=open=>element.toggleAttribute('data-expanded',open);
|
|
100
|
+
more.addEventListener('pointerenter',()=>expand(true));more.addEventListener('click',()=>expand(true));
|
|
101
|
+
for(const b of actions)b.addEventListener('pointerleave',event=>{if(!actions.includes(event.relatedTarget))expand(false);});
|
|
102
|
+
theme.addEventListener('click',()=>{
|
|
103
|
+
entry.dark=!entry.dark;theme.setAttribute('aria-pressed',String(entry.dark));
|
|
104
|
+
applyScreenTheme(entry);
|
|
105
|
+
});
|
|
85
106
|
title.title = model.title;
|
|
86
107
|
refresh.addEventListener('click',()=>{
|
|
87
108
|
stop();
|
|
@@ -347,13 +368,20 @@ function render(now) {
|
|
|
347
368
|
entry.orientation.title=`Switch to ${landscape?'portrait':'landscape'}`;
|
|
348
369
|
entry.orientation.setAttribute('aria-pressed',String(landscape));
|
|
349
370
|
entry.orientation.querySelector('svg').style.rotate=landscape?'0deg':'90deg';
|
|
350
|
-
|
|
351
|
-
|
|
371
|
+
// Icons that fit stay in the row; the rest fold behind a ... button and slide in on hover.
|
|
372
|
+
const row=[entry.fullscreen,entry.refresh,entry.theme,...(canRotate?[entry.orientation]:[])];
|
|
373
|
+
const fit=Math.floor(m.width*camera.scale/28),folded=fit>=row.length?0:row.length-Math.max(0,fit-1),shown=row.length-folded;
|
|
374
|
+
if(entry.paintScale!==camera.scale || entry.paintWidth!==m.width || entry.paintCanRotate!==canRotate || entry.paintShown!==shown){
|
|
375
|
+
entry.paintCanRotate=canRotate;entry.paintShown=shown;
|
|
376
|
+
if(!folded)element.removeAttribute('data-expanded');
|
|
377
|
+
element.toggleAttribute('data-folded',folded>0);
|
|
378
|
+
row.forEach((b,i)=>{
|
|
379
|
+
b.style.transform=`translateX(${-i*28/camera.scale}px) scale(${1/camera.scale})`;
|
|
380
|
+
b.toggleAttribute('data-fold',i>=shown);b.style.setProperty('--i',Math.max(0,i-shown));
|
|
381
|
+
});
|
|
382
|
+
entry.more.style.transform=`translateX(${-shown*28/camera.scale}px) scale(${1/camera.scale})`;
|
|
352
383
|
title.style.transform = `scale(${1/camera.scale})`;
|
|
353
|
-
title.style.maxWidth = `${Math.max(0,m.width*camera.scale-(
|
|
354
|
-
entry.orientation.style.transform=`translateX(${-56/camera.scale}px) scale(${1/camera.scale})`;
|
|
355
|
-
entry.refresh.style.transform=`translateX(${-28/camera.scale}px) scale(${1/camera.scale})`;
|
|
356
|
-
entry.fullscreen.style.transform=`scale(${1/camera.scale})`;
|
|
384
|
+
title.style.maxWidth = `${Math.max(0,m.width*camera.scale-(shown+(folded?1:0))*28-4)}px`;
|
|
357
385
|
element.style.setProperty('--screen-outline', `${1/camera.scale}px`);
|
|
358
386
|
entry.paintScale=camera.scale;entry.paintWidth=m.width;
|
|
359
387
|
}
|
|
@@ -398,7 +426,7 @@ function notify(entry) {
|
|
|
398
426
|
entry.iframe?.contentWindow.postMessage({type:'tio-canvas:visibility', visible:entry.visible && !document.hidden}, location.origin);
|
|
399
427
|
}
|
|
400
428
|
const loader=createScreenLoader({
|
|
401
|
-
onReady(entry){notify(entry);notifyInput(entry,true);entry.refresh.title=`Refresh ${entry.model.title}`;$('#canvas-status').textContent='';},
|
|
429
|
+
onReady(entry){if(entry.dark!==undefined)applyScreenTheme(entry);notify(entry);notifyInput(entry,true);entry.refresh.title=`Refresh ${entry.model.title}`;$('#canvas-status').textContent='';},
|
|
402
430
|
onError(entry,message){entry.refresh.title=message;$('#canvas-status').textContent=message;}
|
|
403
431
|
});
|
|
404
432
|
function mount(entry,replacing=false){loader.mount(entry,replacing);}
|
|
@@ -931,6 +959,15 @@ changelog.addEventListener('click',event=>{
|
|
|
931
959
|
if(event.clientX<rect.left || event.clientX>rect.right || event.clientY<rect.top || event.clientY>rect.bottom)changelog.close();
|
|
932
960
|
});
|
|
933
961
|
changelog.addEventListener('close',()=>$('#layers-more').focus());
|
|
962
|
+
const installGuide=$('#install');
|
|
963
|
+
$('#install-open').onclick=()=>{closeContext();stop();clearContact();delete document.body.dataset.space;renderInstallGuide(installGuide.querySelector('.install-body'));installGuide.showModal();};
|
|
964
|
+
$('#install-close').onclick=()=>installGuide.close();
|
|
965
|
+
installGuide.addEventListener('click',event=>{
|
|
966
|
+
if(event.target!==installGuide)return;
|
|
967
|
+
const rect=installGuide.getBoundingClientRect();
|
|
968
|
+
if(event.clientX<rect.left || event.clientX>rect.right || event.clientY<rect.top || event.clientY>rect.bottom)installGuide.close();
|
|
969
|
+
});
|
|
970
|
+
installGuide.addEventListener('close',()=>$('#layers-more').focus());
|
|
934
971
|
$('#undo').onclick=()=>runHistory('undo');
|
|
935
972
|
$('#redo').onclick=()=>runHistory('redo');
|
|
936
973
|
$('#fit').onclick=()=>fitEntries(fitAll());
|
|
@@ -972,14 +1009,63 @@ moreMenu.addEventListener('beforetoggle',event=>{
|
|
|
972
1009
|
moreMenu.style.left=`${Math.max(inset,Math.min(anchor.left+(anchor.width-width)/2,innerWidth-width-inset))}px`;
|
|
973
1010
|
});
|
|
974
1011
|
moreMenu.addEventListener('toggle',event=>{if(event.newState==='open')moreMenu.focus({preventScroll:true});moreButton.setAttribute('aria-expanded',String(event.newState==='open'));});
|
|
975
|
-
moreMenu.addEventListener('click',event=>{if(event.target.closest('#shortcuts-open,#onboarding-open,#changelog-open'))moreMenu.hidePopover();});
|
|
1012
|
+
moreMenu.addEventListener('click',event=>{if(event.target.closest('#shortcuts-open,#onboarding-open,#changelog-open,#install-open'))moreMenu.hidePopover();});
|
|
976
1013
|
moreMenu.addEventListener('keydown',event=>{
|
|
977
1014
|
event.stopPropagation();
|
|
978
1015
|
if(!['ArrowDown','ArrowUp','Home','End'].includes(event.key))return;
|
|
979
1016
|
event.preventDefault();
|
|
980
|
-
const buttons=[...moreMenu.querySelectorAll('button')],index=buttons.indexOf(document.activeElement);
|
|
1017
|
+
const buttons=[...moreMenu.querySelectorAll('button')].filter(button=>!button.hidden),index=buttons.indexOf(document.activeElement);
|
|
981
1018
|
buttons[event.key==='Home'?0:event.key==='End'?buttons.length-1:index<0?(event.key==='ArrowDown'?0:buttons.length-1):(index+(event.key==='ArrowDown'?1:-1)+buttons.length)%buttons.length].focus();
|
|
982
1019
|
});
|
|
1020
|
+
// ---- Version and updates ----------------------------------------------------
|
|
1021
|
+
// The version row in the More menu. A canvas on the developer's machine asks npm
|
|
1022
|
+
// for the newest version now, again when the tab is shown, and every half hour,
|
|
1023
|
+
// so an update that lands while the canvas stays open still shows up.
|
|
1024
|
+
const updateDialog=$('#update'),updateBody=updateDialog.querySelector('.update-body'),updateButton=$('#update-open');
|
|
1025
|
+
const canCheckUpdates=isLocalHost(location.hostname);
|
|
1026
|
+
let latestVersion=null,updateChecking=null,lastCheck=0;
|
|
1027
|
+
const updateAvailable=()=>latestVersion!==null && compareVersions(latestVersion,version)>0;
|
|
1028
|
+
$('#version-label').textContent=`v${version}`;
|
|
1029
|
+
function showUpdateState(){
|
|
1030
|
+
const available=updateAvailable();
|
|
1031
|
+
moreButton.querySelector('.update-dot').hidden=!available;
|
|
1032
|
+
updateButton.textContent=available?'Update':'Check for update';
|
|
1033
|
+
updateButton.toggleAttribute('data-primary',available);
|
|
1034
|
+
updateButton.hidden=!canCheckUpdates;
|
|
1035
|
+
}
|
|
1036
|
+
function checkForUpdate(){
|
|
1037
|
+
updateChecking??=fetchLatestVersion().then(found=>{latestVersion=found;lastCheck=Date.now();}).finally(()=>{updateChecking=null;showUpdateState();});
|
|
1038
|
+
return updateChecking;
|
|
1039
|
+
}
|
|
1040
|
+
function renderUpdateDialog(failed=false){
|
|
1041
|
+
if(failed)return renderUpdateMessage(updateBody,{title:'Could not check for updates',text:'Check your internet connection and try again.',action:{label:'Try again',run:()=>runUpdateCheck()}});
|
|
1042
|
+
if(updateAvailable())return renderUpdate(updateBody,{current:version,latest:latestVersion});
|
|
1043
|
+
renderUpdateMessage(updateBody,{title:'No updates available',text:`You have the latest version, v${version}.`});
|
|
1044
|
+
}
|
|
1045
|
+
async function runUpdateCheck(){
|
|
1046
|
+
renderUpdateMessage(updateBody,{title:'Checking for updates…',text:'This takes a moment.'});
|
|
1047
|
+
try{await checkForUpdate();renderUpdateDialog();}catch{renderUpdateDialog(true);}
|
|
1048
|
+
}
|
|
1049
|
+
updateButton.onclick=()=>{
|
|
1050
|
+
closeContext();stop();clearContact();delete document.body.dataset.space;
|
|
1051
|
+
moreMenu.hidePopover();
|
|
1052
|
+
updateDialog.showModal();
|
|
1053
|
+
if(updateAvailable())renderUpdateDialog();else runUpdateCheck();
|
|
1054
|
+
};
|
|
1055
|
+
$('#update-close').onclick=()=>updateDialog.close();
|
|
1056
|
+
updateDialog.addEventListener('click',event=>{
|
|
1057
|
+
if(event.target!==updateDialog)return;
|
|
1058
|
+
const rect=updateDialog.getBoundingClientRect();
|
|
1059
|
+
if(event.clientX<rect.left || event.clientX>rect.right || event.clientY<rect.top || event.clientY>rect.bottom)updateDialog.close();
|
|
1060
|
+
});
|
|
1061
|
+
updateDialog.addEventListener('close',()=>moreButton.focus());
|
|
1062
|
+
showUpdateState();
|
|
1063
|
+
if(canCheckUpdates){
|
|
1064
|
+
const quietCheck=()=>{if(Date.now()-lastCheck>10*60*1000)checkForUpdate().catch(()=>{});};
|
|
1065
|
+
quietCheck();
|
|
1066
|
+
document.addEventListener('visibilitychange',()=>{if(!document.hidden)quietCheck();});
|
|
1067
|
+
setInterval(quietCheck,30*60*1000);
|
|
1068
|
+
}
|
|
983
1069
|
window.addEventListener('resize',()=>{cameraMotion=null;cachedWorkArea=null;schedule();});
|
|
984
1070
|
document.addEventListener('visibilitychange',()=>{release();entries.forEach(notify);});
|
|
985
1071
|
const preview=createFullscreen({
|
package/client/changelog.js
CHANGED
|
@@ -11,6 +11,17 @@ export const entries = [
|
|
|
11
11
|
unreleased: true,
|
|
12
12
|
items: [],
|
|
13
13
|
},
|
|
14
|
+
{
|
|
15
|
+
version: '0.2.0',
|
|
16
|
+
date: '2026-11-08',
|
|
17
|
+
items: [
|
|
18
|
+
{ type: 'new', text: 'The More menu shows your version and tells you with a red dot when a new one is out, with steps and a prompt to update.' },
|
|
19
|
+
{ type: 'new', text: 'The More menu has an Install in a project guide with beginner-friendly steps, ready-to-download files and a prompt to copy for your agent.' },
|
|
20
|
+
{ type: 'fixed', text: 'A text box can no longer be dragged shorter than its lines, and its text is never clipped.' },
|
|
21
|
+
{ type: 'new', text: 'Each screen has a theme button next to refresh to preview it in dark or light mode.' },
|
|
22
|
+
{ type: 'improved', text: 'Icons that do not fit a narrow screen fold behind a ... button and slide in when you hover it.' },
|
|
23
|
+
],
|
|
24
|
+
},
|
|
14
25
|
{
|
|
15
26
|
version: '0.1.2',
|
|
16
27
|
date: '2026-11-08',
|
package/client/index.html
CHANGED
|
@@ -63,7 +63,7 @@
|
|
|
63
63
|
<aside id="layers" class="canvas-tools layers-drawer" aria-label="Screens">
|
|
64
64
|
<header class="layers-header">
|
|
65
65
|
<h2 id="layers-title"><span class="layers-logo" role="img" aria-label="Tio"></span><span id="screens-count" hidden>0</span></h2>
|
|
66
|
-
<button id="layers-more" type="button" aria-label="More" aria-haspopup="menu" aria-controls="layers-menu" popovertarget="layers-menu" data-tooltip="More"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5h.01M10 10h.01M10 15.5h.01" stroke-width="2.5"/></svg></button>
|
|
66
|
+
<button id="layers-more" type="button" aria-label="More" aria-haspopup="menu" aria-controls="layers-menu" popovertarget="layers-menu" data-tooltip="More"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 4.5h.01M10 10h.01M10 15.5h.01" stroke-width="2.5"/></svg><span class="update-dot" hidden></span></button>
|
|
67
67
|
<button id="layers-toggle" type="button" aria-label="Minimize Layers" aria-controls="layers-list" aria-expanded="true"><svg viewBox="0 0 20 20" aria-hidden="true"><path/></svg></button>
|
|
68
68
|
</header>
|
|
69
69
|
<ol id="layers-list" aria-label="Layers, top layer first"></ol>
|
|
@@ -88,9 +88,12 @@
|
|
|
88
88
|
<div id="layers-menu" class="canvas-tools" popover="auto" role="menu" aria-label="More" tabindex="-1">
|
|
89
89
|
<button id="theme" type="button" role="menuitem"><svg class="theme-icon" viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M10 3a7 7 0 0 1 0 14Z"/></svg><span id="theme-label">Dark mode</span></button>
|
|
90
90
|
<button id="shortcuts-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 5.5C8.6 4.4 6.6 4 3.5 4v11c3.1 0 5.1.4 6.5 1.5m0-11C11.4 4.4 13.4 4 16.5 4v11c-3.1 0-5.1.4-6.5 1.5m0-11v11"/></svg>Guide & hotkeys</button>
|
|
91
|
-
<hr role="separator">
|
|
92
91
|
<button id="onboarding-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><circle cx="10" cy="10" r="7"/><path d="M8.2 8a1.9 1.9 0 1 1 2.9 1.6c-.7.4-1.1.8-1.1 1.6M10 14h.01"/></svg>Onboarding</button>
|
|
92
|
+
<hr role="separator">
|
|
93
93
|
<button id="changelog-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5.5 3h9a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1h-9a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1ZM7.5 7h5M7.5 10h5M7.5 13h3"/></svg>Change log</button>
|
|
94
|
+
<button id="install-open" type="button" role="menuitem"><svg viewBox="0 0 20 20" aria-hidden="true"><path d="M10 3v9m0 0-3.5-3.5M10 12l3.5-3.5M4 14v2a1 1 0 0 0 1 1h10a1 1 0 0 0 1-1v-2"/></svg>Install in a project</button>
|
|
95
|
+
<hr role="separator">
|
|
96
|
+
<div class="menu-version"><span id="version-label"></span><button id="update-open" type="button" role="menuitem">Check for update</button></div>
|
|
94
97
|
</div>
|
|
95
98
|
<div id="screen-context" class="canvas-tools" popover="auto" role="menu" aria-label="Screen actions" tabindex="-1">
|
|
96
99
|
<button type="button" role="menuitem" data-action="duplicate"><svg viewBox="0 0 20 20" aria-hidden="true"><rect x="7" y="7" width="10" height="10" rx="2"/><path d="M12 7V3H3v9h4"/></svg>Duplicate</button>
|
|
@@ -104,6 +107,14 @@
|
|
|
104
107
|
<header class="layers-header"><h2 id="changelog-title">Change log</h2><button id="changelog-close" type="button" aria-label="Close Change log" data-tooltip="Close" data-shortcut="esc">✕</button></header>
|
|
105
108
|
<div class="shortcuts-body changelog-body"></div>
|
|
106
109
|
</dialog>
|
|
110
|
+
<dialog id="install" class="page-picker" aria-labelledby="install-title">
|
|
111
|
+
<header class="layers-header"><h2 id="install-title">Install in a project</h2><button id="install-close" type="button" aria-label="Close Install in a project" data-tooltip="Close" data-shortcut="esc">✕</button></header>
|
|
112
|
+
<div class="shortcuts-body install-body"></div>
|
|
113
|
+
</dialog>
|
|
114
|
+
<dialog id="update" class="page-picker" aria-labelledby="update-title">
|
|
115
|
+
<header class="layers-header"><h2 id="update-title">Update</h2><button id="update-close" type="button" aria-label="Close Update" data-tooltip="Close" data-shortcut="esc">✕</button></header>
|
|
116
|
+
<div class="shortcuts-body update-body" aria-live="polite"></div>
|
|
117
|
+
</dialog>
|
|
107
118
|
<div id="device-gate" popover="manual" role="alert">
|
|
108
119
|
<div class="device-gate-icons" aria-hidden="true">
|
|
109
120
|
<svg viewBox="0 0 20 20"><rect x="3" y="3" width="14" height="10" rx="1"/><path d="M10 13v4m-4 0h8"/></svg>
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// Content of the "Install in a project" dialog: manual steps plus a prompt
|
|
2
|
+
// the person can copy and hand to a coding agent.
|
|
3
|
+
export const el = (tag, className = '', text = '') => { const node = document.createElement(tag); if (className) node.className = className; if (text) node.textContent = text; return node; };
|
|
4
|
+
|
|
5
|
+
const template = name => new URL(`./templates/${name}`, import.meta.url).href;
|
|
6
|
+
|
|
7
|
+
// Written for someone who has never used a terminal. Each step says what to do,
|
|
8
|
+
// where, and what they should see. `command` gets a copy button, `download` a
|
|
9
|
+
// download button for a ready-made file.
|
|
10
|
+
const steps = [
|
|
11
|
+
{ title: 'Check that this fits your project', text: 'These steps are for a website made of plain files (index.html, about.html, and so on) in one folder. If your project is built with a tool like Next.js, Vite or WordPress, use "Ask an agent to do it" above instead.' },
|
|
12
|
+
{ title: 'Install Node.js', text: 'Node.js is a free program that runs the canvas on your computer. Download the version marked LTS from nodejs.org and install it like any other app. You need version 22 or newer.' },
|
|
13
|
+
{ title: 'Open the Terminal in your project folder', text: 'The Terminal is a window where you type commands. On a Mac: right-click your project folder in Finder and choose "New Terminal at Folder". On Windows: open the folder, click the address bar at the top, type cmd, and press Enter. A window with a blinking cursor opens.' },
|
|
14
|
+
{ title: 'Check Node.js works', text: 'Type this and press Enter. You should see a number like v22.12.0. If you see "not found", close the Terminal, reopen it, and try again.', command: 'node -v' },
|
|
15
|
+
{ title: 'Install the canvas', text: 'Type these two commands, one at a time, pressing Enter after each. The first creates a package.json file, which is a small list of what your project uses. The second adds the canvas. Wait until the cursor comes back.', commands: ['npm init -y', 'npm install --save-dev @tio-canvas/canvas'] },
|
|
16
|
+
{ title: 'Download two files into your project folder', text: 'Download both and move them into the same folder as your index.html. canvas-server.mjs starts your site with the canvas. canvas-screens.mjs lists the pages the canvas shows.', downloads: [['canvas-server.mjs', 'Download canvas-server.mjs'], ['canvas-screens.mjs', 'Download canvas-screens.mjs']] },
|
|
17
|
+
{ title: 'Tell the canvas which pages to show', text: 'Open canvas-screens.mjs in a text editor (Notepad on Windows, or a free editor such as Visual Studio Code; not Word). Each block between { and } is one page. Change src to the address of your page, for example /about.html. Copy a whole block to add another page. The notes at the top of the file explain every field. Save the file.' },
|
|
18
|
+
{ title: 'Start it', text: 'Back in the Terminal, type this and press Enter. Leave the window open: closing it stops the canvas.', command: 'node canvas-server.mjs' },
|
|
19
|
+
{ title: 'Open the canvas', text: 'In your browser, go to this address. Your pages appear on the canvas. To stop it later, click the Terminal and press Ctrl+C. To start it again, run the command from the previous step.', command: 'http://localhost:4400/canvas/' },
|
|
20
|
+
{ title: 'If something goes wrong', text: 'A page shows "Not found": its src in canvas-screens.mjs does not match a real file name. Nothing happens when you start it, or you see a red error: read the last line, it names the file and the problem. Still stuck: use "Ask an agent to do it" above.' },
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
export const agentPrompt = `Install the @tio-canvas/canvas package in this project as a development tool.
|
|
24
|
+
|
|
25
|
+
1. Add it as a dev dependency with this project's package manager: @tio-canvas/canvas.
|
|
26
|
+
2. Read node_modules/@tio-canvas/canvas/docs/integration.md and follow its "Use it in a project" section.
|
|
27
|
+
3. Create dev/canvas/screens.js listing the real pages of this project (id, kind, title, description, src, x, y, width, height, preset). Give storageKey a name unique to this project. The file must not import anything.
|
|
28
|
+
4. Wire the dev server: serve the package client at /canvas with createCanvasHost from "@tio-canvas/canvas/server" (locate, projectModule, pageScripts, interfaceKitBundle, watchTargets). Do this for development only.
|
|
29
|
+
5. Do not link /canvas from any public page, and do not publish it unless I ask.
|
|
30
|
+
6. Start the dev server, open /canvas, and confirm every screen loads. Tell me the URL and anything you could not verify.`;
|
|
31
|
+
|
|
32
|
+
export function codeBlock(code) {
|
|
33
|
+
const pre = el('pre', 'install-code'); pre.append(el('code', '', code)); return pre;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function copyButton(label, text) {
|
|
37
|
+
const button = el('button', 'install-copy', label); button.type = 'button';
|
|
38
|
+
button.onclick = async () => {
|
|
39
|
+
try { await navigator.clipboard.writeText(text); button.textContent = 'Copied'; } catch { button.textContent = 'Copy failed'; }
|
|
40
|
+
setTimeout(() => { button.textContent = label; }, 1600);
|
|
41
|
+
};
|
|
42
|
+
return button;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function commandRow(command) {
|
|
46
|
+
const row = el('div', 'install-command'); row.append(el('code', '', command), copyButton('Copy', command)); return row;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function renderInstallGuide(body) {
|
|
50
|
+
body.replaceChildren();
|
|
51
|
+
const manual = el('section', 'install-section');
|
|
52
|
+
manual.append(el('h3', '', 'Install it yourself'));
|
|
53
|
+
const list = el('ol', 'install-steps');
|
|
54
|
+
for (const step of steps) {
|
|
55
|
+
const item = el('li'); item.append(el('strong', '', step.title), el('p', '', step.text));
|
|
56
|
+
for (const command of step.commands ?? (step.command ? [step.command] : [])) item.append(commandRow(command));
|
|
57
|
+
if (step.downloads) {
|
|
58
|
+
const row = el('div', 'install-downloads');
|
|
59
|
+
for (const [name, label] of step.downloads) {
|
|
60
|
+
const link = el('a', 'install-copy', label); link.href = template(name); link.download = name; row.append(link);
|
|
61
|
+
}
|
|
62
|
+
item.append(row);
|
|
63
|
+
}
|
|
64
|
+
list.append(item);
|
|
65
|
+
}
|
|
66
|
+
manual.append(list);
|
|
67
|
+
|
|
68
|
+
const agent = el('section', 'install-section');
|
|
69
|
+
const head = el('div', 'install-head'); head.append(el('h3', '', 'Ask an agent to do it'));
|
|
70
|
+
head.append(copyButton('Copy prompt', agentPrompt));
|
|
71
|
+
agent.append(head, el('p', '', 'Paste this into your coding agent while it is in the project.'), codeBlock(agentPrompt));
|
|
72
|
+
body.append(agent, manual);
|
|
73
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// The list of pages the canvas shows. One block between { and } is one page.
|
|
2
|
+
// Copy a block to add a page. Keep the commas, the quotes and the brackets.
|
|
3
|
+
//
|
|
4
|
+
// id a short unique name, letters and dashes only (home, about-us)
|
|
5
|
+
// kind 'production' for a finished page, 'prototype' for an idea
|
|
6
|
+
// title the name shown above the page on the canvas
|
|
7
|
+
// description one short sentence about the page
|
|
8
|
+
// src the address of the page, starting with / (for example /index.html)
|
|
9
|
+
// x, y where the page sits on the canvas
|
|
10
|
+
// width,height the size of the page in pixels
|
|
11
|
+
// preset 'laptop', 'iphone-16' or 'ipad-air'
|
|
12
|
+
//
|
|
13
|
+
// Do not add any "import" line to this file.
|
|
14
|
+
export const storageKey = 'my-project:canvas:v1'; // change my-project to your project name
|
|
15
|
+
|
|
16
|
+
export const screens = [
|
|
17
|
+
{
|
|
18
|
+
id: 'home', kind: 'production', title: 'Home', description: 'The homepage.',
|
|
19
|
+
src: '/index.html', x: 160, y: 160, width: 1440, height: 900, preset: 'laptop',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
id: 'home-phone', kind: 'production', title: 'Home on a phone', description: 'The homepage on a phone.',
|
|
23
|
+
src: '/index.html', x: 1700, y: 160, width: 390, height: 844, preset: 'iphone-16',
|
|
24
|
+
},
|
|
25
|
+
];
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// Starts your site on your own computer, with the canvas at /canvas.
|
|
2
|
+
// Run it with: node canvas-server.mjs
|
|
3
|
+
import { createServer } from "node:http";
|
|
4
|
+
import { readFile, stat } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { createCanvasHost } from "@tio-canvas/canvas/server";
|
|
7
|
+
|
|
8
|
+
const root = process.cwd();
|
|
9
|
+
const port = Number(process.env.PORT ?? 4400);
|
|
10
|
+
const canvas = createCanvasHost({ root, config: "canvas-screens.mjs" });
|
|
11
|
+
const types = {
|
|
12
|
+
".html": "text/html; charset=utf-8", ".js": "text/javascript; charset=utf-8", ".mjs": "text/javascript; charset=utf-8",
|
|
13
|
+
".css": "text/css; charset=utf-8", ".json": "application/json", ".svg": "image/svg+xml", ".png": "image/png",
|
|
14
|
+
".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".gif": "image/gif", ".webp": "image/webp", ".ico": "image/x-icon",
|
|
15
|
+
".woff": "font/woff", ".woff2": "font/woff2", ".txt": "text/plain; charset=utf-8",
|
|
16
|
+
};
|
|
17
|
+
const send = (response, body, file) =>
|
|
18
|
+
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" }).end(body);
|
|
19
|
+
|
|
20
|
+
createServer(async (request, response) => {
|
|
21
|
+
const url = new URL(request.url, "http://localhost");
|
|
22
|
+
const pathname = decodeURIComponent(url.pathname);
|
|
23
|
+
try {
|
|
24
|
+
if (pathname === "/__dev/interface-kit.js") {
|
|
25
|
+
response.writeHead(200, { "content-type": types[".js"] }).end(await canvas.interfaceKitBundle());
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (pathname === "/canvas" || pathname.startsWith("/canvas/")) {
|
|
29
|
+
const { directory, localPath, project } = canvas.locate(pathname);
|
|
30
|
+
if (project) {
|
|
31
|
+
response.writeHead(200, { "content-type": types[".js"] }).end(await canvas.projectModule());
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
let file = path.join(directory, localPath === "/" ? "index.html" : localPath);
|
|
35
|
+
if ((await stat(file)).isDirectory()) file = path.join(file, "index.html");
|
|
36
|
+
send(response, await readFile(file), file);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
let file = path.join(root, pathname);
|
|
40
|
+
if (file !== root && !file.startsWith(root + path.sep)) throw new Error("outside the project");
|
|
41
|
+
if ((await stat(file)).isDirectory()) file = path.join(file, "index.html");
|
|
42
|
+
let body = await readFile(file);
|
|
43
|
+
if (path.extname(file) === ".html") {
|
|
44
|
+
const { bridge, tools } = canvas.pageScripts(url, request.headers);
|
|
45
|
+
const html = body.toString("utf8");
|
|
46
|
+
body = html.includes("</body>") ? html.replace("</body>", `${bridge}${tools}</body>`) : html + bridge + tools;
|
|
47
|
+
}
|
|
48
|
+
send(response, body, file);
|
|
49
|
+
} catch {
|
|
50
|
+
response.writeHead(404, { "content-type": "text/plain" }).end("Not found");
|
|
51
|
+
}
|
|
52
|
+
}).listen(port, () => console.log(`Canvas is ready: http://localhost:${port}/canvas/`));
|
package/client/text.js
CHANGED
|
@@ -104,7 +104,7 @@ export function createTexts({ viewport, world, camera, extent, saved, checkpoint
|
|
|
104
104
|
text.width = text.fitWidth ? Math.min(max, extent.width - text.x) : Math.min(text.width, max);
|
|
105
105
|
if (text.width >= max) text.fitWidth = true;
|
|
106
106
|
const height = contentHeight(text, text.width);
|
|
107
|
-
|
|
107
|
+
text.fit = true; text.height = height;
|
|
108
108
|
}
|
|
109
109
|
|
|
110
110
|
// ---- Editing ------------------------------------------------------------
|
|
@@ -297,8 +297,8 @@ export function createTexts({ viewport, world, camera, extent, saved, checkpoint
|
|
|
297
297
|
let height = base.fit ? content : Math.min(base.height, content), fit = base.fit, rawHeight = height;
|
|
298
298
|
if (vertical) {
|
|
299
299
|
rawHeight = base.height + (part.includes('s') ? dy : -dy);
|
|
300
|
-
height =
|
|
301
|
-
fit =
|
|
300
|
+
height = content;
|
|
301
|
+
fit = true;
|
|
302
302
|
}
|
|
303
303
|
drag.over = { w: horizontal ? rawWidth - width : 0, h: vertical ? rawHeight - height : 0 };
|
|
304
304
|
text.fitWidth = horizontal ? width >= drag.maxW : base.fitWidth;
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Asking npm whether a newer version of the package exists.
|
|
2
|
+
export const PACKAGE_NAME = '@tio-canvas/canvas';
|
|
3
|
+
const LATEST_URL = 'https://registry.npmjs.org/@tio-canvas%2Fcanvas/latest';
|
|
4
|
+
const SEMVER = /^(\d+)\.(\d+)\.(\d+)$/;
|
|
5
|
+
|
|
6
|
+
// 1 when a is newer than b, -1 when older, 0 when equal. Anything that is not
|
|
7
|
+
// major.minor.patch counts as equal, so a odd value never raises a false alert.
|
|
8
|
+
export function compareVersions(a, b) {
|
|
9
|
+
const left = SEMVER.exec(a), right = SEMVER.exec(b);
|
|
10
|
+
if (!left || !right) return 0;
|
|
11
|
+
for (let i = 1; i <= 3; i++) if (left[i] !== right[i]) return Number(left[i]) > Number(right[i]) ? 1 : -1;
|
|
12
|
+
return 0;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
// Only a canvas running on the developer's own machine asks npm. A published
|
|
16
|
+
// canvas must not contact anyone when a visitor opens it.
|
|
17
|
+
export const isLocalHost = hostname => ['localhost', '127.0.0.1', '[::1]', '::1'].includes(hostname) || hostname.endsWith('.localhost');
|
|
18
|
+
|
|
19
|
+
export async function fetchLatestVersion(fetchImpl = fetch) {
|
|
20
|
+
const response = await fetchImpl(LATEST_URL, { cache: 'no-store', signal: AbortSignal.timeout(8000) });
|
|
21
|
+
if (!response.ok) throw new Error(`npm answered ${response.status}`);
|
|
22
|
+
const { version } = await response.json();
|
|
23
|
+
if (!SEMVER.test(version)) throw new Error('npm sent an unexpected version');
|
|
24
|
+
return version;
|
|
25
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { PACKAGE_NAME } from './update-check.js';
|
|
2
|
+
import { el, codeBlock, copyButton, commandRow } from './install-guide.js';
|
|
3
|
+
|
|
4
|
+
export const updatePrompt = (current, latest) => `Update the ${PACKAGE_NAME} package in this project from ${current} to ${latest}.
|
|
5
|
+
|
|
6
|
+
1. Use this project's package manager (npm, pnpm or yarn) to install ${PACKAGE_NAME}@${latest}. Keep it a dev dependency.
|
|
7
|
+
2. After it is installed, read node_modules/${PACKAGE_NAME}/client/changelog.js for the entries between ${current} and ${latest}. If a release needs a change in this project (for example in the canvas screens file or the dev server), make it. If nothing needs changing, leave the project's files alone.
|
|
8
|
+
3. Restart the dev server, open /canvas, and confirm every screen still loads.
|
|
9
|
+
4. Tell me the version now installed, what changed in the release, and anything you could not verify.`;
|
|
10
|
+
|
|
11
|
+
const manualSteps = latest => [
|
|
12
|
+
{ title: 'Stop the canvas', text: 'Click the Terminal window where the canvas is running and press Ctrl+C. If you closed that window, skip this step.' },
|
|
13
|
+
{ title: 'Open the Terminal in your project folder', text: 'The Terminal is a window where you type commands. On a Mac: right-click your project folder in Finder and choose "New Terminal at Folder". On Windows: open the folder, click the address bar at the top, type cmd, and press Enter.' },
|
|
14
|
+
{ title: 'Install the new version', text: 'Type this and press Enter. Wait until the cursor comes back. If you set the project up with pnpm or yarn instead of npm, ask an agent with the prompt above.', command: `npm install --save-dev ${PACKAGE_NAME}@${latest}` },
|
|
15
|
+
{ title: 'Start the canvas again', text: 'Type this and press Enter. Leave the window open.', command: 'node canvas-server.mjs' },
|
|
16
|
+
{ title: 'Reload the canvas', text: 'In your browser, reload the canvas page. Open More, and the version at the bottom should now be the new one.' },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export function renderUpdate(body, { current, latest }) {
|
|
20
|
+
body.replaceChildren();
|
|
21
|
+
const summary = el('p', 'update-summary');
|
|
22
|
+
summary.append('Version ', el('strong', '', latest), ` is available. You have ${current}.`);
|
|
23
|
+
|
|
24
|
+
const agent = el('section', 'install-section');
|
|
25
|
+
const head = el('div', 'install-head'); head.append(el('h3', '', 'Ask an agent to do it'), copyButton('Copy prompt', updatePrompt(current, latest)));
|
|
26
|
+
agent.append(head, el('p', '', 'Paste this into your coding agent while it is in the project.'), codeBlock(updatePrompt(current, latest)));
|
|
27
|
+
|
|
28
|
+
const manual = el('section', 'install-section');
|
|
29
|
+
manual.append(el('h3', '', 'Update it yourself'));
|
|
30
|
+
const list = el('ol', 'install-steps');
|
|
31
|
+
for (const step of manualSteps(latest)) {
|
|
32
|
+
const item = el('li'); item.append(el('strong', '', step.title), el('p', '', step.text));
|
|
33
|
+
if (step.command) item.append(commandRow(step.command));
|
|
34
|
+
list.append(item);
|
|
35
|
+
}
|
|
36
|
+
manual.append(list);
|
|
37
|
+
body.append(summary, agent, manual);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function renderUpdateMessage(body, { title, text, action }) {
|
|
41
|
+
body.replaceChildren();
|
|
42
|
+
const state = el('div', 'update-state'); state.append(el('strong', '', title), el('p', '', text));
|
|
43
|
+
if (action) { const button = el('button', 'install-copy', action.label); button.type = 'button'; button.onclick = action.run; state.append(button); }
|
|
44
|
+
body.append(state);
|
|
45
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
# Using the canvas with your own dev server
|
|
2
|
+
|
|
3
|
+
This page is for developers who want to wire the canvas into an existing dev
|
|
4
|
+
server, or who work on the package itself. If you just want to try the canvas,
|
|
5
|
+
the [README](../README.md) has a beginner path.
|
|
6
|
+
|
|
7
|
+
The canvas is plain HTML, CSS and JavaScript. The only dependencies
|
|
8
|
+
(`interface-kit`, `react`, `react-dom`, `esbuild`) exist to build the optional
|
|
9
|
+
development-only Interface Kit overlay. The behaviour of the canvas is described
|
|
10
|
+
in [`exploration-canvas.md`](exploration-canvas.md).
|
|
11
|
+
|
|
12
|
+
## Install
|
|
13
|
+
|
|
14
|
+
```bash
|
|
15
|
+
pnpm add -D @tio-canvas/canvas # or: npm i -D @tio-canvas/canvas
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
## Use it in a project
|
|
19
|
+
|
|
20
|
+
### 1. List your screens
|
|
21
|
+
|
|
22
|
+
Create `dev/canvas/screens.js` (any path works; pass it as `config`). It is a
|
|
23
|
+
standalone ES module that the **browser loads as it stands**, so it must not
|
|
24
|
+
import anything.
|
|
25
|
+
|
|
26
|
+
```js
|
|
27
|
+
export const storageKey = 'my-project:canvas:v1'; // where the browser keeps the saved layout
|
|
28
|
+
export const screens = [
|
|
29
|
+
{ id: 'home', kind: 'production', title: 'Home', description: 'The live homepage.',
|
|
30
|
+
src: '/', x: 160, y: 160, width: 1440, height: 900, preset: 'laptop' },
|
|
31
|
+
];
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
- `id` is stable: saved layouts refer to it. `kind` is `production` or `prototype`.
|
|
35
|
+
`description` is required. Both are checked and a mistake throws.
|
|
36
|
+
- `preset` is a key of the device presets (`laptop`, `iphone-16`, `ipad-air`, …; see `client/registry.js`).
|
|
37
|
+
- `storageKey` is optional. Two projects served from one origin (for example
|
|
38
|
+
`localhost:4321`) must not share a key, or they overwrite each other's layout.
|
|
39
|
+
|
|
40
|
+
Page thumbnails live in `dev/canvas/thumbnails/<id>.webp` (see `tio-canvas thumbnails`).
|
|
41
|
+
|
|
42
|
+
### 2. Serve it from your dev server
|
|
43
|
+
|
|
44
|
+
The package does not own a server. Your dev server asks it where things are:
|
|
45
|
+
|
|
46
|
+
```js
|
|
47
|
+
import { createCanvasHost } from '@tio-canvas/canvas/server';
|
|
48
|
+
const canvas = createCanvasHost({ root, config: 'dev/canvas/screens.js' });
|
|
49
|
+
|
|
50
|
+
// For a request under /canvas:
|
|
51
|
+
const { directory, localPath, project } = canvas.locate(pathname); // which file to read
|
|
52
|
+
// if (project) send `await canvas.projectModule()` as text/javascript instead of the file
|
|
53
|
+
// For an HTML page in development:
|
|
54
|
+
const { bridge, tools } = canvas.pageScripts(url, request.headers); // scripts to add before </body>
|
|
55
|
+
// For GET /__dev/interface-kit.js:
|
|
56
|
+
const script = await canvas.interfaceKitBundle();
|
|
57
|
+
// Reload the workspace when any of these change:
|
|
58
|
+
for (const dir of canvas.watchTargets()) watch(dir, { recursive: true }, notify);
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
`tio-web`'s `build/serve.mjs` is the reference integration.
|
|
62
|
+
|
|
63
|
+
### 3. Publish it with your site (optional)
|
|
64
|
+
|
|
65
|
+
```js
|
|
66
|
+
import { copyCanvas } from '@tio-canvas/canvas/build';
|
|
67
|
+
const screens = await copyCanvas({ root, config: 'dev/canvas/screens.js', target: 'dist/canvas' });
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
This writes the canvas, your screens as `project.js`, only the thumbnails of
|
|
71
|
+
screens that exist, and a `noindex` tag. Do not link to it from a public page.
|
|
72
|
+
|
|
73
|
+
### 4. Thumbnails
|
|
74
|
+
|
|
75
|
+
With the dev server running:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
npx tio-canvas thumbnails http://localhost:4321 --missing
|
|
79
|
+
npx tio-canvas thumbnails --check # fails when a screen has no thumbnail
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Options: `--config <file>`, `--out <dir>`. It uses a Chromium from the Playwright
|
|
83
|
+
cache (`~/Library/Caches/ms-playwright`) or `CHROME_BIN`.
|
|
84
|
+
|
|
85
|
+
## Updating
|
|
86
|
+
|
|
87
|
+
Releases follow semver. A project updates with `pnpm update @tio-canvas/canvas`.
|
|
88
|
+
Nothing else needs to change as long as the major version stays the same.
|
|
89
|
+
|
|
90
|
+
## Development
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
pnpm install
|
|
94
|
+
pnpm test # unit tests and a smoke test of the server and build helpers
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
`client/` is served at `/canvas/`. Its files are the canvas exactly as it ran
|
|
98
|
+
inside `tio-web` (the commit it was extracted from is in the first commit
|
|
99
|
+
message), with two changes: `screens.js` now reads `project.js` and
|
|
100
|
+
`registry.js`, and `canvas.js` takes its storage key from the project.
|
|
101
|
+
|
|
102
|
+
Publishing: `npm login`, then `npm publish` (the scope is public through
|
|
103
|
+
`publishConfig`). Bump the version first and tag it `vX.Y.Z`.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tio-canvas/canvas",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "A private exploration canvas: lay your project's pages out on an infinite board, frame real documents, annotate. Development tool, plain JavaScript, ships nothing to visitors.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|