web: world controls, slice 6 of M3

The lower grid completes: reach chart left, World card right. Two
headline levers (chance to forward, education budget as count plus
share) move instantly and rerun through the store's debounce; the
advanced disclosure holds students, friends per student, clique
tendency, first poster, and the three named seeds, each seed with a
dice reroll plus a Reroll world button (one coalesced run).

Engine 400s render inline under the card header in rose with the
offending field marked by name match; network failures keep using the
banner with retry. A graph-seed reroll remounts the network svg keyed
by graph hash, fading the new layout in over 200 ms.
This commit is contained in:
Justin Visser 2026-06-10 17:45:29 +02:00
parent da1d86aba7
commit fdc28ac686
6 changed files with 520 additions and 6 deletions

View file

@ -0,0 +1,84 @@
<script setup lang="ts">
import { computed } from 'vue'
import AdvancedFields from './AdvancedFields.vue'
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
// immediately and rerun every panel after the shared debounce.
const store = useSimStore()
const base = computed(() => store.state.base)
const educationDisplay = computed(() => {
const share = (base.value.numEducated / Math.max(base.value.numStudents, 1)) * 100
return `${base.value.numEducated} · ${formatPct(share)}`
})
const offendsForwardProb = computed(
() => store.state.validationError?.includes('forwardProb') ?? false,
)
const offendsNumEducated = computed(
() => store.state.validationError?.includes('numEducated') ?? false,
)
</script>
<template>
<section class="card ctl" aria-label="World settings">
<h3>World</h3>
<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="0"
: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)"
/>
<AdvancedFields />
</section>
</template>
<style scoped>
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 20px 22px;
}
h3 {
font-size: 13px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--ink-4);
}
.invalid-note {
margin-top: 8px;
font-size: 12.5px;
font-weight: 500;
color: var(--spread);
}
</style>