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:
parent
430d59a2e8
commit
4ac7ba1624
15 changed files with 832 additions and 280 deletions
202
web/src/App.vue
202
web/src/App.vue
|
|
@ -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 />
|
||||
<main class="page">
|
||||
<HeroHeadline />
|
||||
<MobileScoreStrip />
|
||||
<ScenarioToolbar />
|
||||
<ErrorBanner />
|
||||
<PanelGrid />
|
||||
<LegendRow />
|
||||
<PlayerBar />
|
||||
<div class="lower">
|
||||
<ReachChart />
|
||||
<ControlsCard />
|
||||
</div>
|
||||
<div class="shell">
|
||||
<AppBar :active-page="activePage" @navigate="navigate" />
|
||||
<main class="page">
|
||||
<template v-if="activePage === 'model'">
|
||||
<HeroHeadline />
|
||||
<MobileScoreStrip />
|
||||
<ErrorBanner />
|
||||
<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 {
|
||||
display: grid;
|
||||
grid-template-columns: 7fr 5fr;
|
||||
gap: 18px;
|
||||
margin-top: 26px;
|
||||
align-items: start;
|
||||
.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: 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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
<nav class="tabs" aria-label="Pages">
|
||||
<button
|
||||
ref="badgeButton"
|
||||
class="badge"
|
||||
v-for="tab in PAGE_TABS"
|
||||
:key="tab.id"
|
||||
type="button"
|
||||
:aria-expanded="aboutOpen"
|
||||
@click="aboutOpen = !aboutOpen"
|
||||
class="tab"
|
||||
:class="{ active: activePage === tab.id }"
|
||||
:aria-current="activePage === tab.id ? 'page' : undefined"
|
||||
@click="emit('navigate', tab.id)"
|
||||
>
|
||||
<svg class="ic" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M12 11v5M12 8v.01" />
|
||||
</svg>
|
||||
{{ preset.disclaimerShort }}
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
<AboutPopover v-if="aboutOpen" :anchor="badgeButton" @close="closeAbout" />
|
||||
</span>
|
||||
</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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,28 +37,74 @@ const offendsNumEducated = computed(
|
|||
<p v-if="store.state.validationError" class="invalid-note" role="alert">
|
||||
{{ store.state.validationError }}
|
||||
</p>
|
||||
<LeverSlider
|
||||
label="Chance to forward"
|
||||
hint="per friendship, per round"
|
||||
:value="base.forwardProb"
|
||||
:min="store.state.bounds?.forwardProb.min ?? 0"
|
||||
:max="store.state.bounds?.forwardProb.max ?? 1"
|
||||
:step="0.01"
|
||||
:display-value="formatPct(base.forwardProb * 100)"
|
||||
:invalid="offendsForwardProb"
|
||||
@update="(value) => store.setBaseField('forwardProb', value)"
|
||||
/>
|
||||
<LeverSlider
|
||||
label="Education budget"
|
||||
hint="students the program reaches"
|
||||
:value="base.numEducated"
|
||||
:min="0"
|
||||
:max="base.numStudents"
|
||||
:step="1"
|
||||
:display-value="educationDisplay"
|
||||
:invalid="offendsNumEducated"
|
||||
@update="(value) => store.setBaseField('numEducated', value)"
|
||||
/>
|
||||
|
||||
<div class="group">
|
||||
<p class="group-label">The fake</p>
|
||||
<LeverSlider
|
||||
label="Chance to forward"
|
||||
hint="baseline, per friendship"
|
||||
:value="base.forwardProb"
|
||||
:min="store.state.bounds?.forwardProb.min ?? 0"
|
||||
:max="store.state.bounds?.forwardProb.max ?? 1"
|
||||
:step="0.01"
|
||||
:display-value="formatPct(base.forwardProb * 100)"
|
||||
: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"
|
||||
:value="base.numEducated"
|
||||
:min="0"
|
||||
:max="base.numStudents"
|
||||
:step="1"
|
||||
:display-value="educationDisplay"
|
||||
: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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
115
web/src/components/OverlayCard.vue
Normal file
115
web/src/components/OverlayCard.vue
Normal 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>
|
||||
|
|
@ -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,30 +17,118 @@ 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 v-if="unreachable" class="unreachable" role="alert">
|
||||
<p>
|
||||
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
|
||||
</p>
|
||||
<button class="retry" type="button" @click="store.retry()">Retry</button>
|
||||
</div>
|
||||
<div v-else class="panels">
|
||||
<ScenarioPanel
|
||||
v-for="(panel, panelIndex) in store.state.panels"
|
||||
:key="panel.id"
|
||||
:panel="panel"
|
||||
:accent="accentForPanel(panelIndex)"
|
||||
/>
|
||||
<div ref="gridElement" class="panelgrid">
|
||||
<div v-if="unreachable" class="unreachable" role="alert">
|
||||
<p>
|
||||
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
|
||||
</p>
|
||||
<button class="retry" type="button" @click="store.retry()">Retry</button>
|
||||
</div>
|
||||
<div v-else class="panels">
|
||||
<ScenarioPanel
|
||||
v-for="(panel, panelIndex) in store.state.panels"
|
||||
:key="panel.id"
|
||||
: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>
|
||||
|
|
|
|||
97
web/src/components/PlaceholderPage.vue
Normal file
97
web/src/components/PlaceholderPage.vue
Normal 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>
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
51
web/src/composables/usePage.ts
Normal file
51
web/src/composables/usePage.ts
Normal 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 }
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue