Per-tool config belongs in the tool, matching Auto-Advert's pattern.
SettingsScreen drops:
- TRAIL_MIN_DELTA enum entry + the entire SECTION_GPS gate (it was its
only remaining item after GPS upd / Trail int were removed)
- Render and input handler blocks
TrailScreen gains a small modal popup opened with Hold Enter (the
KEY_CONTEXT_MENU input). Enter cycles through the four min-dist values
(5/10/25/100 m), Esc closes the popup. The popup overlays whichever
view is active; savePrefs runs on each cycle so the value persists.
NodePrefs::trail_min_delta_idx and the TrailStore::MIN_DELTA_* helpers
are unchanged — only the entry point shifted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The previous implementation derived "Time" and "Avg speed" from the
RTC: first().ts and rtc_clock.getCurrentTime(). When the RTC isn't
synced yet (no GPS time fix, no host sync), every point ts is 0, the
current time is 0 too, and elapsed stays at 0 — Time never moves and
Avg speed reads 0 even with the map filling in. Real-world breakage.
Switch the elapsed/avg-speed timebase to millis():
- TrailStore gains _session_start_ms (millis() when the current
start→stop window opened) and _accumulated_ms (banked time across
previous windows).
- setActive(true) records the start; setActive(false) banks the
delta into the accumulator.
- elapsedSeconds = (_accumulated_ms + active session delta) / 1000.
- clear() zeroes both.
Per-point ts stays RTC-derived (used for the HH:MM labels in List view
where a wallclock is what the user actually wants).
TrailScreen drops the now_ts argument it was passing in, and bumps the
refresh interval to 1 s while active so the m:ss counter actually
ticks every second.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
8.5 min of raw 1 s samples (vs 4.25 min) — with the typical 5 m
min-delta gate that's ~30 min of walking or ~10 min of cycling worth
of points before the ring wraps. Cost is one 16-byte TrailPoint per
slot = +4 KB RAM; nRF52840 build was at 64.7 % so ample headroom.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
GPS NMEA polling is cheap, and a slower cadence just hides motion from
the trail; the min-delta gate already filters jitter without throttling
the GPS. Both knobs that exposed cadence-style controls to the user are
removed:
- Trail sampling is fixed at TrailStore::SAMPLING_SECS = 1 s, matching
the sensor manager's built-in GPS update default. The
intervalSecs / intervalLabel / INTERVAL_COUNT helpers go with it.
- GPS update rate ("GPS upd" entry) leaves the sensor manager on its
built-in 1 s default. applyGPSInterval and the GPS_INTERVAL_OPTS /
LABELS tables / gpsIntervalIndex helper are deleted.
Both NodePrefs fields (gps_interval, trail_interval_idx) are retained
as reserved so the schema sentinel doesn't have to bump — older saves
load cleanly, the bytes are just ignored.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
A 3×3 filled square is drawn at the last point of each segment before
a break, and a 3×3 outline square at the first point after the break.
The user can now see where tracking paused and resumed at a glance,
without confusing the gap with sparse sampling.
Start (`+`) and current (`×`) markers unchanged.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two visible cues when the user starts tracking without a fix:
1. The Enter alert now reads "Waiting for GPS fix" instead of
"Tracking started" when the LocationProvider isn't valid at the
moment of toggling. Tracking is still armed — sampling starts as
soon as the GPS lock comes up.
2. The Summary Status field shows "waiting fix" while active and the
ring is still empty (no point ever sampled). It flips to "tracking"
once the first point lands. The detection is "any point recorded
yet?" rather than "is the fix valid right this second", which is
stable across the cadence of UITask::loop sampling.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Two new entries in the existing GPS section of SettingsScreen, gated by
the same ENV_INCLUDE_GPS guard. LEFT/RIGHT cycles through the option
table from Trail.h:
- "Trail int" : 30 s / 10 s / 20 s / 1 min / 5 min / 15 min
- "Trail dist": 5 m / 10 m / 25 m / 100 m
Changes are stored to p->trail_interval_idx / trail_min_delta_idx and
take effect on the next sampling tick in UITask::loop (already gated on
those fields), so no separate apply* helper is needed. _dirty triggers
the normal savePrefs on settings exit.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Third view in TrailScreen, reached by LEFT/RIGHT from Summary/Map.
Newest-first, each row shows `HH:MM +N m` (or `+N.N km` past 1 km).
The first point of each segment shows `start` instead of a delta —
matches the map renderer's segment-aware drawing.
UP/DOWN scrolls within the list; ^/v arrows on the right edge appear
when more rows hide above/below. Time is local (tz_offset_hours
applied via gmtime on (ts + offset)).
Empty trail shows "No trail yet" centred in the content area.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Test feedback revealed four issues:
1. Sampling too sparse — defaults were 60 s interval + 25 m min-delta,
so a walking pace dropped ~80% of samples. New defaults: 30 s + 5 m.
Interval options expanded to { 30, 10, 20, 60, 300, 900 } s and
min-delta to { 5, 10, 25, 100 } m so the user can dial it further
from Settings (phase 5).
2. "Speed" was current-from-last-pair, misleading next to a Time field
that grows monotonically. Switch to avgSpeedKmh = total / elapsed,
labeled "Avg speed".
3. Time advanced only when a new sample landed (it used
last().ts - first().ts). Now elapsedSeconds takes an optional
now_ts and uses it whenever the trail is active, so the Time field
in Summary ticks every render cycle. Sub-1h shown as m:ss for
visible seconds.
4. Stop → start drew a straight line across the dead time. TrailPoint
gains a flags byte; addPoint flags the first point and the first
point after a re-arm as SEG_START. The map renderer skips the line
from the predecessor for SEG_START points (still draws the start
pixel). totalDistanceMeters also skips segment boundaries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The status-bar G/A indicators both flip every 2 s (millis() % 4000 <
2000), but with only auto-advert forcing the 1 s refresh, a screen with
G alone repainted every 5 s — the blink looked sparse vs A. Treat any
blinking indicator (auto_adv OR trail active) as needing the 1 s
cadence on OLED.
E-ink builds keep the 30 s home refresh — blink is gated to "always on"
there via Features::BLINK_INDICATORS = false.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
OLED (64 px height) couldn't fit 5 summary items plus the bottom hint
row — Time/Speed lines overlapped the [Ent]/[Esc] hint. Convert Summary
to a scrolling list: visible-count derived from the available area
(height - listStart - hint - margin) divided by lineStep, scroll arrows
"^"/"v" on the right edge when more items hide above/below. UP/DOWN
scroll within Summary; LEFT/RIGHT still switch views.
E-ink portrait and OLED both benefit; e-ink landscape fits all 5 items
without ever showing the arrows.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
LEFT/RIGHT cycles between Summary (existing) and the new Map view.
Pixel-by-pixel polyline auto-fits the bounding box of the points to
the visible area, with longitude scaled by cos(avg_lat) so trails at
higher latitudes don't smear east/west. Picks the limiting axis and
centres the non-limiting dimension, so the trail fills as much screen
as possible without distorting.
Markers:
- Start point: a 5×5 plus sign
- Current/last point: a 5×5 diagonal cross
Edge cases:
- Empty trail → "No trail yet" centred
- Single point or all samples colocated → single current marker at centre
- Degenerate area_w/h (very small screen) → no draw
Lines are Bresenham via 1×1 fillRect — a few-tens-of-ms render which is
fine for an on-demand screen. A drawPixel override on the two drivers
would speed this up, deferred until the cost becomes visible.
Bottom hint shows `<>` for view nav, "N/2" current view, and the
Enter-toggle label.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Tools › Breadcrumb. Enter on the screen toggles tracking; while
active, UITask::loop samples GPS at the cadence chosen in settings
(default 1 min) and drops the point into a 256-entry RAM ring guarded
by a min-distance gate (default 25 m). A "G" indicator joins "A" in
the status bar with the same blink convention.
New storage:
- examples/companion_radio/Breadcrumb.h — BreadcrumbStore class
- 256 × 12 B = 3 KB RAM (survives auto-off, lost on reboot — explicit
flash-slot save comes in phase 4)
- Haversine min-delta gate, total distance, elapsed seconds, current
km/h, bounding-box helper
New screen:
- examples/companion_radio/ui-new/BreadcrumbScreen.h — Summary view
with Status, Points, Dist (m or km), Time (h:mm), Speed; Enter
toggles tracking, Esc returns to Tools
Schema bump 0xC0DE0002 → 0xC0DE0003 for two new NodePrefs bytes:
breadcrumb_interval_idx (1min default) and breadcrumb_min_delta_idx
(25m default). Older saves leave both at zero, which matches the new
defaults. DataStore::loadPrefsInt/savePrefs read/write the pair.
Logging on/off is a runtime flag inside BreadcrumbStore — not a
preference — so reboot returns to the off state cleanly; the user
will be able to persist a snapshot to a slot in phase 4.
Phase 2 (map view), 3 (point list), 4 (slot save/load, GPX export over
USB), and 5 (settings UI) follow incrementally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on an empty Favourites slot opens a PopupMenu listing candidate
contacts. Sourced first from upstream-favourited chat contacts (those
flagged with c.flags & 0x01), then from recent DM contacts (resolved
from the 4-byte _dm_hist prefix to a full 6-byte pub_key via the contact
list), deduped against the favourites list. Capped at 12 entries.
Selection writes the chosen 6-byte prefix into the target slot; if the
contact is already pinned elsewhere, the previous slot is vacated first
(one contact per dial, per the agreed pin-only semantics). savePrefs
commits to flash and a brief alert confirms.
Plumbing:
- New QuickMsgScreen::getRecentDMContacts() resolves recent DM senders
to 6-byte pub_key prefixes, deduped, newest first
- UITask::getRecentDMContacts() wraps it so HomeScreen doesn't reach
into the QuickMsg screen directly
- HomeScreen gains its own PopupMenu + per-entry pub_key / label buffers
(12 × 6 B keys + 12 × 22 B labels), no shared state with QuickMsg's
Contact-options menu
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
QuickMsgScreen::enterDM (used by openContactDM from the Favourites dial)
now sets _dm_direct_entry so KEY_CANCEL in DM_HIST jumps back to the
home screen instead of falling through CONTACT_PICK → MODE_SELECT. The
flag clears on every reset() and after the cancel returns.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
openContactDM(const ContactInfo&) declared in UITask.h needed the type
visible; previously ContactInfo only reached UITask.cpp transitively
through MyMesh.h and broke the eink build.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Enter on a populated slot of the Favourites grid now jumps straight into
that contact's DM history — resolves the pinned 6-byte pub_key prefix
against the contact list, then routes through new
UITask::openContactDM(ci) which resets QuickMsgScreen and calls
enterDM() to land directly in DM_HIST.
Empty slots still show the "pin from contact options" hint until phase 3
wires the in-grid mini-picker.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a fourth item to the QuickMsg Contact options popup that toggles
between "Pin to dial" (when the contact isn't pinned) and
"Unpin (slot N)" (when it is). Pinning opens a six-slot picker submenu
labeled "Slot N: <name>" or "Slot N: empty"; the chosen slot stores the
6-byte pub_key prefix in NodePrefs::favourite_contacts and savePrefs
commits to flash with a brief confirmation alert.
New UITask helpers backing the flow:
- findFavouriteSlot(pub_key) — returns 0..5 if pinned, -1 otherwise
- isFavouriteSlotEmpty(slot)
- setFavouriteSlot(slot, pub_key) / clearFavouriteSlot(slot)
PopupMenu reuse: a single _ctx_menu instance hosts both the Contact
options menu and the slot picker, gated by _pin_picker_active so input
routes correctly. Reset on screen reset() and on any non-NONE menu
result.
Slot labels live in a member buffer (6×22 B) because PopupMenu stores
the string pointers verbatim.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
ensurePageOrderInit only inserted FAVOURITES when len < PAGE_ORDER_LEN.
Older saves with all 11 slots filled (default order with GPS + SENSORS)
hit the full-array branch and were left without FAVOURITES in the
explicit order, so the page was shown via the missing-page fallback at
end of nav but movePageInOrder couldn't reorder it.
Now drop the last entry (typically SHUTDOWN, which the same fallback
already re-appends at end of nav) to make room. End result: FAVOURITES
sits after CLOCK, reorderable; nav layout matches the new default.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Render issues:
- Drop the "FAVOURITES" title — it overlapped the node-name/battery top
bar; the page-dots indicator already identifies the page
- Use the locally-computed content_y (matches other home pages) so tiles
sit below the dots row instead of starting at headerH() + sepH()
- Add 2 px top/bottom margin inside the grid so cells don't hug the dots
or the panel edge
Upgrade migration (visibility + position):
- Newly-loaded prefs with an older sentinel OR a non-default mask had
HP_FAVOURITES off and could not be enabled — DataStore::loadPrefsInt
now ORs HP_FAVOURITES into home_pages_mask whenever the sentinel
doesn't match the current schema
- Existing page_order_set users were stuck because FAVOURITES wasn't in
page_order, so movePageInOrder bailed — ensurePageOrderInit now
detects the missing FAVOURITES entry and inserts it right after CLOCK,
shifting the tail; if the array is already full the page still
appears via the buildVisibleOrder fallback
Bug fix in ensurePageOrderInit verification loop: iterated to HPB_COUNT
(12) over a PAGE_ORDER_LEN (11) array → one OOB read per call. Now
bounded to PAGE_ORDER_LEN.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
New home page rendering 6 pinned-contact slots. Grid transposes to
orientation: landscape → 3×2, portrait → 2×3. Joystick claims UP/DOWN
and inner LEFT/RIGHT for grid nav; edge LEFT/RIGHT fall through to
page navigation. Selected tile inverts via drawSelectionRow, filled
tile shows contact name + unread badge, empty tile shows "+".
Storage:
- NodePrefs::favourite_contacts[6][6] (6-byte pub_key prefix per slot,
all-zero = empty; collision probability with a real key is 2^-48)
- HomePageBit gains HPB_FAVOURITES = 11 (HPB_COUNT = 12), with a new
HP_FAVOURITES mask bit so the page is toggleable in Settings
- New PAGE_ORDER_LEN constant decouples the persisted array length (11)
from the page-type count; loops over page_order now use it, value-range
checks still use HPB_COUNT
- homePageBit returns 0 for HPB_SETTINGS / HPB_QUICK_MSG explicitly
rather than by bit-index range, so HPB_FAVOURITES (bit 11) is correctly
treated as toggleable
- Default order inserts FAVOURITES after CLOCK; SHUTDOWN drops to the
fallback "missing pages" append (still reachable, lands at end)
Schema sentinel bumped to 0xC0DE0002. DataStore::loadPrefsInt and
savePrefs read/write the new field. Older saves leave the field
zero-initialised (all slots empty), which is the natural starting state.
Phase 2 (Pin from Contact options) and Phase 3 (Pin from empty-slot
mini picker) wire interactions; this commit handles render + nav only.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sample the unread total for the active mode (DM/Channels/Rooms) before
clearing, then alert "N marked read". Matches the original FEATURES.md
design note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Hold Enter (context menu) on the DM / Channels / Rooms picker opens a
single-item "Mark all read" menu that clears every unread counter of
the highlighted type. Per-mode title is a static-const string because
PopupMenu stores the title pointer verbatim.
New UITask::clearAllDMUnread() resets the whole _dm_unread_table;
clearAllChannelUnread() and clearRoomUnread() already existed.
Also document the GPS breadcrumb storage decision in FEATURES.md:
RAM-only ring with explicit per-slot saves, no background flash writes.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
pageBit/bitToPage in HomeScreen and homePageBitIndex in SettingsScreen
each spelled out the bit assignment with magic numbers 0..10. Lift the
numbering into a NodePrefs::HomePageBit enum (HPB_CLOCK … HPB_QUICK_MSG,
HPB_COUNT) and route every reference through it: mapping functions, the
HP_* bitmasks, the homePageLabel table sizing, loop bounds over
page_order, the bounds check on page_order[0], the "always-visible"
discriminator for SETTINGS/QUICK_MSG.
Per-class enums (HomeScreen::HomePage, SettingsScreen::SettingItem)
stay — they index different concerns (navigation page vs. settings
row). The mapping functions still exist, but now reference a single
source of truth for the bit values.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Extends Features.h with preprocessor flags (FEAT_BRIGHTNESS_SETTING,
FEAT_CLOCK_SECONDS_SETTING, FEAT_DISPLAY_ROTATION_SETTING,
FEAT_JOYSTICK_ROTATION_SETTING, FEAT_FULL_REFRESH_SETTING) since
constexpr can't gate enum members or class fields. SettingsScreen.h
now uses #if FEAT_X instead of #if defined(EINK_DISPLAY_MODEL) — grep
on the feature name finds the toggle and every use site, and the
condition names *what* the gate controls.
Driver headers (DisplayDriver.h, GxEPDDisplay.h) keep raw
EINK_DISPLAY_MODEL — they're below the companion_radio layer and the
ifdef there gates on the build's panel type, not a UI feature.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Five sites in UITask.cpp + MyMesh.cpp branched on EINK_DISPLAY_MODEL
purely for runtime-shape decisions (blink rate, default pref values,
refresh intervals). Replace with a constexpr Features namespace:
IS_EINK, BLINK_INDICATORS, CLOCK_HIDE_SECONDS_DEFAULT, HOME_REFRESH_MS,
LOCKSCREEN_REFRESH_MS. Compiler dead-branch-eliminates so cost is
identical to the preprocessor branch, but the code stays reachable to
tooling and the policy lives in one file.
SettingsScreen and driver headers still need real #ifdef — they
condition enum members and class field layouts.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Mechanical sweep across UITask.cpp and SettingsScreen.h. All current
format strings already fit their buffers, but a future format change
would silently overflow with sprintf. snprintf with sizeof makes the
buffer bound explicit and traceable to the array declaration.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Match the rest of the file's defaults; constructor body no longer
re-initialises a field that the in-class initializer already set.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Eight screens repeated the same if/else block to invert the row
background for selected items. Add DisplayDriver::drawSelectionRow that
sets LIGHT, optionally fills the rect, and leaves the colour as DARK
when sel (so the caller's text renders inverted). 61 lines removed
across SettingsScreen, KeyboardWidget (cells + special row), ToolsScreen,
DashboardConfigScreen, BotScreen, RingtoneEditorScreen (note slots +
menu), NearbyScreen, QuickMsgScreen (4 list views).
Card-style rows (QuickMsg hist, NearbyScreen discover, RingtoneEditor
notes display) keep their original drawRect/partial-fill outline for
unselected — they don't match the simple-invert pattern.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Three copies of the same routine: DisplayDriver::decodeCodepoint (added
in the FullscreenMsgView wrap fix) plus per-driver duplicates in
SH1106Display and GxEPDDisplay. Consolidate on DisplayDriver and have
translateUTF8Static also route through it instead of its own inline
decoder. Net: 63 lines removed, single source of truth.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The wrap loop used to memcpy a growing substring and call getTextWidth
on it for every appended codepoint — O(n²) UTF-8 decoding with Lemon
font, ~5-20 ms on a full e-ink redraw of a long message.
Add DisplayDriver::getCodepointWidth(cp) (default: build a UTF-8 buffer
and forward to getTextWidth) plus DisplayDriver::decodeCodepoint() as
the shared UTF-8 walker. Override getCodepointWidth in GxEPDDisplay
(direct lemonXAdvance for Lemon size 1) and SH1106Display (lemonXAdvance
for Lemon, fixed 6*sz for built-in font). FullscreenMsgView now walks
codepoints once and sums widths incrementally.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old saves (without sentinel) load unchanged — rd() lambda zero-inits
anything not on disk. New saves append a 4-byte sentinel that encodes
the schema revision; the low byte is bumped on every layout change in
loadPrefsInt/savePrefs. Mismatch on load logs a warning and continues
with defaults; the next savePrefs writes the current sentinel so the
file self-heals.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Old 80+ case switch generated a jump table around ~600-1200 B of flash;
new layout is two parallel static-const arrays (uint16 codepoint + char
ascii, 144 entries each = 432 B) and a 5-line bsearch loop. Same input
range, same output, faster lookup (~log2(144) ≈ 7 compares vs jump-table
indirection + bounds check).
Adds a static_assert that the two arrays stay in sync.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- DM/channel history rows precomputed age_w (= getTextWidth(age) + 3) but
then called getTextWidth(age) again to place the cursor. Reuse age_w.
- Contact-by-message-count sort fetched each contact twice: once when
building _sorted, once when filling counts[]. Fold counts[] into the
build loop so each contact is fetched once.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Per-char recomputation of (width() >= height() ? 2 : 1) in drawLemonChar
and lemonXAdvance is now passed in by the caller. print() and
getTextWidth() compute scale() once and feed it to every glyph. All
other callers in the file route through the helper for consistency.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
For a 140-byte channel msg with a 64-byte trigger, the old loop did
~8960 tolower() calls per inbound message; now it's 140 tolower calls
plus one strstr.
perf(buzzer): skip nRF52 STOPPED wait when PWM was not running
TASKS_STOP on a disabled PWM never fires EVENTS_STOPPED, so the wait
always timed out at 2 ms. Gating on _pwm_on removes the wait on the
first note of a melody and after pauses, where there's nothing to wait
for.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Previously page_order_set status was inferred from page_order[0] being
in 1..11, so a single junk byte in that range falsely triggered
custom-order mode. Add explicit page_order_set magic (0xA5) written
alongside the array; pre-magic saves are migrated once on load when
their first byte still passes the legacy range check.
Also fix(ui): keep "@[nick] " prefix out of placeholder expansion in
QuickMsgScreen reply path, so a nick containing a token like {loc}
isn't substituted.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
setTextSize(2/3) switches GFX to non-Lemon fonts (built-in scaled or
FreeSans18pt). The Lemon path bypassed GFX entirely and rendered glyphs
at size 1 scale, shrinking splash version and clock when Lemon font
was active.
Refresh CODE_REVIEW.md: remove resolved issues (signed-int timing vars,
abs() no-op, XBM CRC, loadPrefs nesting, lock-screen LPP thrash, reply
prefix raw UTF-8), add new findings (upstream-merge surface, virtual
width()/height() per glyph, page_order sentinel), list enhancement
ideas.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The placeholder rectangle for codepoints outside U+0020-U+04FF was drawn
1 scale unit too low (y-7*sc instead of y-8*sc relative to the GFX
baseline), causing it to sit below full-height glyphs.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>