import { useEffect, useRef, useState } from 'react'; import Konva from 'konva'; /** * Position / transform debug overlay. * * What this is * ──────────── * A developer-only HUD that reports the selected element's live * coordinate state in a fixed bottom-left panel. Built originally * for diagnosing a rotation-pivot bug where bbox-clamping in * `constrainTransform` operated on a different coord system than * the wrapper bounds it was comparing against (stage vs design), * producing per-frame drift on rotation gestures near canvas edges. * The bug is fixed; the overlay is kept as a permanent diagnostic * surface since rotation/clamp/offset interactions are the most * likely re-failure area as the editor evolves and a future * regression will be much faster to diagnose if the readouts are * already wired up. * * What it shows * ───────────── * Four sections, all updated on requestAnimationFrame while mounted: * * • element (state) — React state for the selected element. The * numbers fed BACK into the next render. If state and node * disagree about position/rotation, the next paint will jump. * * • node (live konva) — the live Konva node's attrs. node.x/y is * the pivot position in layer-local coords (with always-center * offset, this equals element.x + W/2, element.y + H/2 by * construction). Offset should be glued to (W/2, H/2). * * • derived — the comparison section. `visual TL (konva)` is the * ground truth: where Konva's own getClientRect says the visible * top-left lands. `simple formula` is what unflipReportedXY * computes when committing position changes. `rot-aware` is * what the formula would be if you naively rotated the offset * vector before subtracting (kept around as a foil to remind * you the simple formula is the round-trip-correct one, even * though it doesn't match visual TL in any obvious way). * * • last gesture — summary of the most recent drag/transform. Most * useful field is `bound calls (N clamped)`: if N > 0 during a * gesture that should have been free, the wrapper clamp is * interfering and the user will see drift. * * Activation * ────────── * Two ways to turn it on, neither requiring a code edit: * * • URL: append `?debug=1` to the editor URL. * • LocalStorage: `localStorage.setItem('paw_debug_overlay', '1')`, * then reload. * * The URL path also persists to localStorage so subsequent reloads * keep it on without re-appending the param. The on-panel × * button clears both and reloads. * * The entire overlay component is gated behind a `DEBUG_OVERLAY_ENABLED` * constant in App.jsx (read once at module load). When the flag is * off, the conditional render short-circuits and this file is dead * code from the user's perspective; the bundler will not tree-shake * the import itself, but the component never mounts and the only * runtime cost is the import being parsed. * * UX * ─── * Fixed bottom-left, semi-transparent dark panel with mono-font * readouts. A small "×" closes it for the rest of the session * (clears the URL param via history.replaceState and the * localStorage key, then reloads). A "copy" button next to the * close emits a markdown-formatted snapshot to the clipboard for * paste-into-chat diagnostics. The panel doesn't intercept canvas * pointer events outside its own box, so it never gets in the way * of an interaction. * * Performance * ────────── * Polls the Konva node every animation frame while mounted. That's * fine for a developer overlay — adds one rAF per frame on top of * Konva's own rendering, well below the budget — but it's the reason * the component is opt-in. We deliberately don't subscribe to Konva * events for the rAF poll, because we want the readouts to update * during continuous transforms, when no React state changes are * firing. (Gesture event subscriptions DO exist, separately, to * capture transform start/move/end snapshots for the "last gesture" * summary.) */ export function DebugOverlay({ element, stageContainerRef }) { // Live readouts. Updated on each rAF tick while mounted; null when // there's no element selected or no node found. const [readout, setReadout] = useState(null); const rafRef = useRef(null); // Gesture-tracking state. Captured by listeners on the Konva node's // transformstart / transform / transformend events. Refs (not // state) because we want the per-frame `transform` handler to be // allocation-light — only the React state we render from updates // when there's meaningful new info. // // Shape: // gestureRef.current = { // startedAt: Date.now() at transformstart // startState: { element snapshot, node snapshot, visual TL } // firstFrame: same shape, captured on FIRST transform event // (i.e. the very first frame after start) // lastFrame: same shape, captured on EVERY transform event // endState: captured on transformend // boundRejects: number of times constrainTransform returned // oldBox during this gesture (i.e. clamped) // boundCalls: total number of constrainTransform calls // } // // The overlay displays the latest completed gesture's delta plus a // running count of bound-rejects on the active gesture. That's // enough to distinguish "first-frame jump" from "clamp-induced // jump" from "transform-end round-trip jump". const gestureRef = useRef(null); const [gestureSummary, setGestureSummary] = useState(null); // Hook the active Konva node's transform events. Re-runs whenever // the selected element changes (so we re-attach to the new node). // We use a separate effect from the rAF poll so the event // subscription doesn't churn when the rAF restarts. useEffect(() => { if (!element) return undefined; const stageContainer = stageContainerRef?.current; if (!stageContainer) return undefined; // Resolve the node. If it isn't mounted yet (e.g. a freshly-added // element that hasn't hit Konva's render pass), retry on the next // frame. The retry loop is bounded by the effect's cleanup. let cancelled = false; let retryRaf = null; let attachedNode = null; const snapshot = (node) => { // Best-effort snapshot of everything we care about. Returns null // if the node has vanished (e.g. element deleted mid-gesture); // callers tolerate null. if (!node) return null; const layer = node.getLayer?.(); let clientRect = null; try { clientRect = layer ? node.getClientRect({ relativeTo: layer }) : node.getClientRect(); } catch { /* ignore */ } return { t: Date.now(), elementX: element.x ?? 0, elementY: element.y ?? 0, elementRotation: element.rotation ?? 0, nodeX: node.x(), nodeY: node.y(), nodeRotation: node.rotation(), offsetX: node.offsetX(), offsetY: node.offsetY(), visualX: clientRect?.x, visualY: clientRect?.y, visualW: clientRect?.width, visualH: clientRect?.height, }; }; const onStart = () => { gestureRef.current = { startedAt: Date.now(), startState: snapshot(attachedNode), firstFrame: null, lastFrame: null, endState: null, boundRejects: 0, boundCalls: 0, }; }; const onTransform = () => { const g = gestureRef.current; if (!g) return; const snap = snapshot(attachedNode); if (!g.firstFrame) g.firstFrame = snap; g.lastFrame = snap; }; const onEnd = () => { const g = gestureRef.current; if (!g) return; g.endState = snapshot(attachedNode); // Materialize a frozen summary for the overlay to render. The // ref-tracked gestureRef gets reset on the next gesture's start. setGestureSummary({ durationMs: g.endState ? g.endState.t - g.startedAt : null, boundCalls: g.boundCalls, boundRejects: g.boundRejects, startState: g.startState, firstFrame: g.firstFrame, endState: g.endState, }); }; const tryAttach = () => { if (cancelled) return; const stage = (() => { if (Konva && Array.isArray(Konva.stages)) { for (const s of Konva.stages) { try { if (s.container() === stageContainer) return s; } catch { /* ignore */ } } } return null; })(); const node = stage?.findOne('.' + element.id); if (!node) { retryRaf = requestAnimationFrame(tryAttach); return; } attachedNode = node; // Namespace events with .pawdbg so we can off() them cleanly // without disturbing anything else Konva or the app has // attached. node.on('transformstart.pawdbg', onStart); node.on('transform.pawdbg', onTransform); node.on('transformend.pawdbg', onEnd); // Drag events too — the same "jump" symptom could appear on // drag if the bound function clamps an early frame. node.on('dragstart.pawdbg', onStart); node.on('dragmove.pawdbg', onTransform); node.on('dragend.pawdbg', onEnd); }; tryAttach(); return () => { cancelled = true; if (retryRaf != null) cancelAnimationFrame(retryRaf); if (attachedNode) { try { attachedNode.off('transformstart.pawdbg'); attachedNode.off('transform.pawdbg'); attachedNode.off('transformend.pawdbg'); attachedNode.off('dragstart.pawdbg'); attachedNode.off('dragmove.pawdbg'); attachedNode.off('dragend.pawdbg'); } catch { /* ignore */ } } }; }, [element, stageContainerRef]); // Bound-function instrumentation. We expose a global hook the // production canvasDragBound / constrainTransform can call into IF // the debug overlay has set window.__pawDebugBound. DesignCanvas // checks the global before each bound-call increment, so the // instrumentation is a no-op when the overlay isn't mounted. // // The hook surface is intentionally minimal: a single boolean // `rejected` argument indicating whether constrainTransform // modified or reverted the user-proposed box. Per-call payloads // were tried during the original diagnostic session but added // allocation pressure to the bound hot path with little additional // diagnostic value over the count-of-clamps. If a future // investigation needs more detail it can be re-added — the // existing hook is a stable insertion point. // // Why a global rather than React context: the bound functions live // inside DesignCanvas's useCallback with an empty dep array (Konva // binds them by identity at mount time, and changing identity would // force a re-attach). Reading the latest hook ref through a global // avoids both the re-bind cost and the gymnastics of passing a ref // through props. useEffect(() => { if (typeof window === 'undefined') return undefined; window.__pawDebugBound = { noteBoundCall: (rejected) => { const g = gestureRef.current; if (!g) return; g.boundCalls += 1; if (rejected) g.boundRejects += 1; }, }; return () => { delete window.__pawDebugBound; }; }, []); useEffect(() => { let cancelled = false; // Resolve the Konva stage owning our container. Same pattern used // by TextEditAffordance and the crop handler in App.jsx. const resolveStage = () => { const container = stageContainerRef?.current; if (!container) return null; if (Konva && Array.isArray(Konva.stages)) { for (const s of Konva.stages) { try { if (s.container() === container) return s; } catch { /* ignore */ } } } const canvas = container.querySelector?.('canvas'); return canvas?._konvaNode?.getStage?.() ?? null; }; const tick = () => { if (cancelled) return; if (!element) { setReadout(null); rafRef.current = requestAnimationFrame(tick); return; } const stage = resolveStage(); const node = stage?.findOne('.' + element.id); if (!node) { // No node — but we still have an element. Element data alone // is useful; mark the node-derived fields as missing. setReadout({ elementId: element.id, element: pickElementFields(element), node: null, derived: null, }); rafRef.current = requestAnimationFrame(tick); return; } // Node-reported values. All of these come straight from Konva's // attrs — node.x() / .y() / .rotation() / .offsetX() / .offsetY() // / .scaleX() / .scaleY() / .width() / .height() are accessors. const nodeX = node.x(); const nodeY = node.y(); const nodeW = node.width?.() ?? 0; const nodeH = node.height?.() ?? 0; const rotDeg = node.rotation(); const offsetX = node.offsetX(); const offsetY = node.offsetY(); const scaleX = node.scaleX(); const scaleY = node.scaleY(); // Stage-coord visual AABB. Definitive "where is this element on // screen" — Konva computes this from the full transform chain. // // IMPORTANT: relativeTo here is the NODE'S OWN LAYER, not the // stage. The layer is offset by LAYER_OFFSET_X/Y in stage coords // (set in DesignCanvas via ``), // while node.x() reports layer-LOCAL coords. Asking for the // client rect relative to the stage would add the layer offset // back in and we'd be comparing two different coord systems. // relativeTo the layer keeps everything in the same // (~design-coord) space as node.x/y, snap bbox attrs, and // element.x/y. let clientRect = null; try { const layer = node.getLayer?.(); clientRect = layer ? node.getClientRect({ relativeTo: layer }) : node.getClientRect(); } catch { /* ignore */ } // Derived: what the CURRENT unflipReportedXY would write back // to element.x/y if a drag/transform ended right now. The // production code path is unflipReportedXY(node, width, height, // flipX, flipY); since the helper now ignores flip flags, this // simplifies to (nodeX - W/2, nodeY - H/2) using whichever width // the onTransformEnd handler reads (the freshly-computed // newWidth, not node.width()). // // For diagnostic purposes we report the SIMPLE formula's output // using element.width as the size source (matches the rotation // case which doesn't change W/H, just the rotation angle). const simpleReportedX = nodeX - (element.width ?? nodeW) / 2; const simpleReportedY = nodeY - (element.height ?? nodeH) / 2; // CORRECT formula: rotate the offset vector by the node's // rotation before subtracting. For a node with offsetX/Y at // (W/2, H/2) and rotation R, the visual top-left in stage // coords is: // visualX = nodeX - (W/2)·cos(R) + (H/2)·sin(R) // visualY = nodeY - (W/2)·sin(R) - (H/2)·cos(R) // Equivalent to: rotate the local offset point (W/2, H/2) // by R, then subtract from nodeX/Y to get where (0,0) in // local space lands. That (0,0) is the visual top-left of // an axis-aligned bbox spanning (0,0) → (W,H) in local space. // // The clientRect.x/y values above are what Konva itself says // the visual top-left is, so if our formula is right these // two should match to within float noise. Comparing them is // the single most useful check for "is the rotation-aware // formula correct?" const rad = (rotDeg * Math.PI) / 180; const cos = Math.cos(rad); const sin = Math.sin(rad); const W = element.width ?? nodeW; const H = element.height ?? nodeH; const correctReportedX = nodeX - (W / 2) * cos + (H / 2) * sin; const correctReportedY = nodeY - (W / 2) * sin - (H / 2) * cos; // Round-trip predictions: if we write {simpleReportedX} into // element.x, next render will draw the node at renderX = // element.x + W/2 = nodeX. So the node's position is // preserved — but its bbox is NOT, because the rotation pivot // moved between the old and new state. The visible top-left // jumps by (correctReportedX - simpleReportedX, ...) on the // next paint. That delta is what the user sees as "jumping." const simpleDeltaX = simpleReportedX - correctReportedX; const simpleDeltaY = simpleReportedY - correctReportedY; const predictedJumpPx = Math.hypot(simpleDeltaX, simpleDeltaY); setReadout({ elementId: element.id, element: pickElementFields(element), node: { x: nodeX, y: nodeY, width: nodeW, height: nodeH, rotation: rotDeg, offsetX, offsetY, scaleX, scaleY, }, derived: { clientRect, simpleReportedX, simpleReportedY, correctReportedX, correctReportedY, simpleDeltaX, simpleDeltaY, predictedJumpPx, }, }); rafRef.current = requestAnimationFrame(tick); }; rafRef.current = requestAnimationFrame(tick); return () => { cancelled = true; if (rafRef.current != null) cancelAnimationFrame(rafRef.current); }; }, [element, stageContainerRef]); const handleDismiss = () => { try { localStorage.removeItem('paw_debug_overlay'); } catch { /* ignore */ } try { const url = new URL(window.location.href); url.searchParams.delete('debug'); window.history.replaceState({}, '', url.toString()); } catch { /* ignore */ } // Force a reload so the App-level gate re-evaluates and unmounts // this component cleanly. Simpler than threading a callback up. window.location.reload(); }; // "Just copied" flash state for the copy button. Flips true on a // successful clipboard write, back to false ~1.2s later via a // timeout. The button reads its label from this so the user sees // immediate feedback that the click landed — clipboard writes are // invisible otherwise. Stored as state (not a ref) so the label // re-renders. const [justCopied, setJustCopied] = useState(false); const copyTimeoutRef = useRef(null); useEffect(() => () => { // Cleanup on unmount: the timeout would harmlessly fire after // unmount and setState into a stale tree if we didn't clear it. if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current); }, []); const handleCopy = async () => { // Build the markdown payload from the current readout + // gestureSummary. Both can be null (e.g. nothing selected, or no // gesture has happened yet); the formatter tolerates that and // omits sections rather than emitting empty tables. const text = formatReadoutForClipboard(readout, gestureSummary); if (!text) return; // Two-tier write: modern Clipboard API first, falling back to the // legacy execCommand path if the modern one is unavailable (older // browsers, insecure contexts, some embedded webviews). The // fallback uses a hidden textarea + selectAll + execCommand which // is the documented compat technique. Either way we flip the // "just copied" flash on success. let ok = false; try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); ok = true; } } catch { /* fall through to fallback */ } if (!ok) { try { const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.left = '-9999px'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); ok = document.execCommand('copy'); document.body.removeChild(ta); } catch { /* ignore */ } } if (ok) { setJustCopied(true); if (copyTimeoutRef.current != null) clearTimeout(copyTimeoutRef.current); copyTimeoutRef.current = setTimeout(() => setJustCopied(false), 1200); } }; // Panel styles inline. The overlay is a developer tool; not worth // adding a CSS file we'd want to delete when the bug is fixed. const panelStyle = { position: 'fixed', left: '0.5rem', bottom: '0.5rem', zIndex: 9999, background: 'rgba(15, 23, 42, 0.92)', color: '#e2e8f0', fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace', fontSize: '11px', lineHeight: 1.45, padding: '0.6rem 0.75rem', borderRadius: '8px', maxWidth: '320px', boxShadow: '0 8px 24px rgba(0, 0, 0, 0.35)', pointerEvents: 'auto', userSelect: 'text', }; const headerStyle = { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '0.4rem', fontWeight: 600, color: '#f59e0b', fontSize: '12px', }; const dismissBtnStyle = { background: 'transparent', border: 'none', color: '#94a3b8', fontSize: '14px', cursor: 'pointer', padding: 0, lineHeight: 1, }; // Copy button. Slightly larger hit target than the dismiss button // because it's the action the user reaches for most often during // a debug session, and uses a subtle pink tint when not flashing // so it reads as the primary affordance on this panel. When // `justCopied` is true, the button briefly swaps its label to // "copied!" with a green tint as confirmation. const copyBtnStyle = { background: 'transparent', border: '1px solid rgba(148, 163, 184, 0.35)', color: justCopied ? '#86efac' : '#cbd5e1', fontSize: '10px', fontWeight: 600, textTransform: 'uppercase', letterSpacing: '0.04em', cursor: 'pointer', padding: '0.15rem 0.4rem', borderRadius: '4px', lineHeight: 1.3, transition: 'color 0.15s ease, border-color 0.15s ease', borderColor: justCopied ? 'rgba(134, 239, 172, 0.55)' : 'rgba(148, 163, 184, 0.35)', }; const headerActionsStyle = { display: 'flex', alignItems: 'center', gap: '0.5rem', }; const sectionStyle = { borderTop: '1px solid rgba(148, 163, 184, 0.18)', paddingTop: '0.35rem', marginTop: '0.35rem', }; const sectionTitle = { color: '#94a3b8', fontSize: '10px', textTransform: 'uppercase', letterSpacing: '0.04em', marginBottom: '0.15rem', }; const highlightDelta = (val) => { // Color-code the delta so a glance tells you whether it's a real // discrepancy or float noise. Threshold at 0.1px — anything below // that is rounding, anything above is the bug. const abs = Math.abs(val); if (abs < 0.1) return '#94a3b8'; if (abs < 2) return '#fbbf24'; return '#f87171'; }; return (
🐛 element debug
{!readout && (
No element selected.
)} {readout && ( <>
id: {readout.elementId.slice(0, 12)}
element (state)
{readout.node && (
node (live konva)
)} {readout.derived && (
derived
{readout.derived.clientRect && ( )}
)} {gestureSummary && (
last gesture
0 ? '#fbbf24' : undefined} hint="how many times canvasDragBound/constrainTransform fired; clamped = returned oldBox or a slid box" /> {gestureSummary.startState && gestureSummary.firstFrame && ( <> )} {gestureSummary.startState && gestureSummary.endState && ( <> )}
)} )}
); } /* Helpers ───────────────────────────────────────────────────────────── */ function pickElementFields(el) { return { x: el.x ?? 0, y: el.y ?? 0, width: el.width ?? el.fontSize ?? 0, height: el.height ?? el.fontSize ?? 0, rotation: el.rotation ?? 0, flipX: !!el.flipX, flipY: !!el.flipY, }; } function fmt(n) { if (n == null || Number.isNaN(n)) return '—'; // Two decimal places — enough resolution to spot sub-pixel drift // without making numbers visually noisy. Negative zero collapses // to plain "0.00". const v = Math.round(n * 100) / 100; return Object.is(v, -0) ? '0.00' : v.toFixed(2); } function fmtDelta(after, before) { if (after == null || before == null) return '—'; const d = after - before; const sign = d > 0 ? '+' : ''; return `${sign}${fmt(d)}`; } function fmtDeltaPair(ax, bx, ay, by) { return `${fmtDelta(ax, bx)}, ${fmtDelta(ay, by)}`; } function hypotPair(ax, bx, ay, by) { if (ax == null || bx == null || ay == null || by == null) return 0; return Math.hypot(ax - bx, ay - by); } /** * Serialize the current readout + gesture summary into a markdown * payload suitable for pasting into chat (issue tracker, doc, etc.). * * Format choices * ────────────── * One markdown table per logical section, with a small header * preamble carrying timestamp + element id. Tables render cleanly * in most chat clients (this one included) and remain readable as * plain text if the renderer doesn't pick them up. * * Numbers go through the same `fmt` helper used by the on-screen * rows so the clipboard contents match what the user sees in the * panel character-for-character (two decimals, signed deltas, em- * dash for missing values). No surprise reformat between visual * and pasted data. * * Each section is independently optional — if there's no node, the * "node (live konva)" table is omitted entirely rather than rendered * with em-dashes everywhere. If no gesture has happened yet, the * "last gesture" section is omitted. The header line is always * present so a paste with just the element-state table is still * self-describing. * * Returns the empty string when there's nothing meaningful to copy * (no readout at all); the caller bails before writing to the * clipboard in that case so we don't replace whatever the user had * there before with empty text. */ function formatReadoutForClipboard(readout, gestureSummary) { if (!readout) return ''; const lines = []; const ts = new Date().toISOString().replace('T', ' ').slice(0, 19); lines.push(`### element debug — ${ts}`); lines.push(`**id**: \`${readout.elementId}\``); lines.push(''); // element (state) lines.push('**element (state)**'); lines.push(''); lines.push('| field | value |'); lines.push('|---|---|'); lines.push(`| x | ${fmt(readout.element.x)} |`); lines.push(`| y | ${fmt(readout.element.y)} |`); lines.push(`| w | ${fmt(readout.element.width)} |`); lines.push(`| h | ${fmt(readout.element.height)} |`); lines.push(`| rot° | ${fmt(readout.element.rotation)} |`); lines.push(`| flip | ${readout.element.flipX ? 'X' : '·'}${readout.element.flipY ? 'Y' : '·'} |`); lines.push(''); // node (live konva) if (readout.node) { lines.push('**node (live konva)**'); lines.push(''); lines.push('| field | value |'); lines.push('|---|---|'); lines.push(`| x | ${fmt(readout.node.x)} |`); lines.push(`| y | ${fmt(readout.node.y)} |`); lines.push(`| offset | ${fmt(readout.node.offsetX)}, ${fmt(readout.node.offsetY)} |`); lines.push(`| scale | ${fmt(readout.node.scaleX)}, ${fmt(readout.node.scaleY)} |`); lines.push(`| rot° | ${fmt(readout.node.rotation)} |`); lines.push(''); } // derived if (readout.derived) { lines.push('**derived**'); lines.push(''); lines.push('| field | value |'); lines.push('|---|---|'); if (readout.derived.clientRect) { lines.push(`| visual TL (konva) | ${fmt(readout.derived.clientRect.x)}, ${fmt(readout.derived.clientRect.y)} |`); } lines.push(`| simple formula | ${fmt(readout.derived.simpleReportedX)}, ${fmt(readout.derived.simpleReportedY)} |`); lines.push(`| rot-aware | ${fmt(readout.derived.correctReportedX)}, ${fmt(readout.derived.correctReportedY)} |`); lines.push(`| Δ x,y | ${fmt(readout.derived.simpleDeltaX)}, ${fmt(readout.derived.simpleDeltaY)} |`); lines.push(`| predicted jump | ${fmt(readout.derived.predictedJumpPx)}px |`); lines.push(''); } // last gesture if (gestureSummary) { lines.push('**last gesture**'); lines.push(''); lines.push('| field | value |'); lines.push('|---|---|'); lines.push(`| duration | ${gestureSummary.durationMs != null ? `${gestureSummary.durationMs}ms` : '—'} |`); lines.push(`| bound calls | ${gestureSummary.boundCalls} (${gestureSummary.boundRejects} clamped) |`); if (gestureSummary.startState && gestureSummary.firstFrame) { lines.push(`| first-frame Δ visual | ${fmtDeltaPair( gestureSummary.firstFrame.visualX, gestureSummary.startState.visualX, gestureSummary.firstFrame.visualY, gestureSummary.startState.visualY, )} |`); lines.push(`| first-frame Δ rot | ${fmtDelta( gestureSummary.firstFrame.nodeRotation, gestureSummary.startState.nodeRotation, )} |`); } if (gestureSummary.startState && gestureSummary.endState) { lines.push(`| total Δ visual | ${fmtDeltaPair( gestureSummary.endState.visualX, gestureSummary.startState.visualX, gestureSummary.endState.visualY, gestureSummary.startState.visualY, )} |`); lines.push(`| total Δ rot | ${fmtDelta( gestureSummary.endState.nodeRotation, gestureSummary.startState.nodeRotation, )} |`); } lines.push(''); } // Trim the trailing blank line so the payload ends without an // awkward extra newline when pasted into a tight text field. while (lines.length > 0 && lines[lines.length - 1] === '') lines.pop(); return lines.join('\n'); } function Row({ label, value, mono, hint, color }) { const rowStyle = { display: 'flex', justifyContent: 'space-between', alignItems: 'baseline', gap: '0.5rem', }; const labelStyle = { color: '#94a3b8', fontSize: '10.5px', }; const valueStyle = { fontFamily: mono ? 'inherit' : undefined, color: color ?? '#e2e8f0', fontVariantNumeric: 'tabular-nums', }; return (
{label} {typeof value === 'number' ? fmt(value) : value}
); } /** * Read the debug-flag state from URL and localStorage. Either source * is enough to enable. Called once at App mount; the result drives * conditional rendering of for the rest of the session * (a reload re-reads, so toggles take effect on next page load). * * Defensive try/catches because SSR and storage-disabled environments * (private windows in some browsers) throw on access. */ export function readDebugFlag() { try { if (typeof window !== 'undefined') { const url = new URL(window.location.href); const param = url.searchParams.get('debug'); if (param === '1' || param === 'true') { // Persist so subsequent reloads keep it on without the URL. try { localStorage.setItem('paw_debug_overlay', '1'); } catch { /* ignore */ } return true; } } } catch { /* ignore */ } try { if (typeof localStorage !== 'undefined') { return localStorage.getItem('paw_debug_overlay') === '1'; } } catch { /* ignore */ } return false; }