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:
parent
e0ecc5c6ba
commit
609ba6f0dd
5 changed files with 163 additions and 3 deletions
|
|
@ -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 />
|
||||||
|
|
|
||||||
117
web/src/components/MobileScoreStrip.vue
Normal file
117
web/src/components/MobileScoreStrip.vue
Normal 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>
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue