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
188
web/src/App.vue
188
web/src/App.vue
|
|
@ -1,27 +1,36 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted } from 'vue'
|
import { onMounted, ref } from 'vue'
|
||||||
import AppBar from '@/components/AppBar.vue'
|
import AppBar from '@/components/AppBar.vue'
|
||||||
import ControlsCard from '@/components/ControlsCard.vue'
|
import ControlsCard from '@/components/ControlsCard.vue'
|
||||||
import ErrorBanner from '@/components/ErrorBanner.vue'
|
import ErrorBanner from '@/components/ErrorBanner.vue'
|
||||||
|
import ExportMenu from '@/components/ExportMenu.vue'
|
||||||
import FocusModal from '@/components/FocusModal.vue'
|
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 MobileScoreStrip from '@/components/MobileScoreStrip.vue'
|
||||||
import NodeTooltip from '@/components/NodeTooltip.vue'
|
import NodeTooltip from '@/components/NodeTooltip.vue'
|
||||||
|
import OverlayCard from '@/components/OverlayCard.vue'
|
||||||
import PanelGrid from '@/components/PanelGrid.vue'
|
import PanelGrid from '@/components/PanelGrid.vue'
|
||||||
|
import PlaceholderPage from '@/components/PlaceholderPage.vue'
|
||||||
import PlayerBar from '@/components/PlayerBar.vue'
|
import PlayerBar from '@/components/PlayerBar.vue'
|
||||||
import ReachChart from '@/components/ReachChart.vue'
|
import ReachChart from '@/components/ReachChart.vue'
|
||||||
import ResultsTable from '@/components/ResultsTable.vue'
|
import ResultsTable from '@/components/ResultsTable.vue'
|
||||||
import ScenarioToolbar from '@/components/ScenarioToolbar.vue'
|
import { usePage } from '@/composables/usePage'
|
||||||
import { usePlayback } from '@/composables/usePlayback'
|
import { usePlayback } from '@/composables/usePlayback'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { useTheme } from '@/composables/useTheme'
|
import { useTheme } from '@/composables/useTheme'
|
||||||
|
|
||||||
const store = useSimStore()
|
const store = useSimStore()
|
||||||
const playback = usePlayback()
|
const playback = usePlayback()
|
||||||
|
const { activePage, navigate } = usePage()
|
||||||
useTheme() // resolve and apply the theme before first paint of the app
|
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 () => {
|
onMounted(async () => {
|
||||||
await store.initialize()
|
await store.initialize()
|
||||||
playback.autoplayOnce()
|
playback.autoplayOnce()
|
||||||
|
|
@ -29,26 +38,59 @@ onMounted(async () => {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AppBar />
|
<div class="shell">
|
||||||
|
<AppBar :active-page="activePage" @navigate="navigate" />
|
||||||
<main class="page">
|
<main class="page">
|
||||||
|
<template v-if="activePage === 'model'">
|
||||||
<HeroHeadline />
|
<HeroHeadline />
|
||||||
<MobileScoreStrip />
|
<MobileScoreStrip />
|
||||||
<ScenarioToolbar />
|
|
||||||
<ErrorBanner />
|
<ErrorBanner />
|
||||||
<PanelGrid />
|
<div class="workbench">
|
||||||
<LegendRow />
|
<div class="top">
|
||||||
<PlayerBar />
|
<PanelGrid class="panels-fill" />
|
||||||
<div class="lower">
|
<aside class="sidebar">
|
||||||
<ReachChart />
|
|
||||||
<ControlsCard />
|
<ControlsCard />
|
||||||
|
</aside>
|
||||||
</div>
|
</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
|
<ResultsTable
|
||||||
|
embedded
|
||||||
:panels="store.state.panels"
|
:panels="store.state.panels"
|
||||||
:results-by-panel-id="store.state.resultsByPanelId"
|
:results-by-panel-id="store.state.resultsByPanelId"
|
||||||
:base="store.state.base"
|
:base="store.state.base"
|
||||||
/>
|
/>
|
||||||
</main>
|
</OverlayCard>
|
||||||
<FooterDisclaimer />
|
|
||||||
<FocusModal v-if="store.state.focusPanelId" />
|
<FocusModal v-if="store.state.focusPanelId" />
|
||||||
<NodeTooltip />
|
<NodeTooltip />
|
||||||
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
<div class="visually-hidden" aria-live="polite">{{ store.state.announcement }}</div>
|
||||||
|
|
@ -56,17 +98,123 @@ onMounted(async () => {
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.page {
|
.page {
|
||||||
max-width: 1240px;
|
max-width: 1320px;
|
||||||
margin: 0 auto;
|
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;
|
display: grid;
|
||||||
grid-template-columns: 7fr 5fr;
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
gap: 18px;
|
gap: 24px;
|
||||||
margin-top: 26px;
|
min-height: 0;
|
||||||
align-items: start;
|
}
|
||||||
|
|
||||||
|
.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) {
|
@media (max-width: 760px) {
|
||||||
|
|
@ -74,8 +222,8 @@ onMounted(async () => {
|
||||||
padding: 22px 16px;
|
padding: 22px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lower {
|
.sidebar {
|
||||||
grid-template-columns: 1fr;
|
margin-top: 22px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -97,7 +97,7 @@ function rerollWorld() {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<details class="adv" open>
|
<details class="adv">
|
||||||
<summary>
|
<summary>
|
||||||
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
||||||
Advanced
|
Advanced
|
||||||
|
|
@ -275,7 +275,8 @@ input[type='number'] {
|
||||||
color: var(--ink);
|
color: var(--ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
/* Single column when the controls container is narrow (sidebar / mobile). */
|
||||||
|
@container controls (max-width: 380px) {
|
||||||
.grid {
|
.grid {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,27 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref } from 'vue'
|
import { PAGE_TABS, type PageId } from '@/composables/usePage'
|
||||||
import AboutPopover from './AboutPopover.vue'
|
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
|
||||||
import { useTheme } from '@/composables/useTheme'
|
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 { theme, toggleTheme } = useTheme()
|
||||||
|
|
||||||
const aboutOpen = ref(false)
|
|
||||||
const badgeButton = ref<HTMLButtonElement | null>(null)
|
|
||||||
|
|
||||||
function closeAbout() {
|
|
||||||
aboutOpen.value = false
|
|
||||||
void nextTick(() => badgeButton.value?.focus())
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<header class="appbar">
|
<header class="appbar">
|
||||||
<div class="in">
|
<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">
|
<span class="glyph" aria-hidden="true">
|
||||||
<svg viewBox="0 0 24 24">
|
<svg viewBox="0 0 24 24">
|
||||||
<circle cx="6" cy="6" r="2.2" />
|
<circle cx="6" cy="6" r="2.2" />
|
||||||
|
|
@ -29,23 +31,22 @@ function closeAbout() {
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
spreadlab
|
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>
|
</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" />
|
<span class="grow" />
|
||||||
<button
|
<button
|
||||||
class="iconbtn"
|
class="iconbtn"
|
||||||
|
|
@ -90,13 +91,13 @@ function closeAbout() {
|
||||||
}
|
}
|
||||||
|
|
||||||
.in {
|
.in {
|
||||||
max-width: 1240px;
|
max-width: 1320px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 0 28px;
|
padding: 0 28px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wordmark {
|
.wordmark {
|
||||||
|
|
@ -106,6 +107,12 @@ function closeAbout() {
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
font-size: 15.5px;
|
font-size: 15.5px;
|
||||||
letter-spacing: -0.01em;
|
letter-spacing: -0.01em;
|
||||||
|
color: var(--ink);
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
padding: 0;
|
||||||
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.glyph {
|
.glyph {
|
||||||
|
|
@ -127,28 +134,34 @@ function closeAbout() {
|
||||||
stroke-linejoin: round;
|
stroke-linejoin: round;
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge-anchor {
|
.tabs {
|
||||||
position: relative;
|
display: flex;
|
||||||
display: inline-flex;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
gap: 2px;
|
||||||
font-size: 12.5px;
|
min-width: 0;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge svg.ic {
|
.tab {
|
||||||
width: 14px;
|
font-size: 13.5px;
|
||||||
height: 14px;
|
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 {
|
.grow {
|
||||||
|
|
@ -165,6 +178,7 @@ function closeAbout() {
|
||||||
display: grid;
|
display: grid;
|
||||||
place-items: center;
|
place-items: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
flex: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.iconbtn:hover {
|
.iconbtn:hover {
|
||||||
|
|
@ -174,10 +188,22 @@ function closeAbout() {
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
@media (max-width: 760px) {
|
||||||
.in {
|
.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;
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,10 @@ import LeverSlider from './LeverSlider.vue'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { formatPct } from '@/lib/format'
|
import { formatPct } from '@/lib/format'
|
||||||
|
|
||||||
// The world card (spec slice 6): two headline levers, the advanced grid,
|
// The world card (spec slice 6, extended 2026-06-18 for the literature
|
||||||
// and the inline spot for engine 400s. Lever changes hit the store
|
// 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.
|
// immediately and rerun every panel after the shared debounce.
|
||||||
|
|
||||||
const store = useSimStore()
|
const store = useSimStore()
|
||||||
|
|
@ -18,12 +20,15 @@ const educationDisplay = computed(() => {
|
||||||
return `${base.value.numEducated} · ${formatPct(share)}`
|
return `${base.value.numEducated} · ${formatPct(share)}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const offendsForwardProb = computed(
|
// The engine 400 names the offending JSON field; mark the matching lever.
|
||||||
() => store.state.validationError?.includes('forwardProb') ?? false,
|
const offends = (field: string) =>
|
||||||
)
|
computed(() => store.state.validationError?.includes(field) ?? false)
|
||||||
const offendsNumEducated = computed(
|
|
||||||
() => store.state.validationError?.includes('numEducated') ?? false,
|
const offendsForwardProb = offends('forwardProb')
|
||||||
)
|
const offendsNovelty = offends('novelty')
|
||||||
|
const offendsHarmAwareness = offends('harmAwareness')
|
||||||
|
const offendsNumEducated = offends('numEducated')
|
||||||
|
const offendsProgramEffect = offends('programEffect')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|
@ -32,9 +37,12 @@ const offendsNumEducated = computed(
|
||||||
<p v-if="store.state.validationError" class="invalid-note" role="alert">
|
<p v-if="store.state.validationError" class="invalid-note" role="alert">
|
||||||
{{ store.state.validationError }}
|
{{ store.state.validationError }}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="group">
|
||||||
|
<p class="group-label">The fake</p>
|
||||||
<LeverSlider
|
<LeverSlider
|
||||||
label="Chance to forward"
|
label="Chance to forward"
|
||||||
hint="per friendship, per round"
|
hint="baseline, per friendship"
|
||||||
:value="base.forwardProb"
|
:value="base.forwardProb"
|
||||||
:min="store.state.bounds?.forwardProb.min ?? 0"
|
:min="store.state.bounds?.forwardProb.min ?? 0"
|
||||||
:max="store.state.bounds?.forwardProb.max ?? 1"
|
:max="store.state.bounds?.forwardProb.max ?? 1"
|
||||||
|
|
@ -43,6 +51,36 @@ const offendsNumEducated = computed(
|
||||||
:invalid="offendsForwardProb"
|
:invalid="offendsForwardProb"
|
||||||
@update="(value) => store.setBaseField('forwardProb', value)"
|
@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
|
<LeverSlider
|
||||||
label="Education budget"
|
label="Education budget"
|
||||||
hint="students the program reaches"
|
hint="students the program reaches"
|
||||||
|
|
@ -54,6 +92,19 @@ const offendsNumEducated = computed(
|
||||||
:invalid="offendsNumEducated"
|
:invalid="offendsNumEducated"
|
||||||
@update="(value) => store.setBaseField('numEducated', value)"
|
@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 />
|
<AdvancedFields />
|
||||||
</section>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -65,6 +116,10 @@ const offendsNumEducated = computed(
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
box-shadow: var(--shadow);
|
box-shadow: var(--shadow);
|
||||||
padding: 20px 22px;
|
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 {
|
h3 {
|
||||||
|
|
@ -75,6 +130,19 @@ h3 {
|
||||||
color: var(--ink-4);
|
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 {
|
.invalid-note {
|
||||||
margin-top: 8px;
|
margin-top: 8px;
|
||||||
font-size: 12.5px;
|
font-size: 12.5px;
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ function closeAbout() {
|
||||||
<template v-else>{{ segment.text }}</template>
|
<template v-else>{{ segment.text }}</template>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
<span class="mobile-note">
|
<span class="disclaimer-note">
|
||||||
<button
|
<button
|
||||||
ref="noteButton"
|
ref="noteButton"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -103,20 +103,7 @@ p b.pending {
|
||||||
color: var(--ink-4);
|
color: var(--ink-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-note {
|
.disclaimer-note {
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
|
||||||
h1 {
|
|
||||||
font-size: 22px;
|
|
||||||
}
|
|
||||||
|
|
||||||
p {
|
|
||||||
font-size: 15px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile-note {
|
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-top: 10px;
|
margin-top: 10px;
|
||||||
|
|
@ -138,5 +125,14 @@ p b.pending {
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 760px) {
|
||||||
|
h1 {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -142,9 +142,13 @@ input[type='range']::-moz-range-thumb {
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.2);
|
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 {
|
.row {
|
||||||
grid-template-columns: 1fr 78px;
|
grid-template-columns: 1fr 78px;
|
||||||
|
gap: 8px 14px;
|
||||||
|
margin-top: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
label {
|
label {
|
||||||
|
|
|
||||||
|
|
@ -166,6 +166,8 @@ const popDurationMs = computed(() => (ROUND_MS / store.state.speed) * 0.3)
|
||||||
height: auto;
|
height: auto;
|
||||||
display: block;
|
display: block;
|
||||||
margin-top: 4px;
|
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
|
/* A graph-seed reroll remounts the svg (keyed by graph hash); the new
|
||||||
layout fades in (spec 5.3). Skipped under reduced motion globally. */
|
layout fades in (spec 5.3). Skipped under reduced motion globally. */
|
||||||
animation: layout-fade 200ms ease-out;
|
animation: layout-fade 200ms ease-out;
|
||||||
|
|
@ -205,4 +207,12 @@ const popDurationMs = computed(() => (ROUND_MS / store.state.speed) * 0.3)
|
||||||
opacity: 1;
|
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>
|
</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">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import ScenarioPanel from './ScenarioPanel.vue'
|
import ScenarioPanel from './ScenarioPanel.vue'
|
||||||
import { useSimStore } from '@/composables/useSimStore'
|
import { useSimStore } from '@/composables/useSimStore'
|
||||||
import { accentForPanel } from '@/lib/accents'
|
import { accentForPanel } from '@/lib/accents'
|
||||||
|
import { MAX_PANELS } from '@/presets/types'
|
||||||
|
|
||||||
const store = useSimStore()
|
const store = useSimStore()
|
||||||
|
|
||||||
|
|
@ -16,9 +17,50 @@ const unreachable = computed(
|
||||||
)
|
)
|
||||||
|
|
||||||
const apiBaseUrl = `${window.location.origin}/api`
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<div ref="gridElement" class="panelgrid">
|
||||||
<div v-if="unreachable" class="unreachable" role="alert">
|
<div v-if="unreachable" class="unreachable" role="alert">
|
||||||
<p>
|
<p>
|
||||||
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
|
Could not reach the spreadlab API at <code>{{ apiBaseUrl }}</code>
|
||||||
|
|
@ -32,14 +74,61 @@ const apiBaseUrl = `${window.location.origin}/api`
|
||||||
:panel="panel"
|
:panel="panel"
|
||||||
:accent="accentForPanel(panelIndex)"
|
: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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.panelgrid {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.panels {
|
.panels {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
grid-template-columns: repeat(2, 1fr);
|
||||||
gap: 18px;
|
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 {
|
.unreachable {
|
||||||
|
|
@ -72,10 +161,27 @@ const apiBaseUrl = `${window.location.origin}/api`
|
||||||
cursor: pointer;
|
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) {
|
@media (max-width: 760px) {
|
||||||
.panels {
|
.panels {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
|
grid-auto-rows: auto;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.add-tile {
|
||||||
|
min-height: 120px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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
|
// 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.
|
// 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 store = useSimStore()
|
||||||
const playback = usePlayback()
|
const playback = usePlayback()
|
||||||
|
|
@ -76,7 +79,7 @@ onBeforeUnmount(() => {
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="playerwrap">
|
<div class="playerwrap">
|
||||||
<div class="player">
|
<div class="player" :class="{ wide }">
|
||||||
<button
|
<button
|
||||||
class="iconbtn"
|
class="iconbtn"
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -178,6 +181,12 @@ onBeforeUnmount(() => {
|
||||||
width: min(680px, 100%);
|
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 {
|
.iconbtn {
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,10 @@ const props = defineProps<{
|
||||||
panels: PanelSpec[]
|
panels: PanelSpec[]
|
||||||
resultsByPanelId: Record<string, Result>
|
resultsByPanelId: Record<string, Result>
|
||||||
base: Config
|
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 {
|
function numStudentsFor(panel: PanelSpec): number {
|
||||||
|
|
@ -20,8 +24,13 @@ function numStudentsFor(panel: PanelSpec): number {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<details class="results">
|
<component
|
||||||
<summary>
|
: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>
|
<svg class="ic chevron" viewBox="0 0 24 24" aria-hidden="true"><path d="M9 6l6 6-6 6" /></svg>
|
||||||
Data table
|
Data table
|
||||||
</summary>
|
</summary>
|
||||||
|
|
@ -53,7 +62,7 @@ function numStudentsFor(panel: PanelSpec): number {
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</details>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -61,6 +70,15 @@ function numStudentsFor(panel: PanelSpec): number {
|
||||||
margin-top: 26px;
|
margin-top: 26px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.results.embedded {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.results.embedded table {
|
||||||
|
margin-top: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
summary {
|
summary {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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) {
|
@media (max-width: 760px) {
|
||||||
/* 44 px touch target for the kebab (spec section 8). */
|
/* 44 px touch target for the kebab (spec section 8). */
|
||||||
.kebab {
|
.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