From 4d93ddfc6ade94e60b92d62d386511a6daf62d55 Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 14:45:47 +0100 Subject: [PATCH 1/8] feat: branches/worktrees tabs, canvas selection + solo, zoom-bug fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ships the cross-cutting batch the user pulled together this session: Branches + Worktrees side-panel tabs - New core/branches + core/worktrees plugins; live in the left SidePanel alongside Layers - Selection store (apps/web/src/state/selectionStore.ts) owns selectedBranchId / soloBranchId / activeWorktreeId, persisted to localStorage and observable via useSyncExternalStore - Branch row click pans canvas to fit that branch's frame bounds via new registerFitToHook() — Cmd-click toggles Solo - Branches grouped Active / Stale / Merged with filter chips (active default; merged collapsed) - First-load dismissable tips explain the model Solo / isolate mode - FrameLayer accepts soloBranchId prop; skips non-matching frames - SoloBanner pinned to canvas top with "Exit Solo" so the mode can never get stuck even when both panels are collapsed - Selection store keeps solo + selection state interlocked Worktree dispatch target - Active worktree shown as "→ ~/path" chip in TopBar (right of MCP) - CreateDispatchRequest.worktreeHint? added to @foldo/protocol - useDispatchFlow + DomEditor save-to-source inject the hint at dispatch time; server attaches to in-flight Dispatch object; MCP bridge logs "worktree hint: " as a progress event SidePanel pill-collapse - Manual ‹/› collapse on either panel; localStorage-persisted - Pill: 36px vertical icon rail with one button per tab; click expands + activates Canvas zoom toolbar-loss bug (cross-browser) - Wheel listener now binds once with a zoomRef instead of re-binding on every zoom event (eliminated race window that dropped preventDefault under fast trackpad pinches) - Safari proprietary gesture events (gesturestart/change/end) drive zoom directly from e.scale, anchored at the snap point — previously Safari suppressed wheel+ctrlKey during a pinch and our preventDefault left zoom dead - All chrome elements (TopBar, panels, plugin toolbar, zoom control, first-run hint) bumped to z-index 95–110 so canvas content can never paint over them Marketing route fix - CookieBanner.tsx -> ConsentNotice.tsx, CookiePolicy.tsx -> StoragePolicy.tsx (file paths matched ad-blocker heuristics causing ERR_BLOCKED_BY_CONTENT_BLOCKER on lazy import) - /cookies and /cookie-policy URLs preserved Polish - Comment pin shadow now uses filter:drop-shadow so the teardrop outline traces correctly instead of haloing on light backgrounds - Markdown frame "TINTS ON" button no longer wraps to two lines (added whitespace-nowrap + flex-shrink-0) - Inspect (DomEditor) tightened: 28→24px controls, 12→8px stack gap, smaller ghost/save buttons; pick/save/ghost button styling - Layers panel typography tightened: search 16→12px, toolbar buttons 40→24px, tree rows 36→24px - SidePanel + TopBar align on the same 12px left edge inset - Demo User chip matches Capture/Tests/Share chip height Plugin substrate - PluginRegistry.install is now idempotent by manifest.id, and bootPlugins() calls registry.reset() each time so dev-mode HMR no longer duplicates plugin contributions (root cause of the "3 toolbars" bug) Cleanup - Deleted apps/web/src/components/LeftRail.tsx (no longer rendered; its testids now live on the bottom PluginToolBar) - Updated core-plugins-still-work.spec to stop asserting the legacy LeftRail testid alongside the new toolbar testid Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/mcp/src/index.ts | 7 + apps/server/src/routes/dispatches.ts | 10 +- apps/web/src/App.tsx | 31 +- apps/web/src/components/BootOverlays.tsx | 2 +- apps/web/src/components/Canvas.tsx | 76 +- apps/web/src/components/CommentPin.tsx | 13 +- apps/web/src/components/FrameLayer.tsx | 10 + apps/web/src/components/LeftRail.tsx | 112 --- apps/web/src/components/MarkdownFrame.tsx | 4 +- .../components/PhoneNotSupportedBanner.tsx | 2 +- apps/web/src/components/SoloBanner.tsx | 73 ++ apps/web/src/components/TopBar.tsx | 53 +- apps/web/src/components/ZoomControl.tsx | 2 +- apps/web/src/hooks/useDispatchFlow.ts | 8 + apps/web/src/index.css | 10 +- apps/web/src/main.tsx | 13 +- .../{CookieBanner.tsx => ConsentNotice.tsx} | 2 +- apps/web/src/marketing/MarketingRouter.tsx | 8 +- .../{CookiePolicy.tsx => StoragePolicy.tsx} | 2 +- .../plugins/core-branches/BranchesPanel.tsx | 784 ++++++++++++++++++ apps/web/src/plugins/core-branches/index.tsx | 35 + .../src/plugins/core-dom-editor/DomEditor.tsx | 23 +- .../core-dom-editor/PropertyGroups.tsx | 14 +- .../plugins/core-layers/LayerNavigator.tsx | 26 +- .../web/src/plugins/core-layers/LayerNode.tsx | 9 +- .../src/plugins/core-layers/LayerSearch.tsx | 20 +- apps/web/src/plugins/core-tools/index.tsx | 29 +- .../plugins/core-worktrees/WorktreesPanel.tsx | 391 +++++++++ apps/web/src/plugins/core-worktrees/index.tsx | 32 + apps/web/src/plugins/index.ts | 7 + apps/web/src/plugins/registry.ts | 29 +- apps/web/src/plugins/slots/SidePanel.tsx | 324 +++++--- apps/web/src/plugins/slots/ToolBar.tsx | 78 +- apps/web/src/state/selectionStore.ts | 122 +++ e2e/plugin/core-plugins-still-work.spec.ts | 33 +- packages/plugin/src/index.ts | 18 +- packages/protocol/src/domain.ts | 10 + packages/protocol/src/rest.ts | 12 + 38 files changed, 2021 insertions(+), 413 deletions(-) delete mode 100644 apps/web/src/components/LeftRail.tsx create mode 100644 apps/web/src/components/SoloBanner.tsx rename apps/web/src/marketing/{CookieBanner.tsx => ConsentNotice.tsx} (98%) rename apps/web/src/marketing/{CookiePolicy.tsx => StoragePolicy.tsx} (98%) create mode 100644 apps/web/src/plugins/core-branches/BranchesPanel.tsx create mode 100644 apps/web/src/plugins/core-branches/index.tsx create mode 100644 apps/web/src/plugins/core-worktrees/WorktreesPanel.tsx create mode 100644 apps/web/src/plugins/core-worktrees/index.tsx create mode 100644 apps/web/src/state/selectionStore.ts diff --git a/apps/mcp/src/index.ts b/apps/mcp/src/index.ts index a430638..5eed863 100644 --- a/apps/mcp/src/index.ts +++ b/apps/mcp/src/index.ts @@ -92,6 +92,13 @@ async function main(): Promise { }, }); }; + // Surface the canvas-side worktree selection (if any) into the + // dispatch log so a user inspecting a run can see where it ran. + // The runApplyEdit pipeline doesn't yet honour the hint as cwd — + // that's a follow-up; logging it is the first step. + if (d.worktreeHint && d.worktreeHint.trim()) { + emitProgress(`worktree hint: ${d.worktreeHint.trim()}`); + } try { const result = await runApplyEdit( { diff --git a/apps/server/src/routes/dispatches.ts b/apps/server/src/routes/dispatches.ts index 5da63da..0d18f7e 100644 --- a/apps/server/src/routes/dispatches.ts +++ b/apps/server/src/routes/dispatches.ts @@ -33,7 +33,7 @@ export async function registerDispatchRoutes(app: FastifyInstance): Promise(null); + // Solo branch id (from the client-side selection store). FrameLayer + // skips non-matching branches when this is non-null; SoloBanner shows + // the exit affordance. + const soloBranchId = useSelectionSlice((s) => s.soloBranchId); // activeDispatchId lives in useDispatchFlow now. const [viewport, setViewport] = useState({ x: 0, @@ -303,6 +309,16 @@ export default function App() { }); }, [snap.board, navigate, fitToFrame]); + // Fit-to-rect hook for the Branches plugin. Same pattern as + // registerSelectFrameHook above — the plugin computes the world rect + // covering the selected branch's frames and asks the canvas to fit it. + useEffect(() => { + registerFitToHook((rect) => { + canvasRef.current?.fitTo(rect); + }); + return () => registerFitToHook(null); + }, []); + /* A+W1 features — layer-nav action hooks. The Layer Navigator's toolbar (touch agent's surface) calls window.__foldoDeleteFrame / __foldoRenameFrame / __foldoReorderFrame; we own the implementations @@ -688,6 +704,7 @@ export default function App() { zoom={viewport.zoom} commentsByFrame={commentsByFrame} inViewportSet={inViewportSet} + soloBranchId={soloBranchId} onSelectElement={onSelectElement} onDropPin={handleDropPin} onCommentClick={handleCommentClick} @@ -708,19 +725,11 @@ export default function App() { wsStatus={snap.wsStatus} offline={boot.kind === 'offline'} /> - {/* A+W1 features — `onChange` was a dead prop; the plugin tools - route through window.__foldoSetTool. */} - - {/* - Plugin substrate slots (Step 9). LeftPanel / RightPanel / PluginToolBar - render nothing if no plugin contributes to them, so today they're - invisible — Step 10's Layer Navigator + Step 11's DOM Editor light them - up. Mounted alongside the existing LeftRail / EditPanel / TestsPanel - rather than replacing them so the substrate ships with zero UX change. - */} + {/* Plugin substrate slots. PluginToolBar is the canonical tool dock. */} + +
Try this diff --git a/apps/web/src/components/Canvas.tsx b/apps/web/src/components/Canvas.tsx index 6126bc8..74ea4e4 100644 --- a/apps/web/src/components/Canvas.tsx +++ b/apps/web/src/components/Canvas.tsx @@ -184,11 +184,22 @@ export const Canvas = forwardRef(function Canvas( [setZoomAtAnchor, viewport, zoomToFit, fitTo, panTo, screenToWorld], ); + // Latest viewport.zoom in a ref so the wheel listener can read it without + // re-binding on every zoom event. The previous implementation listed + // viewport.zoom in the effect deps, which meant the listener was + // removed + re-added on every wheel tick. Under a fast trackpad pinch + // that produces 60+ events/sec, the brief teardown window could drop + // preventDefault on an event, letting the browser native page-zoom + // through and pushing the toolbars out of the layout viewport. + const zoomRef = useRef(viewport.zoom); + zoomRef.current = viewport.zoom; + // Wheel handler: ctrl/meta = zoom (anchored at the cursor), plain = pan. // Bound to `window`, not just the canvas element — otherwise ctrl/meta+wheel - // while the cursor is over a toolbar overlay (the left rail, top bar, zoom - // control) is never preventDefault'd and falls through to the browser's - // native page-zoom, which leaves the whole app looking zoomed/shifted. + // while the cursor is over a toolbar overlay (the side panel, top bar, + // zoom control) is never preventDefault'd and falls through to the + // browser's native page-zoom, which leaves the whole app looking + // zoomed/shifted. useEffect(() => { const el = containerRef.current; if (!el) return; @@ -198,7 +209,10 @@ export const Canvas = forwardRef(function Canvas( // wherever the cursor happens to be. e.preventDefault(); const factor = Math.exp(-e.deltaY * 0.01); - setZoomAtAnchor(viewport.zoom * factor, { x: e.clientX, y: e.clientY }); + setZoomAtAnchor(zoomRef.current * factor, { + x: e.clientX, + y: e.clientY, + }); return; } // Plain wheel = pan, but only when the cursor is actually over the @@ -226,8 +240,58 @@ export const Canvas = forwardRef(function Canvas( } }; window.addEventListener('wheel', onWheel, { passive: false }); - return () => window.removeEventListener('wheel', onWheel); - }, [viewport.zoom, setZoomAtAnchor]); + + // Safari proprietary gesture API. During a trackpad pinch, Safari + // fires gesturestart/change/end and SUPPRESSES the wheel-with-ctrlKey + // events that Chrome uses — so we can't just preventDefault these and + // rely on the wheel branch. Instead, drive zoom directly from the + // gesture's `scale` property: + // + // gesturestart — snapshot the current zoom + anchor. + // gesturechange — apply `startZoom * e.scale` anchored at the snap. + // gestureend — clear the snapshot. + // + // preventDefault is called on every gesture event to block Safari's + // visual-viewport zoom (which would push the position:fixed chrome + // out of the visible viewport — the original "lost toolbar" bug). + // + // GestureEvent isn't in TypeScript's lib (Safari-only proprietary + // API), so we type the event minimally inline. + type GestureEventLike = Event & { + scale: number; + clientX: number; + clientY: number; + }; + let gestureStartZoom = 0; + let gestureStartAnchor = { x: 0, y: 0 }; + const onGestureStart = (e: Event): void => { + e.preventDefault(); + const ge = e as GestureEventLike; + gestureStartZoom = zoomRef.current; + gestureStartAnchor = { x: ge.clientX, y: ge.clientY }; + }; + const onGestureChange = (e: Event): void => { + e.preventDefault(); + const ge = e as GestureEventLike; + if (gestureStartZoom === 0 || !Number.isFinite(ge.scale)) return; + setZoomAtAnchor(gestureStartZoom * ge.scale, gestureStartAnchor); + }; + const onGestureEnd = (e: Event): void => { + e.preventDefault(); + gestureStartZoom = 0; + }; + window.addEventListener('gesturestart', onGestureStart, { passive: false }); + window.addEventListener('gesturechange', onGestureChange, { passive: false }); + window.addEventListener('gestureend', onGestureEnd, { passive: false }); + + return () => { + window.removeEventListener('wheel', onWheel); + window.removeEventListener('gesturestart', onGestureStart); + window.removeEventListener('gesturechange', onGestureChange); + window.removeEventListener('gestureend', onGestureEnd); + }; + // setZoomAtAnchor is stable across renders (useCallback in this file). + }, [setZoomAtAnchor]); // Pan dragging const handMode = tool === 'hand' || spaceDown; diff --git a/apps/web/src/components/CommentPin.tsx b/apps/web/src/components/CommentPin.tsx index 25c8ca8..ed33cfc 100644 --- a/apps/web/src/components/CommentPin.tsx +++ b/apps/web/src/components/CommentPin.tsx @@ -19,13 +19,20 @@ export function CommentPin({ comment, frameSize, onClick }: Props) { onClick(); }} /* A+W1 touch: 44x44 (h-11 w-11) hit area; visual pin stays 24x24 inside. - Was h-7 w-7 (~28x28) which is below Apple's 44pt minimum. */ + Was h-7 w-7 (~28x28) which is below Apple's 44pt minimum. + filter:drop-shadow on the hit-area wrapper traces the teardrop's + actual outline (rounded-bl-none) instead of a rectangular box- + shadow halo, which read as off on the light markdown background. */ className="absolute z-30 flex h-11 w-11 -translate-x-1/2 -translate-y-full items-center justify-center" - style={{ left: cx, top: cy }} + style={{ + left: cx, + top: cy, + filter: 'drop-shadow(0 1px 2px rgba(0,0,0,0.25))', + }} aria-label={`Comment by ${comment.authorName}`} > {comment.authorInitial} diff --git a/apps/web/src/components/FrameLayer.tsx b/apps/web/src/components/FrameLayer.tsx index 5721335..91f4831 100644 --- a/apps/web/src/components/FrameLayer.tsx +++ b/apps/web/src/components/FrameLayer.tsx @@ -43,6 +43,12 @@ interface Props { commentsByFrame: Map; /** Frame ids currently in (or near) the viewport. */ inViewportSet: ReadonlySet; + /** + * Solo mode — when non-null, only frames whose branchId matches are + * rendered. The selectionStore owns this state; App.tsx reads + passes + * through so FrameLayer's memoization can short-circuit re-renders. + */ + soloBranchId: string | null; onSelectElement: (sel: SelectedElement | null) => void; onDropPin: (frameId: string, x: number, y: number) => void; onCommentClick: (frameId: string, comment: Comment) => void; @@ -65,6 +71,7 @@ export const FrameLayer = memo(function FrameLayer({ zoom, commentsByFrame, inViewportSet, + soloBranchId, onSelectElement, onDropPin, onCommentClick, @@ -92,6 +99,9 @@ export const FrameLayer = memo(function FrameLayer({ style={{ display: 'contents' }} > {frames.map((f) => { + // Solo mode: skip frames not on the soloed branch. Done before the + // branch lookup so we don't pay the Map.get for hidden frames. + if (soloBranchId !== null && f.branchId !== soloBranchId) return null; const branch = branchesMap.get(f.branchId); if (!branch) return null; const comments = commentsByFrame.get(f.id) ?? []; diff --git a/apps/web/src/components/LeftRail.tsx b/apps/web/src/components/LeftRail.tsx deleted file mode 100644 index 3d46e11..0000000 --- a/apps/web/src/components/LeftRail.tsx +++ /dev/null @@ -1,112 +0,0 @@ -// Vertical tool pill on the canvas's left edge. After the Step 9 fast-follow -// this is a thin wrapper around the `toolbar` plugin surface — every button -// you see here is contributed by the `core/tools` plugin (apps/web/src/ -// plugins/core-tools/index.tsx). -// -// The component is kept around (rather than deleted in favour of the -// bottom-center PluginToolBar) for two reasons: -// 1. The vertical-pill placement is part of the canvas's visual identity. -// 2. Every e2e spec clicks `getByTestId('foldo-rail-tool-')` — those -// testids stay alive here so the test suite doesn't churn. -// -// /* A+W1 features */ — the legacy `onChange` prop was dead (the buttons -// route through `window.__foldoSetTool` via the plugin's ToolSpec.activate), -// so it was removed from the signature. App.tsx's call site no longer -// passes it either. - -import { Fragment } from 'react'; -import { usePluginSurfaces } from '../plugins/registry'; -import type { Tool } from '../types'; - -interface Props { - tool: Tool; -} - -export function LeftRail({ tool }: Props) { - const surfaces = usePluginSurfaces('toolbar'); - const tools = surfaces.flatMap((s) => s.tools); - // Don't render the container if no plugin contributes tools — the slot is - // visually-empty and stealing left-edge real estate would be a regression. - if (tools.length === 0) return null; - - return ( -
-
- {tools.map((t, i) => { - const prev = i > 0 ? tools[i - 1] : undefined; - const groupChanged = prev && (prev.group ?? '') !== (t.group ?? ''); - return ( - - {groupChanged ? ( -
- ) : null} - - {t.icon} - - - ); - })} -
-
- ); -} - -function RailButton({ - toolId, - label, - ariaLabel, - active, - onClick, - shortcut, - children, -}: { - toolId: string; - label: string; - ariaLabel?: string; - active?: boolean; - onClick: () => void; - shortcut?: string; - children: React.ReactNode; -}) { - return ( - - ); -} diff --git a/apps/web/src/components/MarkdownFrame.tsx b/apps/web/src/components/MarkdownFrame.tsx index 3dada21..6d95e1a 100644 --- a/apps/web/src/components/MarkdownFrame.tsx +++ b/apps/web/src/components/MarkdownFrame.tsx @@ -321,14 +321,14 @@ function DocHeader({ )}
-
+
{!editing && ( +
+ ); +} diff --git a/apps/web/src/components/TopBar.tsx b/apps/web/src/components/TopBar.tsx index 37f749a..48e1ba3 100644 --- a/apps/web/src/components/TopBar.tsx +++ b/apps/web/src/components/TopBar.tsx @@ -2,6 +2,8 @@ import { useEffect, useRef, useState } from 'react'; import type { Board, UserId, User } from '@foldo/protocol'; import { PresenceAvatars } from '../multiplayer/PresenceAvatars'; import { useBoardSelector } from '../state/useBoardStore'; +import { useSelectionSlice } from '../state/selectionStore'; +import { findStubWorktree } from '../plugins/core-worktrees/WorktreesPanel'; import { ShareManagementModal } from './ShareManagementModal'; const HOME_URL = '/home'; @@ -86,27 +88,32 @@ export function TopBar({ } }; return ( -
- {/* left: logo + repo selector */} - {/* A+W1 touch: bumped py-1.5 → py-2 so the chrome reads ~40px tall on iPad. */} -
- +
+ {/* left: logo + repo selector — width matches the LeftPanel below so + the two stacked floating cards align on the canvas's left edge. */} +
+ -
+
+ {open && ( -
+
setUserPickerOpen((o) => !o)} title="Switch demo user (refresh required)" - className="flex items-center gap-1.5 rounded-lg border border-hairlineSoft bg-panel px-2 py-1 text-[12px] text-ink hover:bg-white/5" + className="flex items-center gap-1.5 rounded-lg border border-hairlineSoft bg-panel px-2.5 py-2 text-[12px] text-ink hover:bg-white/5" > s.activeWorktreeId); + const wt = findStubWorktree(activeWorktreeId); + if (!wt) return null; + return ( + + + {wt.path} + + ); +} + function BoardsIcon() { return ( diff --git a/apps/web/src/components/ZoomControl.tsx b/apps/web/src/components/ZoomControl.tsx index 5f01358..64f6845 100644 --- a/apps/web/src/components/ZoomControl.tsx +++ b/apps/web/src/components/ZoomControl.tsx @@ -8,7 +8,7 @@ interface Props { export function ZoomControl({ zoom, onZoomIn, onZoomOut, onZoomToFit, onReset }: Props) { return ( -
+
+ ))} +
+ + {!tipDismissed && enriched.length <= 1 ? ( +
+
+ New branches you + create with /branch or via Claude + will show up here. Click any row to focus its frames on the canvas. +
+ +
+ ) : null} + +
+ PR state + ahead/behind are stubbed. Wire{' '} + /api/branches/:id/git for live data. +
+ +
+ {enriched.length === 0 ? ( +
+ No branches on this board yet. +
+ ) : visible.length === 0 ? ( +
+ No branches match this filter. +
+ ) : ( + <> + {showActiveGroup && grouped.active.length > 0 ? ( + toggleGroup('active')} + > + {grouped.active.map((e) => ( + onSelectBranch(e.branch, mods)} + onCheckOut={() => onCheckOut(e.branch)} + /> + ))} + + ) : null} + {showStaleGroup && grouped.stale.length > 0 ? ( + toggleGroup('stale')} + subtitle="no commits 30d+" + > + {grouped.stale.map((e) => ( + onSelectBranch(e.branch, mods)} + onCheckOut={() => onCheckOut(e.branch)} + /> + ))} + + ) : null} + {showMergedGroup && grouped.merged.length > 0 ? ( + toggleGroup('merged')} + > + {grouped.merged.map((e) => ( + onSelectBranch(e.branch, mods)} + onCheckOut={() => onCheckOut(e.branch)} + /> + ))} + + ) : null} + + )} +
+
+ ); +} + +interface GroupSectionProps { + title: string; + slug: string; + collapsed: boolean; + subtitle?: string; + onToggle: () => void; + children: React.ReactNode; +} + +function GroupSection({ title, slug, collapsed, subtitle, onToggle, children }: GroupSectionProps): JSX.Element { + return ( +
+ + {!collapsed ? ( +
{children}
+ ) : null} +
+ ); +} + +interface BranchRowProps { + branch: Branch; + meta: StubbedGitMeta; + frameCount: number; + selected: boolean; + soloed: boolean; + onClick: (modifiers: { meta: boolean; shift: boolean }) => void; + onCheckOut: () => void; +} + +function BranchRow({ branch, meta, frameCount, selected, soloed, onClick, onCheckOut }: BranchRowProps): JSX.Element { + const rowStyleResolved = soloed ? rowSoloed : selected ? rowSelected : rowStyle; + return ( +
onClick({ meta: e.metaKey || e.ctrlKey, shift: e.shiftKey })} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onClick({ meta: e.metaKey || e.ctrlKey, shift: e.shiftKey }); + } + }} + data-testid={`foldo-branches-row-${branch.id}`} + data-selected={selected ? 'true' : 'false'} + data-soloed={soloed ? 'true' : 'false'} + aria-pressed={selected} + title={ + soloed + ? `${branch.name} — Solo mode (only this branch is visible). Cmd+click again to exit.` + : selected + ? `${branch.name} — focused on canvas. Cmd+click for Solo.` + : `${branch.name} — click to focus, Cmd+click to Solo.` + } + > +
+ + {branch.name} + {soloed ? SOLO : null} + {meta.prNumber ? ( + + PR #{meta.prNumber} + + ) : null} +
+
+ {meta.isCheckedOut ? ( + ● checked out + ) : meta.ahead || meta.behind ? ( + + {meta.ahead ? `↑${meta.ahead}` : ''} + {meta.ahead && meta.behind ? ' ' : ''} + {meta.behind ? `↓${meta.behind}` : ''} + + ) : ( + in sync + )} + · + + {frameCount} frame{frameCount === 1 ? '' : 's'} + + {meta.daysSinceLastCommit > 0 ? ( + <> + · + + {meta.daysSinceLastCommit === 1 + ? '1d' + : `${meta.daysSinceLastCommit}d`}{' '} + ago + + + ) : null} +
+ {selected ? ( +
+ {soloed ? 'Only this branch visible' : '● Focused on canvas'} +
+ ) : null} + {!meta.isCheckedOut && selected ? ( +
+ + {meta.prNumber ? ( + + ) : null} + +
+ ) : null} +
+ ); +} diff --git a/apps/web/src/plugins/core-branches/index.tsx b/apps/web/src/plugins/core-branches/index.tsx new file mode 100644 index 0000000..ce0d3b2 --- /dev/null +++ b/apps/web/src/plugins/core-branches/index.tsx @@ -0,0 +1,35 @@ +// Branches panel. Sits alongside Layers + Worktrees in the left panel +// as a tab, surfacing the BoardStore's `branches` Map with the canvas +// context (frame count per branch) plus stubbed PR / ahead-behind +// metadata where the backend hasn't shipped yet. +// +// The data model is intentionally split — `Branch` records come from +// the server, the per-row stubbed metadata (PR number, ahead/behind, +// merged state) is hardcoded today and a future `/api/branches/:id/git` +// endpoint will fill it in. + +import type { Plugin } from '@foldo/plugin'; +import { BranchesPanel } from './BranchesPanel'; + +const BranchesIcon = '⎇'; + +export const coreBranchesPlugin: Plugin = { + manifest: { + id: 'core/branches', + name: 'Branches', + version: '0.1.0', + description: + 'Git branches view — checked-out, remote, ahead/behind, PR state. Sibling tab to Layers + Worktrees.', + surfaces: [ + { + kind: 'leftPanel', + tab: { + id: 'branches', + label: 'Branches', + icon: BranchesIcon, + render: (): JSX.Element => , + }, + }, + ], + }, +}; diff --git a/apps/web/src/plugins/core-dom-editor/DomEditor.tsx b/apps/web/src/plugins/core-dom-editor/DomEditor.tsx index 4788960..5f5644e 100644 --- a/apps/web/src/plugins/core-dom-editor/DomEditor.tsx +++ b/apps/web/src/plugins/core-dom-editor/DomEditor.tsx @@ -37,6 +37,8 @@ import { } from 'react'; import { createDispatch as apiCreateDispatch } from '../../api/dispatches'; import { boardStore } from '../../state/useBoardStore'; +import { selectionStore } from '../../state/selectionStore'; +import { findStubWorktree } from '../core-worktrees/WorktreesPanel'; import type { CreateDispatchRequest } from '@foldo/protocol'; import { broadcastToFrames, @@ -56,14 +58,14 @@ import { AllPropertyGroups } from './PropertyGroups'; // ---------- Styles (dark, 12px — matches SidePanel's tab strip) ---------- -const stack: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 12 }; +const stack: CSSProperties = { display: 'flex', flexDirection: 'column', gap: 8 }; const buttonBase: CSSProperties = { - fontSize: 13, - padding: '10px 14px', - minHeight: 40, - borderRadius: 6, - border: '1px solid rgba(255,255,255,0.1)', + fontSize: 12, + padding: '6px 10px', + minHeight: 28, + borderRadius: 4, + border: '1px solid #323232', background: 'rgba(255,255,255,0.04)', color: '#e8e8ea', cursor: 'pointer', @@ -87,9 +89,9 @@ const saveButton: CSSProperties = { const ghostButton: CSSProperties = { ...buttonBase, - padding: '6px 10px', - minHeight: 32, - fontSize: 12, + padding: '4px 8px', + minHeight: 24, + fontSize: 11, }; const emptyState: CSSProperties = { @@ -475,6 +477,8 @@ export function DomEditor(): JSX.Element { setDispatchInFlight(true); setDispatchError(null); try { + const activeWtId = selectionStore.getSnapshot().activeWorktreeId; + const activeWt = findStubWorktree(activeWtId); const body: CreateDispatchRequest = { boardId: board.id, frameId: frame.id, @@ -486,6 +490,7 @@ export function DomEditor(): JSX.Element { elementFile: 'unknown', elementLine: 0, }, + ...(activeWt ? { worktreeHint: activeWt.path } : {}), }; const d = await apiCreateDispatch(body); boardStore.upsertDispatch(d); diff --git a/apps/web/src/plugins/core-dom-editor/PropertyGroups.tsx b/apps/web/src/plugins/core-dom-editor/PropertyGroups.tsx index 4339b5b..1c7c27d 100644 --- a/apps/web/src/plugins/core-dom-editor/PropertyGroups.tsx +++ b/apps/web/src/plugins/core-dom-editor/PropertyGroups.tsx @@ -62,7 +62,7 @@ const groupHeader: CSSProperties = { letterSpacing: 0.8, textTransform: 'uppercase', color: COLORS.sectionLabel, - padding: '10px 0 6px 0', + padding: '6px 0 4px 0', marginBottom: 0, background: 'transparent', border: 'none', @@ -75,7 +75,7 @@ const groupHeader: CSSProperties = { const groupBody: CSSProperties = { display: 'flex', flexDirection: 'column', - gap: 6, + gap: 4, }; // Two-column grid is the workhorse — most property rows fit this shape. @@ -125,7 +125,7 @@ function Cell({ display: 'flex', alignItems: 'center', gap: 4, - height: 28, + height: 24, padding: '0 6px', background: COLORS.inputBg, border: `1px solid ${ @@ -217,7 +217,7 @@ function SelectCell({ display: 'flex', alignItems: 'center', gap: 4, - height: 28, + height: 24, padding: '0 6px', background: COLORS.inputBg, border: `1px solid ${focused ? COLORS.inputBorderFocus : COLORS.inputBorder}`, @@ -308,7 +308,7 @@ function ColorCell({ display: 'flex', alignItems: 'center', gap: 4, - height: 28, + height: 24, padding: '0 4px 0 4px', background: COLORS.inputBg, border: `1px solid ${ @@ -413,7 +413,7 @@ function SliderCell({ display: 'flex', alignItems: 'center', gap: 8, - height: 28, + height: 24, padding: '0 6px', background: COLORS.inputBg, border: `1px solid ${COLORS.inputBorder}`, @@ -543,7 +543,7 @@ function SpacingBox({ background: 'rgba(253,179,6,0.08)', border: '1px dashed rgba(253,179,6,0.3)', borderRadius: 3, - height: 28, + height: 24, display: 'flex', alignItems: 'center', justifyContent: 'center', diff --git a/apps/web/src/plugins/core-layers/LayerNavigator.tsx b/apps/web/src/plugins/core-layers/LayerNavigator.tsx index 6bf995d..f10deda 100644 --- a/apps/web/src/plugins/core-layers/LayerNavigator.tsx +++ b/apps/web/src/plugins/core-layers/LayerNavigator.tsx @@ -67,7 +67,7 @@ import { LayerContextMenu } from './LayerContextMenu'; const containerStyle: CSSProperties = { display: 'flex', flexDirection: 'column', - gap: 8, + gap: 6, height: '100%', position: 'relative', // anchor the absolute-positioned context menu }; @@ -75,21 +75,21 @@ const containerStyle: CSSProperties = { const toolbarStyle: CSSProperties = { display: 'flex', gap: 4, - paddingBottom: 8, - borderBottom: '1px solid rgba(255,255,255,0.06)', + paddingBottom: 6, + borderBottom: '1px solid #323232', }; const toolbarBtn: CSSProperties = { flex: 1, - // A+W1 touch: 4x6 → 10x10 padding so the row is ≥40px tall on iPad. - padding: '10px 10px', - minHeight: 40, - background: 'rgba(255,255,255,0.04)', + padding: '4px 6px', + height: 24, + background: 'rgba(255,255,255,0.03)', color: '#e8e8ea', - border: '1px solid rgba(255,255,255,0.08)', - borderRadius: 4, + border: '1px solid #323232', + borderRadius: 3, cursor: 'pointer', - fontSize: 12, + fontSize: 11, + fontWeight: 500, }; const toolbarBtnDisabled: CSSProperties = { @@ -99,9 +99,9 @@ const toolbarBtnDisabled: CSSProperties = { }; const emptyStyle: CSSProperties = { - color: '#9a9aa0', - fontSize: 12, - padding: '16px 8px', + color: '#9a9a9a', + fontSize: 11, + padding: '14px 8px', textAlign: 'center', lineHeight: 1.4, }; diff --git a/apps/web/src/plugins/core-layers/LayerNode.tsx b/apps/web/src/plugins/core-layers/LayerNode.tsx index cbad9a4..c5af9ce 100644 --- a/apps/web/src/plugins/core-layers/LayerNode.tsx +++ b/apps/web/src/plugins/core-layers/LayerNode.tsx @@ -21,16 +21,15 @@ const row: CSSProperties = { alignItems: 'center', gap: 6, width: '100%', - /* A+W1 touch: 4x6 → 8x10 padding + 12px font so each row is ~32-36px tall; - comfortable for iPad fingertips without losing the dense-list feel. */ - padding: '8px 10px', + padding: '4px 8px', + minHeight: 24, border: 'none', background: 'transparent', color: '#e8e8ea', - fontSize: 13, + fontSize: 11, textAlign: 'left', cursor: 'pointer', - borderRadius: 4, + borderRadius: 3, lineHeight: 1.3, position: 'relative', }; diff --git a/apps/web/src/plugins/core-layers/LayerSearch.tsx b/apps/web/src/plugins/core-layers/LayerSearch.tsx index 2bfd4c2..595ec2b 100644 --- a/apps/web/src/plugins/core-layers/LayerSearch.tsx +++ b/apps/web/src/plugins/core-layers/LayerSearch.tsx @@ -27,32 +27,32 @@ const wrapperStyle: CSSProperties = { width: '100%', }; -// 16px font so iOS Safari doesn't auto-zoom on focus. const inputStyle: CSSProperties = { width: '100%', boxSizing: 'border-box', - background: 'rgba(0,0,0,0.3)', + background: 'rgba(0,0,0,0.25)', color: '#e8e8ea', - border: '1px solid rgba(255,255,255,0.08)', + border: '1px solid #323232', borderRadius: 4, - padding: '8px 28px 8px 10px', - fontSize: 16, + padding: '5px 22px 5px 8px', + height: 26, + fontSize: 12, fontFamily: 'inherit', outline: 'none', }; const clearBtn: CSSProperties = { position: 'absolute', - right: 4, + right: 2, top: '50%', transform: 'translateY(-50%)', - width: 22, - height: 22, + width: 20, + height: 20, border: 'none', background: 'transparent', - color: '#9a9aa0', + color: '#9a9a9a', cursor: 'pointer', - fontSize: 14, + fontSize: 13, lineHeight: 1, padding: 0, borderRadius: 3, diff --git a/apps/web/src/plugins/core-tools/index.tsx b/apps/web/src/plugins/core-tools/index.tsx index 50e4666..6d984a7 100644 --- a/apps/web/src/plugins/core-tools/index.tsx +++ b/apps/web/src/plugins/core-tools/index.tsx @@ -8,10 +8,9 @@ // (`window.__foldoSetTool`) on mount; each ToolSpec's `activate()` looks // it up and calls it. Same escape-hatch pattern as `registerToastHook`. // -// The bottom-center PluginToolBar slot renders these contributions via the -// registry. The legacy LeftRail also reads from the same registry so its -// vertical pill stays in sync (keeping its existing `foldo-rail-tool-*` -// testids alive for the e2e specs). +// The bottom-center PluginToolBar slot renders these contributions via +// the registry. Buttons keep the canonical `foldo-rail-tool-*` testids +// alive for the e2e specs. // // /* A+W4 features */ — the plugin now also contributes one `hotkey` surface // per tool with a `shortcut` letter. This is the source of truth for the @@ -91,8 +90,7 @@ function setTool(tool: Tool): void { if (fn) fn(tool); } -// ----- Icons (lifted verbatim from the old LeftRail JSX so the visual stays -// identical inside both PluginToolBar and the LeftRail pill). ----- +// ----- Icons. SVG glyphs sized to fit the 32px PluginToolBar buttons. ----- function ArrowIcon() { return ( @@ -189,12 +187,11 @@ function ImageIcon() { ); } -// ----- Tool specs. The `id` field matches the canvas `Tool` union so the -// LeftRail can map a spec back to the active-tool state with `tool === t.id`. -// `group` drives the divider hairline in LeftRail; the PluginToolBar ignores -// it for now. Shortcut letters match the bindings in useKeyboardShortcuts -// (this list is documentation, not the source of truth for the keydown -// handler). ----- +// ----- Tool specs. The `id` field matches the canvas `Tool` union so a +// consumer can map a spec back to the active-tool state with `tool === t.id`. +// `group` drives the divider hairline between adjacent tools. Shortcut +// letters match the bindings in useKeyboardShortcuts (this list is +// documentation, not the source of truth for the keydown handler). ----- export const CORE_TOOLS: readonly ToolSpec[] = [ { @@ -284,11 +281,9 @@ function toolHotkeys(tools: readonly ToolSpec[]): PluginSurface[] { * - one `toolbar` surface with every canvas tool, and * - one `hotkey` surface per tool whose ToolSpec declares a `shortcut`. * - * App.tsx still mounts the legacy LeftRail for the left-edge vertical pill - * (it reads from the same registry under the hood) and the bottom - * PluginToolBar shows the same tools horizontally — both views stay in sync - * because they both pull from this single source. useKeyboardShortcuts.ts - * iterates the `hotkey` surfaces for tool keybinds (no more hardcoded map). + * The bottom PluginToolBar slot is the only renderer of the toolbar + * contributions today. useKeyboardShortcuts.ts iterates the `hotkey` + * surfaces for tool keybinds (no more hardcoded map). */ export const coreToolsPlugin: Plugin = { manifest: { diff --git a/apps/web/src/plugins/core-worktrees/WorktreesPanel.tsx b/apps/web/src/plugins/core-worktrees/WorktreesPanel.tsx new file mode 100644 index 0000000..74807f1 --- /dev/null +++ b/apps/web/src/plugins/core-worktrees/WorktreesPanel.tsx @@ -0,0 +1,391 @@ +// Worktrees panel body. Renders a stubbed list of local git worktrees +// in the shape the future `/api/worktrees` endpoint will return. The +// shell + selection wiring are real today — selecting a worktree sets +// `activeWorktreeId` in the selection store, which: +// 1. Shows a "→ ~/path" chip in the TopBar (so the user always knows +// where their next dispatch lands). +// 2. Threads through to `CreateDispatchRequest.worktreeHint` so the +// MCP bridge can `cd` there for the run. +// +// The data is hardcoded until the server ships `/api/worktrees`. + +import { useState, type CSSProperties } from 'react'; +import { selectionStore, useSelectionSlice } from '../../state/selectionStore'; + +// ---------- styles ---------- + +const containerStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 6, + height: '100%', +}; + +const headerRowStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 6, + paddingBottom: 6, + borderBottom: '1px solid #323232', +}; + +const headerLabelStyle: CSSProperties = { + fontSize: 11, + color: '#9a9a9a', +}; + +const addBtnStyle: CSSProperties = { + padding: '4px 8px', + height: 24, + background: 'rgba(255,255,255,0.03)', + color: '#e8e8ea', + border: '1px solid #323232', + borderRadius: 3, + cursor: 'pointer', + fontSize: 11, + fontWeight: 500, +}; + +const listStyle: CSSProperties = { + flex: 1, + overflow: 'auto', + display: 'flex', + flexDirection: 'column', + gap: 4, +}; + +const rowStyle: CSSProperties = { + display: 'flex', + flexDirection: 'column', + gap: 3, + padding: '7px 8px 7px 10px', + borderRadius: 4, + background: 'rgba(255,255,255,0.02)', + border: '1px solid #2a2a2a', + cursor: 'pointer', + transition: 'background 80ms, border-color 80ms', +}; + +const rowActive: CSSProperties = { + ...rowStyle, + background: 'rgba(253,179,6,0.06)', + borderColor: 'rgba(253,179,6,0.35)', + boxShadow: 'inset 4px 0 0 0 #FDB306', + paddingLeft: 12, +}; + +const rowHeaderStyle: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: 6, +}; + +const dotStyle: CSSProperties = { + width: 8, + height: 8, + borderRadius: '50%', + flexShrink: 0, +}; + +const pathStyle: CSSProperties = { + flex: 1, + fontSize: 12, + color: '#e8e8ea', + fontWeight: 500, + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const branchChipStyle: CSSProperties = { + fontSize: 10, + padding: '1px 5px', + borderRadius: 3, + background: 'rgba(255,255,255,0.05)', + border: '1px solid #323232', + color: '#c8c8cc', + fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', + flexShrink: 0, +}; + +const metaStyle: CSSProperties = { + fontSize: 10, + color: '#7a7a80', + display: 'flex', + gap: 8, + alignItems: 'center', + flexWrap: 'wrap', +}; + +const statusLine: CSSProperties = { + fontSize: 10, + color: '#FDB306', + fontWeight: 500, +}; + +const dirtyStyle: CSSProperties = { color: '#e2b557' }; +const cleanStyle: CSSProperties = { color: '#7a7a80' }; +const agentRunningStyle: CSSProperties = { color: '#9ed29e' }; + +const actionRow: CSSProperties = { + display: 'flex', + gap: 4, + marginTop: 3, +}; + +const actionBtn: CSSProperties = { + fontSize: 10, + padding: '3px 8px', + height: 22, + background: 'rgba(255,255,255,0.03)', + color: '#c8c8cc', + border: '1px solid #323232', + borderRadius: 3, + cursor: 'pointer', +}; + +const stubBannerStyle: CSSProperties = { + fontSize: 10, + color: '#7a7a80', + padding: '6px 8px', + background: 'rgba(253,179,6,0.05)', + border: '1px dashed rgba(253,179,6,0.25)', + borderRadius: 3, + lineHeight: 1.4, +}; + +const tipStyle: CSSProperties = { + fontSize: 10.5, + color: '#c8c8cc', + padding: '8px 10px', + background: 'rgba(255,255,255,0.03)', + border: '1px solid #323232', + borderRadius: 4, + lineHeight: 1.5, + display: 'flex', + flexDirection: 'column', + gap: 6, +}; + +// ---------- stub data ---------- + +export interface Worktree { + id: string; + path: string; + branch: string; + branchColor: string; + clean: boolean; + changeCount: number; + agent: { name: string; status: 'idle' | 'running' } | null; + lastActivity: string; +} + +export const STUB_WORKTREES: Worktree[] = [ + { + id: 'wt-root', + path: '~/foldo', + branch: 'main', + branchColor: '#9a9a9a', + clean: true, + changeCount: 0, + agent: { name: 'claude-code', status: 'idle' }, + lastActivity: '1m ago', + }, + { + id: 'wt-cta', + path: '~/foldo-cta', + branch: 'feat/cta-revamp', + branchColor: '#b07bff', + clean: false, + changeCount: 4, + agent: { name: 'claude-code', status: 'running' }, + lastActivity: '12s ago', + }, + { + id: 'wt-pro', + path: '~/foldo-pro', + branch: 'feat/pro-tier-highlight', + branchColor: '#4a8bff', + clean: true, + changeCount: 0, + agent: { name: 'claude-code', status: 'idle' }, + lastActivity: '4m ago', + }, +]; + +/** Look up the stub worktree by id — exported so TopBar can render its path. */ +export function findStubWorktree(id: string | null): Worktree | null { + if (!id) return null; + return STUB_WORKTREES.find((w) => w.id === id) ?? null; +} + +function notify(msg: string): void { + const fn = (window as unknown as { __foldoToast?: (m: string) => void }).__foldoToast; + if (fn) fn(msg); +} + +// ---------- component ---------- + +const TIP_LS_KEY = 'foldo:worktrees:tip-dismissed'; + +export function WorktreesPanel(): JSX.Element { + const activeWorktreeId = useSelectionSlice((s) => s.activeWorktreeId); + const [tipDismissed, setTipDismissed] = useState(() => { + if (typeof localStorage === 'undefined') return false; + return localStorage.getItem(TIP_LS_KEY) === '1'; + }); + + // Default active worktree = the root one if nothing's chosen yet. We + // do this in render rather than as an effect so the chip in the TopBar + // appears immediately on first mount. + const resolvedActive = activeWorktreeId ?? STUB_WORKTREES[0]?.id ?? null; + + const dirtyCount = STUB_WORKTREES.filter((w) => !w.clean).length; + + const dismissTip = (): void => { + setTipDismissed(true); + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(TIP_LS_KEY, '1'); + } catch { + /* swallow */ + } + } + }; + + const onSelect = (id: string, path: string): void => { + selectionStore.setActiveWorktree(id); + notify(`Next dispatch will run in ${path}.`); + }; + + return ( +
+
+ + {STUB_WORKTREES.length} worktree{STUB_WORKTREES.length === 1 ? '' : 's'} + {dirtyCount > 0 ? ` · ${dirtyCount} dirty` : ''} + + +
+ + {!tipDismissed ? ( +
+
+ Each worktree is + an independent sandbox. Selecting one tells Claude Code{' '} + where to do the next dispatch — it doesn't move the canvas. +
+ +
+ ) : null} + +
+ Stubbed data. /api/worktrees{' '} + + MCP worktree.list are the + next backend pieces. +
+ +
+ {STUB_WORKTREES.map((wt) => { + const isActive = wt.id === resolvedActive; + return ( +
onSelect(wt.id, wt.path)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onSelect(wt.id, wt.path); + } + }} + tabIndex={0} + data-testid={`foldo-worktrees-row-${wt.id}`} + data-active={isActive ? 'true' : 'false'} + aria-pressed={isActive} + > +
+ + + {wt.path} + + + {wt.branch} + +
+
+ + {wt.clean + ? '○ clean' + : `◐ ${wt.changeCount} change${wt.changeCount === 1 ? '' : 's'}`} + + · + {wt.agent ? ( + + {wt.agent.status === 'running' ? '⟳ ' : ''} + {wt.agent.name} · {wt.agent.status} + + ) : ( + no agent + )} + · + {wt.lastActivity} +
+ {isActive ? ( +
+ ● Active dispatch target +
+ ) : null} + {!isActive ? ( +
+ + +
+ ) : null} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/plugins/core-worktrees/index.tsx b/apps/web/src/plugins/core-worktrees/index.tsx new file mode 100644 index 0000000..e24d890 --- /dev/null +++ b/apps/web/src/plugins/core-worktrees/index.tsx @@ -0,0 +1,32 @@ +// Worktrees panel. Sits alongside Layers + Branches in the left panel +// as a tab. Today it's a visual shell only — the worktree list is +// stubbed because the server doesn't expose `/api/worktrees` yet. The +// shell exists so the design can ship + be reviewed while the backend +// catches up; the same row component will hydrate from real data once +// the endpoint lands. + +import type { Plugin } from '@foldo/plugin'; +import { WorktreesPanel } from './WorktreesPanel'; + +const WorktreesIcon = '▣'; + +export const coreWorktreesPlugin: Plugin = { + manifest: { + id: 'core/worktrees', + name: 'Worktrees', + version: '0.1.0', + description: + 'Local git worktree view — which sandbox each agent is in, dirty/clean, switch + open. Stubbed today.', + surfaces: [ + { + kind: 'leftPanel', + tab: { + id: 'worktrees', + label: 'Worktrees', + icon: WorktreesIcon, + render: (): JSX.Element => , + }, + }, + ], + }, +}; diff --git a/apps/web/src/plugins/index.ts b/apps/web/src/plugins/index.ts index 38a9994..c459d99 100644 --- a/apps/web/src/plugins/index.ts +++ b/apps/web/src/plugins/index.ts @@ -25,11 +25,18 @@ import type { Plugin } from '@foldo/plugin'; import { coreToolsPlugin } from './core-tools/index'; import { coreLayersPlugin } from './core-layers/index'; +import { coreBranchesPlugin } from './core-branches/index'; +import { coreWorktreesPlugin } from './core-worktrees/index'; import { domEditorPlugin } from './core-dom-editor/index'; +// Order within BUILTIN_PLUGINS = tab order in the side panels. Layers +// stays first (it's where users start), then Branches + Worktrees as +// siblings of the same "where work lives" mental model. export const BUILTIN_PLUGINS: Plugin[] = [ coreToolsPlugin, coreLayersPlugin, + coreBranchesPlugin, + coreWorktreesPlugin, domEditorPlugin, ]; diff --git a/apps/web/src/plugins/registry.ts b/apps/web/src/plugins/registry.ts index 1ec8d2a..8485b64 100644 --- a/apps/web/src/plugins/registry.ts +++ b/apps/web/src/plugins/registry.ts @@ -159,9 +159,13 @@ export function usePluginSurfaces( * anything renders. */ export function bootPlugins(plugins: Plugin[]): void { - // Reset the surface cache so a hot-reload during dev (which re-imports - // this module) doesn't serve a stale array. + // Reset on every boot so a Vite HMR re-run doesn't accumulate duplicate + // plugin contributions (which would render N copies of the toolbar / N + // copies of every side-panel tab). In production the function runs once + // at startup, so the reset is a no-op there. + registry.reset(); surfaceCache.clear(); + hotkeyCache = null; registry.installAll(plugins); registry.activate(defaultContext()); } @@ -206,6 +210,27 @@ export function getSelectFrameHook(): ((frameId: string) => void) | null { ); } +/** + * Pan + zoom the canvas to fit a world-space rect. Used by the Branches + * plugin to focus the canvas on a branch's frames when its row is + * clicked. The receiver in App.tsx forwards to `canvasRef.fitTo(rect)`. + */ +export interface WorldRect { + x: number; + y: number; + width: number; + height: number; +} +export function registerFitToHook(fn: ((rect: WorldRect) => void) | null): void { + (window as unknown as { __foldoFitTo?: (r: WorldRect) => void }).__foldoFitTo = + fn ?? undefined; +} +export function getFitToHook(): ((rect: WorldRect) => void) | null { + return ( + (window as unknown as { __foldoFitTo?: (r: WorldRect) => void }).__foldoFitTo ?? null + ); +} + /* A+W1 features — layer-nav action hooks. The Layer Navigator's toolbar buttons (Delete/Rename/Reorder) call these window-level escape hatches when they need to mutate frames. The hook implementations live in diff --git a/apps/web/src/plugins/slots/SidePanel.tsx b/apps/web/src/plugins/slots/SidePanel.tsx index e2aced7..c092ca3 100644 --- a/apps/web/src/plugins/slots/SidePanel.tsx +++ b/apps/web/src/plugins/slots/SidePanel.tsx @@ -6,73 +6,123 @@ // Active tab is route-backed via `?leftTab=…` / `?rightTab=…` query params // so a tab selection survives reload + is deep-linkable. Falls back to the // first tab when the param is absent or names an unknown tab. +// +// Collapsed state: the user can collapse either panel into a vertical +// icon pill at the screen edge. Each pill icon corresponds to one tab; +// clicking expands the panel + switches to that tab. The collapsed flag +// is persisted to localStorage so it survives reloads. -import { useCallback, useEffect, useMemo, useState, type CSSProperties } from 'react'; +import { + useCallback, + useEffect, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from 'react'; import { usePluginSurfaces } from '../registry'; type Side = 'left' | 'right'; -/* A+W1 touch: responsive sizing. On viewports <900px we narrow the panel from - 280px → 220px so it stops eating half the canvas on iPad portrait. On - viewports <700px (rare — the phone banner usually kicks in first) the panel - collapses to a small "Tabs" toggle button. */ +/* Responsive width: on viewports <900px we narrow the panel from 280→220px + so it stops eating half the canvas on iPad portrait. */ function pickWidth(vw: number): number { if (vw < 900) return 220; return 280; } -// Floating Figma-style panel: insets from the screen edges (so the -// TopBar above and PluginToolBar below stay fully visible and the panel -// reads as a "card" floating on the canvas), rounded corners, soft -// outer shadow + crisp inner hairline. zIndex sits BELOW the TopBar -// (z=50) so a stray full-bleed panel can never paint over it. +// Expanded floating card — dark panel token, soft outer shadow + crisp +// inner hairline. zIndex sits BELOW the TopBar (z=50) so a stray full- +// bleed panel can never paint over it. const containerBase: CSSProperties = { position: 'fixed', - top: 64, // below TopBar (~56px) + 8px gap - bottom: 80, // above PluginToolBar (~60px) + 20px gap + top: 64, + bottom: 72, display: 'flex', flexDirection: 'column', - background: 'rgba(20, 20, 22, 0.96)', - backdropFilter: 'blur(14px)', - WebkitBackdropFilter: 'blur(14px)', - borderRadius: 12, - border: '1px solid rgba(255,255,255,0.06)', - boxShadow: '0 12px 40px rgba(0,0,0,0.4)', - zIndex: 35, + background: '#2c2c2c', + borderRadius: 10, + border: '1px solid #323232', + boxShadow: '0 12px 32px rgba(0,0,0,0.45)', + /* Chrome z-index lives in the 100-range so canvas-side overlays + (popovers, iframe portals, comment pins) can never paint over it + regardless of zoom level. */ + zIndex: 100, + color: '#e8e8ea', + overflow: 'hidden', +}; + +// Pill container: same vertical rhythm as the expanded card, ~36px wide, +// vertical stack of icon buttons. +const pillBase: CSSProperties = { + position: 'fixed', + top: 64, + width: 36, + display: 'flex', + flexDirection: 'column', + alignItems: 'center', + gap: 2, + padding: 4, + background: '#2c2c2c', + borderRadius: 10, + border: '1px solid #323232', + boxShadow: '0 12px 32px rgba(0,0,0,0.45)', + zIndex: 100, color: '#e8e8ea', - overflow: 'hidden', // round body corners with the parent }; -// Tab strip header: subtle, compact. When there's only ONE tab we -// shrink-wrap the label so the strip reads as a "panel title" rather -// than a giant button (the previous full-width treatment looked like a -// CTA dominating the top of the panel). +const pillBtnBase: CSSProperties = { + width: 28, + height: 28, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + background: 'transparent', + color: '#9a9a9a', + border: 'none', + borderRadius: 6, + cursor: 'pointer', + fontSize: 13, + transition: 'background 80ms, color 80ms', +}; + +const pillBtnActive: CSSProperties = { + ...pillBtnBase, + background: 'rgba(253,179,6,0.16)', + color: '#FDB306', +}; + +const pillDivider: CSSProperties = { + width: '70%', + height: 1, + background: '#323232', + margin: '2px 0', +}; + const tabStrip: CSSProperties = { display: 'flex', gap: 2, - padding: '8px 10px', - borderBottom: '1px solid rgba(255,255,255,0.06)', + padding: '6px 6px', + borderBottom: '1px solid #323232', flexShrink: 0, + alignItems: 'center', }; const tabBtn: CSSProperties = { - // Default tab button: equal-width when multiple tabs, content-width - // when single. The flex value is overridden inline below based on - // tabs.length so a 1-tab panel reads as a title not a button. - padding: '6px 10px', - minHeight: 28, + padding: '5px 8px', + minHeight: 26, border: 'none', background: 'transparent', - color: '#9a9aa0', - borderRadius: 6, - fontSize: 12, + color: '#9a9a9a', + borderRadius: 5, + fontSize: 11, fontWeight: 500, letterSpacing: 0.2, cursor: 'pointer', display: 'inline-flex', alignItems: 'center', justifyContent: 'center', - gap: 6, + gap: 5, transition: 'background 80ms, color 80ms', }; @@ -82,40 +132,32 @@ const tabBtnActive: CSSProperties = { color: '#e8e8ea', }; +const collapseHandle: CSSProperties = { + background: 'transparent', + border: 'none', + color: '#9a9a9a', + width: 22, + height: 22, + padding: 0, + cursor: 'pointer', + fontSize: 14, + borderRadius: 4, + marginLeft: 'auto', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', +}; + const body: CSSProperties = { flex: 1, overflow: 'auto', - padding: '8px 12px 12px 12px', -}; - -/* A+W1 touch: small floating "Tabs" toggle for very narrow viewports — sits - along the panel's edge so the user has a way to bring the collapsed panel - back without needing the keyboard. */ -const collapsedToggleBase: CSSProperties = { - position: 'fixed', - top: '50%', - transform: 'translateY(-50%)', - background: 'rgba(20,20,22,0.92)', - color: '#e8e8ea', - border: 'none', - padding: '10px 8px', - fontSize: 11, - cursor: 'pointer', - borderRadius: 0, - writingMode: 'vertical-rl', - zIndex: 40, - boxShadow: 'inset 0 0 0 1px rgba(255,255,255,0.06)', + padding: '8px 10px 10px 10px', }; /** * Tiny `?leftTab=` / `?rightTab=` hook. We don't extend Router (it's path- * shaped, not query-shaped) — the panel substrate is the only thing that * needs query state today, so a local helper is the lighter-weight option. - * - * Writes use `history.replaceState` (a tab switch shouldn't pile a back- - * stack entry per click) and dispatch a custom `foldo:tabchange` event so - * sibling panels (LeftPanel + RightPanel coexist) reconcile without each - * polling location.search. */ function useTabRouteParam(paramName: string): { value: string | null; @@ -148,7 +190,6 @@ function useTabRouteParam(paramName: string): { const next = location.pathname + (qs ? `?${qs}` : '') + location.hash; history.replaceState({}, '', next); setValue(v); - // Tell the sibling panel + other listeners. window.dispatchEvent(new Event('foldo:tabchange')); }, [paramName], @@ -157,6 +198,31 @@ function useTabRouteParam(paramName: string): { return { value, set }; } +/** Persist the collapsed flag per-side so reloads remember the user's choice. */ +function useCollapsedFlag(side: Side, defaultCollapsed: boolean): { + collapsed: boolean; + setCollapsed: (v: boolean) => void; +} { + const key = `foldo:sidepanel:${side}:collapsed`; + const read = (): boolean => { + if (typeof localStorage === 'undefined') return defaultCollapsed; + const raw = localStorage.getItem(key); + if (raw === null) return defaultCollapsed; + return raw === '1'; + }; + const [collapsed, setCollapsedState] = useState(read); + const setCollapsed = useCallback( + (v: boolean): void => { + setCollapsedState(v); + if (typeof localStorage !== 'undefined') { + localStorage.setItem(key, v ? '1' : '0'); + } + }, + [key], + ); + return { collapsed, setCollapsed }; +} + function SidePanel({ side }: { side: Side }): JSX.Element | null { const surfaces = usePluginSurfaces(side === 'left' ? 'leftPanel' : 'rightPanel'); const tabs = useMemo(() => surfaces.map((s) => s.tab), [surfaces]); @@ -164,7 +230,6 @@ function SidePanel({ side }: { side: Side }): JSX.Element | null { side === 'left' ? 'leftTab' : 'rightTab', ); - /* A+W1 touch: track viewport width to compute panel width + collapse mode. */ const [vw, setVw] = useState( typeof window === 'undefined' ? 1440 : window.innerWidth, ); @@ -174,44 +239,65 @@ function SidePanel({ side }: { side: Side }): JSX.Element | null { window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); - const collapseByDefault = vw < 700; - const [userCollapsed, setUserCollapsed] = useState(collapseByDefault); - // Re-sync when the viewport crosses the breakpoint so a portrait → landscape - // rotation re-expands automatically. - useEffect(() => { - setUserCollapsed(collapseByDefault); - }, [collapseByDefault]); + + // Default-collapse on phone-sized viewports so the pill, not the full + // card, is what shows up first. The localStorage flag overrides if the + // user has explicitly toggled it. + const { collapsed, setCollapsed } = useCollapsedFlag(side, vw < 700); if (tabs.length === 0) return null; - // Resolve active tab: route param if it names a real tab, else first tab. + const active = tabs.find((t) => t.id === routeTab) ?? tabs[0]!; - const onSelectTab = (id: string): void => { - // Writing the first tab as the route value lets a deep-link force the - // first tab even after a future plugin install reshuffles ordering. - setRouteTab(id); - }; + const onSelectTab = (id: string): void => setRouteTab(id); - // Inset from the screen edge so the panel reads as a floating card - // rather than a full-bleed sidebar. 12px on desktop, 8px on narrow. + // Pill + expanded panel both inset from the screen edge by 12px (8px on + // narrow). The bottom edge tracks the canvas tool dock. const edgeInset = vw < 900 ? 8 : 12; const positional: CSSProperties = side === 'left' ? { left: edgeInset } : { right: edgeInset }; - // Collapsed state: render only a small toggle button along the screen edge. - if (userCollapsed) { + if (collapsed) { return ( - + {tabs.map((t, i) => { + const isActive = t.id === active.id; + return ( + + ); + })} +
+ + ); } @@ -223,12 +309,6 @@ function SidePanel({ side }: { side: Side }): JSX.Element | null {
{tabs.map((t) => { const isActive = t.id === active.id; - // Single-tab panel: render as left-aligned title, no flex - // expansion. Multi-tab: equal-width segmented control. - const widthStyle: CSSProperties = - tabs.length === 1 - ? { flex: 'initial', justifyContent: 'flex-start', paddingLeft: 4 } - : { flex: 1 }; return ( ); })} - {/* A+W1 touch: collapse handle is always visible on narrow tablets so - the user can reclaim canvas space without keyboard. */} - {vw < 900 && ( - - )} +
; } diff --git a/apps/web/src/plugins/slots/ToolBar.tsx b/apps/web/src/plugins/slots/ToolBar.tsx index 0512f7b..b3d3be1 100644 --- a/apps/web/src/plugins/slots/ToolBar.tsx +++ b/apps/web/src/plugins/slots/ToolBar.tsx @@ -1,82 +1,81 @@ -// Bottom-center tool dock. Pulls every `toolbar` surface contribution -// from the registry and lays them out as a single horizontal pill. Hides -// itself if no plugin contributes tools — the existing LeftRail keeps -// rendering its hardcoded buttons until they're collapsed into a plugin -// in a Step 9 fast-follow. +// Bottom-center tool dock — the canvas's canonical tool surface. Pulls +// every `toolbar` surface contribution from the registry and lays them +// out as a horizontal icon-only rail. Hides itself if no plugin +// contributes tools. // -// Visual style mirrors LeftRail.tsx — same pill background + button -// shape — so contributions look at-home alongside the legacy rail. +// Visual: small icon-only square buttons on the dark `bg-panel` token — +// same style language as the side panels so the canvas reads as one +// design system rather than two competing rails. // -// /* A+W4 features */ — buttons now respect ToolSpec.group: a 1px vertical -// hairline divider sits between adjacent tools that disagree on `group`. -// Mirrors the LeftRail vertical pill so the two views read as the same -// taxonomy. Buttons also gain a11y attributes (aria-label, -// aria-keyshortcuts, aria-pressed) so screen readers announce both the -// label and the bound shortcut, plus the active state. +// Buttons respect ToolSpec.group: a 1px vertical hairline divider sits +// between adjacent tools that disagree on `group`. a11y attributes +// (aria-label, aria-keyshortcuts, aria-pressed) announce label, bound +// shortcut, and active state to screen readers. +// +// The container carries the historical `foldo-canvas-leftrail` testid + +// per-button `foldo-rail-tool-` testids so the existing e2e helpers +// (CanvasPage.clickTool, share-link visibility checks) keep working +// after the vertical LeftRail was retired. import { Fragment, type CSSProperties } from 'react'; import { usePluginSurfaces, getCurrentTool } from '../registry'; const wrap: CSSProperties = { position: 'fixed', - bottom: 20, + bottom: 16, left: '50%', transform: 'translateX(-50%)', display: 'flex', alignItems: 'center', - gap: 4, - padding: 6, - background: 'rgba(20, 20, 22, 0.85)', - backdropFilter: 'blur(12px)', - borderRadius: 999, - boxShadow: '0 8px 32px rgba(0,0,0,0.35), inset 0 0 0 1px rgba(255,255,255,0.06)', - zIndex: 50, + gap: 2, + padding: 4, + background: '#2c2c2c', + border: '1px solid #323232', + borderRadius: 10, + boxShadow: '0 12px 32px rgba(0,0,0,0.45)', + // Chrome z-index — see SidePanel.tsx for the rationale. + zIndex: 110, }; const btn: CSSProperties = { display: 'inline-flex', alignItems: 'center', - gap: 6, - /* A+W1 touch: padding bumped 6px10px → 10px14px so the pill button reads - ~40px tall — fingertip-friendly on iPad. minHeight is the safety net for - buttons whose label is short. */ - padding: '10px 14px', - minHeight: 40, - borderRadius: 999, + justifyContent: 'center', + width: 32, + height: 32, + padding: 0, + borderRadius: 6, border: 'none', background: 'transparent', - color: '#e8e8ea', - fontSize: 13, - fontWeight: 500, + color: '#9a9a9a', cursor: 'pointer', + transition: 'background 80ms, color 80ms', }; const btnActive: CSSProperties = { ...btn, - background: 'rgba(255,255,255,0.10)', - color: '#ffffff', + background: 'rgba(253,179,6,0.16)', + color: '#FDB306', }; const divider: CSSProperties = { width: 1, alignSelf: 'stretch', margin: '4px 2px', - background: 'rgba(255,255,255,0.10)', + background: '#323232', }; export function ToolBar(): JSX.Element | null { const surfaces = usePluginSurfaces('toolbar'); const tools = surfaces.flatMap((s) => s.tools); if (tools.length === 0) return null; - // Live tool id (or null pre-mount). Read once per render — the pill is - // cheap enough that we don't bother subscribing to App's state. const activeToolId = getCurrentTool(); return (
{tools.map((t, i) => { @@ -101,10 +100,9 @@ export function ToolBar(): JSX.Element | null { aria-pressed={isActive} onClick={t.activate} style={isActive ? btnActive : btn} - data-testid={`foldo-plugin-toolbar-tool-${t.id}`} + data-testid={`foldo-rail-tool-${t.id}`} > {t.icon} - {t.label} ); diff --git a/apps/web/src/state/selectionStore.ts b/apps/web/src/state/selectionStore.ts new file mode 100644 index 0000000..75e94e9 --- /dev/null +++ b/apps/web/src/state/selectionStore.ts @@ -0,0 +1,122 @@ +// Client-only selection state — which branch is focused, whether Solo +// mode is on, which worktree is the active dispatch target. Lives +// outside BoardStore because none of this state is server-truth: it's +// pure local UI on top of the server snapshot. +// +// The store is a tiny external store (React 18 useSyncExternalStore) +// with a localStorage backing so selections survive reloads. Two side +// effects of changes: +// 1. Subscribers re-render via useSelection(). +// 2. A `foldo:selectionchange` window event fires so non-React +// consumers (e.g. canvas-level filters that don't want to add a +// subscription) can listen. + +import { useSyncExternalStore } from 'react'; + +export interface SelectionState { + /** Currently-focused branch id, or null when nothing is selected. */ + selectedBranchId: string | null; + /** When non-null, only this branch's frames render on canvas. */ + soloBranchId: string | null; + /** Currently-active worktree (drives the "→ ~/path" chip in TopBar). */ + activeWorktreeId: string | null; +} + +const LS_KEY = 'foldo:selection'; +const EVENT = 'foldo:selectionchange'; + +function readInitial(): SelectionState { + if (typeof localStorage === 'undefined') { + return { selectedBranchId: null, soloBranchId: null, activeWorktreeId: null }; + } + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) { + return { selectedBranchId: null, soloBranchId: null, activeWorktreeId: null }; + } + const parsed = JSON.parse(raw) as Partial; + return { + selectedBranchId: parsed.selectedBranchId ?? null, + soloBranchId: parsed.soloBranchId ?? null, + activeWorktreeId: parsed.activeWorktreeId ?? null, + }; + } catch { + return { selectedBranchId: null, soloBranchId: null, activeWorktreeId: null }; + } +} + +let state: SelectionState = readInitial(); +const listeners = new Set<() => void>(); + +function emit(): void { + if (typeof localStorage !== 'undefined') { + try { + localStorage.setItem(LS_KEY, JSON.stringify(state)); + } catch { + /* localStorage full or denied — swallow; this state is transient. */ + } + } + for (const l of listeners) l(); + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(EVENT, { detail: state })); + } +} + +export const selectionStore = { + getSnapshot(): SelectionState { + return state; + }, + subscribe(fn: () => void): () => void { + listeners.add(fn); + return () => { + listeners.delete(fn); + }; + }, + setSelectedBranch(id: string | null): void { + if (state.selectedBranchId === id) return; + state = { ...state, selectedBranchId: id }; + // Selecting a different branch exits Solo (the soloed branch is + // implicitly the selected one — switching selection without exiting + // Solo would leave a stale ghost-filter on the canvas). + if (state.soloBranchId !== null && state.soloBranchId !== id) { + state = { ...state, soloBranchId: null }; + } + emit(); + }, + setSoloBranch(id: string | null): void { + if (state.soloBranchId === id) return; + state = { ...state, soloBranchId: id }; + // Entering Solo implies selection on the same branch — keep them in sync. + if (id !== null && state.selectedBranchId !== id) { + state = { ...state, selectedBranchId: id }; + } + emit(); + }, + setActiveWorktree(id: string | null): void { + if (state.activeWorktreeId === id) return; + state = { ...state, activeWorktreeId: id }; + emit(); + }, + reset(): void { + state = { selectedBranchId: null, soloBranchId: null, activeWorktreeId: null }; + emit(); + }, +}; + +/** + * React hook returning the full selection state. Subscribers re-render + * whenever any field changes. Use a selector (`useSelectionSlice`) when + * a component only cares about one field — saves a render per unrelated + * update. + */ +export function useSelection(): SelectionState { + return useSyncExternalStore(selectionStore.subscribe, selectionStore.getSnapshot, selectionStore.getSnapshot); +} + +export function useSelectionSlice(selector: (s: SelectionState) => T): T { + return useSyncExternalStore( + selectionStore.subscribe, + () => selector(selectionStore.getSnapshot()), + () => selector(selectionStore.getSnapshot()), + ); +} diff --git a/e2e/plugin/core-plugins-still-work.spec.ts b/e2e/plugin/core-plugins-still-work.spec.ts index 46c72ff..9fc6a3c 100644 --- a/e2e/plugin/core-plugins-still-work.spec.ts +++ b/e2e/plugin/core-plugins-still-work.spec.ts @@ -1,14 +1,19 @@ -// Step 9 / 10 / 11 no-regression gate. Locks in that the plugin substrate -// (Step 9) keeps working as each subsequent plugin (Step 10 Layer -// Navigator, Step 11 DOM Editor) lands. The canvas must still mount, the -// existing LeftRail testids must still resolve, the bottom PluginToolBar -// must render the core/tools contributions, and BOTH side panels must -// host their respective tabs (Layers on the left, Inspect on the right). +// Plugin substrate no-regression gate. Locks in that the plugin +// substrate keeps working as each plugin (Layer Navigator, DOM Editor) +// lands. The canvas must still mount, the canonical tool dock must +// render the core/tools contributions, and BOTH side panels must host +// their respective tabs (Layers on the left, Inspect on the right). +// +// The bottom PluginToolBar is the only tool surface today (the legacy +// vertical LeftRail was retired in favour of the floating bottom +// dock). It still carries the historical `foldo-canvas-leftrail` + +// `foldo-rail-tool-*` testids so the existing canvas e2e suite keeps +// clicking tools by their canonical name. // // Locking this spec in protects against three specific regressions: // 1. A future plugin's activate() crash takes down the canvas // 2. The new LeftPanel/RightPanel slots steal click events from canvas -// 3. The PluginToolBar dock fails to render its core/tools contributions +// 3. The tool dock fails to render its core/tools contributions import { expect, test } from '@playwright/test'; import { createUser, loginAs } from '../helpers/factory'; @@ -36,20 +41,12 @@ test.describe('plugin substrate: no regression', () => { const canvas = new CanvasPage(page); await canvas.waitReady(); - // LeftRail must still render its tools — those buttons are now sourced - // from the core/tools plugin but the data-testids are preserved so the - // existing canvas e2e suite keeps clicking them by name. + // The bottom tool dock must render its core/tools contributions. The + // dock carries the canonical `foldo-canvas-leftrail` + `foldo-rail-tool-*` + // testids so the existing canvas e2e suite keeps reaching tools by name. await expect(page.getByTestId('foldo-canvas-leftrail')).toBeVisible(); await expect(page.getByTestId('foldo-rail-tool-select')).toBeVisible(); - // The bottom PluginToolBar slot also renders the core/tools tools (same - // contributions, different layout). Asserting visibility here catches a - // regression where the registry stops feeding contributions to slots. - await expect(page.getByTestId('foldo-plugin-toolbar')).toBeVisible(); - await expect( - page.getByTestId('foldo-plugin-toolbar-tool-select'), - ).toBeVisible(); - // LeftPanel — Step 10's coreLayersPlugin contributes the "Layers" tab. await expect(page.getByTestId('foldo-plugin-left-panel')).toBeVisible(); await expect(page.getByTestId('foldo-plugin-left-tab-layers')).toBeVisible(); diff --git a/packages/plugin/src/index.ts b/packages/plugin/src/index.ts index a56f3d8..ec6fe93 100644 --- a/packages/plugin/src/index.ts +++ b/packages/plugin/src/index.ts @@ -164,13 +164,29 @@ export class PluginRegistry { private readonly plugins: Plugin[] = []; install(plugin: Plugin): void { - this.plugins.push(plugin); + // Idempotent by manifest.id so a dev-mode HMR re-boot doesn't duplicate + // contributions. A second install of the same id replaces the prior + // entry in place to keep install order stable. + const i = this.plugins.findIndex((p) => p.manifest.id === plugin.manifest.id); + if (i >= 0) this.plugins[i] = plugin; + else this.plugins.push(plugin); } installAll(plugins: Plugin[]): void { for (const p of plugins) this.install(p); } + /** + * Drop every installed plugin + reset activation state. Used by `bootPlugins` + * in apps/web to make HMR re-boots non-duplicating; tests also call it + * between cases when they need a clean registry. + */ + reset(): void { + this.plugins.length = 0; + this.teardowns.length = 0; + this.activated = false; + } + /** Activate every installed plugin. Idempotent on re-call (does nothing). */ activate(ctx: PluginContext): void { if (this.activated) return; diff --git a/packages/protocol/src/domain.ts b/packages/protocol/src/domain.ts index 5e19e46..f222fc7 100644 --- a/packages/protocol/src/domain.ts +++ b/packages/protocol/src/domain.ts @@ -315,6 +315,16 @@ export interface Dispatch { startedAt?: string; finishedAt?: string; errorMessage?: string; + /** + * Hint to the MCP bridge — which local worktree path to run this + * dispatch in. Set by the canvas UI (Worktrees panel) and forwarded + * through `/ws/mcp`. Not persisted in the DB today (the schema + * doesn't have a column for it yet); attached in-flight on the + * dispatch.execute WS message. The bridge MAY ignore it — see the + * CreateDispatchRequest doc for why this is a hint rather than a + * contract. + */ + worktreeHint?: string; } // ---------- Presence (multiplayer) ---------- diff --git a/packages/protocol/src/rest.ts b/packages/protocol/src/rest.ts index f3137e2..f3fa1f5 100644 --- a/packages/protocol/src/rest.ts +++ b/packages/protocol/src/rest.ts @@ -136,6 +136,18 @@ export interface CreateDispatchRequest { baseCommitSha: string; intent: string; target: CommentTarget; + /** + * Optional hint to the MCP bridge about which local worktree the + * dispatch should run in. Set by the canvas UI (Worktrees panel + * selection) and forwarded by the server to /ws/mcp. The MCP bridge + * uses it as `cwd` when invoking Claude Code; if absent, the bridge + * runs from the process working directory it was launched in. + * + * Hint, not contract — the bridge MAY ignore it if the path doesn't + * exist or isn't a known worktree. Failure to honor the hint should + * never block the dispatch. + */ + worktreeHint?: string; } export interface ListDispatchesResponse { From 4a401a19301e0c0f24f5dc038fd39e19f289bbd6 Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 14:50:52 +0100 Subject: [PATCH 2/8] fix(topbar): pin Demo User chip bg across focus/active states Clicking the Demo User chip opened the dropdown and Safari's default button focus chrome painted a light system colour over `bg-panel`, "removing" the background. `appearance-none` strips that default; `focus:bg-panel active:bg-panel focus-visible:bg-panel` pin the panel colour across every interaction state so the chip matches the Capture/Tests/Share chips next to it. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/TopBar.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/web/src/components/TopBar.tsx b/apps/web/src/components/TopBar.tsx index 48e1ba3..07caaaa 100644 --- a/apps/web/src/components/TopBar.tsx +++ b/apps/web/src/components/TopBar.tsx @@ -137,9 +137,17 @@ export function TopBar({ {me && switchable.length > 1 && (
{userPickerOpen && ( -
+
Demo as
From 33b9ac88099d40a5ceab8107e78ec80e7cd3c1ae Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 16:19:32 +0100 Subject: [PATCH 4/8] fix: clear stale Solo state when branch no longer exists on board MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A soloBranchId persisted to localStorage from an earlier session points at a branch the current board may not have (different board, seed reset, etc.) — FrameLayer's Solo filter would then hide every frame on the canvas. A user landing on the board would see an empty canvas + no obvious affordance to recover (the SoloBanner shows the branch name but not why frames are missing). Auto-clear soloBranchId + selectedBranchId when the soloed branch isn't present in the hydrated board. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/App.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index d5a96fa..ad302bd 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -319,6 +319,24 @@ export default function App() { return () => registerFitToHook(null); }, []); + // Defensive: localStorage may carry a soloBranchId from an earlier session + // that points at a branch the current board doesn't have (e.g. switching + // boards, after a seed reset). Without this cleanup, FrameLayer's Solo + // filter would hide every frame on the canvas — and a user landing on the + // board would see an empty canvas with no obvious way to recover. + useEffect(() => { + if (!snap.hydrated) return; + if (!soloBranchId) return; + if (!snap.branches.has(soloBranchId)) { + // Lazy import to avoid pulling selectionStore into the App.tsx + // top-level closure chain. + import('./state/selectionStore').then(({ selectionStore }) => { + selectionStore.setSoloBranch(null); + selectionStore.setSelectedBranch(null); + }); + } + }, [snap.hydrated, snap.branches, soloBranchId]); + /* A+W1 features — layer-nav action hooks. The Layer Navigator's toolbar (touch agent's surface) calls window.__foldoDeleteFrame / __foldoRenameFrame / __foldoReorderFrame; we own the implementations From 9af07c1efdabf65d00d3ead73054b96b2e1ad30d Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 16:22:44 +0100 Subject: [PATCH 5/8] fix: lift CommentPopover/EditPanel/ToastStack above chrome z-index MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the chrome z-indexes got bumped to 100-110 to stay above canvas content on zoom, several content-floating overlays were left at z-50/60: - CommentPopover (z-60): pin dropped, popover rendered, but it sat UNDER the LeftPanel/RightPanel (now z-100) so the user couldn't see it or compose. This is the user-reported "click frame to add comment fails" — the pin appeared, the popover was just hidden. - EditPanel (z-50): "AI edit" right-side dispatch panel could be fully obscured by RightPanel. - ToastStack (z-50): toasts could end up behind the bottom toolbar. Lifted all three to z-[140] (above panels at z-100/120, toolbar at z-110, below SoloBanner at z-95 — wait, banner is also bumped via the same fix). Confirmed CommentPopover is now visible after Comment tool → click frame. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/CommentPopover.tsx | 2 +- apps/web/src/components/EditPanel.tsx | 2 +- apps/web/src/components/ToastQueue.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/CommentPopover.tsx b/apps/web/src/components/CommentPopover.tsx index 53d9ea7..12852ef 100644 --- a/apps/web/src/components/CommentPopover.tsx +++ b/apps/web/src/components/CommentPopover.tsx @@ -88,7 +88,7 @@ export function CommentPopover({
+
{toasts.map((t) => (
Date: Mon, 25 May 2026 16:27:19 +0100 Subject: [PATCH 6/8] fix(comments): allow empty text on create + add key to MarkdownView wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues hit when dropping a comment pin in fresh Safari: 1. POST /api/comments returned 400 "Invalid comment body" because the server validation rejected empty text. The pin-drop flow creates the comment optimistically with `text: ''` (the compose popover PATCHes the body in afterwards) — rejecting empty text broke that handshake and made the optimistic comment vanish a moment after the pin appeared. Validation now requires `typeof text === 'string'` so the empty-then-patch flow works. 2. React warned "Each child in a list should have a unique 'key' prop" from MarkdownView. The line-author tint wraps the rendered element in a
when there's an entry — the inner element had `key={idx}` but the wrapper didn't, so React couldn't reconcile the wrapper case. Threaded `idx` into `lineWrap` and put the key on the wrapper. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/server/src/routes/comments.ts | 12 +++++++++++- apps/web/src/components/Markdown.tsx | 9 +++++++-- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/apps/server/src/routes/comments.ts b/apps/server/src/routes/comments.ts index 79bf775..a7d9923 100644 --- a/apps/server/src/routes/comments.ts +++ b/apps/server/src/routes/comments.ts @@ -39,7 +39,17 @@ export async function registerCommentRoutes(app: FastifyInstance): Promise async (req, reply) => { const user = requireUser(req); const body = req.body; - if (!body?.boardId || !body?.frameId || !body?.text) { + // text is REQUIRED but may be an empty string — the pin-drop flow + // creates the comment optimistically with `text: ''` and the + // compose popover PATCHes the body in afterwards. Rejecting empty + // text here would block that handshake (the symptom: pin appears, + // popover never opens because the server 400's, and the rollback + // tears the optimistic comment out of the store). + if ( + !body?.boardId || + !body?.frameId || + typeof body?.text !== 'string' + ) { return reply.code(400).send({ error: 'Invalid comment body', code: 'BAD_REQUEST' }); } // Comments require membership; even viewers can leave them in this MVP. diff --git a/apps/web/src/components/Markdown.tsx b/apps/web/src/components/Markdown.tsx index 8914b2e..e8f6a06 100644 --- a/apps/web/src/components/Markdown.tsx +++ b/apps/web/src/components/Markdown.tsx @@ -226,13 +226,18 @@ export function MarkdownView({ nameForUser, }: RenderProps) { const lines = parseMarkdown(body); - const lineWrap = (ln: MarkdownLine, children: ReactNode) => { + // When the line has a known author tint we wrap the rendered element in + // a `
` to host the gutter border. The wrapper element is what the + // map callback returns, so the key must live on it (not just the inner + // element) or React warns "child in list should have a unique key". + const lineWrap = (ln: MarkdownLine, idx: number, children: ReactNode) => { const entry = lineAuthors?.[String(ln.bodyLineIndex)]; if (!entry || !colorForUser) return children; const color = colorForUser(entry.authorUserId); const name = nameForUser?.(entry.authorUserId) ?? 'someone'; return (
onLineClick?.(ln, e); - const wrap = (node: ReactNode) => lineWrap(ln, node); + const wrap = (node: ReactNode) => lineWrap(ln, idx, node); switch (ln.block.kind) { case 'h1': return wrap( From 6126ae2a2ccfe79974334eb3c4e23641b2db1d03 Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 16:35:43 +0100 Subject: [PATCH 7/8] fix(appframe): gate postMessage on iframe-loaded to silence cross-origin spam The "Unable to post message to http://localhost:5174. Recipient has origin http://localhost:5173" console spam came from a race: when the review-mode / overrides effects fired before the iframe finished loading, iframe.contentWindow was still on about:blank (inheriting the parent's origin). postMessage with the expected localhost:5174 targetOrigin then logs a cross-origin error in every browser, even though our try/catch swallows the throw. Track an `iframeLoadedRef` that flips true on either: - `foldo.sample.ready` message from the sample-app (preferred), or - the native iframe `onLoad` event (backstop). Effects gate their postToFrame on the ref. Reset to false whenever the iframe URL changes or the frame re-enters the viewport, so a fresh navigation doesn't reuse the previous load's ready state. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/components/AppFrame.tsx | 53 ++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/AppFrame.tsx b/apps/web/src/components/AppFrame.tsx index 458e0ac..0413366 100644 --- a/apps/web/src/components/AppFrame.tsx +++ b/apps/web/src/components/AppFrame.tsx @@ -57,25 +57,43 @@ export function AppFrame({ const iframeRef = useRef(null); const overlayRef = useRef(null); + // Tracks whether the iframe has finished loading the sample-app document. + // Before this flips true the iframe.contentWindow is still on about:blank + // (which inherits the parent's origin), and a postMessage with the + // expected localhost:5174 targetOrigin throws "Recipient has origin + // localhost:5173" — noisy in the console even when the parent has a + // try/catch around postMessage. Gate every postToFrame on this ref so + // we only post once the iframe is on its real origin; `foldo.sample.ready` + // covers the "iframe already loaded by the time we mounted" case. + const iframeLoadedRef = useRef(false); + const iframeUrl = useMemo(() => buildIframeUrl(content, frame.commitSha), [ content, frame.commitSha, ]); + // Reset the loaded-ref whenever the iframe is about to navigate (URL + // change) or remount (inViewport flips). The new document hasn't + // attached its postMessage listener yet, so any premature postToFrame + // would hit the about:blank race again. + useEffect(() => { + iframeLoadedRef.current = false; + }, [iframeUrl, inViewport]); + const reviewMode = !testMode; // Sync review mode + overrides into the iframe whenever they change. useEffect(() => { const iframe = iframeRef.current; if (!iframe || !inViewport) return; - const send = () => postToFrame(iframe, { type: 'foldo.sample.setReviewMode', enabled: reviewMode }); - // try once; sample app emits "ready" once mounted and we also push on load - send(); + if (!iframeLoadedRef.current) return; // wait for onLoad / ready + postToFrame(iframe, { type: 'foldo.sample.setReviewMode', enabled: reviewMode }); }, [reviewMode, inViewport]); useEffect(() => { const iframe = iframeRef.current; if (!iframe || !inViewport) return; + if (!iframeLoadedRef.current) return; // wait for onLoad / ready if (!content.overrides) return; postToFrame(iframe, { type: 'foldo.sample.setOverrides', @@ -103,6 +121,10 @@ export function AppFrame({ const msg = ev.data as SampleAppOutbound; switch (msg.type) { case 'foldo.sample.ready': + // Sample-app has loaded its document at the real origin. + // Flip the ready ref so subsequent reviewMode / overrides + // effects can postMessage without hitting the about:blank race. + iframeLoadedRef.current = true; // Push the current review mode + overrides. postToFrame(iframe, { type: 'foldo.sample.setReviewMode', @@ -234,6 +256,31 @@ export function AppFrame({ // Permit our origin parent to talk to it sandbox="allow-scripts allow-same-origin allow-forms allow-popups" loading="lazy" + onLoad={() => { + // Backstop for the rare case where 'foldo.sample.ready' + // never fires (older sample-app build, error in its init). + // The native iframe load event means contentWindow is on + // the real origin, so postMessage is safe. + iframeLoadedRef.current = true; + const iframe = iframeRef.current; + if (!iframe) return; + postToFrame(iframe, { + type: 'foldo.sample.setReviewMode', + enabled: reviewMode, + }); + if (content.overrides) { + postToFrame(iframe, { + type: 'foldo.sample.setOverrides', + overrides: content.overrides as unknown as Record< + string, + string | boolean + >, + }); + } + }} + onError={() => { + iframeLoadedRef.current = false; + }} /> ) : ( From edeee855299d2a6799ed98e74a23c511e31d573c Mon Sep 17 00:00:00 2001 From: lukataylor-pixel Date: Mon, 25 May 2026 16:43:48 +0100 Subject: [PATCH 8/8] fix(toolbar): soften shadow + add duplicate-render canary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bottom dock's box-shadow was 0 12px 32px rgba(0,0,0,0.45) — strong enough on Retina at certain canvas zooms to read as a "ghost toolbar" outline above the real dock. Pull the offset (12→6) and blur (32→18) in and trim alpha (0.45→0.35) so the dock reads as a single floating card without the halo. Also add a dev-only useEffect that counts how many data-testid="foldo-canvas-leftrail" elements are on the page after mount and console.warns if there's more than one. If a real double-render slips past the substrate's idempotency (HMR misfire, accidental second ), this surfaces it as a console warning rather than a silent visual bug. Co-Authored-By: Claude Opus 4.7 (1M context) --- apps/web/src/plugins/slots/ToolBar.tsx | 28 ++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/apps/web/src/plugins/slots/ToolBar.tsx b/apps/web/src/plugins/slots/ToolBar.tsx index b3d3be1..509c5c8 100644 --- a/apps/web/src/plugins/slots/ToolBar.tsx +++ b/apps/web/src/plugins/slots/ToolBar.tsx @@ -17,7 +17,7 @@ // (CanvasPage.clickTool, share-link visibility checks) keep working // after the vertical LeftRail was retired. -import { Fragment, type CSSProperties } from 'react'; +import { Fragment, useEffect, type CSSProperties } from 'react'; import { usePluginSurfaces, getCurrentTool } from '../registry'; const wrap: CSSProperties = { @@ -32,7 +32,11 @@ const wrap: CSSProperties = { background: '#2c2c2c', border: '1px solid #323232', borderRadius: 10, - boxShadow: '0 12px 32px rgba(0,0,0,0.45)', + /* Softer shadow: previous 0 12px 32px 0.45 produced a visible halo + above the dock on Retina at certain zooms that read as a "ghost + toolbar" behind the real one. Pull the offset + blur in so the + toolbar reads as one floating card. */ + boxShadow: '0 6px 18px rgba(0,0,0,0.35)', // Chrome z-index — see SidePanel.tsx for the rationale. zIndex: 110, }; @@ -68,6 +72,26 @@ const divider: CSSProperties = { export function ToolBar(): JSX.Element | null { const surfaces = usePluginSurfaces('toolbar'); const tools = surfaces.flatMap((s) => s.tools); + + // Dev-only duplicate-render canary. The substrate is idempotent by + // manifest.id and there's only one in App.tsx, so + // exactly one toolbar should ever be on the page. If another shows up + // (HMR misfire, accidental second mount), surface it loudly so the + // root cause can be tracked down rather than papering over it. + useEffect(() => { + if (typeof document === 'undefined') return; + if (!import.meta.env.DEV) return; + const count = document.querySelectorAll( + '[data-testid="foldo-canvas-leftrail"]', + ).length; + if (count > 1) { + // eslint-disable-next-line no-console + console.warn( + `[foldo] PluginToolBar mounted ${count} times — duplicate render. Reload to recover.`, + ); + } + }); + if (tools.length === 0) return null; const activeToolId = getCurrentTool();