diff --git a/src/components/Section.jsx b/src/components/Section.jsx
new file mode 100644
index 0000000..caaff5d
--- /dev/null
+++ b/src/components/Section.jsx
@@ -0,0 +1,334 @@
+// Section.jsx — OTIVM-IV
+// Generic panel renderer. Receives a section definition and renders
+// the appropriate sub-component based on the "type" field.
+// Does not know what it is showing — delegates entirely to type.
+//
+// Section definition shape (from context-{id}.json):
+// title — string, shown in panel header
+// type — one of the section types below
+// col — layout column assignment (read by Shell.jsx LayoutGrid)
+// [data] — type-specific data array or object
+//
+// Section types:
+// status-block — key/value pairs
+// parameter-list — actor_parameters rows
+// auctoritas — three-face auctoritas panel
+// cost-table — cost items with amounts and sources
+// drift-log — parameter_drift_log entries
+// action-bar — action buttons
+// route-list — trade route cards with cost breakdown
+// text-block — narrative text, optionally collapsible
+// map-canvas — TESSERA fog-of-war map placeholder
+//
+// The "col" prop is consumed by Shell.jsx LayoutGrid for column placement.
+// Section.jsx itself ignores it.
+
+import { useState } from 'react'
+
+export default function Section({ title, type, col, ...props }) {
+ return (
+
+ )
+}
+
+function SectionBody({ type, ...props }) {
+ switch (type) {
+ case 'status-block': return
+ case 'parameter-list': return
+ case 'auctoritas': return
+ case 'cost-table': return
+ case 'drift-log': return
+ case 'action-bar': return
+ case 'route-list': return
+ case 'text-block': return
+ case 'map-canvas': return
+ default:
+ return Unknown section type: {type}
+ }
+}
+
+// ── Status block ──────────────────────────────────────────────────────────
+// props.items: [{ key, value, cls }]
+// cls: 'good' | 'warn' | 'bad' | ''
+
+function StatusBlock({ items = [] }) {
+ return (
+
+ {items.map((item, i) => (
+
+ {item.key}
+ {item.value}
+
+ ))}
+
+ )
+}
+
+// ── Parameter list ────────────────────────────────────────────────────────
+// props.items: [{ token, name, true_val, perceived, conf }]
+// Shows a gap indicator when true_val !== perceived.
+
+function ParameterList({ items = [] }) {
+ return (
+
+ {items.map((item, i) => {
+ const gap = item.true_val !== item.perceived
+ return (
+
+
+
{item.token}
+
{item.name}
+
+
+
+ {item.perceived}
+
+ {gap && (
+
+
+ true: {item.true_val}
+
+
+ )}
+
{item.conf}
+
+
+ )
+ })}
+
+ )
+}
+
+// ── Auctoritas ────────────────────────────────────────────────────────────
+// props.faces: [{ label, value, discrepant }]
+// props.gap_note: string shown when discrepancy exists
+
+function Auctoritas({ faces = [], gap_note }) {
+ const hasGap = faces.some(f => f.discrepant)
+ return (
+
+
+ {faces.map((face, i) => (
+
+
{face.label}
+
{face.value}
+
+ ))}
+
+ {hasGap && gap_note && (
+
+ {gap_note}
+
+ )}
+
+ )
+}
+
+// ── Cost table ────────────────────────────────────────────────────────────
+// props.note: optional note shown above table
+// props.items: [{ label, amount, period, source, conf, debit }]
+
+function CostTable({ note, items = [] }) {
+ return (
+
+ {note && (
+
+ {note}
+
+ )}
+
+
+
+ Item
+ Amount
+ Source · Conf.
+
+
+
+ {items.map((item, i) => (
+
+
+ {item.label}
+ {item.period && {item.period}
}
+
+
+ {item.debit ? '−' : '+'}{item.amount}
+
+
+ {item.source} · {item.conf}
+
+
+ ))}
+
+
+
+ )
+}
+
+// ── Drift log ─────────────────────────────────────────────────────────────
+// props.items: [{ param, delta, trigger, note, positive }]
+
+function DriftLog({ items = [] }) {
+ return (
+
+ {items.map((item, i) => (
+
+
+ {item.param}
+ {item.delta}
+
+
{item.trigger}
+ {item.note &&
{item.note}
}
+
+ ))}
+
+ )
+}
+
+// ── Action bar ────────────────────────────────────────────────────────────
+// props.buttons: [{ label, style, action, disabled }]
+// props.onAction: fn(action) called when a button is clicked
+
+function ActionBar({ buttons = [], onAction }) {
+ return (
+
+ {buttons.map((btn, i) => (
+ onAction && onAction(btn.action)}
+ >
+ {btn.label}
+
+ ))}
+
+ )
+}
+
+// ── Route list ────────────────────────────────────────────────────────────
+// props.routes: [{ id, name, goods, mode, days, cost, profit,
+// vectura, portoria, other, locked, lock_reason }]
+// props.selectedRoute: id of currently selected route
+// props.onSelect: fn(id) called when a route is clicked
+
+function RouteList({ routes = [], selectedRoute, onSelect }) {
+ return (
+
+ {routes.map(route => {
+ if (route.locked) {
+ return (
+
+
+ {route.name}
+ locked
+
+
{route.goods} · {route.mode}
+ {route.lock_reason && (
+
+ Requires {route.lock_reason}
+
+ )}
+
+ )
+ }
+ const net = route.profit - route.cost
+ const isSelected = selectedRoute === route.id
+ return (
+
onSelect && onSelect(route.id)}
+ >
+
+ {route.name}
+ +{net} dn net
+
+
+ {route.goods} · {route.mode} · {route.days} sim. days
+
+
+
+
Vectura
+
{route.vectura} dn
+
+
+
Portoria
+
{route.portoria} dn
+
+
+
Other
+
{route.other} dn
+
+
+
Revenue
+
{route.profit} dn
+
+
+
+ )
+ })}
+
+ )
+}
+
+// ── Text block ────────────────────────────────────────────────────────────
+// props.entries: [{ date, text }]
+// props.collapsible: boolean
+
+function TextBlock({ entries = [], collapsible = false }) {
+ const [open, setOpen] = useState(!collapsible)
+
+ return (
+
+ {collapsible && (
+
setOpen(o => !o)}
+ >
+ {open ? 'Hide Journal' : 'Show Journal'}
+
+ )}
+ {open && entries.map((entry, i) => (
+
+
{entry.date}
+
{entry.text}
+
+ ))}
+
+ )
+}
+
+// ── Map canvas ────────────────────────────────────────────────────────────
+// Placeholder — the real Map.jsx component is rendered by the MAP context screen.
+// This type exists so Section.jsx can reference it; in production the MAP
+// context screen passes the real Map.jsx output as a child instead.
+
+function MapCanvas() {
+ return (
+
+ TESSERA H7 · roman_14bce
+
+ Map renders here — src/screens/Map.jsx
+
+
+ )
+}
+
+// ── Helpers ───────────────────────────────────────────────────────────────
+
+function bandClass(val) {
+ if (!val) return 'otivm-band-low'
+ const v = val.toLowerCase()
+ if (v === 'high' || v === 'distinguished') return 'otivm-band-high'
+ if (v === 'medium' || v === 'neutral') return 'otivm-band-medium'
+ return 'otivm-band-low'
+}
diff --git a/src/components/Shell.jsx b/src/components/Shell.jsx
new file mode 100644
index 0000000..9a8e9d6
--- /dev/null
+++ b/src/components/Shell.jsx
@@ -0,0 +1,169 @@
+// Shell.jsx — OTIVM-IV
+// Persistent sidebar with context dropdown and layout grid renderer.
+// Built once. Never changes when new contexts are added.
+// Reads src/config/contexts.json for the dropdown.
+// Loads context-{id}.json and renders the appropriate screen component.
+//
+// Props:
+// contexts — array from contexts.json
+// activeId — current context id
+// onContext — fn(id) called when dropdown changes
+// subitems — array of subitem labels for active context
+// layout — layout string from active context JSON
+// token — player save token
+// onNewGame — fn() called when new game is requested
+// children — the screen component(s) to render in the layout grid
+
+import { useState } from 'react'
+
+export default function Shell({
+ contexts = [],
+ activeId,
+ onContext,
+ subitems = [],
+ layout = 'single',
+ token,
+ onNewGame,
+ children,
+}) {
+ const [activeSubitem, setActiveSubitem] = useState(0)
+
+ function handleContextChange(e) {
+ onContext(e.target.value)
+ setActiveSubitem(0)
+ }
+
+ return (
+
+
+ {/* ── Sidebar ── */}
+
+
+
OTIVM
+
mercator romanus · 14 BCE
+
+
+
+
Context
+
+ {/* Context dropdown — reads contexts.json */}
+
+ {contexts.map(ctx => (
+
+ {ctx.name}{ctx.disabled ? ' ⟨future⟩' : ''}
+
+ ))}
+
+
+ {/* Sub-items for the active context */}
+
+ {subitems.map((item, i) => (
+ setActiveSubitem(i)}
+ style={{ width: '100%', background: 'none', border: 'none', textAlign: 'left', cursor: 'pointer' }}
+ >
+ {item}
+
+ ))}
+
+
+
+
+
Save token
+ {token || '—'}
+ {onNewGame && (
+
+
+ new game
+
+
+ )}
+
+
+
+ {/* ── Main area ── */}
+
+
+ {children}
+
+
+
+
+ )
+}
+
+// LayoutGrid — applies the correct CSS grid based on context JSON "layout" value.
+// Children are React elements. Each child declares its column via a "col" prop.
+// Shell.jsx reads the col prop and places the child in the correct grid column.
+//
+// Layout types:
+// single — one full-width column (col prop ignored)
+// two-col — left (2fr) + right (3fr)
+// three-col — left (1fr) + center (2fr) + right (1fr)
+// map — sidebar (280px) + canvas (1fr)
+
+function LayoutGrid({ layout, children }) {
+ const childArray = Array.isArray(children) ? children : [children]
+
+ if (layout === 'single') {
+ return (
+
+ {childArray}
+
+ )
+ }
+
+ if (layout === 'two-col') {
+ const left = childArray.filter(c => c?.props?.col === 'left')
+ const right = childArray.filter(c => c?.props?.col === 'right')
+ return (
+
+ )
+ }
+
+ if (layout === 'three-col') {
+ const left = childArray.filter(c => c?.props?.col === 'left')
+ const center = childArray.filter(c => c?.props?.col === 'center')
+ const right = childArray.filter(c => c?.props?.col === 'right')
+ return (
+
+
+
{left}
+
{center}
+
{right}
+
+
+ )
+ }
+
+ if (layout === 'map') {
+ const sidebar = childArray.filter(c => c?.props?.col === 'sidebar')
+ const canvas = childArray.filter(c => c?.props?.col === 'canvas')
+ return (
+
+ )
+ }
+
+ // Fallback — render all children in a single column
+ return (
+
+ {childArray}
+
+ )
+}