Chart drawing tools: trend, horizontal, rectangle + per-slab persistence#2141
Chart drawing tools: trend, horizontal, rectangle + per-slab persistence#2141dcccrypto merged 18 commits intodcccrypto:mainfrom
Conversation
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 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. |
📝 WalkthroughWalkthroughA 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. ChangesChart Drawing System
Sequence DiagramsequenceDiagram
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")
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Suggested reviewers
🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🧹 Nitpick comments (4)
app/__tests__/components/trade/ChartDrawingToolbar.test.tsx (1)
128-131: 💤 Low valueDead fallback in
getByRolechain.
getByRolethrows when the element is missing — it never returnsnull/undefined, so the??fallback is unreachable and only makes the assertion harder to read. A singlegetByRole("separator")is sufficient (the separator has an explicitrole="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
clearAllupdater doesn't need to read prev state.Since the updater ignores
currentand always returns[], the indirection adds no value over a directsetDrawings([])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 thoughschedulePersistis 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 valueThe 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 winUse
vi.spyOn/vi.stubGlobalso cleanup actually restores the originals.Lines 156–165 directly mutate
HTMLCanvasElement.prototype.getContextvia property assignment.vi.restoreAllMocks()only restores spies created viavi.spyOn; it cannot revert raw property assignments. Lines 170–171 similarly assignglobalThis.ResizeObserver = FakeResizeObserverdirectly with@ts-expect-error, sovi.unstubAllGlobals()(line 185) is a no-op for it. This is inconsistent with the file's idiomatic use ofvi.stubGlobalfor rAF/cAF (lines 176–180).Switch to
vi.spyOn(HTMLCanvasElement.prototype, "getContext")andvi.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
📒 Files selected for processing (17)
app/__tests__/components/trade/ChartDrawingOverlay.test.tsxapp/__tests__/components/trade/ChartDrawingToolbar.test.tsxapp/__tests__/hooks/useChartDrawingTool.test.tsapp/__tests__/hooks/useChartDrawings.test.tsapp/__tests__/lib/chart-canvas.test.tsapp/__tests__/lib/chart-coords.test.tsapp/__tests__/lib/chart-drawings.test.tsapp/__tests__/lib/chart-hit-test.test.tsapp/components/trade/ChartDrawingOverlay.tsxapp/components/trade/ChartDrawingToolbar.tsxapp/components/trade/TradingChart.tsxapp/hooks/useChartDrawingTool.tsapp/hooks/useChartDrawings.tsapp/lib/chart-canvas.tsapp/lib/chart-coords.tsapp/lib/chart-drawings.tsapp/lib/chart-hit-test.ts
| 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(); | ||
| }); |
There was a problem hiding this comment.
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.
| 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.
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
Drawing = trend | horizontal | rectangle, versioned storage envelope,MAX_DRAWINGS_PER_SLAB = 100cap 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./^[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.PriceConverter/TimeConverterinterfaces over the lightweight-charts series + timescale APIs.Math.trunc(ms/1000)for ms→s conversion to keep pre-epoch semantics consistent.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
🤖 Generated with Claude Code
Summary by CodeRabbit
Release Notes
New Features
Tests