web: UI polish pass (layout, seeds, reroll, fit)

- Graph layout: fix the reroll-vs-reload determinism bug (a reroll requested
  the layout before the new edges arrived, caching a link-less uniform circle;
  only cache once edges are present). Retune to a slightly-wide organic blob
  in a 340x270 viewBox so it fills the square-ish cells without flat ovals.
- Reroll world: moved out of Advanced into a button in the WORLD card header;
  any seed reroll (header or per-seed dice) now replays the spread from round 0
  (pendingReplay flag in the store, watched by playback). freshSeed extracted
  to lib/seed.ts.
- Seeds: separated from the real quantities under a 'Seeds' header ('labels for
  a random world, not counts') with a # prefix so a value reads as an id.
- Fit: grid rows subtract the container padding so the 2x2 fits exactly and the
  bottom card shadows are no longer clipped; the settings card absolutely fills
  the sidebar so the graph grid defines the height and the card scrolls
  internally instead of growing taller than the graphs.
This commit is contained in:
Justin Visser 2026-06-18 23:01:47 +02:00
parent 6b2cbd5728
commit 318dca3423
8 changed files with 180 additions and 65 deletions

View file

@ -163,15 +163,15 @@ onMounted(async () => {
grid-column: 1; grid-column: 1;
grid-row: 1; grid-row: 1;
min-height: 0; min-height: 0;
display: flex; position: relative;
flex-direction: column;
} }
/* The settings card fills the sidebar so its height matches the two-row /* The card absolutely fills the sidebar, so its own content never grows the
graph grid; it scrolls internally only if its own content is taller. */ row: the graph grid defines the height, the sidebar matches it, and the
card scrolls internally if its content (e.g. Advanced expanded) is taller. */
.sidebar :deep(.card) { .sidebar :deep(.card) {
flex: 1; position: absolute;
min-height: 0; inset: 0;
overflow-y: auto; overflow-y: auto;
} }

View file

@ -79,21 +79,6 @@ function setField(field: keyof Config, event: Event) {
if (Number.isFinite(nextValue)) store.setBaseField(field, nextValue) if (Number.isFinite(nextValue)) store.setBaseField(field, nextValue)
} }
// Seeds are just names for worlds; a small range keeps the fields and
// shared URLs readable while still giving plenty of distinct worlds.
function freshSeed(): number {
const buffer = new Uint32Array(1)
crypto.getRandomValues(buffer)
return (buffer[0] ?? 0) % 10000
}
function reroll(field: keyof Config) {
store.setBaseField(field, freshSeed())
}
function rerollWorld() {
for (const { field } of seedFields) store.setBaseField(field, freshSeed())
}
</script> </script>
<template> <template>
@ -120,6 +105,13 @@ function rerollWorld() {
@change="setField(def.field, $event)" @change="setField(def.field, $event)"
/> />
</label> </label>
</div>
<div class="seeds-head">
<span class="seeds-title">Seeds</span>
<span class="seeds-note">labels for a random world, not counts</span>
</div>
<div class="grid">
<span <span
v-for="def in seedFields" v-for="def in seedFields"
:key="def.field" :key="def.field"
@ -128,20 +120,23 @@ function rerollWorld() {
> >
<label class="seed"> <label class="seed">
<span class="fl">{{ def.label }}</span> <span class="fl">{{ def.label }}</span>
<input <span class="seedval">
type="number" <span class="hash" aria-hidden="true">#</span>
min="0" <input
step="1" type="number"
:value="store.state.base[def.field]" min="0"
:aria-invalid="offendingField === def.field || undefined" step="1"
@change="setField(def.field, $event)" :value="store.state.base[def.field]"
/> :aria-invalid="offendingField === def.field || undefined"
@change="setField(def.field, $event)"
/>
</span>
</label> </label>
<button <button
class="dice" class="dice"
type="button" type="button"
:aria-label="`Reroll ${def.label}`" :aria-label="`Reroll ${def.label}`"
@click="reroll(def.field)" @click="store.rerollSeed(def.field)"
> >
<svg class="ic" viewBox="0 0 24 24"> <svg class="ic" viewBox="0 0 24 24">
<rect x="4" y="4" width="16" height="16" rx="3" /> <rect x="4" y="4" width="16" height="16" rx="3" />
@ -150,7 +145,6 @@ function rerollWorld() {
</button> </button>
</span> </span>
</div> </div>
<button class="reroll-world" type="button" @click="rerollWorld()">Reroll world</button>
</details> </details>
</template> </template>
@ -226,6 +220,44 @@ details[open] .chevron {
min-width: 0; min-width: 0;
} }
.seeds-head {
display: flex;
align-items: baseline;
flex-wrap: wrap;
gap: 8px;
margin-top: 18px;
}
.seeds-title {
font-size: 12px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-4);
}
.seeds-note {
font-size: 11.5px;
font-weight: 400;
color: var(--ink-4);
}
.seedval {
display: flex;
align-items: center;
gap: 3px;
}
.hash {
color: var(--ink-4);
font-weight: 600;
font-size: 13px;
}
.seedval input[type='number'] {
width: 52px;
}
input[type='number'] { input[type='number'] {
width: 72px; width: 72px;
font: 600 13px/1.4 inherit; font: 600 13px/1.4 inherit;
@ -258,23 +290,6 @@ input[type='number'] {
height: 14px; height: 14px;
} }
.reroll-world {
margin-top: 12px;
font-size: 12.5px;
font-weight: 600;
color: var(--ink-3);
background: none;
border: 1px solid var(--border);
border-radius: 8px;
padding: 5px 12px;
cursor: pointer;
}
.reroll-world:hover {
border-color: var(--ink-4);
color: var(--ink);
}
/* Single column when the controls container is narrow (sidebar / mobile). */ /* Single column when the controls container is narrow (sidebar / mobile). */
@container controls (max-width: 380px) { @container controls (max-width: 380px) {
.grid { .grid {

View file

@ -33,7 +33,16 @@ const offendsProgramEffect = offends('programEffect')
<template> <template>
<section class="card ctl" aria-label="World settings"> <section class="card ctl" aria-label="World settings">
<h3>World</h3> <div class="ctl-head">
<h3>World</h3>
<button class="reroll" type="button" title="Draw a new random world" @click="store.rerollSeeds()">
<svg class="ic" viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="16" height="16" rx="3" />
<path d="M9 9h.01M15 15h.01M15 9h.01M9 15h.01" />
</svg>
Reroll world
</button>
</div>
<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>
@ -122,6 +131,13 @@ const offendsProgramEffect = offends('programEffect')
container-name: controls; container-name: controls;
} }
.ctl-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
h3 { h3 {
font-size: 13px; font-size: 13px;
font-weight: 600; font-weight: 600;
@ -130,6 +146,35 @@ h3 {
color: var(--ink-4); color: var(--ink-4);
} }
.reroll {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 12px;
font-weight: 600;
color: var(--ink-3);
background: var(--bg);
border: 1px solid var(--border);
border-radius: 999px;
padding: 4px 11px;
cursor: pointer;
}
.reroll:hover {
border-color: var(--ink-4);
color: var(--ink);
}
.reroll svg.ic {
width: 13px;
height: 13px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.group + .group { .group + .group {
margin-top: 22px; margin-top: 22px;
border-top: 1px solid var(--border-soft); border-top: 1px solid var(--border-soft);

View file

@ -40,7 +40,12 @@ function syncRowHeight() {
const element = gridElement.value const element = gridElement.value
if (!element) return if (!element) return
if (oneScreen.matches) { if (oneScreen.matches) {
const rowHeight = Math.max(150, Math.floor((element.clientHeight - ROW_GAP_PX) / 2)) // clientHeight includes the container's padding (which gives the card
// shadows room); subtract it so two rows fit the content area exactly.
const style = getComputedStyle(element)
const padV = parseFloat(style.paddingTop) + parseFloat(style.paddingBottom)
const available = element.clientHeight - padV
const rowHeight = Math.max(150, Math.floor((available - ROW_GAP_PX) / 2))
element.style.setProperty('--row-h', `${rowHeight}px`) element.style.setProperty('--row-h', `${rowHeight}px`)
} else { } else {
element.style.removeProperty('--row-h') element.style.removeProperty('--row-h')
@ -166,9 +171,10 @@ onBeforeUnmount(() => {
@media (min-width: 761px) { @media (min-width: 761px) {
.panelgrid { .panelgrid {
overflow-y: auto; overflow-y: auto;
/* Reserve room so the scrollbar (overlay or classic) never sits over the /* Room so the card shadows are not clipped at the scroll edges; the right
right column of graphs when a 5th scenario makes the grid scroll. */ also leaves the scrollbar gutter. syncRowHeight subtracts this padding
padding-right: 14px; so the 2x2 still fits exactly. */
padding: 4px 14px 14px 6px;
scrollbar-gutter: stable; scrollbar-gutter: stable;
} }
} }

View file

@ -16,8 +16,8 @@ export interface LayoutPoint {
y: number y: number
} }
export const NETWORK_VIEW_WIDTH = 380 export const NETWORK_VIEW_WIDTH = 340
export const NETWORK_VIEW_HEIGHT = 230 export const NETWORK_VIEW_HEIGHT = 270
const LAYOUT_PADDING = 14 const LAYOUT_PADDING = 14
const LAYOUT_TICKS = 300 const LAYOUT_TICKS = 300
@ -38,29 +38,31 @@ export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[]
const random = mulberry32(Number(config.graphSeed) >>> 0) const random = mulberry32(Number(config.graphSeed) >>> 0)
const nodes: LayoutNode[] = Array.from({ length: config.numStudents }, (_, index) => { const nodes: LayoutNode[] = Array.from({ length: config.numStudents }, (_, index) => {
// Seeded start positions in a wide disc; the forces do the rest. // Seeded start positions in a balanced disc; the forces do the rest.
const angle = random() * Math.PI * 2 const angle = random() * Math.PI * 2
const radius = Math.sqrt(random()) const radius = Math.sqrt(random())
return { index, x: Math.cos(angle) * radius * 150, y: Math.sin(angle) * radius * 60 } return { index, x: Math.cos(angle) * radius * 120, y: Math.sin(angle) * radius * 90 }
}) })
const links: SimulationLinkDatum<LayoutNode>[] = edges.map((edge) => ({ const links: SimulationLinkDatum<LayoutNode>[] = edges.map((edge) => ({
source: edge[0] ?? 0, source: edge[0] ?? 0,
target: edge[1] ?? 0, target: edge[1] ?? 0,
})) }))
// The vertical pull is stronger than the horizontal one, so the cloud // A gentle horizontal pull with a stronger vertical one settles the cloud
// settles into the panel's wide ellipse by itself; fitting then scales // into a slightly wide blob (a softer version of the original ellipse) while
// both axes uniformly, which keeps the spacing organic (anisotropic // staying organic: its hubs and clusters show, not a uniform disc. The
// stretching reads as line patterns). // charge, link and collision forces do the structural work; fitting then
// scales both axes uniformly, so the spacing stays organic (anisotropic
// stretching would read as line patterns).
forceSimulation(nodes) forceSimulation(nodes)
.randomSource(random) .randomSource(random)
.force( .force(
'link', 'link',
forceLink<LayoutNode, SimulationLinkDatum<LayoutNode>>(links).distance(16).strength(0.3), forceLink<LayoutNode, SimulationLinkDatum<LayoutNode>>(links).distance(16).strength(0.3),
) )
.force('charge', forceManyBody().strength(-15)) .force('charge', forceManyBody().strength(-16))
.force('x', forceX(0).strength(0.028)) .force('x', forceX(0).strength(0.04))
.force('y', forceY(0).strength(0.22)) .force('y', forceY(0).strength(0.085))
.force('collide', forceCollide(5.5)) .force('collide', forceCollide(5.5))
.stop() .stop()
.tick(LAYOUT_TICKS) .tick(LAYOUT_TICKS)
@ -82,6 +84,10 @@ export function layoutForGraph(config: Config, edges: number[][]): LayoutPoint[]
x: offsetX + ((node.x ?? 0) - minX) * scale, x: offsetX + ((node.x ?? 0) - minX) * scale,
y: offsetY + ((node.y ?? 0) - minY) * scale, y: offsetY + ((node.y ?? 0) - minY) * scale,
})) }))
layoutCache.set(cacheKey, points) // Only cache once the real edges are in. A reroll changes the config (and
// this cache key) immediately, before the new edges arrive from the API;
// computing then would cache a link-less, uniform-circle layout under this
// key and never recompute it. Without edges, recompute next time instead.
if (edges.length > 0) layoutCache.set(cacheKey, points)
return points return points
} }

View file

@ -1,3 +1,4 @@
import { watch } from 'vue'
import { useSimStore, type SimStore } from './useSimStore' import { useSimStore, type SimStore } from './useSimStore'
// Playback (spec 5.4): one global round drives every panel, the chart // Playback (spec 5.4): one global round drives every panel, the chart
@ -142,6 +143,21 @@ export function createPlayback(store: SimStore = useSimStore()) {
play() play()
} }
// A reroll (rerollSeed / rerollSeeds) asks for a fresh playthrough: once its
// debounced run lands (running -> idle), restart the spread from round 0.
watch(
() => state.runState,
(now, before) => {
if (!state.pendingReplay) return
if (now === 'idle' && before === 'running') {
state.pendingReplay = false
replay()
} else if (now === 'error') {
state.pendingReplay = false
}
},
)
return { return {
play, play,
pause, pause,

View file

@ -3,6 +3,7 @@ import { ApiError, fetchDefaultConfig, runScenario } from '@/lib/api'
import { clampConfig, clampConfigField } from '@/lib/bounds' import { clampConfig, clampConfigField } from '@/lib/bounds'
import { roundPct } from '@/lib/format' import { roundPct } from '@/lib/format'
import { graphKey } from '@/lib/graph' import { graphKey } from '@/lib/graph'
import { freshSeed } from '@/lib/seed'
import { parseUrlState, serializeUrlState } from '@/lib/urlState' import { parseUrlState, serializeUrlState } from '@/lib/urlState'
import { deepfakeSchoolPreset } from '@/presets/deepfake-school' import { deepfakeSchoolPreset } from '@/presets/deepfake-school'
import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types' import { MAX_PANELS, type PanelSpec, type StudyPreset } from '@/presets/types'
@ -44,6 +45,7 @@ export interface SimState {
validationError: string | null // 400 from the engine, shown inline under the controls validationError: string | null // 400 from the engine, shown inline under the controls
urlStateInvalid: boolean // the opened link held malformed state; preset shown instead urlStateInvalid: boolean // the opened link held malformed state; preset shown instead
announcement: string // text for the single polite live region (spec section 8) announcement: string // text for the single polite live region (spec section 8)
pendingReplay: boolean // a reroll asked playback to restart from round 0 once the run lands
} }
function createPanelId(): string { function createPanelId(): string {
@ -74,6 +76,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
validationError: null, validationError: null,
urlStateInvalid: false, urlStateInvalid: false,
announcement: '', announcement: '',
pendingReplay: false,
}) })
let debounceTimer: ReturnType<typeof setTimeout> | null = null let debounceTimer: ReturnType<typeof setTimeout> | null = null
@ -195,6 +198,20 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
scheduleRun() scheduleRun()
} }
// Seeds name the random world. Rerolling one (or all) draws fresh values
// and asks playback to replay the spread from round 0 once the rerun lands.
const SEED_FIELDS = ['graphSeed', 'thresholdSeed', 'educationSeed'] as const
function rerollSeed(field: keyof Config) {
state.pendingReplay = true
setBaseField(field, freshSeed())
}
function rerollSeeds() {
state.pendingReplay = true
for (const field of SEED_FIELDS) setBaseField(field, freshSeed())
}
// Re-clamp the base config and every panel override; needed when // Re-clamp the base config and every panel override; needed when
// numStudents changes (the relational ceilings move) and when the // numStudents changes (the relational ceilings move) and when the
// bounds first arrive while the URL may have carried wild values. // bounds first arrive while the URL may have carried wild values.
@ -351,6 +368,8 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
initialize, initialize,
retry, retry,
setBaseField, setBaseField,
rerollSeed,
rerollSeeds,
addPanel, addPanel,
duplicatePanel, duplicatePanel,
removePanel, removePanel,

8
web/src/lib/seed.ts Normal file
View file

@ -0,0 +1,8 @@
// Seeds are just names for a random world. A small range (0-9999) keeps the
// fields and shared URLs readable while still giving plenty of distinct
// worlds (amended 2026-06-10: a full uint32 made fields and URLs unreadable).
export function freshSeed(): number {
const buffer = new Uint32Array(1)
crypto.getRandomValues(buffer)
return (buffer[0] ?? 0) % 10000
}