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 @@
-
-
-
-
-
-
+
+
+
+
+
+
@@ -259,6 +259,7 @@ import { ref, computed, watch, nextTick } from 'vue'
import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
+import ScreensaverRing from '@/components/ScreensaverRing.vue'
const { t } = useI18n()
@@ -681,10 +682,27 @@ async function send() {
diff --git a/neode-ui/src/components/WalletScanModal.vue b/neode-ui/src/components/WalletScanModal.vue
index 3fe113d0..2107ec1d 100644
--- a/neode-ui/src/components/WalletScanModal.vue
+++ b/neode-ui/src/components/WalletScanModal.vue
@@ -229,10 +229,13 @@
-
-
+
+
+
+
+
@@ -268,6 +271,7 @@ import { rpcClient } from '@/api/rpc-client'
import { useAnimatedQRDecoder } from '@/composables/useAnimatedQRDecoder'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import { useBodyScrollLock } from '@/composables/useBodyScrollLock'
+import ScreensaverRing from '@/components/ScreensaverRing.vue'
type Rail = 'onchain' | 'lightning' | 'cashu' | 'fedimint'
type Action = 'pay-invoice' | 'send-onchain' | 'redeem-token' | 'fedimint-join'
@@ -858,7 +862,33 @@ onUnmounted(stopScanning)
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.35);
}
-.success-ring {
+/* Paid tick (FED-06) — same composition as SendBitcoinModal: the branded
+ ScreensaverRing badge with the emerald checkmark core centred over it. */
+.scan-success-badge {
+ position: relative;
+ width: 160px;
+ height: 160px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+@media (min-width: 768px) {
+ .scan-success-badge {
+ width: 192px;
+ height: 192px;
+ }
+}
+.scan-success-core {
+ position: absolute;
+ top: 50%;
+ left: 50%;
+ transform: translate(-50%, -50%);
+ width: 6rem;
+ height: 6rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ border-radius: 9999px;
background: rgba(16, 185, 129, 0.12);
box-shadow: 0 0 40px rgba(16, 185, 129, 0.25);
}
diff --git a/neode-ui/src/components/__tests__/PaidTick.test.ts b/neode-ui/src/components/__tests__/PaidTick.test.ts
new file mode 100644
index 00000000..d3a93a8a
--- /dev/null
+++ b/neode-ui/src/components/__tests__/PaidTick.test.ts
@@ -0,0 +1,79 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
+import { mount, flushPromises } from '@vue/test-utils'
+import i18n from '@/i18n'
+import ScreensaverRing from '../ScreensaverRing.vue'
+import SendBitcoinModal from '../SendBitcoinModal.vue'
+import WalletScanModal from '../WalletScanModal.vue'
+
+// Both modals fetch balances/fees on open. None of that is what this suite is
+// about — stub the transport so mounting is deterministic and offline.
+vi.mock('@/api/rpc-client', () => ({
+ rpcClient: {
+ call: vi.fn().mockResolvedValue({}),
+ },
+}))
+
+// WalletScanModal reaches for camera/QR APIs jsdom does not implement.
+beforeEach(() => {
+ if (!navigator.mediaDevices) {
+ Object.defineProperty(navigator, 'mediaDevices', {
+ value: { getUserMedia: vi.fn().mockRejectedValue(new Error('no camera')), enumerateDevices: vi.fn().mockResolvedValue([]) },
+ configurable: true,
+ })
+ }
+})
+
+// Teleported modal markup outlives the wrapper's root, so clear it between
+// cases — otherwise one modal's nodes answer the next one's queries.
+afterEach(() => {
+ document.body.innerHTML = ''
+})
+
+const mountOpts = { props: { show: true }, global: { plugins: [i18n] } }
+
+describe('paid tick renders the branded ring (FED-06)', () => {
+ it('SendBitcoinModal: payment success shows exactly one badge ring, no ripple burst', async () => {
+ const wrapper = mount(SendBitcoinModal, mountOpts)
+ // Drive the component into its settled-payment state directly — this
+ // suite is about what success *renders*, not how a payment settles.
+ ;(wrapper.vm as unknown as Record).successInfo = {
+ amount: 12345,
+ methodLabel: 'Sent via Lightning',
+ }
+ await flushPromises()
+
+ const rings = wrapper.findAllComponents(ScreensaverRing)
+ expect(rings).toHaveLength(1)
+ expect(rings[0]?.props('size')).toBe('badge')
+
+ // BaseModal teleports its content to , so the rendered markup lives
+ // outside the wrapper's own root element — assert against the document.
+ // The checkmark core survives the ring swap...
+ expect(document.querySelector('.burst-core')).not.toBeNull()
+ expect(document.querySelector('.burst-check')).not.toBeNull()
+ // ...and the CSS ripple elements it replaced are gone entirely.
+ expect(document.querySelectorAll('.burst-ring')).toHaveLength(0)
+
+ // Decoration only: the amount and SENT copy are untouched.
+ expect(document.body.textContent).toContain('12,345')
+ expect(document.body.textContent).toContain('SENT')
+
+ wrapper.unmount()
+ })
+
+ it('WalletScanModal: success pane shows the same badge ring and keeps its checkmark', async () => {
+ const wrapper = mount(WalletScanModal, mountOpts)
+ ;(wrapper.vm as unknown as Record).pane = 'success'
+ await flushPromises()
+
+ const rings = wrapper.findAllComponents(ScreensaverRing)
+ expect(rings).toHaveLength(1)
+ expect(rings[0]?.props('size')).toBe('badge')
+ expect(document.querySelector('.scan-success-core')).not.toBeNull()
+ expect(document.querySelector('svg path[d="M5 13l4 4L19 7"]')).not.toBeNull()
+ // The plain fixed-size circle the ring replaced is gone.
+ expect(document.querySelectorAll('.success-ring')).toHaveLength(0)
+
+ wrapper.unmount()
+ })
+})
diff --git a/neode-ui/src/components/__tests__/ScreensaverRing.test.ts b/neode-ui/src/components/__tests__/ScreensaverRing.test.ts
new file mode 100644
index 00000000..9a97c51f
--- /dev/null
+++ b/neode-ui/src/components/__tests__/ScreensaverRing.test.ts
@@ -0,0 +1,36 @@
+import { describe, it, expect } from 'vitest'
+import { mount } from '@vue/test-utils'
+import ScreensaverRing from '../ScreensaverRing.vue'
+
+// The ring is shared by the screensaver (default), SystemDangerZone (compact)
+// and — as of FED-06 — the payment-success tick (badge). These assertions pin
+// the variant mapping so adding a size can never silently re-point an existing
+// call site at different geometry.
+describe('ScreensaverRing', () => {
+ it('maps each size variant to its own ring class', () => {
+ expect(mount(ScreensaverRing).classes()).toContain('viz-ring-default')
+ expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).toContain(
+ 'viz-ring-compact',
+ )
+ expect(mount(ScreensaverRing, { props: { size: 'badge' } }).classes()).toContain(
+ 'viz-ring-badge',
+ )
+ })
+
+ it('keeps the existing variants off the badge class', () => {
+ expect(mount(ScreensaverRing).classes()).not.toContain('viz-ring-badge')
+ expect(mount(ScreensaverRing, { props: { size: 'compact' } }).classes()).not.toContain(
+ 'viz-ring-badge',
+ )
+ })
+
+ it('renders one segment per segmentCount, defaulting to 48', () => {
+ expect(mount(ScreensaverRing).findAll('.viz-segment')).toHaveLength(48)
+ expect(
+ mount(ScreensaverRing, { props: { segmentCount: 12 } }).findAll('.viz-segment'),
+ ).toHaveLength(12)
+ expect(
+ mount(ScreensaverRing, { props: { size: 'badge', segmentCount: 24 } }).findAll('.viz-segment'),
+ ).toHaveLength(24)
+ })
+})