iv: add Shell.jsx and Section.jsx — shell foundation
This commit is contained in:
334
src/components/Section.jsx
Normal file
334
src/components/Section.jsx
Normal 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
169
src/components/Shell.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user