From 7c62a8fb29d87a3d08141ca1ae07cbf723a16173 Mon Sep 17 00:00:00 2001 From: TheCryptoDonkey Date: Fri, 25 Sep 2026 23:57:55 +0100 Subject: [PATCH 1/2] refactor: simplify the Device panel into a summary and collapsible sections The Device screen stacked eleven sections of equal weight with diagnostics and long prose burying the things that need doing. Add a "Your signer" summary of bold status rows (worst first), each with one button that opens and scrolls to the matching section, now a closed-by-default disclosure. Diagnostics and lengthy explanations move into the sections or a nested "Why?" disclosure; warnings tied to an imminent action stay visible. Extract the summary-row derivation to lib/device-summary.ts (unit tested) and give Connectivity, OtaUpdate and Backup a heading prop so their titles can be suppressed when nested under a section summary. --- e2e/admin-home.spec.ts | 5 +- src/components/Backup.svelte | 8 +- src/components/Connectivity.svelte | 8 +- src/components/DevicePanel.svelte | 582 ++++++++++++++++++----------- src/components/OtaUpdate.svelte | 8 +- src/lib/device-summary.test.ts | 138 +++++++ src/lib/device-summary.ts | 126 +++++++ 7 files changed, 645 insertions(+), 230 deletions(-) create mode 100644 src/lib/device-summary.test.ts create mode 100644 src/lib/device-summary.ts diff --git a/e2e/admin-home.spec.ts b/e2e/admin-home.spec.ts index 5ea2ee5..d2e1de5 100644 --- a/e2e/admin-home.spec.ts +++ b/e2e/admin-home.spec.ts @@ -243,10 +243,13 @@ test.describe('mobile', () => { await advancedToggle(page).click() await page.getByRole('button', { name: 'Device', exact: true }).click() - await expect(page.getByRole('heading', { name: 'Network', exact: true })).toBeVisible() + // Network and Security are collapsible, closed by default; open both. + await page.getByText('Network', { exact: true }).click() await expect(page.getByLabel('WiFi SSID')).toBeVisible() await expect(page.getByLabel('WiFi password')).toBeVisible() await expect(page.getByRole('button', { name: 'Save to device', exact: true })).toBeVisible() + + await page.getByText('Security', { exact: true }).click() await expect(page.getByRole('heading', { name: 'Boot PIN', exact: true })).toBeVisible() const unattendedWarning = page.getByText(/A boot PIN is typed over the USB cable/) await expect(unattendedWarning).toBeVisible() diff --git a/src/components/Backup.svelte b/src/components/Backup.svelte index 4993aea..2cb9e52 100644 --- a/src/components/Backup.svelte +++ b/src/components/Backup.svelte @@ -184,10 +184,16 @@ importing = false } } + + interface Props { + /** False when nested under a parent heading (e.g. a collapsible section summary). */ + heading?: boolean + } + let { heading = true }: Props = $props()
-

Backup and restore

+ {#if heading}

Backup and restore

{/if}

Save this signer's app pairings and bridge secret so they survive a factory reset or a reflash. Without a backup, every connected app has to pair again. The signer asks you to confirm on its diff --git a/src/components/Connectivity.svelte b/src/components/Connectivity.svelte index 34ee34b..ae16a71 100644 --- a/src/components/Connectivity.svelte +++ b/src/components/Connectivity.svelte @@ -407,10 +407,16 @@ message = e instanceof Error ? e.message : String(e) } } + + interface Props { + /** False when nested under a parent heading (e.g. a collapsible section summary). */ + heading?: boolean + } + let { heading = true }: Props = $props()

-

Network

+ {#if heading}

Network

{/if}

WiFi-standalone is the standard setup: the signer sits on your network and works from anywhere. USB-only (radio off) is the hardened tier: no network stack runs on the key-holding chip, and Nostr apps reach it through the bridge diff --git a/src/components/DevicePanel.svelte b/src/components/DevicePanel.svelte index e4aa3af..74174da 100644 --- a/src/components/DevicePanel.svelte +++ b/src/components/DevicePanel.svelte @@ -3,10 +3,12 @@ // details, network mode, firmware, security (PIN, bridge secret), the bridge, // and the danger zone. Replaces the old Connectivity, Firmware and Danger // tabs plus the device half of Settings. + import { tick } from 'svelte' import { device, serialTransport, httpTransport, bridgeRestart, mgmtRevokeClient, relaySetLogQuiet, ensureBridgeAuth, usbDisplayFlip, setDisplayFlip, } from '../lib/device.svelte.js' + import { deviceSummaryRows, type DeviceSummaryInput, type SummaryRowId } from '../lib/device-summary.js' import { FrameType, buildSetPin, buildSetBridgeSecret, buildFactoryReset } from '../lib/frame.js' import { getFirmwareVersion } from '../lib/device.svelte.js' import { storageGauge } from '../lib/storage-gauge.js' @@ -412,7 +414,7 @@ function showNoEncryption() { chosenMode = 'none' noEncryptionAck = false - modesSection?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + void openAndScroll(() => { modesOpen = true }, () => modesSection) } // --- Danger zone --- @@ -475,19 +477,70 @@ resetPending = false } } + + // --- "Your signer" summary: one row per concern, worst first. Each row's + // button opens the matching section below and scrolls to it. --- + let backupSection = $state(null) + let diagnosticsSection = $state(null) + + let firmwareOpen = $state(false) + let modesOpen = $state(false) + let securityOpen = $state(false) + let backupOpen = $state(false) + let networkOpen = $state(false) + let displayOpen = $state(false) + let diagnosticsOpen = $state(false) + let bridgeOpen = $state(false) + let dangerOpen = $state(false) + + async function openAndScroll(open: () => void, target: () => HTMLElement | null) { + open() + await tick() + target()?.scrollIntoView({ behavior: 'smooth', block: 'start' }) + } + + function handleSummaryAction(id: SummaryRowId) { + if (id === 'firmware') void openAndScroll(() => { firmwareOpen = true }, () => firmwareSection) + else if (id === 'power-cut') void openAndScroll(() => { modesOpen = true }, () => modesSection) + else if (id === 'backup') void openAndScroll(() => { backupOpen = true }, () => backupSection) + else void openAndScroll(() => { diagnosticsOpen = true }, () => diagnosticsSection) + } + + const summaryInput = $derived({ + updateAvailable: !!updateInfo?.upgrade, + updateVersion: updateInfo?.latest ?? null, + runningVersion, + unlockMode: currentMode, + phoneCount, + overUsb, + needsBackup: pairingBackup.needsBackup, + lastExportAt: pairingBackup.lastExportAt, + crash: !!lastReset?.crash, + fragmented, + storageState: storage?.state ?? null, + modeLabel: modeLabel(), + }) + const summaryRows = $derived(deviceSummaryRows(summaryInput)) + + const powerCutStateWord = $derived( + currentMode === 'phone' ? 'Phone unlock' + : currentMode === 'sapwood' ? 'Sapwood or PIN only' + : currentMode === 'none' ? 'No encryption' + : 'Unknown', + ) + const securityStateWord = $derived(!overUsb ? 'USB only' : vaultStored ? 'Encrypted' : 'Not encrypted') + const backupStateWord = $derived(!overUsb ? 'USB only' : pairingBackup.needsBackup ? 'Needed' : 'Backed up') + const networkStateWord = $derived( + device.mode === 'relay' ? 'WiFi' : device.mode === 'serial' ? 'USB cable' : device.mode === 'http' ? 'Bridge' : '', + ) + const displayStateWord = $derived(screenFlip === null ? '' : screenFlip ? 'Flipped' : 'Upright') + const diagnosticsStateWord = $derived( + lastReset?.crash || fragmented || (storage && storage.state !== 'ok') ? 'Attention' : 'OK', + ) + const bridgeStateWord = $derived(typeof device.bridgeInfo?.mode === 'string' ? device.bridgeInfo.mode : '')

- {#if updateInfo?.upgrade} - - {/if} - {#if pairingBackup.needsBackup}

Pairing backup required

@@ -502,141 +555,36 @@
{/if} - -
-

Connection

- - - - - - {#if typeof health.uptime_s === 'number'} - - {/if} - {#if lastReset} - - {/if} - {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} - - - - - {/if} - {#if typeof maxSignBytes === 'number'} - - {/if} - {#if storage} - - - - - {/if} -
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text}
Free memory - {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} -
Max signed message - {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} - · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} -
Identity & app storage - - {storage.label} -
- {#if storage?.state === 'warn'} -

The signer's storage is filling up. Identities, personas, app pairings - and settings share it. Removing an unused persona or app pairing frees space.

- {/if} - {#if storage?.state === 'full'} -

The signer's storage is nearly full. It will refuse new personas before - app pairings stop working, so remove an unused persona or app pairing now.

- {/if} - {#if fragmented} -

The signer's memory is fragmented (its largest free block is small - relative to total free). This can happen after a burst of decryptions; it clears on the next - restart. Newer firmware frees the TLS buffers between messages to avoid it.

- {/if} - {#if trimmed} -

The signer trimmed this status to its vital fields because its memory - was too fragmented to send the full report. The request log paused this poll instead of the signer - crashing, and resumes once the memory recovers.

- {/if} - {#if lastReset?.crash} -

The signer's last restart was not planned.{#if health.crashed_during} It crashed while - handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log - below restarts empty each boot.

- {/if} - {#if recoveryReason} -

The signer restarted itself after its relay service was unusable for - several minutes ({recoveryReason}) — usually memory too fragmented to place TLS or - publish buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if - this repeats often, the memory readings above tell the story.

- {/if} - {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} -
- Activity light and log detail -
- - -
-

The signer's blue activity light flashes with its log output. Quiet keeps - warnings only, so the light stays dark in normal use.

-
- {/if} - {#if screenFlip !== null} -
- Screen orientation -
- - + +
+

Your signer

+
+ {#each summaryRows as row (row.id)} +
+ + {row.text} + {#if row.actionLabel} + + {/if}
-

Turn the picture round if the signer sits the other way up, for the - other hand or a case. The button labels on its cards move with it. On the signer itself: hold its - button on the DEVICE page.

-
- {/if} -
- - -
- + {/each} +
-
- -
+
+ Firmware{updateInfo?.upgrade ? 'Update available' : 'Up to date'} + +
{#if overUsb || device.mode === 'relay'} -
-

After a power cut

-

How this signer gets its keys back after it restarts. Each choice costs - something; pick the one that suits where the signer lives.

+
+ After a power cut{powerCutStateWord} +

How this signer recovers its keys after a restart. Pick the option that suits + where it lives.

{#each MODES as mode (mode.id)}
@@ -707,18 +655,20 @@ by USB to see whether this browser holds the signer's vault key.

{/if} {/if} -

There is no automatic phone unlock. Anyone holding the board can make it ask - for its key, and a phone that answered by itself would hand it over: no safer than no - encryption, while looking safer.

+
+ Why is there no automatic phone unlock? +

Anyone holding the board can make it ask for its key, and a phone that + answered by itself would hand it over: no safer than no encryption, while looking safer.

+
-
+ {/if} -
-

Security

+
+ Security{securityStateWord} {#if !overUsb}

The boot PIN, bridge secret and encryption at rest are changed over USB. Plug the signer into this computer and connect by cable.

@@ -766,31 +716,9 @@ {/if} {#if pinStatus}

{pinStatus}

{/if} -

Bridge secret

-

Shared secret for bridge authentication (device-decrypts mode). Needs the - button; cannot be set while a bridge session is active.

-
-
- - -
- -
- {#if secretStatus}

{secretStatus}

{/if} -

Encrypt at rest

-

Encrypts the signer's stored keys with a vault key held by this browser, - never by the device — a stolen device yields only ciphertext. The device asks for its - button to confirm.

+

Encrypts the signer's stored keys with a vault key held by this browser, never + by the device: a stolen device yields only ciphertext.

{#if vaultLocked}

This signer is locked. Unlock it from the banner on Home before changing encryption.

@@ -798,7 +726,7 @@

Add an identity to the signer first.

{:else if vaultEscrowKey}
-

Your vault key — store it somewhere safe off this browser +

Your vault key: store it somewhere safe off this browser (password manager, printed) before the signer is sealed. Without it, a sealed signer cannot be unlocked.

{vaultEscrowKey}
@@ -891,18 +819,182 @@
{/if} {#if vaultStatus}

{vaultStatus}

{/if} + +
+ Advanced: bridge secret +

Shared secret for bridge authentication (device-decrypts mode). Needs the + button; cannot be set while a bridge session is active.

+
+
+ + +
+ +
+ {#if secretStatus}

{secretStatus}

{/if} +
{/if} -
+ {#if overUsb} - +
+ Backup{backupStateWord} + +
{/if} + +
+ Network{networkStateWord} + +
+ + +
+ Display and light{displayStateWord} +

Screen orientation and the activity light's log detail.

+ {#if device.mode === 'relay' && typeof device.relayStatus?.log_quiet === 'boolean'} +
+ Activity light and log detail +
+ + +
+

The signer's blue activity light flashes with its log output. Quiet keeps + warnings only, so the light stays dark in normal use.

+
+ {/if} + {#if screenFlip !== null} +
+ Screen orientation +
+ + +
+

Turn the picture round if the signer sits the other way up, for the + other hand or a case. The button labels on its cards move with it. On the signer itself: hold its + button on the DEVICE page.

+
+ {/if} +
+ + +
+ Diagnostics{diagnosticsStateWord} +

Live numbers from the signer: connection, memory and storage.

+ + + + + + {#if typeof health.uptime_s === 'number'} + + {/if} + {#if lastReset} + + {/if} + {#if typeof freeHeap === 'number' && typeof largestBlock === 'number'} + + + + + {/if} + {#if typeof maxSignBytes === 'number'} + + {/if} + {#if storage} + + + + + {/if} +
Connected over{modeLabel()}
Address{device.portInfo || '--'}
Identities{device.masters.filter((m) => !m.persona).length}
Apps{device.slots.length}
Signer up{formatUptime(health.uptime_s)}
Last restart{recoveryReason ? 'self-recovery restart' : lastReset.text}
Free memory + {kb(freeHeap)}{#if fragmented} · fragmented (largest block {kb(largestBlock)}){/if} +
Max signed message + {formatBytes(maxSignBytes)}{#if typeof maxSignBytesObject === 'number' && maxSignBytesObject > maxSignBytes} + · {formatBytes(maxSignBytesObject)} for apps that ask for the compact reply{/if} +
Identity & app storage + + {storage.label} +
+ {#if storage?.state === 'warn'} +

The signer's storage is filling up. Identities, personas, app pairings + and settings share it. Removing an unused persona or app pairing frees space.

+ {/if} + {#if storage?.state === 'full'} +

The signer's storage is nearly full. It will refuse new personas before + app pairings stop working, so remove an unused persona or app pairing now.

+ {/if} + {#if lastReset?.crash} +

The signer's last restart was not planned.{#if health.crashed_during} It crashed while + handling {health.crashed_during}.{/if} If this repeats, note the pattern; the request log + below restarts empty each boot.

+ {/if} + {#if fragmented} +
+ Why is the memory fragmented? +

The signer's largest free block is small relative to total free. + This can happen after a burst of decryptions; it clears on the next restart. Newer firmware + frees the TLS buffers between messages to avoid it.

+
+ {/if} + {#if trimmed} +

The signer trimmed this status to its vital fields because its memory + was too fragmented to send the full report. The request log paused this poll instead of the signer + crashing, and resumes once the memory recovers.

+ {/if} + {#if recoveryReason} +
+ Why did the signer restart itself? +

Its relay service was unusable for several minutes + ({recoveryReason}), usually memory too fragmented to place TLS or publish + buffers. It now recovers on its own instead of staying unreachable until a power-cycle; if + this repeats often, the memory readings above tell the story.

+
+ {/if} +
+ {#if overBridge && device.bridgeInfo} -
-

Bridge

+
+ Bridge{bridgeStateWord} @@ -937,59 +1029,57 @@ onconfirm={handleBridgeRestart} /> - + {/if} -
-

Flash a device

-

Install or re-install firmware on a Heartwood board, whether a new device or a - full re-flash, in the guided flasher.

-
+

Flash another board: install or re-flash firmware in the guided + flasher.

-
-

Danger zone

- -
-
- Disconnect all apps - Every connected app loses access immediately. The signer and its keys are untouched. +
+ Danger zone +
+
+
+ Disconnect all apps + Every connected app loses access immediately. The signer and its keys are untouched. +
+
- -
- {#if revokeAllResult}

{revokeAllResult}

{/if} - -
-
- Factory reset - Erases all keys, apps, bridge secret and PIN. Irreversible. - The device asks for its button to confirm. - {#if device.connected && !canReset} - Not available over WiFi. Wiping the signer needs it in your - hands. Plug it in over USB. - {/if} + {#if revokeAllResult}

{revokeAllResult}

{/if} + +
+
+ Factory reset + Erases all keys, apps, bridge secret and PIN. Irreversible. + The device asks for its button to confirm. + {#if device.connected && !canReset} + Not available over WiFi. Wiping the signer needs it in your + hands. Plug it in over USB. + {/if} +
+
- + {#if resetResult}

{resetResult}

{/if}
- {#if resetResult}

{resetResult}

{/if} -
+ diff --git a/src/components/OtaUpdate.svelte b/src/components/OtaUpdate.svelte index 74e6334..63e22f5 100644 --- a/src/components/OtaUpdate.svelte +++ b/src/components/OtaUpdate.svelte @@ -233,10 +233,16 @@ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB` return `${(bytes / (1024 * 1024)).toFixed(1)} MB` } + + interface Props { + /** False when nested under a parent heading (e.g. a collapsible section summary). */ + heading?: boolean + } + let { heading = true }: Props = $props()
-

Update firmware

+ {#if heading}

Update firmware

{/if} {#if !canUpdate}
Mode{device.bridgeInfo.mode}
Uptime{formatUptime(device.bridgeInfo.uptime_secs as number)}