Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -291,7 +291,7 @@ Files:
- **Levelling after measurement** (`lib/diagram/geometry/levelNearAligned.ts`, run from `Canvas.tsx` when measured sizes/positions settle and nothing is dragged): a card renders at its own size (a wrapped subtitle makes it taller), so nodes the layout lined up by their tops can end up with centres a few px apart and a kinked edge. Nodes joined by an edge off-centre by 0.5–12px are levelled, moving the lighter end, only when that strictly reduces the misalignment of the edges touching it, never into another node and never out of a group. It is idempotent, skips groups and cross-group edges, and records no undo step (`setNodes`).
- **Principles for any diagram** (`layoutUniformity.test.ts`, seeded random diagrams, flat/grouped, sparse/dense, LR/TD): the hard ones hold for every diagram (layout succeeds, no node overlaps, every node inside its group's frame); fan centring and straight links are best effort and ratcheted on means, because interlocking fans contradict each other and no layout can centre them all (known limits: `mvc`, `repo_to_diagram`, `archdraw_self`). `symmetrizeFans` keeps the group range as a bound only as a second attempt (when the plain solve applies nothing). `settleLayout` keeps its final result only if it beats the raw layout (more than 2px off the worst fan or the total miss) or loses no level edge (`levelEdges`: an edge whose ends are level is drawn without a bend); the landing hero is sensitive to a few px of nudging, so never apply the passes unconditionally. All scorers go through `measureLayoutResult`, which turns the engine's absolute child positions into canvas-relative ones; scoring raw engine nodes as canvas nodes double-counts group origins and misleads every grouped score.
- **Final straightening passes** (end of `LayoutStage`, each step kept only when it adds no hard violation, main-path failure, crossing, label collision or bend, keeps fans centred and straight links straight): `alignMainSpine` (`spineAlign.ts`) lines the top-level main path up on one line; `packGroupColumns` (`columnPacking.ts`) closes wide gaps inside a group column so a tier reads as one stack; `alignGroupCentres` (same file) moves whole top-level groups onto a shared centre line, all at once and then one at a time. On dense diagrams the routing usually rejects the group moves (edges between tiers would cut through other tiers), which is intended.
- **Edge channels:** edges of one fan share one vertical channel (`fanChannelX` in `edgeRouteBuilder.ts`); the optimizer (`edgeRouteOptimizer.ts`) treats a shared trunk from a common source/target as a merge, not an overlap (`sharedRun`, anchors < 8px apart count as one point), and a lane offset on an ordinary left-to-right edge moves its channel instead of building a stubbed path.
- **Edge channels:** edges of one fan are routed to one vertical channel (`fanChannelX` in `edgeRouteBuilder.ts`) and then drawn on separate lanes 14px apart, nested so the fan never crosses itself (`separateFanLanes` in `fanLaneSeparation.ts`, last step of `optimizeEdgeRoutes`; a lane is used only when it clears every card and adds no crossing); the optimizer (`edgeRouteOptimizer.ts`) treats a shared trunk from a common source/target as a merge, not an overlap (`sharedRun`, anchors < 8px apart count as one point), and a lane offset on an ordinary left-to-right edge moves its channel instead of building a stubbed path.
- Edge sides (`handlerPairScorer.ts`): a target at least 40px downstream (LR: later column, TD: later row) prefers the flow sides Right→Left / Bottom→Top; same-rank and backward edges use the facing sides.

### Alternate path
Expand Down Expand Up @@ -401,7 +401,7 @@ The hosted `/api/mcp` serves the npm package, so a new tool reaches it only afte

**Preview links are on by default.** `generate_diagram`, `apply_template` (and `update_diagram` for already-published diagrams) return `shareUrl` (read-only preview) and `diagramUrl` (editor) in the same response — `publish` defaults to `true`; `publish:false` is the private opt-out. Tool text puts the link block first via `formatPreviewLinks` (`mcp-server/src/lib/preview-links.ts`) with an explicit "show these links to the user" line, because text-only agents can't render the widget. A failed publish returns the diagram plus `linkError`; it must never fail the generation.

**Inline preview (MCP App).** `mcp-server/src/ui/diagram-widget.html` (`ui://archdraw/diagram-v2.html`) is a canvas only: title, Open in ArchDraw, Expand, zoom, Download PNG. It shows the diagram drawn by `generatePureSVG` (light Studio style, as the share page shows it), which `POST /api/diagram/load` returns as `previewSvg` when called with `preview: true`; `generate_diagram` / `apply_template` pass it to the preview in the tool result's `_meta['archdraw/previewSvg']`, which hosts keep out of the model's context. The preview must report its height (`ui/notifications/size-changed`) or hosts render it at zero height. Never draw diagrams with a separate renderer in the widget. Bump the `ui://` URI when the widget changes, since hosts may cache it.
**Inline preview (MCP App).** `mcp-server/src/ui/diagram-widget.html` (`ui://archdraw/diagram-v3.html`) is a canvas only: title, Open in ArchDraw, Expand, zoom, Download PNG. It shows the diagram drawn by `generatePureSVG` (light Studio style, as the share page shows it), which `POST /api/diagram/load` returns as `previewSvg` when called with `preview: true`; `generate_diagram` / `apply_template` pass it to the preview in the tool result's `_meta['archdraw/previewSvg']`, which hosts keep out of the model's context. The preview must report its height (`ui/notifications/size-changed`) or hosts render it at zero height. Never draw diagrams with a separate renderer in the widget. Bump the `ui://` URI when the widget changes, since hosts may cache it.

Because every MCP generation now creates a `SharedCanvas` row: `POST /api/diagram/load` is throttled per user / client IP (create 30 anon · 60 signed-in, dry-run preview 300, mcp-update 300 per hour) via `checkRateLimitWithFallback` (Redis, in-memory fallback — never use it for AI/billing quotas, those fail closed), and `/api/cron/cleanup-expired-shares` deletes expired shares daily.

Expand Down
11 changes: 10 additions & 1 deletion SEO.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,6 +237,14 @@ Agentic Browsing includes an "agent-centric accessibility" check, so accessibili
- **Dimmed content still has to pass contrast:** scroll effects that fade inactive steps are measured in their faded state. Compute the lowest opacity that keeps the faintest text at 4.5:1 (3:1 for 24px+ or 18.66px+ bold) and never go below it. On the night page, the muted body text needed at least 0.82; the steps had been at 0.38 (1.8:1).
- **Source maps:** `productionBrowserSourceMaps: true` clears "Missing source maps for large first-party JavaScript" and makes production errors readable. Only do this when the code may be public (ArchDraw is open source).

## 11c. Performance: measure before you optimise

- **Lighthouse's mobile LCP is simulated.** With the default `throttlingMethod: simulate`, it replays the page as a slow phone on a slow network and charges LCP for every script and stylesheet requested before the first paint. Read `metrics.observedLargestContentfulPaint` in the JSON report, too. On the ArchDraw home page, the hero paragraph really painted at **224 ms**, together with the first paint, while the simulated LCP said 3.0 s (production) or 3.9 s (local build). Real-user Core Web Vitals in PageSpeed Insights' field data are what count for Google.
- **Compare like with like.** Build `main` and the branch the same way, serve both locally, run Lighthouse three times each and compare medians. A single run, or local against production, is noise.
- **Find the biggest unused chunk and trace it to its import.** Minified chunks hide library names, but fingerprints such as hundreds of `viewBox` values give them away. Here a 241 KB chunk, 96% unused, was the full `@react-icons-cloud/aws` set (about 500 icons in one pre-bundled file that can't be tree-shaken). It was pulled into every canvas, including the landing hero, which uses none of it. Loading it on demand, the first time an AWS component renders, cut unused JavaScript from **292 to 43 KiB**.
- **Lazy isn't lazy if it fires during hydration.** An `IntersectionObserver` with a 200px margin fired at once on phones, so the hero map's JavaScript competed with the first paint. Now, when the map is merely visible, it waits for the `load` event and `requestIdleCallback`; interaction still loads it immediately. That cut total blocking time by 19% (219 → 177 ms) and Speed Index by 7% (2,529 → 2,347 ms).
- **Not every Lighthouse suggestion helps.** `experimental.inlineCss` removed the render-blocking stylesheets but inlined *all* of the app's CSS. The home page HTML grew to 946 KB, and the score fell from 85 to 75. Measure each suggestion; don't apply them in bulk.

## 12. Launch and measurement checklist

After each deploy:
Expand Down Expand Up @@ -286,4 +294,5 @@ After each deploy:
| 2026-11-07 | Free tools (AI generator, text-to-diagram, system design calculator) and comparison pages; this playbook | #32 |
| 2026-11-07 | Clean-ups: lint errors to zero, current MCP install commands in docs | #33 |
| 2026-11-07 | Search Console: sitemap read (116 pages), 8 indexing requests, Rich Results Test; WebMCP origin trial registered | #34 |
| 2026-11-07 | Lighthouse / PageSpeed fixes on the home page: inert hero toggle, AA contrast for dimmed steps, production source maps | not yet opened |
| 2026-11-07 | Lighthouse / PageSpeed fixes on the home page: inert hero toggle, AA contrast for dimmed steps, production source maps | #35 |
| 2026-11-07 | Home page performance: AWS icon set loaded on demand (unused JS 292 → 43 KiB), hero map loads after the page is idle | #36 |
91 changes: 19 additions & 72 deletions frontend/components/canvas/Canvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ import { anyNodeInView } from '@/lib/diagram/canvas/nodesInView';
import { ContextMenu, type ContextMenuState } from '@/components/editor/ContextMenu';
import { useSnapping } from '@/hooks/useSnapping';
import { CometTrailCanvas } from '@/components/canvas/CometTrailCanvas';
import { useMiddleMousePan } from '@/hooks/useCanvasInteractions';
import { useCallback, useEffect, useRef, useState, useMemo, Suspense } from 'react';
import { useShallow } from 'zustand/react/shallow';
import { useCanvasTheme } from '@/lib/theme/appTheme';
Expand All @@ -53,11 +52,9 @@ import { CANVAS_CONFIG, DEFAULT_EDGE_OPTIONS } from '@/lib/config';
import { useGrouping } from '@/hooks/useGrouping';
import { toast } from 'sonner';
import type { Node } from 'reactflow';
import { resolveNodeCollisions } from '@/lib/diagram/geometry/resolveNodeCollisions';
import { useEdgeColors } from '@/lib/theme/edgeColors';
import { calculateNodeDimensions } from '@/lib/diagram/nodes/nodeSizing';
import { createNode, createEdge, createBlankShapeNode, createTextLabelNode } from '@/lib/component-library/factory';
import { nodeCentre } from '@/lib/diagram/geometry/nodeBox';
import { levelNearAlignedNodes } from '@/lib/diagram/geometry/levelNearAligned';
import { reactFlowRef } from '@/lib/client/reactFlowRef';
import { resolveNodeDropConnection } from '@/lib/diagram/canvas/resolveNodeDropConnection';
Expand Down Expand Up @@ -231,12 +228,11 @@ function CanvasInner() {
const isMobile = useIsMobile();

const {
onNodesChange, onEdgesChange, onConnect, onReconnect,
onEdgesChange, onConnect, onReconnect,
setSelectedNodeId, setSelectedNodeIds, setSelectedEdgeId,
setPendingLabelEdgeId,
setNodes, addNodeOnEdgeDrop, addNode,
} = useDiagramStore(useShallow((s) => ({
onNodesChange: s.onNodesChange,
onEdgesChange: s.onEdgesChange,
onConnect: s.onConnect,
onReconnect: s.onReconnect,
Expand All @@ -251,10 +247,9 @@ function CanvasInner() {
const { isDark } = useCanvasTheme();

const reactFlowInstance = useReactFlow();
const { onNodeDrag, onNodeDragStop: onNodeDragStopSnap } = useSnapping();
const { onNodesChange, onNodeDragStop: onNodeDragStopSnap } = useSnapping();
// While a node is lined up with another, alignment wins over the grid so edges can line up exactly.
const alignmentActive = useDiagramStore((s) => s.guideLines.length > 0);
useMiddleMousePan();
useGrouping();

// Live migration for persisted groups saved before the zIndex fix.
Expand Down Expand Up @@ -410,82 +405,31 @@ function CanvasInner() {
};
}, [reactFlowInstance]);

const dragRevision = useRef(0);

// One undo step per drag, recorded at drag start; the moves in between are not recorded.
const onNodeDragStart = useCallback(() => {
dragRevision.current++;
useDiagramStore.getState().pushHistory();
}, []);

const onNodeDragStop = useCallback(
(event: React.MouseEvent, node: Node, draggedNodes: Node[]) => {
dragRevision.current++;
onNodeDragStopSnap(event, node, draggedNodes);

// Auto-straighten: if dragged node has incident edges that are almost horizontal/vertical,
// nudge it so the edge becomes perfectly straight — so user doesn't have to hunt pixel-perfect.
// The drop keeps the last snapped position. Layout repair is explicit;
// moving neighbours or straightening again here makes the release jump.
const store = useDiagramStore.getState();
const isLR = store.activeLayoutPresetId !== 'layered-tb';
const edges = store.edges;
const STRAIGHT_THRESHOLD = 12;
let autoStraightened = false;
const straightenMap = new Map<string, { x: number; y: number }>();
const draggedIds = new Set(draggedNodes.map((n) => n.id));
for (const dragged of draggedNodes) {
const incident = edges.filter((e) => e.source === dragged.id || e.target === dragged.id);
for (const edge of incident) {
const otherId = edge.source === dragged.id ? edge.target : edge.source;
// Nodes dragged together keep their offset; straightening one would shear the selection.
if (draggedIds.has(otherId)) continue;
const other = store.nodes.find((n) => n.id === otherId);
if (!other) continue;
// React Flow's own node carries the absolute position and measured size,
// which the store copy lacks for nodes inside a group.
const dC = nodeCentre(dragged as Node);
const oC = nodeCentre(reactFlowRef.instance?.getNode(otherId) ?? other);
// Determine if edge is primarily horizontal or vertical
const dx = oC.x - dC.x;
const dy = oC.y - dC.y;
if (isLR && Math.abs(dx) > Math.abs(dy) && Math.abs(dy) > 0 && Math.abs(dy) < STRAIGHT_THRESHOLD) {
const cur = straightenMap.get(dragged.id) ?? { x: 0, y: 0 };
if (cur.y === 0 || Math.abs(dy) < Math.abs(cur.y)) cur.y = dy;
straightenMap.set(dragged.id, cur);
autoStraightened = true;
} else if (!isLR && Math.abs(dy) > Math.abs(dx) && Math.abs(dx) > 0 && Math.abs(dx) < STRAIGHT_THRESHOLD) {
const cur = straightenMap.get(dragged.id) ?? { x: 0, y: 0 };
if (cur.x === 0 || Math.abs(dx) < Math.abs(cur.x)) cur.x = dx;
straightenMap.set(dragged.id, cur);
autoStraightened = true;
}
}
}
let currentNodes = store.nodes;
if (autoStraightened) {
currentNodes = currentNodes.map((n) => {
const delta = straightenMap.get(n.id);
if (!delta) return n;
return { ...n, position: { x: n.position.x + delta.x, y: n.position.y + delta.y } };
});
setNodes(currentNodes);
}

// Collision resolution (after straightening)
const resolvedNodes = resolveNodeCollisions(currentNodes);

const hasChanges = currentNodes.some((n, i) =>
n.position.x !== resolvedNodes[i]?.position.x ||
n.position.y !== resolvedNodes[i]?.position.y
);

const latestNodes = hasChanges ? resolvedNodes : useDiagramStore.getState().nodes;

// Persistence is handled by the store's auto-save subscription, which
// saves the final (non-dragging) positions.
if (hasChanges) setNodes(resolvedNodes);

// Recalculate connection handles from the post-drag node positions
useDiagramStore.getState().recalculateHandles(latestNodes);
store.recalculateHandles(store.nodes);
},
[onNodeDragStopSnap, setNodes]
[onNodeDragStopSnap]
);

const onSelectionDragStop = useCallback((event: React.MouseEvent, dragged: Node[]) => {
if (dragged[0]) onNodeDragStop(event, dragged[0], dragged);
}, [onNodeDragStop]);

const onConnectStart: OnConnectStart = useCallback(
(_event, params) => {
connectSucceededRef.current = false;
Expand Down Expand Up @@ -885,14 +829,16 @@ function CanvasInner() {
// edge are levelled (see levelNearAligned). It changes nothing when there is
// nothing to fix, and a fix needs no further fix, so it settles after one pass.
const levelKey = useMemo(
() => (projection ? '' : nodes.map((n) => `${n.id}:${Math.round(n.position.x)}:${Math.round(n.position.y)}:${n.width ?? ''}:${n.height ?? ''}`).join('|')),
() => (projection ? '' : nodes.map((n) => `${n.id}:${n.width ?? ''}:${n.height ?? ''}`).join('|')),
[nodes, projection],
);
useEffect(() => {
if (!levelKey) return;
const revision = dragRevision.current;
const timer = setTimeout(() => {
const store = useDiagramStore.getState();
const current = store.nodes;
if (revision !== dragRevision.current) return;
if (current.some((n) => (n as Node & { dragging?: boolean }).dragging)) return;
const connected = new Set(store.edges.flatMap((e) => [e.source, e.target]));
if (!current.every((n) => !connected.has(n.id) || (n.width && n.height))) return; // still measuring
Expand Down Expand Up @@ -1033,8 +979,9 @@ function CanvasInner() {
onReconnect={projection ? undefined : onReconnect}
onConnectStart={projection ? undefined : onConnectStart}
onConnectEnd={projection ? undefined : onConnectEnd}
onNodeDrag={projection ? undefined : onNodeDrag}
onNodeDragStart={projection ? undefined : onNodeDragStart}
onSelectionDragStart={projection ? undefined : onNodeDragStart}
onSelectionDragStop={projection ? undefined : onSelectionDragStop}
onNodeDragStop={detailView ? undefined : highLevel ? onHighLevelDragStop : onNodeDragStop}
onPaneClick={onPaneClickOrPlace}
onNodeClick={isLiveProject && (!highLevel || detailView) ? onNodeClick : undefined}
Expand Down
Loading
Loading