Skip to content

Declarative render shell, 8 bug fixes, accessibility, and maintainability debt - #2

Merged
fuller merged 37 commits into
mainfrom
perf/declarative-shell
Aug 3, 2026
Merged

Declarative render shell, 8 bug fixes, accessibility, and maintainability debt#2
fuller merged 37 commits into
mainfrom
perf/declarative-shell

Conversation

@fuller

@fuller fuller commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Started as a rendering refactor to match react-native-graph's shape. Reviews along the way surfaced real defects, so it became a bug-fix, accessibility and debt-clearing pass. 36 commits, tests 160 → 437.

No performance claim is made. The CPU case for the original refactor was measured and did not hold up — the whole time axis is 4.2% of a core, and a 35% improvement to its JS was undetectable on device. What this delivers is eight user-visible bug fixes, accessibility support, a large reduction in per-frame allocation, and six of the seven PLAN_MAINT items.

Bugs fixed

All pre-existing. None caught by typecheck, lint or tests.

Symptom Cause
Paused chart snaps backward ~18s on resume timeDebt accrued ~3×/sec during quiescence
Paused chart jumps forward a full minute on foreground Suspended frame loop credited 50ms for the whole absence
Chart renders nothing when the feed stalls Single-series returned before every draw call with data outside the window
showValue freezes permanently after a mode switch Three early returns never published the readout
One leaked entry per dead series id, forever seriesAlpha never pruned
Same, retaining SkPaths Per-series maps never pruned on leaving multi mode
#38fc produced a NaN colour channel 4-digit hex accepted but never expanded
LivelineTransition renders blank, silently active naming a missing child key warned nothing

Plus a scrollDx discontinuity that would have flicked the scroll layer ~35px sideways once per line-cache rebuild — 120Hz-only, invisible to every check available here.

Architecture

<Canvas><Picture/></Canvas> → a fixed four-node tree with the scroll layer under a <Group transform>. Verified in the Skia sources that this keeps the blocked-JS-thread guarantee: the tree compiles once on JS, then shared-value prop updates flow through a UI-thread mapper. The transform advances every vsync (120fps on ProMotion) while recording stays paced at ~60.

Accessibility

Previously zero — VoiceOver and TalkBack met an unlabelled blank region. Adds accessibilityLabel and testID (controls derive their ids from it), labels the icon-only mode toggle and compact series chips, and hides the frame-rate TextInput overlay from assistive tech.

Costs nothing when no screen reader runs: no interval, no state, no per-frame work. With one active, ≤1 React render/sec, comparing the formatted string so a sub-precision-jitter feed stays silent.

Per-frame allocation removed

createCanvas2D ~1,860/sec · orderbook colours ~3,000/sec · axis save()/restore() ~500 objects + ~1,000 JSI calls/sec · a 500-candle .slice() at 120Hz · controls out of the tick path · net −3/frame from the options-struct work.

Maintainability — PLAN_MAINT #2–#7 (only #1 left)

makeLayout extracted from three identical literals · per-series pruning registered beside the field declarations · shared LayoutKey · PillBar extracted, file-wide inline-styles disable removed · options structs replacing 15-parameter signatures (drawLine 15→4, engineStep 12→4) · +42 characterization tests for engineStep.

The characterization suite is the notable one: it pins EngineState values, draw-call counts and call order across all three pipelines, and the options refactor passed it unchanged — behaviour preservation verified, not asserted. 12 mutations applied, 12 caught.

It also mapped the remaining hazards for #1: rangeInited is one flag shared by all three pipelines and leaks Y-range state across mode switches; cleanup is asymmetric; drawMultiFrame takes raw dt where the others take pausedDt. All pinned as-is so the extraction changes them deliberately.

Consumer-facing

  • gesture-handler peer widened to >=2.30.0 — the v3 floor came from one unnecessary type import and broke expo-doctor for every Expo SDK 55 consumer. Verified by installing 2.30.0 and confirming the check could fail first.
  • files[] cleaned of android/ios/cpp/*.podspec — none exist.
  • README: peer table corrected (it contradicted package.json), color formats documented, prop-hoisting guidance, and why androidWarmup/opaque are not set.

Verification

typecheck · lint · 24 suites / 437 tests · yarn install --immutable · bob build.

Device: iOS simulator, Android emulator (GPU-accelerated — Line, Candles, Multi, Orderbook all correct), and a physical iPhone 16 Pro.

Not verified — read before merging

  • Scrub. Synthetic input cannot engage the RNGH pan gesture; needs a human finger. Now possible on the physical device.
  • The 120Hz shear. Prefix at 120Hz vs tail at 60Hz — sub-pixel (~0.25px on a 10s window), needs ProMotion hardware to judge.
  • Screen reader. No VoiceOver session was run against the new a11y code.
  • Android performance. Emulator GL is ANGLE-translated, debug build. Visual correctness only.

🤖 Generated with Claude Code

https://claude.ai/code/session_011N9XLUqavTstRW9dZo83YR

fuller and others added 29 commits July 30, 2026 21:02
Promotes the translate-instead-of-re-record technique from the path level
(draw/lineCache.ts) to the picture level, and extends it to the time axis,
which currently has no cache at all and re-lays out every label every frame.

Three independent scroll-layer slots (line, candles, axis), each a cached
SkPicture composited at a per-frame horizontal offset. Constrained by
drawPicture ignoring globalAlpha, so scroll layers are used only at composite
alpha 1 with a live-draw fallback otherwise — the same gate draw/index.ts
already applies to the grid picture.

Records the accepted trade-off on time-axis label fades and the scope note
that lineCache already captures most of the line-side win.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Generic cross-frame cache for a scroll layer: an SkPicture recorded once in
build-time screen coordinates and re-composited each frame at a horizontal
offset, instead of re-recorded. Promotes the technique lineCache.ts already
applies at the path level up to the picture level.

Nothing consumes this yet — no behavior change.

Key is a push-built positional array owned by the slot rather than named kFoo
fields: three consumers (line, candles, axis) have barely-overlapping key
dimensions, so named fields would force every consumer's dimensions into this
module. Split numeric/reference arrays because palette colors are strings and
formatter identity is a function reference, and merging them would make the
hot per-frame comparison polymorphic. Zero per-frame allocation — both arrays
live on the slot and are written by index.

dx is always recomputed against the build-time reference, never accumulated
(same invariant as lineCache.ts:166-175).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Agent worktrees under .claude/worktrees/ contain full copies of src/, so jest
was collecting every suite three extra times — 40 suites / 772 tests instead of
11 / 226. Git already excludes the path via .git/info/exclude; jest had no
equivalent rule, which made every verification run during parallel work
silently meaningless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Interval selection, label-key generation, overlap resolution and label-set
identity for the cached time-axis scroll layer, extracted as pure worklets with
a type-only Ctx2D import so jest can exercise them. Not wired in yet —
timeAxis.ts, draw/index.ts and engine/step.ts are untouched, so there is no
behavior change.

Overlap resolution is stride decimation on absolute time, not greedy
left-to-right. Greedy is deterministic for fixed input but NOT stable under
scroll: the chain is anchored on whichever label is currently leftmost, so the
frame a label leaves the left edge, every survivor swaps for its neighbour —
a visible flicker once per interval. The old per-label alpha tiebreak was
masking exactly this with a crossfade, and alpha is gone in the new design.
Keeping a label iff round(t/interval) % stride === 0 makes its fate depend only
on its own timestamp, so scrolling can never flip a survivor to a dropout.

Label-set identity carries interval and stride alongside count/first/last:
count+first+last alone collides for real (same span and count, different
stride) and that difference changes the picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds example build:android:release and docs/android-profiling.md, the Android
counterpart to run-and-profile's iOS CPU A/B discipline. Every command in the
doc is marked verified-on-emulator or explicitly unverified.

Main finding: dumpsys gfxinfo framestats is BLIND to this app and must not be
used as the A/B metric. Measured on a release build over 103 clean frames,
ui_record (the HWUI display-list record pass) p50 = 0.10ms — empty.
SurfaceFlinger --list shows no separate SurfaceView layer, so react-native-skia
presents an externally-updated texture: HWUI composites one quad while all
engine work happens on a thread framestats never samples. The 138ms 'total' is
the engine's own frame pacing, not cost. Recorded as a negative result with a
re-test recipe rather than deleted.

Primary metric is therefore per-thread CPU, with Perfetto for the per-frame
timeline. A rendering change should move the main thread; a delta in mqt_v_js
means the example app's workload changed instead.

Also documents three traps hit during verification: hw.gpu.enabled=no is still
persisted in the liveline_test AVD (the -gpu host launch flag overrides it, and
getprop ro.hardware.egl cannot detect the difference — only the SurfaceFlinger
GLES string can); example/android is expo-prebuild output and untracked, so it
is absent in a git worktree; and this Android 34 build emits 23 framestats
columns, so parsers must key on column name, not index.

Emulator only. Physical-hardware baseline remains open.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two changes to drawTimeAxis, both invisible on screen. Per-label alpha,
crossfades, edge fades and the alpha-based overlap tiebreak are all untouched.

1. Label text is memoized by key.

   formatTime(key / 100) is a pure function of key, so a label's text cannot
   change while its key and the formatter both stay the same. The create-labels
   loop nonetheless called the formatter for EVERY target on EVERY frame and
   then overwrote each existing label's text with a byte-identical string. At
   the ~6 labels a 30s window shows, that is ~360 formatter calls per second,
   and defaultFormatTime allocates a Date, three padStart results and a
   template string per call — several thousand short-lived allocations per
   second on the UI thread, producing text that was already correct.

   The formatter now runs only for a key seen for the first time. A formatter
   swap is caught by reference identity (same trade gridLayer.ts makes for
   kFormatValue) and re-texts live labels in place WITHOUT touching their
   alphas, so re-formatting cannot restart a label's fade-in.

2. Still-targeted labels are no longer deleted at alpha 0.

   Found by the new tests, not by reading. `targets` deliberately spans one
   interval beyond each edge, so the buffer keys sit outside the visible
   x-range where edgeAlpha returns 0. Those keys churned: created at alpha 0,
   decayed, deleted, then re-created and re-formatted on the very next frame,
   forever. That is wasted work on its own and it also defeated (1), since
   each re-creation is a genuinely unseen key. Adding `&& !isTarget` to the
   delete parks them at alpha 0 instead — one Map entry (targets is capped at
   30), invisible because the draw pass filters at alpha < 0.02.

Adds src/draw/__tests__/timeAxis.test.ts — this file had no coverage before.
8 tests pinning the memoization, the formatter-swap re-text, that a swap does
not restart fade-in, and that stationary frames after the first cost zero
formatter calls. 12 suites / 234 tests.

Not yet measured on device; this is the change the A/B will be run against.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
All four hit while measuring the timeAxis memoization on 2026-07-30.

- Relaunching resets in-app toggles. The FPS counter was ON for arm 1 (set in
  an earlier session) and OFF for every later arm, because relaunch resets
  React state and it defaults off. An always-on frame callback in one arm and
  not the others cost ~15 points and looked exactly like host drift. The arm
  screenshots are the check: `56 fps` when on, blank `fps` when off.
- Settle is ~50s, not 20. A swapped src/ makes Metro re-bundle; windows opened
  too early read 1-5%, which is the app still loading, not the app being fast.
- Log uptime at the START and END of each arm. Observed 50.7 right after a
  build/install/bundle, draining over minutes; a mid-arm climb is invisible
  when sampled once, and a moving load voids the comparison outright.
- Know the effect size before trusting whole-process CPU. It cannot resolve
  ~1-2k JS allocations/sec on this box.

That last one is now measured rather than assumed. A head-to-head bench of
drawTimeAxis (V8, 20k frames/arm, interleaved, 5 rounds) puts the memoization
at 35.1% faster on the JS half — 5.61us -> 3.64us per frame, distributions
non-overlapping. At 60fps that saves ~0.12ms/sec of JS work, roughly 0.01% of
a core on V8, which is orders of magnitude below what a CPU-time A/B can see.
The device A/B measured null (and slightly negative) for exactly that reason.

Note the bench uses a no-op ctx, so it covers only the JS half of drawTimeAxis.
The ~60-80 Skia JSI calls per frame are NOT measured by it and remain
unquantified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Targets react-native-graph's rendering shape — a declarative Skia tree with
continuous motion as a <Group transform> driven by a shared value — while
keeping the blocked-JS-thread guarantee and current visual output.

Feasibility verified in the Skia sources rather than assumed: sksg/
Container.native.ts compiles the tree once on JS and drives all shared-value
prop updates through a Reanimated mapper on the UI thread, and AnimatedProp<T>
= T | {value: T} confirms Group.transform accepts a shared value. Structural
changes are the only thing that touches JS, and this tree has none after mount.

Records the ceiling: variable-count content (axis labels, candles, particles,
orderbook) cannot enter the declarative tree without forfeiting the guarantee,
so it stays inside <Picture> nodes. This is graph-shaped, not a port.

The fill is deliberately not split — it is a semi-transparent gradient polygon,
so abutting seams at ~0.75 AA coverage, overlapping double-darkens, and a
non-AA clip split can gap at fractional dx (and dx cannot be rounded: ~0.17px
of motion per frame would judder). Only the opaque stroke splits cleanly.

States plainly that this is an architecture goal, not a performance one: it
removes about one draw call per frame and will not be measurable, against a
measured baseline where the entire time axis is 4.2% of a core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Replaces <Canvas><Picture/></Canvas> with react-native-graph's shape:

  <Canvas>
    <Group transform={scrollTransform}>
      <Picture picture={scrollPicture} />
    </Group>
    <Picture picture={screenPicture} />
  </Canvas>

Ships as a STRUCTURAL NO-OP. screenPicture is a pure rename of the old
`picture` and takes the full frame unchanged; scrollPicture is the 1x1 empty
placeholder and nothing records into it yet; scrollTransform is driven from the
frame callback but dx is always 0. Verified pixel-identical on the iOS
simulator — line, gradient fill, grid, badge, pulse ring, reference line and
axis labels all unchanged.

The tree is exactly 4 nodes with no conditionals and no .map(), and must stay
that way. sksg/Container.native.ts compiles the tree once on the JS thread and
then applies shared-value prop updates from a Reanimated mapper on the UI
thread; only a STRUCTURAL change re-enters JS via redraw(). A conditional node
here would silently cost the blocked-JS-thread guarantee, which is the whole
reason the engine exists. A comment above the JSX says so.

LivelineEngine.picture -> screenPicture is not a public API break: index.tsx
exports only Liveline, LivelineTransition and the ./types types, so the engine
interface is internal.

No test covers these two files — both pull in the native Skia binding.
Correctness here rests on typecheck plus the on-device visual check.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Additive: assembleLineTail, slot.scratch, slot.fillScratch and updateLinePaths
are behaviourally untouched, so the combined path still renders identically.

- lineScrollDx(slot, layout) — the one place toX(tRef) - xRefAtBuild is
  written, now shared by the combined path and the split tail so they cannot
  disagree. Same formula as scrollLayer.ts's scrollLayerDx.
- linePrefixPath(slot) — returns slot.prefix as-is, no copy. Confirmed safe:
  assembleLineTail does scratch.addPath(prefix) then offset() on the SCRATCH,
  so the prefix is never translated. Pinned by a test running 200 scrolling
  frames and asserting the prefix's verbs deep-equal their build-time snapshot.
- assembleLineTailStroke(...) — slot-owned tailScratch, moveTo(cutX + dx) plus
  the same two drawSplineTail cubics, in current screen coords.

The tail is re-emitted rather than sliced out of the combined path: composing
it as addPath(tail, extend) would insert a zero-length connector verb and break
verb-for-verb parity with today's output. Two extra cubicTo per frame is
cheaper than that divergence.

The fill is deliberately NOT split — it is a semi-transparent gradient polygon,
so abutting halves seam at ~0.75 AA coverage and overlapping halves
double-darken. See the design doc.

+5 tests (234 -> 239): prefix unmutated over 200 frames, tail scratch allocated
once, tail starts exactly at cutX+dx, combined path equals prefix+tail
verb-for-verb, fill deep-equals today's construction.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Stream A caught that src/draw/scrollLayer.ts already exists on this branch
(3db71f1) with the slot type, the dx formula and the alpha gate integration
needs — and that the design doc mentioned none of it, nor listed it in either
workstream's files. Integration should reuse it, not re-derive dx and alpha
handling. Also disambiguates scrollLayerDx (a pure function returning a number)
from scrollTransform (a shared value holding [{translateX}]), which earlier
wording conflated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ent traps

Four independent cleanups from the best-practices audit.

1. peerDependencies react-native-gesture-handler >=3.0.0 -> >=2.30.0.

   The v3 floor existed only because useLivelineEngine imported the type
   `LegacyComposedGesture`, which is a v3 name. That import was also just
   wrong: this hook always returns a single Gesture.Pan() and never composes,
   so `ComposedGesture | GestureType` was an over-broad union. Narrowed to
   `GestureType`, which exists in both majors.

   The floor mattered because Expo SDK 55 pins ~2.30.0, so every SDK 55
   consumer hit a peer conflict and an expo-doctor failure on install.

   Verified end-to-end, not by inspection: installed react-native-gesture-
   handler@2.30.0 at the repo root and ran the real typecheck. src/ compiles
   clean and all 239 tests pass. Confirmed the test has teeth by re-adding the
   v3-only import under that install and watching it fail with TS2724 "has no
   exported member named 'LegacyComposedGesture'". Two earlier attempts at this
   verification (tsconfig paths override, then installing only into the example
   workspace) silently kept resolving to 3.1.0 and proved nothing.

   The example app stays on 3.1.0 deliberately, so its expo-doctor warning
   remains — that is the example's pin, no longer a library constraint.

2. files[] listed android, ios, cpp, *.podspec and react-native.config.js.
   None of them exist; this is a JS-only library. npm pack still emits 255
   files / 1.3 MB, unchanged.

3. Liveline.tsx documents why `androidWarmup` and `opaque` are NOT set on
   <Canvas>. androidWarmup is a GPU->CPU pixel readback inside onDraw
   (SkiaPictureView.java) and is catastrophic on a continuously-invalidating
   view. `opaque` is what selects SurfaceView over TextureView on Android and
   would likely composite cheaper, but an opaque surface has no destination
   alpha and both drawEdgeFade and drawEmpty composite with destination-out,
   which would erase to black.

4. README documents hoisting config props. Liveline is memo'd with a shallow
   compare, and inline object/function props do more than waste a render: the
   engine mirrors config into a shared value every commit and the frame loop's
   idle detection keys off that object's identity, so a chart fed fresh props
   each render can never go idle. Placed next to the FlatList recipe, which is
   exactly where one row's tick re-renders every row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the declarative shell. The single-series line's prefix stroke is now
recorded once into scrollPicture and translated by <Group transform> instead of
being re-recorded each frame; screenPicture draws the tail stroke, the whole
fill polygon, and everything else. Multi-series and candle mode are untouched
by construction.

Fixes a defect a concurrent review found in 2e1c4d7: `scrollDx` initialised to
0 and `dx` was the literal 0, so the guard `if (scrollDx.value !== dx)` was
`0 !== 0` and the scrollTransform write was UNREACHABLE. The shared-value ->
<Group transform> binding had therefore never executed, which means that
commit's "verified pixel-identical" screenshot proved the tree renders but
proved nothing about the binding it was supposed to de-risk.

That binding is now verified positively, not assumed: forcing dx = scrollDx+40
made the prefix stroke visibly detach and slide 40px clear of the fill, which
simultaneously proves scrollPicture is populated, the transform prop fires on
the UI thread, and there is NO double-draw (no stroke was left behind on the
fill edge, so screenPicture really is drawing the tail only). Probe reverted.

The split is gated on a lineCacheHits hit computed BEFORE drawFrame, so the
picture can never be recorded from a prefix the frame's own screen pass has
already moved past — one re-record per miss, on the following frame.

Alpha gate returns 1 or 0 only, never a partial: drawPicture ignores
globalAlpha, so reveal morph, scrub dimming and degen shake all fall back to
the unchanged combined path.

Z-order note: the prefix stroke now composites below the fill rather than
above. This is a no-op through the public API — theme.ts derives
fillTop/fillBottom from the same rgb as the line, and `color?: string` is the
only colour knob, so the fill hue can never differ from the stroke hue.

Removes src/draw/timeAxisLayer.ts and its 41 tests. It had no consumer
anywhere, shipped to users via files[]/src, and the review found it carried
both a missing worklet-ordering NOTE and a label-set identity that cannot
detect a scale change — with a test asserting that defect was correct.
Recoverable from 302df29 / 82d8f6b on perf/scroll-layer-architecture.

Tests 239 -> 224 (+26 lineScrollLayer, -41 timeAxisLayer). Verified on the iOS
simulator. NOT verified: the scrub fallback path — synthetic input cannot
engage the pan gesture (documented in run-and-profile), so that needs a human.

This is an architecture change, not a speedup. It removes roughly one draw call
per frame against a measured baseline where the entire time axis is 4.2% of a
core.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Picture re-recording stays paced at ~60fps (MIN_FRAME_INTERVAL_MS), but
translating an already-recorded picture is nearly free, so the scroll layer's
<Group transform> now advances on every vsync — 120fps on ProMotion — while
recording cost is unchanged.

This is the actual payoff of putting scroll in a transform. Before this, the
dx write sat BELOW the pacing gate, so the transform was capped at 60 along
with the recording and the split had bought a draw call rather than smoother
motion. A concurrent review flagged exactly that (its finding 10) and it was
right.

The iOS side needed nothing: example/ios/.../Info.plist already carries
CADisableMinimumFrameDurationOnPhone=true (Expo default), without which iOS
hard-caps any app at 60 regardless.

dx cannot simply be recomputed on a skipped vsync — it needs `layout`, which
is computed inside engineStep — so it is linearly extrapolated from the last
two RECORDED frames. Extrapolating observed motion rather than recomputing
from windowSecs/chartW is deliberate: it keeps no copy of the engine's
time-advance rules so it cannot drift out of sync with them, and pause, window
transitions and time-debt catch-up all fall out correctly for free. Every
recorded frame overwrites with the exact value, so error cannot accumulate
past one vsync.

Guarded three ways: only when a scroll layer is actually compositing, only
when a usable rate has been observed, and only across a gap short enough for
that rate to still hold (MAX_SCROLL_EXTRAPOLATION_MS = 20). A quiescence
resume, a return from background or a JS stall leaves the transform untouched
rather than flinging it somewhere it never was.

The math lives in src/engine/scrollExtrapolate.ts rather than inline, so jest
can reach it — useLivelineEngine.ts pulls in the native Skia binding and can't
be unit-tested, and the simulator renders at 60Hz so this branch barely
executes there. A sign error would have shipped invisibly and surfaced only on
real hardware. +13 tests (224 -> 237) covering direction of travel, every
guard boundary, and a 2s 120-vs-60Hz simulation asserting divergence stays
within one frame of motion.

KNOWN CONSEQUENCE, needs real hardware to judge: the prefix now moves at 120Hz
while the tail is re-recorded at 60Hz, so on skipped vsyncs they shear by one
frame of scroll — chartW / (windowSecs * refreshHz), about 0.25px on a 10s
window and 0.08px on 30s. Sub-pixel, but a shimmer at the join rather than a
clean offset. The simulator renders at 60 and cannot show this either way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Four independent quality reviews (reuse, simplification, efficiency,
altitude). Three findings were raised by 2-3 reviewers independently; the
efficiency pass corrected the simplification pass on one, which is why item 7
is narrower than first proposed.

Deleted as dead:
- scrollLayer.ts's entire clip API — setScrollLayerClip, clipX/Y/W/H, and the
  header's ctx.clipRect/translate/drawPicture example. None of it executed:
  the sole consumer bakes the clip into the recording and composites through
  <Group transform>. The module's headline example was not how the module was
  used.
- scrollLayerDx. It and lineCache.ts's lineScrollDx had identical bodies and
  BOTH doc comments claimed to be "the ONE place" the subtraction is written.
  ScrollLayerSlot also loses tRef/xRefAtBuild — copied from the line slot, so
  a mirror that could go stale rather than a source.

Simplified:
- The scroll layer's key drops from 16 pushed dimensions to 4. Thirteen were
  copied verbatim from LineCacheSlot's own key to ask one question: has the
  prefix been rebuilt? A monotonic buildRev counter answers it, and is
  strictly MORE correct — the old key would have silently missed a fourteenth
  dimension had one been added to lineCacheHits.
- canCompositeLineScroll replaces lineScrollLayerAlpha, which returned only
  0 or 1 and encoded a transform condition (shake) as an opacity.
- The extrapolation rationale was written four times, three near-verbatim.
  Full text now lives only in scrollExtrapolate.ts.

Fixed:
- The liveness guard compared against a SECOND empty picture, so before the
  first recorded frame it read "layer is live" when nothing was compositing.
  Now a boolean on EngineState, set where the recorded frame already knows.
- scrollTransform.value = [{ translateX: dx }] allocated an array and an
  object per assignment — up to 240/sec at 120Hz on the UI thread, in a
  library that pools paints and rects to avoid exactly this. Now a 3-slot
  preallocated ring, rotate-mutate-assign, zero steady-state allocation.
  (A shared value must be reassigned to notify its mapper, so mutation alone
  is not an option.)
- assembleLineTail no longer builds the combined path when it will be
  discarded — but ONLY when splitting AND there is no fill. With fill on,
  scratch still feeds the fill polygon and is genuinely needed.

Hardened:
- MIN_FRAME_INTERVAL_MS and MAX_SCROLL_EXTRAPOLATION_MS are coupled by two
  inequalities that nothing expressed. Raising the former to 22 — a plausible
  "record at 45fps" tuning — silently reverts the scroll layer to juddering,
  with no test failing and the symptom visible only on 120Hz hardware. Both
  inequalities are now documented and asserted in engine/__tests__/
  constants.test.ts, turning a silent hardware-only regression into a red test.
- QUIESCENT_FRAME_THRESHOLD's comment claimed "~1.5s at 60fps"; it counts
  vsyncs, not recorded frames, so it is ~0.75s on ProMotion. Pre-existing, but
  this branch is the first code that depends on the distinction.

Deliberately NOT done, each for a stated reason:
- Publishing an analytic scrollDxPerMs slope from step.ts to replace
  observeScrollRate. Very likely the better design — exact rather than
  observed, and it would work on the first vsync after a resume where the
  current code freezes — but it is a design change, not a cleanup.
- Extracting makeChartLayout (touches the candle and multi-series pipelines).
- Reusable ctx in canvas2d.ts (~2200 allocs/sec, the largest single win found,
  and entirely pre-existing).
- drawTimeAxis's per-label save()/restore() (~1000 JSI calls/sec, pre-existing).

Tests 237 -> 218: removed tests for deleted code, added constants.test.ts.
Verified on the iOS simulator — rendering unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Unreleased section predated 33c23cb and 8e674cb. Adds the full-refresh
scroll transform and the coupled frame-timing constants, including the
unverified 120Hz-prefix-vs-60Hz-tail shear — the simulator renders at 60Hz and
cannot show it, so it is recorded as an open question rather than a claim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`yarn lint` was reporting 14 errors from files under .claude/worktrees/ —
full repo copies that are not in the working tree. Same trap already fixed for
jest via modulePathIgnorePatterns; git excludes the path via .git/info/exclude.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… disable

PLAN_MAINT #7. The three control bars (window pills, mode toggle, series
chips) each repeated the same bar chrome and SlidingIndicator wiring.

- <PillBar> owns the shared chrome and the indicator gate.
- useBarStyle(ws, isDark) memoizes the seven style scalars that were being
  recomputed inline on every render — and a live chart re-renders every tick.
- useChipStyle is deliberately separate: chips also branch on
  seriesToggleCompact, which the other two bars know nothing about.

The file-level `eslint-disable react-native/no-inline-styles` is now gone
entirely rather than narrowed.

Deliberately NOT unified: the series bar has no indicator (chips toggle
independently, there is no single active one), and per-button onLayout wiring
stays at the call sites because the two bars key their layout maps differently
(number secs vs string mode) — pulling that in would have needed a render-prop
API costing more coupling than the duplication did.

The <Canvas> subtree is untouched — verified, zero hunks in that region. Its
node structure must stay fixed or the blocked-JS-thread guarantee is lost.

Verified by a style-parity check across all 12 combinations of windowStyle x
theme x seriesToggleCompact, each crossed with active/inactive, hidden/visible
and multi/non-multi, flattened the way RN flattens a style array: all
identical. Not visually verified — repointing Metro would have disturbed four
concurrent agents, and screenshotting the primary checkout would have shown
unmodified code and proved nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN_MAINT #5. lineCache and candleCache declared overlapping invalidation-key
fields under inconsistent names — kMin/kMax vs kMinVal/kMaxVal for the same
layout.minVal/maxVal. Seven common fields now live in a shared LayoutKey in
pathCache.ts, which already held the precedent (CachePath, ensured).

Allocation-free property preserved and documented: the key object is allocated
once per slot in createXCacheSlot, writeLayoutKey mutates in place on the miss
branch only, and layoutKeyMatches is seven direct === compares with no array,
loop or temporary. A docblock warns against "generalizing" it into a
field-name array — this runs 60-120x/sec on the UI thread.

pathCache.ts stays Skia-free via a structural KeyedLayout subset rather than
importing ChartLayout, mirroring the CachePath precedent, so jest can still
exercise cache logic with fake path recorders.

kDataSource stays per-slot: the doc counted it among the common fields, but it
is not derivable from layout so writeLayoutKey(key, layout) cannot write it.
buildRev semantics untouched, so lineScrollLayer's contract is unchanged.

Zero test edits needed — neither cache test ever referenced a k* field by
name; they drive the public functions and assert on recorded path ops.

Follow-up: draw/gridLayer.ts has a third slot with the same shape and would
fold in cleanly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN_MAINT #3 and #2. #3 carries a real bug fix.

**The bug:** EngineState declares eight per-series Maps. `seriesAlpha` has the
same lifecycle as the four that were pruned — written per series, read in two
places — but was never pruned or cleared anywhere. A long-lived chart whose
series ids churn leaked one entry per dead id, forever.

It leaked *because* pruning was four hand-written near-identical loops, so
adding a fifth map meant remembering to write a fifth loop. Registration now
lives next to the field declarations: `perSeriesMaps(s)` returns the maps that
must be pruned together and `pruneByIds(currentIds, maps)` does it. Adding a
ninth map is one line in one place.

Audited all eight rather than assuming:
- smoothValuesScratch — .clear()-ed every multi frame, correctly excluded
  (a test asserts the exclusion).
- lastMultiStashRevs/lastMultiStashData — their separate prune LOOKS like the
  same lossy size>length proxy, but the insert loop runs before the check, so
  a same-count id swap transiently pushes size above length and the dead id is
  caught. They must stay excluded: they are keyed by the PREVIOUS series set,
  and pruning against live ids would destroy the reverse-morph data.
- pausedMultiData is a ninth map the doc didn't count — a nullable whole-map
  snapshot with no cross-series-set lifetime. Excluded, documented.

Call site confirmed non-per-frame: it sits inside the existing shrink guard,
which already allocated a Set there.

**#2:** the same 16-line ChartLayout literal appeared three times, re-verified
byte-identical on this branch before extracting. toX/toY stay closures — one
allocation per frame per chart, exactly as before — with a comment against
"optimizing" them into shared mutable state, which would alias across the
three pipelines.

No existing test modified.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
PLAN_MAINT #6 — the prerequisite for the pipeline extraction (#1). Tests
218 -> 319 across the branch.

Written to catch mutations, not for coverage percentage. Each significant test
pins a specific one-line regression, e.g.:
- hover at x=0: `hoverPixelX === null` weakened to `!hoverPixelX` would freeze
  the crosshair mid-drag at the left edge.
- Three "deliberate non-condition" tests in quiescence: adding !cfg.showPulse,
  !cfg.showMomentum or !cfg.orderbookData. Pulse defaults true, so that one
  would silently disable quiescence for nearly every chart.
- Log-space window transition: 10s->40s halfway is 20 (geometric mean),
  asserted `not` 25 — pins logLerp against a linear rewrite.
- 60Hz vs 120Hz convergence, paired with a test proving an uncorrected lerp
  WOULD diverge, so removing the dt term is caught rather than masked.
- 300-frame convergence asserting toBe(40) bit-exactly, paired with a proof
  that an unsnapped exponential never gets there — a residual epsilon
  invalidates the range and grid caches forever.
- candleAtX: an exhaustive 1000-candle sweep over every bucket centre, which
  catches binary-search off-by-ones that spot checks miss.

Also covers computeAdaptiveSpeed and computeCandleRange, which PLAN_MAINT
omitted.

One finding, source deliberately NOT changed: updateCandleRange is not exactly
frame-rate independent. Its lerp is dt-corrected but the adaptive speed is
recomputed from the current gap every frame, so 120Hz re-evaluates the
schedule twice as often and lands slightly ahead (<1% of travel).
engine/helpers.ts's updateRange avoids this by computing its speed once and
passing it in. The test asserts the actual bounded drift rather than an
aspirational exact match, and still catches removal of dt. Worth deciding
later whether the candle pipeline should hoist its speed computation to match.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Liveline is memo'd, but a live tick produces a new `data` array so the memo
fails and the WHOLE component re-rendered — including all three control bars
and their style hooks — even though none of the controls' inputs changed. At
the example app's 300ms tick that is ~3 wasted subtree reconciliations/sec; at
its 50ms setting, 20/sec.

Adds a memo boundary at <LivelineControls>, which also calls useBarStyle and
useChipStyle inside itself so those leave the tick path too, with three memo'd
bars behind it. A tick now re-renders Liveline only; React bails at the
boundary, skipping three bars, two style hooks, three SlidingIndicators and
the two icon <Canvas>es.

The two btnLayouts maps were pushed DOWN into the bars that produce them
rather than hoisted into a hook — that state was only ever read by its own
bar, so a measurement now re-renders one bar instead of the whole chart.

The non-obvious part: consumers pass inline arrows (the example app's
onModeChange and onSeriesToggle are both inline, and `series` is rebuilt each
parent render). The parent re-renders every tick, so those props are new
identities every tick and the memo would have failed 100% of the time —
decorative rather than effective. useStableValue projects arrays through a
content signature and useStableHandler is a latest-ref wrapper, so the
guarantee holds regardless of consumer discipline rather than depending on it.

No data, value, candles, series, lineData, liveCandle or engine shared value
crosses the boundary; the chip descriptors deliberately carry id/label/color
only.

Public API unchanged: index.tsx byte-identical, LivelineProps field count
unchanged. The <Canvas> subtree is byte-identical too — verified mechanically,
not by eye, because a conditional node there would silently cost the
blocked-JS-thread guarantee.

NOT verified at runtime: react-test-renderer and @testing-library/react-native
are both absent, so Liveline cannot be mounted in jest, and the running
simulator serves the primary checkout rather than the worktree. The evidence
is a prop-by-prop static argument for tick-stability, not a measured render
count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**orderbook.ts — the big one.** `mixColor` called `rgbColor`, which does
`new Float32Array([...])` per call, once per active label inside the render
loop. With MAX_LABELS = 50 alive that is up to 3,000 allocations/sec on the UI
thread, in a library that pools paints, paths and rects precisely to avoid
this. candlestick.ts already avoids the identical trap by precomputing
bull/bear colors outside its loop; the orderbook could not, because each
label's blend varies per-label per-frame.

Now a preallocated `colorPool` of MAX_LABELS Float32Array(4) on OrderbookState,
with `mixColorInto(out, from, to, t)` writing in place.

Pooling was chosen over cache-keying deliberately: `colorStrength` is a
continuous per-label-per-frame value (intensity x fadeIn x fadeOut), so
quantizing it to a key would either band a fading label visibly or grow a
cache entry per frame.

Aliasing argument, checked at the call site rather than assumed:
canvas2d's applyStyle calls `paint.setColor(style)` synchronously inside
fillText/strokeText, copying the four floats into Skia's native paint state
before returning. The loop is strictly sequential (mixColorInto -> fillStyle ->
fillText), so the previous value is consumed before the next slot is written,
and each iteration uses a distinct slot indexed by loop position.

**Hoisted per-frame closures** — small individually, but the codebase's whole
discipline is that these do not exist in the frame path:
- draw/index.ts `collapseC` was allocated on EVERY candle frame but only
  invoked during the brief reveal-collapse window; its definition is now gated
  inside that branch.
- candlestick.ts `bodyRect`/`spatialDim` and dot.ts `drawChevrons` hoisted to
  module-level worklets taking their captured values as parameters.

All three hoists verified declared ABOVE their callers — the babel worklet
transform loses const hoisting, so a helper referenced before its own
assignment captures as undefined and crashes ONLY in release Hermes builds,
which neither jest nor the debug simulator catches.

candlestick.test.ts asserts exact native call counts and passes unmodified, so
the call sequence is unchanged.

**crosshair.ts** — drawCrosshair skipped its outline `if (tooltipOutline)`
while drawMultiCrosshair drew it `if (tooltipOutline !== false)`: opposite
defaults for an omitted argument. README and types.ts both document the default
as `true`, so drawCrosshair now matches. No behavior change today (the single
call site passes an explicit boolean) — a latent trap removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
54ed706 widened the peerDependency to >=2.30.0 but the lockfile still recorded
>=3.0.0, so `yarn install --immutable` failed with YN0028 — i.e. CI would have
broken on that commit. Caught by an agent that hit it while trying to install.

One line; regenerated rather than hand-edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…oll rate

All three pre-existing or introduced-yesterday, none caught by any gate.

**1. timeDebt over-accrued ~3x per second of quiescence (SEVERE).**
`dt = now_ms - s.lastFrameTimestamp`, and `lastFrameTimestamp` is written in
exactly one place — the recording path. The quiescent early-return credited
`timeDebt += dt/1000` without touching it, so consecutive skipped frames
credited 16.7ms, 33.3, 50, 50, 50... (clamped at MAX_DELTA_MS). At 60Hz that
is ~3.0s of debt per 1s of real time, and it got WORSE at 120Hz.

Pause a chart, leave it 10s, unpause: ~28s of debt instead of 10, so
`now = Date.now()/1000 - timeDebt` puts the chart ~18s further into the past
than when it paused — the window snaps right, then rips forward under
PAUSE_CATCHUP_SPEED_FAST.

Fixed with a second clock, `lastAccrualTimestamp`, advanced on every frame that
credits debt. `lastFrameTimestamp` still is not written in the quiescent
branch, so both of its documented properties survive: the frame that breaks
quiescence still gets one large clamped dt, and the pacing gate still measures
"when did state last actually advance". The two clocks are equal on every
recorded frame and diverge only across skips — exactly the interval that was
being double-counted.

**2. A suspended frame loop lost the whole background interval (HIGH).**
setActive(false) stops accrual; wall-clock does not stop. Resume dt is clamped
to 50ms, so a 60s absence credited 0.05s. A PAUSED chart therefore jumped
forward ~60s on foreground, dumping a minute of data off the left edge.

All setActive calls now route through one path that stamps Date.now() on the
first suspension and credits the elapsed time on resume, scaled by
pauseProgress — so unpaused charts (pauseProgress 0) get exactly zero credit
and keep their correct advance-to-wall-clock behaviour, with no JS-side pause
knowledge required. `active={false}` routes through the same path: a chart
scrolled off a FlatList for a minute had the identical bug.

**3. scrollDx discontinuities poisoned observeScrollRate (introduced 33c23cb).**
dx is an offset OF A PARTICULAR recorded picture. Two consecutive recorded
frames 16ms apart can report -35 then 0 with the line perfectly continuous,
because the prefix was rebuilt in between and the new one already bakes in
that 35px. Differencing them yields +2.1 px/ms describing nothing physical;
the next paced-out vsync would shove the <Group transform> ~35px right and the
following frame would snap it back — a flick per line-cache rebuild, on
high-refresh hardware only. The gap bound could never catch it: a rebuild
happens on an ordinary 16ms frame, inside every timing guard.

observeScrollRate now takes `layerChanged` (scrollActive toggled, or
lineCache.buildRev moved) and returns 0, which makes extrapolateScrollDx
return null — "leave the transform exactly where it is". The guard does not
latch.

+21 tests (319 -> 340). timeAccrual.ts is Skia-free so jest can reach it: 1s/1s
accrual at 60 and 120Hz over 10s and 60s runs, a replay of the OLD arithmetic
asserting the ~28s-per-10s regression, and end-to-end property tests that a
paused chart's displayed instant survives 10s quiescent + 60s backgrounded
while an unpaused one advances by exactly the absence.

NOT tested: the hook wiring itself (native Skia import). Bug 3's symptom needs
120Hz hardware — the simulator is 60Hz and never paces out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…i-exit leak

Six confirmed bugs plus allocation cleanup, from a deep review of the least-
reviewed file in the repo. Adds src/engine/__tests__/step.test.ts — the first
behavioural test of engineStep itself, mocking the draw layer so the engine's
DECISIONS are testable without a Skia binding. Confirmed non-vacuous: reverting
step.ts + state.ts makes 9 of its 14 tests fail.

1. BLANK CANVAS. `visible.length < 2` returned before every draw call, so when
   data existed but nothing was in the window — a stalled feed, or returning
   from background — the recorded picture was literally empty. No grid, no
   empty state, nothing. The multi pipeline already handled the same case
   correctly; single-series now matches it verbatim.

2. STALE LIVE VALUE. `out.valueText` was written only by the single-series
   pipeline, and the hook treats null as "leave unchanged", so `showValue` +
   a switch to candle mode froze the readout on its last line-mode number
   forever. Candle now publishes the same number drawBadge prints two lines
   above (so they cannot disagree); multi publishes an explicit '' since there
   is no single "the" value.

3. `candles.slice()` EVERY CANDLE FRAME on the raw buffer — 60k objects/sec on
   a 500-candle chart at 120Hz — for a consumer that only fires when the user
   changes candle width. Now gated on candlesRev. Subtlety preserved: `.width`
   must still track the previous frame's width every frame, so only the array
   copy is gated.

4. PER-SERIES MAPS NEVER PRUNED ON LEAVING multi mode. All pruning was gated
   inside the multi pipeline, which stops running once isMultiSeries goes
   false — so displayValues, seriesAlpha, the scratch maps and lineCaches
   (which retain SkPaths) all survived. Sibling of the seriesAlpha leak fixed
   in aef4ddf. The agent widened the predicate to cover multi->candle too.

5. A series added while already hidden faded OUT instead of never appearing,
   and briefly widened the Y range. Seeds at target alpha, not 1.

6. REVERSE-MORPH STASH ALIASED THE LIVE CANDLE. `visible.slice()` froze the
   array but its last element IS s.displayCandle, lerped in place every frame,
   so the "frozen" stash kept moving. Now copied into a pooled slot; pooling is
   safe by construction (written only on hasData frames, read only on !hasData).

8. updateGridLayer got raw dt in the line and multi branches while candle
   passed pausedDt and documents why. Fixed both.

9. Per-frame allocations: empty-array sentinels for the points/candles slots,
   pooled scratch for oldVisible and the close-price object. hoverEntries could
   NOT be pooled — it is retained in lastHoverEntries and read on scrub
   fade-out frames — so it starts as a read-only sentinel and allocates only
   inside the active-hover branch.

7 was assessed a FALSE POSITIVE and deliberately not "fixed": cfg is written to
a shared value and therefore deep-cloned into the UI runtime, so cfg.lineData
is a per-commit clone, not the caller's array. Slicing it would add an
unconditional O(n) copy per frame — precisely the bug #3 describes.

state.ts reconciled by 3-way merge: this work and c24ac65 added disjoint field
sets from the same base.

Tests 340 -> 354.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
**Reusable ctx.** createCanvas2D built a fresh ~29-allocation object (23
interface methods, 3 helper closures, the save stack, the dash array) on EVERY
recording — 60/sec per Liveline instance, plus each nested grid/scroll-layer
re-record. It was unavoidable only because every method closed over `canvas`,
which is new per recording. The ctx now lives on SkiaCache and is retargeted:
closures read a mutable `cv` instead of a captured const. Zero allocations on
every call past the first per cache. Signature and return type unchanged, so
no call site moved.

Per-recording state is enumerated in a numbered comment block and reset in one
place (`retarget`): canvas, pooled path contents, the adopted-path pointer,
lineDash, the save stack, fonts, and all 13 style props. The build path routes
through retarget too, so it is the single source of truth and the first
recording is identical to every later one.

CRITICAL invariant, documented on the field: one ctx PER CACHE, never module
scope. Two recordings are live simultaneously — engine/gridLayer.ts and
engine/lineScrollLayer.ts each open a nested recorder during the main frame's
recording, and each already passes its own SkiaCache precisely so pooled
paints cannot alias. One ctx per cache inherits that isolation exactly; a
single shared ctx would let the nested recording scribble into the main one,
intermittently.

**Per-label save/restore in drawTimeAxis.** Each save() allocated a 14-field
StyleSnapshot AND issued a canvas.save() JSI call recording a save/restore
pair into the picture — 6-10 of each per frame, plus 12-20 extra recorded ops.
It only protected four properties the next iteration reassigns unconditionally.
Hoisted the loop-invariant ones; globalAlpha is set per label and restored once
after the loop.

Merged with care rather than copied: the agent's base was `main`, so a file
copy would have silently reverted this branch's label-text memoization,
off-screen churn fix and closure hoists. 3-way merged against main as the base
(clean, zero conflicts) and verified both sides survived. typecheck then caught
what jest could not — the agent's test built a TimeAxisState without the
memoization's formatTimeRef field; tests passed regardless because jest does
not typecheck.

Adds timeAxisCallBudget.test.ts (5 tests) kept separate from timeAxis.test.ts:
disjoint concerns (call budget vs formatter memoization) and different fake-ctx
harnesses. One test asserts saveCount === 0 while >= 3 labels draw, which pins
this change directly.

Verified on the simulator: Line, Candles, Multi, Dashboard, Orderbook. Dashboard
is the strongest case — four concurrent instances means four caches and four
contexts, which is exactly where a shared-ctx aliasing bug would show.

Tests 354 -> 359. yarn install --immutable passes; bob build clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read the spec back and verified it claim by claim against 0350072. All eleven
claims match — the four-node tree with no conditionals, prefix-stroke-only
scroll layer, the fill deliberately unsplit and still built whole, the binary
alpha gate via scrollLayerUsable, dx never accumulated (no `dx +=` anywhere in
src/), no double-draw, no text in the scroll layer, and all three addendum
claims including the 4-dimension key.

The one divergence was omission rather than contradiction: 33c23cb's
high-refresh scroll transform — recording paced at ~60fps while the
<Group transform> advances every vsync, with extrapolation and its three
guards — shipped with no design record at all, despite being the most
consequential runtime behaviour on the branch. Written up now, including the
unverified 120Hz-prefix-vs-60Hz-tail shear, which needs ProMotion hardware the
simulator cannot provide.

Status corrected from "implementation dispatched" to implemented.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A red screen naming src/engine/state.ts and a for...of helper — both edited
that day — turned out to be a stale Metro on host 8082 whose module map was
corrupted, while the healthy one served iOS on 8083. Same repo, one platform
broken, and the error pointed straight at innocent code.

Also records that adb reverse does NOT apply here: the app connects to
10.0.2.2, the emulator's NAT route to the host, which bypasses reverse
entirely. Mapping the port did nothing and looked like it should have.

Adds the one-command diagnostic — curl each port for the exact bundle URL —
which removes the emulator, the app's cached error screen and your assumptions
in a single step. It took three wrong hypotheses to reach it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fuller fuller changed the title Declarative render shell + high-refresh scroll transform Declarative render shell, high-refresh scroll, six bug fixes, and maintainability debt Aug 1, 2026
docs/superpowers/specs/ held two working documents from the session that
produced this branch. Neither belongs in the repo:

- the scroll-layer architecture design describes the time-axis scroll layer,
  which was measured, priced and PARKED on another branch — it is not in this
  PR at all
- the declarative-shell design is a pre-implementation record whose content is
  now in the code comments and the CHANGELOG

docs/android-profiling.md stays: it is a real procedure with commands someone
will run again, including the two preconditions that have each silently
invalidated an Android measurement on this project before.

Removes the CHANGELOG's pointer to the deleted spec rather than leaving it
dangling.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fuller fuller changed the title Declarative render shell, high-refresh scroll, six bug fixes, and maintainability debt Declarative render shell, six bug fixes, and maintainability debt Aug 1, 2026
fuller and others added 6 commits August 1, 2026 17:43
An independent validation pass found the `showValue` fix from c49bb03 was
INCOMPLETE, and that the blank-canvas fix in the same commit widened the same
hole.

Three early returns draw the empty/loading state and bail without publishing
`out.valueText`. Since `null` means "leave unchanged" to the caller, the
readout keeps showing whatever the PREVIOUS mode last wrote:

- the shared no-data return (all modes)
- multi-series with no visible series
- single-series `visible.length < 2` — added by c49bb03 itself

So a chart mounted with no data, or one that loses every point out of the
window, or a switch to a multi-series config whose data has not arrived yet,
still showed a stale price from another mode. That is exactly the bug c49bb03
claimed to close, surviving on the paths where no chart is drawn at all.

Fixed with one `publishBlankValue(out, cfg)` helper called at all three sites
rather than three copies — the reviewer's point was that this is one defect
class in three places. It writes '' (a real value that overwrites) rather than
null, and only when `cfg.showValue` is set, so charts without the readout
publish nothing and cost nothing.

Also closes a coverage gap the same review found: `slot.buildRev++` in
`updateLinePaths` had NO test. It is the sole invalidation signal for the
scroll-layer picture and the input to `observeScrollRate`'s `layerChanged`
guard, yet deleting it failed nothing — `lineScrollLayer.test.ts` bumps the
field by hand, so nothing asserted a real rebuild moves it.

Both fixes mutation-verified rather than assumed:
- removing the three `publishBlankValue` calls fails 2 of the new tests
- removing `slot.buildRev++` fails 3 of the new tests (previously 0)

Tests 359 -> 365.

Note for the record: the new lineCache tests passed jest while FAILING
typecheck (wrong `makeLayout` arity). Jest does not typecheck — green tests
are not a green gate. Same trap as the timeAxis test earlier on this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The README's peer table still said `react-native-gesture-handler: >=3.0.0`,
and the prose below it said the library "is not tested against gesture-handler
2.x anymore" — while package.json declares `>=2.30.0` and the CHANGELOG
describes widening it precisely so Expo SDK 55 (which pins ~2.30.0) does not
hit a peer conflict.

So the published README actively contradicted the fix it shipped alongside: an
SDK 55 consumer reading it would conclude their gesture-handler version was
unsupported and either skip the library or needlessly bump gesture-handler,
defeating the whole point of 54ed706. Found by an independent API/docs review.

Table corrected and the prose rewritten to state that both majors are
supported, why the 2.30 floor exists, and that compatibility was verified by
installing 2.30.0 and running the typecheck rather than by inspection. All four
documented peer ranges now match package.json exactly.

Also adds example/eas.json with a `preview` profile (Android APK, iOS device
build) for producing an installable artifact.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The chart is a Skia surface, so VoiceOver and TalkBack previously encountered
an unlabelled blank region — `git grep accessibilit src/` returned zero hits.
For a library whose purpose is displaying a live number, that was the largest
gap between this and something a team would adopt.

Two public props: `accessibilityLabel` (default 'Live chart') and `testID`.
The built-in controls DERIVE their test ids from testID, so Detox/Maestro can
drive the window pills, mode toggle and series chips without further props.

The design constraint, and why the obvious approach was wrong: the live value
lives on the UI thread as a shared value updated 60-120x/sec, while
accessibility props are JS-thread React props. Bridging per frame would
destroy the architecture the whole library exists for.

The implementation does not bridge at all. `engine.valueText` is the LERPED
value — the engine eases toward each incoming number over several frames — so
announcing it would speak an interpolated figure that never existed. The
authoritative number is already a JS-thread prop. So there is no runOnJS, no
extra mapper, and ZERO change to useLivelineEngine.ts or step.ts.

Cost with no screen reader running: one promise and one screenReaderChanged
subscription at mount, then nothing — no interval, no state, no per-frame work.
With a reader active: a 1Hz sample of a ref, committing only when the FORMATTED
string changed (a live feed jitters below display precision constantly;
comparing raw numbers would announce once a second forever). Ticks flow into a
ref, never into state, so the memo boundary added in 6aa05c3 still holds.

Also fixed, and not asked for: the icon-only mode toggle was two entirely
unnamed buttons; compact series chips are bare coloured dots with no text; the
faded-but-mounted series bar was offering a reader controls nobody can see; and
the showValue overlay is a TextInput updating at frame rate that a reader would
have announced continuously — now hidden from assistive tech.

+15 tests (365 -> 380) on the Skia-free announce logic, including the
sub-precision-jitter silence case. Canvas subtree verified byte-identical.

NOT verified with a real screen reader — no VoiceOver session was run. The
zero-cost-when-off and no-re-render-on-tick claims rest on reading the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…lently

Three defects found by an API/docs review.

1. `parseColorRgb` accepted `#` + 3-8 hex digits but only expanded the 3-digit
   form, so a CSS Color 4 shorthand like `#38fc` produced `parseInt('', 16)` =
   NaN for the blue channel, propagating silently into gradients and paints.
   Now expands 3- and 4-digit, returns only for 6/8, and falls through to the
   grey fallback for any other length rather than mangling a partial parse.
   `color` is typed as a plain string with no documented format restriction,
   so a consumer piping a design token or colour-picker value through it got
   garbage rather than an error.

2. Unparseable input (named colours, garbage) fell back to grey with no signal
   at all. Now warns under __DEV__ naming the offending value.

   The warning is de-duplicated against the last-warned value, which the
   original fix did not do: `parseColorRgb` is called from the FRAME PATH —
   draw/dot.ts parses `palette.line` and `palette.badgeOuterBg` while
   scrub-dimming — so an unconditional warn would fire up to 60 times a second
   for as long as a bad colour was set. That is noisier than the silent
   fallback it replaced. One string compare per call, no allocation, and a bad
   colour still reports exactly once.

3. `LivelineTransition` rendered nothing, silently, when `active` matched no
   child key (a typo, or a conditionally-absent child) — blank chart, no
   signal, self-healing only when `active` later changed to a real key. Now
   warns under __DEV__ naming both the bad value and the keys that DO exist.

Also corrects a comment in theme.ts claiming "Dot — always semantic": the dot
paints from `palette.line` unconditionally, and the eight momentum/glow/badge
palette fields it referred to are computed but read by nothing. The fields are
kept (removing them means touching types.ts and fixtures owned elsewhere) with
an accurate comment instead.

Adds src/__tests__/theme.test.ts (15 tests) — the module had none. Covers
3/4/6/8-digit hex, case, rgb()/rgba(), invalid lengths, named colours, a
NaN-freedom sweep, and that resolveTheme derives fillTop/fillBottom from the
same rgb as `line` (the invariant the scroll-layer z-order relies on).

Tests 380 -> 395.

NOT acted on: the agent also reported resolveTheme has "zero callers" and that
Liveline.tsx never imports theme.ts. That is false — Liveline.tsx:41 imports it
and calls it at 745, 762 and 774. Verified before discarding.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The safety net for PLAN_MAINT #1 — extracting three inline pipelines from a
~1,900-line function that share one EngineState. Existing coverage was 17
regression tests pinning bugs fixed today; this pins the surface.

Asserts EngineState field values, which draw functions ran, how many times,
and in what ORDER (via jest invocationCallOrder merged across nine mocks).
Deliberately never asserts argument tuples — a concurrent refactor is
reshaping those signatures, and tuple assertions would break for reasons
unrelated to correctness.

Covers: golden 5-frame sequences per pipeline (~16-22 EngineState fields each,
explicit toEqual rather than snapshot files); the full six-direction mode
transition matrix plus a line->multi->line round trip; and the ordering
invariants that are load-bearing but currently only documented in comments —
the scroll-layer gate running after updateHoverState and before drawFrame,
grid-before-draw in all three pipelines, and pausedDt at all three
updateGridLayer sites.

12 mutations applied, run, reverted — 12 caught. One (flipping the line
pipeline's window buffer) escaped the first draft because the buffer lands on
no EngineState field; a group reading layout.rightEdge off the call closed it.
Independently re-verified here: mutating one pausedDt to dt fails exactly the
grid-dt test.

THREE FINDINGS FOR THE REFACTOR, all pinned as-is on purpose so the extraction
changes them deliberately rather than by accident:

- `rangeInited` is ONE flag shared by all three pipelines, and it is a real
  stale-state leak. After line->candle, updateCandleRange sees it already true
  and LERPS the Y range away from the line's range instead of snapping to the
  candle's. Symmetric for candle->multi. If the extraction gives each pipeline
  its own range state, two tests here will fail — that failure is the signal.
- Cleanup is asymmetric: the five per-series maps are pruned on leaving multi
  mode, but nothing prunes candle state or line state on exit.
- drawMultiFrame gets raw dt while drawFrame/drawCandleFrame get pausedDt. An
  extraction that unifies the three call sites silently changes multi-series
  pause behaviour.

Also documents what resisted characterization: the scroll-layer gate is
unreachable under a mocked draw layer (lineCacheHits needs a prefix only the
real drawFrame builds), so the mock had to write the cache slot — without that
every scroll-layer test would have been vacuously green. A trap for anyone
extending this file.

Tests 395 -> 437. No source file touched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Not cosmetic — this width already blocked real work. A pooled-decimation
optimisation was REVERTED rather than add a 17th positional parameter to
drawLine. At 15 parameters, adjacent same-typed arguments are silently
swappable and the compiler cannot help; drawLine alone passed six consecutive
numbers.

  drawLine          15 -> 4     lineCacheHits        8 -> 3
  drawCandlesticks  15 -> 3     updateLinePaths     13 -> 7
  engineStep        12 -> 4     updateHoverState    12 -> 7
  updateRange       12 -> 7     updateWindowTrans   11 -> 8
  updateCandleCache 10 -> 8     paintLineCurve      12 -> 10

Two mechanisms, chosen per function. Where the parameters were a genuine bag
of unrelated per-frame values: a POOLED args struct on EngineState, created
once in createEngineState and overwritten in place — the precedent
multiSeriesEntryScratch already sets. Where the list was re-deriving fields of
an object the function already received: just pass that object.

The naive version of this change would have been a regression. These run
60-120x/sec on the UI thread, so an options object allocated per call is
exactly the per-frame allocation this codebase pools paints, paths and rects
to avoid. Net effect is the opposite: THREE per-frame object literals removed
(two in step.ts, plus one per visible series per frame in drawMultiFrame),
zero added. Verified by scanning every added non-test line for object/array
literals — the only ones are in the create* functions, which run once per
chart.

Behaviour-preserving, and that is verified rather than asserted: the 42
characterization tests added in bd9b693 pass unchanged. They pin EngineState
values, draw-call counts and call ORDER across all three pipelines, so a
behavioural drift would have shown. Re-confirmed they still have teeth after
the signature change by mutating one pausedDt to dt and watching exactly the
grid-dt test fail.

Non-mechanical bits worth knowing:
- lineCacheHits now derives its key from `visible`, so it indexes it. The
  short-circuit order was restructured so the null-prefix and length/rev
  checks run BEFORE any indexing — an empty array can never reach the index.
- FrameInputs moved to engine/types.ts: defining it in step.ts made state.ts
  import from step.ts, which imports state.ts at runtime. Type-only so it was
  erased, but not a cycle worth leaving for a bundler to trip over.
- s.lineCacheRef and s.lineDrawArgs are now shared across all three pipelines.
  Safe because exactly one pipeline draws per frame and every call site writes
  ALL fields before calling — checked at each of the three drawLine sites and
  both drawCandlesticks sites, and documented on the fields.
- One test got structurally WEAKER and was strengthened rather than accepted:
  lineCache.test.ts asserted kLen by passing an impossible length once the key
  is derived. Replaced with an extra INTERIOR point (same first/last), which
  isolates kLen exactly, plus three previously-untested derived fields.

Deliberately left: updateCandleWindowTransition (19) and updateCandleRange
(14) in candleHelpers.ts — the worst offenders, but out of the scope given.
The WindowTransInputs/RangeInputs pattern ports directly and they are the
obvious next increment.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@fuller fuller changed the title Declarative render shell, six bug fixes, and maintainability debt Declarative render shell, 8 bug fixes, accessibility, and maintainability debt Aug 3, 2026
…en series

Review findings from the pre-merge pass on PR #2:

- Retire-then-dispose every replaced SkPicture (screen ~60/sec, grid and
  scroll layers per rebuild) one frame after its successor composites,
  instead of leaving native pictures to the UI runtime's GC.
- Accumulate suspended-time credit via runOnUI so the += and the frame
  callback's drain are serialized on one runtime (double-credit race on
  fast background/foreground flaps).
- Prune hiddenSeries against live series ids: a removed-then-re-added
  series no longer returns hidden, and dead ids no longer let the
  last-visible-series guard be defeated into a blank chart.
- Move the parseColorRgb dev-warn dedupe off a worklet-captured module
  binding onto the runtime's own global.
- Document (README/CHANGELOG) that formatValue/formatTime must be pure:
  label text is cached per tick and re-evaluated on formatter identity
  change, not per frame.
- Type-pair drawMultiFrame's lineCaches/lineCacheRef so passing one
  without the other can't silently disable caching; pool the pairing on
  EngineState (no per-frame literal).
- Mechanical: scroll ring-buffer size as a named constant; shared
  useBtnLayouts hook; signature builders get a reference pre-check and
  keep control-byte delimiters; unit tests for mixColorInto (+4).

Gates: tsc, eslint, jest 25 suites / 441 tests (characterization suite
unchanged), yarn install --immutable, bob build. Verified live on the
iOS simulator with the dispose path active every frame.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@fuller
fuller merged commit 92199be into main Aug 3, 2026
5 checks passed
@fuller
fuller deleted the perf/declarative-shell branch August 3, 2026 22:33
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.

1 participant