What broke, and the shape of the fix

The in-run masthead is the running head over /play: the wordmark, the objective strip (icon · title · era), and the run gauge + sound/dark/record/reset controls. It packed all of that onto one flex row and never reflowed, so it broke at the two most common small widths — in both light and dark, Chromium and WebKit.

At a phone's 375px the controls printed straight over the objective title. The fix is small and structural: below desktop, the objective drops to its own full-width row, so it always reads in full.

Before

After

iPhone SE (375px), light. Before: the run gauge + icons overprint “Save the Library of Alexandria.” After: wordmark + controls on top, the objective on its own legible row.

The masthead reflows below 1024px

The whole change to the header is one media query. flex-wrap lets the row break; order keeps the wordmark and controls together on the first line; flex-basis: 100% forces the objective onto its own line; and margin-left: auto pins the controls to the right edge of the first line. Above the breakpoint, nothing changes — desktop keeps its single row.

The only structural change to the header — a reflow, not a rewrite.

pages/play.vue · 561 lines
pages/play.vue561 lines · Vue
⋯ 417 lines hidden (lines 1–417)
1<template>
2 <!--
3 Everwhen as one illuminated manuscript whose pages (leaves) turn as a turn
4 progresses. The conductor (provided once here) owns the phase; each leaf is a
5 focused page; the store's two-phase actions land the SFX-bearing writes at
6 leaf arrival, so the soundscape stays in lockstep for free.
7 
8 This is THE game at /play; the classic board UI was retired here.
9 -->
10 <div class="desk">
11 <!-- Post-checkout banner (ported from the board): the missing feedback after
12 returning from Stripe — a credited confirmation, or a gentle no-charge on
13 cancel. Fixed over the desk so it never shifts the codex; self-dismisses. -->
14 <div v-if="gameStore.purchaseNotice" data-testid="purchase-notice" role="status" aria-live="polite"
15 class="purchase-banner" :class="gameStore.purchaseNotice">
16 <span aria-hidden="true">{{ gameStore.purchaseNotice === 'success' ? '✓' : '○' }}</span>
17 <span class="pn-text">{{ gameStore.purchaseNotice === 'success' ? 'Payment received — your runs have been added to your balance.' : "Checkout canceled — you weren't charged." }}</span>
18 <button type="button" data-testid="purchase-notice-close" class="pn-close" aria-label="Dismiss" @click="gameStore.clearPurchaseNotice()"></button>
19 </div>
20 <!-- Blocked-dispatch banner: a content-moderation block refunds the turn and keeps
21 the player's words, but the old notice sat at the FOOT of the dispatch leaf —
22 easy to miss once the page turned back to it. Surface it here instead, fixed
23 over the desk like the purchase strip, so the block is unmissable from any leaf
24 and never shifts the codex. The conductor still lands on the dispatch leaf,
25 where the kept words wait to be edited and resent. -->
26 <div v-if="gameStore.moderationNotice" data-testid="moderation-alert" role="alert" aria-live="assertive"
27 class="blocked-banner">
28 <span class="bb-tag" aria-hidden="true">⚠ blocked</span>
29 <span class="bb-text">{{ gameStore.moderationNotice }} <span class="bb-hint">Your words are kept — edit and send again.</span></span>
30 <button type="button" data-testid="moderation-close" class="bb-close" aria-label="Dismiss" @click="gameStore.clearModerationNotice()"></button>
31 </div>
32 <div class="codex grain">
33 <!-- Masthead — the constant running head (hidden during the End takeover,
34 which carries its own verdict heading). -->
35 <header v-if="conductor.phase.value !== 'end'" class="masthead">
36 <div class="mast-row">
37 <GameTitle />
38 <div v-if="gameStore.currentObjective" class="mast-objective">
39 <ObjectiveDisplay />
40 </div>
41 <div v-else class="mast-spacer" />
42 <div class="mast-controls">
43 <!-- The runs gauge + account popover + the entry to the run-packs modal. -->
44 <RunsBalance class="shrink-0" />
45 <button type="button" class="icon-btn" :class="{ on: recordOpen }" data-testid="codex-record-toggle"
46 title="The record" aria-label="Open the record" @click="toggleRecord"></button>
47 <button type="button" class="icon-btn" data-testid="mute-toggle"
48 :aria-pressed="!audio.muted" :title="audio.muted ? 'Sound off' : 'Sound on'"
49 :aria-label="audio.muted ? 'Unmute sound effects' : 'Mute sound effects'" @click="toggleMute">{{ audio.muted ? '♪̸' : '♪' }}</button>
50 <button type="button" class="icon-btn" :aria-label="isDark ? 'Switch to light mode' : 'Switch to dark mode'" @click="toggleDark"></button>
51 <template v-if="gameStore.currentObjective">
52 <span v-if="resetArmed" class="reset-confirm">
53 <span class="ew-warn">abandon this history?</span>
54 <button type="button" class="icon-btn ew-warn" data-testid="reset-confirm" aria-label="Yes — abandon this history" @click="performReset"></button>
55 <button type="button" class="icon-btn" data-testid="reset-cancel" aria-label="Keep playing" @click="disarmReset"></button>
56 </span>
57 <button v-else type="button" class="icon-btn" data-testid="reset-button" title="New timeline" aria-label="New timeline" @click="handleReset"></button>
58 </template>
59 </div>
60 </div>
61 <div v-if="gameStore.currentObjective" class="mast-strip">
62 <div class="prog-thread">
63 <span class="pct ew-mono" data-testid="masthead-progress">{{ Math.round(gameStore.objectiveProgress) }}%</span>
64 <div class="ew-track" :class="trackClass"><div class="ew-fill" :style="{ width: gameStore.objectiveProgress + '%' }" /></div>
65 <span class="prog-need ew-faint">{{ needLine }}</span>
66 </div>
67 <div class="pips" data-testid="messages-counter" aria-hidden="true">
68 <span v-for="i in TOTAL_MESSAGES" :key="i" class="pip" :class="{ spent: i <= spent }" />
69 </div>
70 </div>
71 <!-- Persistent AI/fiction/adults-only disclosure (ported from the board's
72 footer). Shown during a run on every viewport (the codex foot is hidden
73 on phones, so this rides in the running head instead). -->
74 <p v-if="gameStore.currentObjective" data-testid="footer-disclosure" class="mast-disclosure">
75 Figures are AI, not real people · replies are AI-generated fiction, not historical fact · for adults (18+)
76 </p>
77 <div v-if="conductor.isSending.value" class="sending-bar" role="status" aria-label="Sending the dispatch" />
78 </header>
79 
80 <!-- The leaf stage — pages turn with a quick settle (within-turn reveal
81 beats) or a 3D page-turn (a new page begins: mission/chronicle →
82 dispatch, end → mission). -->
83 <div id="stage" class="stage" @click="onStageClick">
84 <Transition :name="transitionName">
85 <KeepAlive :include="['LeafDispatch']">
86 <component :is="currentLeaf" v-if="conductor.phase.value !== 'end'" :key="conductor.phase.value" />
87 </KeepAlive>
88 </Transition>
89 <!-- A temporal sheen sweeps each turned page (re-keyed per phase to replay). -->
90 <div class="stage-sheen" :key="'sheen-' + conductor.phase.value" aria-hidden="true" />
91 <EndGameScreen v-if="conductor.phase.value === 'end'" @play-again="performReset" />
92 <RecordOverlay :open="recordOpen" @close="recordOpen = false" />
93 <!-- a11y: a polite live region narrates each reveal beat as the player turns
94 to it, so a screen-reader follows without watching the animation. -->
95 <div class="sr-only" role="status" aria-live="polite">{{ announce }}</div>
96 </div>
97 
98 <!-- Page progression — the primary interaction (explicit-continue model). The
99 reveal plays, then waits here; the player turns the page when ready, with
100 Back to revisit. Shown only on reveal pages (the loader + compose pages
101 drive themselves), hidden under the record overlay. Tap-anywhere + →/Enter
102 stay as accelerators. -->
103 <div v-if="isRevealLeaf && !recordOpen" class="leaf-progress">
104 <button v-if="conductor.canBack.value" type="button" class="prog-btn back"
105 data-testid="codex-back" @click="conductor.back()">‹ Back</button>
106 <span v-else aria-hidden="true" />
107 <button type="button" class="prog-btn continue" data-testid="codex-continue"
108 @click="conductor.advance()">{{ continueLabel }}</button>
109 </div>
110 </div>
111 
112 <SiteFooter variant="desk" full />
113 
114 <!-- Fixed-position overlays — mounted at the desk level (OUTSIDE the
115 overflow-hidden codex, which would clip them): the run-packs sales modal,
116 the guided first-run coach-mark, and the dev panel. -->
117 <RunPacksModal />
118 <ClientOnly><CoachMark /></ClientOnly>
119 <ClientOnly><DevPanel /></ClientOnly>
120 </div>
121</template>
122 
123<script setup lang="ts">
124import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
125import { useGameStore } from '~/stores/game'
126import { useAudioStore } from '~/stores/audio'
127import { useCoachingStore } from '~/stores/coaching'
128import { useGameSoundscape } from '~/composables/useGameSoundscape'
129import { useCodexConductor, CODEX_CONDUCTOR } from '~/composables/useCodexConductor'
130import { CODEX_SOUNDSCAPE } from '~/components/codex/keys'
131import { TOTAL_MESSAGES, MAX_PROGRESS } from '~/utils/game-config'
132import { readAuthReturnParams, hasAuthReturn, finalizeAuthReturn } from '~/utils/auth-return'
133import LeafMission from '~/components/codex/LeafMission.vue'
134import LeafDispatch from '~/components/codex/LeafDispatch.vue'
135import LeafSending from '~/components/codex/LeafSending.vue'
136import LeafJudgment from '~/components/codex/LeafJudgment.vue'
137import LeafDice from '~/components/codex/LeafDice.vue'
138import LeafReply from '~/components/codex/LeafReply.vue'
139import LeafRipple from '~/components/codex/LeafRipple.vue'
140import LeafChronicle from '~/components/codex/LeafChronicle.vue'
141import RecordOverlay from '~/components/codex/RecordOverlay.vue'
142 
143const gameStore = useGameStore()
144const audio = useAudioStore()
145const soundscape = useGameSoundscape() // mounted ONCE here — the store-bridge cues
146const conductor = useCodexConductor()
147 
148// Share the conductor + soundscape down to the leaves.
149provide(CODEX_CONDUCTOR, conductor)
150provide(CODEX_SOUNDSCAPE, soundscape)
151 
152const LEAVES = {
153 mission: LeafMission,
154 dispatch: LeafDispatch,
155 sending: LeafSending,
156 judgment: LeafJudgment,
157 dice: LeafDice,
158 reply: LeafReply,
159 ripple: LeafRipple,
160 chronicle: LeafChronicle,
161} as const
162const currentLeaf = computed(() => LEAVES[conductor.phase.value as keyof typeof LEAVES] ?? null)
163 
164// Leaf transition: a quick settle for the within-turn reveal beats, a 3D page-turn
165// when a NEW page begins (mission/chronicle → dispatch, end → mission). flush:'pre'
166// sets the name before <Transition> reads it for the pending phase change.
167const transitionName = ref<'codex-leaf' | 'codex-turn'>('codex-leaf')
168watch(() => conductor.phase.value, (to, from) => {
169 const boundary = (to === 'dispatch' && (from === 'mission' || from === 'chronicle')) || to === 'mission'
170 transitionName.value = boundary ? 'codex-turn' : 'codex-leaf'
171}, { flush: 'pre' })
172 
173const spent = computed(() => TOTAL_MESSAGES - gameStore.remainingMessages)
174const trackClass = computed(() => {
175 const p = gameStore.objectiveProgress
176 return p >= MAX_PROGRESS ? 'win' : p >= 75 ? 'brink' : ''
177})
178const needLine = computed(() => {
179 const p = Math.round(gameStore.objectiveProgress)
180 return p >= 100 ? 'history rewritten' : p === 0 ? 'a history unwritten' : `need +${100 - p}% to win`
181})
182 
183// ── The record overlay ────────────────────────────────────────────────────
184const recordOpen = ref(false)
185function toggleRecord() { recordOpen.value = !recordOpen.value }
186 
187// A tap anywhere on a reveal leaf turns the page (skips the dwell). Not while the
188// record is open, not on the compose/mission/end pages, and never when the tap
189// landed on a control (so buttons/inputs still work).
190function onStageClick(e: MouseEvent) {
191 if (recordOpen.value) return
192 const p = conductor.phase.value
193 if (p === 'dispatch' || p === 'mission' || p === 'sending' || p === 'end') return
194 const t = e.target as HTMLElement
195 if (t.closest('button, a, input, textarea, select, summary, [role="button"]')) return
196 // Selecting prose to copy ends in a click on the stage when the drag is released —
197 // don't read that release as a page-turn. A non-collapsed selection means the player
198 // is highlighting (the reply, the chronicle, the ripple), not asking to advance.
199 const sel = typeof window !== 'undefined' ? window.getSelection() : null
200 if (sel && !sel.isCollapsed && sel.toString().trim()) return
201 conductor.advance()
203 
204// ── Reveal a11y: keyboard parity + screen-reader announcements ─────────────
205const REVEAL_LEAVES = ['judgment', 'dice', 'reply', 'ripple', 'chronicle']
206const isRevealLeaf = computed(() => REVEAL_LEAVES.includes(conductor.phase.value))
207// The reveal pages own the bottom of the screen with the Continue/Back footer. Flag
208// the body while one shows so the shared CoachMark keeps its card clear of that
209// footer (else its bottom-anchored fallback covers the primary Continue control).
210watch(isRevealLeaf, (v) => {
211 if (typeof document !== 'undefined') document.body.classList.toggle('codex-reveal', v)
212}, { immediate: true })
213// The Continue affordance hands the pen back on the last reveal page of a turn.
214const continueLabel = computed(() => conductor.phase.value === 'chronicle' ? 'Next dispatch' : 'Continue')
215// This turn's resolved AI message (held in pendingTurn during the animated
216// reveal, else the latest committed one) — the source for the announcements.
217const turnMsg = computed(() => {
218 if (gameStore.pendingTurn?.aiMessage) return gameStore.pendingTurn.aiMessage
219 for (let i = gameStore.messageHistory.length - 1; i >= 0; i--) {
220 if (gameStore.messageHistory[i].sender === 'ai') return gameStore.messageHistory[i]
221 }
222 return null
223})
224// A polite live region narrates each reveal beat so a screen-reader user follows
225// the auto-advancing flow without watching the animation.
226const announce = computed(() => {
227 const p = conductor.phase.value
228 const m = turnMsg.value
229 const node = gameStore.timelineEvents[gameStore.timelineEvents.length - 1]
230 if (p === 'judgment' && m?.craft) return `The Judge grades your craft: ${m.craft}${m.rollModifier ? `, ${m.rollModifier > 0 ? '+' : ''}${m.rollModifier} to the roll of fate` : ''}.`
231 if (p === 'dice' && m?.diceRoll != null) return `The dice of fate land on ${m.diceRoll}: ${m.diceOutcome}.`
232 if (p === 'reply' && m) return `${m.figureName || 'History'} answers.`
233 if (p === 'ripple' && node) return `History bends. ${node.headline} ${node.progressChange >= 0 ? 'Up' : 'Down'} ${Math.abs(node.progressChange)} percent — now ${Math.round(gameStore.objectiveProgress)} percent toward the objective.`
234 if (p === 'chronicle') return gameStore.chronicle ? `The history, retold: ${gameStore.chronicle.title}.` : 'The history is being retold.'
235 if (p === 'end') return gameStore.gameStatus === 'victory' ? 'History rewritten — victory.' : 'The run is over.'
236 return ''
237})
238 
239// Keyboard parity for tap-to-advance: → / Enter / Space turn a reveal page;
240// Escape closes the record. Never hijack a key while a control or the composer
241// holds focus (so typing + buttons keep working).
242function onKeydown(e: KeyboardEvent) {
243 if (recordOpen.value) {
244 if (e.key === 'Escape') { recordOpen.value = false; e.preventDefault() }
245 return
246 }
247 if (!isRevealLeaf.value) return
248 const ae = document.activeElement as HTMLElement | null
249 if (ae && ae.closest('input, textarea, select, button, a, summary, [role="button"], [contenteditable="true"]')) return
250 if (e.key === 'ArrowRight' || e.key === 'Enter' || e.key === ' ' || e.key === 'Spacebar') {
251 e.preventDefault()
252 conductor.advance()
253 }
255onMounted(() => window.addEventListener('keydown', onKeydown))
256onUnmounted(() => {
257 window.removeEventListener('keydown', onKeydown)
258 if (typeof document !== 'undefined') document.body.classList.remove('codex-reveal')
259})
260 
261// ── Controls (ported from play.vue) ───────────────────────────────────────
262const colorMode = useColorMode()
263const isDark = computed(() => colorMode.value === 'dark')
264function toggleDark() { colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark' }
265 
266function toggleMute() {
267 audio.toggleMute()
268 if (!audio.muted) { soundscape.unlock(); soundscape.play('uiTick') }
270 
271// New-timeline reset: two-step confirm once a run is underway, one tap when there's
272// nothing to lose. resetGame bumps the epoch → the conductor snaps back to mission.
273const resetArmed = ref(false)
274let resetTimer: ReturnType<typeof setTimeout> | null = null
275function handleReset() {
276 // One tap only when there's nothing to lose: no resolved turns AND no contact
277 // chosen (a typed first dispatch is work too — a chosen figure marks it dirty).
278 const composerDirty = !!gameStore.activeFigureName?.trim()
279 if (gameStore.timelineEvents.length === 0 && !composerDirty) { performReset(); return }
280 resetArmed.value = true
281 if (resetTimer) clearTimeout(resetTimer)
282 resetTimer = setTimeout(() => { resetArmed.value = false }, 3500)
284function disarmReset() { resetArmed.value = false; if (resetTimer) clearTimeout(resetTimer) }
285function performReset() {
286 disarmReset()
287 recordOpen.value = false
288 gameStore.resetGame()
290 
291// ── Guided first run (issue #60) — adapted to the leaf flow ────────────────
292// The shell is the single orchestration site; the coaching store stays
293// game-agnostic. Beats anchor to live controls on the current leaf. The
294// roll-swing beat keys off the Dice leaf's ARRIVAL — `timelineEvents` now lands
295// later (at the Ripple leaf's commitRipple), so the old length-watch would skip
296// the dice anchor.
297const coaching = useCoachingStore()
298const autoStartCtx = () => ({
299 hasObjective: !!gameStore.currentObjective,
300 isPlaying: gameStore.gameStatus === 'playing',
301 isFirstTurn: gameStore.timelineEvents.length === 0
302})
303const tourBaseTurns = ref(0)
304watch(() => gameStore.currentObjective, (obj) => {
305 if (obj) coaching.maybeAutoStart(autoStartCtx())
306 else coaching.stop()
307})
308watch(() => coaching.isRunning, (running) => { if (running) tourBaseTurns.value = gameStore.timelineEvents.length })
309// Picking who & when clears the first beat (a real grounded contact + a chosen year).
310watch(() => !!gameStore.figureGrounding?.resolved && gameStore.contactWhen != null,
311 (ready) => { if (ready) coaching.advanceFrom('who-when') })
312// The roll & swing beat fires when the Dice leaf is reached.
313watch(() => conductor.phase.value, (p) => {
314 if (coaching.isRunning && (p === 'dice' || p === 'ripple')) coaching.advanceTo('roll-swing')
315})
316// A second resolved turn means the loop has plainly landed — an overstaying tour bows out.
317watch(() => gameStore.timelineEvents.length, (n) => {
318 if (coaching.isRunning && n >= tourBaseTurns.value + 2) coaching.complete()
319})
320 
321// ── Run lifecycle (ported from play.vue) ──────────────────────────────────
322const route = useRoute()
323const router = useRouter()
324const supabase = useSupabaseClient()
325onMounted(async () => {
326 audio.hydrate()
327 coaching.hydrate()
328 const authReturn = readAuthReturnParams(route.query)
329 if (hasAuthReturn(authReturn)) {
330 const result = await finalizeAuthReturn(authReturn, {
331 settle: async () => {
332 const { data } = await supabase.auth.getSession()
333 return Boolean(data.session && !data.session.user.is_anonymous)
334 },
335 verifyOtp: (params) => supabase.auth.verifyOtp(params)
336 })
337 if (result.status === 'error') {
338 console.warn('[auth] sign-in link could not be finalized:', authReturn.error, authReturn.errorDescription)
339 } else if (result.status === 'signed-in') {
340 await gameStore.claimReferral()
341 }
342 await gameStore.loadBalance()
343 router.replace({ query: {} })
344 } else {
345 const purchase = route.query.purchase
346 if (purchase === 'success' || purchase === 'cancel') {
347 await gameStore.notePurchaseReturn(purchase)
348 router.replace({ query: {} })
349 if (purchase === 'success') {
350 const before = gameStore.runsRemaining
351 for (let i = 0; i < 4; i++) {
352 await new Promise((r) => setTimeout(r, 1500))
353 await gameStore.loadBalance()
354 if (gameStore.runsRemaining !== before) break
355 }
356 }
357 } else {
358 await gameStore.loadBalance()
359 }
360 }
361 // Resume an in-progress run onto the first dispatch (the conductor lands there).
362 if (!gameStore.currentObjective) await gameStore.resumeDraft()
363 coaching.maybeAutoStart(autoStartCtx())
364})
365 
366useHead({
367 title: 'Everwhen — Rewrite History',
368 meta: [{ name: 'description', content: 'Send 160-character messages to anyone in history. A D20 and an AI decide how the timeline bends.' }]
369})
370</script>
371 
372<style scoped>
373.desk {
374 position: fixed; inset: 0;
375 display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 20px;
376 background: var(--codex-desk, radial-gradient(120% 110% at 50% 36%, color-mix(in srgb, var(--ew-bg) 74%, #000) 0%, color-mix(in srgb, var(--ew-bg) 50%, #000) 60%, #000 130%));
378.codex {
379 position: relative;
380 width: min(95vw, 1180px); height: min(92vh, 800px);
381 background: var(--codex-card, var(--ew-bg));
382 border: 1px solid var(--codex-card-border, var(--ew-line)); border-radius: 4px;
383 box-shadow: var(--codex-card-shadow, 0 20px 54px -18px rgba(40, 28, 14, .6), 0 44px 96px -40px rgba(20, 12, 4, .66));
384 display: flex; flex-direction: column; overflow: hidden;
386.codex::after { content: ""; position: absolute; top: 0; bottom: 0; left: 46px; width: 1px; background: linear-gradient(var(--ew-line), transparent 8%, transparent 92%, var(--ew-line)); opacity: .5; pointer-events: none; z-index: 2; }
387.grain::before {
388 content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 0;
389 opacity: var(--codex-grain-op, .035); mix-blend-mode: var(--codex-grain-blend, multiply);
390 background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/></filter><rect width='160' height='160' filter='url(%23n)'/></svg>");
392/* Theme the grain via INHERITED custom properties, never a property on a
393 :global() selector. `:global(html.dark) .grain::before { opacity }` leaks its
394 opacity onto <html> itself under Vue scoped CSS, fading the entire page to ~5%
395 (the dark-mode "blank/invisible" bug). Custom props are inherited + inert on
396 <html>; the scoped ::before reads them. */
397/* Dark mode: lift the (warm) card off a darker, cooler desk so the foreground
398 surface reads clearly against the background. Scoped to the codex frame via
399 inherited custom props — never a property on a :global() selector (see grain). */
400:global(html.dark) {
401 --codex-grain-op: .05; --codex-grain-blend: screen;
402 --codex-desk: radial-gradient(120% 110% at 50% 36%, color-mix(in srgb, var(--ew-bg) 60%, #000) 0%, color-mix(in srgb, var(--ew-bg) 26%, #000) 55%, #000 120%);
403 --codex-card: color-mix(in srgb, var(--ew-bg) 88%, #f0e6d4);
404 --codex-card-border: color-mix(in srgb, var(--ew-line) 50%, #f0e6d4);
405 --codex-card-shadow: inset 0 1px 0 color-mix(in srgb, #f0e6d4 8%, transparent), 0 24px 66px -16px rgba(0, 0, 0, .78), 0 56px 120px -44px rgba(0, 0, 0, .86);
407 
408.masthead { position: relative; z-index: 3; flex: none; padding: 11px 20px 0; }
409.mast-row { display: flex; align-items: center; gap: 14px; }
410.mast-objective { flex: 1 1 auto; min-width: 0; }
411.mast-spacer { flex: 1 1 auto; }
412.mast-controls { display: flex; align-items: center; gap: 4px; }
413.reset-confirm { display: inline-flex; align-items: center; gap: 4px; font-size: 11px; }
414.icon-btn { appearance: none; border: 0; background: transparent; color: var(--ew-faint); width: 32px; height: 32px; border-radius: 4px; cursor: pointer; line-height: 1; font-size: 15px; display: inline-flex; align-items: center; justify-content: center; transition: color var(--ew-dur-1) var(--ew-ease), background var(--ew-dur-1) var(--ew-ease); }
415.icon-btn:hover { color: var(--ew-fg); background: var(--ew-tint); }
416.icon-btn.on { color: var(--ew-accent); }
417 
418/* Reflow the running head below desktop. One line can't hold the wordmark, the
419 objective strip, AND the run gauge + controls: at phone widths the controls
420 overprinted the objective title (near-illegible at 375px), and at tablet widths
421 the title truncated mid-word while "· rewrite history" wrapped. Drop the
422 objective to its own full-width row so it always reads in full; the wordmark
423 keeps the controls company on the row above. The squeeze (with the era shown)
424 reaches ~1024px, so reflow up to there — desktop keeps the single row. */
425@media (max-width: 1024px) {
426 .mast-row { flex-wrap: wrap; row-gap: 8px; }
427 .mast-controls { order: 1; margin-left: auto; }
428 .mast-objective { order: 2; flex-basis: 100%; }
⋯ 132 lines hidden (lines 430–561)
430 
431/* Post-checkout banner — a letterpress strip fixed at the top of the desk. */
432.purchase-banner {
433 position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 60;
434 display: flex; align-items: center; gap: 9px;
435 max-width: min(92vw, 560px); padding: 9px 12px;
436 font-size: 13px; color: var(--ew-fg);
437 background: var(--ew-bg); border: 1px solid var(--ew-line); border-radius: 4px;
438 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, .5);
440.purchase-banner.success { background: var(--ew-tint); border-color: color-mix(in srgb, var(--ew-ok) 40%, var(--ew-line)); }
441.purchase-banner .pn-text { min-width: 0; }
442.purchase-banner .pn-close { appearance: none; border: 0; background: transparent; color: var(--ew-faint); cursor: pointer; line-height: 1; margin-left: auto; flex: none; }
443.purchase-banner .pn-close:hover { color: var(--ew-fg); }
444 
445/* Blocked-dispatch banner — the moderation block, surfaced like the purchase strip:
446 fixed over the desk so it's unmissable and never shifts the codex. Oxblood-keyed
447 with a left rule, distinct from the neutral purchase notice. */
448.blocked-banner {
449 position: fixed; top: 12px; left: 50%; transform: translateX(-50%); z-index: 61;
450 display: flex; align-items: flex-start; gap: 9px;
451 max-width: min(92vw, 560px); padding: 9px 12px;
452 font-size: 13px; line-height: 1.4; color: var(--ew-fg);
453 background: var(--ew-bg);
454 border: 1px solid color-mix(in srgb, var(--ew-bad) 55%, var(--ew-line));
455 border-left: 3px solid var(--ew-bad);
456 border-radius: 4px;
457 box-shadow: 0 10px 28px -10px rgba(20, 12, 4, .5);
459.blocked-banner .bb-tag { flex: none; font-weight: 700; text-transform: uppercase; letter-spacing: .04em; color: var(--ew-bad); }
460.blocked-banner .bb-text { min-width: 0; }
461.blocked-banner .bb-hint { color: var(--ew-mut); }
462.blocked-banner .bb-close { appearance: none; border: 0; background: transparent; color: var(--ew-faint); cursor: pointer; line-height: 1; margin-left: auto; flex: none; }
463.blocked-banner .bb-close:hover { color: var(--ew-fg); }
464 
465/* Persistent disclosure — a slim, muted line in the running head, on every viewport. */
466.mast-disclosure { margin-top: 7px; font-size: 10px; line-height: 1.3; color: var(--ew-faint); }
467 
468.mast-strip { display: flex; align-items: center; gap: 16px; margin-top: 9px; padding-bottom: 9px; border-bottom: 1px solid var(--ew-line); }
469.prog-thread { flex: 1 1 auto; display: flex; align-items: center; gap: 10px; min-width: 0; }
470.pct { font-size: 12px; font-weight: 700; color: var(--ew-fg); min-width: 34px; }
471.ew-track { position: relative; flex: 1 1 auto; height: 5px; background: var(--ew-line); border-radius: 999px; overflow: hidden; }
472.ew-track .ew-fill { position: absolute; left: 0; top: 0; bottom: 0; background: var(--ew-fg); border-radius: 999px; transition: width var(--ew-dur-3) var(--ew-ease), background var(--ew-dur-2) var(--ew-ease); }
473.ew-track.brink .ew-fill { background: var(--ew-accent); box-shadow: 0 0 10px color-mix(in srgb, var(--ew-accent) 60%, transparent); }
474.ew-track.win .ew-fill { background: var(--ew-ok); box-shadow: 0 0 10px color-mix(in srgb, var(--ew-ok) 60%, transparent); }
475.prog-need { font-size: 10px; white-space: nowrap; }
476.pips { display: flex; align-items: center; gap: 6px; }
477.pip { width: 9px; height: 9px; border-radius: 50%; border: 1.4px solid var(--ew-hair); background: transparent; transition: background var(--ew-dur-2) var(--ew-ease), border-color var(--ew-dur-2) var(--ew-ease); }
478.pip.spent { background: var(--ew-accent); border-color: var(--ew-accent); }
479.sending-bar { position: relative; height: 2px; overflow: hidden; background: var(--ew-line); }
480.sending-bar::after { content: ""; position: absolute; inset: 0; background: var(--ew-accent); transform-origin: left; animation: send-sweep 1.15s var(--ew-ease) infinite; }
481@keyframes send-sweep { 0% { transform: translateX(-100%) scaleX(.35); } 55% { transform: translateX(40%) scaleX(.6); } 100% { transform: translateX(160%) scaleX(.35); } }
482 
483.stage { position: relative; z-index: 1; flex: 1 1 auto; min-height: 0; perspective: 2200px; perspective-origin: 0% 50%; }
484/* The temporal sheen that sweeps each turned page (re-keyed per phase to replay). */
485.stage-sheen {
486 position: absolute; inset: 0; pointer-events: none; z-index: 6; opacity: 0;
487 background: linear-gradient(105deg, transparent 34%, color-mix(in srgb, var(--ew-accent) 13%, transparent) 50%, transparent 66%);
488 background-size: 280% 100%; background-position: 180% 0;
489 animation: codex-sheen var(--ew-dur-3) var(--ew-ease);
491@keyframes codex-sheen { 0% { background-position: 180% 0; opacity: .7; } 100% { background-position: -120% 0; opacity: 0; } }
492.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
493/* Page-progression footer — Back (revisit) + Continue (turn the page). flex:none
494 below the stage, so it never overlaps the leaf body. */
495.leaf-progress {
496 position: relative; z-index: 3; flex: none;
497 display: flex; align-items: center; justify-content: space-between; gap: 12px;
498 padding: 10px 20px 12px; border-top: 1px solid var(--ew-line);
500.prog-btn {
501 appearance: none; cursor: pointer; font: inherit; line-height: 1;
502 min-height: 38px; padding: 9px 16px; border-radius: 3px;
503 transition: color var(--ew-dur-1) var(--ew-ease), background var(--ew-dur-1) var(--ew-ease), border-color var(--ew-dur-1) var(--ew-ease);
505.prog-btn.back { border: 1px solid var(--ew-line); background: transparent; color: var(--ew-mut); }
506.prog-btn.back:hover { color: var(--ew-fg); border-color: var(--ew-hair); }
507/* Continue text = the page bg colour, so it reads on the accent in light AND dark
508 (avoids a :global(html.dark) override — see the grain note above). */
509.prog-btn.continue { border: 1px solid var(--ew-accent); background: var(--ew-accent); color: var(--ew-bg); font-weight: 600; letter-spacing: .01em; }
510.prog-btn.continue:hover { background: color-mix(in srgb, var(--ew-accent) 86%, var(--ew-fg)); }
511@media (max-width: 760px) {
512 .leaf-progress { padding: 9px 16px 11px; }
513 .prog-btn { min-height: 44px; padding: 11px 18px; }
515/* Phone: go full-bleed — a centered card wastes a small screen. The codex fills
516 the viewport (masthead · stage · progression footer), so the info-dense leaves
517 get real room instead of a cramped column. */
518@media (max-width: 640px) {
519 .desk { padding: 0; gap: 0; }
520 .codex { width: 100vw; height: 100vh; height: 100dvh; max-width: none; border: 0; border-radius: 0; box-shadow: none; background: var(--ew-bg); }
521 .codex::after { left: 18px; opacity: .3; }
522 .masthead { padding: 9px 13px 0; }
523 .mast-row { gap: 9px; }
524 .mast-controls { gap: 2px; }
525 .mast-strip { gap: 12px; margin-top: 8px; padding-bottom: 8px; }
526 .leaf-progress { padding: 10px 14px calc(11px + env(safe-area-inset-bottom)); }
527 .prog-btn.continue { flex: 1 1 auto; text-align: center; }
529@media (prefers-reduced-motion: reduce) {
530 .ew-track .ew-fill { transition: none; }
531 .sending-bar::after { animation: none; opacity: .6; }
532 .stage-sheen { animation: none; opacity: 0; }
534</style>
535 
536<!-- Leaf transition classes target the leaf components' ROOT elements, which do
537 NOT carry this page's scope id — so they must be global. Prefixed (codex-leaf /
538 codex-turn) to avoid collisions. -->
539<style>
540/* settle — within-turn reveal beats (III→VII): a quick fade + lift cross-fade.
541 Overlapping (never mode=out-in, which would add a wait); both leaves go absolute
542 during the swap so they cross-fade in place. */
543.codex-leaf-enter-active, .codex-leaf-leave-active { position: absolute; inset: 0; }
544.codex-leaf-enter-active { transition: opacity var(--ew-dur-2) var(--ew-ease), transform var(--ew-dur-2) var(--ew-ease); }
545.codex-leaf-leave-active { transition: opacity var(--ew-dur-1) var(--ew-ease), transform var(--ew-dur-1) var(--ew-ease); }
546.codex-leaf-enter-from { opacity: 0; transform: translateY(10px) scale(.994); }
547.codex-leaf-leave-to { opacity: 0; transform: translateY(-8px) scale(.99); }
548 
549/* page-turn — a NEW page begins: the outgoing leaf turns away around the spine
550 while the fresh page settles in. */
551.codex-turn-enter-active, .codex-turn-leave-active { position: absolute; inset: 0; transform-origin: left center; backface-visibility: hidden; }
552.codex-turn-leave-active { transition: transform 560ms var(--ew-ease), opacity 560ms var(--ew-ease); box-shadow: 40px 0 60px -20px rgba(20, 12, 4, .45); }
553.codex-turn-leave-to { transform: rotateY(-110deg); opacity: .2; }
554.codex-turn-enter-active { transition: opacity 380ms var(--ew-ease) 150ms, transform 380ms var(--ew-ease) 150ms; }
555.codex-turn-enter-from { opacity: 0; transform: translateY(8px); }
556 
557@media (prefers-reduced-motion: reduce) {
558 .codex-leaf-enter-active, .codex-leaf-leave-active, .codex-turn-enter-active, .codex-turn-leave-active { transition: opacity 120ms linear !important; }
559 .codex-leaf-enter-from, .codex-leaf-leave-to, .codex-turn-enter-from, .codex-turn-leave-to { transform: none !important; }
561</style>

Before

After

Tablet (768px), light. Before: the title truncates (“Save the Library …”), “· rewrite history” wraps, and the gauge collides with the era. After: full title + era on their own row.
Desktop (1280px): untouched — still a single row, as before.

The coach mark never lands on the running head

The first-run “who & when” coach mark had the same root cause. It floats a card beside the control it teaches, trying placements in order and picking the first that fits the viewport. But the figure picker fills the whole compose column, so no side placement fits and the only one that clears the viewport is above it — squarely on the masthead. The card dropped over the very controls it was teaching.

Two moves fix it. safeTop() measures the masthead's foot and uses it as the floor for placements, so “above the tall control” no longer counts as fitting. And when nothing fits beside the control, useBottomSheet is true and the card becomes the same bottom sheet phones already use — which the leaf scrolls clear of.

safeTop() as the placement floor; useBottomSheet when nothing fits beside the control; cardStyle short-circuits to the sheet.

components/CoachMark.vue · 293 lines
components/CoachMark.vue293 lines · Vue
⋯ 116 lines hidden (lines 1–116)
1<template>
2 <div v-if="visible" class="coach-root">
3 <!-- A non-blocking halo around the live control — draws the eye without a
4 scrim. Drawn here (fixed, from the target's rect) rather than as an
5 outline on the target itself, so an ancestor's overflow can't clip it. -->
6 <div v-if="anchor.present.value && haloStyle" class="coach-halo" :style="haloStyle" aria-hidden="true" />
7 
8 <!-- The coaching card. The ONLY thing in this overlay that takes pointer
9 events — every click outside it falls straight through to the live board. -->
10 <div ref="cardEl" data-testid="coach-mark" class="coach-mark"
11 :style="cardStyle" :data-step="step?.id"
12 role="status" aria-live="polite" aria-atomic="true" aria-label="Guided coaching">
13 <p class="ew-label coach-kicker">{{ step?.kicker }}</p>
14 <p class="coach-body ew-fg">{{ step?.body }}</p>
15 <div class="coach-foot">
16 <span class="coach-count ew-faint" aria-hidden="true">{{ stepIndex + 1 }} / {{ total }}</span>
17 <div class="coach-actions">
18 <button v-if="!step?.gateOnly" type="button" data-testid="coach-next"
19 class="ew-btn ew-btn--primary coach-next" @click="onNext">
20 {{ isLastStep ? 'Done' : 'Next' }}<span v-if="!isLastStep" aria-hidden="true"></span>
21 </button>
22 <span v-else class="coach-waiting ew-faint" aria-hidden="true">send to continue</span>
23 <button type="button" data-testid="coach-skip" class="coach-skip ew-hover-fg" @click="onSkip">Skip tour</button>
24 </div>
25 </div>
26 </div>
27 </div>
28</template>
29 
30<script setup lang="ts">
31/**
32 * CoachMark — the single floating callout for the guided first run. Reads the
33 * coaching store for the active beat, resolves that beat's control via
34 * useCoachAnchor, and floats a small ledger card beside it with a soft halo.
35 *
36 * Non-blocking by construction: the overlay root is pointer-events:none with no
37 * scrim, and only the card re-enables pointer events — so the board (including
38 * the very control being taught) stays fully live. No focus trap, no modal lock.
39 * Mounted once in pages/play.vue, inside <ClientOnly>.
40 */
41import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
42import { useCoachingStore } from '~/stores/coaching'
43import { useGameStore } from '~/stores/game'
44import { useCoachAnchor } from '~/composables/useCoachAnchor'
45import { firstFittingPlacement, placeAt } from '~/utils/coach-placement'
46 
47const coaching = useCoachingStore()
48const gameStore = useGameStore()
49 
50const step = computed(() => coaching.activeStep)
51const stepIndex = computed(() => coaching.stepIndex)
52const total = computed(() => coaching.steps.length)
53const isLastStep = computed(() => coaching.isLastStep)
54 
55// While a turn resolves the board owns the eye (the shaking die, the sweep), so
56// the mark stands down and returns on the next beat — also dodging any flash of
57// the spent "Send it" card between the roll landing and the reveal step entering.
58const mounted = ref(false)
59onMounted(() => { mounted.value = true })
60const visible = computed(() => mounted.value && coaching.isRunning && !gameStore.isLoading)
61 
62// The active beat's anchors: the control it teaches, then an always-present
63// fallback for conditionally-rendered ones (the ⚡ chip → the general ⚡ line).
64const selectorList = computed<string[] | null>(() => {
65 const s = step.value
66 if (!s) return null
67 return s.fallbackAnchor ? [s.anchor, s.fallbackAnchor] : [s.anchor]
68})
69const anchor = useCoachAnchor(selectorList)
70 
71// ── Positioning ──────────────────────────────────────────────────────────
72const GAP = 12 // breathing room between the control and the card
73const MARGIN = 10 // keep the card this far inside the viewport
74 
75// A reactive md breakpoint: the corner-fallback branch of cardStyle has no rect to
76// recompute on, so it would otherwise miss a resize across 768px. The anchored branch
77// already recomputes (its rect moves on resize).
78const isMd = ref(false)
79let mdMql: MediaQueryList | null = null
80function syncMd() { isMd.value = mdMql?.matches ?? false }
81onMounted(() => {
82 if (typeof window === 'undefined' || !window.matchMedia) return
83 mdMql = window.matchMedia('(min-width: 768px)')
84 isMd.value = mdMql.matches
85 mdMql.addEventListener('change', syncMd)
86})
87 
88// The reveal pages flag the body (`codex-reveal`) so this shared card can
89// clear their Continue/Back footer — see cardStyle. A class read isn't reactive, so
90// mirror it into a ref via an observer: the card re-places the instant a reveal page
91// opens or closes, with no dependence on the anchor happening to re-measure.
92const onCodexReveal = ref(false)
93let bodyMO: MutationObserver | null = null
94onMounted(() => {
95 if (typeof document === 'undefined') return
96 const sync = () => { onCodexReveal.value = document.body.classList.contains('codex-reveal') }
97 sync()
98 if (typeof MutationObserver === 'undefined') return
99 bodyMO = new MutationObserver(sync)
100 bodyMO.observe(document.body, { attributes: true, attributeFilter: ['class'] })
101})
102onBeforeUnmount(() => { bodyMO?.disconnect(); bodyMO = null })
103 
104const cardEl = ref<HTMLElement | null>(null)
105const cardSize = ref({ w: 288, h: 170 }) // a sane estimate until measured
106let cardRO: ResizeObserver | null = null
107watch(cardEl, (el) => {
108 cardRO?.disconnect()
109 cardRO = null
110 if (el && typeof ResizeObserver !== 'undefined') {
111 cardRO = new ResizeObserver(() => { cardSize.value = { w: el.offsetWidth, h: el.offsetHeight } })
112 cardRO.observe(el)
113 }
114})
115 
116const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v))
117 
118// The card must clear the running head. For a TALL control (the figure picker fills
119// the compose column) the only viewport-fitting spot is above it — squarely on the
120// masthead. Measuring the masthead's foot as the floor for placements means that
121// case finds nothing it can sit beside, and falls to the bottom sheet (the reported
122// tablet overlap). Live-measured so it tracks the masthead's own reflow; MARGIN when
123// it isn't on screen.
124function safeTop() {
125 if (typeof document === 'undefined') return MARGIN
126 const m = document.querySelector('header.masthead')
127 return m ? Math.round(m.getBoundingClientRect().bottom) + MARGIN : MARGIN
129 
130// A fixed BOTTOM SHEET — centred, just above the leaf's footer dock (76px clears
131// it). The phone default AND the safe fallback whenever an anchored placement won't
132// fit (see useBottomSheet).
133const BOTTOM_SHEET = { top: 'auto', left: '50%', right: 'auto', bottom: '76px', transform: 'translateX(-50%)' }
134 
135// Should the card drop to that bottom sheet instead of nestling beside its control?
136// Always on a phone; and on wider screens whenever NO anchored placement fits below
137// the masthead. A tall control (the figure picker fills the compose column) leaves no
138// room to either side and only clears the viewport above itself — on the masthead —
139// so the old code dropped the card there, over the very controls it taught (the
140// reported tablet overlap). The sheet, paired with the has-bottom-coach scroll that
141// lifts the control above it, never covers anything.
142const useBottomSheet = computed(() => {
143 if (typeof window === 'undefined' || onCodexReveal.value) return false
144 if (!isMd.value) return true
145 const r = anchor.present.value ? anchor.rect.value : null
146 if (!r) return false // no control on screen → desktop corner fallback
147 const { w, h } = cardSize.value
148 const preferred = step.value?.placement ?? 'bottom'
149 const opts = { gap: GAP, margin: MARGIN, minTop: safeTop(), preferred }
150 return firstFittingPlacement(r, w, h, window.innerWidth, window.innerHeight, opts) === null
151})
⋯ 9 lines hidden (lines 152–160)
152 
153const cardStyle = computed(() => {
154 if (typeof window === 'undefined') return {}
155 // The reveal pages own the bottom of the screen with their Continue/Back
156 // footer; sit the card at top-centre (above the centred leaf, clear of that
157 // footer) so it never overlays the primary Continue control — the reported
158 // overlap that stranded the player. The halo still marks the taught control.
159 if (onCodexReveal.value) {
160 return { top: '88px', left: '50%', right: 'auto', bottom: 'auto', transform: 'translateX(-50%)' }
161 }
162 // Phone, or any width where nothing fits beside the control: the bottom sheet.
163 if (useBottomSheet.value) return BOTTOM_SHEET
164 const r = anchor.present.value ? anchor.rect.value : null
⋯ 129 lines hidden (lines 165–293)
165 if (!r) {
166 // Desktop corner fallback: bottom-right.
167 return { top: 'auto', left: 'auto', right: '16px', bottom: '16px' }
168 }
169 // A placement is guaranteed to fit here (else useBottomSheet would be true); the
170 // clamp is a belt-and-braces keep-on-screen.
171 const { w, h } = cardSize.value
172 const vw = window.innerWidth
173 const vh = window.innerHeight
174 const top = safeTop()
175 const preferred = step.value?.placement ?? 'bottom'
176 const p = firstFittingPlacement(r, w, h, vw, vh, { gap: GAP, margin: MARGIN, minTop: top, preferred }) ?? preferred
177 const chosen = placeAt(p, r, w, h, GAP)
178 return {
179 top: `${clamp(chosen.top, top, Math.max(top, vh - h - MARGIN))}px`,
180 left: `${clamp(chosen.left, MARGIN, Math.max(MARGIN, vw - w - MARGIN))}px`,
181 right: 'auto',
182 bottom: 'auto'
183 }
184})
185 
186const haloStyle = computed(() => {
187 const r = anchor.rect.value
188 if (!r) return null
189 const pad = 4
190 return {
191 top: `${r.top - pad}px`,
192 left: `${r.left - pad}px`,
193 width: `${r.width + pad * 2}px`,
194 height: `${r.height + pad * 2}px`
195 }
196})
197 
198// While the bottom sheet shows, flag the body so the active leaf reserves room
199// beneath its content (useCoachAnchor then scrolls the taught control up, clear of
200// the sheet — instead of leaving it pinned under the card).
201watch([visible, useBottomSheet], ([vis, sheet]) => {
202 if (typeof document !== 'undefined') document.body.classList.toggle('has-bottom-coach', vis && sheet)
203}, { immediate: true })
204 
205// ── Controls ─────────────────────────────────────────────────────────────
206function onNext() { coaching.next() }
207function onSkip() { coaching.skip() }
208 
209// Escape dismisses (mirrors the account popover) — documented by the visible
210// "Skip tour". Guarded so it only fires while a beat is actually showing.
211function onKeydown(e: KeyboardEvent) {
212 if (e.key === 'Escape' && coaching.isRunning) coaching.skip()
214onMounted(() => document.addEventListener('keydown', onKeydown))
215onBeforeUnmount(() => {
216 document.removeEventListener('keydown', onKeydown)
217 cardRO?.disconnect()
218 mdMql?.removeEventListener('change', syncMd)
219 if (typeof document !== 'undefined') document.body.classList.remove('has-bottom-coach')
220})
221</script>
222 
223<style scoped>
224/* A click-through overlay: no scrim, no background. Above the board + phone tab
225 bar (z-30), below the account popover (z-40) and the end-screen (z-50). */
226.coach-root {
227 position: fixed;
228 inset: 0;
229 z-index: 35;
230 pointer-events: none;
232 
233/* The halo — the focus-ring vocabulary already in main.css, reused as a pointer. */
234.coach-halo {
235 position: fixed;
236 border: 2px solid var(--ew-accent);
237 border-radius: 5px;
238 box-shadow: 0 0 0 4px color-mix(in srgb, var(--ew-accent) 14%, transparent);
239 pointer-events: none;
240 transition: top var(--ew-dur-2) var(--ew-ease), left var(--ew-dur-2) var(--ew-ease),
241 width var(--ew-dur-2) var(--ew-ease), height var(--ew-dur-2) var(--ew-ease);
243 
244/* The card — a struck ledger card (echoes .ew-card), the only interactive surface. */
245.coach-mark {
246 position: fixed;
247 width: 288px;
248 max-width: calc(100vw - 24px);
249 pointer-events: auto;
250 background: var(--ew-tint);
251 border: 1px solid var(--ew-line);
252 border-radius: 4px;
253 box-shadow: inset 0 0 0 3px var(--ew-tint), inset 0 0 0 4px var(--ew-line),
254 0 10px 28px color-mix(in srgb, var(--ew-fg) 16%, transparent);
255 padding: 12px 13px 11px;
256 animation: coach-in var(--ew-dur-2) var(--ew-ease);
257 transition: top var(--ew-dur-2) var(--ew-ease), left var(--ew-dur-2) var(--ew-ease);
259@keyframes coach-in { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
260 
261.coach-kicker { color: var(--ew-accent); margin-bottom: 5px; }
262.coach-body { font-size: 13px; line-height: 1.5; }
263 
264.coach-foot {
265 display: flex;
266 align-items: center;
267 justify-content: space-between;
268 gap: 10px;
269 margin-top: 11px;
270 padding-top: 9px;
271 border-top: 1px solid var(--ew-line);
273.coach-count { font-family: var(--ew-mono); font-size: 11px; }
274.coach-actions { display: flex; align-items: center; gap: 10px; }
275.coach-next { padding: 4px 12px; font-size: 13px; }
276.coach-waiting { font-size: 11px; font-style: italic; }
277.coach-skip {
278 font-size: 11px;
279 color: var(--ew-faint);
280 background: transparent;
281 border: 0;
282 cursor: pointer;
283 padding: 4px 2px;
285.coach-skip:hover { color: var(--ew-fg); }
286.coach-skip:focus-visible { outline: 2px solid var(--ew-accent); outline-offset: 2px; border-radius: 3px; }
287 
288/* This component is scoped, so the central main.css reduced-motion block can't
289 reach it — neutralize its motion here. Position changes snap; no entrance. */
290@media (prefers-reduced-motion: reduce) {
291 .coach-mark, .coach-halo { transition: none; animation: none; }
293</style>

Before

After

Tablet (768px), first-run coach. Before: the card sits on the masthead, covering the run gauge. After: a bottom sheet, masthead fully clear, halo still marking the picker.

Placement geometry, pulled into a tested util

The placement math used to live inline in the component, where a layout-less test runner (happy-dom) can't exercise it. It now sits in a pure module: firstFittingPlacement returns the first side that fits, or null — the signal to drop to the bottom sheet. fits carries the minTop floor that the masthead feeds.

Pure, framework-free: rects in, a placement (or null) out.

utils/coach-placement.ts · 88 lines
utils/coach-placement.ts88 lines · TypeScript
⋯ 44 lines hidden (lines 1–44)
1/**
2 * Geometry for placing the guided-coach card beside (or below) the control it
3 * teaches. Kept pure and framework-free so the placement DECISION is unit-testable
4 * without a layout engine (happy-dom has none); CoachMark.vue wires these to live
5 * rects + viewport reads.
6 *
7 * The one subtlety worth a test: a TALL control (the figure picker fills the compose
8 * column) leaves no room to either side, and the only spot that clears the viewport
9 * is ABOVE it — squarely on the running head. `minTop` (the masthead's foot) rules
10 * that out, so firstFittingPlacement returns null and the caller drops to the bottom
11 * sheet instead of dropping the card over the masthead (the reported tablet overlap).
12 */
13export type Placement = 'top' | 'bottom' | 'left' | 'right'
14 
15/** A control's (or the card's) box in viewport coordinates. */
16export interface Box {
17 top: number
18 left: number
19 width: number
20 height: number
22 
23export interface Point {
24 top: number
25 left: number
27 
28const OPPOSITE: Record<Placement, Placement> = { top: 'bottom', bottom: 'top', left: 'right', right: 'left' }
29 
30/** The card's top-left for placement `p` around control rect `r`, given the card's
31 * size and the breathing gap between them. */
32export function placeAt(p: Placement, r: Box, w: number, h: number, gap: number): Point {
33 switch (p) {
34 case 'bottom':
35 return { top: r.top + r.height + gap, left: r.left + r.width / 2 - w / 2 }
36 case 'top':
37 return { top: r.top - h - gap, left: r.left + r.width / 2 - w / 2 }
38 case 'right':
39 return { top: r.top + r.height / 2 - h / 2, left: r.left + r.width + gap }
40 case 'left':
41 return { top: r.top + r.height / 2 - h / 2, left: r.left - w - gap }
42 }
44 
45/** Whether a w×h card at `c` sits fully on screen: below `minTop` (the masthead's
46 * foot), and inside the side/bottom margins. */
47export function fits(
48 c: Point,
49 w: number,
50 h: number,
51 vw: number,
52 vh: number,
53 margin: number,
54 minTop: number
55): boolean {
56 return c.top >= minTop && c.left >= margin && c.top + h <= vh - margin && c.left + w <= vw - margin
58 
59/** The order to try placements in: the step's preference, then its opposite, then
⋯ 16 lines hidden (lines 60–75)
60 * the remaining sides. */
61export function placementOrder(preferred: Placement): Placement[] {
62 return [preferred, OPPOSITE[preferred], 'bottom', 'top', 'right', 'left']
64 
65export interface PlacementOpts {
66 gap: number
67 margin: number
68 /** The floor for the card's top — the masthead's foot — so no placement lands on
69 * the running head. */
70 minTop: number
71 preferred: Placement
73 
74/** The first placement that fully fits, or null when none do — the signal for the
75 * caller to fall back to the bottom sheet. */
76export function firstFittingPlacement(
77 r: Box,
78 w: number,
79 h: number,
80 vw: number,
81 vh: number,
82 opts: PlacementOpts
83): Placement | null {
84 for (const p of placementOrder(opts.preferred)) {
85 if (fits(placeAt(p, r, w, h, opts.gap), w, h, vw, vh, opts.margin, opts.minTop)) return p
86 }
87 return null

That makes the load-bearing case directly testable. The discriminating test pins exactly the bug: a tall control where the only viewport-fitting spot is the masthead. With the floor it returns null (→ bottom sheet); lift the floor and it would place “top” — straight over the header. The second assertion is what proves the floor is doing the work.

The floor is load-bearing: same geometry, opposite outcome.

tests/unit/utils/coach-placement.spec.ts · 65 lines
tests/unit/utils/coach-placement.spec.ts65 lines · TypeScript
⋯ 49 lines hidden (lines 1–49)
1import { describe, it, expect } from 'vitest'
2import { placeAt, firstFittingPlacement, type Box } from '../../../utils/coach-placement'
3 
4const CARD_W = 288
5const CARD_H = 170
6const GAP = 12
7const MARGIN = 10
8 
9describe('placeAt', () => {
10 const r: Box = { top: 200, left: 400, width: 100, height: 60 }
11 it('sits the card to the left of the control, vertically centred', () => {
12 expect(placeAt('left', r, CARD_W, CARD_H, GAP)).toEqual({
13 top: 200 + 60 / 2 - CARD_H / 2, // control mid minus card half
14 left: 400 - CARD_W - GAP
15 })
16 })
17 it('sits the card below the control, horizontally centred', () => {
18 expect(placeAt('bottom', r, CARD_W, CARD_H, GAP)).toEqual({
19 top: 200 + 60 + GAP,
20 left: 400 + 100 / 2 - CARD_W / 2
21 })
22 })
23})
24 
25describe('firstFittingPlacement', () => {
26 const opts = (over: Partial<{ minTop: number; preferred: 'top' | 'bottom' | 'left' | 'right' }> = {}) => ({
27 gap: GAP,
28 margin: MARGIN,
29 minTop: MARGIN,
30 preferred: 'left' as const,
31 ...over
32 })
33 
34 it('keeps the preferred side when it fits (room beside the control)', () => {
35 // A compact control near the right of a wide desktop viewport: "left" fits.
36 const r: Box = { top: 200, left: 700, width: 200, height: 200 }
37 expect(firstFittingPlacement(r, CARD_W, CARD_H, 1280, 850, opts({ minTop: 120 }))).toBe('left')
38 })
39 
40 it('flips to the opposite side when the preferred one overflows', () => {
41 // Control hugging the left edge: "left" overflows, so it flips to "right".
42 const r: Box = { top: 200, left: 0, width: 120, height: 200 }
43 expect(firstFittingPlacement(r, CARD_W, CARD_H, 1280, 850, opts())).toBe('right')
44 })
45 
46 // The fix: a tall control (the figure picker fills the compose column) leaves no
47 // room to either side, and the only spot that clears the viewport is ABOVE it —
48 // on the masthead. The masthead floor (minTop) rules that out, so nothing fits and
49 // the caller drops to the bottom sheet instead of dropping the card on the header.
50 describe('a tall control with no room beside it', () => {
51 const tall: Box = { top: 300, left: 40, width: 720, height: 560 }
52 const VW = 800
53 const VH = 900
54 
55 it('returns null once the masthead floor rules out the top placement', () => {
56 expect(firstFittingPlacement(tall, CARD_W, CARD_H, VW, VH, opts({ minTop: 160 }))).toBeNull()
57 })
58 
59 it('WITHOUT that floor it would place "top" — straight over the masthead', () => {
60 // Same geometry, masthead floor lifted: "top" now "fits", which is exactly
61 // the overlap the floor exists to prevent. This is what makes the floor load-bearing.
62 expect(firstFittingPlacement(tall, CARD_W, CARD_H, VW, VH, opts({ minTop: MARGIN }))).toBe('top')
⋯ 3 lines hidden (lines 63–65)
63 })
64 })
65})

The leaf reserves room for the sheet

The bottom sheet needs the leaf to scroll and leave clearance, so the taught control can lift above the card instead of hiding under it. That used to be phone-only; now it's gated on the body flag the coach sets, so it applies at any width the sheet appears — and stays inert (leaf untouched) when the card is anchored on desktop.

Clearance follows the sheet, not the breakpoint.

components/codex/LeafFrame.vue · 67 lines
components/codex/LeafFrame.vue67 lines · Vue
⋯ 38 lines hidden (lines 1–38)
1<template>
2 <!-- One leaf of the manuscript: a folio header (numeral + act name) over a focused
3 body. Each leaf mounts fresh per turn, so the entrance + temporal sheen
4 replay on every page-turn (the conductor swaps leaves by phase). -->
5 <section class="codex-leaf">
6 <span class="sheen" aria-hidden="true" />
7 <header v-if="numeral || name" class="folio">
8 <span class="numeral">{{ numeral }}</span>
9 <h2 class="act-name">{{ name }}</h2>
10 <span v-if="$slots.sub" class="act-sub"><slot name="sub" /></span>
11 </header>
12 <div class="leaf-body"><slot /></div>
13 </section>
14</template>
15 
16<script setup lang="ts">
17defineProps<{ numeral?: string; name?: string }>()
18</script>
19 
20<style scoped>
21.codex-leaf {
22 position: absolute; inset: 0;
23 display: flex; flex-direction: column;
24 padding: 26px 40px 22px 70px;
25 background: var(--ew-bg);
26 animation: leaf-in var(--ew-dur-3) var(--ew-ease);
28.sheen {
29 position: absolute; inset: 0; pointer-events: none; z-index: 5;
30 background: linear-gradient(105deg, transparent 30%, color-mix(in srgb, var(--ew-accent) 16%, transparent) 50%, transparent 70%);
31 background-size: 280% 100%; background-position: 180% 0; opacity: 0;
32 animation: sheen-sweep var(--ew-dur-3) var(--ew-ease);
34.folio { display: flex; align-items: baseline; gap: 12px; flex: none; margin-bottom: 14px; }
35.numeral { font-family: var(--ew-serif); font-style: italic; font-size: 13px; color: var(--ew-accent); min-width: 34px; }
36.act-name { margin: 0; font-family: var(--ew-serif); font-weight: 400; font-size: 20px; color: var(--ew-fg); }
37.act-sub { margin-left: auto; }
38.leaf-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
39/* While the guided first-run coach shows its bottom sheet — a phone always, and any
40 width where the card can't fit beside its control — make this leaf scroll and
41 reserve room beneath, so the taught control lifts clear of the card (via
42 useCoachAnchor's scroll-into-view) instead of hiding under it. Gated on the body
43 flag CoachMark sets, so it's inert unless the sheet is actually up (anchored
44 desktop placement leaves the leaf untouched). */
45:global(body.has-bottom-coach) .leaf-body {
46 overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch;
47 padding-bottom: 220px;
49@keyframes leaf-in { from { opacity: 0; transform: translateY(10px) scale(.992); } to { opacity: 1; transform: none; } }
⋯ 18 lines hidden (lines 50–67)
50@keyframes sheen-sweep { 0% { background-position: 180% 0; opacity: .9; } 100% { background-position: -120% 0; opacity: 0; } }
51@media (prefers-reduced-motion: reduce) {
52 .codex-leaf { animation: none; }
53 .sheen { animation: none; display: none; }
55@media (max-width: 760px) {
56 .codex-leaf { padding: 18px 18px 16px 34px; }
57 .act-name { font-size: 17px; }
59@media (max-width: 640px) {
60 .codex-leaf { padding: 14px 15px 12px 22px; }
61 .folio { margin-bottom: 11px; }
62 /* Tall pages (the Dispatch compose) scroll within the leaf on a phone. Pin
63 overflow-x to hidden: a lone overflow-y makes the browser compute overflow-x
64 to auto, which lets the page drift left-right too. We only ever scroll down. */
65 .leaf-body { overflow-y: auto; overflow-x: hidden; -webkit-overflow-scrolling: touch; }
67</style>