refine(wallet): the balance readout scans as one column, not a barcode
Demo images / Build & push demo images (push) Failing after 2m7s

Seeing it on the node settled the shape. Two rows of per-cell delays
read as a dense flicker — closer to a progress bar than a display, and
short enough against the row's text to look like an underline.

Three rows laid out column-first fixes both: the three cells of a column
now share a delay, so the lit column travels across as a single scan
line, and at 11px the matrix sits with the text rather than under it.

Verified in a real browser against the live node with the balance RPCs
held open: five placeholders, five distinct rail colours (white, orange,
yellow, purple, blue), 42 cells each, all animating, each announcing
what it is waiting for — and no "0 sats" anywhere on screen while the
calls were in flight. They gave way to real figures on arrival, with
Lightning's genuine 0 correctly shown as a figure rather than left
shimmering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-17 09:05:33 -04:00
co-authored by Claude Opus 5
parent fa6fe32ef9
commit 212e349b19
3 changed files with 38 additions and 19 deletions
+6 -4
View File
@@ -196,8 +196,8 @@
not a loading state, and it is the one number that frightens not a loading state, and it is the one number that frightens
people. It inherits currentColor, so each tile shimmers in its own people. It inherits currentColor, so each tile shimmers in its own
rail colour. */ rail colour. */
.bal-pixels { display: inline-grid; grid-template-columns: repeat(14, 4px); grid-auto-rows: 4px; gap: 1px; vertical-align: 0.15em; } .bal-pixels { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(3, 4px); grid-auto-columns: 4px; gap: 1px; vertical-align: 0.1em; }
.bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.4s ease-in-out infinite; } .bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.6s ease-in-out infinite; }
@keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } } @keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } }
@media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } } @media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } }
.bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; } .bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; }
@@ -1083,9 +1083,11 @@
// 28 cells = 14 columns x 2 rows, delays staggered so the lit column // 28 cells = 14 columns x 2 rows, delays staggered so the lit column
// travels across the matrix. // travels across the matrix.
// 14 columns x 3 rows, laid out column-first so the three cells of a
// column share a delay and the lit column scans across as one line.
const BAL_PIXELS = '<span class="bal-pixels" role="status" aria-label="Loading balance">' + const BAL_PIXELS = '<span class="bal-pixels" role="status" aria-label="Loading balance">' +
Array.from({ length: 28 }, function (_, i) { Array.from({ length: 42 }, function (_, i) {
return '<i style="animation-delay:' + (i * 45) + 'ms"></i>'; return '<i style="animation-delay:' + (Math.floor(i / 3) * 55) + 'ms"></i>';
}).join('') + '</span>'; }).join('') + '</span>';
// Render a balance, or the pixel readout when it is not known yet. // Render a balance, or the pixel readout when it is not known yet.
+22 -13
View File
@@ -34,9 +34,18 @@ const props = withDefaults(
{ suffix: 'sats', label: 'balance' }, { suffix: 'sats', label: 'balance' },
) )
// 14 columns × 2 rows. Enough to read as a matrix rather than a spinner, and // 14 columns × 3 rows, laid out column-first so all three cells of a column
// close to the width of a five-figure sat amount. // share a delay and the lit column travels across as a single scan line —
const CELLS = 28 // that is what makes it read as a readout rather than a progress bar.
const COLUMNS = 14
const ROWS = 3
const CELLS = COLUMNS * ROWS
const STEP_MS = 55
/** Delay for cell `i` (1-based), constant within a column. */
function cellDelay(i: number): string {
return `${Math.floor((i - 1) / ROWS) * STEP_MS}ms`
}
/** /**
* Built as one string rather than interpolated around a `<template>`, so the * Built as one string rather than interpolated around a `<template>`, so the
@@ -59,7 +68,7 @@ const display = computed(() => {
:aria-label="`Loading ${props.label}`" :aria-label="`Loading ${props.label}`"
:title="`Loading ${props.label}…`" :title="`Loading ${props.label}…`"
> >
<span v-for="i in CELLS" :key="i" class="balance-pixel" :style="{ '--i': i }" /> <span v-for="i in CELLS" :key="i" class="balance-pixel" :style="{ animationDelay: cellDelay(i) }" />
</span> </span>
<span v-else>{{ display }}</span> <span v-else>{{ display }}</span>
</template> </template>
@@ -67,12 +76,15 @@ const display = computed(() => {
<style scoped> <style scoped>
.balance-pixels { .balance-pixels {
display: inline-grid; display: inline-grid;
grid-template-columns: repeat(14, 3px); /* Column-first: children fill top-to-bottom, then across, so consecutive
grid-auto-rows: 3px; cells share a column and the delay below scans horizontally. */
grid-auto-flow: column;
grid-template-rows: repeat(3, 3px);
grid-auto-columns: 3px;
gap: 1px; gap: 1px;
/* Sit on the text baseline so a row's height doesn't change when the real /* Centred on the text it stands in for, so the row height is unchanged when
figure replaces this. */ the real figure replaces it. */
vertical-align: -1px; vertical-align: 0.05em;
} }
.balance-pixel { .balance-pixel {
@@ -81,10 +93,7 @@ const display = computed(() => {
border-radius: 0.5px; border-radius: 0.5px;
background: currentColor; background: currentColor;
opacity: 0.16; opacity: 0.16;
/* The wave runs left-to-right across columns; the two rows of a column are animation: balance-pixel-scan 1.6s ease-in-out infinite;
offset slightly so it reads as a scan rather than a marching block. */
animation: balance-pixel-scan 1.4s ease-in-out infinite;
animation-delay: calc(var(--i) * 45ms);
} }
@keyframes balance-pixel-scan { @keyframes balance-pixel-scan {
@@ -59,9 +59,17 @@ describe('BalanceAmount', () => {
expect(w.html()).not.toMatch(/background:\s*#|rgb\(/) expect(w.html()).not.toMatch(/background:\s*#|rgb\(/)
}) })
it('renders enough cells to read as a matrix', () => { it('renders a 14x3 matrix scanned column by column', () => {
// Column-first layout is what makes the lit column travel across as one
// scan line; per-cell delays would make it crawl diagonally instead.
const w = mount(BalanceAmount, { props: { sats: null } }) const w = mount(BalanceAmount, { props: { sats: null } })
expect(w.findAll('.balance-pixel').length).toBe(28) const cells = w.findAll('.balance-pixel')
expect(cells.length).toBe(42)
// The three cells of a column share a delay; the next column steps on.
const delay = (i: number) => cells[i]?.attributes('style') ?? ''
expect(delay(0)).toBe(delay(1))
expect(delay(1)).toBe(delay(2))
expect(delay(3)).not.toBe(delay(2))
}) })
}) })