web: single-screen redesign with the literature levers

The dashboard is now a single-screen app shell:
- app-bar page tabs (Model + placeholder Explainer/Findings/Guides) via
  lightweight hash routing (usePage); the wordmark links home
- a 2x2 graph grid that fills the viewport, rows sized to half its height so
  the four cells always show; a + Add tile is the 4th cell, a 5th scenario
  scrolls (with a reserved scrollbar gutter)
- a left settings sidebar exposing the forwarding composite as grouped levers
  (novelty, harm awareness, program strength, plus baseline forwarding and
  education budget); the card is a CSS container so the sliders stack to width
- a full-width dock (legend, full-width player, Export / Reach over time /
  Data table); the reach chart and data table open as overlays
- the illustrative note moved under the hero

ScenarioToolbar is removed (the + tile and the dock Export replace it).
Deviates from the original ui-spec, which is amended with a pointer.
This commit is contained in:
Justin Visser 2026-06-18 22:01:30 +02:00
parent 430d59a2e8
commit 4ac7ba1624
15 changed files with 832 additions and 280 deletions

View file

@ -1,27 +1,36 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { onMounted, ref } from 'vue'
import AppBar from '@/components/AppBar.vue'
import ControlsCard from '@/components/ControlsCard.vue'
import ErrorBanner from '@/components/ErrorBanner.vue'
import ExportMenu from '@/components/ExportMenu.vue'
import FocusModal from '@/components/FocusModal.vue'
import FooterDisclaimer from '@/components/FooterDisclaimer.vue'
import HeroHeadline from '@/components/HeroHeadline.vue'
import LegendRow from '@/components/LegendRow.vue'
import MobileScoreStrip from '@/components/MobileScoreStrip.vue'
import NodeTooltip from '@/components/NodeTooltip.vue'
import OverlayCard from '@/components/OverlayCard.vue'
import PanelGrid from '@/components/PanelGrid.vue'
import PlaceholderPage from '@/components/PlaceholderPage.vue'
import PlayerBar from '@/components/PlayerBar.vue'
import ReachChart from '@/components/ReachChart.vue'
import ResultsTable from '@/components/ResultsTable.vue'
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
import { usePage } from '@/composables/usePage'
import { usePlayback } from '@/composables/usePlayback'
import { useSimStore } from '@/composables/useSimStore'
import { useTheme } from '@/composables/useTheme'
const store = useSimStore()
const playback = usePlayback()
const { activePage, navigate } = usePage()
useTheme() // resolve and apply the theme before first paint of the app
// The reach chart and data table live behind toggles and open as overlays,
// so the dashboard fits one screen on desktop (no page scroll).
const showChart = ref(false)
const showTable = ref(false)
onMounted(async () => {
await store.initialize()
playback.autoplayOnce()
@ -29,26 +38,59 @@ onMounted(async () => {
</script>
<template>
<AppBar />
<div class="shell">
<AppBar :active-page="activePage" @navigate="navigate" />
<main class="page">
<template v-if="activePage === 'model'">
<HeroHeadline />
<MobileScoreStrip />
<ScenarioToolbar />
<ErrorBanner />
<PanelGrid />
<LegendRow />
<PlayerBar />
<div class="lower">
<ReachChart />
<div class="workbench">
<div class="top">
<PanelGrid class="panels-fill" />
<aside class="sidebar">
<ControlsCard />
</aside>
</div>
<div class="dock">
<LegendRow />
<PlayerBar wide />
<div class="view-actions">
<ExportMenu />
<button class="view-btn" type="button" @click="showChart = true">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 19V5M4 19h16M8 15l3-4 3 2 4-6" />
</svg>
Reach over time
</button>
<button class="view-btn" type="button" @click="showTable = true">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="5" width="16" height="14" rx="2" />
<path d="M4 10h16M10 10v9" />
</svg>
Data table
</button>
</div>
</div>
</div>
</template>
<PlaceholderPage v-else :page="activePage" />
</main>
<FooterDisclaimer />
</div>
<OverlayCard v-if="showChart" title="Reach over time" @close="showChart = false">
<ReachChart />
</OverlayCard>
<OverlayCard v-if="showTable" title="Reach by scenario" @close="showTable = false">
<ResultsTable
embedded
:panels="store.state.panels"
:results-by-panel-id="store.state.resultsByPanelId"
:base="store.state.base"
/>
</main>
<FooterDisclaimer />
</OverlayCard>
<FocusModal v-if="store.state.focusPanelId" />
<NodeTooltip />
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
@ -56,17 +98,123 @@ onMounted(async () => {
<style scoped>
.page {
max-width: 1240px;
max-width: 1320px;
margin: 0 auto;
padding: 36px 28px 30px;
padding: 28px 28px 24px;
}
.lower {
.workbench {
margin-top: 18px;
}
.view-actions {
display: flex;
justify-content: center;
gap: 10px;
margin-top: 18px;
}
.view-btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--ink-3);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 999px;
padding: 6px 14px;
cursor: pointer;
}
.view-btn:hover {
border-color: var(--ink-4);
color: var(--ink);
}
.view-btn svg.ic {
width: 15px;
height: 15px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
/* Top row: settings sidebar (left) beside the graph grid (right). The dock
below it (legend, player, view actions) spans the full content width, so
the timeline includes the sidebar's width. */
@media (min-width: 761px) {
.workbench {
display: flex;
flex-direction: column;
}
.top {
display: grid;
grid-template-columns: 7fr 5fr;
gap: 18px;
margin-top: 26px;
align-items: start;
grid-template-columns: 320px minmax(0, 1fr);
gap: 24px;
min-height: 0;
}
.sidebar {
grid-column: 1;
grid-row: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
/* The settings card fills the sidebar so its height matches the two-row
graph grid; it scrolls internally only if its own content is taller. */
.sidebar :deep(.card) {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.panels-fill {
grid-column: 2;
min-width: 0; /* let the panel SVGs shrink inside the grid track */
min-height: 0;
}
.dock {
margin-top: 16px;
}
}
/* Snap the dashboard to one screen when the window is tall enough; the graph
grid takes the leftover height. Short windows fall back to normal scroll
so nothing clips. */
@media (min-width: 761px) and (min-height: 760px) {
.shell {
height: 100dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.workbench {
flex: 1;
min-height: 0;
}
.top {
flex: 1;
min-height: 0;
grid-template-rows: minmax(0, 1fr);
}
}
@media (max-width: 760px) {
@ -74,8 +222,8 @@ onMounted(async () => {
padding: 22px 16px;
}
.lower {
grid-template-columns: 1fr;
.sidebar {
margin-top: 22px;
}
}
</style>

View file

@ -97,7 +97,7 @@ function rerollWorld() {
</script>
<template>
<details class="adv" open>
<details class="adv">
<summary>
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
Advanced
@ -275,7 +275,8 @@ input[type='number'] {
color: var(--ink);
}
@media (max-width: 760px) {
/* Single column when the controls container is narrow (sidebar / mobile). */
@container controls (max-width: 380px) {
.grid {
grid-template-columns: 1fr;
}

View file

@ -1,25 +1,27 @@
<script setup lang="ts">
import { nextTick, ref } from 'vue'
import AboutPopover from './AboutPopover.vue'
import { useSimStore } from '@/composables/useSimStore'
import { PAGE_TABS, type PageId } from '@/composables/usePage'
import { useTheme } from '@/composables/useTheme'
const { preset } = useSimStore()
// The app bar carries the wordmark (a home link to the model), the page tabs
// (Model plus the planned resource pages), and the theme + source controls.
// The "illustrative, not validated" note now lives under the hero, so it is
// present on the model page without crowding the bar.
defineProps<{ activePage: PageId }>()
const emit = defineEmits<{ navigate: [page: PageId] }>()
const { theme, toggleTheme } = useTheme()
const aboutOpen = ref(false)
const badgeButton = ref<HTMLButtonElement | null>(null)
function closeAbout() {
aboutOpen.value = false
void nextTick(() => badgeButton.value?.focus())
}
</script>
<template>
<header class="appbar">
<div class="in">
<span class="wordmark">
<button
class="wordmark"
type="button"
aria-label="spreadlab home"
@click="emit('navigate', 'model')"
>
<span class="glyph" aria-hidden="true">
<svg viewBox="0 0 24 24">
<circle cx="6" cy="6" r="2.2" />
@ -29,23 +31,22 @@ function closeAbout() {
</svg>
</span>
spreadlab
</span>
<span class="badge-anchor">
<button
ref="badgeButton"
class="badge"
type="button"
:aria-expanded="aboutOpen"
@click="aboutOpen = !aboutOpen"
>
<svg class="ic" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="9" />
<path d="M12 11v5M12 8v.01" />
</svg>
{{ preset.disclaimerShort }}
</button>
<AboutPopover v-if="aboutOpen" :anchor="badgeButton" @close="closeAbout" />
</span>
<nav class="tabs" aria-label="Pages">
<button
v-for="tab in PAGE_TABS"
:key="tab.id"
type="button"
class="tab"
:class="{ active: activePage === tab.id }"
:aria-current="activePage === tab.id ? 'page' : undefined"
@click="emit('navigate', tab.id)"
>
{{ tab.label }}
</button>
</nav>
<span class="grow" />
<button
class="iconbtn"
@ -90,13 +91,13 @@ function closeAbout() {
}
.in {
max-width: 1240px;
max-width: 1320px;
margin: 0 auto;
padding: 0 28px;
height: 56px;
display: flex;
align-items: center;
gap: 14px;
gap: 16px;
}
.wordmark {
@ -106,6 +107,12 @@ function closeAbout() {
font-weight: 650;
font-size: 15.5px;
letter-spacing: -0.01em;
color: var(--ink);
background: none;
border: none;
padding: 0;
cursor: pointer;
flex: none;
}
.glyph {
@ -127,28 +134,34 @@ function closeAbout() {
stroke-linejoin: round;
}
.badge-anchor {
position: relative;
display: inline-flex;
}
.badge {
display: inline-flex;
.tabs {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
font-weight: 500;
color: var(--ink-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 12px 4px 9px;
cursor: pointer;
gap: 2px;
min-width: 0;
}
.badge svg.ic {
width: 14px;
height: 14px;
.tab {
font-size: 13.5px;
font-weight: 550;
color: var(--ink-3);
background: none;
border: none;
border-radius: 8px;
padding: 6px 11px;
cursor: pointer;
white-space: nowrap;
}
.tab:hover {
background: var(--bg);
color: var(--ink);
}
.tab.active {
background: var(--bg);
color: var(--ink);
font-weight: 600;
}
.grow {
@ -165,6 +178,7 @@ function closeAbout() {
display: grid;
place-items: center;
cursor: pointer;
flex: none;
}
.iconbtn:hover {
@ -174,10 +188,22 @@ function closeAbout() {
@media (max-width: 760px) {
.in {
padding: 0 16px;
padding: 0 12px;
gap: 8px;
}
.badge-anchor {
/* The wordmark text drops to the glyph; the tabs scroll horizontally. */
.wordmark {
font-size: 0;
gap: 0;
}
.tabs {
overflow-x: auto;
scrollbar-width: none;
}
.tabs::-webkit-scrollbar {
display: none;
}
}

View file

@ -5,8 +5,10 @@ import LeverSlider from './LeverSlider.vue'
import { useSimStore } from '@/composables/useSimStore'
import { formatPct } from '@/lib/format'
// The world card (spec slice 6): two headline levers, the advanced grid,
// and the inline spot for engine 400s. Lever changes hit the store
// The world card (spec slice 6, extended 2026-06-18 for the literature
// levers): the forwarding composite and the program, grouped so the levers
// read as a structured model rather than a flat wall, plus the advanced
// grid and the inline spot for engine 400s. Lever changes hit the store
// immediately and rerun every panel after the shared debounce.
const store = useSimStore()
@ -18,12 +20,15 @@ const educationDisplay = computed(() => {
return `${base.value.numEducated} · ${formatPct(share)}`
})
const offendsForwardProb = computed(
() => store.state.validationError?.includes('forwardProb') ?? false,
)
const offendsNumEducated = computed(
() => store.state.validationError?.includes('numEducated') ?? false,
)
// The engine 400 names the offending JSON field; mark the matching lever.
const offends = (field: string) =>
computed(() => store.state.validationError?.includes(field) ?? false)
const offendsForwardProb = offends('forwardProb')
const offendsNovelty = offends('novelty')
const offendsHarmAwareness = offends('harmAwareness')
const offendsNumEducated = offends('numEducated')
const offendsProgramEffect = offends('programEffect')
</script>
<template>
@ -32,9 +37,12 @@ const offendsNumEducated = computed(
<p v-if="store.state.validationError" class="invalid-note" role="alert">
{{ store.state.validationError }}
</p>
<div class="group">
<p class="group-label">The fake</p>
<LeverSlider
label="Chance to forward"
hint="per friendship, per round"
hint="baseline, per friendship"
:value="base.forwardProb"
:min="store.state.bounds?.forwardProb.min ?? 0"
:max="store.state.bounds?.forwardProb.max ?? 1"
@ -43,6 +51,36 @@ const offendsNumEducated = computed(
:invalid="offendsForwardProb"
@update="(value) => store.setBaseField('forwardProb', value)"
/>
<LeverSlider
label="Novelty / shock"
hint="how new or shocking it feels"
:value="base.novelty"
:min="store.state.bounds?.novelty.min ?? 0"
:max="store.state.bounds?.novelty.max ?? 1"
:step="0.05"
:display-value="formatPct(base.novelty * 100)"
:invalid="offendsNovelty"
@update="(value) => store.setBaseField('novelty', value)"
/>
</div>
<div class="group">
<p class="group-label">The year group</p>
<LeverSlider
label="Harm awareness"
hint="shared AI-literacy in the year"
:value="base.harmAwareness"
:min="store.state.bounds?.harmAwareness.min ?? 0"
:max="store.state.bounds?.harmAwareness.max ?? 1"
:step="0.05"
:display-value="formatPct(base.harmAwareness * 100)"
:invalid="offendsHarmAwareness"
@update="(value) => store.setBaseField('harmAwareness', value)"
/>
</div>
<div class="group">
<p class="group-label">The program</p>
<LeverSlider
label="Education budget"
hint="students the program reaches"
@ -54,6 +92,19 @@ const offendsNumEducated = computed(
:invalid="offendsNumEducated"
@update="(value) => store.setBaseField('numEducated', value)"
/>
<LeverSlider
label="Program strength"
hint="how much it cuts an educated student's sharing"
:value="base.programEffect"
:min="store.state.bounds?.programEffect.min ?? 0"
:max="store.state.bounds?.programEffect.max ?? 1"
:step="0.05"
:display-value="formatPct(base.programEffect * 100)"
:invalid="offendsProgramEffect"
@update="(value) => store.setBaseField('programEffect', value)"
/>
</div>
<AdvancedFields />
</section>
</template>
@ -65,6 +116,10 @@ const offendsNumEducated = computed(
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px 22px;
/* The card is a container so its sliders and advanced grid adapt to the
sidebar width (narrow -> stacked), not the viewport width. */
container-type: inline-size;
container-name: controls;
}
h3 {
@ -75,6 +130,19 @@ h3 {
color: var(--ink-4);
}
.group + .group {
margin-top: 22px;
border-top: 1px solid var(--border-soft);
padding-top: 4px;
}
.group-label {
margin-top: 16px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-3);
}
.invalid-note {
margin-top: 8px;
font-size: 12.5px;

View file

@ -49,7 +49,7 @@ function closeAbout() {
<template v-else>{{ segment.text }}</template>
</template>
</p>
<span class="mobile-note">
<span class="disclaimer-note">
<button
ref="noteButton"
type="button"
@ -103,8 +103,27 @@ p b.pending {
color: var(--ink-4);
}
.mobile-note {
display: none;
.disclaimer-note {
display: block;
position: relative;
margin-top: 10px;
}
.note-button {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--ink-4);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.note-button svg.ic {
width: 14px;
height: 14px;
}
@media (max-width: 760px) {
@ -115,28 +134,5 @@ p b.pending {
p {
font-size: 15px;
}
.mobile-note {
display: block;
position: relative;
margin-top: 10px;
}
.note-button {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--ink-4);
background: none;
border: none;
padding: 0;
cursor: pointer;
}
.note-button svg.ic {
width: 14px;
height: 14px;
}
}
</style>

View file

@ -142,9 +142,13 @@ input[type='range']::-moz-range-thumb {
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
}
@media (max-width: 760px) {
/* Stack (label above the track) when the controls container is narrow,
e.g. inside the sidebar or on mobile. */
@container controls (max-width: 380px) {
.row {
grid-template-columns: 1fr 78px;
gap: 8px 14px;
margin-top: 14px;
}
label {

View file

@ -166,6 +166,8 @@ const popDurationMs = computed(() => (ROUND_MS / store.state.speed) * 0.3)
height: auto;
display: block;
margin-top: 4px;
/* viewBox + default preserveAspectRatio scales the graph to fit, so on
desktop it can fill the flex cell height (set below) without cropping. */
/* A graph-seed reroll remounts the svg (keyed by graph hash); the new
layout fades in (spec 5.3). Skipped under reduced motion globally. */
animation: layout-fade 200ms ease-out;
@ -205,4 +207,12 @@ const popDurationMs = computed(() => (ROUND_MS / store.state.speed) * 0.3)
opacity: 1;
}
}
/* Desktop: fill the panel's flex network area (the cell grows with the
viewport); the graph scales to fit, centred. */
@media (min-width: 761px) {
.net {
height: 100%;
}
}
</style>

View file

@ -0,0 +1,115 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
// A lightweight modal sheet for the on-demand views (reach chart, data
// table) in the single-screen layout: scrim, centered card, Esc / scrim /
// close-button dismissal. Focus moves to the close button on open; the
// full focus-trap of FocusModal is overkill for read-only content and is
// revisited in the accessibility pass.
defineProps<{ title: string }>()
const emit = defineEmits<{ close: [] }>()
const closeButton = ref<HTMLButtonElement | null>(null)
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
onMounted(() => {
document.addEventListener('keydown', onKeydown)
closeButton.value?.focus()
})
onBeforeUnmount(() => document.removeEventListener('keydown', onKeydown))
</script>
<template>
<div class="scrim" @click.self="emit('close')">
<div class="sheet" role="dialog" aria-modal="true" :aria-label="title">
<div class="sheet-head">
<h2>{{ title }}</h2>
<button ref="closeButton" class="close" type="button" aria-label="Close" @click="emit('close')">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M6 6l12 12M18 6L6 18" />
</svg>
</button>
</div>
<div class="sheet-body">
<slot />
</div>
</div>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 40;
background: rgba(15, 23, 42, 0.5);
display: grid;
place-items: center;
padding: 24px;
}
.sheet {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 16px;
box-shadow: 0 24px 64px -16px rgba(15, 23, 42, 0.4);
width: min(760px, 100%);
max-height: 85dvh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.sheet-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--border-soft);
}
h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-3);
}
.close {
width: 32px;
height: 32px;
border-radius: 8px;
border: none;
background: none;
color: var(--ink-3);
display: grid;
place-items: center;
cursor: pointer;
flex: none;
}
.close:hover {
background: var(--bg);
color: var(--ink);
}
.close svg.ic {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
}
.sheet-body {
padding: 18px;
overflow: auto;
}
</style>

View file

@ -1,8 +1,9 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import ScenarioPanel from './ScenarioPanel.vue'
import { useSimStore } from '@/composables/useSimStore'
import { accentForPanel } from '@/lib/accents'
import { MAX_PANELS } from '@/presets/types'
const store = useSimStore()
@ -16,9 +17,50 @@ const unreachable = computed(
)
const apiBaseUrl = `${window.location.origin}/api`
const canAdd = computed(() => store.state.panels.length < MAX_PANELS)
// Append a panel and open its editor (the + tile replaces the old toolbar
// button).
function addScenario() {
const added = store.addPanel()
if (added) store.state.editingPanelId = added.id
}
// Size each grid row to exactly half the visible grid height so the 2x2 is
// always fully visible and a fifth scenario spills into a scrolling third
// row. Only while the dashboard is locked to one screen (matches App.vue's
// gate); otherwise the CSS fallback and natural flow apply.
const gridElement = ref<HTMLElement | null>(null)
const ROW_GAP_PX = 16
const oneScreen = window.matchMedia('(min-width: 761px) and (min-height: 760px)')
let rowObserver: ResizeObserver | null = null
function syncRowHeight() {
const element = gridElement.value
if (!element) return
if (oneScreen.matches) {
const rowHeight = Math.max(150, Math.floor((element.clientHeight - ROW_GAP_PX) / 2))
element.style.setProperty('--row-h', `${rowHeight}px`)
} else {
element.style.removeProperty('--row-h')
}
}
onMounted(() => {
rowObserver = new ResizeObserver(syncRowHeight)
if (gridElement.value) rowObserver.observe(gridElement.value)
oneScreen.addEventListener('change', syncRowHeight)
syncRowHeight()
})
onBeforeUnmount(() => {
rowObserver?.disconnect()
oneScreen.removeEventListener('change', syncRowHeight)
})
</script>
<template>
<div ref="gridElement" class="panelgrid">
<div v-if="unreachable" class="unreachable" role="alert">
<p>
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
@ -32,14 +74,61 @@ const apiBaseUrl = `${window.location.origin}/api`
:panel="panel"
:accent="accentForPanel(panelIndex)"
/>
<button v-if="canAdd" class="add-tile" type="button" @click="addScenario()">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 5v14M5 12h14" />
</svg>
Add scenario
</button>
</div>
</div>
</template>
<style scoped>
.panelgrid {
min-height: 0;
}
.panels {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 18px;
grid-template-columns: repeat(2, 1fr);
grid-auto-rows: var(--row-h, minmax(180px, 1fr));
gap: 16px;
}
.add-tile {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 180px;
border: 1.5px dashed var(--border);
border-radius: var(--radius);
background: none;
color: var(--ink-4);
font-size: 14.5px;
font-weight: 600;
cursor: pointer;
transition:
border-color 0.15s,
color 0.15s,
background 0.15s;
}
.add-tile:hover {
border-color: var(--edu);
color: var(--edu);
background: var(--surface);
}
.add-tile svg.ic {
width: 46px;
height: 46px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
}
.unreachable {
@ -72,10 +161,27 @@ const apiBaseUrl = `${window.location.origin}/api`
cursor: pointer;
}
/* Desktop: the grid fills the height its parent gives it (App.vue makes the
grid a flex child) and scrolls internally past four scenarios. */
@media (min-width: 761px) {
.panelgrid {
overflow-y: auto;
/* Reserve room so the scrollbar (overlay or classic) never sits over the
right column of graphs when a 5th scenario makes the grid scroll. */
padding-right: 14px;
scrollbar-gutter: stable;
}
}
@media (max-width: 760px) {
.panels {
grid-template-columns: 1fr;
grid-auto-rows: auto;
gap: 14px;
}
.add-tile {
min-height: 120px;
}
}
</style>

View file

@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed } from 'vue'
import { usePage, type PageId } from '@/composables/usePage'
// Stub pages for the planned free public resource. Intentional placeholders
// (not "404"s): they state what each page will hold so a visitor, or a
// grader following the proposal, sees the intended shape of the resource.
const props = defineProps<{ page: PageId }>()
const { navigate } = usePage()
type InfoPage = Exclude<PageId, 'model'>
const COPY: Record<InfoPage, { title: string; body: string }> = {
explainer: {
title: 'Explainer',
body: 'Plain-language background: what AI-generated non-consensual deepfakes are, how they spread through a school year group, and why educating the most-connected students slows the spread more than educating at random.',
},
findings: {
title: 'Findings',
body: 'Results from the model, and in time from the real-school study, will be published here in plain language alongside the data, so schools and families can see what the evidence says.',
},
guides: {
title: 'Guides',
body: 'Practical guides for schools, parents, and teenagers: how to spot synthetic images, how to refuse and report safely, and how to support someone who has been targeted.',
},
}
const content = computed(() => COPY[props.page as InfoPage] ?? COPY.explainer)
</script>
<template>
<section class="placeholder">
<span class="tag">Coming soon</span>
<h1>{{ content.title }}</h1>
<p>{{ content.body }}</p>
<p class="note">
Part of the planned free public resource. The interactive model is live on the
<button class="link" type="button" @click="navigate('model')">Model</button> tab.
</p>
</section>
</template>
<style scoped>
.placeholder {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
gap: 14px;
padding: 48px 24px;
}
.tag {
font-size: 11.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--edu);
background: color-mix(in srgb, var(--edu) 12%, transparent);
border-radius: 999px;
padding: 4px 12px;
}
h1 {
font-size: 30px;
font-weight: 700;
letter-spacing: -0.022em;
color: var(--ink);
}
p {
max-width: 560px;
font-size: 16px;
line-height: 1.55;
color: var(--ink-3);
}
.note {
font-size: 13.5px;
color: var(--ink-4);
}
.link {
font: inherit;
color: var(--edu);
background: none;
border: none;
padding: 0;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
</style>

View file

@ -11,7 +11,10 @@ import { useSimStore } from '@/composables/useSimStore'
// The modal docks a second PlayerBar over the same store; only the page
// instance owns the global keyboard map so keys are not handled twice.
const props = withDefaults(defineProps<{ globalKeys?: boolean }>(), { globalKeys: true })
const props = withDefaults(defineProps<{ globalKeys?: boolean; wide?: boolean }>(), {
globalKeys: true,
wide: false,
})
const store = useSimStore()
const playback = usePlayback()
@ -76,7 +79,7 @@ onBeforeUnmount(() => {
<template>
<div class="playerwrap">
<div class="player">
<div class="player" :class="{ wide }">
<button
class="iconbtn"
type="button"
@ -178,6 +181,12 @@ onBeforeUnmount(() => {
width: min(680px, 100%);
}
/* On the dashboard the player spans the full content width (the modal keeps
the centered pill). */
.player.wide {
width: 100%;
}
.iconbtn {
width: 36px;
height: 36px;

View file

@ -12,6 +12,10 @@ const props = defineProps<{
panels: PanelSpec[]
resultsByPanelId: Record<string, Result>
base: Config
// When shown inside an overlay (single-screen layout) the table renders
// directly, without the collapsing disclosure. Default keeps the inline
// disclosure used elsewhere.
embedded?: boolean
}>()
function numStudentsFor(panel: PanelSpec): number {
@ -20,8 +24,13 @@ function numStudentsFor(panel: PanelSpec): number {
</script>
<template>
<details class="results">
<summary>
<component
:is="embedded ? 'div' : 'details'"
class="results"
:class="{ embedded }"
:open="embedded || undefined"
>
<summary v-if="!embedded">
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
Data table
</summary>
@ -53,7 +62,7 @@ function numStudentsFor(panel: PanelSpec): number {
</tr>
</tbody>
</table>
</details>
</component>
</template>
<style scoped>
@ -61,6 +70,15 @@ function numStudentsFor(panel: PanelSpec): number {
margin-top: 26px;
}
.results.embedded {
margin-top: 0;
}
.results.embedded table {
margin-top: 0;
width: 100%;
}
summary {
display: inline-flex;
align-items: center;

View file

@ -348,6 +348,33 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
}
}
/* Desktop: the panel is a flex column so the network area grows to fill the
2x2 grid cell; the graph scales with it (NetworkView height 100%). Mobile
keeps the natural width-based scaling (a flex-fill with no definite cell
height would collapse the SVG). */
@media (min-width: 761px) {
.panel {
display: flex;
flex-direction: column;
}
.netbtn {
flex: 1;
min-height: 0;
}
.skeleton {
flex: 1;
display: flex;
flex-direction: column;
}
.net-bone {
flex: 1;
aspect-ratio: auto;
}
}
@media (max-width: 760px) {
/* 44 px touch target for the kebab (spec section 8). */
.kebab {

View file

@ -1,124 +0,0 @@
<script setup lang="ts">
import { computed } from 'vue'
import ExportMenu from './ExportMenu.vue'
import { useSimStore } from '@/composables/useSimStore'
import { MAX_PANELS } from '@/presets/types'
const store = useSimStore()
const atPanelCap = computed(() => store.state.panels.length >= MAX_PANELS)
// A refresh is in flight over existing results; the initial load shows
// skeleton cards instead of this spinner.
const refreshing = computed(
() => store.state.runState === 'running' && Object.keys(store.state.resultsByPanelId).length > 0,
)
// Spec 5.6: a new panel opens its editor right away.
function addScenario() {
const addedPanel = store.addPanel()
if (addedPanel) store.state.editingPanelId = addedPanel.id
}
</script>
<template>
<div class="toolbar">
<h2>Scenarios</h2>
<span v-if="refreshing" class="spinner" role="status" aria-label="Updating scenarios" />
<span class="grow" />
<ExportMenu />
<button
class="btn primary"
type="button"
:disabled="atPanelCap"
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
@click="addScenario()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
Add scenario
</button>
</div>
</template>
<style scoped>
.toolbar {
display: flex;
align-items: center;
margin: 30px 0 14px;
gap: 10px;
}
h2 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-4);
}
.grow {
flex: 1;
}
.spinner {
width: 14px;
height: 14px;
border-radius: 50%;
border: 2px solid var(--border);
border-top-color: var(--ink-3);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
font-size: 13.5px;
font-weight: 600;
line-height: 1;
color: var(--ink-2);
background: var(--surface);
border: 1px solid var(--border);
border-radius: 10px;
padding: 8px 14px;
cursor: pointer;
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.04);
}
.btn:hover:not(:disabled) {
border-color: var(--ink-4);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn.primary {
background: var(--ink);
color: var(--surface);
border-color: var(--ink);
}
.btn svg.ic {
width: 15px;
height: 15px;
}
@media (max-width: 760px) {
.toolbar {
margin-top: 16px;
}
/* Keep the toolbar one row down to 320 px with comfortable touch
targets; ExportMenu collapses itself to an icon. */
.btn {
min-height: 44px;
}
}
</style>

View file

@ -0,0 +1,51 @@
import { ref } from 'vue'
// Lightweight hash routing for the top-level pages (no Vue Router). The
// dashboard ("model") lives at the bare URL so its query-string state stays
// clean; the informational pages get a shareable hash (e.g. /#explainer).
export type PageId = 'model' | 'explainer' | 'findings' | 'guides'
export interface PageTab {
id: PageId
label: string
}
export const PAGE_TABS: PageTab[] = [
{ id: 'model', label: 'Model' },
{ id: 'explainer', label: 'Explainer' },
{ id: 'findings', label: 'Findings' },
{ id: 'guides', label: 'Guides' },
]
const PAGE_IDS = PAGE_TABS.map((tab) => tab.id) as string[]
function pageFromHash(): PageId {
const raw = window.location.hash.replace(/^#\/?/, '')
return PAGE_IDS.includes(raw) && raw !== 'model' ? (raw as PageId) : 'model'
}
// Module singleton so every component reads the same active page; the
// hashchange listener is registered once.
const activePage = ref<PageId>(pageFromHash())
window.addEventListener('hashchange', () => {
activePage.value = pageFromHash()
})
export function usePage() {
function navigate(page: PageId) {
if (page === 'model') {
// Strip the hash without a reload, preserving the dashboard's query.
if (window.location.hash) {
history.replaceState(history.state, '', window.location.pathname + window.location.search)
}
activePage.value = 'model'
} else {
// Setting the hash fires hashchange, which updates activePage.
window.location.hash = page
}
}
return { activePage, navigate }
}