What changed, and why

The referral-credit reward (issue #96) shipped, but nothing in the UI told a player it existed. The only trace was a passive line in the account popover — +N from sharing — and that appears after a credit lands. So the feature was invisible exactly when it should pull you in: a player had no way to learn that sharing a run earns runs.

This PR is copy only. It adds a short, accurate note in the two places the sharer meets the mechanic — the share affordance and the runs balance — plus three tests that pin the wording. No logic, no data model, no API touched. Blast radius is two Vue templates and two test files (52 inserted lines, nothing removed).

FileChange
components/ShareControls.vueearn note on the share CTA and the live public-link state
components/RunsBalance.vuealways-visible earn hint in the account popover
tests/unit/components/*.spec.tsthree tests asserting the copy renders

The mechanic the copy has to match

Copy is only worth shipping if it's true. Here is the ground truth the wording is checked against, all server-authoritative (the client only triggers a claim, it never grants credit).

QuestionAnswerWhere
Who earns?the sharer (referrer), never the new playerreferral.ts:123
What triggers it?a new player opens the /r/:token link and signs up with email (anonymous → email account)referral.ts:140
How much?1 run by defaultreferral.ts:47
Guardrailsidempotent per email + per device, self-referral blocked, cap 25referral.ts:48

The default reward (1 run) and the qualifying check: anonymous or email-less → not credited.

server/utils/referral.ts · 209 lines
server/utils/referral.ts209 lines · TypeScript
⋯ 45 lines hidden (lines 1–45)
1/**
2 * Referral credits (issue #96) — the growth loop that rewards a sharer when a NEW
3 * player they referred SIGNS UP WITH AN EMAIL.
4 *
5 * The capability is the share link: opening /r/:token stamps a marker cookie (the
6 * opaque token, never the owner — see server/middleware/referral.ts). When that
7 * visitor later signs up — attaches an email to their anonymous account — the client
8 * fires POST /api/referral/claim, which resolves the marker to the sharer and credits
9 * them one run, exactly once.
10 *
11 * Everything here is SERVER-AUTHORITATIVE: the qualifying email signup is read from the
12 * real Supabase session (currentUser), so a client can only TRIGGER the check, never
13 * self-grant. The anti-abuse is layered: email-gated · self-referral rejected (same
14 * account OR same browser) · idempotent per referred subject AND per device · a
15 * per-referrer cap · a global model-spend cap already gates the cost of free plays.
16 */
17import { getCookie, setCookie, deleteCookie } from 'h3'
18import type { H3Event } from 'h3'
19import { currentUser } from './auth'
20import { deviceId } from './device'
21import { balanceStore } from './balance-store'
22import { runSnapshotStore } from './run-snapshot-store'
23import { isUuid } from './uuid'
24import {
25 shouldSendReferralDigest,
26 REFERRAL_DIGEST_THRESHOLD,
27 REFERRAL_DIGEST_WINDOW_MS
28} from '~/utils/referral-digest'
29 
30/** The marker cookie: the share token a visitor arrived on. Holds only the token (it's
31 * already in the URL — reveals nothing), httpOnly so just the server reads it at claim
32 * time. 30 days — long enough that "saw a share, signed up later" still attributes. */
33export const REFERRAL_COOKIE = 'ew_ref'
34const COOKIE_MAX_AGE = 60 * 60 * 24 * 30
35 
36/** Knobs (env-overridable; sane defaults). One run per qualified signup, a soft
37 * per-sharer cap, and the batched-digest throttle. */
38export interface ReferralConfig {
39 reward: number
40 cap: number
41 digestEnabled: boolean
42 digestThreshold: number
43 digestWindowMs: number
45 
46const DEFAULTS = {
47 reward: 1,
48 cap: 25,
⋯ 89 lines hidden (lines 49–137)
49 digestEnabled: true,
50 digestThreshold: REFERRAL_DIGEST_THRESHOLD,
51 digestWindowMs: REFERRAL_DIGEST_WINDOW_MS
53 
54/** Resolve config from runtimeConfig in a Nuxt context, raw env otherwise (mirrors
55 * supabaseConfig — keeps this unit-testable in plain node). */
56export function referralConfig(): ReferralConfig {
57 let rc: Record<string, unknown> = {}
58 try {
59 rc = useRuntimeConfig() as unknown as Record<string, unknown>
60 } catch {
61 // not in a Nuxt context (eval harness / unit tests) — fall back to env.
62 }
63 const pick = (rcVal: unknown, env: string): string | undefined => {
64 const v = rcVal != null && rcVal !== '' ? String(rcVal) : undefined
65 return v ?? process.env[env]
66 }
67 const int = (s: string | undefined, dflt: number): number => {
68 const n = s != null ? parseInt(s, 10) : NaN
69 return Number.isFinite(n) ? n : dflt
70 }
71 const bool = (s: string | undefined, dflt: boolean): boolean =>
72 s == null ? dflt : s !== 'false' && s !== '0'
73 
74 const digestWindowHours = int(pick(rc.referralDigestWindowHours, 'EVERWHEN_REFERRAL_DIGEST_WINDOW_HOURS'), DEFAULTS.digestWindowMs / 3_600_000)
75 return {
76 reward: Math.max(0, int(pick(rc.referralReward, 'EVERWHEN_REFERRAL_REWARD'), DEFAULTS.reward)),
77 cap: int(pick(rc.referralCap, 'EVERWHEN_REFERRAL_CAP'), DEFAULTS.cap),
78 digestEnabled: bool(pick(rc.referralDigestEnabled, 'EVERWHEN_REFERRAL_DIGEST'), DEFAULTS.digestEnabled),
79 digestThreshold: Math.max(1, int(pick(rc.referralDigestThreshold, 'EVERWHEN_REFERRAL_DIGEST_THRESHOLD'), DEFAULTS.digestThreshold)),
80 digestWindowMs: Math.max(0, digestWindowHours) * 3_600_000
81 }
83 
84/** Stamp the "arrived via this share" marker. Called from server middleware on the real
85 * /r/:token page request, so the Set-Cookie reaches the browser. */
86export function setReferralMarker(event: H3Event, token: string): void {
87 setCookie(event, REFERRAL_COOKIE, token, {
88 httpOnly: true,
89 // localhost is a secure context, so dev over http still works (mirrors device.ts).
90 secure: !import.meta.dev,
91 sameSite: 'lax',
92 path: '/',
93 maxAge: COOKIE_MAX_AGE
94 })
96 
97/** Why a claim landed the way it did — drives nothing client-side beyond telemetry; the
98 * reward (if any) is what matters. */
99export type ClaimReason =
100 | 'credited'
101 | 'disabled'
102 | 'not-signed-up'
103 | 'no-referral'
104 | 'unknown-token'
105 | 'self'
106 | 'duplicate'
107 | 'duplicate-device'
108 | 'capped'
109 | 'error'
110 
111export interface ClaimResult {
112 /** True only when a run was actually credited to the sharer. */
113 credited: boolean
114 reason: ClaimReason
115 /** Runs credited to the sharer (present only on a real credit). */
116 reward?: number
118 
119/**
120 * Process a referral claim for the current request. Fired right after a magic-link
121 * sign-in finalizes. Returns a benign no-op for every non-qualifying case (never throws
122 * to the caller — a referral hiccup must not break the post-sign-in flow). The credited
123 * party is the SHARER, not this request's user.
124 */
125export async function claimReferral(event: H3Event): Promise<ClaimResult> {
126 // 0. Program off-switch: reward <= 0 disables referrals entirely. Short-circuit BEFORE
127 // recording anything, so the idempotency ledger isn't burned while off — re-enabling
128 // later still credits a player who hadn't yet been processed.
129 const cfg = referralConfig()
130 if (cfg.reward <= 0) return { credited: false, reason: 'disabled' }
131 
132 // 1. The qualifying event: a genuine EMAIL signup. currentUser throws on a real auth
133 // error — treat that as not-signed-up (benign), never surface it.
134 let user
135 try {
136 user = await currentUser(event)
137 } catch {
138 return { credited: false, reason: 'not-signed-up' }
139 }
140 if (!user || user.isAnonymous || !user.email) {
141 return { credited: false, reason: 'not-signed-up' }
⋯ 68 lines hidden (lines 142–209)
142 }
143 
144 // 2. The marker. No marker (or a tampered one) → nothing to attribute.
145 const token = getCookie(event, REFERRAL_COOKIE)
146 if (!token || !isUuid(token)) return { credited: false, reason: 'no-referral' }
147 
148 // 3. Resolve the token to the sharer's subject — server-internal, never exposed.
149 const referrer = await runSnapshotStore().ownerSubjectByToken(token)
150 if (!referrer) {
151 clearReferralMarker(event) // definitive: a dead token never resolves
152 return { credited: false, reason: 'unknown-token' }
153 }
154 
155 // 4. Self-referral: a sharer can't credit themselves — same account OR same browser
156 // (an anonymous, device-keyed sharer opening their own link in this browser).
157 const device = deviceId(event)
158 if (referrer === user.id || referrer === device) {
159 clearReferralMarker(event)
160 return { credited: false, reason: 'self' }
161 }
162 
163 // 5. Credit — atomic + idempotent + capped in the store. A throw here (transient
164 // store error) leaves the marker so the next sign-in can retry; otherwise the
165 // marker is consumed.
166 const res = await balanceStore().creditReferral(referrer, user.id, device, token, cfg.reward, cfg.cap)
167 clearReferralMarker(event)
168 
169 // 6. Notify the sharer — BATCHED, never per-referral (only on a real credit).
170 if (res.credited && cfg.digestEnabled) await maybeSendReferralDigest(referrer, cfg)
171 
172 return {
173 credited: res.credited,
174 reason: res.status as ClaimReason,
175 reward: res.credited ? cfg.reward : undefined
176 }
178 
179function clearReferralMarker(event: H3Event): void {
180 deleteCookie(event, REFERRAL_COOKIE, { path: '/' })
182 
183/**
184 * The batched referral notice. The pure throttle decides whether a digest is due
185 * (threshold OR window); if so, mark the referrer's credits notified and emit it. Today
186 * the "channel" is a single telemetry line (the [ai]/[mod] idiom) — the seam a real
187 * email/web-push for opted-in accounts (#83) slots into, with the throttle guaranteeing
188 * it stays a digest whatever the channel.
189 */
190async function maybeSendReferralDigest(referrer: string, cfg: ReferralConfig): Promise<void> {
191 const state = await balanceStore().referralDigestState(referrer)
192 const due = shouldSendReferralDigest({
193 ...state,
194 now: Date.now(),
195 threshold: cfg.digestThreshold,
196 windowMs: cfg.digestWindowMs
197 })
198 if (!due) return
199 await balanceStore().markReferralsNotified(referrer)
200 emitReferralDigest(referrer, state.unnotifiedCount)
202 
203function emitReferralDigest(referrer: string, newCredits: number): void {
204 // Suppressed under vitest (like the [ai] gateway line) — the throttle itself is what
205 // the tests assert, not the emission. A real channel resolves subject → address and
206 // honors per-user opt-out here.
207 if (process.env.VITEST) return
208 console.info(`[referral-digest] referrer=${referrer.slice(0, 8)} new_credits=${newCredits}`)

ShareControls — name the reward where you share

This component is the owner's share affordance. Before, it sold sharing purely as "post your rewritten history" — the reward went unmentioned at the one moment a player decides to hand someone a link. The note is added to both resting states: the live public-link view (you have a link to send right now) and the initial "Share this run" CTA (the pitch to share at all). The two states are mutually exclusive, so only one note is ever in the DOM.

The earn note in the public-link state (top) and the initial CTA (bottom).

components/ShareControls.vue · 173 lines
components/ShareControls.vue173 lines · Vue
⋯ 17 lines hidden (lines 1–17)
1<template>
2 <!-- The owner's share affordance for one saved run (issue #88): make it public (with an
3 opt-in handle, off by default), see the live link, send it, or revoke it. Reads its
4 current state from the server so it shows the right face after a reload. The handle,
5 when attached, is the account's GENERATED username (issue #117) — never typed. -->
6 <div data-testid="share-controls" class="border ew-line rounded-sm p-4">
7 <div v-if="loading" class="ew-faint italic text-sm">Checking share status…</div>
8 
9 <div v-else-if="unavailable" class="ew-faint text-sm" data-testid="share-unavailable">
10 Sharing isn't available for this run yet.
11 </div>
12 
13 <!-- Public: the link + the ways to send it + revoke -->
14 <template v-else-if="shared">
15 <div class="flex items-center gap-2 flex-wrap">
16 <span class="ew-label ew-label--rule mb-0"><span aria-hidden="true">🌍</span> This run is public</span>
17 </div>
18 <p class="ew-muted text-sm mt-2">Anyone with this link can view it — no account needed.</p>
19 <p class="ew-faint text-xs mt-2" data-testid="share-earn-note">
20 <span aria-hidden="true"></span> Earn a free run each time a new player opens it and signs up.
21 </p>
⋯ 13 lines hidden (lines 22–34)
22 <code data-testid="share-url" class="block ew-mono text-xs ew-muted bg-transparent border ew-line rounded-sm px-2.5 py-2 mt-2 break-all">{{ publicUrl }}</code>
23 <div class="mt-3">
24 <ShareLinks :url="publicUrl" :text="shareText" />
25 </div>
26 <p class="ew-faint text-xs mt-3" data-testid="share-attribution">{{ attributionNote }}</p>
27 <button type="button" class="ew-btn text-sm mt-3" :disabled="busy" data-testid="share-stop" @click="stopSharing">
28 {{ busy ? 'Stopping…' : 'Stop sharing' }}
29 </button>
30 </template>
31 
32 <!-- Private: the opt-in to make it public -->
33 <template v-else>
34 <template v-if="!expanded">
35 <span class="ew-label ew-label--rule mb-0">Share this run</span>
36 <p class="ew-muted text-sm mt-2">Post your rewritten history — the verdict, the epilogue, the timeline.</p>
37 <p class="ew-faint text-xs mt-2" data-testid="share-earn-note">
38 <span aria-hidden="true"></span> Earn a free run each time a new player opens your link and signs up.
39 </p>
⋯ 134 lines hidden (lines 40–173)
40 <button type="button" class="ew-btn ew-btn--primary text-sm mt-3" data-testid="share-open" @click="expanded = true">
41 ↗ Share this run
42 </button>
43 </template>
44 <template v-else>
45 <span class="ew-label ew-label--rule mb-0">Make this run public</span>
46 <p class="ew-muted text-sm mt-2">A public, read-only page at an unguessable link. Anonymous unless you attach your handle.</p>
47 <label class="flex items-center gap-2 text-sm mt-3 cursor-pointer">
48 <input v-model="attribute" type="checkbox" data-testid="share-name-toggle" />
49 <span>Attach my handle</span>
50 </label>
51 <!-- The handle is generated, shown read-only (never an input). Reroll for another. -->
52 <div v-if="attribute" data-testid="share-handle" class="flex items-center gap-2 mt-2 flex-wrap">
53 <span class="ew-serif ew-fg font-semibold">{{ username || 'Conjuring a name…' }}</span>
54 <button type="button" class="ew-tap ew-hover-fg text-xs underline disabled:opacity-50 disabled:no-underline"
55 :disabled="rerolling || !username" data-testid="share-handle-reroll" @click="reroll">{{ rerolling ? 'rerolling…' : 'reroll' }}</button>
56 </div>
57 <div class="flex items-center gap-2 mt-3 flex-wrap">
58 <button type="button" class="ew-btn ew-btn--primary text-sm" :disabled="busy" data-testid="share-create" @click="makePublic">
59 {{ busy ? 'Creating…' : 'Create public link' }}
60 </button>
61 <button type="button" class="ew-btn text-sm" :disabled="busy" data-testid="share-cancel" @click="expanded = false">
62 Cancel
63 </button>
64 </div>
65 </template>
66 </template>
67 
68 <p v-if="error" class="ew-bad text-sm mt-3" data-testid="share-error">{{ error }}</p>
69 </div>
70</template>
71 
72<script setup lang="ts">
73/**
74 * ShareControls — the owner's share state machine for a saved run. It fetches the run's
75 * current share state on mount (so a reload shows "public, here's the link" vs "share this
76 * run" correctly), toggles public/private against /api/run-share, and offers an opt-in
77 * handle at share time (off by default — anonymous). The handle is the account's GENERATED
78 * username (issue #117): there is no text input — the player attaches it or rerolls it, but
79 * never types it. Ownership is enforced server-side; this only ever acts on the account's
80 * own runs.
81 */
82import { ref, computed, onMounted } from 'vue'
83import type { ShareState } from '~/utils/run-snapshot'
84import { useGameStore } from '~/stores/game'
85 
86const props = defineProps<{
87 runId: string
88 /** The prebuilt brag line the parent composes from the run (objective + verdict). */
89 shareText: string
90}>()
91 
92const gameStore = useGameStore()
93const username = computed(() => gameStore.username)
94 
95const state = ref<ShareState | null>(null)
96const loading = ref(true)
97const unavailable = ref(false)
98const busy = ref(false)
99const rerolling = ref(false)
100const error = ref('')
101const expanded = ref(false)
102// Whether to attach the account handle to this share (off → anonymous, #88's default).
103const attribute = ref(false)
104 
105const shared = computed(() => !!state.value?.token)
106const origin = computed(() => useRequestURL().origin)
107const publicUrl = computed(() => (state.value?.token ? `${origin.value}/r/${state.value.token}` : ''))
108// When attributed, show the LIVE account handle (it tracks a reroll), not the mount-time
109// snapshot; fall back to the stored name if the handle hasn't loaded yet.
110const attributionNote = computed(() =>
111 state.value?.name ? `Shared as "${username.value || state.value.name}"` : 'Shared anonymously — no handle attached.'
113 
114onMounted(async () => {
115 // Have the handle ready in case the player opts to attach it.
116 void gameStore.loadUsername()
117 try {
118 state.value = await $fetch<ShareState>('/api/run-share', { query: { runId: props.runId } })
119 // A stored name means this run was shared WITH attribution — reflect that.
120 if (state.value?.name) attribute.value = true
121 } catch {
122 // The run isn't owned/saved yet — nothing to share against.
123 unavailable.value = true
124 } finally {
125 loading.value = false
126 }
127})
128 
129async function makePublic() {
130 busy.value = true
131 error.value = ''
132 try {
133 state.value = await $fetch<ShareState>('/api/run-share', {
134 method: 'POST',
135 body: { runId: props.runId, attribute: attribute.value }
136 })
137 expanded.value = false
138 } catch (e) {
139 error.value = errorMessage(e) ?? 'Could not create the link. Try again.'
140 } finally {
141 busy.value = false
142 }
144 
145async function reroll() {
146 rerolling.value = true
147 try {
148 await gameStore.rerollUsername()
149 } finally {
150 rerolling.value = false
151 }
153 
154async function stopSharing() {
155 busy.value = true
156 error.value = ''
157 try {
158 await $fetch('/api/run-share', { method: 'DELETE', query: { runId: props.runId } })
159 state.value = { token: null, name: null }
160 } catch {
161 error.value = 'Could not stop sharing. Try again.'
162 } finally {
163 busy.value = false
164 }
166 
167/** Pull the server's statusMessage off an H3/ofetch error, if present. */
168function errorMessage(e: unknown): string | null {
169 const data = (e as { data?: { statusMessage?: unknown; message?: unknown } })?.data
170 const msg = data?.statusMessage ?? data?.message
171 return typeof msg === 'string' && msg.trim() ? msg : null
173</script>

RunsBalance — make the earn path discoverable first

The account popover is where a player looks to answer "how do I get more runs?" It already had a post-hoc tally (+N from sharing) gated on referralCredits > 0. That's the reward landing, not the invitation. The new hint is always shown, so the earn path is visible before any credit exists; the tally below it is untouched and still appears only once something has been earned. Together: the hint is the how, the tally is the what you've earned.

Help text (21–24), the always-on earn hint (26–31), then the unchanged earned tally (33–39).

components/RunsBalance.vue · 160 lines
components/RunsBalance.vue160 lines · Vue
⋯ 20 lines hidden (lines 1–20)
1<template>
2 <!-- The run balance, as a header gauge (◈ N runs), doubling as the entry to a
3 small account popover. Mirrors MessagesCounter's mono-gauge idiom. -->
4 <div ref="rootRef" data-testid="runs-balance" class="relative">
5 <button type="button" data-testid="runs-balance-toggle"
6 class="inline-flex items-baseline gap-1 ew-mono text-sm ew-tap ew-hover-fg"
7 :aria-expanded="open" aria-haspopup="dialog" :aria-label="aria" @click="toggle">
8 <span class="ew-faint" aria-hidden="true"></span>
9 <span class="ew-fg font-semibold">{{ display }}</span>
10 <span class="ew-faint text-xs hidden sm:inline">{{ runs === 1 ? 'run' : 'runs' }}</span>
11 </button>
12 
13 <Transition name="ew-pop">
14 <div v-if="open" data-testid="account-popover" role="dialog" aria-label="Your account"
15 class="absolute right-0 mt-2 w-72 ew-bg border ew-line rounded-md shadow-xl z-40 p-3.5 text-left">
16 <p class="ew-label">Your runs</p>
17 <div class="flex items-baseline gap-1.5 mt-0.5">
18 <span class="ew-mono text-2xl font-extrabold ew-fg leading-none">{{ runs }}</span>
19 <span class="ew-muted text-xs">{{ runs === 1 ? 'run left' : 'runs left' }}</span>
20 </div>
21 <p class="ew-faint text-[11px] leading-relaxed mt-2">
22 <template v-if="runs <= 0">Out of runs — grab a pack to keep playing.</template>
23 <template v-else>Each run is one full game. Packs are one-time and never expire.</template>
24 </p>
25 
26 <!-- How to earn more without buying (issue #96): share a run, and a new player who
27 opens your link and signs up earns you a free run. Always shown, so the path is
28 discoverable before any credit lands (the tally below only appears once earned). -->
29 <p class="ew-faint text-[11px] leading-relaxed mt-1.5" data-testid="referral-earn-hint">
30 Share a run to earn more — a free run each time a new player you bring in signs up.
31 </p>
32 
33 <!-- Referral credits earned from sharing (issue #96): a passive, pull-based
34 notice — it never interrupts, it just shows what your shares have earned. -->
35 <p v-if="gameStore.referralCredits > 0" data-testid="referral-credits"
36 class="ew-ok text-[11px] leading-relaxed mt-1.5">
37 <span aria-hidden="true"></span> +{{ gameStore.referralCredits }} from sharing —
38 {{ gameStore.referralCount === 1 ? 'a player you brought in signed up' : `${gameStore.referralCount} players you brought in signed up` }}.
39 </p>
⋯ 121 lines hidden (lines 40–160)
40 
41 <button type="button" data-testid="account-get-runs" class="ew-btn ew-btn--primary w-full mt-3 text-sm justify-center"
42 @click="getRuns">Get more runs</button>
43 
44 <div class="mt-3 pt-2.5 border-t ew-line">
45 <template v-if="gameStore.accountEmail">
46 <p class="ew-faint text-[11px] leading-relaxed">Signed in as <span class="ew-fg">{{ gameStore.accountEmail }}</span></p>
47 <button type="button" data-testid="account-signout" class="ew-tap ew-hover-fg text-[11px] mt-1 underline" @click="signOut">Sign out</button>
48 </template>
49 <SignInForm v-else-if="gameStore.accountAnonymous"
50 hint="Save your runs to an account — keep them across devices, and unlock buying." />
51 <p v-else-if="gameStore.deviceRef" class="ew-faint text-[10px]">
52 Anonymous play · support id <span class="ew-mono">{{ gameStore.deviceRef }}</span>
53 </p>
54 </div>
55 
56 <!-- Into the full account surfaces (issue #117): the profile (handle + runs), the
57 saved runs, and account settings. The popover stays the quick gauge; the real
58 home for account management is /settings. Shown to anonymous players too: they
59 own their runs and handle from turn one. -->
60 <div class="mt-3 pt-2.5 border-t ew-line flex flex-col gap-1.5">
61 <NuxtLink to="/profile" data-testid="account-profile" class="ew-tap ew-hover-fg text-[11px] underline" @click="close">
62 <span aria-hidden="true"></span> Your profile
63 </NuxtLink>
64 <NuxtLink to="/runs" data-testid="account-past-runs" class="ew-tap ew-hover-fg text-[11px] underline" @click="close">
65 <span aria-hidden="true"></span> Your past runs
66 </NuxtLink>
67 <NuxtLink to="/settings" data-testid="account-settings" class="ew-tap ew-hover-fg text-[11px] underline" @click="close">
68 <span aria-hidden="true"></span> Account settings
69 </NuxtLink>
70 </div>
71 
72 <!-- Replay the guided first run on demand. Only on a live board, where the
73 tour has controls to point at — skipping it disables it for good
74 (completion is remembered); this brings it back. -->
75 <div v-if="canReplayTour" class="mt-3 pt-2.5 border-t ew-line">
76 <button type="button" data-testid="account-replay-tour" class="ew-tap ew-hover-fg text-[11px] underline"
77 @click="replayTour"><span aria-hidden="true"></span> Replay the guided tour</button>
78 </div>
79 </div>
80 </Transition>
81 </div>
82</template>
83 
84<script setup lang="ts">
85/**
86 * RunsBalance — the persistent header gauge for runs remaining, and the entry to a
87 * lightweight account popover (runs left, a one-line explainer, "get more runs",
88 * and a short support id). Play is anonymous (device-keyed) until real accounts
89 * exist, so "account" is deliberately minimal. Closes on outside-click / Escape.
90 */
91import { ref, computed, onMounted, onUnmounted } from 'vue'
92import { useGameStore } from '~/stores/game'
93import { useCoachingStore } from '~/stores/coaching'
94import { resetToAnonymous } from '~/utils/sign-out'
95 
96const gameStore = useGameStore()
97const coaching = useCoachingStore()
98const open = ref(false)
99const rootRef = ref<HTMLElement | null>(null)
100 
101const runs = computed(() => gameStore.runsRemaining ?? 0)
102// A dash until the balance has loaded, so the gauge never flashes a misleading 0.
103const display = computed(() => (gameStore.runsRemaining == null ? '–' : String(runs.value)))
104const aria = computed(() =>
105 gameStore.runsRemaining == null ? 'Loading your runs' : `${runs.value} ${runs.value === 1 ? 'run' : 'runs'} remaining; open account`
107 
108function toggle() { open.value = !open.value }
109function close() { open.value = false }
110 
111function getRuns() {
112 close()
113 void gameStore.openBuyModal()
115 
116// The tour anchors to live board controls, so replay is offered only during a
117// playing run (on the briefing screen there's nothing to point at).
118const canReplayTour = computed(() => !!gameStore.currentObjective && gameStore.gameStatus === 'playing')
119function replayTour() {
120 close()
121 coaching.replay()
123 
124const supabase = useSupabaseClient()
125async function signOut() {
126 // Reset to anonymous resiliently (no failure-prone global revoke that could
127 // abort the whole sign-out), then reload the balance. The popover stays open so
128 // it re-renders to the signed-out state — visible feedback that the click landed.
129 await resetToAnonymous({
130 signOut: (options) => supabase.auth.signOut(options),
131 signInAnonymously: () => supabase.auth.signInAnonymously()
132 })
133 await gameStore.loadBalance()
135 
136function onDocClick(e: MouseEvent) {
137 if (open.value && rootRef.value && !rootRef.value.contains(e.target as Node)) close()
139function onKeydown(e: KeyboardEvent) { if (e.key === 'Escape') close() }
140 
141onMounted(() => {
142 document.addEventListener('click', onDocClick)
143 document.addEventListener('keydown', onKeydown)
144})
145onUnmounted(() => {
146 document.removeEventListener('click', onDocClick)
147 document.removeEventListener('keydown', onKeydown)
148})
149</script>
150 
151<style scoped>
152.ew-pop-enter-active,
153.ew-pop-leave-active { transition: opacity var(--ew-dur-1) var(--ew-ease); }
154.ew-pop-enter-from,
155.ew-pop-leave-to { opacity: 0; }
156@media (prefers-reduced-motion: reduce) {
157 .ew-pop-enter-active,
158 .ew-pop-leave-active { transition: none; }
160</style>

Tests that pin the copy

Each new test mounts the real component and asserts the note renders with the reward in it — the discriminating check that only this copy layer satisfies, not coverage padding. ShareControls is checked in both states (private CTA and, with a stubbed link, the public state). RunsBalance is checked with referralCredits = 0 — proving the hint shows before anything is earned, which is the whole point of the change.

Two tests: the reward is named on the initial affordance and once public.

tests/unit/components/ShareControls.spec.ts · 118 lines
tests/unit/components/ShareControls.spec.ts118 lines · TypeScript
⋯ 89 lines hidden (lines 1–89)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { mount, flushPromises } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import ShareControls from '../../../components/ShareControls.vue'
5import { useGameStore } from '../../../stores/game'
6 
7/**
8 * ShareControls after issue #117: the free-text display-name input is GONE. The owner can
9 * attach their account's GENERATED handle (opt-in, off by default) and reroll it, but can
10 * never type a name — which is what closes the self-chosen-handle abuse surface. These pin
11 * that the input is gone, the handle is shown on opt-in, and creating a link sends the
12 * `attribute` flag, never a typed name.
13 */
14describe('ShareControls (issue #117)', () => {
15 let gameStore: ReturnType<typeof useGameStore>
16 let fetchMock: ReturnType<typeof vi.fn>
17 let lastPostBody: Record<string, unknown> | null
18 
19 beforeEach(() => {
20 setActivePinia(createPinia())
21 gameStore = useGameStore()
22 // Preset the handle so loadUsername short-circuits and the shown name is deterministic.
23 gameStore.username = 'Gilded Scribe'
24 lastPostBody = null
25 fetchMock = vi.fn().mockImplementation((url: string, opts?: Record<string, unknown>) => {
26 if (url === '/api/run-share' && opts?.method === 'POST') {
27 lastPostBody = opts.body as Record<string, unknown>
28 return Promise.resolve({ token: 'tok-123', name: lastPostBody.attribute ? 'Gilded Scribe' : null })
29 }
30 if (url === '/api/run-share') return Promise.resolve({ token: null, name: null }) // GET status
31 if (url === '/api/profile') return Promise.resolve({ username: 'Gilded Scribe' })
32 return Promise.resolve({})
33 })
34 vi.stubGlobal('$fetch', fetchMock)
35 })
36 
37 afterEach(() => {
38 vi.unstubAllGlobals()
39 vi.restoreAllMocks()
40 })
41 
42 async function mountExpanded() {
43 const wrapper = mount(ShareControls, { props: { runId: 'r1', shareText: 'brag' } })
44 await flushPromises() // settle the on-mount share-status fetch
45 await wrapper.find('[data-testid="share-open"]').trigger('click') // open the create form
46 return wrapper
47 }
48 
49 it('has no free-text name input anywhere in the share flow', async () => {
50 const wrapper = await mountExpanded()
51 await wrapper.find('[data-testid="share-name-toggle"]').setValue(true) // even with attribution ON
52 expect(wrapper.find('[data-testid="share-name-input"]').exists()).toBe(false)
53 expect(wrapper.find('input[type="text"]').exists()).toBe(false)
54 })
55 
56 it('shows the generated handle (not an input) when attribution is toggled on', async () => {
57 const wrapper = await mountExpanded()
58 expect(wrapper.find('[data-testid="share-handle"]').exists()).toBe(false) // off by default
59 await wrapper.find('[data-testid="share-name-toggle"]').setValue(true)
60 const handle = wrapper.find('[data-testid="share-handle"]')
61 expect(handle.exists()).toBe(true)
62 expect(handle.text()).toContain('Gilded Scribe')
63 })
64 
65 it('creating WITH attribution sends attribute:true and no typed name', async () => {
66 const wrapper = await mountExpanded()
67 await wrapper.find('[data-testid="share-name-toggle"]').setValue(true)
68 await wrapper.find('[data-testid="share-create"]').trigger('click')
69 await flushPromises()
70 expect(lastPostBody).toEqual({ runId: 'r1', attribute: true })
71 expect(lastPostBody).not.toHaveProperty('name')
72 })
73 
74 it('creating anonymously sends attribute:false', async () => {
75 const wrapper = await mountExpanded()
76 await wrapper.find('[data-testid="share-create"]').trigger('click')
77 await flushPromises()
78 expect(lastPostBody).toEqual({ runId: 'r1', attribute: false })
79 })
80 
81 it('rerolls the account handle from the share affordance', async () => {
82 const wrapper = await mountExpanded()
83 await wrapper.find('[data-testid="share-name-toggle"]').setValue(true)
84 const rerollSpy = vi.spyOn(gameStore, 'rerollUsername').mockResolvedValue('Amber Archivist')
85 await wrapper.find('[data-testid="share-handle-reroll"]').trigger('click')
86 await flushPromises()
87 expect(rerollSpy).toHaveBeenCalledOnce()
88 })
89 
90 // Issue #96 referral credits: the share affordance is where the reward should be
91 // legible, so a player knows sharing earns runs at the moment they decide to share.
92 it('names the referral reward on the initial share affordance (issue #96)', async () => {
93 const wrapper = mount(ShareControls, { props: { runId: 'r1', shareText: 'brag' } })
94 await flushPromises() // settle the on-mount share-status fetch (private state)
95 const note = wrapper.find('[data-testid="share-earn-note"]')
96 expect(note.exists()).toBe(true)
97 expect(note.text()).toContain('free run')
98 })
99 
100 it('keeps the referral reward legible once the run is public (issue #96)', async () => {
101 // A run that's already shared: the GET status returns a live token.
102 fetchMock.mockImplementation((url: string, opts?: Record<string, unknown>) => {
103 if (url === '/api/run-share' && opts?.method) return Promise.resolve({ token: 'tok-9', name: null })
104 if (url === '/api/run-share') return Promise.resolve({ token: 'tok-9', name: null }) // GET status
105 if (url === '/api/profile') return Promise.resolve({ username: 'Gilded Scribe' })
106 return Promise.resolve({})
107 })
108 const wrapper = mount(ShareControls, {
109 props: { runId: 'r1', shareText: 'brag' },
110 global: { stubs: { ShareLinks: true } }
111 })
112 await flushPromises()
113 expect(wrapper.find('[data-testid="share-url"]').exists()).toBe(true) // confirm the public state
114 const note = wrapper.find('[data-testid="share-earn-note"]')
115 expect(note.exists()).toBe(true)
116 expect(note.text()).toContain('free run')
117 })
⋯ 1 line hidden (lines 118–118)
118})

The earn hint is present even when nothing has been earned from sharing.

tests/unit/components/RunsBalance.spec.ts · 118 lines
tests/unit/components/RunsBalance.spec.ts118 lines · TypeScript
⋯ 68 lines hidden (lines 1–68)
1import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
2import { mount, flushPromises } from '@vue/test-utils'
3import { setActivePinia, createPinia } from 'pinia'
4import RunsBalance from '../../../components/RunsBalance.vue'
5import { useGameStore } from '../../../stores/game'
6import { useCoachingStore } from '../../../stores/coaching'
7import { listCuratedObjectives } from '~/server/utils/objectives'
8 
9describe('RunsBalance', () => {
10 let gameStore: ReturnType<typeof useGameStore>
11 let fetchMock: ReturnType<typeof vi.fn>
12 
13 beforeEach(() => {
14 setActivePinia(createPinia())
15 gameStore = useGameStore()
16 fetchMock = vi.fn().mockResolvedValue({ packs: [] })
17 vi.stubGlobal('$fetch', fetchMock)
18 })
19 
20 afterEach(() => {
21 vi.unstubAllGlobals()
22 vi.restoreAllMocks()
23 })
24 
25 it('shows a dash until the balance has loaded', () => {
26 gameStore.runsRemaining = null
27 const wrapper = mount(RunsBalance)
28 expect(wrapper.find('[data-testid="runs-balance-toggle"]').text()).toContain('–')
29 })
30 
31 it('shows the run count once loaded', () => {
32 gameStore.runsRemaining = 7
33 const wrapper = mount(RunsBalance)
34 expect(wrapper.find('[data-testid="runs-balance-toggle"]').text()).toContain('7')
35 })
36 
37 it('opens the account popover and shows the support id', async () => {
38 gameStore.runsRemaining = 3
39 gameStore.deviceRef = 'abcd1234'
40 const wrapper = mount(RunsBalance)
41 expect(wrapper.find('[data-testid="account-popover"]').exists()).toBe(false)
42 
43 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
44 const pop = wrapper.find('[data-testid="account-popover"]')
45 expect(pop.exists()).toBe(true)
46 expect(pop.text()).toContain('3')
47 expect(pop.text()).toContain('abcd1234')
48 })
49 
50 it('shows accrued referral credits in the popover (issue #96)', async () => {
51 gameStore.runsRemaining = 4
52 gameStore.referralCredits = 2
53 gameStore.referralCount = 2
54 const wrapper = mount(RunsBalance)
55 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
56 const credits = wrapper.find('[data-testid="referral-credits"]')
57 expect(credits.exists()).toBe(true)
58 expect(credits.text()).toContain('+2 from sharing')
59 })
60 
61 it('hides the referral line when nothing has been earned from sharing', async () => {
62 gameStore.runsRemaining = 4
63 gameStore.referralCredits = 0
64 const wrapper = mount(RunsBalance)
65 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
66 expect(wrapper.find('[data-testid="referral-credits"]').exists()).toBe(false)
67 })
68 
69 it('explains how to earn runs by sharing, even before any are earned (issue #96)', async () => {
70 gameStore.runsRemaining = 4
71 gameStore.referralCredits = 0 // nothing earned yet — the tally is hidden, the how is not
72 const wrapper = mount(RunsBalance)
73 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
74 const hint = wrapper.find('[data-testid="referral-earn-hint"]')
75 expect(hint.exists()).toBe(true)
76 expect(hint.text()).toContain('free run')
77 })
78 
⋯ 40 lines hidden (lines 79–118)
79 it('opens the run-packs modal from "Get more runs"', async () => {
80 gameStore.runsRemaining = 0
81 const wrapper = mount(RunsBalance)
82 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
83 await wrapper.find('[data-testid="account-get-runs"]').trigger('click')
84 await flushPromises()
85 
86 expect(gameStore.buyModalOpen).toBe(true)
87 expect(fetchMock).toHaveBeenCalledWith('/api/packs')
88 })
89 
90 it('offers "Replay the guided tour" in the popover during a live run', async () => {
91 gameStore.runsRemaining = 2
92 gameStore.currentObjective = listCuratedObjectives()[0] // a live, playing board
93 const wrapper = mount(RunsBalance)
94 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
95 expect(wrapper.find('[data-testid="account-replay-tour"]').exists()).toBe(true)
96 })
97 
98 it('hides replay on the briefing screen (no board to point at)', async () => {
99 gameStore.runsRemaining = 2
100 gameStore.currentObjective = null // mission-select, no live board
101 const wrapper = mount(RunsBalance)
102 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
103 expect(wrapper.find('[data-testid="account-replay-tour"]').exists()).toBe(false)
104 })
105 
106 it('replays the tour and closes the popover', async () => {
107 const coaching = useCoachingStore()
108 gameStore.runsRemaining = 2
109 gameStore.currentObjective = listCuratedObjectives()[0]
110 const wrapper = mount(RunsBalance)
111 await wrapper.find('[data-testid="runs-balance-toggle"]').trigger('click')
112 await wrapper.find('[data-testid="account-replay-tour"]').trigger('click')
113 
114 expect(coaching.status).toBe('running')
115 expect(coaching.stepIndex).toBe(0)
116 expect(wrapper.find('[data-testid="account-popover"]').exists()).toBe(false)
117 })
118})