@tio-canvas/canvas 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/bin/tio-canvas.mjs +12 -0
  4. package/client/arrange.js +41 -0
  5. package/client/arrow-geometry.js +56 -0
  6. package/client/arrow-selection.js +68 -0
  7. package/client/arrows.js +205 -0
  8. package/client/bridge.js +74 -0
  9. package/client/canvas.css +733 -0
  10. package/client/canvas.js +1009 -0
  11. package/client/corners.js +22 -0
  12. package/client/device-gate.js +15 -0
  13. package/client/fullscreen.js +118 -0
  14. package/client/geometry.js +79 -0
  15. package/client/history.js +37 -0
  16. package/client/hover-preview.js +35 -0
  17. package/client/index.html +112 -0
  18. package/client/layer-order.js +20 -0
  19. package/client/layers.js +154 -0
  20. package/client/menu-position.js +36 -0
  21. package/client/onboarding.js +302 -0
  22. package/client/page-picker.js +76 -0
  23. package/client/platform.js +16 -0
  24. package/client/range-selection.js +54 -0
  25. package/client/registry.js +31 -0
  26. package/client/screen-loader.js +67 -0
  27. package/client/screens.js +7 -0
  28. package/client/select-field.js +191 -0
  29. package/client/shortcuts.js +158 -0
  30. package/client/smart-handles.js +28 -0
  31. package/client/smart-selection.js +52 -0
  32. package/client/spacing.js +45 -0
  33. package/client/text-format.js +49 -0
  34. package/client/text.js +466 -0
  35. package/client/tio-logo.png +0 -0
  36. package/client/tooltip.js +128 -0
  37. package/client/touch-preview.js +112 -0
  38. package/docs/exploration-canvas.md +315 -0
  39. package/package.json +31 -4
  40. package/src/build.js +33 -0
  41. package/src/index.js +3 -0
  42. package/src/interface-kit-bundle.js +103 -0
  43. package/src/interface-kit-entry.js +16 -0
  44. package/src/project.js +29 -0
  45. package/src/server.js +70 -0
  46. package/src/thumbnails.js +95 -0
@@ -0,0 +1,112 @@
1
+ // Dev-only mouse-to-touch preview. Native device emulation and trusted touch
2
+ // events require browser tooling; this supplies a scaled contact and inertial drag scroll.
3
+ import { suppressPreviewHover } from './hover-preview.js';
4
+
5
+ // Exponential coast: velocity falls to 1/e every 325ms. Values use iframe CSS
6
+ // pixels and elapsed time, so zoom and refresh rate do not change the gesture.
7
+ const COAST={decay:325,maxSpeed:3,minSpeed:.02,sampleWindow:80,stale:100};
8
+ export function createTouchPreview(canDrag,onContact) {
9
+ const style=document.createElement('style');
10
+ // A simulated tap must not inherit keyboard focus styling from the editor.
11
+ // Keyboard focus inside a native popover needs its own top-layer outline.
12
+ style.textContent=`html[data-canvas-touch-preview],html[data-canvas-touch-preview] *{cursor:none!important}
13
+ html[data-canvas-touch-preview][data-canvas-touch-pointer] :focus-visible{outline:none!important}
14
+ html[data-canvas-touch-preview][data-canvas-touch-pointer] tp-focus-ring{display:none!important}
15
+ html[data-canvas-touch-preview]:not([data-canvas-touch-pointer]) :is(:popover-open:focus-visible,:popover-open :focus-visible){outline:var(--ring-width,2px) solid var(--ring,currentColor);outline-offset:var(--ring-offset,2px)}`;
16
+ document.head.append(style);
17
+ const refreshHover=suppressPreviewHover();
18
+ const reducedMotion=matchMedia('(prefers-reduced-motion: reduce)');
19
+ let enabled=false,drag=null,suppressClick=false,coastFrame=0;
20
+ function stopCoast(){cancelAnimationFrame(coastFrame);coastFrame=0;}
21
+ function coast(target,vx,vy){
22
+ stopCoast();if(reducedMotion.matches || !canDrag())return;
23
+ let previous=performance.now();
24
+ function tick(now){
25
+ coastFrame=0;
26
+ if(!enabled || !canDrag() || !target.isConnected)return;
27
+ const elapsed=Math.min(50,now-previous);previous=now;
28
+ const decay=Math.exp(-elapsed/COAST.decay);
29
+ const moved=scrollFrom(target,vx*COAST.decay*(1-decay),vy*COAST.decay*(1-decay));
30
+ vx=moved.x?vx*decay:0;vy=moved.y?vy*decay:0;
31
+ if(Math.hypot(vx,vy)>=COAST.minSpeed)coastFrame=requestAnimationFrame(tick);
32
+ }
33
+ if(Math.hypot(vx,vy)>=COAST.minSpeed)coastFrame=requestAnimationFrame(tick);
34
+ }
35
+ reducedMotion.addEventListener('change',()=>{if(reducedMotion.matches)stopCoast();});
36
+ const hide=()=>onContact({visible:false});
37
+ function stop() {
38
+ const current=drag;drag=null;
39
+ if(current?.target.hasPointerCapture?.(current.id))current.target.releasePointerCapture(current.id);
40
+ }
41
+ function cancel(){stopCoast();stop();hide();}
42
+ window.addEventListener('pointerdown',event=>{
43
+ if(enabled){stopCoast();document.documentElement.setAttribute('data-canvas-touch-pointer','');}
44
+ if(!enabled || !canDrag() || event.pointerType!=='mouse' || event.button!==0 || event.shiftKey || event.metaKey || event.ctrlKey)return;
45
+ if(event.target.closest('input,textarea,select,[contenteditable]:not([contenteditable="false"])'))return;
46
+ suppressClick=false;
47
+ drag={id:event.pointerId,target:event.target,x:event.clientX,y:event.clientY,startX:event.clientX,startY:event.clientY,moved:false,samples:[{x:event.clientX,y:event.clientY,time:performance.now()}]};
48
+ event.preventDefault();
49
+ },true);
50
+ window.addEventListener('pointermove',event=>{
51
+ if(!enabled || event.pointerType!=='mouse')return;
52
+ if(!event.buttons || drag)event.stopImmediatePropagation();
53
+ onContact({visible:true,x:event.clientX,y:event.clientY});
54
+ if(!drag)return;
55
+ if(!drag.moved && Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<6)return;
56
+ event.preventDefault();
57
+ if(!drag.moved){drag.moved=true;drag.target.setPointerCapture?.(drag.id);}
58
+ scrollFrom(drag.target,drag.x-event.clientX,drag.y-event.clientY);
59
+ const now=performance.now();
60
+ drag.samples.push({x:event.clientX,y:event.clientY,time:now});
61
+ while(drag.samples.length>2 && now-drag.samples[0].time>COAST.sampleWindow)drag.samples.shift();
62
+ drag.x=event.clientX;drag.y=event.clientY;
63
+ },{capture:true,passive:false});
64
+ window.addEventListener('pointerup',()=>{
65
+ const current=drag;stop();if(!current?.moved)return;
66
+ suppressClick=true;
67
+ const first=current.samples[0],last=current.samples.at(-1),elapsed=Math.max(8,last.time-first.time);
68
+ if(performance.now()-last.time>COAST.stale)return; // Holding still stops a fling.
69
+ const speed=value=>Math.max(-COAST.maxSpeed,Math.min(COAST.maxSpeed,value));
70
+ coast(current.target,speed((first.x-last.x)/elapsed),speed((first.y-last.y)/elapsed));
71
+ },true);
72
+ window.addEventListener('click',event=>{
73
+ if(suppressClick && event.detail>0){event.preventDefault();event.stopImmediatePropagation();suppressClick=false;}
74
+ },true);
75
+ window.addEventListener('lostpointercapture',()=>{if(drag)cancel();});
76
+ window.addEventListener('keydown',event=>{
77
+ if(!['Shift','Control','Alt','Meta'].includes(event.key))document.documentElement.removeAttribute('data-canvas-touch-pointer');
78
+ stopCoast();if(event.key==='Escape')cancel();
79
+ },true);
80
+ window.addEventListener('wheel',stopCoast,{capture:true,passive:true});
81
+ window.addEventListener('pointercancel',()=>{suppressClick=false;cancel();});
82
+ window.addEventListener('pointerout',event=>{if(!event.relatedTarget && !drag)hide();});
83
+ window.addEventListener('blur',cancel);
84
+ document.addEventListener('visibilitychange',()=>{if(document.hidden)cancel();});
85
+ window.addEventListener('canvasvisibilitychange',event=>{if(!event.detail.visible)cancel();});
86
+ return {configure(touch,active){
87
+ enabled=touch;document.documentElement.toggleAttribute('data-canvas-touch-preview',touch);
88
+ if(touch)refreshHover();else document.documentElement.removeAttribute('data-canvas-touch-pointer');
89
+ if(!touch || !active)cancel();
90
+ }};
91
+ }
92
+
93
+ function scrollFrom(target,dx,dy) {
94
+ const root=document.scrollingElement;let movedX=0,movedY=0;
95
+ for(let node=target;node && (dx || dy);node=node.parentElement){
96
+ const style=getComputedStyle(node),isRoot=node===root;
97
+ for(const axis of ['X','Y']){
98
+ const delta=axis==='X'?dx:dy;if(!delta)continue;
99
+ const overflow=style[`overflow${axis}`];
100
+ const bodyOverflow=isRoot?getComputedStyle(document.body)[`overflow${axis}`]:'';
101
+ if(!/auto|scroll|overlay/.test(overflow) && !(isRoot && !/hidden|clip/.test(overflow) && !/hidden|clip/.test(bodyOverflow)))continue;
102
+ const property=axis==='X'?'scrollLeft':'scrollTop',before=node[property];
103
+ node.scrollBy({left:axis==='X'?delta:0,top:axis==='Y'?delta:0,behavior:'instant'});
104
+ const moved=node[property]-before;
105
+ if(axis==='X')movedX+=moved;else movedY+=moved;
106
+ const remainder=delta-moved;
107
+ const remaining=style[`overscrollBehavior${axis}`]==='auto'?remainder:0;
108
+ if(axis==='X')dx=remaining;else dy=remaining;
109
+ }
110
+ }
111
+ return {x:movedX,y:movedY};
112
+ }
@@ -0,0 +1,315 @@
1
+ # Private exploration canvas and future interactive gallery
2
+
3
+ > **Note for the package.** This document was written inside `tio-web`, where the canvas files lived in `dev/canvas/`. In this package they are in `client/`, and the page list (`screens.js`) and thumbnails belong to the project that installs it. Project-specific details (the tioputrad.com pages, Worker gate, prototypes) describe `tio-web`, the reference project.
4
+
5
+ ## Agreed scope
6
+
7
+ The canvas is a private workspace at `/canvas`, accessible only by typing its URL, in development and in production. It is not a portfolio page, has no site navigation link, carries `noindex`, and is not in the sitemap. `build/build.mjs` copies `dev/canvas/` to `dist/canvas/` with a generated `screens.js`. Prototype pages ship to `dist/prototypes/` too, but the Worker (`src/worker-gate.js`) serves them only to a frame inside the site: they open in the canvas, and a typed address is a 404. A command-line client can forge the headers, so this is not authentication. Interface Kit is injected by the dev server alone and never ships. The production canvas adds its own bridge script to the same-origin screens it frames (the dev server does that for development), and `public/_headers` allows same-origin framing (`X-Frame-Options: SAMEORIGIN`). `pnpm preview` serves `dist/canvas` exactly as a visitor gets it.
8
+
9
+ The canvas and public gallery are independent. Canvas experiments do not automatically appear in the gallery. Build the canvas first, then tackle the gallery using the requirements below without another requirements interview.
10
+
11
+ ## Canvas behavior
12
+
13
+ - Finite 20,000 × 20,000 CSS-pixel workspace. Screen placement, resizing, and pan share the same 0–20,000 bounds: a frame at a workspace corner can meet the usable canvas corner when pan reaches that edge (to the right of the expanded Layers drawer), without extra outside whitespace. If a zoomed workspace axis is smaller than the usable viewport, center it on that axis. The expanded Layers drawer reserves its width plus the 10px outside gaps for pan limits and fit calculations; collapsing it restores the full canvas width. At an edge, this hard boundary takes priority over pointer-anchored zoom or centering a fit operation. Device presets move a frame inward if needed to preserve the exact preset size; custom resizing cannot extend past the workspace edge. Ordinary vertical and horizontal wheel/trackpad scrolling pans the canvas on both axes over the background or inactive screens. A single selected screen is active: ordinary scrolling inside it stays native and scrolls its own contents instead of the canvas. Clicking screen content or its title activates it; background selection clearing deactivates it. Multi-selection remains a group drag surface, not an active document. Command/Ctrl+wheel zooms around the pointer on the host and inside screens; trackpad pinch uses the browser's Ctrl+wheel event with a stronger zoom response. Plain scrolling must never change scale. Zoom range: 10–200%. Middle-button drag or Space + primary-button drag pans. Ordinary clicks belong to the screen. Command/Ctrl-modified wheel always zooms, including in an active screen; trackpad pinch zooms through the browser's Ctrl+wheel event. Ordinary active-screen scrolling, scrollbars, and keyboard scrolling remain available. Space+wheel pans even over active content. This is a canvas-only revision, not a change to the future public gallery scroll requirements.
14
+ - Screen document geometry uses whole CSS pixels: x, y, width, and height are always integers. Round restored legacy layouts, drag destinations, numeric dimensions, and spacing edits to the nearest pixel. Group movement rounds one shared translation so its internal gaps do not change. Magnetic edge/center/gap snapping is applied before pixel rounding; when an exact center alignment needs half a pixel, whole-pixel geometry wins. Restored fractional positions and custom dimensions are migrated back to storage. Camera pan/zoom and pointer measurements retain fractional precision; this does not constrain the document to device pixels at arbitrary zoom. Gap calculations use the integer document geometry, never transformed browser rectangles, so entering 50 stays exactly 50 rather than a floating-point remainder.
15
+ - Screen content is real HTML in a same-origin iframe, not a screenshot or an HTML canvas drawing. Each document owns its styles, fonts, responsive layout, and interaction state.
16
+ - Clicking content selects its screen without consuming the click. Shift+click toggles membership in the selection, including clicks inside screen documents. Drag on empty canvas to draw a selection rectangle; any positive-area intersection with a screen's content frame selects it, not just full containment. Shift+drag adds to the current selection. A background press starts with a hidden, cleared selection rectangle; only an actual drag draws it. A click must never redisplay the previous rectangle. Zero-area rectangles do not select screens. Middle-button and Space+drag remain pan gestures.
17
+ - Dragging a selected title moves the whole selection without changing its internal spacing. A multi-selection gets individual outlines plus a group bounding outline. Fit to screen and Cmd+D operate on the entire selection. The size inspector and resize handles are hidden for groups; resizing remains a single-screen operation. A group instead has an editable Gap toolbar centered beneath its bounding box, using the same styling and screen-space offset as the single-screen inspector. For a multi-selection, the entire group bounding box is a drag surface, including frame contents and empty gaps. This intentionally takes priority over embedded controls while the group is selected. Return to a single selection to interact with its controls. Shift+click within that surface can still deselect an individual member. Single-screen contents remain interactive, not a drag surface.
18
+ - Holding Shift while dragging a title or selection group constrains motion horizontally or vertically relative to the original position. The dominant initial movement chooses the axis, which stays fixed while Shift remains held. Release Shift for free movement; press it again to choose the current dominant axis. The locked coordinate does not magnetically snap away from its original value. A guide follows the original center line. Shift+click still toggles membership when no drag occurs.
19
+ - The group Gap toolbar shows Gap X for a predominantly horizontal arrangement or Gap Y for a predominantly vertical one, determined by the larger spread of screen origins (horizontal wins ties). It measures edge-to-edge spacing in workspace pixels, independent of zoom. Unequal distances show Mixed; overlapping equal distances show Overlap. Entering a nonnegative integer distributes the selected screens along that axis in spatial order, anchored at the first screen, while preserving each screen's other coordinate, dimensions, preset, stacking, and mounted document. This is one ordered row or column, not automatic grid packing. Zero gap is allowed. Values are capped to the room remaining in the finite workspace; if the selected sizes cannot fit after the anchor, the field is disabled. A change is one undoable, persisted position edit. Single selection restores the ordinary size inspector.
20
+ - While moving a frame or group, snap to other frames' edges/centers or matching gaps in the same row/column. The attraction distance is 6 screen pixels at any zoom. Gap values are measured from existing adjacent frames, not a hardcoded grid; insertion between two frames can also snap to equal gaps. Alignment/spacing guides appear during a move and clear on release/cancel. Equal-gap snapping draws labeled indicators both for the active gap and the existing reference gap. Insertion between two frames draws two separate equal-gap segments, not a line across the moving frame. Snapping does not change the selected frames' relative positions.
21
+ - Selected screen titles turn blue. Titles retain their screen-space font size but are ellipsized to the frame's visible width when zoomed out. The native title tooltip and accessible name keep the full name.
22
+ - Delete only through Delete/Backspace or the right-click context menu, from the host or a screen document, except while editing text. There is no Delete button in the toolbar. Clicking a screen title or starting a canvas gesture ends host field editing; selecting mobile content also releases a stale host field without taking focus from project editors. Forwarded shortcuts act on the current selection even if keyboard focus remains in an older iframe. Every selected instance can be deleted, including the last instance of a source. An empty canvas stays empty after refresh; removed originals and copies do not return. The source catalog remains available in Add pages without mounting hidden documents. This changes canvas instances only, never source files. Fit to screen stays visible but is disabled when no screen is selected.
23
+ - Double-click a screen title to rename it inline. The title editor hugs its text up to the existing title-width limit, with 4px padding on every side. It grows and shrinks as text changes instead of filling the screen width. Enter or blur commits; Escape cancels. A whitespace-only name restores the original source title without a duplicate number. Names are instance-specific, limited to 200 characters, persisted, and undoable. Renaming does not change source IDs, URLs, files, or the numeric naming of future default copies.
24
+ - The Layers drawer is on the **left**, inset 10px from the left and top, with a separate help/appearance toolbar beneath it. The toolbar sits 10px from the left and bottom, with a 10px gap between it and the expanded drawer. Minimizing the drawer never hides that toolbar. It uses the same container styling as the other canvas tools. One screen is one layer, shown with its current device icon; Custom uses a diagonal two-way arrow. The top row is the frontmost screen. Click to select, Shift+click to toggle membership, or drag across row labels to select a contiguous range. A muted grabber appears left of the device icon on hover or keyboard focus, without shifting the label. Its tint follows the canvas theme. Drag anywhere across the grabber, device icon, or their shared gap to reorder. Dragging a selected group preserves its relative order, with an insertion guide and automatic list scrolling at the edges. Escape cancels a layer drag. Cmd+[ / Cmd+] move the selection one layer backward/forward; Cmd+Shift+[ / Cmd+Shift+] send it to the bottom/top. These shortcuts also work from non-editing iframe contexts, while host fields and project editors keep native keyboard behavior. The header button sits at the right of the Layers heading and minimizes the drawer to a header of the same width. Rows have a 4px gap. Double-click a layer name to rename it inline; Enter or blur saves, and Escape cancels. Layer order and minimized state survive reload; order changes are undoable, while minimized state is a view preference outside history. Reordering changes stacking without moving iframe DOM nodes, so documents, scroll, and project state remain intact. Native layer buttons also support Tab, Enter, and Space.
25
+ - Canvas document history supports undo/redo for preset changes, numeric sizing, whole resize/move gestures, add/duplicate/delete, rename, and layer order. One drag is one edit. Use Cmd+Z / Cmd+Shift+Z on macOS, Ctrl+Z / Ctrl+Shift+Z or Ctrl+Y elsewhere, or the toolbar buttons. Native text fields, inline rename editing, and project editors retain their own undo behavior. Theme, pan/zoom/fit, drawer minimization, selection-only changes, and new source registration are excluded. History is bounded to 100 edits and resets on refresh; final layout/names remain persisted. New source entries are added in `screens.js`, not through history; its dev reload starts a fresh history session.
26
+ - Undo restores canvas document state, not arbitrary project internals. Existing unchanged iframe documents remain mounted during history restoration. Undoing deletion or redoing a duplicate recreates that removed document, so its internal project content starts fresh. This avoids keeping every historically deleted document alive in memory.
27
+ - Resize changes the iframe's actual viewport dimensions. Device presets lock both numeric fields and resize handles. The user must explicitly choose Custom before resizing; dragging never switches presets automatically.
28
+ - Four corner nodes and four midpoint grabbers resize Custom frames. Midpoint grabbers appear on hover. The selected frame has a toolbar centered 10 screen pixels beneath it, with preset, width, and height. Controls, outline, handles, and the gap retain their screen-space size at every zoom.
29
+ - Custom select menus follow their fields on every canvas render, including pan and animated zoom. They open below or above according to available space, stay inside the viewport using `--canvas-panel-inset`, and keep that same clearance from visible fixed toolbars and the Layers drawer. An offscreen field does not hide its open menu: the menu follows as far as the visible free space allows. Long menus scroll within that space. Start/End arrow menus remain above their fields and content-sized; other selects have a 150px minimum width. Changing or clearing selection closes the menu immediately. Choosing an option releases focus rather than returning it to the field. `tests/canvas-menu-position.mjs` checks placement geometry; `tests/canvas-select-menus.mjs` checks live pan tracking, viewport boundaries, toolbar clearance, and menu lifecycle in the browser.
30
+ - Canvas toolbars share the Screens panel's 10px outside inset. The bottom-center editing toolbar holds Cursor, Arrow, and Add pages. Undo and Redo live in a separate top-right history toolbar. The bottom-right zoom toolbar holds, in order: Fit to screen, Fit all, Zoom to 100%, a divider, magnifying-glass minus, editable zoom percentage, and magnifying-glass plus. Minus and plus change zoom by 10 percentage points at or below 50%, and 20 points above 50%, clamped to 10–200%. The step uses the pending zoom target during an animation so rapid clicks correctly cross the threshold. Tooltips and accessible labels report the current step. Both buttons and Zoom to 100% use the same 260ms eased camera transition as Fit. Rapid clicks accumulate full steps toward the latest target while movement continues from the current camera; reduced motion applies the target immediately. Below 1100px the editing toolbar moves above the zoom toolbar with a 10px gap to avoid overlap. The bottom-left toolbar holds Keyboard shortcuts and the square icon-only theme toggle independently of drawer minimization. Fit all (Shift+1) and Fit selected screens (Shift+2) ease camera position and scale together over 260ms, including shortcuts forwarded from screen documents. Repeated fits retarget from the current camera; wheel zoom, field edits, or pan interrupts the movement. Reduced motion applies the final camera immediately. Fields, dialogs, and fullscreen previews keep their native keyboard behavior. Add pages opens the page picker. The native modal dialog has a scrim and a 500 × 700px footprint capped to the viewport. Its header matches the Layers header height; its middle section scrolls the registered page catalog; its entire footer is hidden until at least one page is checked. The footer places Select all then Unselect all on the left and Add page(s) on the right. Each row has a custom accessible checkbox with a muted outline and a 16px visual inside a 44px target (negative inline margins preserve the thumbnail position) to the left of its thumbnail. Only the checkbox toggles selection; clicking the rest of a card immediately adds that page alone, regardless of other checked pages. A pill shows the number of existing screens for a page without disabling it. Shift-click adds a contiguous range. Dragging from an unchecked checkbox checks a range with edge scrolling and preserves earlier checks; dragging from a checked checkbox unchecks that range. Multiple selected pages are added as independent new screens in one undoable edit, selected and fitted into view. The close button, Escape, or a scrim click closes without changes. Canvas shortcuts are inactive while a modal is open. Adding uses the same 200-copy safety limit as duplication, but its placement is separate: on an empty canvas the first added screen is centered at workspace coordinate (10,000, 10,000). Otherwise each new screen starts 100 workspace pixels beyond the furthest right edge of all existing screens, aligned to that screen’s top edge. Batch additions continue to the right with the same gap. A batch that cannot fit within the workspace is rejected in full; Add pages never wraps to another row. Reset view is intentionally removed. Buttons have gray surfaces and no border. Fields have 8px horizontal padding, centered numeric values, and the preset chevron has a 6px gap after its label.
31
+ - Cmd+D duplicates the selected screen(s) when not editing a text field, including when focus is inside that screen. Copies reuse one source URL but mount separate documents with fresh internal state. Names use the original source title plus `(1)`, `(2)`, and so on, even when duplicating a copy. Copies retain their own position, dimensions, and preset after refresh. A copy is placed to the right of the occupied row, selected, and fitted into view. Repeated shortcuts append beyond earlier copies even when keyboard focus stays in the original iframe. Group copies share one translation and preserve internal spacing. When the current row reaches the right workspace boundary, copies wrap to a free row below, then search earlier free rows if necessary. Placement stays inside the finite workspace and never overlaps existing frames. Duplication does nothing only if no candidate placement fits or the instance safety limit would be exceeded; a group is never partially duplicated. At most 200 duplicate instances are retained to bound storage and document count; this is a safety limit, not a performance promise.
32
+ - Position, dimensions, preset, name, layer order, minimized drawer state, and camera survive refresh using versioned localStorage. Malformed, oversized, and inaccessible storage falls back safely. Internal project state need not survive refresh.
33
+ - A screen is visible if any of its content frame intersects the viewport. Zoom level alone never replaces it with a preview. A not-yet-seen screen loads on its first intersection. Once loaded, it is never unloaded merely for leaving the viewport: input, component state, and scroll remain intact.
34
+ - Loaded screens receive visibility notifications, also accounting for a hidden browser tab. Their own animation/timer work must cooperate to pause. Arbitrary third-party code cannot be universally frozen while guaranteeing its state remains intact.
35
+ - Mobile exploration is deferred. No framework or dependency was approved for this implementation.
36
+
37
+ ## Arrows
38
+
39
+ The bottom-center toolbar has Cursor (V), Arrow (A), and Add pages. Cursor keeps
40
+ normal screen interaction, selection, and dragging. In Arrow mode, drag from
41
+ empty space or a screen edge to create a connector. Completing an arrow returns
42
+ to Cursor and selects it. Escape cancels an unfinished gesture and returns to
43
+ Cursor. Space-drag and middle-button pan still work in either tool.
44
+
45
+ Arrows can remain free or bind either endpoint to any position along an object's
46
+ four edges. Bindings store an object ID, side, and normalized edge position, so
47
+ the geometry is not coupled to screens. The four side-center dots use the same
48
+ size and surface treatment as reorder dots. Center snapping has a 20 screen-pixel
49
+ capture zone and takes priority over the 12 screen-pixel edge capture zone.
50
+ A bound endpoint stays 8 workspace pixels outside its edge at every position;
51
+ zoom scales that distance with the object. Moving or resizing the object updates
52
+ the connector. Deleting the object leaves its endpoint at its last position.
53
+
54
+ Click an arrow to select it; Shift-click toggles membership. A background drag
55
+ selects every arrow stroke the rectangle intersects, alongside screens. Selection
56
+ uses the actual line or circular arc, not empty space inside its bounding box.
57
+ A selected arrow retains its normal ink with a 1px blue center stroke that stays
58
+ 1px at every zoom. Its head keeps its ordinary ink.
59
+
60
+ A single arrow retains its two endpoint nodes and balanced-arc middle node. Drag
61
+ an endpoint to attach or detach it, its line to move it, or the middle node
62
+ perpendicular to its chord to bend it. Focused endpoint nodes accept arrow keys
63
+ (one workspace pixel, or ten with Shift); the middle node's arrow keys adjust
64
+ its bend. Multiple free arrows have a group box with four corner handles only.
65
+ Corner resizing scales endpoint positions and bend uniformly about the opposite
66
+ corner, preserving circular arcs; stroke thickness stays unchanged. If any arrow
67
+ in the group is bound, group movement and resizing are locked and configuration
68
+ remains editable. A mixed screen/arrow selection exposes configuration but not
69
+ arrow resize handles.
70
+
71
+ The arrow toolbar is docked directly above the main toolbar with the same 10px
72
+ outside margin as its vertical gap. It follows the main toolbar's responsive
73
+ position, keeping within the viewport. It contains two icon-only endpoint menus:
74
+ a horizontal line or a left arrow for the start, a horizontal line or a right
75
+ arrow for the end. Accessible names still identify each endpoint. After a divider,
76
+ line thickness offers exactly 2px, 4px, 6px, 8px, and 10px (default 4px), followed
77
+ by Solid/Dotted. Legacy thicknesses are normalized to the nearest allowed value.
78
+ A curved selection adds a final divider and the secondary Reset to straight
79
+ button. Configuration edits apply to every selected arrow in one history step;
80
+ unequal field values show Mixed. Nodes and tools retain their screen-space size.
81
+ Arrow tools are hidden in fullscreen previews without reloading the document.
82
+
83
+ Screens and arrows share one bottom-to-top object order. Command+[ / Command+]
84
+ move the selected objects backward/forward by one position; adding Shift sends
85
+ them to the back/front. This includes arrows even though the Layers drawer lists
86
+ only screens. Order is persisted and undoable. Every floating canvas toolbar
87
+ paints above the entire object stack, regardless of an object's position.
88
+
89
+ Creation, endpoint edits, movement, bending, styles, reset, and deletion participate
90
+ in canvas undo/redo. Delete/Backspace removes a selected arrow unless a field is
91
+ being edited. Final arrows and bindings persist alongside the existing layout;
92
+ selection and active tool do not. Up to 1,000 arrows can be restored, with
93
+ validation and a 1 MB total saved-document read limit. All arrow code is served
94
+ only by the development canvas and is excluded from production.
95
+
96
+ Verification: `node tests/canvas-arrow-geometry.mjs` checks geometry, validation,
97
+ and history. `node tests/canvas-arrows.mjs` checks the actual browser tools,
98
+ endpoint and middle-node dragging, circular midpoint, toolbar placement, styles,
99
+ reset, edge binding and detachment, screen movement/resizing, deletion, undo/redo,
100
+ reload persistence, cancellation, and the existing iframe/fullscreen flow.
101
+ `node tests/canvas-arrow-revisions.mjs` covers marquee and multi-selection,
102
+ proportional corner resize, binding locks, batch configuration, icon menus,
103
+ docked toolbar spacing, shared dropdown styling, 1px selection, shared-object
104
+ stacking, and persistence.
105
+
106
+ ## Fullscreen screen preview
107
+
108
+ Every screen has a fullscreen icon immediately to the right of Refresh, whether
109
+ selected or not. Both icons stay visible without selecting the screen.
110
+ This is fullscreen within the browser viewport, not the browser Fullscreen API.
111
+ It hides all canvas tools and fills the viewport with the selected document.
112
+ The existing iframe stays mounted: page state, scroll, and inputs survive entry
113
+ and exit. The preview always follows the browser's viewport dimensions, including window
114
+ resizes, without changing the saved screen size. Canvas camera position and zoom
115
+ are preserved.
116
+
117
+ A dark, circular 36 × 36px hamburger button floats above the preview. Its resting
118
+ position is 16px from both edges of a corner. Dragging uses pointer capture and
119
+ works with mouse or touch. On release, the button's center chooses one of four
120
+ viewport quadrants, and the button travels to that corner over 260ms using a
121
+ transform transition. A new drag interrupts from the currently painted position.
122
+ Reduced motion snaps without travel. Dragging does not trigger a menu click.
123
+
124
+ The button toggles a controls panel containing only Back to canvas. Resolution
125
+ presets and custom W/H are available only in the canvas inspector, never in
126
+ fullscreen. The panel shares the canvas tools' surfaces, borders, and typography. The panel aligns with the button's outside edge and
127
+ opens below a top corner or above a bottom corner, separated by 8px. Its outer
128
+ edge stays 16px from the viewport edge; long panels scroll within the available
129
+ height. Clicking outside, including inside the preview document, closes it.
130
+ Escape inside the controls closes the panel and returns focus to the button;
131
+ Escape on the closed button returns to the canvas. Escape in the project only
132
+ closes the controls, leaving project keyboard handling intact.
133
+
134
+ Exiting restores the screen's canvas dimensions and camera without changing its
135
+ preset or adding a history edit. Floating position and fullscreen state are
136
+ session-only.
137
+
138
+ Fullscreen input stays native: the bridge does not intercept canvas keyboard
139
+ shortcuts, wheel zoom/pan, Space, or middle-button gestures, and simulated mobile
140
+ touch/hover overrides are disabled. Other loaded screens receive hidden-state
141
+ notifications while the fullscreen document is visible. Everything here lives
142
+ under `dev/canvas/` and is excluded from production output.
143
+
144
+ ## Active screen input, touch preview, and refresh
145
+
146
+ The host sends each loaded screen its active state and preset input mode, then
147
+ resends them after a load, preset/selection change, or history restoration. Only
148
+ one selected screen is active. Its wheel listener does not prevent ordinary
149
+ scrolling, preserving the browser's own nested scroll containers and page scroll.
150
+ Command+wheel is always routed to canvas zoom. Inactive screens still forward
151
+ ordinary wheel to canvas pan. These policies remain development-only.
152
+
153
+ Phone and tablet presets (`touch: true` in `screens.js`) enable a lightweight
154
+ mouse touch preview in `touch-preview.js`. It reports pointer positions in iframe CSS pixels to a translucent contact
155
+ drawn by the host canvas. Its diameter is 24 logical CSS pixels, scaled with the
156
+ screen when the canvas zooms, unlike fixed-size tool chrome. Drawing outside the
157
+ iframe keeps the contact above native popovers and dialogs: their browser top
158
+ layer bypasses every ordinary `z-index` within that iframe. The host validates
159
+ the source window and maps coordinates through the current frame and camera. A primary-button drag on active
160
+ non-editor content scrolls eligible containers under the pointer on either
161
+ axis, respecting overflow locks and overscroll containment. A stationary tap
162
+ keeps normal click behavior; a drag suppresses the following click to prevent
163
+ accidental activation. Releasing a moving drag now starts simulated momentum on
164
+ the same scroll chain: recent pointer velocity decays exponentially (325ms time
165
+ constant), with a 3 CSS-pixels/ms speed cap and no overscroll bounce. Velocity
166
+ uses iframe CSS pixels and elapsed time, independent of canvas zoom and refresh
167
+ rate. Holding still before release produces no fling. A new press, wheel input,
168
+ keyboard input, lost capture, deactivation, preset change, hidden/offscreen
169
+ screen, or blur cancels the gesture/coast. `prefers-reduced-motion` retains direct
170
+ drag scrolling but omits the inertial tail. Text editors retain ordinary mouse editing, and Space /
171
+ middle-button gestures remain canvas pan. The contact hides on exit/blur;
172
+ preset changes, cancellation, loss of pointer capture, and hidden documents
173
+ release gestures.
174
+
175
+ This is not Chrome DevTools device emulation. It does not create trusted touch
176
+ events, change `pointerType` or touch/hover media queries, emulate multi-touch,
177
+ or reproduce native device momentum/physics exactly. The inertial tail is a
178
+ local simulation. Browser-native touch gestures and project-specific touch-only
179
+ handlers still require actual device/browser tooling.
180
+
181
+ Mobile presets also disable hover interaction in the preview. `hover-preview.js`
182
+ guards `:hover` selectors in readable preview stylesheets without increasing
183
+ specificity, including nested rules and styles loaded later. The guard becomes
184
+ inert when leaving the mobile preset, restoring desktop hover without reload.
185
+ Mouse hover/move handlers are suppressed while ordinary presses, taps, keyboard
186
+ focus, and editor drags remain available. This changes only the iframe's live
187
+ CSS rules/events, never source files or built production assets. It does not
188
+ change browser media-query capabilities; inaccessible cross-origin stylesheets
189
+ and closed shadow roots cannot be rewritten by this preview. Current portfolio
190
+ styles are same-origin and are covered by the browser regressions.
191
+
192
+ Simulated taps also suppress native focus outlines and the portfolio's separate
193
+ focus-ring overlay while using the pointer. The preview still moves actual focus
194
+ into the activity card; it does not blur controls or remove keyboard access.
195
+ A non-modifier key restores keyboard focus styling. Focused controls inside an
196
+ open popover get a native outline so their keyboard indicator is painted in the
197
+ same top layer, rather than behind the card. These overrides are inactive in
198
+ desktop presets and never enter the production stylesheets.
199
+
200
+ Each screen has a fixed-screen-size refresh icon at the right edge of its
201
+ name row. It loads a replacement document from that instance's registered source page,
202
+ resetting its internal state and scroll without refreshing the canvas or other
203
+ screens. The previous document stays painted until the replacement stylesheet,
204
+ fonts, and initial layout are ready. The pending frame is invisible, inert, and
205
+ hidden from accessibility tools; promotion removes the old frame. This avoids a
206
+ blank white navigation frame, including when refreshing a dark page. `screen-loader.js` owns creation, readiness, replacement, and cleanup. The bridge reports document/font readiness without waiting for images or weather; slow nonessential requests must not hold the refresh button locked. Native lazy loading keeps initial embedded media separate from host load completion. A 15-second refresh deadline preserves the previous document, removes the stalled replacement, unlocks retry, and reports an accessible error if readiness never arrives. The refresh
207
+ button is disabled while a replacement is pending, preventing stacked reloads.
208
+ Position, dimensions, preset, name, camera, and selection stay unchanged;
209
+ refresh is not a canvas history edit. Readiness notification restores active/touch
210
+ configuration automatically.
211
+
212
+ ## Canvas tool appearance and isolation
213
+
214
+ The public [Untitled UI component catalog](https://www.untitledui.com/react/components),
215
+ [buttons](https://www.untitledui.com/react/components/buttons),
216
+ [inputs](https://www.untitledui.com/react/components/inputs), and
217
+ [select](https://www.untitledui.com/react/components/select) pages are visual references,
218
+ not installed libraries. The implementation is original native HTML/CSS/JavaScript:
219
+ neutral surfaces, compact rounded controls, subtle panel and screen shadows, muted labels,
220
+ and a clear keyboard focus ring. It is inspired by the reference, not an exact
221
+ port of its React components. No React, Tailwind, Untitled UI package, copied
222
+ proprietary component source, external stylesheet, or external font is used.
223
+
224
+ `dev/canvas/canvas.css` owns the canvas-only tokens and controls. It is loaded
225
+ only by the canvas document; iframe documents keep their own design systems.
226
+ No portfolio styles or tokens are changed. No tool assets reach production. The canvas background has a subtle dotted grid (muted ink at 12%, reduced from 28%), anchored to camera movement and zoom with minimum spacing to avoid a dense pattern at low zoom. Dots remain behind the iframe surfaces; each screen has a thin shadow to distinguish it from the workspace.
227
+
228
+ ## Files and adding screens
229
+
230
+ All canvas code is in `dev/canvas/`, outside build inputs. The dev server explicitly serves this directory. Changes reload the development browser.
231
+
232
+ 1. Put a local HTML exploration under `dev/prototypes/web-pages/<experiment>/` or `dev/prototypes/mini-projects/<project>/` (or use an existing local page, such as `/`). Use its `/prototypes/.../` URL. See `dev/prototypes/README.md` for promotion rules.
233
+ 2. Add an entry in `dev/canvas/screens.js`: stable `id`, `kind` (`production` or `prototype`; the Add pages dialog groups pages by it in an accordion), `title`, a short required `description` (shown under the title in that dialog; the file throws without one), local `src`, `x`, `y`, `width`, `height`, and `preset`. The default is `id: 'web-page-prod'`, `kind: 'production'`, `title: 'Web Page'`, `src: '/'`, using the 1440 × 900 laptop preset. This renders the local production implementation of tioputrad.com, not the deployed remote domain. Future experiments should use distinct IDs such as `web-page-exp-1`, readable titles such as `Web Page (Exp 1)`, and their own local page. The first screen of a page has no number; its copies are named `Web Page (Exp 1) (1)`, `(2)`, and so on. If the first screen is deleted and the page is added again, the new screen is the unnumbered one. Display names are editable; stable IDs identify sources regardless of rename.
234
+ 3. Use `custom` or a preset key from that file. Presets specify CSS viewport sizes, not full browser device emulation: no device pixel ratio, user-agent, capability/media-query, or browser-chrome emulation. Phone and tablet presets enable the limited touch preview described below.
235
+ 4. Do not add production credentials, writes, or unsafe remote fixtures. The initial interaction study is local-only and makes no network calls beyond its local files. A real website preview may load its usual image/weather requests; review this before adding it.
236
+
237
+ The old Interaction study page is removed from the development canvas. Its small interactive fixture exists only under `tests/fixtures/` and is supplied through test-only browser request interception, never a server route or registered screen. The default portfolio can make its usual image/weather requests and has normal outbound links; it is the local website implementation, not a mock.
238
+
239
+ The dev server injects `bridge.js` into a screen requested with `?canvasScreen=1`. Its messages are checked by origin and iframe window identity. It forwards selection and canvas gestures without replacing component handlers. This is trusted-document style isolation, not a security sandbox. Do not run untrusted code here.
240
+
241
+ A cooperating exploration can listen to:
242
+
243
+ ```js
244
+ window.addEventListener('canvasvisibilitychange', ({ detail: { visible } }) => {
245
+ // Stop or resume this project's own animation or timer work.
246
+ });
247
+ ```
248
+
249
+ It can also use `html[data-canvas-visible="false"]` to pause CSS animation. The fixture demonstrates this. No production component imports canvas code.
250
+
251
+ The canvas host owns the only live-reload EventSource connection. Embedded
252
+ `?canvasScreen=1` documents receive the bridge but no individual reload stream;
253
+ a host reload already refreshes every mounted screen. One stream per iframe
254
+ exhausts the browser's per-origin HTTP connection limit after a few screens and
255
+ can block later documents, assets, and refresh replacements indefinitely.
256
+
257
+ There is a real memory tradeoff: preserving every visited document retains its memory. Visibility notifications reduce active work, not retained memory. Measure realistic collections before claiming that any number of screens remains smooth.
258
+
259
+ ## Future public gallery: preserved requirements
260
+
261
+ - Keep the existing square tiles and the site's settled layout/motion. Each project has an independent design system; its styles must not affect the portfolio or other projects.
262
+ - The primary direction is automatically interactive as a tile comes into view, without a manual activation click. Load only what is necessary near the visible gallery; avoid running every project's animation simultaneously.
263
+ - Some later projects may use an opening interaction that fills the main container. This is acknowledged but deferred; do not implement it in the canvas phase.
264
+ - Native nested scrolling should continue into the outer page when the inner project reaches its boundary. Test actual iframe/browser behavior before promising reliable scroll chaining.
265
+ - Mobile is deferred: possible static UI images instead of interactivity, or a different exploration flow. Do not commit to either yet.
266
+ - No custom project keyboard navigation is required. Preserve native buttons, typing, and an escape path from focus; do not deliberately disable native keyboard access.
267
+ - A preview must come from the same implementation, at matching dimensions and initial state. Keep it until the live view is ready. Account for fonts, assets, randomized data, and entrance animations so the handoff does not jump or restart visibly. Exact equivalence is not automatic.
268
+ - A framework may technically live inside one project's build and iframe without being imported by the portfolio. This is NOT approval to install one. Ask for approval per project and update the repo-wide runtime-dependency policy if an exception is agreed. Keep the portfolio framework-free.
269
+ - Measure built bytes, network downloads, frame behavior, and retained memory. Isolation does not remove the cost of frameworks or multiple loaded documents.
270
+
271
+ ## Drag duplication and spatial reorder
272
+
273
+ Alt/Option can be held before a move or pressed after a screen or selection has
274
+ already started moving. The first press creates one copy of each selected screen,
275
+ restores the original positions, and continues the drag with the copies without
276
+ moving the camera or jumping the pointer. Repeated presses during the same drag
277
+ do not create more copies. The complete drag is one history edit. If the 200-added-
278
+ instance limit would be exceeded, no part of the group is copied and the original
279
+ move can continue.
280
+
281
+ A multi-selection arranged as a non-overlapping row, column, or complete aligned
282
+ grid shows pink center dots. Scattered or overlapping selections do not get dots;
283
+ reorder never silently tidies a selection. Drag a dot to insert that screen into
284
+ another spatial slot, shifting the screens in between. A blue outline previews
285
+ the drop position. Escape,
286
+ pointer cancellation, or window blur cancels a reorder and restores all positions.
287
+
288
+ Reorder changes positions, not the hierarchy in Layers, dimensions, preset, or
289
+ iframe identity. Existing horizontal and vertical gaps are preserved. With mixed
290
+ screen sizes, row heights and column widths follow the largest screen in each
291
+ band; an order that would exceed the workspace boundary is rejected. Each drag
292
+ is one undoable edit, and positions survive reload.
293
+
294
+ `dev/canvas/smart-selection.js` owns layout validation and pure slot planning.
295
+ `dev/canvas/smart-handles.js` owns the screen-space buttons and destination marker;
296
+ `canvas.js` owns gestures, model updates, and history. Neither module ships in the
297
+ production portfolio.
298
+
299
+ ## Verification
300
+
301
+ Run `node tests/canvas-history.mjs` for history/shortcut checks, `node tests/canvas-geometry.mjs` for pure geometry checks and `node tests/canvas.mjs` after a build for browser checks. This uses the already installed Chromium headless shell and the Chrome DevTools Protocol, without adding a dependency. It checks development routing, preview denial, build exclusion, selection/click-through, preset locks, custom sizing, persistence, gestures, visibility/state retention, two-axis wheel pan and Command-modified inside-screen zoom, editable zoom, fit controls, duplicate shortcuts/restoration, toolbar spacing, multi-selection, partial/additive marquee selection, group movement, snap guides/equal gaps, protected deletion/restoration, title truncation, shared pan/frame bounds at several zoom levels, stale-marquee prevention, full-area group dragging, reference gap indicators, the real portfolio source, native active-screen scrolling, mobile contact scaling/drag/tap, vertical and nested horizontal inertia, deceleration and interruption, reduced-motion behavior, CSS/JavaScript hover suppression and desktop restoration, isolated refresh/configuration restoration, and repeated iframe duplication placement, document undo/redo and its exclusions, native text undo, rename/reset/cancel/persistence, hidden unavailable actions, and browser errors. Test physical trackpad/mouse behavior and platform-specific scrolling manually as well; automation does not replace those checks.
302
+
303
+ Run `node tests/canvas-activity.mjs` for the focused mobile activity regression. It opens the real portfolio card, compares a screenshot pixel with and without the cursor to prove paint order, checks that a tap has no focus ring, and verifies that Tab restores keyboard focus and a native top-layer outline.
304
+
305
+ Run `node tests/canvas-regressions.mjs` for field-to-screen Delete focus, native field editing, shortcuts from an older iframe, mobile selection focus, wide-screen row wrapping, seven fully loaded portfolio instances with one host reload stream, and a screenshot-pixel check during deliberately delayed refresh CSS. Pass `delete`, `duplicate`, `connections`, or `refresh` to run one case.
306
+
307
+ The focused `add-pages` regression covers modal dimensions, catalog indicators, adding duplicates, empty-canvas persistence and re-adding, add undo/redo, multi-page Shift-click and pointer-drag selection using a test-only catalog, and Layers pointer-drag selection. The `layers` regression uses device-icon drag handles for reordering and retains inline rename and iframe-state checks. The older `tests/canvas.mjs` suite still assumes last-instance deletion protection and includes wheel coordinates covered by the Layers drawer; it needs a separate update before its full result is meaningful.
308
+
309
+ Run `node tests/canvas-smart-selection.mjs` for row/column/grid reorder geometry,
310
+ mixed-size gap preservation, insertion, invalid layouts, and bounds.
311
+ The focused `node tests/canvas-regressions.mjs smart` browser case verifies dot
312
+ centering and hit targets, destination preview, Escape rollback, iframe retention,
313
+ plain insertion with modifiers held, absence of arrow-key position changes, and undo/redo. `node tests/canvas-regressions.mjs late-alt` checks
314
+ late modifier presses, stable moving-copy positions, group duplication, capacity
315
+ rejection, and history.
package/package.json CHANGED
@@ -1,6 +1,33 @@
1
1
  {
2
2
  "name": "@tio-canvas/canvas",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
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
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "Tio Putra",
8
+ "repository": { "type": "git", "url": "git+https://github.com/tiobedil/tio-canvas.git" },
9
+ "homepage": "https://github.com/tiobedil/tio-canvas#readme",
10
+ "bugs": { "url": "https://github.com/tiobedil/tio-canvas/issues" },
11
+ "bin": { "tio-canvas": "./bin/tio-canvas.mjs" },
12
+ "exports": {
13
+ ".": "./src/index.js",
14
+ "./server": "./src/server.js",
15
+ "./build": "./src/build.js",
16
+ "./project": "./src/project.js",
17
+ "./thumbnails": "./src/thumbnails.js",
18
+ "./client/*": "./client/*",
19
+ "./package.json": "./package.json"
20
+ },
21
+ "files": ["bin", "client", "src", "docs", "LICENSE", "README.md"],
22
+ "publishConfig": { "access": "public" },
23
+ "engines": { "node": ">=22" },
24
+ "scripts": {
25
+ "test": "node tests/run.mjs"
26
+ },
27
+ "dependencies": {
28
+ "esbuild": "^0.28.2",
29
+ "interface-kit": "0.1.3",
30
+ "react": "^19.3.1",
31
+ "react-dom": "^19.3.1"
32
+ }
33
+ }
package/src/build.js ADDED
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import { existsSync } from "node:fs";
3
+ import { cp, mkdir, readFile, writeFile } from "node:fs/promises";
4
+ import { fileURLToPath } from "node:url";
5
+ import { resolveProject, loadProject } from "./project.js";
6
+
7
+ const CLIENT = fileURLToPath(new URL("../client", import.meta.url));
8
+
9
+ /**
10
+ * Copy the canvas into `<target>`, ready to be served at /canvas: the package's
11
+ * files, the project's screens written as `project.js`, and only the thumbnails
12
+ * of screens that exist. Returns the screens, so the project can add its own
13
+ * checks (for example, that no production screen points at a prototype).
14
+ */
15
+ export async function copyCanvas({ target, ...options }) {
16
+ const project = resolveProject(options);
17
+ const { screens, storageKey } = await loadProject(project);
18
+ const keep = new Set(screens.map((screen) => `${screen.id}.webp`));
19
+ await cp(CLIENT, target, { recursive: true });
20
+ await writeFile(path.join(target, "project.js"), `export const screens = ${JSON.stringify(screens)};\nexport const storageKey = ${JSON.stringify(storageKey)};\n`);
21
+ await mkdir(path.join(target, "thumbnails"), { recursive: true });
22
+ if (existsSync(project.thumbnailsDir)) {
23
+ await cp(project.thumbnailsDir, path.join(target, "thumbnails"), {
24
+ recursive: true,
25
+ filter: (source) => path.extname(source) !== ".webp" || keep.has(path.basename(source))
26
+ });
27
+ }
28
+ const page = path.join(target, "index.html");
29
+ const html = await readFile(page, "utf8");
30
+ if (!html.includes("<head>")) throw new Error("canvas/index.html needs a <head> for the noindex tag.");
31
+ await writeFile(page, html.replace("<head>", '<head>\n <meta name="robots" content="noindex, nofollow">'));
32
+ return screens;
33
+ }
package/src/index.js ADDED
@@ -0,0 +1,3 @@
1
+ export { createCanvasHost } from "./server.js";
2
+ export { copyCanvas } from "./build.js";
3
+ export { resolveProject, loadProject, DEFAULT_CONFIG, DEFAULT_STORAGE_KEY } from "./project.js";