From 579398981fdb9829b213bd6f4e764427a086decc Mon Sep 17 00:00:00 2001 From: archipelago Date: Sat, 1 Aug 2026 06:07:10 -0400 Subject: [PATCH] feat(01-03): paid tick renders the branded screensaver ring (FED-06) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both paid-tick surfaces now show the EQ-segment ring instead of a CSS ripple burst (send modal) and a plain circle (scan modal), via a new `badge` size variant at 160px/192px with matching --viz-radius. A transform scale of the compact variant was ruled out in 01-UI-SPEC.md because it would scale segment stroke width and blur along with the geometry. Both surfaces use the identical composition — a badge-sized relative container with the checkmark core absolutely centred over the ring — so the two ticks cannot drift apart visually. ScreensaverRing also gains the prefers-reduced-motion guard it never had, on the component rather than per call site, so the screensaver and SystemDangerZone variants are covered too. Also records live-browser verification for 01-13's scroll cue (three viewports, plus a geometry probe of the hide condition). Verified: 5 new tests; full suite 102 files / 822 tests green; npm run build clean with viz-ring-badge present in the built bundle. The live visual no-clipping observation Task 2 asks for is explicitly NOT done — recorded as deferred to plan 01-07's consolidated sign-off. Co-Authored-By: Claude Opus 5 (1M context) --- .../01-03-SUMMARY.md | 170 ++++++++++++++++++ .../01-13-SUMMARY.md | 25 ++- neode-ui/src/components/ScreensaverRing.vue | 35 +++- neode-ui/src/components/SendBitcoinModal.vue | 61 ++++--- neode-ui/src/components/WalletScanModal.vue | 40 ++++- .../src/components/__tests__/PaidTick.test.ts | 79 ++++++++ .../__tests__/ScreensaverRing.test.ts | 36 ++++ 7 files changed, 406 insertions(+), 40 deletions(-) create mode 100644 .planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md create mode 100644 neode-ui/src/components/__tests__/PaidTick.test.ts create mode 100644 neode-ui/src/components/__tests__/ScreensaverRing.test.ts diff --git a/.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md new file mode 100644 index 00000000..12592451 --- /dev/null +++ b/.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md @@ -0,0 +1,170 @@ +--- +phase: 01-federation-mesh-hardening +plan: 03 +subsystem: ui +tags: [vue, css, wallet, branding, reduced-motion] + +requires: + - phase: 01-federation-mesh-hardening + provides: "ScreensaverRing.vue's existing default/compact size variants and segment-pulse animation, plus the two paid-tick surfaces (SendBitcoinModal's ripple burst, WalletScanModal's plain circle)" +provides: + - "A third `badge` ring variant (160px / 192px, --viz-radius 80px / 96px) sized to sit inside a modal card" + - "The site-wide prefers-reduced-motion guard ScreensaverRing was missing entirely, now covering every variant including the two pre-existing ones" + - "Both paid-tick surfaces rendering the identical branded ring" +affects: [wallet, web5, screensaver] + +tech-stack: + added: [] + patterns: + - "New size variants on a shared visual component are added as their own class + mapping, never as a transform scale of an existing one — a scaled ring would also scale its segment stroke widths and blur, which is why 01-UI-SPEC.md ruled out the transform hack." + +key-files: + created: + - neode-ui/src/components/__tests__/ScreensaverRing.test.ts + - neode-ui/src/components/__tests__/PaidTick.test.ts + modified: + - neode-ui/src/components/ScreensaverRing.vue + - neode-ui/src/components/SendBitcoinModal.vue + - neode-ui/src/components/WalletScanModal.vue + +key-decisions: + - "Composition is identical on both surfaces: a `position: relative` badge-sized container holding with the checkmark core absolutely centred over it — so the two paid ticks cannot drift apart visually." + - "SendBitcoinModal keeps its burst-pop/burst-draw check animation; only the three ripple `burst-ring` spans and the burst-ripple keyframes were removed, since the ring now carries the motion." + - "WalletScanModal's core was kept at 6rem (it had no ripple to replace, just a w-24 circle) against SendBitcoinModal's 7rem, preserving each surface's existing checkmark proportion rather than homogenising them." + - "The reduced-motion guard was added to ScreensaverRing itself rather than per call site, so the screensaver and SystemDangerZone variants gain it too — 01-UI-SPEC.md flagged its total absence as a real gap this phase should close." + +requirements-completed: [FED-06] + +coverage: + - id: D1 + description: "The payment-success tick in SendBitcoinModal renders the EQ-segment ring, not a CSS ripple burst" + requirement: "FED-06" + verification: + - kind: unit + ref: "neode-ui/src/components/__tests__/PaidTick.test.ts#SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst" + status: pass + human_judgment: false + - id: D2 + description: "The scan-modal paid tick renders the same ring, so the paid tick is identical on every surface" + requirement: "FED-06" + verification: + - kind: unit + ref: "…#WalletScanModal: success pane shows the same badge ring and keeps its checkmark" + status: pass + human_judgment: false + - id: D3 + description: "A badge variant exists at 160px/192px with --viz-radius 80px/96px, alongside untouched default and compact variants" + requirement: "FED-06" + verification: + - kind: unit + ref: "neode-ui/src/components/__tests__/ScreensaverRing.test.ts#maps each size variant to its own ring class; #keeps the existing variants off the badge class" + status: pass + human_judgment: false + - id: D4 + description: "Success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changed" + requirement: "FED-06" + verification: + - kind: unit + ref: "…#SendBitcoinModal… asserts the 12,345 amount and SENT copy still render" + status: pass + human_judgment: false + - id: D5 + description: "SystemDangerZone and Screensaver continue to render compact and default unchanged" + requirement: "FED-06" + verification: + - kind: other + ref: "git status shows Screensaver.vue and SystemDangerZone.vue untouched; viz-ring-compact occurrence count unchanged at 3" + status: pass + human_judgment: false + - id: D6 + description: "Segment animation is disabled under prefers-reduced-motion for every size variant" + requirement: "FED-06" + verification: + - kind: other + ref: "@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.55 } } — exactly one such block in ScreensaverRing.vue" + status: pass + human_judgment: false + - id: D7 + description: "The badge ring fits inside the modal card without clipping at either breakpoint" + requirement: "FED-06" + verification: + - kind: manual_procedural + ref: "NOT yet observed live — see Deviations. Geometric check only: 160px badge inside a max-w-2xl card is ~344px of usable width at a 390px viewport and 672px at desktop, so the ring cannot exceed the card box." + status: deferred + human_judgment: true + +duration: 55min +completed: 2026-08-01 +status: complete +--- + +# Phase 1 Plan 3: On-Brand Paid Tick (FED-06) Summary + +**Both paid-tick surfaces now render the screensaver's EQ-segment ring at a new modal-sized `badge` variant, and the shared ring component finally honours `prefers-reduced-motion` — a guard it had been missing for every variant, not just the new one.** + +## Performance + +- **Duration:** ~55 min +- **Completed:** 2026-08-01 +- **Tasks:** 2/2 +- **Files modified:** 5 (3 components, 2 new test files) + +## Accomplishments + +- `ScreensaverRing` gained a `badge` size (160px mobile / 192px from 768px up, with matching `--viz-radius` 80px / 96px) mapped through `sizeClass`, following the exact shape of the existing two variants. `default` and `compact` are untouched. +- The reduced-motion guard was added to the component itself, so the screensaver and SystemDangerZone call sites gain it as a side benefit — `01-UI-SPEC.md` had flagged its complete absence. +- `SendBitcoinModal`'s success pane swapped its three CSS ripple spans for the ring, keeping the emerald pop-in check and its draw animation; the dead `burst-ring*` rules and `burst-ripple` keyframes were removed and the reduced-motion rule updated to drop the clause referencing them. +- `WalletScanModal`'s plain `w-24` circle became the same composition, so both paid ticks are now literally the same markup shape. +- Two new suites (5 tests) pin the variant mapping, the segment count, and both call sites — including that the ripple elements are gone and the amount/SENT copy is unchanged. + +## Task Commits + +1. **Task 1: badge variant + SendBitcoinModal** and **Task 2: WalletScanModal** — committed together with this SUMMARY (both surfaces share the composition; splitting them would have committed a half-converted pair of paid ticks). + +## Files Created/Modified + +- `neode-ui/src/components/ScreensaverRing.vue` — `'badge'` added to the size union, `sizeClass` widened to an if-chain, `.viz-ring-badge` rule + 768px breakpoint, and the `prefers-reduced-motion` guard on `.viz-segment`. +- `neode-ui/src/components/SendBitcoinModal.vue` — `ScreensaverRing` import; success pane restructured to `.send-success-badge` > ring + centred `.send-success-burst`/`.burst-core`; ripple markup, `.burst-ring*` rules and `burst-ripple` keyframes deleted. +- `neode-ui/src/components/WalletScanModal.vue` — `ScreensaverRing` import; `.scan-success-badge` + `.scan-success-core` replacing the fixed circle and its `.success-ring` rule. +- `neode-ui/src/components/__tests__/ScreensaverRing.test.ts` — 3 tests. +- `neode-ui/src/components/__tests__/PaidTick.test.ts` — 2 tests, one per surface. + +## Deviations from Plan + +### Test assertions had to target the document, not the wrapper + +**Found during:** Task 1, first green run +**Issue:** `wrapper.find('.burst-core')` returned nothing even though the markup rendered. `BaseModal` teleports its content to `document.body`, so the rendered nodes live outside the mounted wrapper's own root element. Component-tree queries (`findAllComponents`) still work, which is why the ring assertions passed while the DOM ones failed. +**Resolution:** DOM assertions switched to `document.querySelector(...)`, with an `afterEach` clearing `document.body` so one modal's teleported nodes cannot answer the next test's queries. +**Files modified:** `neode-ui/src/components/__tests__/PaidTick.test.ts` + +### Auto-fixed: vue-tsc strict-null on indexed access + +**Found during:** `npm run build` +**Issue:** `rings[0].props('size')` failed `vue-tsc` under `noUncheckedIndexedAccess` (TS2532) — the same class of failure commit `4a8925f0` fixed in `usePaidItemViewer.test.ts`. +**Resolution:** optional chaining (`rings[0]?.props('size')`), matching that commit's fix exactly. +**Files modified:** `neode-ui/src/components/__tests__/PaidTick.test.ts` + +### Outstanding: the live dev-preview observation Task 2 asks for was NOT made + +**Found during:** Task 2 verification +**Issue:** Task 2 requires confirming on a dev preview that neither ring is clipped by the modal card's scrolling container at a narrow and a desktop viewport. Reaching either success pane in a real browser requires an actually-settled payment; the built preview on `:4321` has no backend to settle one, and the running demo/dev servers in this tree belong to other sessions and are not to be disturbed. +**Resolution:** NOT resolved. Recorded honestly as deferred rather than claimed. The automated evidence (5 tests, `npm run build` green, `viz-ring-badge` present in the built bundle) covers structure but not appearance. The plan already consolidates the blocking human sign-off for this into **plan 01-07**, which is where the visual check belongs; the geometric argument (a 160px badge inside a `max-w-2xl` card, ~344px usable at a 390px viewport) says clipping is implausible, but it is not an observation. +**Files modified:** none + +## Known Stubs + +None. + +## Threat Flags + +None — presentational only. The plan's safety prohibition (the ring must never render success for an unsettled payment) is structurally satisfied: no success-gating condition was touched, only the markup inside an already-gated pane. + +## Self-Check: PASSED + +- FOUND: `viz-ring-badge` in `ScreensaverRing.vue` (3 occurrences: mapping + rule + breakpoint) +- FOUND: `prefers-reduced-motion` in `ScreensaverRing.vue` (exactly 1) +- CONFIRMED: `burst-ring` count in `SendBitcoinModal.vue` == 0; `success-ring` in `WalletScanModal.vue` == 0 +- CONFIRMED: `npx vitest run` — 102 files, 822 tests, all pass +- CONFIRMED: `npm run build` exits 0 and `grep -rq 'viz-ring-badge' web/dist/neode-ui/assets/` succeeds + diff --git a/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md b/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md index 7aaf1e4e..8e76c9d8 100644 --- a/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md +++ b/.planning/phases/01-federation-mesh-hardening/01-13-SUMMARY.md @@ -128,9 +128,32 @@ None. None — a presentational affordance with no new endpoint, no auth surface, and no state mutation. The one safety-relevant property (that it cannot set confirmation state) is asserted by test. +## Live-browser verification (added post-commit) + +Run against the built bundle on the `:4321` preview via Playwright, at the three viewports the +original executor's script targeted: + +| Viewport | Cue shown | After activating it | +|---|---|---| +| 1280×620 (short) | yes | tickbox in view, `checked=false`, cue gone | +| 1440×1000 (tall) | **no** — element never renders | n/a | +| 390×740 (narrow) | yes | tickbox in view, `checked=false`, cue gone | + +A geometry probe confirmed the mechanism rather than just the outcome: after activation the scroll +container sits at its maximum offset (`scrollTop == scrollHeight - clientHeight`) with the tickbox's +bottom above the container's (442 vs 454 at 1280×620; 622 vs 634 at 390×740), which is exactly the +condition the cue's visibility is derived from. + +One caveat worth recording: the *first* run reported the cue still visible after activation at +1280×620. It did not reproduce on any subsequent run, and the geometry probe showed the cue absent at +600 ms, 1500 ms and 3000 ms after the click at both viewports. The first run was the cold load +immediately after a rebuild, so it is almost certainly smooth-scroll settling — but it is written +down rather than discarded, because it is the one observation that contradicts the contract. + ## Self-Check: PASSED - FOUND: `neode-ui/src/views/OnboardingSeedGenerate.vue` (modified) - FOUND: `neode-ui/src/views/__tests__/OnboardingScrollCue.test.ts` (created, 6 tests) -- CONFIRMED: `npx vitest run` full frontend suite green +- CONFIRMED: `npx vitest run` full frontend suite green (102 files, 822 tests) +- CONFIRMED: live browser, 3 viewports (table above) diff --git a/neode-ui/src/components/ScreensaverRing.vue b/neode-ui/src/components/ScreensaverRing.vue index dd82e9ea..cb865a9d 100644 --- a/neode-ui/src/components/ScreensaverRing.vue +++ b/neode-ui/src/components/ScreensaverRing.vue @@ -14,13 +14,18 @@ import { computed } from 'vue' const props = withDefaults(defineProps<{ /** Visual size: 'default' matches the screensaver; 'compact' drops the - * min-width breakpoints (useful inside overlays on narrower canvases). */ - size?: 'default' | 'compact' + * min-width breakpoints (useful inside overlays on narrower canvases); + * 'badge' is the paid-tick size that fits inside a modal card (FED-06). */ + size?: 'default' | 'compact' | 'badge' /** Override segment count. Defaults to 48 (screensaver standard). */ segmentCount?: number }>(), { size: 'default', segmentCount: 48 }) -const sizeClass = computed(() => props.size === 'compact' ? 'viz-ring-compact' : 'viz-ring-default') +const sizeClass = computed(() => { + if (props.size === 'compact') return 'viz-ring-compact' + if (props.size === 'badge') return 'viz-ring-badge' + return 'viz-ring-default' +}) function getSegmentStyle(i: number) { const deg = (i / props.segmentCount) * 360 @@ -70,6 +75,21 @@ function getSegmentStyle(i: number) { } } +/* Paid-tick badge (FED-06) — sized to sit inside a modal card without + clipping, unlike the compact variant (240-320px against a ~112px core). */ +.viz-ring-badge { + width: 160px; + height: 160px; + --viz-radius: 80px; +} +@media (min-width: 768px) { + .viz-ring-badge { + width: 192px; + height: 192px; + --viz-radius: 96px; + } +} + .viz-segment { position: absolute; left: 50%; @@ -111,4 +131,13 @@ function getSegmentStyle(i: number) { 92.9%{ opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); } 100% { opacity: 0.3; transform: rotate(var(--segment-deg)) translateY(calc(-1 * var(--viz-radius))) scaleY(0.4); } } + +/* Site-wide reduced-motion convention — applies to every size variant, so + the ring holds a static, legible pose instead of pulsing. */ +@media (prefers-reduced-motion: reduce) { + .viz-segment { + animation: none; + opacity: 0.55; + } +} diff --git a/neode-ui/src/components/SendBitcoinModal.vue b/neode-ui/src/components/SendBitcoinModal.vue index 7d8d433f..e097b796 100644 --- a/neode-ui/src/components/SendBitcoinModal.vue +++ b/neode-ui/src/components/SendBitcoinModal.vue @@ -3,14 +3,14 @@