diff --git a/CLAUDE.md b/CLAUDE.md
index 6540c18..3c75e40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -35,7 +35,8 @@ These exist because breaking one of them breaks a promise made to users.
- **Never fake live.** Every reading carries its age. If the box is
unreachable, the app shows the last value with its timestamp — never a
stale number styled as current.
-- **Freshness is two fields, never one.** `carrier` (relay, cache, none) and
+- **Freshness is two fields, never one.** `carrier` (webrtc, relay, cache,
+ none — from the registry) and
`srcState` (live, lagging, stale, down, never) are orthogonal. Collapsing
them into a single enum cannot express "connected, but the inverter went
quiet 40 seconds ago", which is the case users most need to see.
@@ -72,7 +73,9 @@ These exist because breaking one of them breaks a promise made to users.
from this app's catalogue in CI.
- Tests sit beside the code as `*.test.ts`. Full-flow tests live in `tests/`.
- Prefer explicit state over clever reactivity. A 1 Hz stream must not
- recompute the tree; field cells are `$state.raw` and updated by fid.
+ re-render the tree: readings live in one session value so a frame is
+ consistent with itself, and everything derived from it memoises, so a
+ frame that changes nothing repaints nothing.
## Build and test
diff --git a/src/App.svelte b/src/App.svelte
index 400b806..3e00897 100644
--- a/src/App.svelte
+++ b/src/App.svelte
@@ -181,6 +181,10 @@
if (import.meta.env.DEV && id === SIM_SITE_ID) {
// Dynamic import keeps the simulator out of production bundles entirely.
void import('$lib/dev/simulated-site').then(({ attachSimulatedSite }) => {
+ // The import lands an await after it was asked for, and a sign-out in
+ // that gap replaces the store. A feed for the discarded one must not
+ // attach — nor overwrite the stopFeed that stops the current one.
+ if (store !== site) return
stopFeed = attachSimulatedSite(store).stop
})
} else {
@@ -264,6 +268,16 @@
openFeed(site, pairedSiteId)
}
+ /**
+ * The last sign-out failed and the home was put back. False otherwise.
+ *
+ * Held by the shell because the failure outlives the screen that met it:
+ * a failed leave remounts every view against the restored store, and a
+ * remounted Box has no memory of its own. The same shape as connectHelp —
+ * shell state passed down, so the screen can say what happens now.
+ */
+ let leaveFailed = $state(false)
+
/**
* Leave this home — or put it back, if the disk would not let go of it.
*
@@ -278,11 +292,15 @@
*
* When the disk refuses, this phone still holds the home. So the shell puts
* it back on screen and back on the wire rather than leaving a frozen view
- * behind a message promising it still works, and rethrows so the Box screen
- * can say what happened.
+ * behind a message promising it still works. The views are torn down the
+ * same way a successful leave tears them down — kept, they hold the stopped
+ * store, untracked on purpose, and never ask the restored one for anything.
+ * `leaveFailed` is how the remounted Box screen still knows what happened,
+ * because its own memory of the attempt goes with it.
*/
async function leave() {
const id = site.siteId ?? siteId
+ leaveFailed = false
try {
const { leaveHome } = await import('$lib/state/leave')
@@ -301,6 +319,12 @@
// the slow way — through the database, after painting nothing.
void import('$lib/identity/pairing').then(({ setCurrentSite }) => setCurrentSite(id))
}
+ // The same reset the success path makes, for the same reason: a view
+ // kept from the old store would come back holding a home that has
+ // stopped. The route has not moved, so the screen being looked at is
+ // rebuilt in place against the restored store.
+ leaveFailed = true
+ seen = { plan: false, history: false, box: false }
throw err
}
@@ -372,7 +396,12 @@
A view that has never been opened is still not built: `seen` gates
the first mount, so the cost is paid once and never again. -->
+ This screen didn't load — it will try again next time you open the app.
+
{/await}
{/if}
@@ -450,6 +491,14 @@
min-height: 60vh;
}
+ /* A view whose code never arrived. Quiet prose where the screen would be,
+ because a blank panel under a working tab bar reads as a broken app. */
+ .load-note {
+ padding: var(--space-7) var(--space-4);
+ color: var(--fg-muted);
+ font-size: 13px;
+ }
+
/* `hidden` is the switch, so a view that is not showing costs no layout and
is invisible to assistive technology — while keeping its element
instances, its scroll position and whatever it had already loaded. */
diff --git a/src/App.svelte.test.ts b/src/App.svelte.test.ts
index 874ec8c..f4d9e65 100644
--- a/src/App.svelte.test.ts
+++ b/src/App.svelte.test.ts
@@ -29,6 +29,16 @@ import {
} from '$lib/identity/vault'
import { CarrierBase, type Carrier, type CarrierStatus } from '$lib/carrier/carrier'
import { encodeFrame, LANE_CONTROL } from '$lib/protocol/frame'
+import { SiteStore } from '$lib/state/site.svelte'
+
+// jsdom has no ResizeObserver, and the History chart measures its own box
+// with one. Nothing below depends on the width it would report.
+class QuietResizeObserver {
+ observe(): void {}
+ unobserve(): void {}
+ disconnect(): void {}
+}
+globalThis.ResizeObserver ??= QuietResizeObserver as unknown as typeof ResizeObserver
/* The relay, stubbed at the one seam the shell reaches it through.
*
@@ -209,6 +219,48 @@ describe('signing out, from the phone', () => {
await screen.findByText(/Live via encrypted relay/i, undefined, { timeout: 4_000 })
expect(localStorage.getItem('ftw.site'), 'the launch pointer went with the home').toBe(SITE_ID)
})
+
+ it('gives a kept view back to the restored store, not to the stopped one', async () => {
+ // The other half of putting the home back. Views read the store once,
+ // untracked on purpose, so a view kept across the failed leave holds the
+ // one that was stopped for good — History sat mounted and never asked
+ // the restored store anything, forty seconds past every retry window.
+ // The failed path has to reset `seen` the way the successful one does.
+ const askedHistory = vi.spyOn(SiteStore.prototype, 'history')
+
+ await houseOnScreen()
+ ;(await screen.findByRole('button', { name: /^history$/i })).click()
+ await vi.waitFor(() => expect(askedHistory).toHaveBeenCalled(), { timeout: 4_000 })
+ const oldStore = askedHistory.mock.contexts.at(-1)
+ const before = askedHistory.mock.calls.length
+
+ const database = await db()
+ const clear = database.clear.bind(database)
+ vi.spyOn(database, 'clear').mockImplementation(async (store) => {
+ if (store === 'snapshot') await clear(store)
+ else throw new Error('quota exceeded')
+ })
+
+ await confirmSignOut()
+ // The failure is still said, by a screen that was itself remounted.
+ await screen.findByText(/still on this phone and still works/i, undefined, { timeout: 4_000 })
+
+ // Back to History, the way a person returns to it.
+ ;(await screen.findByRole('button', { name: /^history$/i })).click()
+ await vi.waitFor(
+ () => {
+ expect(
+ askedHistory.mock.calls.length,
+ 'the restored store was never asked for a window'
+ ).toBeGreaterThan(before)
+ expect(
+ askedHistory.mock.contexts.at(-1),
+ 'the ask went to the store that was stopped for good'
+ ).not.toBe(oldStore)
+ },
+ { timeout: 4_000 }
+ )
+ })
})
/* The state this whole screen was built for, and the one it used to get wrong.
diff --git a/src/lib/carrier/carrier.ts b/src/lib/carrier/carrier.ts
index 4e8465e..78d4ba8 100644
--- a/src/lib/carrier/carrier.ts
+++ b/src/lib/carrier/carrier.ts
@@ -86,16 +86,3 @@ export class CarrierBase {
this.#statusHandlers.clear()
}
}
-
-/**
- * How long a carrier must be quiet before the app stops calling it live.
- *
- * Deliberately longer than one missed frame. At 1 Hz a single dropped tick is
- * normal on mobile; demoting on the first gap would make the freshness band
- * flicker, and a flickering indicator teaches users to ignore it — which
- * defeats the one thing it exists to do.
- */
-export const DEGRADE_AFTER_MS = 4_000
-
-/** Further silence, after which the app falls back to the cache carrier. */
-export const FALL_BACK_AFTER_MS = 10_000
diff --git a/src/lib/carrier/handshake-resilience.test.ts b/src/lib/carrier/handshake-resilience.test.ts
index 3984d99..b227a81 100644
--- a/src/lib/carrier/handshake-resilience.test.ts
+++ b/src/lib/carrier/handshake-resilience.test.ts
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { NoiseCarrier } from './noise'
import { CarrierBase, type Carrier, type CarrierStatus } from './carrier'
-import { generateKeyPair } from '$lib/crypto/noise'
+import { generateKeyPair, HandshakeState } from '$lib/crypto/noise'
+import { NoiseTransport } from '$lib/crypto/transport'
import type { CarrierState } from '$lib/protocol/types'
/* A stray frame during the handshake must not end the carrier.
@@ -53,7 +54,7 @@ function carrierUnderTest() {
})
const seen: CarrierStatus[] = []
carrier.onStatus((s) => seen.push(s))
- return { inner, carrier, seen }
+ return { inner, carrier, seen, box }
}
describe('a handshake meeting somebody else’s frames', () => {
@@ -103,4 +104,41 @@ describe('a handshake meeting somebody else’s frames', () => {
expect(closed, 'silence never ended the handshake').toBeDefined()
expect(closed && 'retryable' in closed && closed.retryable).toBe(true)
})
+
+ it('asks again after the deadline, and a late-answering box still gets in', async () => {
+ // The deadline ends the first attempt retryably — but the socket is
+ // still open, and the inner carrier never re-emits 'open' on a socket
+ // that never dropped. The retry has to come from the Noise carrier
+ // itself, or a box rebooting through an update leaves the app closed
+ // until the epoch rotates.
+ const { inner, carrier, seen, box } = carrierUnderTest()
+ inner.open()
+ await vi.advanceTimersByTimeAsync(10)
+ expect(inner.sent.length).toBe(1)
+
+ await vi.advanceTimersByTimeAsync(13_000)
+ expect(seen.some((s) => s.phase === 'closed' && s.retryable)).toBe(true)
+
+ // A fresh message 1 goes out on its own, without the socket moving.
+ // Walked in small steps so the answer below lands while the newest
+ // attempt is still waiting, wherever the jitter put it. The step count
+ // is a bound, not a schedule: far more fake time than any first retry.
+ for (let i = 0; i < 100 && inner.sent.length === 1; i++) await vi.advanceTimersByTimeAsync(500)
+ expect(inner.sent.length, 'no second handshake was ever attempted').toBeGreaterThan(1)
+
+ // The box comes back and answers the newest attempt; the session opens
+ // with no reload and no reconnect.
+ const responder = HandshakeState.responder({ staticKey: box })
+ await responder.readMessage(inner.sent.at(-1)!)
+ inner.deliver(await responder.writeMessage())
+ await vi.advanceTimersByTimeAsync(10)
+ expect(carrier.status.phase).toBe('open')
+
+ // And frames flow: the box's first transport frame decrypts and surfaces.
+ const heard: Uint8Array[] = []
+ carrier.onFrame((f) => heard.push(f))
+ const boxTransport = new NoiseTransport(responder.split())
+ inner.deliver(boxTransport.encrypt(Uint8Array.from([7, 7, 7])))
+ expect(heard).toEqual([Uint8Array.from([7, 7, 7])])
+ })
})
diff --git a/src/lib/carrier/noise.ts b/src/lib/carrier/noise.ts
index abf4bc4..f621a1c 100644
--- a/src/lib/carrier/noise.ts
+++ b/src/lib/carrier/noise.ts
@@ -27,6 +27,19 @@ import { NoiseTransport } from '$lib/crypto/transport'
*/
const HANDSHAKE_DEADLINE_MS = 12_000
+/**
+ * Retry pacing for a handshake that timed out on a healthy socket.
+ *
+ * The inner carrier redials a dead socket on its own, but it never re-emits
+ * 'open' on one that stayed up — and a box rebooting through an update says
+ * nothing while its socket stands. Without a retry here, one silent handshake
+ * would leave the app closed until the epoch rotates. Full jitter, like the
+ * relay's dial backoff, and capped so a revoked phone — deliberate silence,
+ * forever — costs one 48-byte message a minute at worst.
+ */
+const HANDSHAKE_BACKOFF_BASE_MS = 3_000
+const HANDSHAKE_BACKOFF_CAP_MS = 60_000
+
/**
* Message 2 of Noise_IK: the responder's ephemeral public key and one AEAD
* tag over an empty payload. Fixed by the pattern, so anything else on the
@@ -73,6 +86,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
/** True once message 1 is out and we are waiting for the reply. */
#awaitingReply = false
#deadline: ReturnType | undefined
+ #retry: ReturnType | undefined
+ #attempt = 0
#log: ((line: string) => void) | undefined
/** Kept so each reconnection can start a fresh handshake from the same input. */
@@ -129,6 +144,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
if (this.#closed) return
this.#closed = true
+ clearTimeout(this.#deadline)
+ this.#clearRetry()
for (const u of this.#unsub) u()
this.#unsub = []
@@ -145,6 +162,8 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
if (this.#closed) return
if (s.phase === 'open') {
+ this.#attempt = 0
+ this.#clearRetry()
this.#beginHandshake()
return
}
@@ -152,10 +171,13 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
// The inner carrier reconnects on its own, but a Noise session cannot
// survive the gap: its keys are bound to one handshake and its counters
// to one stream. So a drop restarts the handshake rather than resuming,
- // which is also why split() must never be called twice.
+ // which is also why split() must never be called twice. A pending retry
+ // is cancelled too — its socket is gone, and the reconnect ends in an
+ // 'open' that starts a fresh handshake at once.
this.#transport?.close()
this.#transport = null
this.#awaitingReply = false
+ this.#clearRetry()
this.#setStatus(s)
}
@@ -223,6 +245,7 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
this.#transport = new NoiseTransport(handshake.split())
this.#awaitingReply = false
this.#handshake = null
+ this.#attempt = 0
this.#setStatus({ phase: 'open', sinceMs: Date.now() })
})
.catch((err) => {
@@ -242,9 +265,28 @@ export class NoiseCarrier extends CarrierBase implements Carrier {
}
#fail(reason: string, retryable = true): void {
+ if (this.#closed) return
this.#transport?.close()
this.#transport = null
this.#awaitingReply = false
this.#setStatus({ phase: 'closed', reason, retryable })
+
+ // A retryable failure on a socket that still stands is retried from here,
+ // because nowhere else will: nothing above consumes `retryable`, and the
+ // inner carrier only re-emits 'open' after an actual reconnect. Between
+ // attempts the status stays closed-and-retryable, which is the truth.
+ if (!retryable || this.#inner.status.phase !== 'open') return
+ this.#clearRetry()
+ const ceiling = Math.min(HANDSHAKE_BACKOFF_CAP_MS, HANDSHAKE_BACKOFF_BASE_MS * 2 ** this.#attempt)
+ this.#attempt = Math.min(this.#attempt + 1, 16)
+ this.#retry = setTimeout(() => {
+ this.#retry = undefined
+ this.#beginHandshake()
+ }, Math.random() * ceiling)
+ }
+
+ #clearRetry(): void {
+ clearTimeout(this.#retry)
+ this.#retry = undefined
}
}
diff --git a/src/lib/carrier/relay.test.ts b/src/lib/carrier/relay.test.ts
index 29c2534..2a949b2 100644
--- a/src/lib/carrier/relay.test.ts
+++ b/src/lib/carrier/relay.test.ts
@@ -8,7 +8,7 @@
* carrier is expected to sort itself out without being asked.
*/
-import { describe, it, expect, beforeEach, afterEach } from 'vitest'
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { RelayServer } from '../../../relay/src/server.ts'
import { RelayCarrier, BACKOFF_CAP_MS } from './relay'
import { rendezvousHandle } from './rendezvous'
@@ -187,3 +187,83 @@ describe('the relay carrier', () => {
expect(BACKOFF_CAP_MS).toBe(60_000)
})
})
+
+/**
+ * A socket the test opens, feeds and drops by hand, so a crash-looping relay
+ * can be played back without a server behind it.
+ */
+class StubSocket {
+ static all: StubSocket[] = []
+ readyState = 0
+ binaryType = ''
+ onopen: (() => void) | null = null
+ onmessage: ((ev: { data: unknown }) => void) | null = null
+ onclose: ((ev: { code: number; reason: string }) => void) | null = null
+ onerror: (() => void) | null = null
+
+ constructor(readonly url: string) {
+ StubSocket.all.push(this)
+ }
+
+ send(): void {}
+ close(): void {
+ this.readyState = 3
+ }
+
+ /** The relay accepts and the box is present. */
+ accept(): void {
+ this.readyState = 1
+ this.onopen?.()
+ this.onmessage?.({ data: 'ready' })
+ }
+ frame(): void {
+ this.onmessage?.({ data: new Uint8Array(8).buffer })
+ }
+ drop(): void {
+ this.readyState = 3
+ this.onclose?.({ code: 1006, reason: '' })
+ }
+}
+
+describe('backing off from a relay that accepts and then drops', () => {
+ beforeEach(() => {
+ vi.useFakeTimers()
+ StubSocket.all = []
+ })
+ afterEach(() => vi.useRealTimers())
+
+ it('resets the dial backoff on a delivered frame, not on the accept', () => {
+ // random: () => 1 makes every delay its full ceiling, so the schedule is
+ // exact: 500, 1000, 2000... A relay stuck in a crash loop accepts each
+ // join and dies before a frame moves. Resetting the counter at the
+ // accept had every redial landing at the floor interval for as long as
+ // the loop lasted.
+ const carrier = new RelayCarrier({
+ url: 'ws://relay.invalid',
+ secret: SECRET,
+ WebSocketImpl: StubSocket as unknown as typeof WebSocket,
+ random: () => 1,
+ })
+
+ StubSocket.all.at(-1)!.accept()
+ StubSocket.all.at(-1)!.drop()
+ vi.advanceTimersByTime(500)
+ expect(StubSocket.all.length).toBe(2)
+
+ StubSocket.all.at(-1)!.accept()
+ StubSocket.all.at(-1)!.drop()
+ vi.advanceTimersByTime(999)
+ expect(StubSocket.all.length, 'an accept alone reset the backoff').toBe(2)
+ vi.advanceTimersByTime(1)
+ expect(StubSocket.all.length).toBe(3)
+
+ // One frame through is proof the path works, and the counter heals.
+ StubSocket.all.at(-1)!.accept()
+ StubSocket.all.at(-1)!.frame()
+ StubSocket.all.at(-1)!.drop()
+ vi.advanceTimersByTime(500)
+ expect(StubSocket.all.length).toBe(4)
+
+ carrier.close()
+ })
+})
diff --git a/src/lib/carrier/relay.ts b/src/lib/carrier/relay.ts
index 0263aaa..1ad3225 100644
--- a/src/lib/carrier/relay.ts
+++ b/src/lib/carrier/relay.ts
@@ -182,7 +182,6 @@ export class RelayCarrier extends CarrierBase implements Carrier {
#onMessage(ev: MessageEvent): void {
if (typeof ev.data === 'string') {
if (ev.data === CTRL_READY) {
- this.#attempt = 0
this.#corrections = 0
this.#setStatus({ phase: 'open', sinceMs: this.#now() })
} else if (ev.data === CTRL_GONE) {
@@ -193,6 +192,11 @@ export class RelayCarrier extends CarrierBase implements Carrier {
}
if (this.#status.phase !== 'open') return
+ // Only a delivered frame proves the path works, so this is where the dial
+ // backoff resets. Resetting on the accept let a relay that accepts and
+ // then dies keep us dialling at the floor interval for as long as it
+ // crash-looped.
+ this.#attempt = 0
this.emitFrame(new Uint8Array(ev.data as ArrayBuffer))
}
diff --git a/src/lib/crypto/noise.test.ts b/src/lib/crypto/noise.test.ts
index fb67a36..a9e58a2 100644
--- a/src/lib/crypto/noise.test.ts
+++ b/src/lib/crypto/noise.test.ts
@@ -236,6 +236,64 @@ describe('a relay cannot present itself as a box', () => {
})
})
+describe('a stray frame cannot poison a waiting handshake', () => {
+ // The relay broadcasts every uplink frame to all streams in a room, and
+ // message 2 is exactly 48 bytes — two phones connecting at once read each
+ // other's replies. A reply that fails to authenticate must leave the
+ // handshake exactly as it was, or the genuine reply can never open it.
+ it('completes on the genuine message 2 after rejecting somebody else’s', async () => {
+ const { app, box } = pair()
+ const initiator = HandshakeState.initiator({ staticKey: app, remoteStatic: box.publicKey })
+ const responder = HandshakeState.responder({ staticKey: box })
+
+ await responder.readMessage(await initiator.writeMessage())
+ const m2 = await responder.writeMessage()
+
+ // Another household's valid message 2: right shape, wrong session.
+ const otherBox = generateKeyPair()
+ const otherInitiator = HandshakeState.initiator({
+ staticKey: generateKeyPair(),
+ remoteStatic: otherBox.publicKey,
+ })
+ const otherResponder = HandshakeState.responder({ staticKey: otherBox })
+ await otherResponder.readMessage(await otherInitiator.writeMessage())
+ const stray = await otherResponder.writeMessage()
+
+ await expect(initiator.readMessage(stray)).rejects.toThrow(NoiseError)
+
+ // The genuine reply still opens the session, and a transport frame
+ // round-trips under the split keys.
+ await initiator.readMessage(m2)
+ const a = initiator.split()
+ const b = responder.split()
+ const ad = new Uint8Array(0)
+ const frame = utf8('grid_w=1200')
+ expect(bytesToHex(b.recv.decryptWithAd(ad, a.send.encryptWithAd(ad, frame)))).toBe(bytesToHex(frame))
+ expect(bytesToHex(a.recv.decryptWithAd(ad, b.send.encryptWithAd(ad, frame)))).toBe(bytesToHex(frame))
+ })
+
+ it('completes on the genuine message 1 after rejecting somebody else’s', async () => {
+ const { app, box } = pair()
+ const initiator = HandshakeState.initiator({ staticKey: app, remoteStatic: box.publicKey })
+ const responder = HandshakeState.responder({ staticKey: box })
+ const m1 = await initiator.writeMessage()
+
+ // A message 1 aimed at some other box. Its first decryption cannot
+ // authenticate here, and it must not move the responder either.
+ const strayInitiator = HandshakeState.initiator({
+ staticKey: generateKeyPair(),
+ remoteStatic: generateKeyPair().publicKey,
+ })
+ await expect(responder.readMessage(await strayInitiator.writeMessage())).rejects.toThrow(NoiseError)
+
+ await responder.readMessage(m1)
+ await initiator.readMessage(await responder.writeMessage())
+ expect(initiator.isComplete).toBe(true)
+ expect(responder.isComplete).toBe(true)
+ expect(bytesToHex(initiator.split().handshakeHash)).toBe(bytesToHex(responder.split().handshakeHash))
+ })
+})
+
describe('state machine', () => {
it('refuses to write out of turn', async () => {
const { app, box } = pair()
diff --git a/src/lib/crypto/noise.ts b/src/lib/crypto/noise.ts
index 83e7d2c..1279d94 100644
--- a/src/lib/crypto/noise.ts
+++ b/src/lib/crypto/noise.ts
@@ -226,6 +226,18 @@ export class CipherState {
}
}
+ /**
+ * An independent copy: same key bytes, same counter, on its own buffer so
+ * destroying one cannot wipe the other. Exists for the handshake's
+ * commit-on-success reads; a transport cipher is never cloned.
+ */
+ clone(): CipherState {
+ const copy = new CipherState(this.#key?.slice() ?? null)
+ copy.#nonce = this.#nonce
+ copy.#destroyed = this.#destroyed
+ return copy
+ }
+
/** Wipe the key. A closed session must not leave one reachable. */
destroy(): void {
this.#key?.fill(0)
@@ -282,6 +294,15 @@ class SymmetricState {
return plaintext
}
+ /** A copy to run a candidate message against. See the readMessage pair. */
+ clone(): SymmetricState {
+ const copy = new SymmetricState()
+ copy.ck = this.ck.slice()
+ copy.h = this.h.slice()
+ copy.cipher = this.cipher.clone()
+ return copy
+ }
+
split(): [CipherState, CipherState] {
const [k1, k2] = hkdfN(this.ck, new Uint8Array(0), 2)
return [new CipherState(k1!.slice()), new CipherState(k2!.slice())]
@@ -436,15 +457,22 @@ export class HandshakeState {
throw new NoiseError(`handshake message 1 is ${message.length} bytes`, 'E_NOISE_MESSAGE')
}
- this.#re = message.subarray(0, DH_BYTES)
- this.#sym.mixHash(this.#re)
- this.#sym.mixKey(await this.#s.diffieHellman(this.#re))
+ // Commit on success, as in #readMessage2: a message that fails to
+ // authenticate must leave no trace, or one stray frame ends every
+ // handshake that was still waiting for the real one.
+ const sym = this.#sym.clone()
+ const re = message.subarray(0, DH_BYTES)
+ sym.mixHash(re)
+ sym.mixKey(await this.#s.diffieHellman(re))
// Fails here when the initiator pinned somebody else's static key, which
// is exactly the relay-impersonation case.
- this.#rs = this.#sym.decryptAndHash(message.subarray(DH_BYTES, encStaticEnd))
- this.#sym.mixKey(await this.#s.diffieHellman(this.#rs))
- const payload = this.#sym.decryptAndHash(message.subarray(encStaticEnd))
+ const rs = sym.decryptAndHash(message.subarray(DH_BYTES, encStaticEnd))
+ sym.mixKey(await this.#s.diffieHellman(rs))
+ const payload = sym.decryptAndHash(message.subarray(encStaticEnd))
+ this.#sym = sym
+ this.#re = re
+ this.#rs = rs
this.#step = 'write2'
return payload
}
@@ -466,12 +494,20 @@ export class HandshakeState {
throw new NoiseError(`handshake message 2 is ${message.length} bytes`, 'E_NOISE_MESSAGE')
}
- this.#re = message.subarray(0, DH_BYTES)
- this.#sym.mixHash(this.#re)
- this.#sym.mixKey(dh(this.#e!.secretKey, this.#re))
- this.#sym.mixKey(await this.#s.diffieHellman(this.#re))
- const payload = this.#sym.decryptAndHash(message.subarray(DH_BYTES))
-
+ // All mixing runs against a copy, which becomes the state only once the
+ // tag verifies. The relay broadcasts every frame in the room, and message
+ // 2 is exactly 48 bytes — two phones connecting at once read each other's
+ // replies. Mixing a stray one into the live state before the tag check
+ // would poison ck and h, and the genuine reply could never authenticate.
+ const sym = this.#sym.clone()
+ const re = message.subarray(0, DH_BYTES)
+ sym.mixHash(re)
+ sym.mixKey(dh(this.#e!.secretKey, re))
+ sym.mixKey(await this.#s.diffieHellman(re))
+ const payload = sym.decryptAndHash(message.subarray(DH_BYTES))
+
+ this.#sym = sym
+ this.#re = re
this.#step = 'done'
return payload
}
diff --git a/src/lib/format/explanation.ts b/src/lib/format/explanation.ts
index b0062ca..ab058bb 100644
--- a/src/lib/format/explanation.ts
+++ b/src/lib/format/explanation.ts
@@ -21,6 +21,16 @@ export const FID = {
BATTERY_W: 4,
BATTERY_SOC: 5,
LOAD_W: 6,
+ /**
+ * Which source feeds each power reading. Nothing in the app reads them
+ * yet, but this table is the app's copy of the registry's frozen set and
+ * tests/registry-contract.test.ts holds the two equal — a fid with no
+ * name here would need an exemption there, and an exemption list is the
+ * thing the registry exists to avoid.
+ */
+ SRC_GRID: 7,
+ SRC_PV: 8,
+ SRC_BATTERY: 9,
/** Present only on a site with a charger. Absent means no EV node. */
EV_W: 10,
} as const
diff --git a/src/lib/format/plan.test.ts b/src/lib/format/plan.test.ts
index 6ca4d07..0887d25 100644
--- a/src/lib/format/plan.test.ts
+++ b/src/lib/format/plan.test.ts
@@ -63,6 +63,9 @@ describe('planHeadline', () => {
const h = planHeadline(p, T0 + 60_000)
expect(h.text).toMatch(/charging at 3.0 kW/)
expect(h.text).toMatch(/Then it covers the house/)
+ // The unit must survive the sentence around it: "2.0 kW", never "2.0 kw"
+ // — a lowercased clause once dragged the unit down with it.
+ expect(h.text).not.toMatch(/\d\s*k?w\b/)
expect(h.slotIndex).toBe(0)
})
diff --git a/src/lib/format/plan.ts b/src/lib/format/plan.ts
index 4ccd209..e09b549 100644
--- a/src/lib/format/plan.ts
+++ b/src/lib/format/plan.ts
@@ -109,7 +109,9 @@ export function planHeadline(plan: Plan | null, nowMs: number): PlanHeadline {
const inMs = change.startMs - nowMs
return {
- text: `${describeSlot(now, 'now')} Then ${describeSlot(change, 'later').toLowerCase()} ${inWords(inMs)}.`,
+ // The 'later' wording already starts lowercase; lowercasing the whole
+ // clause here turned the unit into "170 w".
+ text: `${describeSlot(now, 'now')} Then ${describeSlot(change, 'later')} ${inWords(inMs)}.`,
slotIndex: current,
}
}
diff --git a/src/lib/protocol/contract.ts b/src/lib/protocol/contract.ts
index eccc6e4..970e8e6 100644
--- a/src/lib/protocol/contract.ts
+++ b/src/lib/protocol/contract.ts
@@ -49,3 +49,9 @@ export const ROLE_LABELS: Record = {
export function roleHasScope(role: string, scope: string): boolean {
return (ROLE_SCOPES[role] ?? []).includes(scope)
}
+
+/** The scope the box checks for OP_SET_MODE. */
+export const SCOPE_MODE_WRITE: Scope = 'ftw.mode.write'
+
+/** The capability a box offers when its optimizer accepts dispatch. */
+export const CAP_PLAN_DISPATCH = 'plan.dispatch'
diff --git a/src/lib/protocol/session.ts b/src/lib/protocol/session.ts
index 2529fd2..9169825 100644
--- a/src/lib/protocol/session.ts
+++ b/src/lib/protocol/session.ts
@@ -17,6 +17,7 @@ import {
wireBytes,
LANE_CONTROL,
LANE_BULK,
+ BULK_BUCKETS,
FrameError,
} from './frame'
import {
@@ -188,7 +189,8 @@ interface PendingHistory {
onChunk: (chunk: HistChunk) => void
resolve: (end: HistEnd) => void
reject: (err: Error) => void
- timer: ReturnType
+ /** Unset only before the request is on the wire. See #armDeadline. */
+ timer?: ReturnType
}
/**
@@ -203,7 +205,8 @@ export const API_TIMEOUT_MS = 20_000
interface PendingApi {
resolve: (res: ApiResponse) => void
reject: (err: Error) => void
- timer: ReturnType
+ /** Unset only before the request is on the wire. See #armDeadline. */
+ timer?: ReturnType
head: ApiHead | null
chunks: Uint8Array[]
bytes: number
@@ -217,7 +220,8 @@ export const PLAN_TIMEOUT_MS = 8_000
interface PendingPlan {
resolve: (plan: Plan) => void
reject: (err: Error) => void
- timer: ReturnType
+ /** Unset only before the request is on the wire. See #armDeadline. */
+ timer?: ReturnType
}
/** A price window is one bulk message too, so it keeps the plan's deadline. */
@@ -226,7 +230,8 @@ export const PRICE_TIMEOUT_MS = 8_000
interface PendingPrices {
resolve: (prices: Prices) => void
reject: (err: Error) => void
- timer: ReturnType
+ /** Unset only before the request is on the wire. See #armDeadline. */
+ timer?: ReturnType
}
/**
@@ -369,6 +374,8 @@ export class Session {
#pendingPrices = new Map()
#pendingApi = new Map()
#pendingCmd = new Map()
+ /** The api queue's tail. Always settled-safe; see api(). */
+ #apiTail: Promise = Promise.resolve()
/** Set only while the box says it is starting. See BOOT_RETRY_MS. */
#bootRetry: ReturnType | undefined
@@ -394,9 +401,31 @@ export class Session {
* band says how long ago that was. Carrier stays 'cache', which is a
* carrier and not a failure state: it is how the app has something honest
* to show in its first frame.
+ *
+ * The cache read races the connect, and either may win. Landing while a
+ * carrier is mid-handshake, the cache still paints — that is the cold-start
+ * promise — but only the data: the phase, the carrier and the box's clock
+ * belong to the connection already under way. Nothing writes the carrier
+ * again on a connection that stays up, so a cache that overwrote it here
+ * left "can't reach your box" standing over a live stream for as long as
+ * the app was open.
*/
restore(patch: Partial): void {
- if (this.#state.phase === 'streaming') return
+ const phase = this.#state.phase
+ if (phase === 'streaming') return
+
+ if (phase === 'handshaking' || phase === 'subscribing' || phase === 'booting') {
+ // Fields already held mean a reconnect, and live readings are newer
+ // than any snapshot on disk.
+ if (this.#state.fields.size > 0) return
+ const data = { ...patch }
+ delete data.phase
+ delete data.carrier
+ delete data.uptimeMs
+ this.#patch(data)
+ return
+ }
+
this.#patch({ ...patch, phase: 'idle', carrier: 'cache' })
}
@@ -486,13 +515,13 @@ export class Session {
this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1
return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- this.#pendingHistory.delete(id)
- reject(new Error('history request timed out'))
- }, HIST_TIMEOUT_MS)
-
- this.#pendingHistory.set(id, { onChunk, resolve, reject, timer })
+ // Sent before anything is registered: a payload the largest bucket
+ // cannot carry throws out of the executor, rejecting the promise with
+ // no entry and no timer left behind for the deadline to sweep up.
this.#sendBulk({ t: 'hist.query', id, b: query })
+
+ this.#pendingHistory.set(id, { onChunk, resolve, reject })
+ this.#armDeadline(this.#pendingHistory, id, HIST_TIMEOUT_MS, 'history request timed out')
})
}
@@ -509,13 +538,12 @@ export class Session {
this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1
return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- this.#pendingPlan.delete(id)
- reject(new Error('plan request timed out'))
- }, PLAN_TIMEOUT_MS)
-
- this.#pendingPlan.set(id, { resolve, reject, timer })
+ // Send first: a refused payload rejects through the executor and
+ // leaves nothing registered. See history().
this.#sendBulk({ t: 'plan.get', id })
+
+ this.#pendingPlan.set(id, { resolve, reject })
+ this.#armDeadline(this.#pendingPlan, id, PLAN_TIMEOUT_MS, 'plan request timed out')
})
}
@@ -533,13 +561,12 @@ export class Session {
this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1
return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- this.#pendingPrices.delete(id)
- reject(new Error('price request timed out'))
- }, PRICE_TIMEOUT_MS)
-
- this.#pendingPrices.set(id, { resolve, reject, timer })
+ // Send first: a refused payload rejects through the executor and
+ // leaves nothing registered. See history().
this.#sendBulk({ t: 'price.get', id, b: query })
+
+ this.#pendingPrices.set(id, { resolve, reject })
+ this.#armDeadline(this.#pendingPrices, id, PRICE_TIMEOUT_MS, 'price request timed out')
})
}
@@ -554,28 +581,41 @@ export class Session {
* an answer here, not a failure. Rejects with `ApiError` when the box sent a
* stable code instead, and with a plain Error when the wire went away, the
* deadline passed, or the answer arrived in pieces that do not fit together.
+ *
+ * One call is on the wire at a time, because that is how many the box
+ * serves: a second in flight is answered "busy", which costs whichever view
+ * lost the race a thirty-second backoff over a collision no view can see.
+ * The queue is chained on settle, not success, so a call that fails hands
+ * the wire to the one behind it — and each call's deadline is armed when it
+ * dispatches, because a place in the queue is not time spent waiting on the
+ * box.
*/
api(req: ApiReq): Promise {
+ const turn = this.#apiTail.then(() => this.#dispatchApi(req))
+ this.#apiTail = turn.then(
+ () => undefined,
+ () => undefined
+ )
+ return turn
+ }
+
+ #dispatchApi(req: ApiReq): Promise {
+ // Checked at dispatch, not enqueue: what matters is whether the wire is
+ // there when this call's turn comes. A queued call whose predecessor was
+ // settled by the carrier going away meets the same answer it would have
+ // met in the pending map, now instead of at its deadline.
if (!this.#carrier) return Promise.reject(new Error('no carrier'))
+ if (this.#carrier.status.phase === 'closed') {
+ return Promise.reject(new Error('carrier closed'))
+ }
const id = this.#nextRequestId
this.#nextRequestId = (this.#nextRequestId + 1) % 0xffffffff || 1
return new Promise((resolve, reject) => {
- const timer = setTimeout(() => {
- this.#pendingApi.delete(id)
- reject(new Error('api request timed out'))
- }, API_TIMEOUT_MS)
-
- this.#pendingApi.set(id, {
- resolve,
- reject,
- timer,
- head: null,
- chunks: [],
- bytes: 0,
- nextSeq: 0,
- })
+ // Sent before anything is registered: a payload the largest bucket
+ // cannot carry throws out of the executor, rejecting the promise with
+ // no entry and no timer left behind for the deadline to sweep up.
this.#sendBulk({
t: 'api.req',
id,
@@ -588,6 +628,16 @@ export class Session {
...(req.body ? { body: wireBytes(req.body) } : {}),
},
})
+
+ this.#pendingApi.set(id, {
+ resolve,
+ reject,
+ head: null,
+ chunks: [],
+ bytes: 0,
+ nextSeq: 0,
+ })
+ this.#armDeadline(this.#pendingApi, id, API_TIMEOUT_MS, 'api request timed out')
})
}
@@ -693,9 +743,20 @@ export class Session {
case 'tick':
this.#onTick(envelope.b as Tick)
break
- case 'hist.chunk':
- this.#pendingHistory.get(envelope.id ?? -1)?.onChunk(envelope.b as HistChunk)
+ case 'hist.chunk': {
+ const hist = this.#pendingHistory.get(envelope.id ?? -1)
+ if (hist) {
+ // A window still arriving is not a window that has gone quiet.
+ this.#armDeadline(
+ this.#pendingHistory,
+ envelope.id!,
+ HIST_TIMEOUT_MS,
+ 'history request timed out'
+ )
+ hist.onChunk(envelope.b as HistChunk)
+ }
break
+ }
case 'hist.end':
this.#settleHistory(envelope.id, envelope.b as HistEnd)
break
@@ -780,6 +841,10 @@ export class Session {
this.#patch({
phase: 'streaming',
+ // A snapshot only arrives over a carrier, so the stream names the one
+ // feeding it — whatever a cache paint that landed mid-handshake may
+ // have said in the meantime.
+ ...(this.#carrier ? { carrier: this.#carrier.kind } : {}),
uptimeMs: b.uptimeMs,
controlRev: b.controlRev,
dict: b.dict,
@@ -872,6 +937,7 @@ export class Session {
// rather than allowed to rewrite what the caller will be told.
if (pending.head !== null) return
pending.head = head
+ this.#armDeadline(this.#pendingApi, id!, API_TIMEOUT_MS, 'api request timed out')
}
#onApiChunk(id: number | undefined, chunk: ApiChunk): void {
@@ -891,6 +957,7 @@ export class Session {
pending.nextSeq += 1
pending.chunks.push(chunk.data)
pending.bytes += chunk.data.length
+ this.#armDeadline(this.#pendingApi, id!, API_TIMEOUT_MS, 'api request timed out')
}
/**
@@ -1010,7 +1077,7 @@ export class Session {
if (!this.#carrier) return
// Sized by trial: the encoder refuses to grow a bucket, so the frame is
// built once and stepped up rather than guessed at from the object.
- for (const bucket of [1024, 4096, 16384]) {
+ for (const bucket of BULK_BUCKETS) {
try {
this.#carrier.send(encodeFrame({ lane: LANE_BULK, flags: 0, envelope }, bucket))
return
@@ -1021,6 +1088,30 @@ export class Session {
throw new FrameError('bulk payload exceeds the largest bucket', 'E_FRAME_EXCEEDS_BUCKET')
}
+ /**
+ * Arm — or push back — a request's deadline.
+ *
+ * The deadline measures silence, not total time. A multi-megabyte answer
+ * arriving steadily in chunks is a request that is working, and a deadline
+ * armed once at dispatch would kill it mid-flow while the box kept
+ * streaming into an id nobody held any more. So every head and chunk
+ * re-arms it, and what expires is a wire that has gone quiet.
+ */
+ #armDeadline
; reject: (err: Error) => void }>(
+ map: Map,
+ id: number,
+ ms: number,
+ message: string
+ ): void {
+ const pending = map.get(id)
+ if (!pending) return
+ clearTimeout(pending.timer)
+ pending.timer = setTimeout(() => {
+ map.delete(id)
+ pending.reject(new Error(message))
+ }, ms)
+ }
+
#detach(): void {
clearTimeout(this.#bootRetry)
for (const u of this.#unsub) u()
diff --git a/src/lib/state/age.test.ts b/src/lib/state/age.test.ts
index 7476b22..352b20c 100644
--- a/src/lib/state/age.test.ts
+++ b/src/lib/state/age.test.ts
@@ -3,6 +3,8 @@ import 'fake-indexeddb/auto'
import { Session } from '$lib/protocol/session'
import { LoopbackCarrier } from '$lib/carrier/loopback'
import { SimBox } from '$lib/sim/box'
+import { saveSnapshot } from '$lib/store/snapshot'
+import { FID } from '$lib/format/explanation'
import type { SourceState } from '$lib/protocol/types'
/* Age is a claim, and a wrong one is the one thing this app must never make.
@@ -135,3 +137,46 @@ describe('the age keeps moving when the stream stops', () => {
site.destroy()
})
})
+
+/* The cached view's age, when the snapshot was already behind at capture.
+ *
+ * Time on the shelf is only half the answer. A snapshot written while the
+ * inverter had been quiet for a minute holds readings a minute older than the
+ * write stamp says, and the source rows in the snapshot carry exactly that —
+ * lastOk against the box's clock at capture. Counting the shelf alone
+ * reported those readings younger than they ever were.
+ */
+describe('the age of a cached view', () => {
+ it('carries the staleness the readings already had at capture', async () => {
+ // The meter's last answer was 40 s behind the box's clock when the
+ // snapshot was written, and the snapshot has sat two minutes since.
+ await saveSnapshot({
+ siteId: 'box-stale-capture',
+ savedAtMs: Date.now() - 120_000,
+ uptimeMs: 500_000,
+ fields: { [FID.GRID_W]: 1_200 },
+ sources: {
+ meter: {
+ kind: 'driver',
+ name: 'meter',
+ lastOkMs: 460_000,
+ staleAfterMs: 5_000,
+ state: 'stale',
+ },
+ },
+ dispatchBlockedBy: [],
+ dict: { [String(FID.GRID_W)]: { name: 'grid_w', unit: 'W', srcId: 'meter' } },
+ controlRev: 1,
+ })
+
+ const { SiteStore } = await import('./site.svelte')
+ const site = new SiteStore('test')
+ await site.start('box-stale-capture')
+
+ expect(site.carrier, 'nothing restored, so the rest proves nothing').toBe('cache')
+ // Two minutes on the shelf plus the 40 s the meter already lagged.
+ expect(site.ageMs).toBeGreaterThanOrEqual(160_000)
+
+ site.destroy()
+ })
+})
diff --git a/src/lib/state/ask.svelte.ts b/src/lib/state/ask.svelte.ts
index 88b3d65..c8cad9f 100644
Binary files a/src/lib/state/ask.svelte.ts and b/src/lib/state/ask.svelte.ts differ
diff --git a/src/lib/state/flow.test.ts b/src/lib/state/flow.test.ts
index 68bdddc..7c8177c 100644
--- a/src/lib/state/flow.test.ts
+++ b/src/lib/state/flow.test.ts
@@ -76,4 +76,44 @@ describe('flowReadings', () => {
const grid = r.planets.find((p) => p.id === 'grid')!
expect(grid.sub).toBe('no data')
})
+
+ it('never hands the component a negative number to draw', () => {
+ // The wire's sign is direction — positive into the site, negative out —
+ // and the hero renders kw as text, so a sign passed through here is a raw
+ // minus on screen: "-3.40 kW" over "exporting". Direction travels as
+ // toHub and as the sub line's word, never as the number's sign.
+ const everythingOutward = flowReadings(
+ fields([
+ [FID.GRID_W, -3_400],
+ [FID.PV_W, -2_300],
+ [FID.BATTERY_W, -2_000],
+ [FID.EV_W, 0],
+ [FID.LOAD_W, 900],
+ ])
+ )
+ for (const p of everythingOutward.planets) {
+ expect(p.kw, `${p.id} carried the wire's sign into the hero`).toBeGreaterThanOrEqual(0)
+ }
+ })
+
+ it('says which way the battery is moving, in the component’s own words', () => {
+ // A battery moves power both ways, so unlike solar the magnitude alone
+ // cannot say which. Without the word, "2.00 kW" is a battery doing
+ // something unstated.
+ const discharging = flowReadings(fields([[FID.BATTERY_W, -2_000]])).planets.find(
+ (p) => p.id === 'battery'
+ )!
+ expect(discharging.kw).toBeCloseTo(2)
+ expect(discharging.sub).toBe('discharging')
+
+ const charging = flowReadings(fields([[FID.BATTERY_W, 1_800]])).planets.find(
+ (p) => p.id === 'battery'
+ )!
+ expect(charging.sub).toBe('charging')
+
+ const resting = flowReadings(fields([[FID.BATTERY_W, 0]])).planets.find(
+ (p) => p.id === 'battery'
+ )!
+ expect(resting.sub).toBe('idle')
+ })
})
diff --git a/src/lib/state/flow.ts b/src/lib/state/flow.ts
index 5771ea0..3a3c803 100644
--- a/src/lib/state/flow.ts
+++ b/src/lib/state/flow.ts
@@ -50,7 +50,10 @@ export function flowReadings(fields: ReadonlyMap): FlowReadings
kw: 0, toHub: true, color: 'var(--fg-muted)', sub: 'no data', clickable: false,
})
} else {
- const g = gridW / 1000
+ // Magnitude only: the sign is wire convention, and the sub line already
+ // carries the direction. A minus over "exporting" is the raw sign the UI
+ // never shows.
+ const g = Math.abs(gridW) / 1000
planets.push({
id: 'grid', corner: 'bottom-left', title: 'GRID', role: 'grid',
kw: g, toHub: gridW >= 0,
@@ -77,15 +80,18 @@ export function flowReadings(fields: ReadonlyMap): FlowReadings
const batteryW = fields.get(FID.BATTERY_W)
if (batteryW !== undefined) {
- const b = batteryW / 1000
+ // Magnitude only, and the direction spelled out in the sub. A battery
+ // moves power both ways, so unlike solar the number alone cannot say
+ // which — and a raw minus is the one thing the UI never shows.
+ const b = Math.abs(batteryW) / 1000
const socPermille = fields.get(FID.BATTERY_SOC)
planets.push({
id: 'battery', corner: 'top-right', title: 'BATTERY', role: 'battery',
kw: b, toHub: batteryW < 0,
- // Direction carried by the value's colour: charge green (filling),
+ // Direction also in the value's colour: charge green (filling),
// discharge red (draining), idle the battery's identity cyan.
color: idle(batteryW) ? 'var(--cyan)' : batteryW >= 0 ? 'var(--green-e)' : 'var(--red-e)',
- sub: '',
+ sub: idle(batteryW) ? 'idle' : batteryW >= 0 ? 'charging' : 'discharging',
soc: socPermille === undefined ? null : Math.round(socPermille / 10),
clickable: false,
})
diff --git a/src/lib/state/history.svelte.ts b/src/lib/state/history.svelte.ts
index fc1ce38..1a7d3d0 100644
--- a/src/lib/state/history.svelte.ts
+++ b/src/lib/state/history.svelte.ts
@@ -13,7 +13,6 @@
import type { HistChunk, HistEnd, Resolution } from '$lib/protocol/messages'
import {
- RESOLUTIONS,
planQuery,
assembleFrame,
clipFrame,
@@ -107,6 +106,11 @@ export class HistoryStore {
* away, on the screen where the wire is busiest.
*/
select(range: RangeKey): void {
+ // The cursor is an index into the current frame, and the same index in
+ // another range is another moment — against a longer step it can even
+ // name a time in the future. There is no honest sample to move it to
+ // until the new frame exists, so it lets go.
+ if (range !== this.range) this.cursor = null
this.range = range
}
@@ -175,7 +179,7 @@ export class HistoryStore {
this.loaded = true
show()
- if (siteId) void pruneTiles(siteId, toMs - RESOLUTIONS[end.resActual].retentionMs)
+ if (siteId) void pruneTiles(siteId, toMs)
} catch (err) {
// A reply for a range the user has already moved off is not this
// range's news, and it is not a reason to ask for this one again.
diff --git a/src/lib/state/history.test.ts b/src/lib/state/history.test.ts
new file mode 100644
index 0000000..80263ac
--- /dev/null
+++ b/src/lib/state/history.test.ts
@@ -0,0 +1,31 @@
+/* The cursor, as the range moves under it.
+ *
+ * The cursor is an index into the current frame. The same index against
+ * another range's frame names another moment — against a longer step it can
+ * even name a time in the future, and the readout dates a sample nobody is
+ * pointing at.
+ */
+
+import { describe, it, expect } from 'vitest'
+import { HistoryStore } from './history.svelte'
+import { SiteStore } from './site.svelte'
+
+describe('the cursor across a range change', () => {
+ it('does not survive into a frame where its index means a different time', () => {
+ const store = new HistoryStore(new SiteStore('test'))
+ store.cursor = 200
+
+ store.select('30d')
+
+ expect(store.cursor, 'an index into the old frame was kept against the new one').toBeNull()
+ })
+
+ it('stays put when the range does not actually change', () => {
+ const store = new HistoryStore(new SiteStore('test'))
+ store.cursor = 12
+
+ store.select('24h')
+
+ expect(store.cursor).toBe(12)
+ })
+})
diff --git a/src/lib/state/plan.svelte.ts b/src/lib/state/plan.svelte.ts
index 15ee905..9d0d205 100644
--- a/src/lib/state/plan.svelte.ts
+++ b/src/lib/state/plan.svelte.ts
@@ -9,12 +9,10 @@
import type { Plan, SiteMode, CmdResult, ModeInfo } from '$lib/protocol/messages'
import { OP_SET_MODE } from '$lib/protocol/messages'
import { CommandError } from '$lib/protocol/session'
+import { CAP_PLAN_DISPATCH, SCOPE_MODE_WRITE } from '$lib/protocol/contract'
import type { SiteStore } from './site.svelte'
import { FID } from '$lib/format/explanation'
-/** From contract/registry.yaml, and the scope the box checks for OP_SET_MODE. */
-const SCOPE_MODE_WRITE = 'ftw.mode.write'
-
/** What the user sees while an intent is in flight. */
export type CommandState =
| { kind: 'idle' }
@@ -30,7 +28,18 @@ export class PlanStore {
#site: SiteStore
#timer: ReturnType | null = null
- plan = $state(null)
+ /**
+ * What the box intends to do, read where the session keeps it.
+ *
+ * The session holds the one copy, because a plan does not only arrive as an
+ * answer: the box replans after a mode change made from any phone and
+ * pushes the result unasked, with no request id. A copy held here caught
+ * only the answers, so a long-lived connection drained to "no plan" while
+ * the session's plan moved on without it.
+ */
+ get plan(): Plan | null {
+ return this.#site.session.plan
+ }
/**
* Bumped when something other than the session wants the plan again.
@@ -105,7 +114,7 @@ export class PlanStore {
* gets this wrong is merely rude rather than unsafe.
*/
get canControl(): boolean {
- return this.#site.session.caps.has('plan.dispatch') && this.#hasModeScope
+ return this.#site.session.caps.has(CAP_PLAN_DISPATCH) && this.#hasModeScope
}
/**
@@ -160,17 +169,19 @@ export class PlanStore {
}
/**
- * Fetch the plan, and say so when the box could not send one.
+ * Ask for the plan, and say so when the box could not send one.
*
- * Rejects on failure as well as saying it, because the caller that heals
- * this — `askWhenLive` — has no other way to tell an answer from a failure
- * the store swallowed. The view keeps whatever plan it had either way.
+ * The answer itself lands on session state, where `plan` reads it — this
+ * only asks and owns the failure sentence. Rejects on failure as well as
+ * saying it, because the caller that heals this — `askWhenLive` — has no
+ * other way to tell an answer from a failure the store swallowed. The view
+ * keeps whatever plan it had either way.
*/
async load(): Promise {
this.loading = true
this.problem = null
try {
- this.plan = await this.#site.plan()
+ await this.#site.plan()
} catch (err) {
// A plan the box could not send is not a broken app. What happens now
// is that the app asks again on its own, so that is what it says — the
diff --git a/src/lib/state/plan.test.ts b/src/lib/state/plan.test.ts
new file mode 100644
index 0000000..1aa8b4e
--- /dev/null
+++ b/src/lib/state/plan.test.ts
@@ -0,0 +1,40 @@
+/* The plan the box pushes unasked.
+ *
+ * A plan does not only arrive as an answer. The box replans after a mode
+ * change made from any phone and pushes the result with no request id,
+ * because nobody on this connection asked. The session keeps that copy; a
+ * store holding its own caught only the answers, so a long-lived connection
+ * drained to "no plan for right now" while the box's intent moved on.
+ */
+
+import { describe, it, expect, vi } from 'vitest'
+import 'fake-indexeddb/auto'
+import { SiteStore } from './site.svelte'
+import { PlanStore } from './plan.svelte'
+import { LoopbackCarrier } from '$lib/carrier/loopback'
+import { SimBox } from '$lib/sim/box'
+import { OP_SET_MODE } from '$lib/protocol/messages'
+
+describe('a replan this phone never asked for', () => {
+ it('reaches the plan on screen', async () => {
+ const box = new SimBox({})
+ const site = new SiteStore('test')
+ const store = new PlanStore(site)
+ site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
+ await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 })
+
+ // Nothing has been asked for, and nothing has been pushed.
+ expect(store.plan).toBeNull()
+
+ // A mode change straight over the wire — as another phone's would land —
+ // never touching this store's load(). The box replans and pushes the
+ // result with no pending id to claim it.
+ const result = await site.command(OP_SET_MODE, { mode: 'planner_self' })
+ expect(result.state, 'the mode change failed, so no replan is coming').toBe('applied')
+
+ await vi.waitFor(() => expect(store.plan).not.toBeNull(), { timeout: 2_000 })
+ expect(store.plan!.rev).toBeGreaterThan(1)
+
+ site.destroy()
+ })
+})
diff --git a/src/lib/state/site.svelte.ts b/src/lib/state/site.svelte.ts
index eb55c1c..4ba6357 100644
--- a/src/lib/state/site.svelte.ts
+++ b/src/lib/state/site.svelte.ts
@@ -46,6 +46,16 @@ const NOW_FIDS: readonly Fid[] = [
FID.EV_W,
]
+/**
+ * How long a 1 Hz stream may be quiet before the silence is reported.
+ *
+ * A stream is always a fraction of a second behind its last frame, and on
+ * mobile a single dropped tick is normal. Reporting that would make a healthy
+ * view flicker between "now" and "1s ago" forever, which reads as a fault
+ * where there is none. Past a couple of beats the silence is real.
+ */
+const STREAM_QUIET_AFTER_MS = 3_000
+
/**
* The sources behind the readings on the Now view, named by the box.
*
@@ -74,6 +84,8 @@ export class SiteStore {
#writer = new SnapshotWriter()
#siteId: string | null = null
#markedLive = false
+ /** Set for good in destroy(). A dead store must not accept a carrier. */
+ #destroyed = false
/** Wall clock when the last frame arrived. Null until one does. */
#lastFrameAtMs = $state(null)
@@ -133,6 +145,18 @@ export class SiteStore {
* read itself was already started by the inline script in index.html.
*/
async start(siteId: string): Promise {
+ // Repointed at a different home. The subscriber above tags every
+ // streaming frame with #siteId, so the old session has to be gone before
+ // the id moves — one frame in the gap and the old house is sealed to disk
+ // under the new one's id. The per-site bookkeeping resets with it, so the
+ // new home's cache restores exactly as on a launch. A launch and a
+ // same-home restart take neither branch.
+ if (this.#siteId !== null && this.#siteId !== siteId) {
+ this.#session.close()
+ this.#lastFrameAtMs = null
+ this.cachedAtMs = null
+ this.#markedLive = false
+ }
this.#siteId = siteId
const cached = (await takeBootSnapshot()) ?? (await loadSnapshot(siteId))
@@ -301,7 +325,16 @@ export class SiteStore {
*/
get ageMs(): number {
if (this.carrier === 'cache' && this.cachedAtMs !== null) {
- return Date.now() - this.cachedAtMs
+ // The snapshot's readings were not new when they were written: each
+ // source row carries how far its last answer already lagged the box's
+ // clock at capture, and time on the shelf alone understates the age by
+ // exactly that. NaN rows — stamps from another boot — mean unknown,
+ // and unknown must not make the number smaller.
+ const shelfMs = Date.now() - this.cachedAtMs
+ const atCapture = nowSourceIds(this.session)
+ .map((s) => this.#session.ageOf(s))
+ .filter((a) => !Number.isNaN(a))
+ return atCapture.length > 0 ? shelfMs + Math.max(...atCapture) : shelfMs
}
const ages = nowSourceIds(this.session)
.map((s) => this.#session.ageOf(s))
@@ -328,11 +361,7 @@ export class SiteStore {
const at = this.#lastFrameAtMs
if (at === null) return 0
const since = this.#now - at
- // A 1 Hz stream is always a fraction of a second behind its last frame.
- // Reporting that would make a healthy view flicker between "now" and "1s
- // ago" forever, which reads as a fault where there is none. Past a couple
- // of beats the silence is real and every millisecond of it counts.
- if (since < 3_000) return 0
+ if (since < STREAM_QUIET_AFTER_MS) return 0
return since
}
@@ -360,6 +389,15 @@ export class SiteStore {
}
connect(carrier: Carrier): void {
+ // A carrier can finish connecting after the store it was meant for is
+ // gone — sign out during a slow connect. Handing it to the session would
+ // start a live, self-reconnecting stream to the home this phone just
+ // left, unreferenced and unclosable. Closed rather than dropped, because
+ // a dropped carrier leaks its socket.
+ if (this.#destroyed) {
+ carrier.close()
+ return
+ }
this.#session.connect(carrier)
}
@@ -369,6 +407,7 @@ export class SiteStore {
}
destroy(): void {
+ this.#destroyed = true
if (this.#ticker !== null) {
clearInterval(this.#ticker)
this.#ticker = null
diff --git a/src/lib/state/site.test.ts b/src/lib/state/site.test.ts
new file mode 100644
index 0000000..ce6a0d9
--- /dev/null
+++ b/src/lib/state/site.test.ts
@@ -0,0 +1,75 @@
+/* The store and the home it points at.
+ *
+ * Two ways the pointer and the stream disagree, and both end with this phone
+ * doing something for a home it should not:
+ *
+ * - start() repoints the store while the old home is still streaming. The
+ * subscriber tags every streaming frame with the current site id, so one
+ * frame in the gap seals house A's readings to disk under house B's id —
+ * and B's next cold start paints A's kitchen.
+ * - a carrier finishes connecting after the store was destroyed. Handed to
+ * the session, it starts a live, self-reconnecting stream to the home
+ * this phone just signed out of, unreferenced and unclosable.
+ *
+ * Everything here is real: a Session, a SimBox, the loopback carrier.
+ */
+
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import 'fake-indexeddb/auto'
+import { SiteStore } from './site.svelte'
+import { LoopbackCarrier } from '$lib/carrier/loopback'
+import { SimBox } from '$lib/sim/box'
+import { db } from '$lib/store/db'
+import { loadSnapshot } from '$lib/store/snapshot'
+
+beforeEach(async () => {
+ const database = await db()
+ for (const store of ['sites', 'snapshot', 'tiles', 'meta', 'keys'] as const) {
+ await database.clear(store)
+ }
+})
+
+describe('a store repointed at another home mid-stream', () => {
+ it('never seals the old house under the new id', async () => {
+ const box = new SimBox({})
+ const site = new SiteStore('test')
+ await site.start('home-a')
+ site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
+ await vi.waitFor(() => expect(site.session.phase).toBe('streaming'), { timeout: 2_000 })
+
+ // Repointed without a disconnect: signing out of A and into B while A's
+ // frames are still in the air.
+ await site.start('home-b')
+
+ // One more frame from A's box, and the flush the shell makes on every
+ // visibilitychange.
+ box.tick()
+ await new Promise((r) => setTimeout(r, 20))
+ await site.persistNow()
+
+ expect(
+ await loadSnapshot('home-b'),
+ "house A's readings were sealed under house B's id"
+ ).toBeNull()
+
+ site.destroy()
+ })
+})
+
+describe('a carrier that finishes connecting after sign-out', () => {
+ it('is closed, not adopted', async () => {
+ const box = new SimBox({})
+ const spoken = vi.spyOn(box, 'receive')
+
+ const site = new SiteStore('test')
+ site.destroy()
+
+ // The connect that was in flight when the user signed out.
+ const carrier = new LoopbackCarrier(box, { latencyMs: 0 })
+ site.connect(carrier)
+ await new Promise((r) => setTimeout(r, 50))
+
+ expect(carrier.status.phase, 'a store nothing owns kept a live carrier').toBe('closed')
+ expect(spoken, 'the dead store still spoke to the box').not.toHaveBeenCalled()
+ })
+})
diff --git a/src/lib/store/tiles.test.ts b/src/lib/store/tiles.test.ts
index bea5032..2ca8449 100644
--- a/src/lib/store/tiles.test.ts
+++ b/src/lib/store/tiles.test.ts
@@ -13,6 +13,7 @@ import { packColumns } from '$lib/protocol/history'
import type { HistChunk } from '$lib/protocol/messages'
const SITE = 'sim-0001'
+const DAY_MS = 86_400_000
function chunk(overrides: Partial = {}): HistChunk {
return {
@@ -84,9 +85,36 @@ describe('history tiles on disk', () => {
await saveTile(SITE, chunk())
await saveTile(SITE, chunk({ tileId: '5m/1/3062', startMs: 3062 * 43_200_000 }))
- const dropped = await pruneTiles(SITE, 3062 * 43_200_000)
+ // A month past the newer tile: the older one has left the box's 5m
+ // retention, the newer one sits exactly on its edge.
+ const dropped = await pruneTiles(SITE, 3062 * 43_200_000 + 30 * DAY_MS)
expect(dropped).toBe(1)
expect((await loadTiles(SITE, ['5m/1/3061', '5m/1/3062'])).size).toBe(1)
})
+
+ it('prunes each resolution by its own retention', async () => {
+ // The box keeps a month of 5m beside two years of 1h, and both answers
+ // prune the same store. Judged by one cutoff, a 5m answer's month would
+ // evict hour tiles deep inside their own two years — the year chart
+ // self-destructing every time the day chart is opened.
+ const nowMs = 3062 * 43_200_000 + 30 * DAY_MS
+ await saveTile(SITE, chunk()) // 5m, a month and a half old: expired
+ await saveTile(
+ SITE,
+ chunk({
+ tileId: '1h/1/500',
+ res: '1h',
+ startMs: nowMs - 300 * DAY_MS,
+ stepMs: 3_600_000,
+ })
+ )
+
+ const dropped = await pruneTiles(SITE, nowMs)
+
+ expect(dropped).toBe(1)
+ const held = await loadTiles(SITE, ['5m/1/3061', '1h/1/500'])
+ expect(held.has('1h/1/500'), 'the year cache was judged by the month’s window').toBe(true)
+ expect(held.has('5m/1/3061')).toBe(false)
+ })
})
diff --git a/src/lib/store/tiles.ts b/src/lib/store/tiles.ts
index d050416..ff0c8f6 100644
--- a/src/lib/store/tiles.ts
+++ b/src/lib/store/tiles.ts
@@ -17,7 +17,8 @@
import { db } from './db'
import { cacheKey, seal, unseal, type Bytes } from './seal'
-import type { HistChunk } from '$lib/protocol/messages'
+import { RESOLUTIONS, type ResolutionSpec } from '$lib/protocol/history'
+import type { HistChunk, Resolution } from '$lib/protocol/messages'
export interface CachedTile {
tileId: string
@@ -115,8 +116,13 @@ export function haveList(cached: ReadonlyMap): { tileId: str
* Without this the cache grows for as long as the app is installed, holding
* years the box itself has already evicted. Cheap enough to run after a
* query, which is the only time the answer changes.
+ *
+ * Every row is judged against its own resolution's retention. The store
+ * holds a month of 5m beside two years of 1h, so a single cutoff would let
+ * one answer's window evict the other resolution's whole cache — the year
+ * chart self-destructing every time the day chart is opened.
*/
-export async function pruneTiles(siteId: string, oldestKeptMs: number): Promise {
+export async function pruneTiles(siteId: string, nowMs: number): Promise {
try {
const database = await db()
const tx = database.transaction('tiles', 'readwrite')
@@ -124,7 +130,11 @@ export async function pruneTiles(siteId: string, oldestKeptMs: number): Promise<
for (const row of await tx.store.getAll()) {
if (row.siteId !== siteId) continue
- if (row.startMs >= oldestKeptMs) continue
+ // A resolution this build has never heard of has no retention to judge
+ // by, and data it cannot judge is data it must not delete.
+ const spec: ResolutionSpec | undefined = RESOLUTIONS[row.res as Resolution]
+ if (!spec) continue
+ if (row.startMs >= nowMs - spec.retentionMs) continue
await tx.store.delete(row.key)
dropped += 1
}
diff --git a/src/lib/ui/Chart.svelte b/src/lib/ui/Chart.svelte
index 0d5aaa7..a80ae2b 100644
--- a/src/lib/ui/Chart.svelte
+++ b/src/lib/ui/Chart.svelte
@@ -55,6 +55,16 @@
let { frame, traces, axis, ticks = [], cursor = null, onCursor, height = 200 }: Props = $props()
+ /**
+ * What a role that resolves to nothing is painted as.
+ *
+ * An empty resolution is a renamed token or a stylesheet that never
+ * mounted, and a theme-matched fallback would mask it in the theme it
+ * matches. One mid-grey for every role is visibly wrong in both themes,
+ * which keeps tokens.css the only place a real colour lives.
+ */
+ const TOKEN_MISSING = '#808080'
+
let canvas = $state(null)
let box = $state(null)
let width = $state(320)
@@ -121,12 +131,11 @@
// Resolved once per paint. Components read design roles, never raw
// values, and getComputedStyle inside the stroke loop is a stall.
const style = getComputedStyle(box)
- const role = (name: string, fallback: string) =>
- style.getPropertyValue(name).trim() || fallback
- const line = role('--line', '#2a2a2a')
- const lineSoft = role('--line-soft', '#222222')
- const sunken = role('--surface-sunken', '#101010')
- const colors = traces.map((t) => role(t.colorVar, '#888'))
+ const role = (name: string) => style.getPropertyValue(name).trim() || TOKEN_MISSING
+ const line = role('--line')
+ const lineSoft = role('--line-soft')
+ const sunken = role('--surface-sunken')
+ const colors = traces.map((t) => role(t.colorVar))
// Hours the box has nothing for, shaded before anything is drawn over
// them. Recessed rather than marked, so it reads as absence.
@@ -164,7 +173,7 @@
shapes.forEach((shape, t) => {
if (!shape) return
- const color = colors[t] ?? '#888'
+ const color = colors[t] ?? TOKEN_MISSING
if (shape.kind === 'band') {
// The spread of readings inside each pixel, filled; their mean, drawn
@@ -256,7 +265,7 @@
if (cursor !== null && cursor >= 0 && cursor < frame.points) {
const cx = Math.round(x(cursor)) + 0.5
- ctx.strokeStyle = role('--fg-muted', '#858585')
+ ctx.strokeStyle = role('--fg-muted')
ctx.lineWidth = 1
ctx.beginPath()
ctx.moveTo(cx, 0)
@@ -267,7 +276,7 @@
const column = frame.columns[frame.names.indexOf(trace.name)]
const v = column?.[cursor!]
if (v === undefined || v === MISSING_SAMPLE) return
- ctx.fillStyle = colors[t] ?? '#888'
+ ctx.fillStyle = colors[t] ?? TOKEN_MISSING
ctx.beginPath()
ctx.arc(x(cursor!), y(v), 3, 0, Math.PI * 2)
ctx.fill()
diff --git a/src/lib/ui/Chart.svelte.test.ts b/src/lib/ui/Chart.svelte.test.ts
index 42bbbf3..bda5b0e 100644
--- a/src/lib/ui/Chart.svelte.test.ts
+++ b/src/lib/ui/Chart.svelte.test.ts
@@ -109,11 +109,17 @@ function strokedPaths(ops: Op[]): Path[] {
return out
}
-function mount(columns: Int32Array[], names: string[], traces: Trace[], axis: Domain) {
+function mount(
+ columns: Int32Array[],
+ names: string[],
+ traces: Trace[],
+ axis: Domain,
+ roles: Record = ROLES
+) {
const { ops, ctx } = recorder()
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue(ctx as never)
vi.spyOn(window, 'getComputedStyle').mockReturnValue({
- getPropertyValue: (name: string) => ROLES[name] ?? '',
+ getPropertyValue: (name: string) => roles[name] ?? '',
} as unknown as CSSStyleDeclaration)
const frame: SeriesFrame = {
@@ -214,4 +220,25 @@ describe('colour comes from the palette', () => {
expect(Object.values(ROLES), `painted with ${colour}`).toContain(colour)
}
})
+
+ it('falls back to one neutral grey when a role resolves to nothing', () => {
+ // An empty resolution is a renamed token or a stylesheet that never
+ // mounted. The fallbacks used to be dark-palette hexes, which drew dark
+ // rules on a light card in light theme and blended into dark — masking
+ // the missing token in exactly one theme each. Wrong has to be visible
+ // the same way in both: one grey, everywhere.
+ const points = WIDTH * 2
+ const grid = new Int32Array(points).fill(2000)
+ const pv = new Int32Array(points).fill(-1000)
+
+ const ops = mount([grid, pv], ['grid_w', 'pv_w'], [GRID, PV], [-4000, 4000], {})
+ const painted = new Set()
+ for (const op of ops) {
+ if (op.op === 'stroke') painted.add(op.strokeStyle)
+ if (op.op === 'fill' || op.op === 'fillRect') painted.add(op.fillStyle)
+ }
+
+ expect(painted.size).toBeGreaterThan(0)
+ expect([...painted]).toEqual(['#808080'])
+ })
})
diff --git a/src/lib/ui/InstallHint.svelte b/src/lib/ui/InstallHint.svelte
index 92ce1c5..976c659 100644
--- a/src/lib/ui/InstallHint.svelte
+++ b/src/lib/ui/InstallHint.svelte
@@ -29,7 +29,10 @@
Share, then Add to Home Screen. It opens
instantly and keeps your readings between visits.
-
+
+
{/if}
diff --git a/src/lib/ui/InstallHint.svelte.test.ts b/src/lib/ui/InstallHint.svelte.test.ts
new file mode 100644
index 0000000..ad32459
--- /dev/null
+++ b/src/lib/ui/InstallHint.svelte.test.ts
@@ -0,0 +1,36 @@
+/* The hint's one control, as voice control reaches it.
+ *
+ * Someone steering by voice says the word they can see. The button used to
+ * carry aria-label="Dismiss" over visible text saying "Close", so "tap
+ * Close" matched nothing — the accessible name and the printed one must be
+ * the same word.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render, screen } from '@testing-library/svelte'
+
+// The hint decides once, from the real environment, whether to exist at
+// all. What is under test is the button, not the iOS detection — so the
+// gate is held open.
+vi.mock('$lib/pwa/install', () => ({
+ currentEnvironment: () => ({}),
+ hintAlreadySeen: () => false,
+ isIosSafariTab: () => true,
+ markHintSeen: () => {},
+}))
+
+import InstallHint from './InstallHint.svelte'
+
+describe('the install hint', () => {
+ afterEach(() => {
+ document.body.replaceChildren()
+ vi.restoreAllMocks()
+ })
+
+ it('names its button by the word printed on it', async () => {
+ render(InstallHint)
+
+ const button = await screen.findByRole('button', { name: 'Close' })
+ expect(button.getAttribute('aria-label'), 'an aria-label overrode the visible word').toBeNull()
+ })
+})
diff --git a/src/views/Access.svelte b/src/views/Access.svelte
index c09b2f4..4f70c01 100644
--- a/src/views/Access.svelte
+++ b/src/views/Access.svelte
@@ -161,6 +161,10 @@
{#await import('$lib/ui/QrCode.svelte') then module}
{@const QrCode = module.default}
+ {:catch}
+
+
The code didn't load — open this screen again to draw it.
{/await}
{:else}
diff --git a/src/views/Box.svelte b/src/views/Box.svelte
index a166b08..97d33dc 100644
--- a/src/views/Box.svelte
+++ b/src/views/Box.svelte
@@ -46,12 +46,25 @@
* Rejects with the home still on the phone and still working.
*/
leave: () => Promise
+ /**
+ * The shell's word that the last sign-out attempt failed.
+ *
+ * A failed leave tears every view down and puts the home back, this
+ * screen included, so the failure cannot live in this screen's own
+ * state — the instance that met it is gone. Opening on the same
+ * sentence it would have shown beats a Sign out button pretending
+ * nothing happened.
+ */
+ stuck?: boolean
}
- let { site, leave }: Props = $props()
+ let { site, leave, stuck = false }: Props = $props()
type Stage = 'idle' | 'confirming' | 'leaving' | 'stuck' | 'copy-kept'
- let stage = $state('idle')
+ // Read once at construction, on purpose: the shell's word is about how
+ // this instance opens, not a value to follow afterwards.
+ // svelte-ignore state_referenced_locally
+ let stage = $state(stuck ? 'stuck' : 'idle')
/**
* Whether Sourceful is holding a sealed copy of this home.
diff --git a/src/views/Box.svelte.test.ts b/src/views/Box.svelte.test.ts
index 8e8599f..fa671a4 100644
--- a/src/views/Box.svelte.test.ts
+++ b/src/views/Box.svelte.test.ts
@@ -186,6 +186,21 @@ describe('leaving, from the phone', () => {
const stillThere = await database.get('sites', SITE_ID)
expect(stillThere, 'the home this screen says is still here').toBeTruthy()
})
+
+ it('opens on the unfinished sign-out when the shell says so', async () => {
+ // A failed leave tears this screen down with every other view when the
+ // shell puts the home back, so the instance that met the failure — and
+ // held it in local state — is gone. The shell's word is what keeps the
+ // sentence on screen instead of a Sign out button pretending nothing
+ // happened.
+ const site = new SiteStore('test')
+ void site.start(SITE_ID)
+ render(Box, { props: { site, leave: vi.fn(async () => {}), stuck: true } })
+
+ const said = () => (document.body.textContent ?? '').replace(/\s+/g, ' ')
+ await vi.waitFor(() => expect(said()).toMatch(/That didn.t finish/i))
+ expect(said()).toMatch(/still on this phone and still works/i)
+ })
})
/* The spare key, and what it costs.
diff --git a/src/views/Energy.svelte b/src/views/Energy.svelte
index 8ea4706..651858d 100644
--- a/src/views/Energy.svelte
+++ b/src/views/Energy.svelte
@@ -18,7 +18,7 @@
crash — the same rule as every other capability.
-->
-{#if !hasHome}
-
-
Nothing paired yet
-
- Scan the code shown on your FTW box to connect. Everything stays between
- this app and your box — nothing readable passes through Sourceful.
-
-
-
Pairing lands with the enrollment flow.
-
-{:else if site.session.phase === 'booting'}
+{#if site.session.phase === 'booting'}
Your box is starting
@@ -78,7 +99,7 @@
Nothing is wrong — it will appear here as soon as it is ready.
{/if}
{:else if site.session.phase === 'terminated'}
@@ -218,11 +239,6 @@
font-weight: 500;
}
- .primary:disabled {
- opacity: 0.4;
- cursor: default;
- }
-
/* The same pair of weights the pairing screen uses, because they lead to the
same screen: the primary is for a phone that cannot get in at all, and the
quiet one is a question asked while the app is still trying. */
diff --git a/src/views/Now.svelte.test.ts b/src/views/Now.svelte.test.ts
new file mode 100644
index 0000000..607eb5b
--- /dev/null
+++ b/src/views/Now.svelte.test.ts
@@ -0,0 +1,118 @@
+/* The first screen, on the two honesty switches it owns.
+ *
+ * The house diagram claims two things a glance cannot check: that moving
+ * particles mean power is flowing at this very moment, and that the sentence
+ * on the boot screen is a sentence. Both fail silently — a quiet inverter
+ * keeps the particles moving over readings a minute old, and a wire enum
+ * reads as a fault code on the one screen that promises nothing is wrong.
+ *
+ * Everything is real: a Session, a SimBox, the loopback carrier, the box's
+ * own . Only the feed call is a spy.
+ */
+
+import { describe, it, expect, vi, afterEach } from 'vitest'
+import { render } from '@testing-library/svelte'
+import Now from './Now.svelte'
+import { SiteStore } from '$lib/state/site.svelte'
+import { LoopbackCarrier } from '$lib/carrier/loopback'
+import { SimBox } from '$lib/sim/box'
+import type { FtwEnergyFlowElement } from '$vendor/ftw/ftw-energy-flow.js'
+
+/** Fixed so the simulated house is the same every run. */
+const NOON = new Date(2026, 6, 15, 12, 0, 0).getTime()
+
+function flowEl(): FtwEnergyFlowElement | null {
+ return document.querySelector('ftw-energy-flow')
+}
+
+describe('the Now screen', () => {
+ afterEach(() => {
+ document.body.replaceChildren()
+ vi.useRealTimers()
+ vi.restoreAllMocks()
+ })
+
+ it('speaks about a starting box in words, never the wire token', async () => {
+ // The box sends codes; this app owns all prose. boot.phase is a name
+ // shared with the box — 'vacuum' is a database being compacted, and on
+ // the screen that says nothing is wrong it reads as a fault.
+ vi.useFakeTimers()
+ vi.setSystemTime(NOON)
+
+ const box = new SimBox({ now: () => Date.now(), faults: { booting: true } })
+ const site = new SiteStore('test')
+ site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
+
+ render(Now, { props: { site, active: true } })
+ const text = () => document.body.textContent ?? ''
+ for (let i = 0; i < 100 && !/starting/i.test(text()); i++) {
+ await vi.advanceTimersByTimeAsync(20)
+ }
+
+ expect(text()).toMatch(/Your box is starting/i)
+ expect(text(), 'a wire token reached the screen').not.toMatch(/\bvacuum\b|\bmigrate\b|\bdrivers\b/i)
+ // The progress itself is kept — it is the one number worth showing.
+ expect(text()).toMatch(/40\s?%/)
+ })
+
+ it('holds the house still when a source goes quiet on a healthy socket', async () => {
+ // The case the static switch exists for and phase alone cannot see:
+ // connected, streaming, and the inverter went quiet a while ago. Moving
+ // particles over those readings claim power is flowing right now.
+ vi.useFakeTimers()
+ vi.setSystemTime(NOON)
+
+ const box = new SimBox({ now: () => Date.now() })
+ const site = new SiteStore('test')
+ site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
+
+ render(Now, { props: { site, active: true } })
+ for (let i = 0; i < 100 && !flowEl(); i++) await vi.advanceTimersByTimeAsync(20)
+
+ expect(flowEl(), 'the house never drew at all').not.toBeNull()
+ expect(flowEl()!.hasAttribute('static'), 'a live stream was drawn still').toBe(false)
+
+ box.faults = { ...box.faults, sourceStates: { 'inverter.sungrow': 'stale' } }
+ box.tick(1_000)
+ await vi.advanceTimersByTimeAsync(100)
+
+ expect(site.session.phase, 'the session moved, so this is not the case under test').toBe(
+ 'streaming'
+ )
+ expect(site.srcState).not.toBe('live')
+ expect(
+ flowEl()!.hasAttribute('static'),
+ 'particles kept flowing over readings that are not current'
+ ).toBe(true)
+ })
+
+ it('does not feed the diagram while hidden, and catches up on return', async () => {
+ // Now stays mounted behind the other tabs, hidden by the shell. The
+ // stream keeps arriving either way; pushing it into a display:none SVG
+ // at 1 Hz is work nobody can see. Coming back must start from the
+ // present, not replay what was skipped.
+ vi.useFakeTimers()
+ vi.setSystemTime(NOON)
+
+ const box = new SimBox({ now: () => Date.now() })
+ const site = new SiteStore('test')
+ site.connect(new LoopbackCarrier(box, { latencyMs: 0 }))
+
+ const { rerender } = render(Now, { props: { site, active: false } })
+ for (let i = 0; i < 100 && !flowEl(); i++) await vi.advanceTimersByTimeAsync(20)
+ expect(flowEl()).not.toBeNull()
+
+ const fed = vi.spyOn(flowEl()!, 'setReadings')
+
+ // Ten seconds of live power into a view another tab is covering.
+ for (let i = 0; i < 10; i++) {
+ box.tick()
+ await vi.advanceTimersByTimeAsync(1_000)
+ }
+ expect(fed, 'a hidden SVG was fed at 1 Hz').not.toHaveBeenCalled()
+
+ await rerender({ active: true })
+ await vi.advanceTimersByTimeAsync(20)
+ expect(fed, 'coming back never caught the view up').toHaveBeenCalled()
+ })
+})
diff --git a/src/views/Pair.svelte b/src/views/Pair.svelte
index 5fe86c2..17e72c3 100644
--- a/src/views/Pair.svelte
+++ b/src/views/Pair.svelte
@@ -9,7 +9,7 @@