What changed, and why

The logged-in account popover (components/RunsBalance.vue) had three rough edges: the nav links read as oversized, the signed-in email didn't stand out, and Sign out was easy to miss. This change addresses all three in one file. It is class-only — no script and no behavior change, so every data-testid and click handler is untouched.

The "too big" links have a clear cause. Each link carried ew-tap, a helper built as a 40px hit area for tiny icon controls. Inside the popover's flex flex-col, that stretched every link to the full popover width and stood it 40px tall with its label centered, so three short links piled up into a tall, loose block. The fix drops ew-tap for a compact py-1, and left-aligns the column with items-start, so the links hug their text as a tight list.

Account popover, before vs after, in light and dark
The popover's account section, before (current) and after (this change), rendered against the real design tokens in light and dark.

The three edits, in one file

Everything lives in the popover's account section. The three highlighted spots below are the whole change; the rest of the file is folded.

AskBeforeAfter
Links too big / too wideew-tap (40px box) in a stretched flex-colpy-1 + items-start — compact, left-aligned
Email should be boldclass="ew-fg"class="ew-fg font-semibold"
Sign out more prominentunderlined text link (ew-tap … underline)bordered ew-btn button
Each row is one edit; nothing else in the component moved.

The account section at HEAD. Spotlighted: the bold email span (88), the sign-out button class (95), the items-start column (116), and the three link classes (121, 129, 137).

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