web: mobile pass, slice 10 of M3

The MobileScoreStrip lands: sticky under the app bar, one pill per
panel with the round-synced colored share, horizontally scrollable (the
only horizontal scroller, verified at 320 px), tapping scrolls to the
panel (instant under reduced motion, scroll-margin clears the sticky
stack).

Touch targets reach 44 px on mobile: player buttons, panel kebabs, and
toolbar buttons; Export collapses to its icon so the toolbar stays one
row down to 320 px.
This commit is contained in:
Justin Visser 2026-06-10 18:09:51 +02:00
parent e0ecc5c6ba
commit 609ba6f0dd
5 changed files with 163 additions and 3 deletions

View file

@ -7,6 +7,7 @@ import FocusModal from '@/components/FocusModal.vue'
import FooterDisclaimer from '@/components/FooterDisclaimer.vue' import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
import HeroHeadline from '@/components/HeroHeadline.vue' import HeroHeadline from '@/components/HeroHeadline.vue'
import LegendRow from '@/components/LegendRow.vue' import LegendRow from '@/components/LegendRow.vue'
import MobileScoreStrip from '@/components/MobileScoreStrip.vue'
import NodeTooltip from '@/components/NodeTooltip.vue' import NodeTooltip from '@/components/NodeTooltip.vue'
import PanelGrid from '@/components/PanelGrid.vue' import PanelGrid from '@/components/PanelGrid.vue'
import PlayerBar from '@/components/PlayerBar.vue' import PlayerBar from '@/components/PlayerBar.vue'
@ -31,6 +32,7 @@ onMounted(async () => {
<AppBar /> <AppBar />
<main class="page"> <main class="page">
<HeroHeadline /> <HeroHeadline />
<MobileScoreStrip />
<ScenarioToolbar /> <ScenarioToolbar />
<ErrorBanner /> <ErrorBanner />
<PanelGrid /> <PanelGrid />

View file

@ -0,0 +1,117 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSimStore } from '@/composables/useSimStore'
import { roundPct } from '@/lib/format'
import { reachedCountAtRound } from '@/lib/reach'
// Mobile only (spec section 9): a sticky strip of one pill per panel so
// the comparison survives the single-column stack. The percentages follow
// the playhead like the panel cards; tapping a chip scrolls to its panel.
const store = useSimStore()
interface StripChip {
panelId: string
label: string
pctText: string
tone: 'good' | 'bad' | 'pending'
}
const chips = computed<StripChip[]>(() =>
store.state.panels.map((panel) => {
const result = store.state.resultsByPanelId[panel.id]
if (!result) return { panelId: panel.id, label: panel.label, pctText: '…', tone: 'pending' }
const numStudents = store.effectiveConfig(panel).numStudents
const pct = (reachedCountAtRound(result.reachedAtRound, store.state.round) / numStudents) * 100
return {
panelId: panel.id,
label: panel.label,
pctText: `${roundPct(pct)}%`,
tone: roundPct(pct) <= store.preset.toneThresholdPct ? 'good' : 'bad',
}
}),
)
function scrollToPanel(panelId: string) {
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
document
.getElementById(`panel-${panelId}`)
?.scrollIntoView({ behavior: reducedMotion ? 'auto' : 'smooth', block: 'start' })
}
</script>
<template>
<div class="strip">
<button
v-for="chip in chips"
:key="chip.panelId"
class="s-chip"
type="button"
@click="scrollToPanel(chip.panelId)"
>
<span class="s-label">{{ chip.label }}</span>
<b :class="chip.tone">{{ chip.pctText }}</b>
</button>
</div>
</template>
<style scoped>
.strip {
display: none;
}
@media (max-width: 760px) {
.strip {
display: flex;
gap: 8px;
overflow-x: auto;
margin-top: 18px;
position: sticky;
top: 56px;
z-index: 10;
background: var(--bg);
padding: 10px 0;
scrollbar-width: none;
}
.s-chip {
flex: none;
display: flex;
align-items: center;
gap: 8px;
min-height: 44px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 14px;
font-size: 12.5px;
font-weight: 550;
color: var(--ink-3);
box-shadow: var(--shadow);
cursor: pointer;
}
.s-label {
max-width: 130px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.s-chip b {
font-size: 15px;
}
b.bad {
color: var(--spread);
}
b.good {
color: var(--edu);
}
b.pending {
color: var(--ink-4);
}
}
</style>

View file

@ -335,5 +335,16 @@ input[type='range']::-moz-range-thumb {
.player .iconbtn.step { .player .iconbtn.step {
display: none; display: none;
} }
/* 44 px touch targets (spec section 8). */
.iconbtn {
width: 44px;
height: 44px;
}
.iconbtn.play {
width: 48px;
height: 48px;
}
} }
</style> </style>

View file

@ -94,7 +94,7 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
</script> </script>
<template> <template>
<section class="panel" :class="{ dimmed }" :aria-label="panel.label"> <section :id="`panel-${panel.id}`" class="panel" :class="{ dimmed }" :aria-label="panel.label">
<div class="head"> <div class="head">
<span class="swatch" :style="{ background: accent }" aria-hidden="true" /> <span class="swatch" :style="{ background: accent }" aria-hidden="true" />
<span class="label">{{ panel.label }}</span> <span class="label">{{ panel.label }}</span>
@ -340,4 +340,18 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
background-position: -200% 0; background-position: -200% 0;
} }
} }
@media (max-width: 760px) {
/* 44 px touch target for the kebab (spec section 8). */
.kebab {
min-width: 44px;
min-height: 44px;
margin-right: -12px;
}
/* Anchor target lands below the sticky app bar plus score strip. */
.panel {
scroll-margin-top: 128px;
}
}
</style> </style>

View file

@ -24,9 +24,15 @@ function addScenario() {
<h2>Scenarios</h2> <h2>Scenarios</h2>
<span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" /> <span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" />
<span class="grow" /> <span class="grow" />
<button class="btn" type="button" disabled title="Export arrives in a later slice"> <button
class="btn export"
type="button"
disabled
title="Export arrives in a later slice"
aria-label="Export"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" /></svg> <svg class="ic" viewBox="0 0 24 24"><path d="M12 4v11m0 0l-4-4m4 4l4-4M5 20h14" /></svg>
Export <span class="btn-text">Export</span>
</button> </button>
<button <button
class="btn primary" class="btn primary"
@ -116,5 +122,15 @@ h2 {
.toolbar { .toolbar {
margin-top: 16px; margin-top: 16px;
} }
/* Keep the toolbar one row down to 320 px: Export collapses to an icon
and the buttons grow to comfortable touch targets. */
.btn {
min-height: 44px;
}
.btn.export .btn-text {
display: none;
}
} }
</style> </style>