Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/universe-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ jobs:
working-directory: scripts/universe/visual-qa
run: >-
node capture.mjs --base=http://127.0.0.1:$GATEWAY_PORT --out=./artifacts-chain-states
--routes=dogecoin,dogecoin-drc20,zcash,zcash-block
--routes=dogecoin,dogecoin-drc20,dogecoin-dunes,dogecoin-dune,zcash,zcash-block,zcash-zrc20,zcash-zrc20-token
--states=chain-authority-down,chain-behind,chain-not-ready-protocols,chain-not-ready-unexplained,chain-object-missing
--themes=default,dark --viewports=375,1440

Expand Down
55 changes: 39 additions & 16 deletions docs/product/DESIGN-SYSTEM.md
Original file line number Diff line number Diff line change
Expand Up @@ -443,6 +443,29 @@ matrix, and both fixed layers eat into it. The header drops to 48px, the bottom
bar puts each icon beside its label instead of above it, and the scroll padding
comes down to match. No destination and no control is removed to buy the space.

### Reserving room for data that has not arrived

In a column with a viewport height, anything that grows moves everything below
it, and a panel that grows by a third of the screen is the whole layout-shift
budget on its own. So a block that will be filled from the network reserves its
room before the answer arrives, and the loading state fills that same box.

The reservation needs one height, and states that carry different content do
not have one by default. The transaction tracker is the worked example. Its
rows under the tracker bar settle at two sizes: a pending transaction shows a
first-seen time and an ETA, one line each; a confirmed one shows a timestamp
whose relative time goes to a second line below 992px, and a confirmation count
that is a button rather than a line of text. Reserving the smaller trades one
shift for another.

The rule that follows: give the row the reservation, not the block. Every row
in that panel is two lines of value tall, because the tallest of them is, so
both settled states are 4.5em a row, the block holds 9em with nothing in it,
and skeleton rows fill it while the transaction is being read. Nothing moves
when the answer arrives, and nothing moves again if a watched transaction
confirms. Rows taller than their content centre it, because a reservation that
shows as a gap under the text reads as a mistake.

### The safe area

`viewport-fit=cover` is set, so the page runs under the notch and the home
Expand Down Expand Up @@ -619,34 +642,34 @@ rather than of the machine:
| Budget | Value | Why it is gateable |
|---|---|---|
| eager payload, compressed | 480kB | the same commit gives the same number anywhere |
| cumulative layout shift | 0.1 | a property of the stylesheet, not the clock |
| cumulative layout shift | 0.1 | mostly the stylesheet, and the load can only add to it |

The eager payload is runtime, polyfills, main and styles: what a browser must
have before the shell exists. Measured at `bea93c1ec`, develop was 463kB
compressed and 1655kB raw; with the mobile work it is 464kB and 1659kB. The
ceiling is set where a real regression trips it and ordinary drift does not.
Raise it deliberately, and say what bought the bytes.

Layout shift is less deterministic than a stylesheet property sounds. The same
tree measured 0.042, 0.046 and 0.069 on the blocks route across three machines,
because a busier machine delivers content later and a shift that lands after
the first paint counts while the same shift before it does not. Load can only
add shifts, never remove one, so a route over its ceiling is measured again, up
to twice, and judged on the smallest reading. The table says when a route was
measured more than once, because a gate that quietly retries until it passes is
worse than one that fails.

Largest contentful paint is printed and not gated. The 2.5 second target is a
field number for a real device on a real network, and this is a throttled
headless browser on a runner that is also building and serving. The same commit
measured twelve seconds and twenty on two routes whose shells are identical. A
number that swings by eight seconds between runs of the same code cannot gate
anything; used as one it fails honest changes and passes slow ones depending on
what else the machine is doing. What is still failed is a paint that never
happens, which is a shell that did not render rather than one that rendered
slowly.

One route is over the layout budget for a reason that is written down rather
than hidden. On the transaction route, at about three seconds, `div.panel`
grows from 46 to 193 pixels as the tracker bar and the confirmations render
into it, pushing `div.bottom-panel` down 148 pixels. That is 0.103 of shift,
measured on develop before this work and 0.108 after. It is recorded with its
figure and printed every run, and the route is held to that figure, so drifting
further fails even while it is over. Reserving the room means knowing the
panel's height before the transaction is read, and it is not one height: an
alert, a tracker bar and a confirmation count are three different sizes. That
is work on the tracker's loading design, not on the shell.
what else the machine is doing. The paint is waited for rather than read once,
and when it still has not arrived the page is asked what is on it: a screenful
of text with no paint entry is a measurement problem and is reported as one,
while a page with nothing on it is the fault the check exists for. That
distinction does not move with the load.

### Real devices

Expand Down Expand Up @@ -674,7 +697,7 @@ rotation, back navigation, and a live update arriving.
| `scripts/universe/visual-qa/capture.mjs` | the route matrix: themes, widths, and data states |
| `scripts/universe/visual-qa/modes-check.mjs` | forced colours and 200 percent zoom |
| `scripts/universe/visual-qa/mobile-check.mjs` | the mobile gate: windows, insets, targets, fixed layers, rotation |
| `scripts/universe/visual-qa/mobile-perf.mjs` | the payload, the layout shift, and the recorded exception to it |
| `scripts/universe/visual-qa/mobile-perf.mjs` | the payload, the layout shift, and the table for recording a route that is over budget |
| `frontend/src/app/universe/universe-viewport.service.ts` | the visual viewport, published as CSS, for the software keyboard |
| `scripts/universe/visual-qa/fixtures.mjs` | the data every reviewed route renders from |

Expand Down
13 changes: 11 additions & 2 deletions frontend/src/app/components/tracker/tracker.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
}
<div class="data">
@if (tx && !tx.status?.confirmed) {
<div class="field narrower mt-2">
<div class="field narrower">
<div class="label" i18n="transaction.first-seen|Transaction first seen">First seen</div>
<div class="value">
@if (transactionTime > 0) {
Expand Down Expand Up @@ -82,7 +82,7 @@
</div>
}
} @else if (tx && tx.status?.confirmed) {
<div class="field narrower mt-2">
<div class="field narrower">
<div class="label" i18n="transaction.confirmed-at">Confirmed at</div>
<div class="value">
<app-timestamp [customFormat]="'yyyy-MM-dd HH:mm'" [unixTime]="tx.status.block_time" [hideTimeSince]="true"></app-timestamp>
Expand All @@ -106,6 +106,15 @@
</span>
</div>
</div>
} @else if (isLoading) {
<div class="field narrower" aria-hidden="true">
<div class="label"><span class="skeleton-loader" style="width: 6em;"></span></div>
<div class="value"><span class="skeleton-loader" style="max-width: 200px;"></span></div>
</div>
<div class="field narrower" aria-hidden="true">
<div class="label"><span class="skeleton-loader" style="width: 5em;"></span></div>
<div class="value"><span class="skeleton-loader" style="max-width: 120px;"></span></div>
</div>
}
</div>
</div>
Expand Down
51 changes: 51 additions & 0 deletions frontend/src/app/components/tracker/tracker.component.scss
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,57 @@
padding: 1em 0.5em 0;
}

/**
* The two rows under the tracker bar, held at one size.
*
* They are what used to move the page. The panel painted at 45px with this
* block empty, and at about three seconds, when the transaction arrived, two
* rows appeared in it and pushed everything below down by 148 pixels. The
* bottom panel is flex-grow inside a viewport-height column, so it took the
* push as a move and a shrink at once, and that single shift was the route's
* whole 0.103 of layout shift.
*
* Reserving the room needs one height, and the states did not have one.
* Measured at 390 by 844: a pending transaction settles at two 48px rows,
* first seen and an ETA; a confirmed one at 72 and 68, because below 992px
* the confirmed timestamp puts its relative time on a second line and the
* confirmation count is a button rather than a line of text. Reserving the
* smaller would have traded one shift for another.
*
* So the row is what gets the reservation, not the block: every row here is
* two lines of value tall, because the tallest of them is. Both settled
* states are then 4.5em a row and 9em together, the block holds that height
* with nothing in it, and skeleton rows fill it while the transaction is being
* read. Nothing moves when
* the transaction arrives, and nothing moves again if it confirms while it is
* being watched, which used to be a second 44px jump at the moment the
* tracker exists to show.
*/
.data {
min-height: 9em;
// Carried here from the first row, which used to hold it, so that the space
// above the rows is there in the states that have no rows to hold it.
margin-top: 0.5rem;

.field.narrower {
// The row is taller than most of its content now, so the content sits in
// the middle of it rather than against the top with the reservation
// showing as a gap underneath. Baseline alignment cannot do this: it pins
// the label to the value's first line, which on the confirmed row is the
// date and leaves the label above the relative time rather than beside
// the pair of them.
align-items: center;

.value {
min-height: 3em;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
}
}
}

.accelerate {
display: flex !important;
align-self: auto;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
import { describe, expect, it } from 'vitest';
import {
cubeGradient,
readCandidateBuckets,
} from '@app/universe/multichain-explorer/candidate-buckets';

/** A Dogecoin view, in the exact overlay contract shape. */
const DOGE_VIEW = {
schemaVersion: 'universe-candidate-buckets-v1',
chain: 'dogecoin',
network: 'mainnet',
snapshotId: 'snap-1',
sequenceAtomic: '7',
observedAt: '2026-08-30T07:00:00.000Z',
tip: { heightAtomic: '5900001', blockHash: 'd'.repeat(64), observedAt: '2026-08-30T07:00:00.000Z' },
completeness: 'complete',
semantics: 'ordered-by-fee-policy',
feeModel: { kind: 'fee-per-kilobyte', unit: 'koinu/kB', minRelayFeeAtomicPerKb: '100000' },
capacity: {
maxBlockSizeBytesAtomic: '1000000',
minerSoftCapBytesAtomic: null,
targetBlockSecondsAtomic: '60',
},
buckets: [
{
indexAtomic: '0',
txCountAtomic: '2',
totalSizeBytesAtomic: '1200',
totalFeesAtomic: '6600000',
feeQuantilesDecimal: ['1000000', null, null, null, null, null, null, '10000000'],
medianFeeDecimal: '1000000',
fillDecimal: '0.001',
overflow: false,
unknownFeeCountAtomic: '0',
},
{
indexAtomic: '1',
txCountAtomic: '3',
totalSizeBytesAtomic: '1800',
totalFeesAtomic: null,
feeQuantilesDecimal: [null, null, null, null, null, null, null, null],
medianFeeDecimal: null,
fillDecimal: '0.001',
overflow: true,
unknownFeeCountAtomic: '2',
},
],
disclosures: ['ordered-not-a-forecast', 'unknown-fees-not-placed', 'some-future-id'],
};

describe('readCandidateBuckets', () => {
it('reads the view without strengthening any claim', () => {
const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE');
expect(reading).not.toBeNull();
expect(reading!.unit).toBe('koinu/kB');
// The cadence line names a target, never an arrival time.
expect(reading!.cadenceNotice).toContain('60');
expect(reading!.cadenceNotice).not.toMatch(/in ~|minutes away/i);
expect(reading!.semanticsNotice).toContain('Not a forecast');
expect(reading!.cubes).toHaveLength(2);
const first = reading!.cubes[0];
expect(first.txCount?.display).toBe('2');
// Total fees shift by the chain precision: 6600000 koinu is 0.066 DOGE.
expect(first.totalFees?.display).toBe('0.066');
expect(first.feeSpan).toEqual({ low: '1000000', high: '10000000' });
});

it('keeps the overflow bucket honest about what it holds', () => {
const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE');
const overflow = reading!.cubes[1];
expect(overflow.overflow).toBe(true);
expect(overflow.totalFees).toBeNull();
expect(overflow.unknownFeeCount?.display).toBe('2');
expect(overflow.medianFee).toBeNull();
});

it('shows a disclosure id it has no copy for rather than dropping it', () => {
const reading = readCandidateBuckets(DOGE_VIEW, 8, 'DOGE');
expect(reading!.disclosures).toHaveLength(3);
expect(reading!.disclosures[2]).toBe('some-future-id');
});

it('labels ZIP-317 tiers and never implies an order', () => {
const reading = readCandidateBuckets(
{
...DOGE_VIEW,
semantics: 'zip317-eligibility-tiers',
feeModel: {
kind: 'zip-317',
unit: 'zatoshi/logical-action',
revisionAtomic: '1',
marginalFeeAtomic: '5000',
graceActionsAtomic: '2',
unpaidActionLimitAtomic: null,
},
buckets: [
{ ...DOGE_VIEW.buckets[0], tier: 'fee-unknown' },
],
disclosures: ['zip317-random-selection'],
},
8,
'ZEC'
);
expect(reading!.semanticsNotice).toContain('No order is implied');
expect(reading!.cubes[0].tierLabel).toBe('Fee not readable');
expect(reading!.cubes[0].tierTone).toBe('neutral');
});

it('is not this reading at all for other payloads', () => {
expect(readCandidateBuckets({ transactions: [] }, 8, 'DOGE')).toBeNull();
expect(readCandidateBuckets(null, 8, 'DOGE')).toBeNull();
});
});

describe('cubeGradient', () => {
const cube = {
index: '0',
overflow: false,
tierLabel: null,
tierTone: null,
txCount: null,
totalSize: null,
totalFees: null,
medianFee: null,
feeSpan: null,
fillPercent: 50,
quantiles: ['1', '2', '3', '4'],
unknownFeeCount: null,
};

it('leaves the empty share empty and bands the filled share', () => {
const gradient = cubeGradient(cube, ['aaaaaa', 'bbbbbb', 'cccccc']);
expect(gradient).toContain('var(--u-block-projected-empty) 50%');
expect(gradient).toContain('#aaaaaa');
expect(gradient).toContain('#cccccc');
});

it('renders an empty cube with no bands at all', () => {
expect(cubeGradient({ ...cube, fillPercent: 0 }, ['aaaaaa'])).toBe(
'var(--u-block-projected-empty)'
);
expect(cubeGradient({ ...cube, quantiles: [] }, ['aaaaaa'])).toBe(
'var(--u-block-projected-empty)'
);
});
});
Loading
Loading