From f6287b80cb250e9f5982a0ca242930c30a54ef5e Mon Sep 17 00:00:00 2001 From: otivm Date: Sun, 3 May 2026 14:51:13 +0000 Subject: [PATCH] =?UTF-8?q?iv:=20add=20Shell.jsx=20and=20Section.jsx=20?= =?UTF-8?q?=E2=80=94=20shell=20foundation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Section.jsx | 334 +++++++++++++++++++++++++++++++++++++ src/components/Shell.jsx | 169 +++++++++++++++++++ 2 files changed, 503 insertions(+) create mode 100644 src/components/Section.jsx create mode 100644 src/components/Shell.jsx 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 ( +
+
+ {title} +
+
+ +
+
+ ) +} + +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} +
+ )} + + + + + + + + + + {items.map((item, i) => ( + + + + + + ))} + +
ItemAmountSource · Conf.
+ {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) => ( + + ))} +
+ ) +} + +// ── 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 && ( + + )} + {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 ── */} + + + {/* ── 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 ( +
+
+
{left}
+
{right}
+
+
+ ) + } + + 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 ( +
+
+
{sidebar}
+
{canvas}
+
+
+ ) + } + + // Fallback — render all children in a single column + return ( +
+ {childArray} +
+ ) +}