web: panel management, slice 7 of M3

The kebab menu goes live: Edit and Rename open the PanelEditorPopover
(rename focuses the label in its header), Duplicate copies with a
' copy' suffix and the next accent, Remove deletes immediately and is
disabled on the last panel. Add scenario opens the new panel's editor
right away, still capped at six.

The editor offers the strategy select plus every Config field as a row
against the base value: a differing value becomes an override
(highlighted row, per-field reset, chips on the card), typing the base
value back clears it, and runs touch only that panel through the
debounce. Engine 400s appear inline in the popover with the offending
field marked.

Per-panel failures now leave a rose dot on the kebab while the panel
keeps its last good result; the store tracks failed panel ids and
clears them on the next successful run. Field and strategy labels moved
to lib/fieldLabels so controls, editor, and table share one vocabulary.
This commit is contained in:
Justin Visser 2026-06-10 17:51:21 +02:00
parent fdc28ac686
commit eb1f7427c4
9 changed files with 545 additions and 20 deletions

View file

@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useSimStore } from '@/composables/useSimStore'
import { CONFIG_FIELD_LABELS } from '@/lib/fieldLabels'
import type { Config } from '@/types/engine'
// The collapsible advanced grid (spec section 4): world-shaping numbers
@ -17,10 +18,10 @@ interface NumberFieldDef {
}
const numberFields: NumberFieldDef[] = [
{ field: 'numStudents', label: 'Students', min: 2, step: 1 },
{ field: 'edgesPerNode', label: 'Friends per student', min: 1, step: 1 },
{ field: 'triangleProb', label: 'Clique tendency', min: 0, step: 0.05 },
{ field: 'origin', label: 'First poster', min: 0, step: 1 },
{ field: 'numStudents', label: CONFIG_FIELD_LABELS.numStudents, min: 2, step: 1 },
{ field: 'edgesPerNode', label: CONFIG_FIELD_LABELS.edgesPerNode, min: 1, step: 1 },
{ field: 'triangleProb', label: CONFIG_FIELD_LABELS.triangleProb, min: 0, step: 0.05 },
{ field: 'origin', label: CONFIG_FIELD_LABELS.origin, min: 0, step: 1 },
]
interface SeedFieldDef {
@ -29,9 +30,9 @@ interface SeedFieldDef {
}
const seedFields: SeedFieldDef[] = [
{ field: 'graphSeed', label: 'Friendship network' },
{ field: 'thresholdSeed', label: 'Who resists' },
{ field: 'educationSeed', label: 'Random picks' },
{ field: 'graphSeed', label: CONFIG_FIELD_LABELS.graphSeed },
{ field: 'thresholdSeed', label: CONFIG_FIELD_LABELS.thresholdSeed },
{ field: 'educationSeed', label: CONFIG_FIELD_LABELS.educationSeed },
]
// The engine's 400 message names the offending Go/JSON field; mark it.

View file

@ -0,0 +1,297 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { useSimStore } from '@/composables/useSimStore'
import { CONFIG_FIELD_LABELS, STRATEGY_LABELS } from '@/lib/fieldLabels'
import type { PanelSpec } from '@/presets/types'
import type { Config } from '@/types/engine'
// The per-panel config diff editor (spec 5.7): the label is renamed
// inline in the header, the strategy is a select, and every Config field
// is a row showing the base value. Typing a different value creates an
// override (highlighted row, reset icon); typing the base value back
// removes it. Apply follows the store's debounced single-panel rerun.
const props = defineProps<{ panel: PanelSpec; focusLabel?: boolean }>()
const emit = defineEmits<{ close: [] }>()
const store = useSimStore()
const popover = ref<HTMLElement | null>(null)
const labelInput = ref<HTMLInputElement | null>(null)
const fieldNames = Object.keys(store.state.base) as (keyof Config)[]
const offendingField = computed(() => {
const validationError = store.state.validationError
if (!validationError) return null
return fieldNames.find((field) => validationError.includes(field)) ?? null
})
function isOverridden(field: keyof Config): boolean {
return field in props.panel.overrides
}
function effectiveValue(field: keyof Config): number {
return props.panel.overrides[field] ?? store.state.base[field]
}
function setOverride(field: keyof Config, event: Event) {
const nextValue = (event.target as HTMLInputElement).valueAsNumber
if (Number.isFinite(nextValue)) store.setPanelOverride(props.panel.id, field, nextValue)
}
function rename(event: Event) {
const label = (event.target as HTMLInputElement).value.trim()
if (label) store.renamePanel(props.panel.id, label)
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') emit('close')
}
function onPointerDown(event: PointerEvent) {
if (popover.value?.contains(event.target as Node)) return
emit('close')
}
onMounted(() => {
if (props.focusLabel) {
labelInput.value?.focus()
labelInput.value?.select()
} else {
popover.value?.querySelector('select')?.focus()
}
document.addEventListener('keydown', onKeydown)
document.addEventListener('pointerdown', onPointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeydown)
document.removeEventListener('pointerdown', onPointerDown, true)
})
</script>
<template>
<div
ref="popover"
class="editor"
role="dialog"
:aria-label="`Edit scenario ${panel.label}`"
tabindex="-1"
>
<div class="head">
<input
ref="labelInput"
class="label-input"
type="text"
:value="panel.label"
aria-label="Scenario name"
@change="rename"
/>
<button class="close" type="button" aria-label="Close editor" @click="emit('close')">
<svg class="ic" viewBox="0 0 24 24"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<p v-if="store.state.validationError" class="invalid-note" role="alert">
{{ store.state.validationError }}
</p>
<label class="row strategy">
<span class="rl">Strategy</span>
<select
:value="panel.strategy"
@change="store.setPanelStrategy(panel.id, ($event.target as HTMLSelectElement).value)"
>
<option v-for="(label, strategy) in STRATEGY_LABELS" :key="strategy" :value="strategy">
{{ label }}
</option>
</select>
</label>
<div class="rows">
<div
v-for="field in fieldNames"
:key="field"
class="row"
:class="{ overridden: isOverridden(field), invalid: offendingField === field }"
>
<label class="rl" :for="`${panel.id}-${field}`">{{ CONFIG_FIELD_LABELS[field] }}</label>
<span class="base" :class="{ hidden: !isOverridden(field) }"
>base {{ store.state.base[field] }}</span
>
<input
:id="`${panel.id}-${field}`"
type="number"
:value="effectiveValue(field)"
:aria-invalid="offendingField === field || undefined"
@change="setOverride(field, $event)"
/>
<button
v-if="isOverridden(field)"
class="reset"
type="button"
:aria-label="`Reset ${CONFIG_FIELD_LABELS[field]} to the base value`"
@click="store.clearPanelOverride(panel.id, field)"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M3 12a9 9 0 1 0 2.6-6.3M5 3v4h4" /></svg>
</button>
<span v-else class="reset-spacer" aria-hidden="true" />
</div>
</div>
</div>
</template>
<style scoped>
.editor {
position: absolute;
top: 44px;
right: 12px;
z-index: 25;
width: min(300px, calc(100% - 24px));
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow-lift);
padding: 12px 14px 14px;
}
.head {
display: flex;
align-items: center;
gap: 8px;
}
.label-input {
flex: 1;
min-width: 0;
font: 600 13.5px/1.4 inherit;
font-family: inherit;
color: var(--ink);
background: var(--bg);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 5px 9px;
}
.close {
width: 28px;
height: 28px;
border: none;
background: none;
border-radius: 8px;
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: 14px;
height: 14px;
}
.invalid-note {
margin-top: 8px;
font-size: 12px;
font-weight: 500;
color: var(--spread);
}
.rows {
margin-top: 4px;
}
.row {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 6px;
border-radius: 8px;
font-size: 12.5px;
}
.row.strategy {
margin-top: 10px;
padding: 5px 6px;
}
.row.overridden {
background: var(--edu-soft);
}
.row.invalid {
outline: 1px solid var(--spread);
}
.rl {
flex: 1;
min-width: 0;
color: var(--ink-3);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.base {
font-size: 11px;
color: var(--ink-4);
white-space: nowrap;
}
.base.hidden {
visibility: hidden;
}
select {
font: 600 12.5px/1.4 inherit;
font-family: inherit;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 4px 8px;
}
input[type='number'] {
width: 72px;
font: 600 12.5px/1.4 inherit;
font-family: inherit;
color: var(--ink-2);
background: var(--bg);
border: 1px solid var(--border-soft);
border-radius: 8px;
padding: 4px 8px;
text-align: right;
}
.reset {
width: 22px;
height: 22px;
border: none;
background: none;
border-radius: 6px;
color: var(--edu);
display: grid;
place-items: center;
cursor: pointer;
flex: none;
}
.reset svg.ic {
width: 12px;
height: 12px;
}
.reset-spacer {
width: 22px;
flex: none;
}
</style>

View file

@ -0,0 +1,107 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
// The kebab dropdown (spec 5.7). Items come from the panel; the menu
// handles roving focus (arrows), Esc, and outside clicks. The opener
// restores focus on close.
export interface PanelMenuItem {
key: string
label: string
disabled?: boolean
}
const props = defineProps<{ items: PanelMenuItem[]; anchor: HTMLElement | null }>()
const emit = defineEmits<{ select: [key: string]; close: [] }>()
const menu = ref<HTMLElement | null>(null)
function menuButtons(): HTMLButtonElement[] {
return Array.from(menu.value?.querySelectorAll('button:not(:disabled)') ?? [])
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
emit('close')
return
}
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return
event.preventDefault()
const buttons = menuButtons()
if (buttons.length === 0) return
const activeIndex = buttons.findIndex((button) => button === document.activeElement)
const offset = event.key === 'ArrowDown' ? 1 : -1
const nextIndex = (activeIndex + offset + buttons.length) % buttons.length
buttons[nextIndex]?.focus()
}
function onPointerDown(event: PointerEvent) {
const target = event.target as Node
if (menu.value?.contains(target)) return
if (props.anchor?.contains(target)) return
emit('close')
}
onMounted(() => {
menuButtons()[0]?.focus()
document.addEventListener('pointerdown', onPointerDown, true)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onPointerDown, true)
})
</script>
<template>
<div ref="menu" class="menu" role="menu" @keydown="onKeydown">
<button
v-for="item in items"
:key="item.key"
type="button"
role="menuitem"
:disabled="item.disabled"
@click="emit('select', item.key)"
>
{{ item.label }}
</button>
</div>
</template>
<style scoped>
.menu {
position: absolute;
top: calc(100% + 6px);
right: 0;
z-index: 25;
min-width: 150px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow-lift);
padding: 5px;
display: flex;
flex-direction: column;
}
button {
text-align: left;
font-size: 13.5px;
font-weight: 500;
color: var(--ink-2);
background: none;
border: none;
border-radius: 8px;
padding: 7px 10px;
cursor: pointer;
}
button:hover:not(:disabled) {
background: var(--bg);
color: var(--ink);
}
button:disabled {
color: var(--ink-4);
cursor: default;
}
</style>

View file

@ -1,4 +1,5 @@
<script setup lang="ts">
import { STRATEGY_LABELS } from '@/lib/fieldLabels'
import { formatPct } from '@/lib/format'
import type { PanelSpec } from '@/presets/types'
import type { Config, Result } from '@/types/engine'
@ -13,12 +14,6 @@ const props = defineProps<{
base: Config
}>()
const strategyLabels: Record<string, string> = {
none: 'None',
random: 'Random',
'most-connected': 'Most connected',
}
function numStudentsFor(panel: PanelSpec): number {
return panel.overrides.numStudents ?? props.base.numStudents
}
@ -48,7 +43,7 @@ function numStudentsFor(panel: PanelSpec): number {
<tr v-for="panel in panels" :key="panel.id">
<th scope="row">{{ panel.label }}</th>
<template v-if="resultsByPanelId[panel.id]">
<td>{{ strategyLabels[panel.strategy] ?? panel.strategy }}</td>
<td>{{ STRATEGY_LABELS[panel.strategy] ?? panel.strategy }}</td>
<td>{{ resultsByPanelId[panel.id]!.educated.length }}</td>
<td>{{ resultsByPanelId[panel.id]!.numReached }} / {{ numStudentsFor(panel) }}</td>
<td>{{ formatPct(resultsByPanelId[panel.id]!.reachedPct) }}</td>

View file

@ -1,15 +1,59 @@
<script setup lang="ts">
import { computed } from 'vue'
import { computed, ref } from 'vue'
import NetworkView from './NetworkView.vue'
import PanelEditorPopover from './PanelEditorPopover.vue'
import PanelMenu, { type PanelMenuItem } from './PanelMenu.vue'
import { useSimStore } from '@/composables/useSimStore'
import { roundPct } from '@/lib/format'
import { reachedCountAtRound } from '@/lib/reach'
import type { PanelSpec } from '@/presets/types'
import { MAX_PANELS, type PanelSpec } from '@/presets/types'
const props = defineProps<{ panel: PanelSpec; accent: string }>()
const store = useSimStore()
const menuOpen = ref(false)
const focusLabelOnOpen = ref(false)
const kebabButton = ref<HTMLButtonElement | null>(null)
const editing = computed(() => store.state.editingPanelId === props.panel.id)
const failed = computed(() => store.state.failedPanelIds.has(props.panel.id))
const menuItems = computed<PanelMenuItem[]>(() => [
{ key: 'edit', label: 'Edit' },
{ key: 'rename', label: 'Rename' },
{ key: 'duplicate', label: 'Duplicate', disabled: store.state.panels.length >= MAX_PANELS },
{ key: 'remove', label: 'Remove', disabled: store.state.panels.length <= 1 },
])
function onMenuSelect(itemKey: string) {
menuOpen.value = false
switch (itemKey) {
case 'edit':
case 'rename':
focusLabelOnOpen.value = itemKey === 'rename'
store.state.editingPanelId = props.panel.id
break
case 'duplicate':
store.duplicatePanel(props.panel.id)
kebabButton.value?.focus()
break
case 'remove':
store.removePanel(props.panel.id)
break
}
}
function closeMenu() {
menuOpen.value = false
kebabButton.value?.focus()
}
function closeEditor() {
store.state.editingPanelId = null
kebabButton.value?.focus()
}
const result = computed(() => store.state.resultsByPanelId[props.panel.id])
const numStudents = computed(() => store.effectiveConfig(props.panel).numStudents)
@ -50,10 +94,36 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
<div class="head">
<span class="swatch" :style="{ background: accent }" aria-hidden="true" />
<span class="label">{{ panel.label }}</span>
<button class="kebab" type="button" disabled title="Scenario menu arrives in a later slice">
<button
ref="kebabButton"
class="kebab"
type="button"
aria-haspopup="menu"
:aria-expanded="menuOpen"
:aria-label="`Scenario menu for ${panel.label}`"
@click="menuOpen = !menuOpen"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6h.01M12 12h.01M12 18h.01" /></svg>
<span
v-if="failed"
class="fail-dot"
:title="`The last run of ${panel.label} failed; showing its previous result`"
/>
</button>
<PanelMenu
v-if="menuOpen"
:items="menuItems"
:anchor="kebabButton"
@select="onMenuSelect"
@close="closeMenu"
/>
</div>
<PanelEditorPopover
v-if="editing"
:panel="panel"
:focus-label="focusLabelOnOpen"
@close="closeEditor"
/>
<template v-if="result">
<div class="pct" :class="tone">{{ roundPct(reachedPctNow) }}<small>%</small></div>
<div class="meta">
@ -104,6 +174,7 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
display: flex;
align-items: center;
gap: 8px;
position: relative;
}
.swatch {
@ -134,10 +205,23 @@ const dimmed = computed(() => store.state.runState === 'running' && result.value
border-radius: 8px;
display: grid;
place-items: center;
cursor: pointer;
position: relative;
}
.kebab:disabled {
cursor: default;
.kebab:hover {
background: var(--bg);
color: var(--ink);
}
.fail-dot {
position: absolute;
top: 2px;
right: 2px;
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--spread);
}
.pct {

View file

@ -11,6 +11,12 @@ const atPanelCap = computed(() => store.state.panels.length >= MAX_PANELS)
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>
@ -27,7 +33,7 @@ const refreshing = computed(
type="button"
:disabled="atPanelCap"
:title="atPanelCap ? 'Maximum 6 scenarios' : undefined"
@click="store.addPanel()"
@click="addScenario()"
>
<svg class="ic" viewBox="0 0 24 24"><path d="M12 6v12M6 12h12" /></svg>
Add scenario

View file

@ -152,11 +152,14 @@ describe('useSimStore runs', () => {
expect(store.state.errorMessage).toBe('fetch failed')
expect(store.state.validationError).toBeNull()
expect(store.state.resultsByPanelId).toEqual(goodResults)
// Every panel in the failed run gets the rose kebab dot.
expect(store.state.failedPanelIds.size).toBe(3)
runScenarioMock.mockImplementation(async (request) => fakeResponse(request))
await store.retry()
expect(store.state.runState).toBe('idle')
expect(store.state.errorMessage).toBeNull()
expect(store.state.failedPanelIds.size).toBe(0)
})
it('routes a 400 to validationError instead of the banner', async () => {

View file

@ -28,6 +28,8 @@ export interface SimState {
playing: boolean
speed: PlaybackSpeed
focusPanelId: string | null
editingPanelId: string | null // panel whose editor popover is open
failedPanelIds: Set<string> // panels whose latest request failed (rose kebab dot)
hoveredNode: number | null
runState: RunState
errorMessage: string | null // network/server failure, shown in the ErrorBanner
@ -54,6 +56,8 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
playing: false,
speed: 1,
focusPanelId: null,
editingPanelId: null,
failedPanelIds: new Set<string>(),
hoveredNode: null,
runState: 'idle',
errorMessage: null,
@ -135,6 +139,10 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
// Keep the last good results untouched; only the latest run may
// surface its error.
if (!isLatestRun) return
settled.forEach((outcome, index) => {
const panel = targets[index]
if (panel && outcome.status === 'rejected') state.failedPanelIds.add(panel.id)
})
const reason: unknown = firstFailure.reason
state.runState = 'error'
if (reason instanceof ApiError && reason.status === 400) {
@ -153,6 +161,7 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
if (outcome?.status !== 'fulfilled') return
state.resultsByPanelId[panel.id] = outcome.value.result
state.edgesByGraphHash[graphKey(outcome.value.config)] = outcome.value.edges
state.failedPanelIds.delete(panel.id)
})
if (!isLatestRun) return
@ -213,7 +222,9 @@ export function createSimStore(preset: StudyPreset = deepfakeSchoolPreset) {
if (index < 0) return
state.panels.splice(index, 1)
delete state.resultsByPanelId[panelId]
state.failedPanelIds.delete(panelId)
if (state.focusPanelId === panelId) state.focusPanelId = null
if (state.editingPanelId === panelId) state.editingPanelId = null
syncToUrl()
}

View file

@ -0,0 +1,21 @@
import type { Config, Strategy } from '@/types/engine'
// One human name per Config field, shared by the controls card and the
// panel editor so the same lever never appears under two names.
export const CONFIG_FIELD_LABELS: Record<keyof Config, string> = {
numStudents: 'Students',
edgesPerNode: 'Friends per student',
triangleProb: 'Clique tendency',
forwardProb: 'Chance to forward',
numEducated: 'Education budget',
origin: 'First poster',
graphSeed: 'Friendship network',
thresholdSeed: 'Who resists',
educationSeed: 'Random picks',
}
export const STRATEGY_LABELS: Record<Strategy, string> = {
none: 'None',
random: 'Random',
'most-connected': 'Most connected',
}