phase-7: motion, an installable app, and a capture that survives no signal

GSAP carries the counter roll-ups, the bandeja card physics, the dialog
transitions and the three success moments FLOWS.md allows. Every one of
them checks prefers-reduced-motion first and does nothing when it is set.

boneyard and canvas-ui are not what SPEC.md's stack table says they are:
on npm the names belong to two abandoned projects that do neither job.
The skeletons were already ours; the two canvas spots are now sixty lines
each with no dependency. DECISIONS.md records the substitution.

The app installs, keeps a scan taken with no network in IndexedDB and
sends it when there is one, falls back to a page that explains itself,
and can push a deadline notice. Reading the log of what is queued is the
source of truth, so the notice clears when the capture actually lands.

The CSP now allows scripts by per-request nonce rather than by
'unsafe-inline'. That forced /offline to render per request: a
prerendered page carries a build-time nonce no live policy matches, so
its scripts were blocked and it never hydrated.

Two crashes fixed on the way. web-push throws on a VAPID subject that is
not https: or mailto:, and the code handed it APP_PUBLIC_URL, so any
machine with push keys died at boot; a misconfigured optional channel now
switches itself off and says why. And a subscription the push service
answers 410 for is deleted rather than retried forever.

Lighthouse on the production build: accessibility 100, best practices 96,
SEO 100, performance 73. The performance number is not trustworthy on
this machine and DECISIONS.md says why; total blocking time did fall from
17.6s to 1.7s once the hero canvas stopped drawing at full resolution
every frame and the landing page stopped importing GSAP.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Michilis
2026-09-05 20:48:48 +00:00
co-authored by Claude Opus 5
parent 4c39926483
commit e4eb1617d1
59 changed files with 2148 additions and 67 deletions
+107
View File
@@ -621,3 +621,110 @@ were on screen next to each other.
- **Filtering the error queue by account.** The overview lists a user's own open errors and
links to the queue. CONTRACTS.md gives `/admin/errors` a stage and status filter and no
user filter, and one was not invented.
## Phase 7
### boneyard and canvas-ui do not exist as SPEC.md describes them
SPEC.md section 3 names both in the stack table. On npm, `boneyard` is an abandoned 2015
"architectural toolkit" and `canvas-ui` is Mesosphere's abandoned design system. Neither
does what FLOWS.md asks of the name, so both were built here instead, to the behaviour
FLOWS.md specifies rather than to the package name:
- **Skeletons** are `components/ui/skeleton.tsx`, shipped since phase 0, with exactly the
named shapes FLOWS.md section 1 lists. No content load anywhere shows a spinner.
- **The two canvas spots** are `components/canvas/`: a slow liquid wash behind the landing
hero (A1) and a brief bloom on the screen that says a declaration is filed (D3). Sixty
lines each, no dependency, off under reduced motion with the same picture held still as
the fallback.
Installing either package would have added dead weight and done none of the work. This is
recorded here rather than buried in a comment because it contradicts the stack table.
### Motion is one module and one preference
`lib/motion.ts` holds two durations, one ease, and the four helpers everything uses:
`useGsap` (a context that reverts on unmount), `useReveal`, `useCountUp` and the reduced
motion read. Every animation checks the preference, so a reader who has asked for less
motion gets none rather than a fast one.
`usePrefersReducedMotion` lives in `lib/browser.ts`, not next to the GSAP helpers. The
landing page needs the answer and must not pull an animation library in to ask the
question; the motion module re-exports it so callers have one place to look.
### Reading a browser-only value is not state
`lib/browser.ts` reads through `useSyncExternalStore`. "Is this iOS", "which language does
the browser want", "is the app installed", "is reduced motion on": all are true on the
first client render and none needs a second pass. Writing them from an effect renders once
with a placeholder and again with the truth, which for reduced motion means an animation
that starts and is then told not to.
### The stack shows a shoulder, not a card
FLOWS.md B4 wants the next card to scale up as the current one flies out. A whole card
behind sits entirely hidden behind a tall front card and entirely exposed behind a short
one, since the front card's height follows its content. A shoulder above the top edge reads
as a stack at every height.
### The service worker caches the shell and nothing else
No API response is cached. A tax figure that is quietly out of date is worse than one that
is honestly missing, so a data request that fails, fails, and the screen says so. What is
cached is one offline page, which explains itself and offers a retry.
`/offline` sits outside `[locale]`, because the worker caches exactly one URL and a page
that only existed per locale would mean caching one and showing it to everybody. It picks
its language in the browser from the two catalogs we already ship.
### Offline captures wait in IndexedDB, and the queue is the source of truth
A capture taken with no network is stored whole and sent later. Both the scanner and the
shell read the queue rather than remembering that something was queued, so the notice
disappears when the capture actually lands rather than when the screen guesses it has.
Sending retries on an interval as well as on the `online` event: a phone walking back into
coverage does not reliably fire that event, and a stranded photograph is the one failure
this feature exists to prevent.
### CSP by nonce, styles still inline
The proxy mints a nonce per request and Next stamps it on the scripts it renders, with
`'strict-dynamic'` for the chunks they load. `style-src` keeps `'unsafe-inline'`: React
writes inline `style` attributes for things like a dragged card and there is no way to
nonce those.
This forced one change: `/offline` is rendered per request rather than prerendered. A
prerendered page carries a build-time nonce that no live policy matches, so its scripts
were blocked and the page rendered without ever hydrating. The service worker caches
headers along with the body, so the copy it serves offline stays self consistent.
### A crash at boot, from an optional channel
`webpush.setVapidDetails` throws when the VAPID subject is not an `https:` or a `mailto:`
URL, and the code handed it `APP_PUBLIC_URL`, which is `http://localhost:3005` in
development. Any machine with push keys configured therefore died at boot. There is now a
`PUSH_VAPID_SUBJECT` variable, a fallback to `APP_PUBLIC_URL` when it is https and then to
`mailto:SMTP_FROM`, and with none of the three push switches itself off and says so. A
misconfigured optional channel must never take the API down.
### Subscriptions the push service has given up on are deleted
A 404 or a 410 from the push service means the browser threw the subscription away, and the
row is removed. Anything else is transient and the row stays: a network blip is not a
reason to stop notifying someone forever.
### Lighthouse: measured, not met
The target is a mobile score of 90 or better. Measured here on the production build:
accessibility 100, best practices 96, SEO 100, performance 73. The performance number is
not trustworthy on this machine. Lighthouse reports a `benchmarkIndex` of about 560 (a
healthy development machine is 1000 or more) on a shared, loaded box, and then applies a
4x CPU multiplier on top of that. The same page with the multiplier removed scores 89.
What the phase actually fixed is real and measurable: total blocking time went from
17,590ms to 1,700ms once the hero canvas stopped drawing at full resolution on every frame
and the landing page stopped importing GSAP. First contentful paint 0.9s, largest
contentful paint 1.9s, cumulative layout shift 0. The remaining 4 points of best practices
are Chrome flagging `style-src 'unsafe-inline'`, which is the deliberate choice above.
**A 90+ mobile score cannot be verified in this environment.** It belongs on the list with
Docker.
### The four states, screen by screen
Every data screen ships a skeleton, an error with a retry and content. The two detail
screens (a comprobante, a declaration) have no empty state on purpose: a detail screen
either has its subject or is a 404, and there is no third case. The crossfade from skeleton
to content is applied where the skeleton is a separate early return; the three list screens
render their skeleton inside the same tree as their heading, where fading the whole tree
would also fade a heading that never changed.