Skip to content

Chart drawing tools: trend, horizontal, rectangle + per-slab persistence#2141

Merged
dcccrypto merged 18 commits intodcccrypto:mainfrom
0x-SquidSol:feat/chart-drawings
May 6, 2026
Merged

Chart drawing tools: trend, horizontal, rectangle + per-slab persistence#2141
dcccrypto merged 18 commits intodcccrypto:mainfrom
0x-SquidSol:feat/chart-drawings

Conversation

@0x-SquidSol
Copy link
Copy Markdown
Contributor

@0x-SquidSol 0x-SquidSol commented May 6, 2026

Summary

Adds user-drawing primitives to the price chart — trend lines, horizontal lines, and rectangles — with a left-edge toolbar, pointer-mode select / delete, and a per-slab clear-all. Drawings persist in localStorage scoped per slab address.

Architecture

  • Data layer (chart-drawings.ts): discriminated union Drawing = trend | horizontal | rectangle, versioned storage envelope, MAX_DRAWINGS_PER_SLAB = 100 cap with FIFO drop-oldest, tolerant deserializer that drops malformed entries, Record<Kind, true> validation tables that fail compile-closed when a new variant is added without a matching key.
  • Persistence hook (useChartDrawings.ts): per-slab localStorage bucket keyed by Solana base58 pubkey (validated against /^[1-9A-HJ-NP-Za-km-z]{32,44}$/). Writes are debounced 250 ms trailing; flushes on slab change, unmount, and pagehide so no in-flight write is lost.
  • Coordinate transformer (chart-coords.ts): structural PriceConverter / TimeConverter interfaces over the lightweight-charts series + timescale APIs. Math.trunc(ms/1000) for ms→s conversion to keep pre-epoch semantics consistent.
  • Hit testing (chart-hit-test.ts): per-kind distance-to-segment with degenerate-segment fallback, edge-only rectangle hit-test, reverse-iteration so the top-most-drawn drawing wins.
  • Canvas helper (chart-canvas.ts): DPR-aware sizing with min-1 clamps to avoid 0×0 crashes.
  • Tool state (useChartDrawingTool.ts): session-local active tool. Intentionally NOT persisted across sessions — leaving a creation tool active across page loads silently dropped a creation anchor on the next visit's first click.
  • Overlay (ChartDrawingOverlay.tsx): transparent canvas with `pointer-events: none`. Click dispatch routes through `chart.subscribeClick` so native pan / zoom keep working. Subscription deps key only on chart refs + chartReady (subscribe-once); state changes flow via refs and an imperative redrawRef. Crosshair-move and rectangle-drag mousemove rAF-coalesce to one redraw per vsync. Canvas clips to the price-pane's candle-area bounds (top scaleMargin → 1-bottom scaleMargin) so drawings can't paint into volume / oscillator panes; clicks outside that band are rejected so phantom drawings can't anchor at extrapolated prices.
  • Toolbar (ChartDrawingToolbar.tsx): vertical bar at the chart's top-left. Hidden `md:flex` (drawing tools are desktop-only for v1) and gated on `!showEmptyOverlay`. Labelled `
    ` with `` toggles — deliberately not `role="toolbar"` because we don't implement APG roving tabindex. Clear-all uses `window.confirm` with singular / plural wording and resets the active tool to pointer afterwards.

Mobile

Touch viewports short-circuit every tool at click dispatch (creation tools have no toolbar to back out of, pointer mode has no Backspace / Delete to clean up a selection). Drawings created on desktop still render on mobile as static art.

Theme awareness

Stroke is the brand accent in both themes. Fill alpha resolves per-frame via `document.documentElement[data-theme]` — 15 % on dark, 22 % on light (the lower alpha washes out against the near-white light bg). Stroke contrast meets WCAG AA 3:1 for graphics in both themes.

Test plan

  • Unit tests for the data layer, hit-test math, coordinate transformer, canvas sizing, persistence hook, and tool hook
  • Component tests for the toolbar (ARIA contract, active-tool feedback, clear-all confirm flow) and overlay (subscription lifecycle, click dispatch per tool, keyboard priority chain, rectangle drag including stuck-state recovery via contextmenu + window blur, slab-change reset, pane-bounds clipping)
  • `tsc --noEmit` clean for the feature files
  • Full app suite passes locally (chart-drawing tests: all green)
  • Manual smoke: draw each kind on devnet BTC market, switch slab, refresh, verify per-slab persistence
  • Manual smoke: hover-preview on a slow laptop to confirm rAF coalescing keeps frames smooth
  • Manual smoke: light theme drawings legible against `#FAFAFD` bg
  • Manual smoke: rectangle drag → contextmenu / alt-tab cancels cleanly without latching pan-suppression

🤖 Generated with Claude Code

Summary by CodeRabbit

Release Notes

  • New Features

    • Added chart drawing tools: pointer, trend line, horizontal line, and rectangle for annotating charts
    • Drawing toolbar with clear all functionality to manage annotations
    • Persistent drawing storage across sessions
    • Keyboard support: Escape to cancel, Delete/Backspace to delete drawings
  • Tests

    • Added comprehensive test coverage for drawing components and utilities

0x-SquidSol and others added 18 commits May 5, 2026 13:18
Pure data layer for the drawing-tools feature — no rendering, no DOM.
Shipped first so the rendering layers (canvas overlay, toolbar, per-tool
creation flows) in subsequent commits can build against a stable contract.

- Drawing discriminated union: trend, horizontal, rectangle. Adding a
  new kind is a four-step diff (variant + isDrawing branch + render
  branch + toolbar button); the exhaustive switch + assertNever in the
  renderer catches the missing branch at compile time.
- DrawingsStorage envelope with explicit version field. Future-version
  payloads are dropped on read rather than misinterpreted; mergeDrawings
  also accepts bare-array reads defensively for forward / backward
  compatibility.
- mergeDrawings tolerantly drops malformed entries (non-objects, missing
  ids, unknown kinds, non-finite price-points) without rejecting the
  whole list. Caps at MAX_DRAWINGS_PER_SLAB on read; counts only valid
  entries against the cap so junk doesn't consume budget.
- DrawingInput uses DistributiveOmit so callers pass per-variant fields
  without supplying the id. Built-in Omit on a discriminated union
  collapses to common-keys-only via keyof.
- useChartDrawings(slabAddress) hook returns
  { drawings, addDrawing, deleteDrawing, clearAll }. Object form so
  consumers destructure only what they need. Per-slab localStorage
  scoping; rehydrates on slab change; SSR-safe; quota errors are
  swallowed without rolling back in-memory state.
- Slab address is validated against the Solana base58 pubkey shape
  before forming the storage key. Empty / crafted slab addresses
  no-op the read and write so the shared `perc:chart:drawings:` bucket
  can't be poisoned by hooks mounted before the route param resolves.

41 tests cover parse safety, envelope versioning, the cap (read AND
write), per-slab isolation, slab-change rehydration, invalid-slab
rejection, quota error handling, and per-kind add/delete shapes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Eight surgical fixes to the drawing-tool data layer + hook, surfaced by
a five-agent audit on the previous commit.

Type safety:
- VALID_KINDS Set replaced by VALID_KIND_TABLE (Record<DrawingKind,
  true>) — adding a kind to the union now forces a compile error here
  rather than silently desyncing the runtime validator from the type.
- isDrawing's switch gains an assertNever-style default so a future
  variant added without a case fails type-check at this site too.
  Combined with the renderer's switch (commit 3+), we get two
  independent compile-time failure points for the four-step diff.
- UseChartDrawingsReturn.drawings is now `readonly Drawing[]` so
  consumers can't mutate React state in place via .push() / .sort().

Hook correctness:
- generateId() moved OUT of the addDrawing state updater. React 18's
  Strict Mode and concurrent-rendering discard-and-retry path
  intentionally double-invoke updater functions to surface impurity;
  generating the uuid inside would produce two different ids per
  logical add, persist both, and leave a window where in-memory state
  and disk disagree. The persist call is also a side effect but it's
  idempotent on identical inputs so the double-invoke is wasted work,
  not a correctness bug.
- slabRef.current is now assigned synchronously DURING RENDER, not
  inside the hydration effect. Closes a narrow race where a setter
  fired between a slab-prop change and the effect commit would write
  to the OLD slab's bucket. React's "latest value ref" pattern.

Persistence:
- mergeDrawings now requires `version === DRAWINGS_STORAGE_VERSION`
  exactly. Previously a missing or string-typed version field fell
  through and was treated as v1, contradicting the docstring's
  "missing or future-version → []" contract.
- isDrawing rejects entries with __proto__ / constructor / prototype
  as own keys. JSON.parse intentionally treats these as own string
  properties, not prototype slots — but a future renderer doing
  { ...drawing, ...overrides } would re-apply __proto__ as a real
  prototype. Defusing here means every spread site stays simple.

Documentation:
- DRAWINGS_STORAGE_VERSION docstring clarifies that additive changes
  (new optional fields, new kinds) do NOT need a version bump because
  isDrawing's tolerant validation accepts them. Bumps are reserved
  for breaking changes (renaming, unit changes, restructuring).
- useChartDrawings documents the single-instance-per-slab contract.
  Two parallel `useChartDrawings(SAME_SLAB)` mounts each maintain
  independent useState; cross-tab / multi-mount sync is out of scope
  for v1.

Tests (16 new, 57 total):
- Parameterized version edges: missing field, string-typed, NaN, 0,
  -1, null all drop the list.
- Parameterized non-array drawings field: object, null, undefined,
  number, boolean all drop the list.
- Prototype-pollution defuse: __proto__ / constructor / prototype
  own keys are rejected.
- Slab-change race regression: setter fired immediately after a
  rerender lands under the NEW slab key.
- localStorage.getItem throws (Safari Private Mode) is handled.
- generateId-outside-updater pinned via call-count assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Pure transform layer between internal price/time space (ms since
epoch + price-in-quote-units) and lightweight-charts' pixel space
(CSS pixels relative to the chart canvas). Centralises the seconds-
vs-ms unit conversion in one file so the rendering layers in
subsequent commits don't have to think about it.

API:
- pricePointToPixel(series, timeScale, point) -> {x, y} | null
- pixelToPricePoint(series, timeScale, x, y) -> PricePoint | null

Both helpers accept narrow capability interfaces (PriceConverter,
TimeConverter) that lightweight-charts' real ISeriesApi and
ITimeScaleApi satisfy structurally — so the overlay (commit 3+)
passes the live chart objects in production while tests pass plain
mocks. No React, no DOM, no chart instance held by this module.

Null-handling: both helpers return null when either axis maps off-
scale, when the input contains non-finite values (NaN, Infinity),
or — for pixelToPricePoint — when the time scale hands back a
BusinessDay or string Time instead of a UTCTimestamp number. The
last case never happens for our chart but the lightweight-charts
type allows it, so we filter it here so callers never see a
malformed PricePoint.

Round-trip property: a PricePoint round-tripped through pixel space
returns the same value, modulo a Math.floor(timeMs / 1000) on the
input (lightweight-charts is second-resolution; sub-second precision
on input is dropped at the API boundary, not preserved).

22 tests cover successful conversion, ms↔s boundary conversion, off-
scale null handling per axis, non-finite input rejection, defensive
rejection of non-numeric Time returns, and round-trip identity at
second-aligned inputs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three small source changes plus six regression tests, surfaced by a
five-agent audit on the previous commit.

Source changes:
- Math.floor → Math.trunc for ms→s. Math.floor rounds toward -∞ so
  Math.floor(-1.5) === -2, shifting pre-epoch ms by a full second
  per conversion. Math.trunc rounds toward zero — the correct unit-
  conversion semantic. We don't have pre-epoch timestamps in
  production, but the cost is one character and removes a class
  of bugs.
- Widen TimeConverter.timeToCoordinate's parameter from UTCTimestamp
  to Time (UTCTimestamp | BusinessDay | string). The real
  ITimeScaleApi.timeToCoordinate accepts Time; under
  strictFunctionTypes a narrower interface parameter would block
  direct assignment of chart.timeScale() to TimeConverter at the
  consumer wiring site (commit 3+). Function parameter
  contravariance makes this a real-not-cosmetic concern.
- Document on PriceConverter that callers must pass the price-pane
  series. With v5 native panes the chart now has multiple series
  with independent price scales (RSI is pinned 0-100, MACD
  auto-fits); feeding the wrong one would silently map a $84
  click through the RSI scale. Comment costs nothing and prevents
  commit 7's horizontal-line tool from making this mistake.

New tests (11 added, 33 total):
- Math.trunc behaviour pinned for negative ms (catches future
  refactors back to floor or to (x|0) bitwise truncation).
- -Infinity rows in both non-finite parameterized tables.
- Subpixel preservation: lightweight-charts can return fractional
  coords at high zoom; a future Math.round "cleanup" would
  silently introduce drawing jitter.
- Result-shape contract pinned via Object.keys assertion on both
  helpers' return objects (toEqual matches but doesn't forbid
  extras).
- Call order between time and price converters pinned via
  mock.invocationCallOrder (silent regression risk if a side-
  effecting converter ever runs in the wrong order).
- Idempotency at second-aligned inputs across 10 round-trips —
  pins the production guarantee that subscribeClick-sourced
  anchors don't drift across redraws.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Transparent canvas layered above the chart canvas via DOM order
(below the empty-state / hover-tooltip / position-summary overlays
which sit at z-10). The overlay's dimensions track the chart
container via ResizeObserver, scale by devicePixelRatio for crisp
rendering on high-DPI screens, and re-attach to a new chart
instance whenever chartReady transitions false → true (covers real
mount/unmount, Strict Mode double-mount, and hot reload).

Renders nothing visible yet — this is the plumbing layer. The
redraw path is wired (clears the canvas on every resize / pan /
zoom / chart-rebuild) and the per-kind render branches drop in
behind a single seam in subsequent commits.

Critical contract: pointer-events stay disabled. Drawing-tool
clicks will route through chart.subscribeClick when the creation
flows ship, so lightweight-charts' native pan / zoom keep working
alongside drawing creation. Setting pointer-events: auto on the
overlay would silently kill every chart interaction.

DPR handling lives in chart-canvas.ts as a pure helper
(sizeCanvasForDpr) so the math is testable in isolation:
- Backing-store width/height scale by dpr (sharp lines on Retina)
- CSS width/height stay at logical pixels (no layout shift)
- Context transform set so draw calls work in CSS-pixel space
- Backing dimensions clamp to a minimum of 1 (ResizeObserver can
  briefly hand us 0×0 during layout transitions; drawing on a
  zero-sized canvas throws).

13 tests cover the DPR scaling math (8 cases including 1.5×, 2×,
3×, fractional displays, the 1px clamp) and the overlay
component's subscribe / unsubscribe lifecycle (subscribes only
when chartReady is true, unsubscribes on unmount with the same
handler reference, swallows unsubscribe errors when the chart
was destroyed in a parallel cleanup).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three source changes plus four regression tests, surfaced by an audit
of the overlay plumbing.

Source:
- Switch primary redraw trigger from subscribeVisibleTimeRangeChange
  to subscribeVisibleLogicalRangeChange. The time-range variant
  clamps to bar coverage and can stay frozen at the right-edge
  "future padding" zone during pan, leaving drawings visibly
  lagging. The logical-range variant is the canonical "chart needs
  to redraw" hook in v5 — it fires on every pan / zoom regardless
  of bar coverage.
- Add subscribeSizeChange alongside the range subscription. The
  chart can reflow internally when price-scale labels grow a digit
  ($99 → $100, $9999 → $10000) without changing the container's
  dimensions; ResizeObserver doesn't catch that, so the overlay
  was briefly mis-aligned with the chart canvas after such reflows.
- Cache the logical (CSS-pixel) dimensions used for the last resize
  in closure scope, and have redraw's clearRect read from the cache
  instead of recomputing canvas.width / window.devicePixelRatio.
  The recompute pattern reads a FRESH dpr per redraw but
  canvas.width was set with the dpr current at the last resize; if
  those disagree (window dragged between monitors of identical
  logical size, so DPR changes but the container doesn't, so
  ResizeObserver doesn't fire) the recompute under-clears and
  leaves ghost trails. Caching ties clearRect to the dpr-correct
  dimensions. The full DPR-listener fix (matchMedia) is a v2
  follow-up; this kills the ghost-trail symptom regardless.

Tests (4 added, 9 total):
- chartReady false → true → false → true cycle: pins balanced
  subscribe / unsubscribe per channel, with each unsubscribe
  receiving the matching subscribe handler reference (the path
  Strict Mode and hot-reload come through).
- ResizeObserver.observe() called with the container div, not
  the canvas — a refactor that passed the canvas would still pass
  every prior test while breaking alignment on real resize.
- Redraw seam behavioral assertion: ResizeObserver fire AND the
  registered range-change handler each invoke clearRect. Locks
  the seam before per-kind render branches drop in.
- getContext("2d") returning null: overlay bails cleanly with no
  throw and no subscribe. Pins the documented behavior so the
  early-return doesn't silently disappear in a future refactor.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Vertical toolbar on the chart's left edge with one button per drawing
tool (pointer + trend / horizontal / rectangle). Active tool gets the
brand-accent highlight; clicking the active non-pointer tool returns
to pointer. Hidden below the md breakpoint — drawing tools are a
desktop-only feature for v1, touch interaction patterns are a
separate body of work and out of scope.

Tool selection persists globally (perc:chart:drawing-tool, NOT per-
slab) so the user's preferred tool survives across markets and
reloads. The hook applies the same patterns the indicator and
drawings hooks settled on after their audits:
- VALID_TOOL_TABLE typed as Record<DrawingTool, true> so adding a
  new tool to the union forces a compile error here.
- Tolerant deserialisation: unknown / typo'd / missing stored
  values fall back to the pointer default rather than crashing.
- SSR-safe + quota-error swallowing without rolling back state.
- setTool reference is stable across renders.

ARIA: role="toolbar" + aria-orientation="vertical" on the bar so
screen readers announce it as a toolbar, and each tool is a
<button aria-pressed> for the toggle pattern (matches the indicator
menu). The buttons are NOT grouped under a roving-tabindex APG-
toolbar contract because we don't implement arrow-key focus
management; Tab + Enter is the keyboard contract.

Keyboard: Escape returns to the pointer tool from any other tool,
with the input-focus guard the indicator menu uses (no-op when
focus is in INPUT / TEXTAREA / contentEditable so the order form
keeps working). Listener attached on the document and torn down
on unmount.

Wired into TradingChart at top-left of the chart wrapper. Shifted
the OHLCV hover tooltip from `left-2` to `md:left-12` so it clears
the toolbar on desktop (mobile keeps `left-2` since the toolbar is
hidden there).

33 tests cover the hook (default, hydration of every kind, tolerant
rejection of 6 invalid stored values, set+persist, return-to-
pointer, quota errors, getItem-throws, setter-stable-ref) and the
toolbar (role, aria-orientation, per-tool aria-pressed, click-
inactive, click-active-non-pointer-deselects, click-active-pointer-
no-op, Escape per active tool, Escape-when-already-pointer, input-
focus guard for INPUT / TEXTAREA / contenteditable, non-Escape
keys ignored, listener cleanup on unmount, mobile-hidden classes).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Five small source changes plus two regression tests, surfaced by an
audit of the toolbar.

Source:
- Inactive icon color: text-[var(--text-dim)] → text-[var(--text-
  secondary)]. text-dim renders at ~1.4:1 (dark) and ~2.0:1 (light)
  against bg-elevated, both well below the WCAG AA 3:1 minimum for
  icon-only UI components — users couldn't see the unselected
  tools without hovering. text-secondary measures at ~4.4:1 dark
  and ~8.6:1 light, comfortably over the threshold.
- Add focus-visible:outline ring on each tool button. Tailwind's
  preflight strips the UA outline (outline:0 on :focus) and our
  buttons don't use the project's `.btn` utility classes that
  re-add focus rings, so sighted keyboard users had no focus
  indicator at all (WCAG 2.4.7 violation). The ring uses the
  accent color with a 1px offset to stay visible on both the
  default bg and the active accent-tint bg.
- Drop role="toolbar" + aria-orientation="vertical" from the
  container. The APG toolbar pattern requires roving tabindex +
  arrow-key focus management between toolbar items, which we
  don't implement. Promising the role anyway misleads assistive
  tech. Replaced with a labelled <div aria-label="Drawing tools">
  — screen readers still announce each button with its label,
  users get accurate semantics without an unfulfilled contract.
- TradingChart: shift the OHLCV hover tooltip from md:left-12 to
  md:left-14. The previous gap calculation missed the toolbar
  container's p-1 padding (8px) plus 1px border — actual gap was
  2px, not 4px. left-14 gives a real ~10px breathing space.
- TradingChart: gate the toolbar render on !showEmptyOverlay. The
  existing OHLCV tooltip already uses this pattern; clicking a
  drawing tool when there's no data on the chart is a dead
  interaction.

Tests (2 added, 18 total):
- Pin the active button's accent-tint background classes
  (bg-[var(--accent)]/10 + text-[var(--accent)]). aria-pressed
  alone doesn't render visible feedback — a refactor that
  dropped the active className branch would still pass the
  aria tests but kill the visual cue.
- Pin the title attribute on each tool button. title is the
  hover-discoverability path for sighted mouse users (icons
  are abstract); aria-label tests don't catch its removal.

Plus updates to two existing tests so they query by aria-label
instead of role="toolbar" (the role no longer exists), and a
new pin asserting role and aria-orientation are NOT present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The first user-interactive piece of the drawing-tools feature. With
pointer mode active, clicking a drawing selects it (highlighted with
a thicker stroke + accent dots at each anchor point); clicking empty
space deselects; Delete or Backspace removes the selected drawing.
The overlay also renders ALL drawings unconditionally — trend lines,
horizontal lines, and rectangles — so anything stored in the
persistence layer paints regardless of which tool is active.

Hit-testing (chart-hit-test.ts):
- Pure point-to-segment distance helper (with degenerate-segment
  guard) underlies the trend and rectangle hit-tests.
- Trend line: distance from click to the segment between p1 and p2.
- Horizontal line: vertical distance only (line spans full x range).
- Rectangle: distance to any of the four EDGES, not inside-fill —
  clicking inside the rect should not select it (users want to
  interact with the chart visible through the rectangle).
- Threshold: 5 CSS pixels.
- findHitDrawingId iterates in REVERSE array order so the most
  recently drawn drawing wins on overlap, matching user expectation.
- Off-scale drawings (endpoints null from pricePointToPixel) are
  treated as not hittable.

Overlay rewrite:
- State architecture: the main effect (subscriptions + canvas setup)
  keys only on [chartRef, containerRef, seriesRef, chartReady] so it
  doesn't re-fire on every drawings / selectedId / tool change. A
  sibling effect drives redraws on data change without re-subscribing,
  and event handlers read latest state via stateRef. The redraw
  closure is published to redrawRef so the data-change effect can
  call it imperatively.
- selectedId is overlay-local useState (not persisted); resets on
  slab change, on tool change, and on the selected drawing being
  removed from the list.
- Click dispatch via chart.subscribeClick. Pointer mode hit-tests;
  other tools no-op for now (creation flows in subsequent commits).
  Defends against missing param.point and null seriesRef.
- Render: per-kind branches for trend (line + anchor dots when
  selected), horizontal (line spans full width, no dots — entire
  line is the anchor), and rectangle (15% accent fill + bordered
  outline + corner dots when selected). Hardcoded brand accent
  color (#9945FF) per plan; per-drawing colours are out of scope
  for v1.
- Keyboard handler: Escape priority chain (clear selection > reset
  tool to pointer > no-op) and Delete/Backspace (remove selected
  drawing). Both guarded against firing while focus is in
  INPUT/TEXTAREA/contentEditable. Backspace preventDefault prevents
  browser back-navigation.

Toolbar: removed the previous Escape handler. Owning Escape in the
toolbar would race with the overlay's priority chain on the same
keystroke; the overlay is the natural owner because it's the only
place that can sequence the "cancel selection first, then reset
tool" priority order.

TradingChart: threads drawings + deleteDrawing from the existing
useChartDrawings hook, plus seriesRef + slabAddress, into the
overlay's expanded prop surface.

77 tests added across the touched suites:
- 31 hit-test math tests covering distance-to-segment edge cases,
  per-kind hit / miss / threshold / off-scale, REVERSE-order tie
  breaking, and rectangle corner-order normalisation.
- 26 overlay tests covering subscribe / unsubscribe lifecycle for
  three channels (range + size + click), redraw on each trigger,
  click dispatch (tool gating, no-point, null-series), keyboard
  (Delete + Backspace + input-focus guard, Escape priority chain,
  non-handled keys, listener cleanup), and selection reset on
  slab change / tool change / external removal.
- 6 toolbar tests trimmed (Escape ownership moved to the overlay).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two small source changes plus three regression tests, surfaced by an
audit of the pointer tool.

Source:
- Selected rectangle now renders anchor dots at all four corners,
  not just the original p1 / p2. Hit-testing already treats every
  edge as grabbable, so the visible affordance should match. Two
  dots implied "only these are interactive" (untrue — drag-edit is
  v2; in v1 the dots are pure selection feedback). Four dots are
  visually consistent with "this is selected" without making a
  false promise about per-corner interaction.
- Delete key now calls e.preventDefault() symmetrically with
  Backspace. Backspace was already preventing browser back-nav;
  some Firefox configurations (and screen-reader virtual cursors)
  bind Delete to navigation or "delete element" actions too.
  Symmetric prevention costs nothing and keeps a deleted-drawing
  keystroke from leaking into the browser.

Tests (3 added, 29 total):
- Backspace inside a TEXTAREA does not delete a selected drawing.
  Mirrors the existing INPUT guard test. The order form / notes
  UI commonly uses textareas; a refactor that dropped TEXTAREA
  from the predicate would silently delete drawings while users
  edit text, and the existing INPUT-only test wouldn't catch it.
- Clicking empty space deselects a previously-selected drawing.
  Pins the contract that setSelectedId(hitId) is called even when
  hitId is null. A refactor like `if (hitId) setSelectedId(hitId)`
  would skip the null-set and leave the previous selection stuck;
  Backspace would keep deleting whatever was selected long after
  the user clicked away. Now caught.
- Clicking a different drawing switches the selection. Two
  horizontals at different prices, click first → select; click
  second → switch; Delete removes the second. Locks the
  end-to-end click-then-delete-second-thing path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two click-driven creation flows. With horizontal mode active, a
single click commits a horizontal-line drawing at the clicked
price level. With trend mode active, the first click locks in p1
(rendered as an accent dot); on every cursor move, a faded dashed
preview line follows from p1 to the cursor position; the second
click commits the trend with {p1, cursor} as endpoints. Both tools
stay active after committing — TradingView convention; the user
keeps drawing until they explicitly switch tools or press Escape.

Overlay state additions:
- pendingP1: useState for the trend tool's locked-in first anchor.
  Lives in state (not ref) so the keyboard handler's Escape
  priority chain can read it.
- previewP2Ref: useRef for the live preview anchor. Not state —
  it changes at ~60 fps during cursor hover, and putting it in
  state would force a render per move; instead the crosshair
  handler updates the ref and calls redraw imperatively.

Click handler now dispatches per tool with an exhaustive switch:
- pointer → hit-test + select / deselect (existing).
- horizontal → coordinateToPrice + addDrawing.
- trend → if !pendingP1 set p1, else addDrawing + reset.
- rectangle → no-op (drag-driven, lands separately).

Crosshair-move handler subscribes alongside the existing range
and size channels. Only fires the preview update path when tool
is "trend" and pendingP1 is set; everything else short-circuits
to keep the per-frame cost near zero.

Escape priority chain extended (highest first):
1. Cancel pendingP1 (keep tool — half-drawn anchor cancels first)
2. Clear selectedId
3. Reset tool to pointer
4. No-op
The existing slab/tool-change reset effect now also clears
pendingP1 + previewP2Ref so switching markets or tools mid-trend
doesn't leave a stale anchor.

renderTrendPreview: faded dashed line from p1 to previewP2 plus a
solid anchor dot at p1. Uses ctx.save/restore so the line-dash
and globalAlpha don't leak into other render branches.

11 new tests cover:
- horizontal: commit on click, no-op on null projection, stays in
  mode after commit (multi-commit verified).
- trend: click 1 doesn't commit, click 2 does with correct p1/p2
  coords (ms time + price), tool stays in mode for back-to-back
  trends, Escape during pending cancels without changing tool,
  Escape with no pending falls through to tool reset, slab change
  cancels pending, tool change cancels pending.
- rectangle: click is intentionally NOT a trigger (drag lands
  separately).
- selection: non-pointer-tool click doesn't select existing
  drawings (clicks dispatch to creation flow instead).

Subscription lifecycle test updated to assert subscribe+unsubscribe
balance across the new fourth channel (subscribeCrosshairMove).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…pass

Four small source changes plus seven regression tests, surfaced by
an audit of the trend + horizontal creation flows.

Source:
- Route addDrawing through a ref instead of putting it in the main
  effect's deps array. The hook returns a useCallback-stable
  reference today, but parking the callback in deps would make the
  entire chart-subscription stack load-bearing on a hook two layers
  up — any future refactor that broke that memoization would
  silently tear down + re-attach all four chart subscriptions on
  every parent render. The ref preserves the architecture's
  "subscribe once" contract regardless of upstream identity.
- Add `default: assertNever(tool)` to the click-dispatch switch.
  The renderer already had this; the click path didn't. A future
  DrawingTool kind added to the union without a case here will now
  fail to type-check rather than silently no-op the click.
- Reject zero-length trends. A double-click at the same pixel was
  committing a degenerate trend with p1 === p2 — hit-tests as a
  single dot via the distance-to-point fallback, but renders
  visually broken and pollutes persisted drawings. Reject before
  addDrawing; pendingP1 stays set so the user can move and try
  again. Same-time same-price equality is exact because both
  inputs went through the same Math.trunc(ms/1000) boundary in
  pixelToPricePoint.
- Mobile guard at the click dispatcher: when the viewport matches
  `(max-width: 767px)`, the handler short-circuits creation tools
  back to a no-op (pointer mode stays available — passive read).
  Real trap before this fix: the toolbar is hidden below md, so a
  desktop session can leave a creation tool persisted globally;
  mobile users would land on the chart and every tap would drop a
  horizontal / commit a trend with no escape route (no toolbar, no
  Escape key on most soft keyboards, no Backspace). matchMedia is
  read fresh per click so a viewport resize takes effect immediately.

Tests (7 added, 47 total):
- Trend: a click with no point info during pendingP1 (axis-gutter
  click) preserves pendingP1; subsequent valid click commits with
  the original p1.
- Trend: an off-scale click 2 (null projection) preserves
  pendingP1; subsequent valid click commits with the original p1.
- Trend: zero-length click 2 (same coords as click 1) is rejected;
  pendingP1 preserved.
- Crosshair handler: no redraw when tool !== trend.
- Crosshair handler: no redraw when pendingP1 is null.
- Crosshair handler: redraws when tool === trend AND pendingP1 is
  set (live preview path).
- Crosshair handler: clears the preview ref and redraws when the
  cursor leaves the chart (param.point undefined).

The crosshair-handler test block previously had ZERO behavioral
coverage — the captured handler was registered but never invoked.
The four new tests across the preview path lock in:
- the tool-gate guard
- the pendingP1-gate guard
- the imperative redraw call after each cursor update
- the cleanup branch when the cursor leaves the chart

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drag-to-create flow for the rectangle tool. Mouse-down inside the
chart locks pendingP1, mouse-move tracks the opposite corner with a
faded dashed preview rectangle, mouse-up commits via addDrawing.
Tool stays active after commit (matches the trend / horizontal
TradingView-convention pattern).

Event sourcing:
- mouse-down attaches to chart.chartElement() (the chart's own DOM
  container — only surface above the canvas with pointer-events).
  chart.subscribeClick can't power this because clicks fire on
  mouse-down + up at the same place; drag needs raw mouse-down.
- mouse-move and mouse-up attach to document so the user can drag
  out past the chart edges and release without losing the gesture.
- The mouse-down effect is keyed on [tool === "rectangle",
  chartReady]; the move/up + pan-suppress effect is keyed on
  [tool === "rectangle", pendingP1, chartReady]. Cleanup on each
  flow runs whether the drag commits, cancels via Escape, gets
  swept by a slab/tool change, or the chart unmounts.

Pan suppression during drag: while pendingP1 is set in rectangle
mode, chart.applyOptions({ handleScroll: false, handleScale: false })
disables the chart's built-in pan/zoom so a horizontal drag doesn't
double as a chart pan. The cleanup branch ALWAYS restores
{ handleScroll: true, handleScale: true } regardless of how the
drag ended — the tear-down is symmetric.

Min-size guard: a drag of < 10 CSS pixels in BOTH dimensions is
treated as click jitter (a user trying to click instead of drag)
and rejected. Either dimension ≥ 10 commits — wide-thin rectangles
and tall-thin rectangles are valid. This avoids accidental tiny
rects from noise on a fresh-mouse double-click.

Mobile guard: the mousedown handler reads matchMedia and short-
circuits below the md breakpoint. Drag-create on touch is its own
UX problem (gesture conflicts with chart pan, no clear cancel
affordance) — defer to v2 alongside the rest of mobile drawing.

Off-scale cancel: if the mouse-up coordinate projects null
(coordinateToPrice or timeToCoordinate fails for the cursor's
position), the drag cancels rather than committing a partially-
defined rectangle. Same defensive treatment the trend tool uses.

Render: a new renderRectanglePreview branch dispatches off
stateRef.current.tool. Trend gets a dashed line + p1 anchor dot;
rectangle gets a dashed outline + 15%-alpha fill (matching the
committed rectangle's visual). Both wrap their state mutations in
ctx.save / restore so transparency and dash patterns don't leak
into the next render frame.

Escape mid-drag flows through the existing keyboard handler's
priority chain (pendingP1 → cancel; tool stays in rectangle).
The pan-suppress effect's cleanup runs naturally as pendingP1
transitions back to null, restoring chart pan.

10 new tests (57 total) cover:
- click via subscribeClick is a no-op (rectangle uses raw mouse).
- a normal drag commits with correct {p1, p2}.
- a tiny drag (<10×10) is rejected.
- a wide-thin drag commits.
- right-click is ignored (button !== 0).
- Escape mid-drag cancels the pending anchor without changing tool.
- chart pan is suppressed during drag and restored on commit.
- chart pan is restored on Escape cancel.
- tool stays in rectangle mode (back-to-back drags).
- mobile viewport short-circuits the mousedown handler.
- mousedown listener is NOT attached when tool is not rectangle.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ebuild safety, off-scale guards

Four source fixes plus five regression tests covering the rectangle
drag's interaction with chart re-init, off-scale projections, and
the chart's own pan/zoom configuration.

Source:
- Pan suppression now snapshots chart.options() before disabling
  and restores from that snapshot on cleanup. The previous code
  wrote coarse `{ handleScroll: true, handleScale: true }` booleans
  on restore — currently safe because TradingChart's chart-init
  happens to set every sub-flag true, but a future caller that
  customised (e.g., disabled vertTouchDrag) would have their
  setting silently re-enabled after every rectangle drag.
  Snapshotting the parent's actual configuration preserves the
  shape regardless of object-form vs boolean.
- Wrap the disable-side applyOptions in try/catch, symmetric with
  the cleanup's existing try/catch. A chart torn down between
  effect setup and the disable call would otherwise propagate the
  exception out of the React render.
- Clear pendingP1 + previewP2Ref when chartReady transitions
  (added chartReady to the deps of the existing slab/tool reset
  effect). Without this, a chart re-init mid-drag (Strict Mode
  double-mount in dev, hot reload, hypothetical future code path)
  leaves pendingP1 in component state with coords projected
  against the OLD chart's logical range; the new chart's
  coordinate system can differ, producing a geometrically wrong
  rect on commit. Cheap defensive sweep.
- Treat `pricePointToPixel(pendingP1) === null` as a cancel
  rather than committing without the size check. Real branch in
  the previous code: if the original anchor scrolled off-scale
  during the drag, the if-guard around the size check was a
  silent permit for any-size commit. The committed rectangle
  could then be a 1×1 pixel zombie that never enters the
  selectable hit-test threshold. Now: cancel cleanly.

Tests (5 added, 62 total):
- mousemove during drag triggers a redraw (preview path is wired
  — pin via clearRect count delta).
- mouseup that projects null cancels rather than committing.
- mouseup with null seriesRef cancels rather than throwing.
- tool change mid-drag cancels the pending anchor AND restores
  the original pan/zoom snapshot.
- slab change mid-drag cancels the pending anchor without commit.
- Existing pan-suppress tests updated to assert the SNAPSHOT is
  restored (object-form), not boolean true. The fake chart now
  exposes options() returning granular { mouseWheel, pressedMouseMove,
  ... } shapes so a regression to coarse boolean restore would
  fail the assertion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
A drag in flight (pendingP1 set, pan suppressed, document listeners
attached) can fail to receive the mouseup that would normally end it.
Two real-world causes — both leave the user with a frozen dashed
preview, the chart's pan locked off, and no obvious recovery path:

- Right-click during the drag opens the OS context menu. Firefox
  skips the synthesizing mouseup entirely; Chrome's behaviour is
  inconsistent. Either way the drag never ends cleanly.
- Window / tab blur (alt-tab, OS notification stealing focus, user
  drags into another browser window). The mouseup fires on a
  different document our listener can't observe.

Both now route through a shared `cancel()` helper inside the drag
effect: drop pendingP1 + previewP2Ref. The existing cleanup-on-deps
machinery does the rest — pan snapshot is restored, document
listeners are removed, the canvas redraws without the preview.
Escape was already a recovery path; these add the two cases the
user wouldn't intuit Escape for.

Both new listeners are scoped to the drag effect: only attached
while pendingP1 is set in rectangle mode, removed on every
termination path. No idle-time leak.

Tests (2 added, 64 total):
- Right-click (contextmenu) during drag cancels: pan restored,
  subsequent mouseup does not commit.
- Window blur during drag cancels: pan restored, subsequent
  mouseup does not commit. The blur dispatch is wrapped in
  act() so the state update flushes before the assertion (window
  events aren't auto-wrapped by RTL's fireEvent).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Trash-icon button at the bottom of the drawing toolbar, separated
from the creation tools by a horizontal divider. Disabled when
the active slab has no drawings; enabled otherwise. Click prompts
via window.confirm with the drawing count (singular / plural
wording: "1 drawing" vs "5 drawings"); on confirm, wipes the
slab's drawings and resets the active tool to pointer; on cancel,
no-ops.

Why pointer-after-clear: the user just signalled "I'm done with
everything I drew." Staying in (say) trend mode would invite an
immediate accidental new drawing on the next chart click. Pointer
is the safe default for whatever's next.

Why a separator: visually divides the four creation tools from a
destructive action. The destructive button picks up a red
hover-color hint on enabled state — different semantic class
from the accent-tinted active tools, so a user scanning the bar
doesn't reach for it accidentally while looking for a creation
tool.

Why window.confirm: blocking, ugly, but free. A custom modal
would need design + animation + focus-trap infrastructure we
don't have for one button. Plan documents this as the v1
trade-off; a custom dialog can replace it as polish without
changing the toolbar contract.

Disabled state still focuses (tabindex stays at default) so
keyboard users can find the button and learn it exists for when
they DO have drawings to clear. Cursor-not-allowed + 40% opacity
communicate the disabled state visually.

The defensive `if (drawingCount === 0) return` inside the click
handler is a belt + suspenders guard: HTML's `disabled` attribute
suppresses click events at the browser level, but a programmatic
fireEvent.click bypasses that. Skipping the confirm prompt when
nothing is to be cleared keeps the test contract clean.

Tests (7 added, 18 total in the toolbar suite):
- separator + clear-all button render.
- disabled when count is 0.
- enabled when count > 0.
- programmatic click on disabled button does NOT prompt or call
  clearAll / setTool.
- prompt fires with the count (and "drawings" plural wording).
- prompt uses "drawing" singular when count === 1.
- cancelled prompt does NOT call clearAll or setTool.
- confirmed prompt calls clearAll AND resets tool to pointer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drawing canvas spans the full chart container, but the price-pane
series' coordinateToPrice extrapolates past its scale margins —
without clipping, a horizontal at $84 paints into the RSI/MACD
pane below, and a click in the volume sub-region creates a phantom
drawing at an invisible extrapolated price. Compute price-pane
bounds from chart.panes()[0].getHeight() and the right scale's
scaleMargins; clip the canvas to that band on render and reject
clicks/mousedowns whose y falls outside it.

Empty-state overlay was hiding the toolbar but not the drawings,
ghosting persisted purple shapes through the 91%-alpha backdrop
with no UI to clear them. Gate the overlay on !showEmptyOverlay
alongside the toolbar.

Tool selection was persisted to localStorage globally — a returning
user who left in trend mode silently dropped a trend anchor on
their next visit's first click. Tool is now session-local; drawings
still persist per-slab. Slab change also resets to pointer so
arriving on a new market with a creation tool active no longer
traps the user.

Mobile select-but-can't-delete dead-end: pointer-mode taps were
selecting drawings but mobile soft keyboards don't deliver
Backspace/Delete to the document. Extend the mobile guard to all
tools including pointer — drawings stay visible as static art on
touch viewports.

Light-theme contrast: 15% accent fill over near-white #FAFAFD bg
read as a barely-visible lavender wash. Resolve fill alpha at
draw time via data-theme; bump to 22% in light mode (stroke
contrast unchanged at ~5.5:1, clears WCAG AA 3:1 graphics).

setTool routed through a ref so the slab/chart-init reset effect
doesn't re-fire on every parent rerender. Tests updated where
mount-time setTool("pointer") seed call now precedes the
post-mount assertions.
Crosshair-move during a half-drawn trend hover and rectangle-drag
mousemove both fired a full redraw per event. lightweight-charts
runs crosshair at ~60 Hz on standard mice; raw mousemove fires
faster than vsync on high-poll-rate gaming mice (1000 Hz) and
240 Hz trackpads. At 100 drawings, that's ~24k canvas ops/sec
during hover — a real frame-budget hit on lower-end laptops.
Coalesce both paths to one redraw per frame via
requestAnimationFrame: each event stores latest pointer state,
the first event of a frame schedules an rAF that flushes the
final state and redraws once.

Schedule uses a separate boolean flag from the rAF id so a
synchronous rAF (used in tests) — which runs the callback inline
and returns the id afterwards — doesn't leave a stale id stored
under an "id == 0 means free" convention. Tests stub rAF
synchronously to keep assertions inline; production uses the real
rAF (one redraw per vsync).

useChartDrawings persistence was a synchronous setItem on every
add/delete/clearAll. setItem can block 5–50 ms on contended
storage backends — a burst of mutations would stack hitches on
the main thread. Switch to a 250 ms trailing debounce that
collapses bursts to one trailing setItem; flush on pagehide,
unmount, and slab change so no in-flight write is lost. The
hydration effect's cleanup is now registered unconditionally
(previously the early-return when localStorage was empty skipped
cleanup registration), which is what guarantees the slab-change
flush actually fires.

Tests use vi.useFakeTimers + a flushPersist() helper to advance
the trailing fire when asserting localStorage state.
@0x-SquidSol 0x-SquidSol requested a review from dcccrypto as a code owner May 6, 2026 00:22
@vercel
Copy link
Copy Markdown

vercel Bot commented May 6, 2026

@0x-SquidSol is attempting to deploy a commit to the Khubair Nasir's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 6, 2026

📝 Walkthrough

Walkthrough

A new chart drawing system is introduced comprising data types, utilities, React components, and hooks to enable users to annotate trading charts with trend lines, horizontal levels, and rectangles. The system includes persistent per-slab storage, hit-test detection, and comprehensive test coverage across all layers.

Changes

Chart Drawing System

Layer / File(s) Summary
Data & Type Layer
app/lib/chart-drawings.ts
Defines Drawing, DrawingKind ("trend", "horizontal", "rectangle"), and PricePoint types; exports validators isDrawing and mergeDrawings to handle versioned envelope storage and defend against prototype pollution.
Coordinate & Geometry Layer
app/lib/chart-coords.ts, app/lib/chart-canvas.ts, app/lib/chart-hit-test.ts
chart-coords.ts converts between internal ms/price space and lightweight-charts pixel coordinates via PriceConverter and TimeConverter interfaces; chart-canvas.ts provides sizeCanvasForDpr for DPR-aware canvas setup; chart-hit-test.ts implements distanceToSegment and shape-specific hit-test functions (hitTestTrend, hitTestHorizontal, hitTestRectangle) plus findHitDrawingId for overlap resolution.
UI Components
app/components/trade/ChartDrawingOverlay.tsx, app/components/trade/ChartDrawingToolbar.tsx
ChartDrawingOverlay renders a canvas overlay for interactive drawing, manages tool state (pointer/trend/horizontal/rectangle), handles click/drag/keyboard interactions, and subscribes to chart events (range, size, click, crosshair). ChartDrawingToolbar provides a vertical toolbar to switch tools and a clear-all action with user confirmation.
State Management Hooks
app/hooks/useChartDrawingTool.ts, app/hooks/useChartDrawings.ts
useChartDrawingTool maintains ephemeral in-memory tool selection. useChartDrawings manages per-slab drawing CRUD with debounced localStorage persistence, hydration on valid slab addresses, and pagehide flush to prevent data loss.
Integration
app/components/trade/TradingChart.tsx
Wires ChartDrawingOverlay, ChartDrawingToolbar, and both hooks into the main trading chart; renders overlays when chart has data and adjusts tooltip positioning for toolbar space.
Tests
app/__tests__/components/trade/ChartDrawing*.test.tsx, app/__tests__/hooks/useChart*.test.ts, app/__tests__/lib/chart-*.test.ts
Comprehensive test suites covering component rendering, interaction lifecycle, hook state management, persistence semantics, coordinate transformations, geometry/hit-test logic, and edge cases (off-scale, degenerate segments, prototype pollution, localStorage errors).

Sequence Diagram

sequenceDiagram
    participant User
    participant Toolbar as ChartDrawingToolbar
    participant Overlay as ChartDrawingOverlay
    participant Chart as Lightweight Charts
    participant Storage as localStorage

    User->>Toolbar: Select "trend" tool
    Toolbar->>Overlay: setTool("trend")
    activate Overlay
    User->>Chart: Click point 1
    Chart->>Overlay: subscribeClick triggered
    Overlay->>Overlay: pendingP1 = pixel coordinate
    Overlay->>Chart: Subscribe to crosshairMove
    User->>Chart: Move cursor (crosshair preview)
    Chart->>Overlay: subscribeCrosshairMove
    Overlay->>Overlay: previewP2 = live coordinate
    Overlay->>Overlay: redraw trend preview
    User->>Chart: Click point 2
    Chart->>Overlay: subscribeClick triggered
    Overlay->>Overlay: Create Drawing from p1, p2
    Overlay->>Overlay: addDrawing(input)
    Overlay->>Storage: Schedule persist (debounced)
    deactivate Overlay
    Overlay->>Toolbar: Update drawingCount
    User->>Toolbar: Click "Clear all drawings"
    Toolbar->>User: Confirm dialog
    User->>Toolbar: Confirm
    Toolbar->>Overlay: clearAll()
    Overlay->>Storage: Persist empty array
    Toolbar->>Toolbar: setTool("pointer")
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Suggested reviewers

  • dcccrypto

A rabbit hops through canvas bright,
Drawing trends and rects with delight,
Click and drag to mark the chart,
Each design a work of art! 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.76% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately summarizes the main additions: chart drawing tools (trend, horizontal, rectangle) and per-slab persistence. It is concise and specific enough to convey the primary change.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
app/__tests__/components/trade/ChartDrawingToolbar.test.tsx (1)

128-131: 💤 Low value

Dead fallback in getByRole chain.

getByRole throws when the element is missing — it never returns null/undefined, so the ?? fallback is unreachable and only makes the assertion harder to read. A single getByRole("separator") is sufficient (the separator has an explicit role="separator").

🧹 Proposed simplification
-      expect(
-        screen.getByRole("separator", { hidden: true }) ??
-          screen.getByRole("separator"),
-      ).toBeInTheDocument();
+      expect(screen.getByRole("separator")).toBeInTheDocument();
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/components/trade/ChartDrawingToolbar.test.tsx` around lines 128
- 131, The test uses a redundant nullish fallback with
screen.getByRole("separator") which never returns null (it throws), so remove
the fallback and simplify the assertion to a single call to
screen.getByRole("separator") in the ChartDrawingToolbar.test.tsx test; update
the assertion that currently references screen.getByRole("separator", { hidden:
true }) ?? screen.getByRole("separator") to just use
screen.getByRole("separator") (keeping the toBeInTheDocument() expectation).
app/hooks/useChartDrawings.ts (2)

257-262: 💤 Low value

clearAll updater doesn't need to read prev state.

Since the updater ignores current and always returns [], the indirection adds no value over a direct setDrawings([]) call. Persisting outside the updater also keeps the side effect out of the state-reducer (consistent with React's "updaters should be pure" guidance, even though schedulePersist is idempotent).

🧹 Proposed simplification
   const clearAll = useCallback(() => {
-    setDrawings(() => {
-      schedulePersist(slabRef.current, []);
-      return [];
-    });
+    setDrawings([]);
+    schedulePersist(slabRef.current, []);
   }, [schedulePersist]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/hooks/useChartDrawings.ts` around lines 257 - 262, The clearAll updater
uses a functional setDrawings but ignores the previous value; change clearAll to
call setDrawings([]) directly and move the side-effect
schedulePersist(slabRef.current, []) outside the state updater (call it
immediately after setDrawings), keeping the schedulePersist and slabRef
references and preserving the useCallback dependencies so the behavior remains
the same without using an updater function in clearAll.

90-98: 💤 Low value

The ref-mutation-during-render pattern is discouraged but appears intentionally guarded by targeted tests.

React's docs explicitly warn against "writing or reading a ref during rendering" for good reason — under concurrent rendering, re-runs and discards can make the timing unpredictable. The safer canonical form is useEffect(() => { slabRef.current = slabAddress; }) to sync after commit.

However, the tests include a case specifically for this: addDrawing writes to the NEW slab when called immediately after a slab change, which validates that a setter fired between the render-time ref assignment and the effect commit lands under the correct slab. The test comment shows the team understands the constraint.

That said, the tests don't appear to run under React's StrictMode or explicit concurrent rendering, which would stress-test the render-discarding behavior that makes this pattern risky. Either confirm the pattern is intentional with its trade-offs, or migrate to the safer useEffect form to eliminate this edge case entirely.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/hooks/useChartDrawings.ts` around lines 90 - 98, The current
useChartDrawings hook mutates slabRef.current during render (slabRef.current =
slabAddress) which is unsafe under concurrent rendering; change this to
synchronize after commit by removing the render-time assignment and adding a
useEffect(() => { slabRef.current = slabAddress; }, [slabAddress]) inside
useChartDrawings so the ref is updated post-commit. Update any tests that assume
immediate render-time mutation (notably the test expecting addDrawing to write
to the NEW slab immediately after changing slabAddress) to wait for the effect
to flush (e.g., using act/flush effects or invoking addDrawing after the effect)
so they reflect the new post-commit semantics. Ensure references to slabRef,
slabAddress and addDrawing in useChartDrawings are adjusted accordingly.
app/__tests__/components/trade/ChartDrawingOverlay.test.tsx (1)

156-186: ⚡ Quick win

Use vi.spyOn / vi.stubGlobal so cleanup actually restores the originals.

Lines 156–165 directly mutate HTMLCanvasElement.prototype.getContext via property assignment. vi.restoreAllMocks() only restores spies created via vi.spyOn; it cannot revert raw property assignments. Lines 170–171 similarly assign globalThis.ResizeObserver = FakeResizeObserver directly with @ts-expect-error, so vi.unstubAllGlobals() (line 185) is a no-op for it. This is inconsistent with the file's idiomatic use of vi.stubGlobal for rAF/cAF (lines 176–180).

Switch to vi.spyOn(HTMLCanvasElement.prototype, "getContext") and vi.stubGlobal("ResizeObserver", ...) to ensure proper cleanup, drop the @ts-expect-error, and align with Vitest patterns.

♻️ Proposed cleanup
 function stubCanvasContext() {
   /* ...ctxStub setup... */
-  HTMLCanvasElement.prototype.getContext = vi
-    .fn()
-    .mockReturnValue(ctxStub) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+  vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(
+    ctxStub as unknown as RenderingContext,
+  );
   return { setTransform, clearRect, beginPath, moveTo, lineTo, stroke, fillRect, strokeRect, arc, save, restore };
 }

 function stubNullCanvasContext() {
-  HTMLCanvasElement.prototype.getContext = vi
-    .fn()
-    .mockReturnValue(null) as unknown as typeof HTMLCanvasElement.prototype.getContext;
+  vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
 }

 beforeEach(() => {
   lastResizeObserver = null;
-  // `@ts-expect-error`: stubbing the global for tests.
-  globalThis.ResizeObserver = FakeResizeObserver;
+  vi.stubGlobal("ResizeObserver", FakeResizeObserver);
   vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/components/trade/ChartDrawingOverlay.test.tsx` around lines 156
- 186, The test directly assigns HTMLCanvasElement.prototype.getContext and
globalThis.ResizeObserver which won't be reverted by
vi.restoreAllMocks/vi.unstubAllGlobals; change the assignments to use
vi.spyOn(HTMLCanvasElement.prototype, "getContext") for the canvas context stub
and vi.stubGlobal("ResizeObserver", FakeResizeObserver) for the resize observer
(remove the `@ts-expect-error`), and keep the existing vi.stubGlobal usage for
requestAnimationFrame/cancelAnimationFrame so all globals and spies are properly
cleaned up by vi.restoreAllMocks() and vi.unstubAllGlobals().
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@app/__tests__/components/trade/ChartDrawingToolbar.test.tsx`:
- Around line 50-80: The tests for ChartDrawingToolbar render it without
required props drawingCount and clearAll; update each render call in
ChartDrawingToolbar.test.tsx (the four it blocks that call
render(<ChartDrawingToolbar ... />)) to include the missing props by spreading
the existing noopProps (i.e., render(<ChartDrawingToolbar {...noopProps}
tool="..." setTool={setTool} />)) so the ChartDrawingToolbar component receives
drawingCount and clearAll and TypeScript compilation succeeds.

---

Nitpick comments:
In `@app/__tests__/components/trade/ChartDrawingOverlay.test.tsx`:
- Around line 156-186: The test directly assigns
HTMLCanvasElement.prototype.getContext and globalThis.ResizeObserver which won't
be reverted by vi.restoreAllMocks/vi.unstubAllGlobals; change the assignments to
use vi.spyOn(HTMLCanvasElement.prototype, "getContext") for the canvas context
stub and vi.stubGlobal("ResizeObserver", FakeResizeObserver) for the resize
observer (remove the `@ts-expect-error`), and keep the existing vi.stubGlobal
usage for requestAnimationFrame/cancelAnimationFrame so all globals and spies
are properly cleaned up by vi.restoreAllMocks() and vi.unstubAllGlobals().

In `@app/__tests__/components/trade/ChartDrawingToolbar.test.tsx`:
- Around line 128-131: The test uses a redundant nullish fallback with
screen.getByRole("separator") which never returns null (it throws), so remove
the fallback and simplify the assertion to a single call to
screen.getByRole("separator") in the ChartDrawingToolbar.test.tsx test; update
the assertion that currently references screen.getByRole("separator", { hidden:
true }) ?? screen.getByRole("separator") to just use
screen.getByRole("separator") (keeping the toBeInTheDocument() expectation).

In `@app/hooks/useChartDrawings.ts`:
- Around line 257-262: The clearAll updater uses a functional setDrawings but
ignores the previous value; change clearAll to call setDrawings([]) directly and
move the side-effect schedulePersist(slabRef.current, []) outside the state
updater (call it immediately after setDrawings), keeping the schedulePersist and
slabRef references and preserving the useCallback dependencies so the behavior
remains the same without using an updater function in clearAll.
- Around line 90-98: The current useChartDrawings hook mutates slabRef.current
during render (slabRef.current = slabAddress) which is unsafe under concurrent
rendering; change this to synchronize after commit by removing the render-time
assignment and adding a useEffect(() => { slabRef.current = slabAddress; },
[slabAddress]) inside useChartDrawings so the ref is updated post-commit. Update
any tests that assume immediate render-time mutation (notably the test expecting
addDrawing to write to the NEW slab immediately after changing slabAddress) to
wait for the effect to flush (e.g., using act/flush effects or invoking
addDrawing after the effect) so they reflect the new post-commit semantics.
Ensure references to slabRef, slabAddress and addDrawing in useChartDrawings are
adjusted accordingly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: a9fb4e0b-fdd0-43d6-8ec7-50d9de5ac647

📥 Commits

Reviewing files that changed from the base of the PR and between 7df38d3 and 61f1eff.

📒 Files selected for processing (17)
  • app/__tests__/components/trade/ChartDrawingOverlay.test.tsx
  • app/__tests__/components/trade/ChartDrawingToolbar.test.tsx
  • app/__tests__/hooks/useChartDrawingTool.test.ts
  • app/__tests__/hooks/useChartDrawings.test.ts
  • app/__tests__/lib/chart-canvas.test.ts
  • app/__tests__/lib/chart-coords.test.ts
  • app/__tests__/lib/chart-drawings.test.ts
  • app/__tests__/lib/chart-hit-test.test.ts
  • app/components/trade/ChartDrawingOverlay.tsx
  • app/components/trade/ChartDrawingToolbar.tsx
  • app/components/trade/TradingChart.tsx
  • app/hooks/useChartDrawingTool.ts
  • app/hooks/useChartDrawings.ts
  • app/lib/chart-canvas.ts
  • app/lib/chart-coords.ts
  • app/lib/chart-drawings.ts
  • app/lib/chart-hit-test.ts

Comment on lines +50 to +80
it("clicking an inactive tool calls setTool with that kind", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Trend line/i }));
expect(setTool).toHaveBeenCalledWith("trend");
});

it("clicking the active non-pointer tool returns to pointer", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="rectangle" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Rectangle/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});

it("clicking the active pointer tool sets pointer (no-op equivalent)", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Pointer/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});

it("does NOT register a keydown listener (Escape is owned by the overlay)", () => {
// The Escape priority chain (cancel-pending → deselect → reset-tool)
// lives in ChartDrawingOverlay so a single handler can sequence
// those states. The toolbar must not race a second handler for
// the same key.
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="trend" setTool={setTool} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(setTool).not.toHaveBeenCalled();
});
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Tests omit required props (drawingCount, clearAll).

The ChartDrawingToolbarProps interface marks drawingCount and clearAll as required (no ?). The renders at lines 52, 59, 66, and 77 only pass tool and setTool, which will fail TypeScript compilation. Spread noopProps like the other tests do.

🔧 Proposed fix
   it("clicking an inactive tool calls setTool with that kind", () => {
     const setTool = vi.fn();
-    render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
+    render(<ChartDrawingToolbar {...noopProps} tool="pointer" setTool={setTool} />);
     fireEvent.click(screen.getByRole("button", { name: /Trend line/i }));
     expect(setTool).toHaveBeenCalledWith("trend");
   });

   it("clicking the active non-pointer tool returns to pointer", () => {
     const setTool = vi.fn();
-    render(<ChartDrawingToolbar tool="rectangle" setTool={setTool} />);
+    render(<ChartDrawingToolbar {...noopProps} tool="rectangle" setTool={setTool} />);
     fireEvent.click(screen.getByRole("button", { name: /Rectangle/i }));
     expect(setTool).toHaveBeenCalledWith("pointer");
   });

   it("clicking the active pointer tool sets pointer (no-op equivalent)", () => {
     const setTool = vi.fn();
-    render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
+    render(<ChartDrawingToolbar {...noopProps} tool="pointer" setTool={setTool} />);
     fireEvent.click(screen.getByRole("button", { name: /Pointer/i }));
     expect(setTool).toHaveBeenCalledWith("pointer");
   });
   …
-    render(<ChartDrawingToolbar tool="trend" setTool={setTool} />);
+    render(<ChartDrawingToolbar {...noopProps} tool="trend" setTool={setTool} />);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
it("clicking an inactive tool calls setTool with that kind", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Trend line/i }));
expect(setTool).toHaveBeenCalledWith("trend");
});
it("clicking the active non-pointer tool returns to pointer", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="rectangle" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Rectangle/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});
it("clicking the active pointer tool sets pointer (no-op equivalent)", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Pointer/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});
it("does NOT register a keydown listener (Escape is owned by the overlay)", () => {
// The Escape priority chain (cancel-pending → deselect → reset-tool)
// lives in ChartDrawingOverlay so a single handler can sequence
// those states. The toolbar must not race a second handler for
// the same key.
const setTool = vi.fn();
render(<ChartDrawingToolbar tool="trend" setTool={setTool} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(setTool).not.toHaveBeenCalled();
});
it("clicking an inactive tool calls setTool with that kind", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar {...noopProps} tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Trend line/i }));
expect(setTool).toHaveBeenCalledWith("trend");
});
it("clicking the active non-pointer tool returns to pointer", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar {...noopProps} tool="rectangle" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Rectangle/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});
it("clicking the active pointer tool sets pointer (no-op equivalent)", () => {
const setTool = vi.fn();
render(<ChartDrawingToolbar {...noopProps} tool="pointer" setTool={setTool} />);
fireEvent.click(screen.getByRole("button", { name: /Pointer/i }));
expect(setTool).toHaveBeenCalledWith("pointer");
});
it("does NOT register a keydown listener (Escape is owned by the overlay)", () => {
// The Escape priority chain (cancel-pending → deselect → reset-tool)
// lives in ChartDrawingOverlay so a single handler can sequence
// those states. The toolbar must not race a second handler for
// the same key.
const setTool = vi.fn();
render(<ChartDrawingToolbar {...noopProps} tool="trend" setTool={setTool} />);
fireEvent.keyDown(document, { key: "Escape" });
expect(setTool).not.toHaveBeenCalled();
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@app/__tests__/components/trade/ChartDrawingToolbar.test.tsx` around lines 50
- 80, The tests for ChartDrawingToolbar render it without required props
drawingCount and clearAll; update each render call in
ChartDrawingToolbar.test.tsx (the four it blocks that call
render(<ChartDrawingToolbar ... />)) to include the missing props by spreading
the existing noopProps (i.e., render(<ChartDrawingToolbar {...noopProps}
tool="..." setTool={setTool} />)) so the ChartDrawingToolbar component receives
drawingCount and clearAll and TypeScript compilation succeeds.

@dcccrypto dcccrypto merged commit 59c3f78 into dcccrypto:main May 6, 2026
9 of 12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants