Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
7fa4cf7
docs: scroll-layer rendering architecture design
fuller Jul 31, 2026
3db71f1
feat(draw): scroll-layer slot foundation (stage 0)
fuller Jul 31, 2026
4e8ac3f
test: exclude .claude/ from jest module paths
fuller Jul 31, 2026
302df29
feat(draw): time-axis pure layer logic (stage 3a)
fuller Jul 31, 2026
1f71eb6
docs: Android profiling procedure + release build script
fuller Jul 31, 2026
de8c9a0
perf(timeAxis): memoize label text; fix off-screen label churn
fuller Jul 31, 2026
e2c56d8
docs(skill): record four A/B traps that voided a real measurement
fuller Jul 31, 2026
b6c27a9
docs: declarative render shell design (react-native-graph-shaped)
fuller Jul 31, 2026
2e1c4d7
feat: declarative render shell (two pictures + Group transform)
fuller Jul 31, 2026
21c4d98
feat(draw): expose line prefix stroke separately from tail
fuller Jul 31, 2026
3bc2d74
docs: fix declarative design omissions found during implementation
fuller Jul 31, 2026
54ed706
fix: widen gesture-handler peer range, drop dead files entries, docum…
fuller Jul 31, 2026
af7424b
feat: wire line prefix stroke into the scroll layer
fuller Aug 1, 2026
33c23cb
feat: let the scroll transform run at full refresh rate
fuller Aug 1, 2026
8e674cb
refactor: apply /simplify findings (net -217 lines)
fuller Aug 1, 2026
788d261
docs(changelog): record the high-refresh scroll transform
fuller Aug 1, 2026
4f80363
chore(lint): ignore .claude/ agent worktrees
fuller Aug 1, 2026
5767ff3
refactor(Liveline): extract PillBar; drop the file-wide inline-styles…
fuller Aug 1, 2026
37da8e3
refactor(draw): unify the two cache modules' layout key
fuller Aug 1, 2026
aef4ddf
fix(engine): prune seriesAlpha; extract makeLayout
fuller Aug 1, 2026
dffc200
test(engine): cover quiescence, helpers and candleHelpers (+97 tests)
fuller Aug 1, 2026
6aa05c3
perf(Liveline): keep the controls out of the tick path
fuller Aug 1, 2026
af8950e
perf(draw): pool orderbook colors, hoist per-frame closures
fuller Aug 1, 2026
eff31f8
fix: refresh yarn.lock for the widened gesture-handler peer range
fuller Aug 1, 2026
c24ac65
fix(engine): three timing bugs — quiescence debt, background gap, scr…
fuller Aug 1, 2026
c49bb03
fix(engine/step): blank canvas, stale readout, per-frame copies, mult…
fuller Aug 1, 2026
0350072
perf(draw): reusable Canvas2D context; drop per-label save/restore
fuller Aug 1, 2026
fadd61d
docs: conformance-check the declarative spec against the implementation
fuller Aug 1, 2026
1d5fd27
docs(android): record the wrong-Metro trap
fuller Aug 1, 2026
52b17a6
chore: drop session design docs from the PR
fuller Aug 1, 2026
1360975
fix(engine/step): publish a blank readout on every early return
fuller Aug 1, 2026
b52d96c
docs: fix README peer range; add EAS preview build config
fuller Aug 3, 2026
b9e96d4
feat(a11y): screen-reader and test-automation support
fuller Aug 3, 2026
a4b39c2
fix(theme): correct 4/8-digit hex parsing; warn instead of failing si…
fuller Aug 3, 2026
bd9b693
test(engine): characterization suite for engineStep (+42)
fuller Aug 3, 2026
7f7c953
refactor: options structs for the widest signatures (PLAN_MAINT #4)
fuller Aug 3, 2026
0f0e02a
fix: dispose retired SkPictures, serialize suspend credit, prune hidd…
fuller Aug 3, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 24 additions & 6 deletions .claude/skills/run-and-profile/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,13 +70,31 @@ A/B protocol:
Commit/stash first.
2. **Screenshot the baseline arm.** If the old `src/` throws, you're timing a
red error screen — cheap to render, looks like a huge win.
3. Relaunch between arms, settle ~20s. Use the default Line tab where possible:
it survives a reload with no navigation, removing a variance source.
4. 3 runs/arm. Within-arm spread should be 2-3 points; much wider means the host
3. Relaunch between arms, settle **~50s**, not 20 — a swapped `src/` makes Metro
re-bundle, and a window opened too early reads 1-5% (the app is still
loading, not cheap). Any run that implausibly low is a settle artifact, not
data. Use the default Line tab where possible: it survives a reload with no
navigation, removing a variance source.
4. **Relaunching resets in-app toggles — check them per arm.** Burned a full
A/B on 2026-07-30: 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. That is an always-on frame callback present in one arm
and absent in the others — it cost ~15 points and looked exactly like host
drift. The arm screenshots are the check: the control reads `56 fps` when on
and a blank `fps` when off. Confirm every arm matches before comparing.
5. 3 runs/arm. Within-arm spread should be 2-3 points; much wider means the host
is too busy to measure on.
5. Re-measure the first arm at the end as a drift control.
6. Log `uptime` per arm — shared 8-core box, has run at load 24. Relative deltas
survive moderate load; absolute numbers don't.
6. Re-measure the first arm at the end as a drift control.
7. Log `uptime` per arm, **at the start AND end of each arm** — a mid-arm climb
is invisible if you only sample once. Shared 8-core box, has run at load 24;
observed 50.7 immediately after a build/install/bundle, draining over
several minutes. Relative deltas survive moderate load; absolute numbers
don't, and a *moving* load voids the comparison outright.
8. **Know your effect size before trusting this method.** Whole-process CPU
cannot resolve a change worth ~1-2k JS allocations/sec — that is inside the
noise on this box (measured null, and slightly negative, for the timeAxis
memoization on 2026-07-30 despite unit tests proving the work was removed).
For allocation-shaped changes, prefer symbol-level sampling over total CPU.

Reference (2026-07-27, iPhone 17 Pro sim, Debug, load ~8-11), `main` 60e493e →
`perf-hardening` 2fb7ff2: Line 127.8% → 85.4%, Candles 108.5% → 63.5%.
Expand Down
158 changes: 158 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,164 @@

All notable changes to this project will be documented in this file.

## [Unreleased]

### Fixed

- **Native `SkPicture`s are now disposed, not left to GC.** The screen picture
is replaced every recorded frame (~60/sec) and the grid/scroll layer
pictures on every rebuild; each replacement dropped a native picture whose
tiny JS wrapper gave the garbage collector no reason to hurry. Every swap
site now retires the outgoing picture and `dispose()`s it one frame later,
once the tree has composited its replacement — bounding native memory on
exactly the long-running live charts the library targets.
- **Suspended-time credit is race-free.** The wall-clock interval accrued
while the frame loop was suspended was added from the JS thread while the
UI thread drained the same shared value — a fast background/foreground flap
could double-credit an interval and leave a paused chart further in the
past than it ever was. Both writes now run serialized on the UI runtime.
- **Removed series no longer haunt the toggle state.** A series removed from
`series` and later re-added came back hidden, and its dead id still counted
against the "don't hide the last visible series" guard, which could
therefore be defeated into a blank chart. Hidden ids are now pruned when
the series list changes.
- The unrecognized-color dev warning no longer mutates a module-level
binding from the UI runtime (worklet-captured bindings are per-runtime
copies); the dedupe flag lives on the runtime's own global instead.

### Changed

- **`formatValue` / `formatTime` must be pure functions of their input.**
Axis and grid label text is cached per tick and re-evaluated when the
formatter's identity changes, not every frame (a per-frame `Date` +
string-format per label was measurable UI-thread churn). Formatters that
read ambient state — relative time, a captured mutable locale — now render
stale text; pass a new function instance to invalidate. Documented in the
README.

### Added

- **Accessibility.** The chart is a Skia surface, so a screen reader previously
found an unlabelled blank region. `Liveline` now announces itself as an
`image` (React Native has no chart role) with a default label of
`'Live chart'`, overridable via the new **`accessibilityLabel`** prop, and
exposes the live number through `accessibilityValue` — run through the
consumer's `formatValue`, with the momentum direction appended
(`"64,201.55, rising"`).

This costs nothing when no screen reader is running. The value is gated on
`AccessibilityInfo.isScreenReaderEnabled()` plus its `screenReaderChanged`
listener; with no reader active there is no sampling timer, no state and no
accessibility value — one boolean test per render, and no UI→JS traffic. The
reading is taken from the props already on the JS thread rather than bridged
from the engine's UI-thread shared value, so the per-frame render path is
untouched and the chart still does not re-render on tick. When a reader *is*
active the value is sampled once a second, and readings that format
identically are skipped so a still chart stays quiet. `formatValue` is
therefore also called on the JS thread, roughly 1Hz, while a reader runs.

The built-in controls gained labels and selected/checked state — most
importantly the icon-only line/candle toggle, which had no text for a reader
to fall back on. The live-value overlay (`showValue`) is hidden from
assistive tech, since it is a `TextInput` driven at frame rate.

- **`testID`** prop on the chart container. The built-in controls derive their
own ids from it (`-window-<secs>`, `-mode-line`, `-mode-candle`,
`-series-<id>`), so Detox and Maestro can target the chart and drive its
controls.

### Fixed

- **`parseColorRgb` (used to derive the palette from the `color` prop) no
longer produces `NaN` channels for 4-digit hex shorthand** (`#rgba`, the
CSS Color 4 shorthand-with-alpha form, e.g. `"#38fc"`). It also now handles
8-digit hex (`#rrggbbaa`) correctly, and rejects any hex length other than
3/4/6/8 instead of silently mangling it — both now fall back to the
existing grey default. In development, an unparseable `color` (including
named CSS colors like `"red"`, which were never supported) logs a warning
naming the offending value instead of failing silently into gradients and
paints. See the `color` prop docs in the README for the full list of
supported formats.
- **`LivelineTransition` now warns in development when `active` matches no
child's `key`.** Previously a typo'd or conditionally-absent `active` value
produced a blank chart area with no signal until `active` later changed to
a key that does exist. The warning names both the bad value and the keys
that are actually available.
- **`react-native-gesture-handler` peer range widened to `>=2.30.0`** (was
`>=3.0.0`). The v3 floor existed only because the engine imported a
v3-only type name for a union it never used — the hook always returns a
single `Gesture.Pan()`, so the return type is now just `GestureType`,
which exists in both majors. Expo SDK 55 pins `~2.30.0`, so every SDK 55
consumer previously hit a peer conflict and an `expo-doctor` failure on
install. Verified by installing 2.30.0 and running the real typecheck, not
by inspection.

### Changed

- **Declarative render shell** — the chart now renders as a fixed four-node
Skia tree (`<Canvas>` → `<Group transform>` → `<Picture>`, plus a sibling
`<Picture>`) instead of a single picture. The structure never changes after
mount, so the Reanimated mapper that drives it stays entirely on the UI
thread and the "keeps animating while the JS thread is blocked" guarantee
is preserved. This is an architecture change, not a performance one; no
speedup is claimed for it.
- **The single-series line's prefix stroke moved into that scroll layer** —
the spline through all but the last data point is recorded once into its
own `SkPicture` and composited at a horizontal offset via
`<Group transform>`, re-recorded only when the line path cache misses; the
per-frame screen picture strokes only the tail. The fill polygon is
deliberately **not** split (it is one semi-transparent closed shape; every
way of cutting it leaves a seam or a double-darkened column) and is still
drawn whole every frame. Any frame that can't composite the layer at
alpha 1 — the reveal morph, the loading/empty crossfade, scrub dimming,
the degen shake — falls back to drawing the whole line live, because
`drawPicture` ignores `globalAlpha`. Multi-series and candle mode are
untouched and keep drawing the combined path. In accounting terms this
removes roughly one draw call per frame; it was not measurable on device
and no speedup is claimed for it.
- **Time-axis labels no longer re-format every frame** — `formatTime` is a
pure function of a label's key, but the axis called it for every label on
every frame and overwrote each label's text with a byte-identical string
(~360 calls/sec at a 30s window, each allocating a `Date` and three
`padStart` results with the default formatter). It now runs only for a key
seen for the first time; a formatter swap is caught by reference identity
and re-texts live labels in place without disturbing their fades. Also
fixes a churn bug found by the new tests: labels one interval beyond each
edge — deliberately targeted, but at alpha 0 — were deleted and re-created
every single frame forever. Purely internal; per-label alpha, crossfades
and edge fades are unchanged. Worth ~35% of that function's JS time in
isolation, and undetectable on device (the whole time axis is 4.2% of a
core).

- **The scroll layer's transform now runs at the display's full refresh
rate.** Picture re-recording stays paced at ~60fps, but translating an
already-recorded picture is nearly free, so the `<Group transform>` advances
on every vsync — 120fps on a ProMotion display — while recording cost is
unchanged. On a vsync that pacing skips there is no layout to recompute `dx`
from, so it is linearly extrapolated from the last two recorded frames and
overwritten with the exact value on the next one; a quiescence resume, a
return from background or a JS stall leaves the transform untouched rather
than flinging it. Note the consequence: the prefix moves at 120Hz while the
tail is re-recorded at 60Hz, so on skipped vsyncs they shear by one frame of
scroll — roughly 0.25px on a 10s window, 0.08px on 30s. Sub-pixel, but
unverified: the iOS simulator renders at 60Hz and cannot show it either way.
- **`MIN_FRAME_INTERVAL_MS` and `MAX_SCROLL_EXTRAPOLATION_MS` are coupled** by
two inequalities that are now documented in `engine/constants.ts` and
asserted in `engine/__tests__/constants.test.ts`. Tuning either alone can
silently disable high-refresh scrolling in a way only 120Hz hardware would
reveal.

### Removed

- **`src/draw/timeAxisLayer.ts` and its tests** — pure label-selection logic
built for a time-axis scroll layer that was measured, priced and parked
rather than adopted, and which nothing imported. Recoverable from
`302df29` / `82d8f6b` on `perf/scroll-layer-architecture` if the axis ever
moves into a scroll layer. No API change; the module was never exported.
- **Non-existent `android`, `ios`, `cpp`, `*.podspec` and
`react-native.config.js` entries from `files[]`** — this is a JS-only
library. `npm pack` output is unchanged (255 files, 1.3 MB).

## [0.2.1] - 2026-07-28

### Changed
Expand Down
Loading
Loading