Skip to content

feat(app): tell the truth about freshness, keep the tabs in reach, and let a phone leave - #14

Merged
frahlg merged 2 commits into
mainfrom
freshness-shell-charts-and-sign-out
Aug 6, 2026
Merged

feat(app): tell the truth about freshness, keep the tabs in reach, and let a phone leave#14
frahlg merged 2 commits into
mainfrom
freshness-shell-charts-and-sign-out

Conversation

@frahlg

@frahlg frahlg commented Aug 6, 2026

Copy link
Copy Markdown
Member

Five things Fredrik found testing on his own box, and the holes that fixing the first one opened.

Needs srcfl/ftw#838, merged — the vendored chart cites its commit.

"No reading yet" on a box that was answering

The band read Live via encrypted relay · no reading yet for a whole session while every number on screen moved.

The app was asking after three source ids it had been compiled with — the simulator's meter.p1, inverter.sungrow, battery.sungrow. A real box builds its dictionary from its running drivers, so on Fredrik's box every id is a driver name and none of the three exists. Unknown ranks worse than dead, so all three came back never.

This is CLAUDE.md's "never hand-write a name shared with the box", exactly. The ids now come from the dictionary the box sends. It could not have been caught against the simulator — it is the only box in the world that matched.

Fixing that opened worse holes than it closed

Most of this PR is closing them. Each was reproduced, and each has a test that fails when its fix is reverted.

  • A carrier was claimed the moment its socket opened. A launch from cache showed a two-hour-old house under "Live via encrypted relay" — with the age suppressed, because the band hides it while live. Nothing on screen betrayed it.
  • Answering hello moved the box's clock without sending a reading. A box that was booting, wedged, or refusing this device looked live. It re-hellos every five seconds, so it re-armed forever.
  • An empty source list read as healthy — the worst-of scan starts at live and never enters its loop. A box that names no source for anything drawn had every driver down and the band said all clear.
  • A cache restore counted as an arriving frame, dating readings from the launch that displayed them.

So: a carrier is claimed once it has delivered a reading; entering the stream is the first reading (the snapshot carries the same uptime hello just announced); a box that names no source is judged by the sources it does report; and a stream that has gone quiet stops being live, which is what puts the age back on screen.

The tab bar below the fold

The shell was min-height: 100dvh, so it grew past the viewport rather than scrolling inside it, and the nav went with it. Measured on Plan at 375 px: document scroll 1826 px → 0, nav bottom edge 2638 px → 812 px.

Charts

The price chart took 57 % of a phone before the timeline began; now 45 %. History was "lite hackig i sin form". Both are the box's own components — changed upstream and re-vendored, not forked.

A phone can leave

Signing out clears what is on the device. It does not tell the box to forget it: the app has no message that says so, and a sign-out that only worked online would not be one — a phone is handed on exactly where its box is not. The screen says both halves and sends you to the box for the other.

The order is the design: the pointer, then the rows, then the key — and never the key while a row it sealed is still there. That combination is a house on screen that can never be opened again: the next launch mints a fresh key, unsealing throws where nothing catches it, and the app paints nothing. A phone that reloading cannot fix, in an app whose principle is that reloading is never the fix.

Two screens that contradicted each other

The band told a phone whose access had been withdrawn that it could not reach its box, while the screen directly below said the owner had removed it. The box was perfectly reachable; it had made a decision about the phone.

And "keeps trying on its own" was a promise nothing kept when no carrier had been built at all. ConnectError already carries the sentence saying what to do instead, and the app was throwing it away.

Review notes

  • 475 tests, 495 files type-clean. Every fix here was verified by reverting it and watching its test fail — three rounds of this work were lost to tests that passed against both broken and fixed code.
  • Two of the four agents that wrote parts of this stalled before reporting, so their code was read and verified by others afterwards rather than trusted.
  • Known and deliberately not in this PR: boxFingerprint duplicates fingerprintOf in Pair.svelte; Chart.svelte carries raw hex fallbacks against the design-role convention; the band/line switch flips on a 20 px width change; openFeed's dynamic imports can land after a sign-out.

🤖 Generated with Claude Code

…d let a phone leave

Five things Fredrik found testing on his own box.

The freshness band read "no reading yet" for a whole session while every number
on screen moved. The app was asking after three source ids it had been compiled
with — the simulator's — and a real box names its sources after its own
drivers, so all three came back unknown and unknown ranks worse than dead. The
ids now come from the dictionary the box sends, which is what the rule about
never hand-writing a name shared with the box is for. The simulator was the
only box in the world that matched.

Fixing that opened worse holes than it closed, and closing those is most of
this change. A carrier was claimed the moment its socket opened, so a launch
from cache showed a two-hour-old house under "Live via encrypted relay" with
the age suppressed. Answering hello moved the box's clock without sending a
reading, so a box that was booting, wedged or refusing this device looked
live — and it re-hellos every five seconds, so it re-armed forever. An empty
source list read as healthy, because the worst-of scan starts at live and never
enters its loop. A cache restore counted as an arriving frame.

So: a carrier is claimed once it has delivered a reading, entering the stream
is the first one, a box that names no source is judged by the sources it does
report, and a stream that has gone quiet stops being live so the age returns to
the screen.

The tab bar sat below the fold on any view taller than the screen: the shell
was min-height, so it grew past the viewport instead of scrolling inside it.

The price chart took 57% of a phone before the timeline began, and history drew
its series in a way Fredrik called hackig. Both are the box's own components,
changed upstream and re-vendored rather than forked.

And a phone can leave. Signing out clears what is on the device; it does not
tell the box to forget it, because the app has no message that says so and a
sign-out that only worked online would not be one. The screen says both halves
and sends you to the box for the other. The order is the design: the pointer,
then the rows, then the key — and never the key while a row it sealed is still
there, because that is a house on screen that can never be opened again and no
amount of reloading fixes it.

Two screens stopped contradicting each other. The band told a phone whose
access had been withdrawn that it could not reach its box, while the screen
below said the owner had removed it. And "keeps trying on its own" was a
promise nothing kept when no carrier had been built at all — ConnectError
already carries the sentence saying what to do, and the app was throwing it
away.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Aug 6, 2026

Copy link
Copy Markdown

Deploying with  Cloudflare Workers  Cloudflare Workers

The latest updates on your project. Learn more about integrating Git with Workers.

Status Name Latest Commit Updated (UTC)
✅ Deployment successful!
View logs
ftw-webapp 8c444a9 Aug 06 2026, 09:44 AM

The instanceof needed ConnectError, which meant importing connect.ts on the
launch path — and the test that mocks that module does not export the class, so
every run raised an unhandled rejection while all 475 tests passed. Green
locally only because I filtered the summary down to the lines I expected.

The help string is the only part being used, so read it structurally. That also
keeps the carrier stack out of the entry chunk, which is what the dynamic
import was for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@frahlg
frahlg merged commit 4c4cf9b into main Aug 6, 2026
2 checks passed
@frahlg
frahlg deleted the freshness-shell-charts-and-sign-out branch August 6, 2026 09:45

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b86690a2d3

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/lib/store/reset.ts
Comment on lines +67 to +69
} catch (err) {
failures.push(err)
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Preserve the site row after any projection clear fails

When any store clear fails, this catch records the error but continues deleting later stores, including sites; a failure clearing snapshot, or a failure clearing the final keys store after sites succeeded, therefore makes forgetEverything() reject after the rendezvous secret and pinned box key are already gone. The recovery path in App.svelte then calls openFeed(), but connectToSite() requires that site row and returns not_paired, so the screen's claim that the home is still present and working is false and the user must physically rescan the box. Stop clearing subsequent stores after the first failure, or otherwise keep the site row until the operation can complete.

Useful? React with 👍 / 👎.

Comment thread src/lib/store/snapshot.ts
Comment on lines 148 to +152
stop(): void {
if (this.#timer) clearTimeout(this.#timer)
this.#timer = null
this.#pending = null
this.#stopped = true

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Wait for active snapshot writes before clearing storage

When #flush() has already removed a snapshot from #pending and is awaiting sealing or IndexedDB, stop() clears only the timer and pending slot; the active saveSnapshot() continues and can execute its final snapshot put after forgetEverything() has cleared that store. This can make sign-out report success while a sealed household snapshot is written back, especially when a timer or visibilitychange flush overlaps the sign-out. Track and await or invalidate the in-flight write before clearing the databases.

Useful? React with 👍 / 👎.

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