feat: canvas harness#81
Merged
Merged
Conversation
prepare the feat/canvas-harness branch with @canvas-harness/core and @canvas-harness/react. no code changes yet — react-flow stays the active canvas backend until phase 7 of the migration plan.
scaffold webui/src/features/board/harness/convert/ with explicit note ↔ node and link ↔ edge converters covering all 18 node types and all 4 EdgeEnd variants (attached center / attached offset / free source / free target). round-trip preserves identity fields (version, graphUid, parentId, roughSeed), extra properties (emoji, pinned, slideName), and edge control point + arrowheads + pathStyle. degrees ↔ radians and 0-100 ↔ 0-1 opacity converted at the boundary; fillStyle and underline/strikethrough dropped per migration plan §3.3. vitest + jsdom installed for the round-trip suite (30 cases). no app code wired to the converters yet — that lands in phase 2.
note.content.markdown is the body (inline text on a shape — see note-card.tsx:94). note.label is the title (sheet header, folder name, breadcrumbs, list cards). canvas-harness node.content maps to the body, not the title. - node.content ↔ note.content.markdown - data.label ↔ note.label (preserved untouched for round-trip) - legacy fallback retained: when note.content is absent but note.label is present, treat label as the text — matches note-card.tsx's `note.content?.markdown || note.label?.markdown`
four small builders + a memoized hook that fan the current Dim0
theme out to canvas-harness's customization surfaces:
- resolver.ts — ThemeResolver for the 5 fallback tokens
(strokeColor / backgroundColor / textColor /
edge.strokeColor / edge.label.background),
derived from the theme swatch trio
(bg, primary, accent)
- selection-color.ts — selection chrome color (outline / handles /
marquee / draft edges); single accent across
all themes for v1, OVERRIDES map ready for
per-theme tuning
- minimap-colors.ts — Minimap viewportColor (matches selection) +
backgroundColor / borderColor / defaultNodeColor
from the swatch
- background.ts — CanvasBackground (color + pattern=none for now;
paper-texture pattern is §12 open question)
- use-board-theme.ts — composes the four via useTheme(); memoized so
the renderer isn't recreated each render
all six themes (parchment / catppuccin / tokyo-night / gruvbox /
monokai-pro / rose-pine) × light/dark covered. nothing wired to a
canvas yet — phase 4 picks this up.
phase 2: scaffolds the two stores and the snapshot+diff persistence
loop. no UI wired — verified via unit tests + tsc.
store/
create-board-store.ts thin createCanvasStore({ nodeTypes }) factory.
nodeTypes empty for now; phase 3 supplies them.
board-app-store.ts zustand store for app-level state that doesn't
belong in the canvas store: boardId / rootId,
boardLabel / visibility / canEdit / isLoading,
viewSlides / presentationMode, folder depth,
background (+ texture), activeNodeSurface.
setBoardScope resets per-board state on change.
persist/
diff-snapshots.ts Snapshot + ApiCall types + diffSnapshots(prev, next).
json-string compare on Node/Edge; emits
removeLink → removeNote → addNote → addLink →
updateNote → updateLink for referential safety.
flush-api-calls.ts Maps ApiCall[] onto the existing api/ helpers
(addNotes / updateNote / removeNote / addLinks /
updateLink / removeLink). Parallel within
phases, bulk POST for adds.
snapshot-load.ts hydrateBoardStore: GET /boards/:id, convert
notes/links to nodes/edges, applyBatch + clear
history. first-load is never undoable.
use-debounced-save.ts subscribe('change') → debounce 500ms → snapshot
→ diff → flush. undo/redo participate for free
(history-origin commits re-emit 'change'; the
post-undo scene is what gets snapshotted next
flush). returns SaveStatus pill state.
tests: 7 new in diff-snapshots.test.ts (no-op / add / remove / update /
ordering / edge-before-node remove / node-before-edge add). total 38.
phase 3.0 — pre-fix that was missing from the convert layer.
four built-ins use different names than Dim0:
rectangle → rect
layered-rectangle → layered-rect
layered-circle → layered-ellipse
slide → frame
note.style.type was being passed straight through, so a converted
"rectangle" node hit the lib with type "rectangle" — unknown to the
built-in paint dispatch. tests passed because we never rendered.
- harness/convert/node-type.ts bidirectional map
- note-to-node + node-to-note call the helpers instead of passing
style.type through unchanged
- round-trip.test.ts +9 cases (4 renames × 2 directions
+ 1 passthrough for custom types).
total 47, all green.
custom defs (folder / sheet / code-sandbox / widget / document)
pass through unchanged — soft-diamond moves to built-in, cutting
the planned customs from 6 → 5.
phase 3.1 — four small composable presentational components that
phase 3.2 custom node views layer together. each genuinely shared,
no stubs.
- NodeHeader title bar with iconify emoji on the left, lock
indicator on the right. inline title editing opt-in
via onTitleEdit (double-click → input → Enter
commits, Esc cancels).
- NodeBreadcrumb ancestry path, last segment non-interactive, others
optional click via onNavigate.
- NodeToolbar compact icon-button row with radix tooltips. takes
a typed ToolbarAction[] so custom nodes assemble
their own actions.
- NodeFooter status pill (idle / pending / saving / saved / error)
+ relative timestamp + free-form left content.
styled with the existing shadcn + tailwind palette so they slot into
canvas-harness's DOM overlay without bespoke styling. no tests —
purely presentational, the behavior surfaces in phase 3.2.
phase 3.2 (1/5) — first custom node. ships together with the router
infrastructure since neither works without the other.
learning: NodeTypeDef.view in v0.0.5 is a stub. real mount happens
via <Canvas renderCustomNodeView={(id) => ReactNode}>. OverlayItem
(lib-internal) wraps each view at node.x/y/w/h. children passed to
OverlayItem are referentially stable across 'change' events, so each
view must subscribe per-id via useNode(id) to see live updates.
new structure:
node-types/
folder/
def.ts — defineNode({ drawPlaceholder, lod, hitTest })
no `view` field — that's unused in v0.0.5
placeholder.ts — canvas paint: folder body + tab, theme-coloured
view.tsx — ({ id }) → ReactNode; useNode(id) for live data;
iconify glyph + label; pointer-event-transparent
so canvas dispatches clicks
index.ts
render-view.tsx — useRenderCustomNodeView() returns the
dispatcher fn for `<Canvas renderCustomNodeView>`
type → component registry
index.ts — boardNodeTypes[] for createBoardStore + router
migration plan §4.1 + §2.2 updated to match the actual lib contract.
no behavior wired yet — phase 4 mounts the canvas + folds these in.
phase 3.2 (2/5).
document is the first node where canvas node.type diverges from
Dim0 note.style.type. Dim0 documents are notes with type==="document"
but style.type is a regular NodeType (rectangle by default). canvas-
harness needs a distinct paint type, so noteToNode now overrides
node.type → "document" when note.type === "document".
to keep the round-trip lossless, NoteNodeData now carries the
original style.type as `styleType`. nodeToNote restores from it
before falling back to canvasTypeToDim0(node.type).
document custom node:
def.ts — defineNode({ type: "document", ... })
placeholder.ts — page outline + folded top-right corner
view.tsx — FilePdf / generic file icon + name + status pill
(pending / processing / completed / failed)
migration plan §4.1 contract followed: no view field in def,
useNode(id) inside the view, registered in render-view VIEW_REGISTRY
+ node-types boardNodeTypes[].
48 tests (47 + 1 new doc round-trip).
phase 3.2 (3/5). widget renders node.content as an HTML/JS iframe inline on the canvas — its body IS the inline view (unlike sheet / code-sandbox which only show a preview). title bar at top stays pointer-event-transparent so the canvas dispatches drags; the iframe opts back in via pointer-events: auto so charts and interactive widgets stay clickable. sandbox: allow-scripts allow-popups allow-modals allow-forms. no same-origin — iframe can't reach parent storage / cookies. LOD: minZoomForReact 0.6, minZoomForPlaceholder 0.2. iframes unmount below 0.6 since they're expensive; the placeholder draws a framed card with a title-bar divider + faded grid pattern in the content area so the type is still recognisable at low zoom.
phase 3.2 (4/5). inline view shows title + language badge + preview of the first ~12 lines of code (monospace, no syntax highlighting at this LOD). the full editor opens via the modal surface flow — phase 5 wires that through board-app-store.activeNodeSurface. placeholder: dark card with horizontal pseudo-code strokes of varying widths suggesting indented lines. shape reads as code at any zoom. LOD: minZoomForReact 0.5, minZoomForPlaceholder 0.2.
phase 3.2 (5/5). sheet inline view shows title + plain-text preview of the first ~8 lines of node.content. cheap stripMarkdown drops bold / italic / code / link / heading marks so the preview reads cleanly without mounting a markdown renderer (that lives in the modal surface, phase 5). full TipTap editor opens via board-app-store.activeNodeSurface. placeholder: lined-paper card with a title stripe + horizontal lines suggesting body text. light cream background that flips to dark stone for dark mode. LOD: minZoomForReact 0.4, minZoomForPlaceholder 0.15 — sheets are common, render at moderate zooms. phase 3 done. five customs total (folder / document / widget / code-sandbox / sheet) + router. all built-ins handled by lib paint. ready for phase 4 (canvas mount + chrome).
phase 4.1 — lights on. <HarnessCanvas /> mounts the canvas-harness
store + canvas + minimap behind a localStorage feature flag so the
react-flow path stays the default. enable via devtools:
localStorage.setItem("topix:feature.canvas-harness", "1")
location.reload()
what HarnessCanvas does:
- createBoardStore({ nodeTypes: boardNodeTypes }) (lazy via useRef)
- reads boardId / rootId from useBoardAppStore (board-view mirrors
scope from useGraphStore so both stores stay in sync while we're
side-by-side)
- hydrateBoardStore on scope change (now clears the scene first;
cancelled-flag guard against late-arriving fetches)
- useBoardDebouncedSave gated by a `ready` flag so hydration ops
don't trigger spurious POSTs — the flag flips after hydration
completes and the persistence effect re-baselines lastSavedRef
- feeds useBoardTheme into <Canvas theme selectionColor background>
+ <Minimap *Color>
- useRenderCustomNodeView dispatches sheet / widget / code-sandbox /
document / folder to their views
tool state, keyboard shortcuts, top-bar rewire land in phase-4
follow-up commits. for now the canvas mounts in select mode only;
pan via middle-button or space+drag, zoom via cmd+scroll.
phase 4.2.
tool state moves into board-app-store (string field, default 'select')
so the top-bar can share it with the canvas. setBoardScope resets to
'select' on scope change. HarnessCanvas pulls it via the store and
passes to <Canvas tool={tool}>.
new useBoardKeyboard(store) hook:
- Cmd/Ctrl+Z → store.undo()
- Cmd/Ctrl+Shift+Z → store.redo()
- Cmd/Ctrl+Y → store.redo()
- V → tool 'select'
- H → tool 'pan'
- F → tool 'frame'
skipped when focus is in input / textarea / contentEditable so inline
editing keeps the native shortcuts. canvas-harness already wires
Cmd+C/X/V/[/] internally — we don't override.
…tus)
phase 4.3.
three small floating components mounted alongside <Canvas>:
HarnessToolbar center-top tool tray.
select / pan | rect / ellipse / diamond |
arrow / text / frame. reads + writes `tool`
on board-app-store so keyboard shortcuts + UI
stay in sync.
HarnessHistoryControls top-left undo / redo buttons. uses
useCanUndo / useCanRedo from the lib for
disabled state.
HarnessSaveStatus top-right pill. mirrors the use-debounced-save
status (idle / pending / saving / saved / error)
with idle rendering nothing.
intentionally scoped — full top-bar (add sheet / code-sandbox / widget /
share / chat / folder-depth indicator) reuse is deferred to a follow-up
sub-phase. this is enough chrome to drive the canvas + verify save / undo
in dev. existing data hydrates through the conversion layer; built-in
shapes drag-to-create via tool buttons; customs render from existing
board data.
phase 4.4. toolbar buttons now actually do something.
useCreateHandlers(store, boardId) routes <Canvas onCreateDrag /
onClick> through the Dim0 conversion layer:
1. translate the canvas-harness tool name back to Dim0's NodeType
(canvasTypeToDim0) — needed for createDefaultNote
2. createDefaultNote so the node carries Dim0 default style +
properties (font / colors / emoji slot / etc.) — required for
round-trip persistence to land valid Notes on the server
3. override nodePosition / nodeSize from the gesture (drag rect for
onCreateDrag, world-click + default size for onClick)
4. noteToNode + store.addNode
shape tools covered: rect / ellipse / diamond / tag / capsule /
thought-cloud / layered-rect / layered-ellipse / layered-diamond /
soft-diamond / text / frame. select / pan / arrow handled by the lib
internally; we no-op for those.
tool stays active after creation so the user can quickly drop
multiple of the same type — matches playground + tldraw / figma UX.
the migration plan calls for "full swap, no incremental cohabitation"
on this branch. the localStorage flag was extra ceremony — branch is
already the unit of work.
board-view now always mounts <HarnessCanvas />:
- no flag check, no react-flow branch
- scope mirrored unconditionally into useBoardAppStore
- useGetBoard call kept as a compat shim so chat / dashboard /
other components still reading from useGraphStore keep working
until they're migrated (phase 7 deletes the legacy store path)
react-flow code (graph-editor.tsx and everything below) stays in
the repo but is unreachable from board-view. phase 7 deletes those
files in bulk along with the @xyflow/react dep.
no toggle needed any more — just open the app.
the root-layout renders a full-width page header at absolute top-0 z-50, height 64px. it visually contains only the sidebar trigger + label on the left, but its DOM element extends full-width and intercepts clicks across the entire top strip. my chrome was at z-10 — visually showing through the empty header space but unclickable. the existing top-bar uses z-50 to land in the same stacking layer as the header; with equal z-index, later DOM siblings win hit-testing, and my chrome is rendered after. four spots bumped: toolbar / history-controls / save-status (top strip) + minimap (bottom-right, less critical but consistent).
both canvas-harness and Dim0 use the 0-100 opacity scale. the lib's resolveOpacity divides by 100 internally (defaults.ts:67 — `return style.opacity / 100`). dim0StyleToCanvas was pre-dividing by 100 — so a Dim0 default opacity:100 reached the lib as 1, then the lib divided again to 0.01. shapes painted their fill at globalAlpha=0.01 (effectively invisible) while the rough.js stroke pass — which sets no globalAlpha (rough/draw.ts:192-195) — kept painting at alpha 1. symptom on screen: colored rough outlines visible, fills missing. visible fill returned when editing because the textarea overlay is a DOM element with its own CSS background, immune to canvas alpha. fix: - dim0StyleToCanvas opacity: s.opacity (no divide) - dim0LinkStyleToCanvas inherits via spread, fixed automatically - canvasStyleToDim0 opacity: s?.opacity ?? 100 (no * 100) - canvasEdgeStyleToDim0Link same round-trip test updated — node.style.opacity now equals the Dim0 value (80, not 0.8). migration plan §3.3 corrected — lib uses 0-100, not 0-1. 48 tests still pass.
was top-left, but the top-left corner clashes visually with the sidebar trigger sitting just above. bottom-left keeps it out of the header zone and pairs nicely with the bottom-right minimap.
renderer.ts:340-360 dispatches custom nodes by checking `if (def.view)`. truthy → add the id to the overlay set (which triggers onOverlayChange → mountedIds → <Canvas renderCustomNodeView> → React view mount). falsy → fall through to def.renderCanvas (also falsy here) → nothing painted. migration plan §4.1 wrongly called def.view a v0.0.5 stub. it isn't — the field is the live dispatch flag. without it, custom nodes disappear at idle and only appear during pan (which forces the preferCanvas path to drawPlaceholder). each def now imports its view and sets `view: <View>`. the lib only checks truthiness; the actual React mount goes through renderCustomNodeView + VIEW_REGISTRY, so def.view is purely a flag. affects folder / document / widget / code-sandbox / sheet.
canvas-harness applies autoFit on resize-commit (use-interaction-gesture.ts:202-218): for selected nodes with shouldAutoFit, it measures node.content laid out at node.w and forces node.h up if content needs more height. grow-only — so user-shrunk heights snap back on pointerup. for built-in shapes that's the right UX (sticky / shape grows to fit user-typed text). but our customs carry the FULL body markdown in node.content (sheet text, code, widget html source) while the React view only renders a preview — autoFit would snap the box tall enough for the whole body, breaking height resize. set style.autoFit = false at the convert boundary for folder / sheet / code-sandbox / widget / document. built-in shapes keep autoFit on. AUTOFIT_DISABLED_TYPES set + one-line override in noteToNode. symptom this fixes: width resize works, height resize bounces back to content height on release.
flex columns with overflow-auto on the body don't engage scroll
because flex children default to min-height: auto, which keeps the
item from shrinking below its content's intrinsic height. without
min-h-0 the body grows past its allotted slot and overflow-auto
never fires.
switching to absolute-positioned inner shell sidesteps any
percentage-height edge cases on top of that:
<div className="relative h-full w-full pointer-events-none">
<div className="absolute inset-0 flex flex-col overflow-hidden ...">
<div className="shrink-0 ...">{title}</div>
<div className="min-h-0 flex-1 overflow-auto scrollbar-thin
pointer-events-auto">
{body}
</div>
</div>
</div>
outer: positioning context, drag passes through (pointer-events-none).
inner: fills outer by absolute inset-0 — robust against any other
percentage-height quirks.
title: shrink-0, inherits pointer-events-none so canvas drag works
on the title bar.
body: flex-1 + min-h-0 + overflow-auto enables scroll;
pointer-events-auto so the wheel scrolls instead of zooming.
also drops the previously-applied line-clamp on the preview <p> in
sheet so the scrolling body can grow naturally.
folder / document / widget unchanged: folder and document have no
scrollable body content; widget's iframe already does its own scroll.
pairs with the autoFit-disable fix in the previous commit — that
lets height-resize actually stick, this lets content scroll inside
the resized box.
backend rejects canvas-harness's default `${clientId}-${counter}` ids
with "value u-99e8-1 is not a valid point ID, valid values are either
an unsigned integer or a UUID". server validates link.source / .target
against a UUID/int regex.
createCanvasStore takes an `idGenerator` option. wiring Dim0's existing
generateUuid (uuidv4 with dashes stripped — what every existing note in
the DB uses) makes every lib-generated id (arrow tool edges, addImage /
addSvg, copy-paste-created ids, drag-to-create shapes) acceptable to
the existing endpoints.
no collision risk — uuids are globally unique, same guarantee as the
clientId-embedded scheme. phase 6 collab is unaffected; the lib only
needs uniqueness, not clientId-embedded ids.
reproduces dim0 prod behavior on the canvas-harness path. localStorage
entries are read by board-app-store.setBoardScope on scope change; this
commit threads them through useBoardTheme into <Canvas background>.
new css-vars.ts — readCssVar(name) helper: resolves a CSS custom
property from :root at call time. used for texture colors so they
track the theme variant exactly.
background.ts rewritten with the dim0 logic:
- boardBackground is `null` → fall back to swatch[0]
(step 2 swaps this to readCssVar('--background')
for an exact match)
- boardBackground set → applyBackgroundAlpha(
darkModeDisplayHex(c, isDark) ?? c,
0.5,
) — same 50%-alpha tint behavior
- texture "dots" → pattern: "dots", patternColor: --muted-foreground
- texture "lines" → pattern: "grid", patternColor: --muted
("lines" is dim0's name; "grid" is canvas-harness's,
same line-grid pattern semantically)
- gap: 25, minZoom: 0.4 (matches dim0's react-flow Background props)
use-board-theme.ts subscribes to boardBackground +
boardBackgroundTexture from board-app-store; memo dep list grows so
changes trigger renderer.setBackground.
helpers reused from existing webui code:
- applyBackgroundAlpha (board/utils/board-background.ts)
- darkModeDisplayHex (board/lib/colors/dark-variants.ts)
- BoardBackgroundTexture type
step 1 of 2. step 2: swap the swatch[0] fallback for
readCssVar('--background') so the default page bg matches the rest of
the app pixel-for-pixel.
ports dim0's bottom-left ViewportControls panel onto the harness path
and reduces the dot / line pattern contrast against the page bg.
new chrome/viewport-controls.tsx (bottom-left, replaces the prior
HarnessHistoryControls):
- zoom % button (click resets camera.z to 1)
- undo / redo (via store.undo / store.redo, gated by useCanUndo /
useCanRedo, disabled visual state)
- background popover trigger — shows current tinted preview
- popover content mirrors dim0:
• color grid: white + TAILWIND_50 palette (dark-mode-shifted
for visibility against dark themes via darkModeDisplayHex)
• texture row: None / Lines / Dots with icons (GridFour,
DotsNine), highlights current selection
• Reset buttons per section
texture color was reading --muted-foreground / --muted at full
opacity. canvas-harness paints dots / lines more boldly than
react-flow's SVG pattern, so they came out too contrasty. now via
new readCssVarMixed(name, percent) helper in css-vars.ts — uses a
hidden DOM probe so the browser does the color-mix natively (works
for oklch / rgb / hex source vars). dots @ 25%, lines @ 35%.
history-controls.tsx removed — undo / redo absorbed into the
viewport-controls panel to match dim0's single-row layout.
step 2 — default page bg now matches the rest of the app exactly,
not the swatch approximation. user-picked tints pre-blend against
--background via color-mix so they no longer mix with the renderer's
hardcoded wrap-div bg.
background.ts changes:
defaultBg = readCssVar("--background") || swatchBg
tinted = boardBackground
? blendCssColors(
darkAwareUserColor,
"var(--background)",
50, // 50% user + 50% --background
)
: null
// canvas-harness paints a SOLID color; using rgba(..., 0.5) would
// bleed the wrap-div's hardcoded `#f8fafc`. pre-blending in JS gives
// the same visual result as dim0's CSS alpha overlay on bg-background.
new helper in css-vars.ts:
blendCssColors(a, b, aPercent) — generic color-mix wrapper. uses a
hidden DOM probe so the browser resolves any CSS color format
(oklch, rgb, hex, var(--*)). returns a solid resolved value.
minimap colors unchanged here (still swatch-derived). swap to CSS
vars is a follow-up if the minimap bg ends up looking out of sync.
restore the last viewport on board mount; save on every gesture-end.
no per-frame work, no visibilitychange listener, no cleanup save — a
single 'interaction' subscription captures everything that matters,
because the camera doesn't move between gestures.
viewport-storage.ts
same localStorage key + shape as dim0's react-flow path
(topix:graph-viewports, Record<scopeKey, { x, y, zoom }>) so saved
viewports survive the migration. canvas-harness CameraState.z
translates to/from `zoom` at the boundary.
scopeKey = `${boardId}:${rootId ?? ""}` (matches dim0 exactly).
use-viewport-persistence.ts
restore: one-shot after the `ready` flag flips (post-hydration).
store.setCamera(saved) — synchronous, no tween.
save: subscribe('interaction'). on every panning|zooming → idle
transition, write store.getCamera() to the scope key.
tracks previous mode in a useRef to detect the edge.
wired into HarnessCanvas alongside useBoardDebouncedSave +
useBoardKeyboard. zero impact on pan/zoom hot path — the
'interaction' callback fires on mode changes only (≈ 2 events per
gesture, not 60 per second).
ports dim0's rail-style StylePanel onto the harness path. mounted
mid-left as a floating sidebar that shows when one or more
non-frame nodes are selected, hides otherwise.
chrome/style-panel/
key-swatch.tsx small color swatch button. direct port —
dark-mode-aware via darkModeDisplayHex,
checker pattern for the transparent entry.
color-panel.tsx tailwind-palette ColorGrid. direct port —
left-click picks family, right-click opens
shade grid (compact variant).
panel.tsx rail-style StylePanel adapted to canvas-harness
Style. 10 settings: border / background / text
color (ColorGrid), stroke width / style, sloppiness,
roundness, text align, font family / size. each
row is a popover trigger opening on the right.
skips fillStyle (dropped in convert layer) and
textStyle (canvas-harness lacks underline /
strikethrough — partial mapping not worth the
conversion cost here).
sidebar.tsx reads useSelection() + useNodes(), derives the
first selected non-frame node's style as the
representative shown in the rail, dispatches
style patches to ALL selected via store.batch
for atomic undo.
index.ts
edges are deferred (pathStyle / arrowheads → v2 follow-up).
style memory (last-used → applied to new shapes via useStyleDefaults)
also deferred — small but needs threading through the create
handlers in use-create-handlers.ts.
positioned left-3 top-1/2 (mid-left) so it doesn't collide with the
viewport-controls panel at bottom-left or the toolbar at top-center.
popovers open to the RIGHT (inward) so they don't clip off-screen.
phase 4.5 — custom node creation from the UI.
toolbar.tsx grows a 4th group at the end with the four custom types.
icons (phosphor): Folder, Notepad, CodeBlock, Browser.
use-create-handlers.ts adds the four type strings to SHAPE_TOOLS so
they route through the same drag-to-create / click-to-create path as
built-in shapes:
isShapeTool(tool) → canvasTypeToDim0(tool) → createDefaultNote
→ noteToNode → store.addNode
createDefaultNote handles each type's default size (sheet 320×200,
code-sandbox 320×320, widget 800×500, folder 150×150) and default
style. tool stays active after creation so the user can drop multiple
in a row, same as shapes (V to switch back to select).
document tool deferred — that's a file-upload flow, not a click-to-
create. lands in phase 5 alongside drag-drop image upload.
Three coupled fixes so sheets behave like prod's nested-route surfaces: 1. Surface ↔ URL sync — openNodeSurface / closeNodeSurface now push /boards/$id/sheets/$noteId via a setNodeSurfaceNavigator callback registered by the new useHarnessSurfaceFromUrl hook. Browser back closes surfaces; refreshing on a sheet URL re-opens it; shareable links work. 2. Local-or-remote sheet read — SheetPanel falls back to useGetNote when the node isn't on the current canvas (subpages reached via the editor's /subpage slash command). Writes branch on isLocalNote: store.updateNode for canvas-resident sheets, updateNote REST mutation for off-canvas ones. boardId resolves from note.graphUid so per-note API calls hit the right graph even for cross-subgraph subpages. 3. Sheet stack background — wires the existing SheetStackBackground ghost-card layers behind nested sheets so depth reads visually, matching prod.
Three coupled additions plus the long-missing image-render fix: 1. Drag-drop image upload — onDragOver/onDrop on the canvas wrap downscales, uploads, and adds image nodes at the world-space drop point (centered, slight stagger when multi-dropping). Images created inside a sub-folder carry parent_id via the same rootId pipeline used for shape creation. 2. Image-search dialog — port of prod's ImageSearchDialog wired into a new "More" overflow dropdown at the right of the toolbar. Both search results and the in-dialog file picker route through the shared useHarnessAddImage hook, dropping at the current viewport center via a new wrap-ref context. 3. ?center=<nodeId> URL param — snaps camera to center the target node after hydration, then strips the param so a later refresh doesn't re-snap. 4. Image render fix in noteToNode — canvas-harness's paintImageNode reads node.data.src / naturalW / naturalH, but the convert layer was only stashing the URL inside data.properties.imageUrl. Image nodes (new + pre-existing) rendered as empty boxes. Lifting the url + dims onto the data primitives so they actually paint.
Adds an optional `is_local_offset: bool = False` to PositionProperty. When set on a link's start_point / end_point and the link's source/target resolves to an attached node, the position is to be interpreted as a node-local offset (relative to the node's top-left, pre-rotation) instead of an absolute world coordinate. Default False keeps the legacy world-coord interpretation for existing rows, so no data migration is needed — edges upgrade in place on their next save. Unblocks the harness frontend from having to cascade updateLink calls every time an attached node moves.
Edges now save `start_point` / `end_point` as local offsets relative to the attached node's top-left when the source/target resolves to a node, with `is_local_offset: true` on the wire. This matches canvas-harness's in-memory model and removes the need to resave an edge every time its attached node moves — the offset is invariant under node geometry changes, only the rendering reads node.x/y. Legacy edges in the old world-coord format are detected on load (no `is_local_offset` flag) and stashed with a per-end marker on edge.data. The persist diff cascades an `updateLink` for those legacy edges only when their attached node's geometry changes, which upgrades them in place. Newly-saved edges + freshly-upgraded edges don't need the cascade. Drops the `isCenter` shortcut that omitted `position` for center-attached endpoints; always emitting `position` kills the PATCH-strip risk when an endpoint went from off-center back to center. Pairs with backend commit bc3bedd which added the `is_local_offset` field to PositionProperty.
Canvas-harness stores `edge.control` as the cubic Bezier control points `[c1, c2]`; the wire format `edgeControlPoint.position` is the single midpoint the curve passes through at t=0.5 (matches prod's convention). We were saving `c1` into that field directly, so dragging the edge midpoint never round-tripped through refresh. Save now resolves source/target to world coords and inverts the lib's symmetric split (M = (S + T + 6·c) / 8). Load uses the lib's own `midpointToCubicControls` to derive `[c1, c2]` from the saved midpoint. Setback we accept (matches excalidraw): moving an attached node leaves the midpoint at its stored world coord until the user re-drags it — adding "midpoint follows nodes" is its own scope.
New shapes and edges inherit the user's last-used style — change a rect's background to red, the next ellipse / diamond / arrow also comes out red. Persisted to localStorage so preferences survive reloads. Driven by canvas-harness store events: every `node.update` or `edge.update` op carrying a `style` patch folds the resolved style into a shared bucket (excalidraw / tldraw / figma model). Custom node types (folder, sheet, code-sandbox, widget, document) are excluded from both input and output so they keep their built-in visual identity. Frames also opt out of the merge to stay generic. Arrow tool picks up edge memory via the lib's `arrowDefaults` prop; create handlers merge node memory after `noteToNode` so the convert layer stays untouched.
Phase 6.0 — origin-aware persist. The debounced save loop now
branches on `batch.origin`: remote batches (agent writes via
`store.applyBatch({ origin: 'remote' })`) rebaseline lastSaved and
skip the timer so we don't re-upload data the server already has.
Local and history batches continue to flow through the normal diff.
Phase 6.1 — harness/agent module. `apply-tool-output.ts` exposes
`applyNoteOutput` and `applyLinkOutput`: fetch canonical Note/Link
from the server, convert via the harness convert layer, apply as a
single remote-origin op batch. Notes outside the current rootId
skip canvas materialization but still update the per-note query
cache so an open subpage panel sees the edit. `agent-bridge.ts`
provides `setAgentBridge` / `getAgentBridge` (mirrors the existing
setBoardNavigate / setNodeSurfaceNavigator pattern) so the agent's
`sendMessage` mutation can reach the harness store without
importing the canvas component tree.
Phase 6.2 — send-message rewire. After the legacy graph-store
apply block, `useSendMessage` now also mirrors note/link tool
outputs into the harness store via the bridge. Switches the
post-stream camera nav from `?center_around=` to our existing
`?center=` URL param, extended in `use-center-from-url.ts` to
accept comma-separated ids (fits the union bounding rect with
10% padding, zoom capped at 2).
`board-view.tsx` mounts `<FloatingAssistant>` and `<CopilotSheet>`
on the harness board surface, threading `current_chat_id` from the
URL search params and `chatSheetOpen` from the legacy store as a
compat shim. Both stays in sync until phase 7 deletes the legacy
store.
Switches four legacy useGraphStore reads onto the harness useBoardAppStore so the agent UI is driven by the store our own actions write to, not the URL-synced compat shim: - floating-island.tsx + chat/input.tsx — `hasActiveSurface` (drives the @page / @board chip) - use-submit-prompt.ts — `rootId` baked into the send-message payload so agent writes land in the right folder scope - save-as-note.tsx — `rootId` baked into the post-save navigate Trims one URL→legacy-store hop and shrinks the surface that phase 7 will need to remove when the legacy graph-store goes away.
The agent's message-context builder now sees what the user has selected on the canvas-harness canvas. Before this, selection lived only in the harness store (legacy graph-store wasn't synced) so the floating-island composer's "selected" chip never lit up and prompts like "summarize what I selected" had no context to attach. New `harness/canvas-store-ref.ts` exposes a module-level `set/getCanvasStoreRef` bridge (same shape as setBoardNavigate / setNodeSurfaceNavigator / setAgentBridge). HarnessCanvas registers the active store in a useEffect. `useHasMessageContext` subscribes to the lib's 'selection' event channel via the bridge through useSyncExternalStore — works without a CanvasProvider ancestor, so the floating-island composer (which lives as a sibling of HarnessCanvas) can read selection without restructuring the tree. `buildMessageContext` resolves notes via store.getNode → nodeToNote with a React-Query-cache fallback for off-canvas subpages. Z-index shortcuts (Cmd+] / Cmd+[ / Cmd+Shift+] / Cmd+Shift+[) ship for free — canvas-harness's Canvas component already binds them internally with INPUT/TEXTAREA focus guards. No new code needed.
Right-click on a selection now opens a context menu with four sections, matching prod's behavior on the canvas-harness branch: - Position: send backward / forward / to back / to front via the lib's reorder methods (Cmd+[ / Cmd+] continue to work alongside). - Export: copy selected as PNG (clipboard-first, file-download fallback) with a transparent-background toggle, plus an SVG download bonus — both via the lib's exportSelection / exportSelectionSvg helpers. - AI Spark: summarize / mapify / schemify / quizify / drawify / explain — six actions dispatched through the existing useAiSparkActions hook. - Translate: expandable submenu with a custom-language input + the eight common languages from prod. Backing the AI Spark + Translate flows: a new useHarnessApplyMindMap hook in harness/agent/. It subscribes to useMindMapStore — the staging buffer the existing API hooks ( convertToMindMap / drawify / translateText) write into — and drains new mindmaps into the canvas-harness store as a local-origin op batch. The normal debounced-save loop then POSTs them. Replaces the unmounted react-flow drainer (useAddMindMapToBoard) that silently no-op'd on the harness. Skips point-node materialization; edges attach natively via EdgeEnd.localOffset at node center.
The right-click menu was bottom-heavy with the 6 AI Spark actions plus Translate expanded inline. Fold both under a single "AI ▶" click-to-expand entry — Position and Export stay inline since they're more frequently used and self-contained. The collapsed state shows just three top-level groups; expanding AI reveals the six actions plus the existing Translate submenu nested with a left-border indent for hierarchy.
Port prod's IconSearchDialog and refactor the harness toolbar to match prod's styling, icon set, full shapes menu, and keyboard map.
Adapt node + edge colors for dark mode at render time without persisting the adapted values. Stored colors round-trip on data._storedColors; node.style holds the displayed projection. Theme toggles re-project via a single remote-origin batch — no save, one redraw.
Panel now supports edges (stroke + text color, stroke width / style, sloppiness, path style, source + target arrowheads, font family + size). Sidebar splits node vs edge selection (nodes win on mixed) and routes edge-only fields correctly (pathStyle on Edge, not Edge.style). New edges drawn by the arrow tool stamp _storedColors + project for the current theme mode so dark-mode paints stay coherent.
Fire once per board scope on hydrate, scheduled via requestIdleCallback (RAF fallback for Safari). Paints to an offscreen canvas with the lib's renderMinimapContent — same code path the live minimap uses — and uploads through the existing saveThumbnail API. Skips empty boards; failures are best-effort.
Replace the toolbar Frame button with a Slides button that opens a right-side panel listing the board's frames as draggable slides. Reorder calls store.setFrameOrder (one undoable, collab-syncing op); Add Slide creates a 960x540 frame at the viewport center; Present enters presentation mode anchored on the first frame. Presentation mode hides frame chrome via Renderer.setHideFrames before the camera moves (no flash) and restores both on exit. Arrow keys step slides, Esc exits, M toggles the panel.
Sheet preview now renders rich markdown via MarkdownView (Streamdown + GFM + KaTeX + Shiki) capped at 800 chars, gated by useIsInView so off-screen sheets at high zoom don't re-parse. Folder drops the iconify glyph for an inline SVG silhouette matching the canvas placeholder — same look low- and high-zoom. New NodeDragHandle gives sheet / code-sandbox / widget a grab target that bypasses the body's click-to-open and forwards pointer events to canvas-harness for select / drag.
WidgetIframe now serves its content through a Blob URL src instead of srcDoc — Chromium's srcDoc navigation can silently fail inside a CSS-transformed ancestor (canvas-harness applies a camera transform to the overlay). Dropped the unmount cleanup that set src to about:blank: under React's mount/unmount churn on canvas motion, the cleanup could land on the live DOM node and never get re-navigated back, leaving panned widgets stuck at about:blank. Custom-node title captions (sheet / folder / widget / code-sandbox / document) now use font-handwriting (Architects Daughter) for a hand-drawn feel that fits the board surface.
Pulls the upstream z-index fix for sendToBack into the renderer. Splits two files that were mixing component + non-component exports so eslint's react-refresh/only-export-components stops flagging them: wrap-ref-context.tsx → context+hook (.ts) and provider (.tsx); edge-glyphs.tsx → glyph components, with constants + types moved to edge-glyph-options.ts.
…zoom FolderSilhouette SVG was using hsl(var(--card)) which is invalid — --card resolves to oklch(), not raw HSL components. Fell back to SVG's default black fill in idle. Switched to var(--card) so the silhouette renders card-colored in both idle and motion states. Lowered minZoomForPlaceholder to 0.05 on all custom node defs (folder / sheet / widget / code-sandbox / document) so they stay visible at deep zoom-out — matches built-in primitive behavior. Lib's prior floor was a perf knob for >1000-node boards; we don't run at that scale.
4565a59 to
32e3da2
Compare
Re-enables the Document entry in the More dropdown. New DocumentUploadDialog posts a PDF through the existing parseDocument API, then converts the returned Notes + Links via noteToNode / linkToEdge and applies them as a single remote-origin batch (server already saved them; save loop skips it). Toast surfaces parse progress + result; dialog closes immediately so a long parse doesn't block the canvas. Sheet body switches from <button> to role="button" div. MarkdownView's code blocks render their own Streamdown download/copy buttons, and nested <button> was triggering a hydration error. Keyboard handlers preserve Enter/Space activation. Move WidgetIframe Blob URL allocation inside useEffect so the create + revoke live in the same cleanup cycle. With useMemo + a separate effect, StrictMode's fake unmount/remount revoked the URL while the iframe was still fetching it, surfacing "Not allowed to load local resource: blob:..." in Chromium.
Adds the view-mode dropdown to the toolbar (Board / Files / List) and ships both alternate surfaces: - ListView: OS-finder-style row index of document-like nodes (sheet, widget, code-sandbox, document, folder). Folder click navigates to the subboard; everything else opens the surface modal. Sorted by listOrder. - LinearView (Files): responsive grid that reuses the canvas custom React views inside each cell. Drag-handle reorder via store.batch() of listOrder updates. One source of truth for each type's look. - EmbeddedNodeViewProvider context suppresses the inner NodeDragHandle when reusing canvas views inside cards (avoids double grips). - Canvas-only toolbar tools hide in non-board modes. - Sheet off-screen body renders a dimmed "Sheet paused" placeholder instead of nothing (matches widget's pattern). Tested with the existing harness suite (55/55); type-check + lint clean.
…odel GraphStore.patch_note was always validating the merged payload against the bare Note model, which has type: Literal["note"]. Document rows (Note subclass with type: Literal["document"]) failed validation on any update — surfaces as soon as you reorder a document in Files view, or anything else that PATCHes a document node. Switch to Document.model_validate when the existing row is a Document, keep Note.model_validate otherwise. Document-specific properties (mimeType, status, summary) survive round-trip. Client side: drop the temporary type="note" workaround in the persist flush, just strip immutable fields (id, graphUid, createdAt) and let the real type ride along.
Read-only counterpart to TipTap's PageRefView. MarkdownView (Streamdown) now turns `[Title](page://<id>)` links into a small @-icon chip via a short-circuit in MarkdownLink. Subpage directive blocks (`:::page <id>\nTitle\n:::`) collapse to inline page-ref links via a pre-remark string preprocessor, so subpages render as chips too instead of raw `:::page` text. Snapshot title is rendered as-is — no live PageProvider lookup. Suitable for read-only contexts (sheet card previews, agent messages, newsfeed view).
Harness is the sole canvas renderer. This drops the entire react-flow-based code path it ran in parallel with: graph-store migration - Move chatSheetOpen onto board-app-store; everything else shell-side (board-screen, board-view, root-layout, create-board) reads scope from board-app-store directly. - sidebar-label reads boardCanEdit / boardLabel from board-app-store and the live sheet title via a small canvas-store-ref adapter (use-harness-node-external). - Strip the duplicate legacy block in send-message that mirrored each AI tool output into graph-store (harness bridge already does it). - Drop useGetBoard / useParseDocument React hooks (bare API fns remain — harness consumes them directly). Deletions - store/graph-store.ts and every component / hook / util tied to it: graph-editor + graph-canvas + node-view + note-card + the linear / list views, slide-panel + presentation-controls, sheet / widget / code-sandbox panels, action-panel + top-bar, edge + shapes sub-trees, folder-breadcrumb (restored a board-app-store version), motion-state-context, the legacy style panel, every retired hook (use-add-node, use-thumbnail-capture, use-make-thumbnail, use-board-shortcuts, use-place-line, use-copy-paste, use-center-around, use-edge-label-edit, use-node-viewport-cull, use-content-min-height, use-add-image-from-file, use-drop-image-upload, use-fit-nodes, use-decorated-edges, use-export-selection-png) and orphaned utils (point-attach, line-placement, flow-view, edge-node-geometry, edge-orientation, edge-label-estimate, shape-content-scale, thumbnail, markdown-height-estimate, note-box) plus the rough/* SVG variants and the components/notes/* shapes that only the legacy view rendered. Net: 107 files touched, ~18.7k LOC deleted. type-check + lint clean; harness round-trip suite (55 tests) green.
Brings in upstream canvas markdown math rendering + minor renderer perf tweaks. No webui-side changes needed.
Adds a short "Canvas engine" section linking to the canvas-harness library and noting the board scales to thousands of nodes, comparable to tldraw / Excalidraw / hosted whiteboards.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
No description provided.