iv: add Shell.jsx and Section.jsx — shell foundation

This commit is contained in:
otivm
2026-05-03 14:51:13 +00:00
parent 30649ae4bb
commit f6287b80cb
2 changed files with 503 additions and 0 deletions

334
src/components/Section.jsx Normal file
View File

@@ -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 (
<div className="otivm-section">
<div className="otivm-section-head">
<span className="otivm-section-title">{title}</span>
</div>
<div className="otivm-section-body">
<SectionBody type={type} {...props} />
</div>
</div>
)
}
function SectionBody({ type, ...props }) {
switch (type) {
case 'status-block': return <StatusBlock {...props} />
case 'parameter-list': return <ParameterList {...props} />
case 'auctoritas': return <Auctoritas {...props} />
case 'cost-table': return <CostTable {...props} />
case 'drift-log': return <DriftLog {...props} />
case 'action-bar': return <ActionBar {...props} />
case 'route-list': return <RouteList {...props} />
case 'text-block': return <TextBlock {...props} />
case 'map-canvas': return <MapCanvas {...props} />
default:
return <div style={{ fontSize: '0.75rem', color: 'var(--otivm-ink-ghost)', fontStyle: 'italic' }}>Unknown section type: {type}</div>
}
}
// ── Status block ──────────────────────────────────────────────────────────
// props.items: [{ key, value, cls }]
// cls: 'good' | 'warn' | 'bad' | ''
function StatusBlock({ items = [] }) {
return (
<div>
{items.map((item, i) => (
<div key={i} className="otivm-status-row">
<span className="otivm-status-key">{item.key}</span>
<span className={`otivm-status-val${item.cls ? ' ' + item.cls : ''}`}>{item.value}</span>
</div>
))}
</div>
)
}
// ── Parameter list ────────────────────────────────────────────────────────
// props.items: [{ token, name, true_val, perceived, conf }]
// Shows a gap indicator when true_val !== perceived.
function ParameterList({ items = [] }) {
return (
<div>
{items.map((item, i) => {
const gap = item.true_val !== item.perceived
return (
<div key={i} className="otivm-param-row">
<div>
<div className="otivm-param-token">{item.token}</div>
<div className="otivm-param-name">{item.name}</div>
</div>
<div style={{ textAlign: 'right' }}>
<span className={`otivm-band ${bandClass(item.perceived)}`}>
{item.perceived}
</span>
{gap && (
<div>
<span className="otivm-band otivm-band-gap" style={{ marginTop: '3px' }}>
true: {item.true_val}
</span>
</div>
)}
<span className="otivm-param-conf">{item.conf}</span>
</div>
</div>
)
})}
</div>
)
}
// ── 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 (
<div>
<div className="otivm-auct-three">
{faces.map((face, i) => (
<div key={i} className={`otivm-auct-face${face.discrepant ? ' discrepant' : ''}`}>
<div className="otivm-auct-face-label">{face.label}</div>
<div className="otivm-auct-face-value">{face.value}</div>
</div>
))}
</div>
{hasGap && gap_note && (
<div style={{ marginTop: '8px', fontSize: '0.72rem', fontStyle: 'italic', color: 'var(--otivm-gold)', lineHeight: 1.5 }}>
{gap_note}
</div>
)}
</div>
)
}
// ── Cost table ────────────────────────────────────────────────────────────
// props.note: optional note shown above table
// props.items: [{ label, amount, period, source, conf, debit }]
function CostTable({ note, items = [] }) {
return (
<div>
{note && (
<div style={{ fontSize: '0.7rem', fontStyle: 'italic', color: 'var(--otivm-ink-ghost)', marginBottom: '10px' }}>
{note}
</div>
)}
<table className="otivm-cost-table">
<thead>
<tr>
<th>Item</th>
<th style={{ textAlign: 'right' }}>Amount</th>
<th>Source · Conf.</th>
</tr>
</thead>
<tbody>
{items.map((item, i) => (
<tr key={i}>
<td>
{item.label}
{item.period && <div className="source">{item.period}</div>}
</td>
<td className={`amount ${item.debit ? 'debit' : 'credit'}`}>
{item.debit ? '' : '+'}{item.amount}
</td>
<td>
<span className="source">{item.source} · {item.conf}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Drift log ─────────────────────────────────────────────────────────────
// props.items: [{ param, delta, trigger, note, positive }]
function DriftLog({ items = [] }) {
return (
<div>
{items.map((item, i) => (
<div key={i} className="otivm-drift-entry">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '2px' }}>
<span className="otivm-drift-param">{item.param}</span>
<span className={item.positive ? 'otivm-drift-pos' : 'otivm-drift-neg'}>{item.delta}</span>
</div>
<div className="otivm-drift-trigger">{item.trigger}</div>
{item.note && <div className="otivm-drift-note">{item.note}</div>}
</div>
))}
</div>
)
}
// ── Action bar ────────────────────────────────────────────────────────────
// props.buttons: [{ label, style, action, disabled }]
// props.onAction: fn(action) called when a button is clicked
function ActionBar({ buttons = [], onAction }) {
return (
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
{buttons.map((btn, i) => (
<button
key={i}
className={`btn ${btn.style || 'otivm-btn-dispatch'}`}
disabled={btn.disabled}
onClick={() => onAction && onAction(btn.action)}
>
{btn.label}
</button>
))}
</div>
)
}
// ── 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 (
<div>
{routes.map(route => {
if (route.locked) {
return (
<div key={route.id} className="otivm-route-card locked">
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span className="otivm-route-name">{route.name}</span>
<span style={{ fontSize: '0.68rem', color: 'var(--otivm-ink-ghost)' }}>locked</span>
</div>
<div className="otivm-route-goods">{route.goods} · {route.mode}</div>
{route.lock_reason && (
<div style={{ fontSize: '0.7rem', fontStyle: 'italic', color: 'var(--otivm-ink-ghost)' }}>
Requires {route.lock_reason}
</div>
)}
</div>
)
}
const net = route.profit - route.cost
const isSelected = selectedRoute === route.id
return (
<div
key={route.id}
className={`otivm-route-card${isSelected ? ' selected' : ''}`}
onClick={() => onSelect && onSelect(route.id)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: '3px' }}>
<span className="otivm-route-name">{route.name}</span>
<span className="otivm-route-net">+{net} dn net</span>
</div>
<div className="otivm-route-goods">
{route.goods} · {route.mode} · {route.days} sim. days
</div>
<div className="otivm-route-costs">
<div className="otivm-cost-cell">
<div className="otivm-cost-cell-label">Vectura</div>
<div className="otivm-cost-cell-value">{route.vectura} dn</div>
</div>
<div className="otivm-cost-cell">
<div className="otivm-cost-cell-label">Portoria</div>
<div className="otivm-cost-cell-value">{route.portoria} dn</div>
</div>
<div className="otivm-cost-cell">
<div className="otivm-cost-cell-label">Other</div>
<div className="otivm-cost-cell-value">{route.other} dn</div>
</div>
<div className="otivm-cost-cell rev">
<div className="otivm-cost-cell-label">Revenue</div>
<div className="otivm-cost-cell-value">{route.profit} dn</div>
</div>
</div>
</div>
)
})}
</div>
)
}
// ── Text block ────────────────────────────────────────────────────────────
// props.entries: [{ date, text }]
// props.collapsible: boolean
function TextBlock({ entries = [], collapsible = false }) {
const [open, setOpen] = useState(!collapsible)
return (
<div>
{collapsible && (
<button
className="btn otivm-btn-dispatch"
style={{ width: '100%', marginBottom: '8px', fontSize: '0.65rem' }}
onClick={() => setOpen(o => !o)}
>
{open ? 'Hide Journal' : 'Show Journal'}
</button>
)}
{open && entries.map((entry, i) => (
<div key={i} className="otivm-journal-entry">
<div className="otivm-journal-date">{entry.date}</div>
<div className="otivm-journal-text">{entry.text}</div>
</div>
))}
</div>
)
}
// ── 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 (
<div className="otivm-map-canvas" style={{ minHeight: '480px' }}>
<span>TESSERA H7 · roman_14bce</span>
<span style={{ fontFamily: 'Crimson Pro, serif', fontStyle: 'italic', fontSize: '0.78rem', letterSpacing: 0, color: '#1e3040', marginTop: '8px' }}>
Map renders here src/screens/Map.jsx
</span>
</div>
)
}
// ── 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'
}

169
src/components/Shell.jsx Normal file
View File

@@ -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 (
<div style={{ display: 'flex' }}>
{/* ── Sidebar ── */}
<aside className="otivm-sidebar">
<div className="otivm-sidebar-brand">
<div className="otivm-sidebar-title">OTIVM</div>
<div className="otivm-sidebar-subtitle">mercator romanus · 14 BCE</div>
</div>
<div className="otivm-sidebar-nav">
<span className="otivm-nav-label">Context</span>
{/* Context dropdown — reads contexts.json */}
<select
className="otivm-context-select"
value={activeId}
onChange={handleContextChange}
>
{contexts.map(ctx => (
<option key={ctx.id} value={ctx.id} disabled={ctx.disabled}>
{ctx.name}{ctx.disabled ? ' ⟨future⟩' : ''}
</option>
))}
</select>
{/* Sub-items for the active context */}
<div style={{ marginTop: '12px' }}>
{subitems.map((item, i) => (
<button
key={item}
className={`otivm-nav-sub${activeSubitem === i ? ' active' : ''}`}
onClick={() => setActiveSubitem(i)}
style={{ width: '100%', background: 'none', border: 'none', textAlign: 'left', cursor: 'pointer' }}
>
{item}
</button>
))}
</div>
</div>
<div className="otivm-sidebar-footer">
<span>Save token</span>
{token || '—'}
{onNewGame && (
<div style={{ marginTop: '8px' }}>
<button className="otivm-new-game-btn" onClick={onNewGame}>
new game
</button>
</div>
)}
</div>
</aside>
{/* ── Main area ── */}
<main className="otivm-main" style={{ flex: 1 }}>
<LayoutGrid layout={layout}>
{children}
</LayoutGrid>
</main>
</div>
)
}
// 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 (
<div className="otivm-layout-wrap">
{childArray}
</div>
)
}
if (layout === 'two-col') {
const left = childArray.filter(c => c?.props?.col === 'left')
const right = childArray.filter(c => c?.props?.col === 'right')
return (
<div className="otivm-layout-wrap">
<div className="otivm-layout-two-col">
<div>{left}</div>
<div>{right}</div>
</div>
</div>
)
}
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 (
<div className="otivm-layout-wrap">
<div className="otivm-layout-three-col">
<div>{left}</div>
<div>{center}</div>
<div>{right}</div>
</div>
</div>
)
}
if (layout === 'map') {
const sidebar = childArray.filter(c => c?.props?.col === 'sidebar')
const canvas = childArray.filter(c => c?.props?.col === 'canvas')
return (
<div className="otivm-layout-wrap">
<div className="otivm-layout-map">
<div>{sidebar}</div>
<div>{canvas}</div>
</div>
</div>
)
}
// Fallback — render all children in a single column
return (
<div className="otivm-layout-wrap">
{childArray}
</div>
)
}