@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 CHANGED
@@ -1,107 +1,95 @@
1
- # @tio-canvas/canvas
1
+ # Tio Canvas
2
2
 
3
- A private exploration canvas for a web project. Pages of the project are laid out
4
- as real, live documents (same-origin iframes) on a finite board that you can pan,
5
- zoom, arrange, resize to device presets, rename, annotate with arrows and text,
6
- and preview full screen. It is a development tool: it adds nothing to what a
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
- Plain HTML, CSS and JavaScript. No framework, no bundler for the canvas itself.
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
- The behaviour of the canvas is described in [`docs/exploration-canvas.md`](docs/exploration-canvas.md).
10
+ Pick one way.
15
11
 
16
- ## Install
12
+ ### Option 1: Ask an AI agent (easiest)
17
13
 
18
- ```bash
19
- pnpm add -D @tio-canvas/canvas # or: npm i -D @tio-canvas/canvas
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
- ```js
51
- import { createCanvasHost } from '@tio-canvas/canvas/server';
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
- // For a request under /canvas:
55
- const { directory, localPath, project } = canvas.locate(pathname); // which file to read
56
- // if (project) send `await canvas.projectModule()` as text/javascript instead of the file
57
- // For an HTML page in development:
58
- const { bridge, tools } = canvas.pageScripts(url, request.headers); // scripts to add before </body>
59
- // For GET /__dev/interface-kit.js:
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
- `tio-web`'s `build/serve.mjs` is the reference integration.
66
-
67
- ### 3. Publish it with your site (optional)
68
-
69
- ```js
70
- import { copyCanvas } from '@tio-canvas/canvas/build';
71
- const screens = await copyCanvas({ root, config: 'dev/canvas/screens.js', target: 'dist/canvas' });
72
- ```
73
-
74
- This writes the canvas, your screens as `project.js`, only the thumbnails of
75
- screens that exist, and a `noindex` tag. Do not link to it from a public page.
76
-
77
- ### 4. Thumbnails
78
-
79
- With the dev server running:
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
- npx tio-canvas thumbnails http://localhost:4321 --missing
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
- Options: `--config <file>`, `--out <dir>`. It uses a Chromium from the Playwright
87
- cache (`~/Library/Caches/ms-playwright`) or `CHROME_BIN`.
87
+ ## Something more advanced?
88
88
 
89
- ## Updating
90
-
91
- Releases follow semver. A project updates with `pnpm update @tio-canvas/canvas`.
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
- `client/` is served at `/canvas/`. Its files are the canvas exactly as it ran
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
- Publishing: `npm login`, then `npm publish` (the scope is public through
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: hidden; line-height: 1.25; font-weight: 400; color: var(--canvas-text);
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
- if(entry.paintScale!==camera.scale || entry.paintWidth!==m.width || entry.paintCanRotate!==canRotate){
351
- entry.paintCanRotate=canRotate;
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-(canRotate?92:64))}px`;
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({
@@ -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 &amp; 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
- if (text.fit || text.height >= height) { text.fit = true; text.height = height; } else text.height = Math.max(minHeight(text), text.height);
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 = clamp(rawHeight, minHeight(text), Math.max(minHeight(text), content));
301
- fit = height >= content;
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,2 @@
1
+ // The installed version of the package. scripts/stamp-version.mjs rewrites it on release.
2
+ export const version = '0.2.0';
@@ -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.1.3",
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",