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'
}