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:
co-authored by
Claude Opus 5
parent
4c39926483
commit
e4eb1617d1
+107
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user