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;
}