prototype: revisit five administration workflows after design review
This commit is contained in:
@@ -6,6 +6,7 @@
|
||||
"dev": "vite",
|
||||
"prototype:database-management": "vite --config vite.database-management-prototype.config.ts",
|
||||
"prototype:administration-pages": "vite --config vite.administration-pages-prototype.config.ts",
|
||||
"prototype:administration-review": "vite --config vite.administration-review.config.ts",
|
||||
"build": "tsc -b && vite build",
|
||||
"preview": "vite preview",
|
||||
"test": "vitest run",
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { ArrowLeft, ArrowRight, Database, FileText, Layers, LayoutDashboard, Menu, Search, Settings2, Terminal, Users, X, ChevronRight } from "lucide-react";
|
||||
import { inventory, modules, readLocation, variants, workspaces, type Item, type Scenario, type Surface, type Variant } from "./model";
|
||||
import { Content, Status, type ContentProps, type ConnectionValues } from "./Content";
|
||||
|
||||
// Three structural variants of the same five Administration routes. Throwaway,
|
||||
// synthetic data, in-memory actions only. No production modules or API imports.
|
||||
const icons = { workspace: Layers, database: Database, evidence: FileText, memory: LayoutDashboard, pi: Terminal };
|
||||
const order: Surface[] = ["database", "memory", "evidence", "workspace", "pi"];
|
||||
const initial = readLocation();
|
||||
export function Button({ children, primary, onClick, disabled, className = "" }: { children: ReactNode; primary?: boolean; onClick?: () => void; disabled?: boolean; className?: string }) {
|
||||
return <button type="button" className={`rv-button ${primary ? "rv-primary" : ""} ${className}`} onClick={onClick} disabled={disabled}>{children}</button>;
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [route, setRoute] = useState(initial);
|
||||
const { surface, variant, workspace } = route;
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [hostOpen, setHostOpen] = useState(false);
|
||||
const [adminOpen, setAdminOpen] = useState(false);
|
||||
const [width, setWidth] = useState("full");
|
||||
const [scenarioMap, setScenarioMap] = useState<Record<string, Scenario>>({});
|
||||
const [selection, setSelection] = useState<Record<string, string>>({});
|
||||
const [filters, setFilters] = useState<Record<string, string>>({});
|
||||
const [section, setSection] = useState(0);
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [notice, setNotice] = useState("");
|
||||
const [savedCards, setSavedCards] = useState<Record<string, Item[]>>({});
|
||||
const [connections, setConnections] = useState<Record<string, ConnectionValues>>({});
|
||||
const scope = `${surface}:${surface === "pi" ? "installation" : workspace}`;
|
||||
const scenario = scenarioMap[scope] ?? "ready";
|
||||
const rows = surface === "memory" ? savedCards[workspace] ?? inventory.memory : inventory[surface];
|
||||
const selected = surface === "workspace" ? workspace : selection[scope] ?? rows[0]?.id;
|
||||
const item = rows.find(r => r.id === selected) ?? rows[0];
|
||||
const query = filters[scope] ?? "";
|
||||
const shownRows = rows.filter(r => `${r.title} ${r.category}`.toLocaleLowerCase().includes(query.toLocaleLowerCase()));
|
||||
const currentWorkspace = workspaces.find(w => w.id === workspace)!;
|
||||
const currentUrl = useRef(location.href);
|
||||
const dirtyRef = useRef(dirty); dirtyRef.current = dirty;
|
||||
const notify = (text: string) => setNotice(text);
|
||||
const canLeave = () => !dirtyRef.current || window.confirm("Discard unsaved changes and leave this form?");
|
||||
function writeRoute(next: typeof route, replace = false) {
|
||||
const url = new URL(location.href);
|
||||
url.searchParams.set("thoth_route", `administration/${next.surface}`);
|
||||
url.searchParams.set("thoth_workspace", next.workspace);
|
||||
url.searchParams.set("variant", next.variant);
|
||||
if (url.href !== location.href) history[replace ? "replaceState" : "pushState"](history.state, "", url);
|
||||
currentUrl.current = url.href;
|
||||
setRoute(next);
|
||||
}
|
||||
function navigate(next: Partial<typeof route>, replace = false) {
|
||||
if (!canLeave()) return;
|
||||
writeRoute({ ...route, ...next }, replace);
|
||||
setDirty(false); setEditing(false); setFocused(false); setSection(0); setNotice(""); setAdminOpen(false);
|
||||
}
|
||||
useEffect(() => {
|
||||
const onPop = () => {
|
||||
if (dirtyRef.current && !window.confirm("Discard unsaved changes and follow browser history?")) {
|
||||
history.pushState(history.state, "", currentUrl.current); return;
|
||||
}
|
||||
setRoute(readLocation()); currentUrl.current = location.href;
|
||||
setDirty(false); setEditing(false); setFocused(false); setSection(0); setNotice("");
|
||||
};
|
||||
const onUnload = (e: BeforeUnloadEvent) => { if (dirtyRef.current) { e.preventDefault(); e.returnValue = ""; } };
|
||||
window.addEventListener("popstate", onPop); window.addEventListener("beforeunload", onUnload);
|
||||
return () => { window.removeEventListener("popstate", onPop); window.removeEventListener("beforeunload", onUnload); };
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if ((e.target as HTMLElement).closest("input,textarea,select,button,[contenteditable],[role=tablist]")) return;
|
||||
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
|
||||
e.preventDefault(); cycle(e.key === "ArrowRight" ? 1 : -1);
|
||||
};
|
||||
window.addEventListener("keydown", onKey); return () => window.removeEventListener("keydown", onKey);
|
||||
});
|
||||
function cycle(delta: number) { const keys: Variant[] = ["A", "B", "C"]; navigate({ variant: keys[(keys.indexOf(variant) + delta + 3) % 3] }, true); }
|
||||
function choose(id: string) {
|
||||
if (!canLeave()) return;
|
||||
if (surface === "workspace") writeRoute({ ...route, workspace: id });
|
||||
setSelection(s => ({ ...s, [scope]: id })); setFocused(true); setEditing(false); setDirty(false); setNotice("");
|
||||
if (surface === "pi") setSection(inventory.pi.findIndex(r => r.id === id));
|
||||
}
|
||||
function changeSection(n: number) {
|
||||
if (!canLeave()) return;
|
||||
setEditing(false); setDirty(false); setSection(n); setFocused(true);
|
||||
}
|
||||
function setScenario(next: Scenario) { if (!canLeave()) return; setEditing(false); setDirty(false); setScenarioMap(s => ({ ...s, [scope]: next })); }
|
||||
function beginOperation(label: string) {
|
||||
setScenarioMap(s => ({ ...s, [scope]: "running" }));
|
||||
notify(`${label}: simulated operation started. You can switch pages and return. Use “Complete operation” to finish the demonstration.`);
|
||||
}
|
||||
function beginEdit() { setEditing(true); setFocused(true); }
|
||||
function primaryAction() {
|
||||
if (surface === "memory") { if (!canLeave()) return; setSelection(s => ({ ...s, [scope]: "new" })); setEditing(true); setDirty(false); setFocused(true); setSection(0); }
|
||||
else if (surface === "evidence") changeSection(2);
|
||||
else beginOperation(modules[surface].primary);
|
||||
}
|
||||
const props: ContentProps = {
|
||||
surface, workspace: currentWorkspace, scenario, item: selected === "new" ? undefined : item, section, editing,
|
||||
connectionValues: connections[workspace],
|
||||
onConnectionSave: (values) => { setConnections(s => ({ ...s, [workspace]: values })); setDirty(false); setEditing(false); notify("Connection saved in memory for this prototype. No real credentials or database were used."); },
|
||||
onEdit: beginEdit, onDirty: setDirty, onCancel: () => { if (canLeave()) { setEditing(false); setDirty(false); } },
|
||||
onSave: (updated) => {
|
||||
if (surface === "memory" && updated) {
|
||||
const next = selected === "new" ? [updated, ...(scenario === "empty" ? [] : rows)] : rows.map(r => r.id === updated.id ? updated : r);
|
||||
setSavedCards(s => ({ ...s, [workspace]: next })); setSelection(s => ({ ...s, [scope]: updated.id }));
|
||||
}
|
||||
setScenarioMap(s => ({ ...s, [scope]: "ready" }));
|
||||
setDirty(false); setEditing(false); notify("Saved in this prototype only. No installation data was changed.");
|
||||
},
|
||||
onDelete: () => { setSavedCards(s => ({ ...s, [workspace]: rows.filter(r => r.id !== selected) })); setSelection(s => ({ ...s, [scope]: rows.find(r => r.id !== selected)?.id ?? "" })); notify("Card and its links removed from the demo. Other cards are kept."); },
|
||||
onNavigate: (next) => navigate({ surface: next }), onOperation: beginOperation, onNotice: notify,
|
||||
onSection: changeSection, onScenario: setScenario,
|
||||
};
|
||||
const context = <div className="rv-context"><span>{surface === "pi" ? "Installation scope" : "Administrative workspace"}</span>{surface === "pi" ? <strong>All workspaces</strong> : <select aria-label="Administrative workspace" value={workspace} onChange={e => navigate({ workspace: e.target.value })}>{workspaces.map(w => <option value={w.id} key={w.id}>{w.name}</option>)}</select>}<span className="rv-context-core">Core workspace: PSD Clinical</span></div>;
|
||||
const heading = <header className="rv-page-heading"><div><p className="rv-eyebrow">Administration <span>/ {surface === "pi" ? "Installation" : currentWorkspace.name}</span></p><h1>{modules[surface].title}</h1><p className="rv-lede">{modules[surface].subtitle}</p></div><Button primary onClick={primaryAction} disabled={scenario === "running" || editing}>{modules[surface].primary}</Button></header>;
|
||||
const sections = <nav className="rv-tabs" aria-label={`${surface} sections`}>{modules[surface].sections.map((name, n) => <button key={name} aria-current={section === n ? "page" : undefined} onClick={() => changeSection(n)}>{name}</button>)}</nav>;
|
||||
const index = <div className="rv-index"><div className="rv-index-title"><h2>{modules[surface].noun}</h2><span>{scenario === "empty" ? 0 : rows.length}</span></div>{surface !== "pi" && <label className="rv-search"><Search size={16} /><input aria-label={`Search ${modules[surface].noun}`} placeholder="Search by name or type" value={query} onChange={e => setFilters(s => ({ ...s, [scope]: e.target.value }))} /></label>}<div className="rv-records">{scenario === "empty" ? <p className="rv-small">No records in this demonstration state.</p> : shownRows.length ? shownRows.map(r => <button key={r.id} className="rv-record" aria-current={selected === r.id ? "true" : undefined} onClick={() => choose(r.id)}><small>{r.category}</small><strong>{r.title}</strong><span>{r.meta}</span><ChevronRight size={15} /></button>) : <p className="rv-small">No matches. Try a shorter search.</p>}</div></div>;
|
||||
const content = <Content key={`${scope}:${selected}:${section}:${editing}`} {...props} />;
|
||||
const status = <div className="rv-statusline"><Status scenario={scenario} /><span>{surface === "pi" ? "Installation catalog" : `Scope: ${currentWorkspace.id}`}</span><span className="rv-sample-note">Illustrative records</span></div>;
|
||||
const navigation = <><p className="rv-eyebrow">ThothII</p><strong className="rv-nav-title">Administration</strong><nav aria-label="Administration pages">{order.map(s => { const Icon = icons[s]; return <a key={s} href={`?thoth_route=administration/${s}&thoth_workspace=${workspace}&variant=${variant}`} onClick={e => { if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; e.preventDefault(); navigate({ surface: s }); }} aria-current={surface === s ? "page" : undefined}><Icon size={17} /><span>{s === "pi" ? "Pi runtime" : s[0].toUpperCase() + s.slice(1)}</span>{surface === s && <ChevronRight size={14} />}</a>; })}</nav><div className="rv-rail-note"><p>Working context</p><strong>{currentWorkspace.name}</strong><small>Browsing here does not change the core session.</small></div></>;
|
||||
|
||||
return <><div className={`rv-host rv-host-${width} ${collapsed ? "rv-host-collapsed" : ""}`}>
|
||||
<header className="rv-topbar"><button onClick={() => { setCollapsed(!collapsed); setHostOpen(!hostOpen); }} aria-label="Toggle portal sidebar"><Menu size={20} /></button><strong>Omics <span>Portal</span></strong><span className="rv-topbar-divider" /><span className="rv-topbar-location">Clinical data & research</span><span className="rv-topbar-user">Administrator <span className="rv-avatar">AD</span></span></header>
|
||||
<div className="rv-host-body"><aside className={`rv-portal-nav ${hostOpen ? "rv-host-nav-open" : ""}`} aria-label="Simulated Omics Portal navigation"><p>PORTAL</p>{[[LayoutDashboard,"Overview"],[Users,"Patients"],[Database,"Clinical data"],[Settings2,"ThothII"]].map(([Icon,label]) => { const I = Icon as typeof Menu; return <div key={String(label)} className={label === "ThothII" ? "rv-portal-current" : ""}><I size={19} /><span>{String(label)}</span></div>; })}<small>Host shell preview</small></aside>
|
||||
<div className="rv-mount"><div className="rv-breadcrumb"><span>Omics Portal</span><ChevronRight size={13} /><strong>ThothII</strong><span>Administration</span></div><div className="rv-thoth" data-variant={variant}>
|
||||
<div className="rv-admin-mobile"><strong>ThothII administration</strong><button onClick={() => setAdminOpen(!adminOpen)} aria-expanded={adminOpen} aria-label="Toggle administration navigation">{adminOpen ? <X size={18} /> : <Menu size={18} />}</button></div>
|
||||
<aside className={`rv-admin-rail ${adminOpen ? "rv-admin-open" : ""}`}>{navigation}</aside>
|
||||
<main className={`rv-page rv-variant-${variant}`} aria-label={modules[surface].title}>
|
||||
{context}{heading}{status}
|
||||
{notice && <div className="rv-notice" role="status"><span>{notice}</span><button aria-label="Dismiss message" onClick={() => setNotice("")}><X size={16} /></button></div>}
|
||||
{dirty && <div className="rv-dirty" role="status">Unsaved changes. Save or cancel before leaving.</div>}
|
||||
{variant === "A" && <div className="rv-workbench">{index}<div className="rv-workbench-detail">{surface !== "pi" && sections}{content}</div></div>}
|
||||
{variant === "B" && <div className="rv-focus">{sections}{!focused && section === 0 && surface !== "pi" ? <><div className="rv-focus-intro"><h2>{modules[surface].noun}</h2><p>Select a record to open its full page. Search and selection are kept when you return.</p></div>{index}</> : <>{surface !== "pi" && <Button className="rv-back" onClick={() => { if (canLeave()) { setFocused(false); setEditing(false); setDirty(false); setSection(0); } }}><ArrowLeft size={15} /> Back to {modules[surface].noun.toLowerCase()}</Button>}{content}</>}</div>}
|
||||
{variant === "C" && <div className="rv-operations"><section className="rv-operation-summary"><div><p className="rv-eyebrow">Next action</p><h2>{scenario === "running" ? "An operation is in progress" : scenario === "attention" ? "Resolve the preparation step" : surface === "evidence" ? "Maintain the local knowledge archive" : "Ready for administrative work"}</h2><p>{surface === "workspace" ? "Check the catalog prerequisite, then build the reference indexes for this workspace." : surface === "database" ? "Synchronize the source schema before reviewing metadata and preparing the workspace." : surface === "memory" ? "Review the authoritative cards. Retry any pending retrieval-index updates separately." : surface === "evidence" ? "Edit the Markdown files externally, consolidate locally, then review and push your Git changes." : "Check the runtime and its configured default. Changes to models are made in the installation file."}</p></div><Status scenario={scenario} /></section><div className="rv-operation-body"><nav className="rv-step-nav" aria-label="Operation sections">{modules[surface].sections.map((s,i) => <button key={s} aria-current={section === i ? "step" : undefined} onClick={() => changeSection(i)}><span>{String(i+1).padStart(2,"0")}</span>{s}<ChevronRight size={15}/></button>)}</nav><div className="rv-operation-detail">{section === 0 && surface !== "pi" && <details className="rv-choose-record"><summary>Choose {surface === "workspace" ? "workspace" : "record"}<span>{item?.title}</span></summary>{index}</details>}{content}</div></div></div>}
|
||||
<footer className="rv-page-footer"><span>{surface === "pi" ? "Global installation controls" : "Workspace-scoped administration"}</span><span>No active core session required</span></footer>
|
||||
</main>
|
||||
</div></div></div>
|
||||
</div>
|
||||
{import.meta.env.DEV && <aside className="rv-lab" aria-label="Prototype review controls"><div className="rv-lab-main"><span className="rv-lab-tag">DESIGN REVIEW · V2</span><button aria-label="Previous variant" onClick={() => cycle(-1)}><ArrowLeft size={17} /></button><label><span className="rv-sr">Layout variant</span><select aria-label="Layout variant" value={variant} onChange={e => navigate({ variant: e.target.value as Variant },true)}>{Object.entries(variants).map(([key,name]) => <option value={key} key={key}>{key} · {name}</option>)}</select></label><button aria-label="Next variant" onClick={() => cycle(1)}><ArrowRight size={17} /></button><span className="rv-lab-help">Synthetic data · No server writes</span></div><details><summary>Review settings</summary><div className="rv-lab-options"><label>Page state<select aria-label="Page state" value={scenario} onChange={e => setScenario(e.target.value as Scenario)}>{["ready","attention","running","empty","error"].map(s => <option key={s}>{s}</option>)}</select></label><label>Host width<select aria-label="Host width" value={width} onChange={e => setWidth(e.target.value)}><option value="full">Available width</option><option value="compact">960 px host</option><option value="phone">390 px host</option></select></label>{scenario === "running" && <Button onClick={() => { setScenario("ready"); notify("Simulated operation completed."); }}>Complete operation</Button>}<p>States and edits are held in memory and reset on reload. The URL retains the page, administrative workspace and layout.</p></div></details></aside>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { ArrowUpRight, Check, CircleAlert, Clock3, Copy, Database, FileText, GitBranch, KeyRound, Play, RefreshCw, ShieldCheck } from "lucide-react";
|
||||
import { Button } from "./App";
|
||||
import { type Item, type Scenario, type Surface, type workspaces } from "./model";
|
||||
|
||||
export function Status({ scenario }: { scenario: Scenario }) {
|
||||
const Icon = scenario === "ready" ? Check : scenario === "running" ? Clock3 : CircleAlert;
|
||||
return <span className={`rv-status rv-status-${scenario}`}><Icon size={14} />{{ ready: "Ready", attention: "Needs attention", running: "In progress", empty: "Not configured", error: "Unavailable" }[scenario]}</span>;
|
||||
}
|
||||
function Section({ title, children, label }: { title: string; children: ReactNode; label?: string }) {
|
||||
return <section className="rv-section">{label && <p className="rv-eyebrow">{label}</p>}<h3>{title}</h3>{children}</section>;
|
||||
}
|
||||
function Facts({ data }: { data: Array<[string, ReactNode]> }) { return <dl className="rv-facts">{data.map(([name,value]) => <div key={name}><dt>{name}</dt><dd>{value}</dd></div>)}</dl>; }
|
||||
function CopyValue({ value, label }: { value: string; label: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
return <div className="rv-code"><code>{value}</code><button aria-label={`Copy ${label}`} onClick={async () => { try { await navigator.clipboard.writeText(value); setCopied(true); } catch { setCopied(false); } }}>{copied ? <Check size={16} /> : <Copy size={16} />}</button></div>;
|
||||
}
|
||||
export type ConnectionValues = { host: string; port: string; database: string; user: string };
|
||||
export type ContentProps = {
|
||||
surface: Surface; workspace: typeof workspaces[number]; scenario: Scenario; item?: Item; section: number; editing: boolean;
|
||||
onEdit: () => void; onDirty: (v: boolean) => void; onCancel: () => void; onSave: (item?: Item) => void;
|
||||
onDelete: () => void; onNavigate: (s: Surface) => void; onOperation: (label: string) => void;
|
||||
onNotice: (text: string) => void; onSection: (section: number) => void; onScenario: (s: Scenario) => void;
|
||||
connectionValues?: ConnectionValues; onConnectionSave: (values: ConnectionValues) => void;
|
||||
};
|
||||
export function Content(p: ContentProps) {
|
||||
if (p.scenario === "error") return <div className="rv-content"><div className="rv-empty rv-error" role="alert"><CircleAlert size={28}/><h2>This page could not be loaded</h2><p>The service is unavailable. Your last saved data has not been changed.</p><Button primary onClick={() => p.onScenario("ready")}><RefreshCw size={15}/>Retry loading</Button></div></div>;
|
||||
if (p.scenario === "empty" && !p.editing) return <div className="rv-content"><div className="rv-empty"><FileText size={30}/><h2>{p.surface === "memory" ? "No Memory cards yet" : p.surface === "evidence" ? "No Evidence files in this workspace" : p.surface === "database" ? "Database not configured" : p.surface === "workspace" ? "Workspace preparation required" : "No available session models"}</h2><p>{p.surface === "memory" ? "Create a card to record a reusable clarification or an approved SQL rule." : p.surface === "evidence" ? "Create a Markdown file in the curated archive, then run consolidation to activate it." : p.surface === "database" ? "Configure one catalog binding for this workspace. The source database remains read-only." : p.surface === "workspace" ? "Check the source and configure the database before running preprocessing." : "Add eligible models to the Installation Model Catalog and reload the runtime."}</p>{p.surface === "database" && <Button primary onClick={() => { p.onScenario("ready"); p.onSection(1); }}>Configure catalog</Button>}{p.surface === "workspace" && <Button onClick={() => p.onNavigate("database")}>Open Database management</Button>}{p.surface === "evidence" && <CopyValue value={`evidence/curated/domain/new-rule.md`} label="example file path"/>}</div></div>;
|
||||
return <div className="rv-content">
|
||||
{p.scenario === "running" && <div className="rv-operation-banner" role="status"><Clock3 size={18}/><div><strong>Operation in progress</strong><p>The job belongs to {p.surface === "pi" ? "this installation" : p.workspace.name}. Changing pages does not cancel it.</p><div className="rv-progress" role="progressbar" aria-label="Illustrative operation progress" aria-valuenow={55} aria-valuemin={0} aria-valuemax={100}><span/></div><Button onClick={() => { p.onScenario("ready"); p.onNotice("Demonstration completed. The server was not contacted."); }}>Demo: complete operation</Button></div></div>}
|
||||
{p.surface === "workspace" ? <Workspace {...p}/> : p.surface === "database" ? <Catalog {...p}/> : p.surface === "evidence" ? <Evidence {...p}/> : p.surface === "memory" ? <Memory {...p}/> : <Pi {...p}/>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
function Workspace(p: ContentProps) {
|
||||
const [clear, setClear] = useState(false);
|
||||
const [secret, setSecret] = useState("");
|
||||
return <><div className="rv-record-heading"><div><p className="rv-eyebrow">Selected workspace</p><h2>{p.workspace.name}</h2><p className="rv-mono">{p.workspace.id}</p></div><LayersMark/></div>
|
||||
{p.section === 0 && <>
|
||||
<Section title="Preparation for analysis" label="Readiness"><p>Preprocessing builds the reference indexes for this workspace from its catalog snapshot and Evidence. Memory has its own independent index.</p><ol className="rv-checklist"><li><Check/><div><strong>Workspace source</strong><span>Active Git revision · <code>a71c9f2</code></span></div></li><li><Check/><div><strong>Database catalog</strong><span>{p.workspace.database} · Metadata revision 12</span></div><button onClick={() => p.onNavigate("database")} aria-label="Open database prerequisite"><ArrowUpRight size={17}/></button></li><li>{p.scenario === "attention" ? <CircleAlert/> : <Check/>}<div><strong>Reference indexes</strong><span>{p.scenario === "attention" ? "Built from revision 11. Run preprocessing to use revision 12." : "Current for metadata revision 12"}</span></div></li></ol></Section>
|
||||
<Section title="Preprocessing"><div className="rv-callout"><ShieldCheck size={20}/><div><strong>Workspace-scoped operation</strong><p>Uses the catalog snapshot, prepares schema and reference indexes, and includes configured Evidence. It does not modify the source database.</p></div></div><Facts data={[["Catalog revision", "12"],["Preprocessed revision", p.scenario === "attention" ? "11" : "12"],["Reference collection", `${p.workspace.id}_reference`],["Last successful run", "Today · 09:42 (sample)"]]}/><div className="rv-actions"><Button primary disabled={p.scenario === "running"} onClick={() => p.onOperation("Workspace preprocessing")}><Play size={15}/>Run preprocessing</Button><Button onClick={() => p.onNavigate("database")}><Database size={15}/>Open database</Button></div></Section>
|
||||
<details className="rv-disclosure"><summary>Clear derived data</summary><p>Remove schema/Evidence vectors, LSH and derived checkpoints. Keep the curated files, catalog, source database, Memory and sessions.</p>{clear ? <div className="rv-confirm"><strong>Clear derived data for {p.workspace.name}?</strong><div className="rv-actions"><Button onClick={() => { setClear(false); p.onScenario("attention"); p.onNotice("Derived data cleared in the simulation. Authoritative data was kept."); }}>Confirm clear</Button><Button onClick={() => setClear(false)}>Cancel</Button></div></div> : <Button disabled={p.scenario === "running"} onClick={() => setClear(true)}>Clear derived data…</Button>}</details>
|
||||
</>}
|
||||
{p.section === 1 && <><Section title="Source identity" label="Git-managed"><p>Change identity and Evidence source declarations in the workspace repository. Fetch and validate the candidate revision before activating it.</p><Facts data={[["Source file", `${p.workspace.id}/workspace.yaml`],["Active Git revision", "a71c9f2"],["Language", "Italian"],["Database binding", <button className="rv-text-button" onClick={() => p.onNavigate("database")}>{p.workspace.database} <ArrowUpRight size={14}/></button>]]}/><div className="rv-actions"><Button onClick={() => p.onNotice("Simulated source validation passed. No repository files were changed.")}>Validate workspace source</Button><Button onClick={() => p.onNotice("Simulated connection check passed.")}>Test workspace connections</Button></div></Section><Section title="Shared workspace repository"><p>Repository updates apply to all workspaces listed in the registry. An invalid candidate leaves the previous revision active.</p><Button disabled={p.scenario === "running"} onClick={() => p.onOperation("Shared workspace repository update")}><GitBranch size={15}/>Update workspace repository</Button></Section></>}
|
||||
{p.section === 2 && <Section title="Runtime secrets" label="Workspace requirements"><p>Only non-database runtime requirements belong here. Database credentials are maintained in Database management. Stored values are never revealed.</p><label className="rv-field">Evidence source access token <span className="rv-small">Illustrative optional connector requirement</span><input type="password" autoComplete="new-password" placeholder="Enter a replacement value" value={secret} onChange={e => { setSecret(e.target.value); p.onDirty(!!e.target.value); }}/></label><div className="rv-actions"><Button primary disabled={!secret} onClick={() => { setSecret(""); p.onSave(); }}>Save entered secret</Button><Button onClick={() => p.onNavigate("database")}>Database credentials</Button></div></Section>}
|
||||
</>;
|
||||
}
|
||||
function LayersMark() { return <span className="rv-record-emblem"><GitBranch size={24}/></span>; }
|
||||
|
||||
function Catalog(p: ContentProps) {
|
||||
const [tab, setTab] = useState("Columns");
|
||||
const [connection, setConnection] = useState(p.connectionValues ?? { host: "warehouse.internal", port: "5432", database: p.workspace.database, user: "readonly_analyst" });
|
||||
const [password, setPassword] = useState("");
|
||||
return <><div className="rv-record-heading"><div><p className="rv-eyebrow">{p.workspace.name} / Catalog</p><h2>{p.section === 0 ? `${p.workspace.schema}.${p.item?.title ?? "patients"}` : p.workspace.database}</h2><p>{p.section === 0 ? p.item?.detail : "One workspace binding · PostgreSQL · Read-only source access"}</p></div><span className="rv-record-emblem"><Database size={24}/></span></div>
|
||||
<div className="rv-crosslink"><span>Workspace preparation {p.scenario === "attention" ? "needs a refresh" : "uses metadata revision 12"}.</span><button onClick={() => p.onNavigate("workspace")}>Open workspace <ArrowUpRight size={14}/></button></div>
|
||||
{p.section === 0 && <><nav className="rv-subtabs" aria-label="Schema detail">{["Columns","Relationships"].map(t => <button key={t} aria-current={tab === t ? "page" : undefined} onClick={() => setTab(t)}>{t}</button>)}</nav>{tab === "Columns" ? <><div className="rv-table-scroll"><table><caption>Catalog columns · illustrative excerpt</caption><thead><tr><th>Column</th><th>Type</th><th>Description</th><th>Sensitivity</th></tr></thead><tbody>{[["patient_id","uuid","Identificativo del paziente","Identifier"],["recorded_at","timestamp","Data di registrazione","Not marked"],["status","varchar","Stato del record","Not marked"],["source_code","varchar","Codice del sistema origine","Needs review"]].map(([name,type,description,sensitivity]) => <tr key={name}><td><code>{name}</code></td><td><code>{type}</code></td><td>{description}</td><td><span className={sensitivity === "Needs review" ? "rv-tag rv-tag-warning" : "rv-tag"}>{sensitivity}</span></td></tr>)}</tbody></table></div><p className="rv-small">Column descriptions and sensitivity are catalog metadata. Source values are not displayed in this prototype.</p></> : <div className="rv-table-scroll"><table><caption>Catalog relationships</caption><thead><tr><th>From</th><th>To</th><th>Kind</th></tr></thead><tbody><tr><td><code>visits.patient_id</code></td><td><code>patients.patient_id</code></td><td>Foreign key</td></tr><tr><td><code>samples.patient_id</code></td><td><code>patients.patient_id</code></td><td>Foreign key</td></tr></tbody></table></div>}<div className="rv-actions"><Button disabled={p.scenario === "running"} onClick={() => p.onOperation("Schema synchronization")}><RefreshCw size={15}/>Synchronize schema</Button><Button onClick={() => p.onSection(2)}>Review metadata</Button></div></>}
|
||||
{p.section === 1 && <Section title="Connection settings"><p>Connection and credentials are scoped to this workspace binding. Saving a connection does not synchronize the schema.</p><form onSubmit={e => { e.preventDefault(); p.onConnectionSave(connection); }}><div className="rv-form-grid">{Object.entries(connection).map(([key,value]) => <label key={key} className="rv-field">{key[0].toUpperCase()+key.slice(1)}<input required disabled={!p.editing} value={value} onChange={e => { setConnection(s => ({ ...s, [key]: e.target.value })); p.onDirty(true); }}/></label>)}<label className="rv-field rv-span-all">Replacement password<input type="password" autoComplete="new-password" placeholder="Stored password remains hidden" disabled={!p.editing} value={password} onChange={e => { setPassword(e.target.value); p.onDirty(true); }}/></label></div><div className="rv-actions">{p.editing ? <><button className="rv-button rv-primary" type="submit">Save connection</button><Button onClick={p.onCancel}>Cancel</Button></> : <><Button primary onClick={p.onEdit}>Edit connection</Button><Button onClick={() => p.onNotice("Connection test passed in the simulation.")}>Test connection</Button></>}</div></form></Section>}
|
||||
{p.section === 2 && <><Section title="Metadata descriptions"><p>Generate draft descriptions from the catalog and permitted source samples. Review and consolidate them using the existing catalog workflow.</p><Facts data={[["Tables described", "4 of 4"],["Columns described", "62 of 70"],["Metadata model", "Installation catalog default"],["Scope", p.workspace.schema]]}/><Button primary disabled={p.scenario === "running"} onClick={() => p.onOperation("Description generation")}>Generate descriptions</Button></Section><Section title="Sensitivity analysis"><p>Review column sensitivity before sampling and analytical use. Source synchronization, description generation and sensitivity analysis remain distinct operations.</p><div className="rv-actions"><Button disabled={p.scenario === "running"} onClick={() => p.onOperation("Sensitivity analysis")}>Analyze sensitivity</Button><Button onClick={() => p.onNotice("Sample review: source_code requires an administrator’s sensitivity classification.")}>Review suggestions</Button></div></Section></>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Evidence(p: ContentProps) {
|
||||
const [decision, setDecision] = useState("");
|
||||
return <>
|
||||
{p.section === 0 && <><div className="rv-record-heading"><div><p className="rv-eyebrow">{p.item?.category ?? "Domain knowledge"}</p><h2>{p.item?.title}</h2><p><span className="rv-tag">Active locally</span> <span className="rv-small">Canonical Markdown archive</span></p></div><span className="rv-record-emblem"><FileText size={24}/></span></div><Section title="Content"><p className="rv-reading" lang="it">{p.item?.detail}</p></Section><Section title="Scope"><Facts data={[["Workspace", p.workspace.name],["Table", `${p.workspace.schema}.patients`],["Purpose", "Domain clarification · Schema linking"],["Source", "Manual declaration (sample)"]]}/></Section><Section title="File & provenance"><p>Edit this document with your preferred external editor. Consolidation activates valid local changes; Git review and push are manual.</p><CopyValue value={`evidence/curated/domain/${p.item?.id}.md`} label="relative Evidence path"/><div className="rv-actions"><Button onClick={() => p.onSection(2)}>Maintenance instructions</Button><Button onClick={() => p.onSection(1)}>Sources & comparisons</Button></div></Section></>}
|
||||
{p.section === 1 && <><div className="rv-record-heading"><div><p className="rv-eyebrow">Source maintenance</p><h2>Refresh deliberately, review changes</h2><p>External sources are reacquired only when requested. A manual correction remains in force until you resolve the comparison.</p></div></div><div className="rv-actions"><Button primary disabled={p.scenario === "running"} onClick={() => p.onOperation("Evidence source refresh")}>Import or refresh sources</Button></div><Section title="Source comparison" label="Illustrative review"><div className="rv-comparison"><div><p className="rv-eyebrow">Current manual correction</p><p lang="it">Contare i pazienti distinti con almeno una visita valida.</p></div><div><p className="rv-eyebrow">Refreshed source proposal</p><p lang="it">Contare i record delle visite nel periodo selezionato.</p></div></div><p>Replacing the content requires an explicit decision. Refresh alone does not overwrite it.</p>{decision ? <p role="status" className="rv-callout">{decision} (prototype only)</p> : <div className="rv-actions"><Button primary onClick={() => setDecision("Current correction kept")}>Keep current correction</Button><Button onClick={() => setDecision("Source proposal selected for activation")}>Use source proposal</Button></div>}</Section></>}
|
||||
{p.section === 2 && <><div className="rv-record-heading"><div><p className="rv-eyebrow">External editing</p><h2>Maintain the Evidence archive</h2><p>The browser helps you locate and verify knowledge. Your editor maintains the Markdown files.</p></div></div><ol className="rv-instructions"><li><strong>Edit the canonical files</strong><p>Use the installation host path shown by the real application. This demonstration shows relative paths only.</p><CopyValue value={`${p.workspace.id}/evidence/curated/`} label="archive directory"/></li><li><strong>Validate and consolidate</strong><p>Correct validation errors, then activate the local index. Source refresh is a separate operation.</p><CopyValue value={`tht --installation /path/to/thothii-installation.yaml workspace evidence consolidate --workspace ${p.workspace.id}`} label="consolidation command"/></li><li><strong>Review and publish with Git</strong><p>From the Evidence working tree, inspect the status and diff, then commit and push manually.</p><CopyValue value="git status\ngit diff" label="Git review commands"/></li></ol><Button onClick={() => p.onNotice("File list refreshed in the demonstration. No host files were read.")}><RefreshCw size={15}/>Refresh files</Button></>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Memory(p: ContentProps) {
|
||||
const [title,setTitle] = useState(p.item?.title ?? "");
|
||||
const [detail,setDetail] = useState(p.item?.detail ?? "");
|
||||
const [category,setCategory] = useState(p.item?.category ?? "Domain clarification");
|
||||
const [confirmDelete,setConfirmDelete] = useState(false);
|
||||
if (p.editing) return <form className="rv-memory-form" onSubmit={e => { e.preventDefault(); p.onSave({ id: p.item?.id ?? `demo-${Date.now()}`, title, detail, category, meta: "Manual correction · Indexed (demo)" }); }}><div className="rv-record-heading"><div><p className="rv-eyebrow">{p.workspace.name}</p><h2>{p.item ? "Edit Memory card" : "New Memory card"}</h2><p>Save an explicit, reusable rule. Changes stay in this prototype.</p></div></div><label className="rv-field">Title<input required value={title} onChange={e => {setTitle(e.target.value);p.onDirty(true);}}/></label><label className="rv-field">Family<select value={category} onChange={e => {setCategory(e.target.value);p.onDirty(true);}}>{["Domain clarification","SQL rule","Solved question","Explained error"].map(v=><option key={v}>{v}</option>)}</select></label><label className="rv-field">Content<textarea required rows={6} value={detail} onChange={e=>{setDetail(e.target.value);p.onDirty(true);}}/></label><div className="rv-actions"><button type="submit" className="rv-button rv-primary">Save card</button><Button onClick={p.onCancel}>Cancel</Button></div></form>;
|
||||
if (!p.item) return <div className="rv-empty"><h2>No cards to display</h2><p>Create a new card using the action above.</p></div>;
|
||||
return <><div className="rv-record-heading"><div><p className="rv-eyebrow">{p.item.category}</p><h2>{p.item.title}</h2><p><span className="rv-tag">{p.scenario === "attention" ? "Index update pending" : "Indexed"}</span> <span className="rv-small">Authoritative card available</span></p></div><Button onClick={p.onEdit}>Edit card</Button></div>
|
||||
{p.section === 0 && <><Section title="Reusable knowledge"><p className="rv-reading" lang="it">{p.item.detail}</p></Section><Section title="Rationale"><p lang="it">Il criterio rende esplicita l’unità di analisi e impedisce che le relazioni uno-a-molti alterino il risultato.</p></Section><Facts data={[["Origin", "Approved analytical session (sample)"],["Scope", `${p.workspace.schema}.patients`],["Workspace", p.workspace.name],["Retrieval projection", p.scenario === "attention" ? "Retry required" : "Current"]]}/><div className="rv-actions"><Button onClick={() => p.onSection(1)}>Dependencies & links</Button><Button onClick={() => setConfirmDelete(true)}>Delete card…</Button></div>{confirmDelete && <div className="rv-confirm"><strong>Delete “{p.item.title}” and its links?</strong><p>Other cards and Evidence are kept.</p><div className="rv-actions"><Button onClick={p.onDelete}>Confirm deletion</Button><Button onClick={()=>setConfirmDelete(false)}>Keep card</Button></div></div>}</>}
|
||||
{p.section === 1 && <><Section title="Schema dependencies"><p>Structured dependencies connect this card to the catalog. A successful physical schema synchronization can remove cards whose dependencies no longer exist.</p><Facts data={[["Database", p.workspace.database],["Schema", p.workspace.schema],["Table", "patients"],["Column", "patient_id"]]}/></Section><Section title="Linked knowledge"><div className="rv-linked"><FileText size={18}/><div><strong>Evitare il doppio conteggio nei join</strong><p>Related explained error · sample link</p></div></div><p>Links belong to the Memory archive. They do not make this card an Evidence unit.</p></Section></>}
|
||||
{p.section === 2 && <Section title="Retrieval index updates"><div className="rv-callout"><ShieldCheck size={20}/><div><strong>The authoritative card is preserved</strong><p>A failed projection does not delete saved content. Retry the retrieval-index update explicitly.</p></div></div>{p.scenario === "attention" ? <div className="rv-actions"><Button primary onClick={() => p.onOperation("Memory index retry")}>Retry index update</Button></div> : <p><Check size={16}/> No pending updates for this demonstration.</p>}</Section>}
|
||||
</>;
|
||||
}
|
||||
|
||||
function Pi(p: ContentProps) {
|
||||
const [logs,setLogs] = useState(false);
|
||||
const [platform,setPlatform] = useState("Linux");
|
||||
return <>
|
||||
<div className="rv-record-heading"><div><p className="rv-eyebrow">Installation scope</p><h2>{["Runtime & catalog default","Available session models","Sanitized diagnostics","Host maintenance"][p.section]}</h2><p>These settings apply across workspaces. The browser does not maintain a second model configuration.</p></div><span className="rv-record-emblem"><KeyRound size={24}/></span></div>
|
||||
{p.section === 0 && <><div className="rv-runtime"><div><p className="rv-eyebrow">Bundled Pi</p><strong>0.80.3 <small>sample version</small></strong></div><Status scenario={p.scenario}/></div><Section title="Installation catalog default"><Facts data={[["Provider", "zai"],["Model", "glm-5.3"],["Reasoning", "medium"],["Credentials", p.scenario === "attention" ? "Missing" : "Present · values hidden"]]}/><p>Change <code>modelCatalog.defaults.session</code> in the installation YAML and reload Pi to apply it.</p><div className="rv-actions"><Button primary disabled={p.scenario === "running"} onClick={() => p.onOperation("Catalog default test")}>Test catalog default</Button><Button onClick={() => p.onSection(3)}>Host instructions</Button></div></Section></>}
|
||||
{p.section === 1 && <div className="rv-model-list">{[["GLM 5.3","zai / glm-5.3","Default"],["DeepSeek V4 Pro","deepseek / deepseek-v4-pro","Available"],["Local Qwen","local-qwen / qwen3.6-35b-a3b","Available"]].map(([name,id,status]) => <div key={id}><div><strong>{name}</strong><code>{id}</code></div><span className="rv-tag">{status}</span></div>)}<p className="rv-small">Illustrative catalog. Availability in production is read from the installation.</p></div>}
|
||||
{p.section === 2 && <Section title="Request a diagnostic excerpt"><p>Fetch up to 200 sanitized lines only when requested. Credentials and declared secret values are removed.</p><Button onClick={() => setLogs(true)}>Show sanitized logs</Button>{logs && <pre className="rv-terminal" aria-label="Sanitized diagnostic example">{`[sample] Runtime ready\n[sample] Catalog default resolved\n[sample] Provider credential: [REDACTED]\n[sample] No active diagnostic errors`}</pre>}</Section>}
|
||||
{p.section === 3 && <><nav className="rv-subtabs" aria-label="Host platform">{["Linux","macOS","Windows"].map(v=><button key={v} onClick={()=>setPlatform(v)} aria-current={platform===v?"page":undefined}>{v}</button>)}</nav><Section title={`Maintain Pi on ${platform}`}><p>Run these commands on the installation host. Review active sessions before a restart or version update.</p><ol className="rv-instructions"><li><strong>Edit the single configuration source</strong><CopyValue value={platform === "Windows" ? "deploy\\<installation-id>\\thothii-installation.yaml" : "deploy/<installation-id>/thothii-installation.yaml"} label="installation path"/></li><li><strong>Reload and verify</strong><CopyValue value="tht pi restart --yes --drain\ntht pi doctor\ntht pi test" label="Pi maintenance commands"/></li></ol><p className="rv-small">Commands are examples to review and copy; nothing is executed from this prototype.</p></Section></>}
|
||||
</>;
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
# Administration design review V2
|
||||
|
||||
Throwaway prototype, 2026-09-10. **No variant selected yet.**
|
||||
|
||||
Question: which page structure best supports the five existing administrative
|
||||
workflows inside the space allocated by Omics Portal?
|
||||
|
||||
## Run
|
||||
|
||||
From `frontend/`:
|
||||
|
||||
```sh
|
||||
npm run prototype:administration-review
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:5175/?thoth_route=administration/workspace&variant=A`.
|
||||
The development-only configuration rejects production builds. No production
|
||||
imports, API requests, real credentials, patient records or host writes are used.
|
||||
Google Fonts loads the same Manrope/Fraunces families as the current frontend;
|
||||
system/Georgia fallbacks remain usable offline.
|
||||
|
||||
The original prototype remains untouched at `../administration-pages/` on port
|
||||
5174, preserved in commit `debb63d8` on `codex/prototype-administration-pages`.
|
||||
The incomplete production changes already present in the working tree were not
|
||||
continued by this review. The previous specification/tickets are suspended drafts.
|
||||
|
||||
## Confirmed after the renewed grill
|
||||
|
||||
1. Preserve existing features and authoring/persistence contracts. Workspace
|
||||
definitions are Git-managed; Evidence uses external Markdown editing and local
|
||||
consolidation; Memory has web CRUD; Database owns its catalog binding and
|
||||
metadata operations; Pi configuration remains installation-authored.
|
||||
2. Share the administrative Workspace selection across relevant pages, without
|
||||
changing the Workspace selected for core work. Pi has installation scope.
|
||||
3. Share visual hierarchy and interaction conventions; allow each module's content
|
||||
and internal structure to differ. Do not invent a resource list for Pi.
|
||||
4. Use explicit saves and unsaved-change protection, including browser traversal.
|
||||
Preserve filters and selection. Leaving a page must not implicitly cancel an
|
||||
existing server-owned job. Actual API/job recovery limits must be audited in the
|
||||
subsequent spec; the prototype only models the intended experience.
|
||||
5. **Final mounting and integration happen on the server**, not in the owner's
|
||||
local Omics Portal checkout. If Omics Portal code changes, the owner can pull
|
||||
those changes locally afterward. Record server integration as an explicit
|
||||
delivery/validation step, respecting existing operational migration gates.
|
||||
6. Five peer pages, right-side navigation, English UI chrome and workspace-language
|
||||
content remain accepted. Namespaced `thoth_route` supports page restoration;
|
||||
`thoth_workspace` holds the stable administrative selection.
|
||||
7. Full preprocessing belongs to Workspace readiness, with Database prerequisites
|
||||
and cross-links. Each workspace currently has at most one catalog binding;
|
||||
this design does not introduce shared database entities or a many-to-many model.
|
||||
8. Preserve all prototypes. Proceed to `to-spec` and `to-tickets` after the owner
|
||||
selects the new layout(s). No new specification or tracker tickets were
|
||||
published in this review turn.
|
||||
|
||||
## Alternatives
|
||||
|
||||
| Variant | Structure | Useful when | Tradeoff |
|
||||
| --- | --- | --- | --- |
|
||||
| A: Workbench | Persistent list/section navigation next to detail; Pi uses its installation sections | Frequent switching between records, catalog inspection and Memory curation | The detail pane has less width; narrow containers stack the areas |
|
||||
| B: Focused pages | Full-width list, followed by a full record page with section navigation and return to list | Sustained reading and editing; Evidence and complex forms | Switching records takes a return to the list |
|
||||
| C: Operations first | Contextual operational summary, section navigation, collapsible record choice and detail | Workspace preparation, diagnostics and occasional maintenance | More vertical space and less immediate access to the full record list |
|
||||
|
||||
All five routes render domain-specific content in all three variants. This is a
|
||||
representative interaction prototype, not an exhaustive replacement for existing
|
||||
forms. Advanced Memory link editing, all Database grids, detailed review histories,
|
||||
permissions and full API validation are implementation/spec work, not new scope.
|
||||
|
||||
The V2 prototype uses a separate development entry beside the previous prototype
|
||||
because the production worktree contains interrupted, unapproved implementation.
|
||||
Its portal frame represents header/sidebar geometry only; it is not a running
|
||||
Django/Bootstrap integration. No Omics Portal files were changed.
|
||||
|
||||
## What to try
|
||||
|
||||
- Switch all five modules from the right rail (the navigation button when narrow).
|
||||
- Change Administrative workspace, then open another module. Core workspace stays
|
||||
PSD Clinical; Pi explicitly says All workspaces.
|
||||
- Open a Memory card, edit its title/content and save. Attempt navigation while
|
||||
dirty and cancel the browser confirmation. Create/delete sample cards as well.
|
||||
- Open Database Connection, edit the sample host and save it in memory.
|
||||
- Follow Database to Workspace and back. The administrative selection is retained.
|
||||
- Start a simulated preprocessing job, visit Pi, return and complete the demo job.
|
||||
- Open Evidence maintenance/source comparison and Pi diagnostics/host instructions.
|
||||
- Use Review settings to simulate ready/attention/running/empty/error states and
|
||||
960/390px host containers independently of browser width.
|
||||
- Use Back/Forward and reload. Module, Workspace and variant are in the URL;
|
||||
fixtures, edits and simulated jobs reset on reload. Real job resumption requires
|
||||
the subsequent backend-aware implementation.
|
||||
|
||||
Layout variants are selectable with the bottom review bar, or left/right arrows
|
||||
outside interactive controls. These controls are not proposed production UI.
|
||||
|
||||
## Design decisions
|
||||
|
||||
Impeccable's product guidance informs a daylight-office, warm-light presentation:
|
||||
institutional bordeaux host header, restrained action color, distinct neutral
|
||||
surfaces for navigation and content, readable statuses with icons and words.
|
||||
Manrope carries operational controls and A/C headings; B uses Fraunces for the page
|
||||
and record titles. Prose has bounded line lengths, forms use consistent labels,
|
||||
and responsive changes depend on container width. The host owns its red topbar;
|
||||
Thoth does not add a second red page banner.
|
||||
|
||||
## Verification performed
|
||||
|
||||
- Isolated TypeScript check of the V2 entry passed.
|
||||
- Chromium: 3 variants × 5 modules × 4 viewport widths (1600, 1280, 960, 390):
|
||||
60 initial views with no document-level horizontal overflow or runtime errors.
|
||||
Tables can scroll inside their own bounded container.
|
||||
- A 390px simulated host within a 1600px browser had a 372px content area and no
|
||||
overflow beyond the host. The inline navigation collapsed at container width.
|
||||
- Twelve browser behavior checks passed: unsaved navigation cancellation, Memory
|
||||
save, shared administrative context/core isolation, Pi scope, job navigation,
|
||||
connection save, Back, routed Workspace on reload, B detail/return, mobile
|
||||
navigation, creation from an empty Memory state, and error/retry.
|
||||
- Desktop A/B/C and mobile screenshots were visually inspected. The prototype
|
||||
was opened in the in-app browser for review.
|
||||
- Standalone Playwright WebKit was unavailable (browser executable not installed).
|
||||
No cross-browser, real-device, Bootstrap collision, authentication, backend or
|
||||
server-integration acceptance is claimed by these prototype checks.
|
||||
|
||||
Verdict pending the owner's selection. The next specification must distinguish
|
||||
demonstrated UI decisions from simulated data and behavior.
|
||||
@@ -0,0 +1,15 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ThothII · Administration design review</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Fraunces:opsz,wght@9..144,500;9..144,600&display=swap" rel="stylesheet" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./review.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>);
|
||||
@@ -0,0 +1,51 @@
|
||||
// Synthetic data only. These are presentation fixtures, never API payloads.
|
||||
export type Surface = "workspace" | "database" | "evidence" | "memory" | "pi";
|
||||
export type Variant = "A" | "B" | "C";
|
||||
export type Scenario = "ready" | "attention" | "running" | "empty" | "error";
|
||||
export type Item = { id: string; title: string; category: string; detail: string; meta: string };
|
||||
export const variants = { A: "Workbench", B: "Focused pages", C: "Operations first" };
|
||||
export const workspaces = [
|
||||
{ id: "psd-clinical", name: "PSD Clinical", database: "clinical_dwh", schema: "clinical" },
|
||||
{ id: "genetics", name: "Genetics research", database: "genetics_dwh", schema: "research" },
|
||||
{ id: "sandbox", name: "Research sandbox", database: "sandbox_dwh", schema: "sandbox" },
|
||||
];
|
||||
export const modules: Record<Surface, { title: string; subtitle: string; noun: string; primary: string; sections: string[] }> = {
|
||||
workspace: { title: "Workspace management", subtitle: "Source identity, runtime requirements and preparation for analysis.", noun: "Workspaces", primary: "Run preprocessing", sections: ["Readiness", "Source & configuration", "Runtime secrets"] },
|
||||
database: { title: "Database management", subtitle: "Connection settings and the metadata used to understand your data.", noun: "Schema tables", primary: "Synchronize schema", sections: ["Schema", "Connection", "Descriptions & sensitivity"] },
|
||||
evidence: { title: "Evidence management", subtitle: "Read domain knowledge, locate source files and maintain the local archive.", noun: "Evidence units", primary: "Maintenance instructions", sections: ["Library", "Sources & comparisons", "Maintenance"] },
|
||||
memory: { title: "Memory management", subtitle: "Review and refine knowledge retained from analytical work.", noun: "Memory cards", primary: "New card", sections: ["Library", "Dependencies & links", "Index updates"] },
|
||||
pi: { title: "Pi management", subtitle: "Inspect the installation runtime, available models and diagnostics.", noun: "Installation", primary: "Test catalog default", sections: ["Runtime", "Session models", "Diagnostics", "Host instructions"] },
|
||||
};
|
||||
export const evidence: Item[] = [
|
||||
{ id: "ev-population", title: "Popolazione clinica di riferimento", category: "Domain rule", detail: "Il conteggio della popolazione deve riferirsi ai pazienti distinti. La presenza di più visite non incrementa il numero di pazienti.", meta: "Manual declaration · Active locally" },
|
||||
{ id: "ev-visit", title: "Visita e follow-up", category: "Glossary", detail: "Una visita rappresenta un evento clinico. Il follow-up descrive un controllo successivo alla valutazione iniziale.", meta: "Source document · Active locally" },
|
||||
{ id: "ev-status", title: "Stato del campione", category: "Enumeration", detail: "Lo stato del campione distingue il materiale raccolto, in lavorazione e validato. I valori mancanti non equivalgono a un esito negativo.", meta: "Manual declaration · Active locally" },
|
||||
{ id: "ev-date", title: "Intervallo temporale di osservazione", category: "SQL example", detail: "Esplicitare sempre le date di inizio e fine dell’osservazione. Un intervallo semiaperto evita di escludere eventi dell’ultimo giorno.", meta: "Source document · Active locally" },
|
||||
];
|
||||
export const memories: Item[] = [
|
||||
{ id: "mem-count", title: "Conteggio dei pazienti distinti", category: "SQL rule", detail: "Quando la domanda riguarda il numero di pazienti, usare COUNT(DISTINCT patient_id) dopo il collegamento con le visite.", meta: "Approved in a session · Indexed" },
|
||||
{ id: "mem-active", title: "Significato di paziente attivo", category: "Domain clarification", detail: "Un paziente è attivo nel periodo osservato se presenta almeno una visita valida nel periodo richiesto.", meta: "Manual correction · Indexed" },
|
||||
{ id: "mem-question", title: "Pazienti con almeno due visite", category: "Solved question", detail: "Raggruppare per paziente e filtrare i gruppi con almeno due visite, mantenendo esplicito l’intervallo temporale.", meta: "Approved in a session · Indexed" },
|
||||
{ id: "mem-error", title: "Evitare il doppio conteggio nei join", category: "Explained error", detail: "Il join tra due relazioni uno-a-molti può moltiplicare le righe. Aggregare prima del collegamento quando la misura lo richiede.", meta: "Approved correction · Indexed" },
|
||||
];
|
||||
export const tables: Item[] = [
|
||||
{ id: "patients", title: "patients", category: "18 columns", detail: "Anagrafica dei pazienti e identificativi di collegamento con gli eventi clinici.", meta: "Primary key: patient_id · 3 relationships" },
|
||||
{ id: "visits", title: "visits", category: "24 columns", detail: "Eventi clinici associati ai pazienti, con data, tipo e stato della visita.", meta: "Primary key: visit_id · 2 relationships" },
|
||||
{ id: "samples", title: "samples", category: "12 columns", detail: "Campioni biologici raccolti durante il percorso clinico.", meta: "Primary key: sample_id · 2 relationships" },
|
||||
{ id: "observations", title: "observations", category: "16 columns", detail: "Osservazioni e misurazioni cliniche registrate durante le visite.", meta: "Primary key: observation_id · 1 relationship" },
|
||||
];
|
||||
export const piItems: Item[] = [
|
||||
{ id: "runtime", title: "Runtime & default", category: "Installation", detail: "Bundled Pi runtime and the catalog default for new sessions.", meta: "Read-only configuration" },
|
||||
{ id: "models", title: "Session models", category: "Model catalog", detail: "Models available to the interactive workflow.", meta: "Installation-defined eligibility" },
|
||||
{ id: "diagnostics", title: "Sanitized diagnostics", category: "On demand", detail: "Request a bounded excerpt without exposing declared secrets.", meta: "Up to 200 lines" },
|
||||
{ id: "host", title: "Host instructions", category: "Operator guide", detail: "Maintain the installation configuration with the native tht CLI.", meta: "Linux · macOS · Windows" },
|
||||
];
|
||||
export const workspaceItems: Item[] = workspaces.map(w => ({ id: w.id, title: w.name, category: "Git-managed workspace", detail: "Identity and Evidence sources are maintained in the workspace repository.", meta: w.database }));
|
||||
export const inventory: Record<Surface, Item[]> = { workspace: workspaceItems, database: tables, evidence, memory: memories, pi: piItems };
|
||||
export function readLocation() {
|
||||
const q = new URLSearchParams(location.search);
|
||||
const surface = q.get("thoth_route")?.split("/").at(-1) as Surface;
|
||||
const variant = q.get("variant") as Variant;
|
||||
const workspace = q.get("thoth_workspace");
|
||||
return { surface: surface in modules ? surface : "workspace" as Surface, variant: variant in variants ? variant : "A" as Variant, workspace: workspaces.some(w => w.id === workspace) ? workspace! : "psd-clinical" };
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
/* Product scene: an analyst maintaining clinical knowledge on a daylight office
|
||||
workstation. Warm light surfaces match the host; bordeaux marks actions and
|
||||
location. Fixed-size typography keeps forms predictable inside the portal. */
|
||||
:root {
|
||||
--rv-red: oklch(55.87% .1881 23.2);
|
||||
--rv-red-dark: oklch(47% .155 23.2);
|
||||
--rv-red-soft: oklch(96% .018 23.2);
|
||||
--rv-ink: oklch(28% .012 355);
|
||||
--rv-muted: oklch(49% .012 355);
|
||||
--rv-paper: oklch(99.2% .002 20);
|
||||
--rv-worktop: oklch(96.5% .005 20);
|
||||
--rv-sunken: oklch(93% .007 20);
|
||||
--rv-line: oklch(87.5% .009 20);
|
||||
--rv-green: oklch(41% .075 156);
|
||||
--rv-green-bg: oklch(95% .02 156);
|
||||
--rv-amber: oklch(42% .085 73);
|
||||
--rv-amber-bg: oklch(96% .035 83);
|
||||
--rv-blue: oklch(43% .065 246);
|
||||
--rv-blue-bg: oklch(95% .018 246);
|
||||
--rv-sans: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||
--rv-serif: "Fraunces", Georgia, serif;
|
||||
--rv-mono: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
color: var(--rv-ink); background: var(--rv-sunken); font-family: var(--rv-sans);
|
||||
font-size: 16px; font-synthesis: none;
|
||||
}
|
||||
body { margin: 0; }
|
||||
.rv-host *, .rv-lab * { box-sizing: border-box; }
|
||||
.rv-host, .rv-lab { line-height: 1.5; }
|
||||
.rv-host button, .rv-host input, .rv-host select, .rv-host textarea, .rv-lab button, .rv-lab select { font: inherit; }
|
||||
.rv-host button, .rv-lab button { cursor: pointer; }
|
||||
.rv-host button:disabled { cursor: not-allowed; opacity: .5; }
|
||||
.rv-host button, .rv-lab button { color: inherit; }
|
||||
.rv-host :is(button,a,input,select,textarea,summary):focus-visible, .rv-lab :is(button,select,summary):focus-visible { outline: 3px solid var(--rv-red); outline-offset: 3px; }
|
||||
.rv-host :is(h1,h2,h3,p) { margin: 0; }
|
||||
.rv-host h1 { font-size: 1.65rem; line-height: 1.2; letter-spacing: -.045em; font-weight: 800; text-wrap: balance; }
|
||||
.rv-host h2 { font-size: 1.25rem; line-height: 1.35; letter-spacing: -.025em; font-weight: 750; overflow-wrap: anywhere; }
|
||||
.rv-host h3 { font-size: 1rem; font-weight: 750; }
|
||||
.rv-host p { max-width: 72ch; }
|
||||
.rv-host code, .rv-mono { font-family: var(--rv-mono); font-size: .8rem; overflow-wrap: anywhere; }
|
||||
.rv-host svg { flex-shrink: 0; vertical-align: middle; }
|
||||
.rv-host { container: portal / inline-size; background: var(--rv-worktop); min-height: calc(100dvh - 72px); max-width: 100%; margin: 0 auto 110px; box-shadow: 0 0 0 1px var(--rv-line); }
|
||||
.rv-host-compact { width: 960px; }
|
||||
.rv-host-phone { width: 390px; }
|
||||
.rv-topbar { display: flex; align-items: center; gap: 18px; padding: 0 24px; min-height: 64px; color: var(--rv-paper); background: var(--rv-red); }
|
||||
.rv-topbar > button { border: 0; background: transparent; color: inherit; padding: 10px; }
|
||||
.rv-topbar strong { font-size: 1.25rem; letter-spacing: -.035em; white-space: nowrap; }
|
||||
.rv-topbar strong span { font-weight: 400; }
|
||||
.rv-topbar-location { font-size: .8rem; }
|
||||
.rv-topbar-divider { height: 22px; width: 1px; background: oklch(95% .005 20 / .35); }
|
||||
.rv-topbar-user { margin-left: auto; display: flex; align-items: center; gap: 14px; font-size: .75rem; }
|
||||
.rv-avatar { display: grid; place-items: center; width: 32px; height: 32px; background: oklch(95% .005 20 / .2); border: 1px solid oklch(95% .005 20 / .3); border-radius: 50%; font-size: .7rem; font-weight: 800; }
|
||||
.rv-host-body { display: grid; grid-template-columns: 208px minmax(0,1fr); }
|
||||
.rv-portal-nav { border-right: 1px solid var(--rv-line); padding: 28px 14px; background: var(--rv-paper); }
|
||||
.rv-portal-nav > p { font-size: .65rem; font-weight: 800; letter-spacing: .14em; padding: 0 14px 14px; color: var(--rv-muted); }
|
||||
.rv-portal-nav > div { display: flex; align-items: center; gap: 12px; padding: 13px 14px; font-size: .8rem; margin-bottom: 4px; border-radius: 5px; color: var(--rv-muted); }
|
||||
.rv-portal-nav > .rv-portal-current { color: var(--rv-red-dark); background: var(--rv-red-soft); font-weight: 800; }
|
||||
.rv-portal-nav > small { display: block; padding: 20px 14px; font-size: .65rem; color: var(--rv-muted); }
|
||||
.rv-host-collapsed .rv-host-body { grid-template-columns: 64px minmax(0,1fr); }
|
||||
.rv-host-collapsed .rv-portal-nav { padding-inline: 8px; }
|
||||
.rv-host-collapsed .rv-portal-nav > div { padding-inline: 14px; }
|
||||
.rv-host-collapsed .rv-portal-nav :is(span,p,small) { display: none; }
|
||||
.rv-mount { min-width: 0; padding: 0 22px 24px; }
|
||||
.rv-breadcrumb { min-height: 52px; display: flex; align-items: center; flex-wrap: wrap; gap: 9px; font-size: .7rem; color: var(--rv-muted); }
|
||||
.rv-breadcrumb > span:last-child { margin-left: auto; }
|
||||
.rv-thoth { display: grid; grid-template-columns: minmax(0,1fr) 174px; align-items: start; background: var(--rv-paper); border: 1px solid var(--rv-line); border-radius: 7px; container: thoth / inline-size; isolation: isolate; }
|
||||
.rv-page { min-width: 0; grid-column: 1; grid-row: 1; container: page / inline-size; }
|
||||
.rv-admin-rail { grid-column: 2; grid-row: 1; align-self: stretch; border-left: 1px solid var(--rv-line); background: var(--rv-worktop); padding: 25px 12px; }
|
||||
.rv-eyebrow { color: var(--rv-muted); font-size: .65rem; font-weight: 800; letter-spacing: .08em; text-transform: uppercase; }
|
||||
.rv-eyebrow > span { font-weight: 500; letter-spacing: .02em; }
|
||||
.rv-nav-title { display: block; font-size: .8rem; margin: 5px 0 22px; }
|
||||
.rv-admin-rail nav { display: grid; gap: 5px; }
|
||||
.rv-admin-rail a { text-decoration: none; display: flex; align-items: center; gap: 9px; padding: 11px 9px; border: 1px solid transparent; border-radius: 5px; font-size: .78rem; font-weight: 650; color: var(--rv-muted); }
|
||||
.rv-admin-rail a > svg:last-child:not(:first-child) { margin-left: auto; }
|
||||
.rv-admin-rail a:hover { background: var(--rv-sunken); color: var(--rv-ink); }
|
||||
.rv-admin-rail a[aria-current] { color: var(--rv-red-dark); border-color: oklch(86% .055 23); background: var(--rv-red-soft); font-weight: 800; }
|
||||
.rv-rail-note { border-top: 1px solid var(--rv-line); margin-top: 30px; padding-top: 18px; font-size: .7rem; }
|
||||
.rv-rail-note strong { display: block; margin-top: 4px; }
|
||||
.rv-rail-note small { display: block; color: var(--rv-muted); margin-top: 10px; line-height: 1.6; }
|
||||
.rv-context { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 11px 25px; border-bottom: 1px solid var(--rv-line); font-size: .7rem; background: var(--rv-worktop); border-radius: 7px 0 0 0; }
|
||||
.rv-context > span:first-child { color: var(--rv-muted); }
|
||||
.rv-context select { background: var(--rv-paper); border: 1px solid var(--rv-line); padding: 6px 26px 6px 9px; border-radius: 4px; font-size: .75rem; font-weight: 700; max-width: 100%; }
|
||||
.rv-context-core { margin-left: auto; color: var(--rv-muted); font-size: .65rem; }
|
||||
.rv-page-heading { display: flex; align-items: flex-start; flex-wrap: wrap; justify-content: space-between; gap: 20px; padding: 30px 28px 22px; }
|
||||
.rv-page-heading > div { flex: 1 1 290px; min-width: 0; }
|
||||
.rv-page-heading h1 { margin: 8px 0 10px; }
|
||||
.rv-lede { color: var(--rv-muted); font-size: .8rem; line-height: 1.65; }
|
||||
.rv-button { min-height: 38px; border: 1px solid var(--rv-line); border-radius: 5px; padding: 9px 13px; display: inline-flex; align-items: center; justify-content: center; gap: 7px; font-size: .75rem !important; font-weight: 750 !important; background: var(--rv-paper); text-align: center; line-height: 1.35; }
|
||||
.rv-button:hover { border-color: var(--rv-muted); background: var(--rv-worktop); }
|
||||
.rv-button.rv-primary { background: var(--rv-red); color: var(--rv-paper); border-color: var(--rv-red); }
|
||||
.rv-button.rv-primary:hover { background: var(--rv-red-dark); border-color: var(--rv-red-dark); }
|
||||
.rv-statusline { display: flex; align-items: center; flex-wrap: wrap; gap: 12px; padding: 11px 28px; border-block: 1px solid var(--rv-line); font-size: .68rem; color: var(--rv-muted); }
|
||||
.rv-sample-note { margin-left: auto; }
|
||||
.rv-status { display: inline-flex; align-items: center; gap: 5px; padding: 4px 7px; font-size: .68rem; line-height: 1.35; font-weight: 800; border-radius: 4px; color: var(--rv-green); background: var(--rv-green-bg); white-space: nowrap; }
|
||||
.rv-status-attention, .rv-status-empty { color: var(--rv-amber); background: var(--rv-amber-bg); }
|
||||
.rv-status-running { color: var(--rv-blue); background: var(--rv-blue-bg); }
|
||||
.rv-status-error { color: var(--rv-red-dark); background: var(--rv-red-soft); }
|
||||
.rv-tabs { display: flex; gap: 22px; padding: 0 24px; overflow-x: auto; border-bottom: 1px solid var(--rv-line); }
|
||||
.rv-tabs button, .rv-subtabs button { flex-shrink: 0; background: transparent; border: 0; border-bottom: 2px solid transparent; padding: 15px 0; font-size: .75rem; font-weight: 700; color: var(--rv-muted); }
|
||||
.rv-tabs button[aria-current], .rv-subtabs button[aria-current] { color: var(--rv-red-dark); border-bottom-color: var(--rv-red); }
|
||||
.rv-index { padding: 20px 16px; min-width: 0; }
|
||||
.rv-index-title { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 16px; }
|
||||
.rv-index-title h2 { font-size: .8rem; letter-spacing: 0; }
|
||||
.rv-index-title > span { font-family: var(--rv-mono); font-size: .7rem; color: var(--rv-muted); }
|
||||
.rv-search { display: flex; gap: 8px; align-items: center; background: var(--rv-paper); border: 1px solid var(--rv-line); border-radius: 4px; padding: 9px; color: var(--rv-muted); }
|
||||
.rv-search input { border: 0; background: transparent; padding: 0; min-width: 0; width: 100%; font-size: .7rem; color: var(--rv-ink); }
|
||||
.rv-records { display: grid; gap: 5px; margin-top: 16px; }
|
||||
.rv-record { display: block; position: relative; width: 100%; text-align: left; border: 1px solid transparent; border-radius: 5px; background: transparent; padding: 13px 24px 13px 12px; }
|
||||
.rv-record:hover { background: var(--rv-worktop); }
|
||||
.rv-record[aria-current] { border-color: oklch(84% .04 23); background: var(--rv-red-soft); }
|
||||
.rv-record small { display: block; font-size: .6rem; color: var(--rv-muted); margin-bottom: 4px; }
|
||||
.rv-record strong { display: block; font-size: .8rem; line-height: 1.5; overflow-wrap: anywhere; }
|
||||
.rv-record > span { display: block; font-size: .63rem; color: var(--rv-muted); margin-top: 6px; }
|
||||
.rv-record > svg { position: absolute; top: 42%; right: 6px; color: var(--rv-muted); }
|
||||
.rv-workbench { display: grid; grid-template-columns: minmax(0,1fr); }
|
||||
.rv-workbench > .rv-index { background: var(--rv-worktop); border-bottom: 1px solid var(--rv-line); }
|
||||
.rv-workbench-detail, .rv-operation-detail { min-width: 0; }
|
||||
.rv-content { padding: 24px; min-width: 0; }
|
||||
.rv-record-heading { display: flex; align-items: flex-start; justify-content: space-between; gap: 20px; margin-bottom: 28px; }
|
||||
.rv-record-heading > div { min-width: 0; }
|
||||
.rv-record-heading h2 { margin: 7px 0 10px; }
|
||||
.rv-record-heading p:not(.rv-eyebrow) { font-size: .78rem; color: var(--rv-muted); }
|
||||
.rv-record-emblem { width: 44px; height: 44px; flex-shrink: 0; display: grid; place-items: center; color: var(--rv-red-dark); background: var(--rv-red-soft); border: 1px solid oklch(88% .03 23); border-radius: 8px; }
|
||||
.rv-section { margin: 26px 0; }
|
||||
.rv-section:first-child { margin-top: 0; }
|
||||
.rv-section > .rv-eyebrow { margin-bottom: 6px; }
|
||||
.rv-section > h3 { margin-bottom: 12px; }
|
||||
.rv-section > p, .rv-disclosure > p, .rv-instructions p { font-size: .85rem; line-height: 1.8; color: var(--rv-muted); margin: 10px 0 16px; }
|
||||
.rv-section > p.rv-reading { font-size: 1rem; color: var(--rv-ink); line-height: 1.85; }
|
||||
.rv-facts { display: grid; grid-template-columns: minmax(0,1fr); margin: 18px 0; gap: 0 24px; }
|
||||
.rv-facts > div { border-top: 1px solid var(--rv-line); padding: 13px 0; min-width: 0; }
|
||||
.rv-facts dt { font-size: .7rem; color: var(--rv-muted); margin-bottom: 5px; }
|
||||
.rv-facts dd { margin: 0; font-size: .8rem; font-weight: 650; overflow-wrap: anywhere; }
|
||||
.rv-actions { display: flex; flex-wrap: wrap; gap: 9px; margin-top: 20px; }
|
||||
.rv-callout { display: flex; align-items: flex-start; gap: 13px; padding: 16px; background: var(--rv-blue-bg); border: 1px solid oklch(85% .025 246); border-radius: 5px; color: var(--rv-blue); }
|
||||
.rv-callout strong { display: block; font-size: .8rem; }
|
||||
.rv-callout p { font-size: .78rem; line-height: 1.75; margin-top: 6px; }
|
||||
.rv-checklist { list-style: none; margin: 18px 0; padding: 0; }
|
||||
.rv-checklist li { display: flex; align-items: flex-start; gap: 12px; padding: 14px 0; border-bottom: 1px solid var(--rv-line); }
|
||||
.rv-checklist li > svg { width: 17px; height: 17px; color: var(--rv-green); margin-top: 3px; }
|
||||
.rv-checklist li strong { font-size: .8rem; display: block; }
|
||||
.rv-checklist li span { display: block; font-size: .75rem; color: var(--rv-muted); margin-top: 4px; }
|
||||
.rv-checklist button { margin-left: auto; background: transparent; border: 1px solid var(--rv-line); border-radius: 4px; padding: 7px; }
|
||||
.rv-disclosure, .rv-choose-record { border: 1px solid var(--rv-line); border-radius: 5px; padding: 14px 16px; margin-top: 24px; }
|
||||
.rv-disclosure summary, .rv-choose-record summary { cursor: pointer; font-weight: 700; font-size: .78rem; }
|
||||
.rv-confirm { padding: 18px; background: var(--rv-red-soft); border: 1px solid oklch(85% .06 23); border-radius: 5px; font-size: .85rem; margin-top: 20px; }
|
||||
.rv-field { display: grid; min-width: 0; gap: 8px; margin-bottom: 18px; font-size: .8rem; font-weight: 700; }
|
||||
.rv-field :is(input,textarea,select) { min-width: 0; width: 100%; background: var(--rv-paper); border: 1px solid var(--rv-line); color: var(--rv-ink); padding: 10px 12px; border-radius: 4px; font-size: .9rem; line-height: 1.6; font-weight: 450; }
|
||||
.rv-field :is(input,textarea,select):disabled { opacity: 1; background: var(--rv-worktop); color: var(--rv-muted); }
|
||||
.rv-field textarea { resize: vertical; }
|
||||
.rv-form-grid { display: grid; grid-template-columns: minmax(0,1fr); column-gap: 18px; }
|
||||
.rv-span-all { grid-column: 1 / -1; }
|
||||
.rv-small { font-size: .72rem; color: var(--rv-muted); line-height: 1.65; font-weight: 450; }
|
||||
.rv-text-button, .rv-crosslink button { display: inline-flex; gap: 5px; align-items: center; background: transparent; color: var(--rv-red-dark) !important; border: 0; padding: 0; text-decoration: underline; text-underline-offset: 3px; text-align: left; }
|
||||
.rv-crosslink { display: flex; justify-content: space-between; flex-wrap: wrap; gap: 10px; background: var(--rv-worktop); padding: 12px 14px; border-radius: 4px; margin: 0 0 20px; font-size: .72rem; }
|
||||
.rv-subtabs { display: flex; gap: 24px; border-bottom: 1px solid var(--rv-line); margin-bottom: 14px; }
|
||||
.rv-table-scroll { overflow-x: auto; max-width: 100%; }
|
||||
.rv-table-scroll table { width: 100%; border-collapse: collapse; font-size: .75rem; }
|
||||
.rv-table-scroll caption { text-align: left; padding: 10px 0; font-size: .65rem; color: var(--rv-muted); }
|
||||
.rv-table-scroll th { background: var(--rv-worktop); font-size: .65rem; text-align: left; font-weight: 800; white-space: nowrap; }
|
||||
.rv-table-scroll td, .rv-table-scroll th { padding: 12px 11px; border-bottom: 1px solid var(--rv-line); vertical-align: top; }
|
||||
.rv-table-scroll td { min-width: 95px; }
|
||||
.rv-tag { display: inline-block; padding: 3px 6px; border-radius: 3px; font-size: .62rem; font-weight: 700; background: var(--rv-worktop); color: var(--rv-muted); }
|
||||
.rv-tag-warning { background: var(--rv-amber-bg); color: var(--rv-amber); }
|
||||
.rv-code { display: flex; align-items: flex-start; gap: 8px; min-width: 0; padding: 13px; background: var(--rv-worktop); border: 1px solid var(--rv-line); border-radius: 4px; margin: 12px 0; }
|
||||
.rv-code code { flex: 1; min-width: 0; white-space: pre-wrap; font-size: .73rem; }
|
||||
.rv-code button { flex-shrink: 0; padding: 4px; border: 0; background: transparent; }
|
||||
.rv-instructions { margin: 20px 0; padding-left: 24px; }
|
||||
.rv-instructions li { padding: 4px 0 20px 8px; font-size: .85rem; }
|
||||
.rv-instructions li::marker { color: var(--rv-red-dark); font-weight: 800; }
|
||||
.rv-comparison { display: grid; grid-template-columns: minmax(0,1fr); gap: 16px; }
|
||||
.rv-comparison > div { padding: 18px; border: 1px solid var(--rv-line); background: var(--rv-worktop); border-radius: 4px; }
|
||||
.rv-comparison p[lang] { margin-top: 10px; font-size: .9rem; line-height: 1.75; }
|
||||
.rv-linked { display: flex; align-items: flex-start; gap: 12px; padding: 16px; border: 1px solid var(--rv-line); font-size: .8rem; }
|
||||
.rv-linked p { color: var(--rv-muted); margin-top: 5px; font-size: .7rem; }
|
||||
.rv-model-list > div { padding: 20px 0; border-bottom: 1px solid var(--rv-line); display: flex; align-items: center; gap: 10px; justify-content: space-between; }
|
||||
.rv-model-list strong { font-size: .9rem; }
|
||||
.rv-model-list code { display: block; font-size: .7rem; margin-top: 5px; }
|
||||
.rv-runtime { display: flex; align-items: center; justify-content: space-between; gap: 18px; padding: 22px; background: var(--rv-worktop); border: 1px solid var(--rv-line); border-radius: 5px; }
|
||||
.rv-runtime strong { display: block; font-size: 1.45rem; font-family: var(--rv-mono); margin-top: 8px; }
|
||||
.rv-runtime small { font: 400 .7rem var(--rv-sans); display: block; margin-top: 5px; color: var(--rv-muted); }
|
||||
.rv-terminal { padding: 18px; font: .75rem/1.85 var(--rv-mono); color: oklch(94% .01 20); background: oklch(28% .01 20); border-radius: 5px; white-space: pre-wrap; overflow-wrap: anywhere; }
|
||||
.rv-notice, .rv-dirty { padding: 14px 22px; background: var(--rv-blue-bg); color: var(--rv-blue); display: flex; align-items: flex-start; justify-content: space-between; gap: 10px; font-size: .78rem; }
|
||||
.rv-notice button { border: 0; background: transparent; padding: 2px; }
|
||||
.rv-dirty { background: var(--rv-amber-bg); color: var(--rv-amber); }
|
||||
.rv-operation-banner { display: flex; align-items: flex-start; gap: 14px; padding: 18px; background: var(--rv-blue-bg); margin-bottom: 24px; border-radius: 5px; font-size: .78rem; color: var(--rv-blue); }
|
||||
.rv-operation-banner p { margin: 8px 0; }
|
||||
.rv-progress { height: 5px; background: oklch(86% .025 246); margin: 14px 0; border-radius: 3px; }
|
||||
.rv-progress > span { width: 55%; height: 100%; background: var(--rv-blue); display: block; border-radius: inherit; }
|
||||
.rv-empty { padding: 44px 12px; text-align: center; }
|
||||
.rv-empty > svg { color: var(--rv-muted); margin-bottom: 18px; }
|
||||
.rv-empty h2 { margin-bottom: 12px; }
|
||||
.rv-empty p { margin: 0 auto 22px; max-width: 48ch; font-size: .85rem; color: var(--rv-muted); }
|
||||
.rv-error { color: var(--rv-red-dark); }
|
||||
.rv-page-footer { display: flex; flex-wrap: wrap; gap: 12px; justify-content: space-between; padding: 18px 24px; border-top: 1px solid var(--rv-line); font-size: .63rem; color: var(--rv-muted); }
|
||||
/* B gives each record the full reading width; the list is a separate page. */
|
||||
.rv-variant-B .rv-page-heading h1 { font-family: var(--rv-serif); font-size: 1.875rem; font-weight: 500; letter-spacing: -.025em; }
|
||||
.rv-focus { padding-bottom: 24px; }
|
||||
.rv-focus-intro { padding: 26px 32px 0; }
|
||||
.rv-focus-intro p { font-size: .8rem; color: var(--rv-muted); margin-top: 10px; }
|
||||
.rv-focus > .rv-index { padding: 22px 32px; }
|
||||
.rv-focus > .rv-index .rv-index-title { display: none; }
|
||||
.rv-focus .rv-records { gap: 0; }
|
||||
.rv-focus .rv-record { border-radius: 0; border-bottom: 1px solid var(--rv-line); padding: 20px 30px 20px 16px; }
|
||||
.rv-focus .rv-record strong { font-size: .95rem; }
|
||||
.rv-focus > .rv-content { max-width: 860px; margin: auto; padding: 18px 32px 32px; }
|
||||
.rv-focus .rv-record-heading h2 { font-family: var(--rv-serif); font-size: 1.7rem; font-weight: 500; }
|
||||
.rv-focus .rv-section { margin-block: 34px; }
|
||||
.rv-back { margin: 22px 32px 6px; }
|
||||
/* C places status and operational sections before the record's content. */
|
||||
.rv-operations { padding: 22px; }
|
||||
.rv-operation-summary { background: var(--rv-worktop); border: 1px solid var(--rv-line); border-radius: 6px; display: flex; align-items: flex-start; gap: 20px; justify-content: space-between; padding: 22px; }
|
||||
.rv-operation-summary h2 { margin: 8px 0 10px; font-size: 1.125rem; }
|
||||
.rv-operation-summary p:not(.rv-eyebrow) { font-size: .78rem; color: var(--rv-muted); line-height: 1.75; }
|
||||
.rv-operation-body { display: grid; grid-template-columns: minmax(0,1fr); gap: 20px; margin-top: 24px; }
|
||||
.rv-step-nav { display: flex; flex-wrap: wrap; align-content: start; gap: 6px; }
|
||||
.rv-step-nav button { display: flex; align-items: center; text-align: left; gap: 10px; background: transparent; border: 1px solid var(--rv-line); border-radius: 4px; padding: 12px; font-size: .73rem; font-weight: 650; }
|
||||
.rv-step-nav button > span { font: .68rem var(--rv-mono); color: var(--rv-muted); }
|
||||
.rv-step-nav button[aria-current] { background: var(--rv-red-soft); border-color: oklch(84% .04 23); color: var(--rv-red-dark); }
|
||||
.rv-operation-detail > .rv-content { padding: 8px 0 0; }
|
||||
.rv-choose-record { margin-top: 0; margin-bottom: 22px; }
|
||||
.rv-choose-record summary span { display: block; font-size: .68rem; color: var(--rv-muted); margin: 5px 0 0 15px; font-weight: 450; }
|
||||
.rv-choose-record > .rv-index { padding: 16px 0 0; }
|
||||
.rv-admin-mobile { display: none; }
|
||||
@container page (min-width: 43rem) {
|
||||
.rv-workbench { grid-template-columns: 220px minmax(0,1fr); }
|
||||
.rv-workbench > .rv-index { border-bottom: 0; border-right: 1px solid var(--rv-line); }
|
||||
.rv-facts, .rv-form-grid, .rv-comparison { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.rv-operation-body { grid-template-columns: 175px minmax(0,1fr); gap: 24px; }
|
||||
.rv-step-nav { display: grid; }
|
||||
.rv-step-nav button > svg { margin-left: auto; }
|
||||
.rv-focus .rv-record { display: grid; grid-template-columns: 150px minmax(0,1fr); align-items: baseline; gap: 2px 20px; }
|
||||
.rv-focus .rv-record > span { grid-column: 2; }
|
||||
}
|
||||
@container page (max-width: 42.999rem) {
|
||||
.rv-workbench > .rv-index .rv-records { grid-template-columns: repeat(2,minmax(0,1fr)); }
|
||||
.rv-workbench > .rv-index { max-height: 265px; overflow-y: auto; }
|
||||
.rv-context-core { margin-left: 0; width: 100%; }
|
||||
.rv-record-heading { flex-wrap: wrap; }
|
||||
}
|
||||
@container portal (max-width: 62rem) {
|
||||
.rv-host-body { grid-template-columns: 64px minmax(0,1fr); }
|
||||
.rv-portal-nav { padding-inline: 8px; }
|
||||
.rv-portal-nav :is(span,p,small) { display: none; }
|
||||
.rv-mount { padding-inline: 14px; }
|
||||
.rv-topbar-location, .rv-topbar-divider { display: none; }
|
||||
}
|
||||
@container thoth (max-width: 47rem) {
|
||||
.rv-page { grid-column: 1 / -1; grid-row: 3; }
|
||||
.rv-admin-mobile { display: flex; grid-column: 1 / -1; grid-row: 1; align-items: center; justify-content: space-between; border-bottom: 1px solid var(--rv-line); padding: 12px 16px; font-size: .75rem; }
|
||||
.rv-admin-mobile button { background: transparent; border: 1px solid var(--rv-line); padding: 7px; border-radius: 4px; }
|
||||
.rv-admin-rail { display: none; }
|
||||
.rv-admin-rail.rv-admin-open { display: block; grid-column: 1 / -1; grid-row: 2; border-left: 0; border-bottom: 1px solid var(--rv-line); }
|
||||
.rv-admin-open .rv-rail-note, .rv-admin-open > :is(p,strong) { display: none; }
|
||||
.rv-admin-open nav { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
}
|
||||
@container portal (max-width: 42rem) {
|
||||
.rv-host-body, .rv-host-collapsed .rv-host-body { grid-template-columns: minmax(0,1fr); }
|
||||
.rv-portal-nav { display: none; }
|
||||
.rv-portal-nav.rv-host-nav-open { display: flex; flex-wrap: wrap; gap: 6px; border-right: 0; border-bottom: 1px solid var(--rv-line); padding: 8px; }
|
||||
.rv-portal-nav.rv-host-nav-open span { display: inline; }
|
||||
.rv-topbar { padding-inline: 10px; gap: 8px; }
|
||||
.rv-topbar-user { font-size: 0; gap: 0; }
|
||||
.rv-mount { padding-inline: 8px; }
|
||||
.rv-breadcrumb > span:last-child { display: none; }
|
||||
}
|
||||
@container page (max-width: 28rem) {
|
||||
.rv-page-heading, .rv-content { padding: 22px 16px; }
|
||||
.rv-page-heading h1 { font-size: 1.4rem; }
|
||||
.rv-context, .rv-statusline { padding-inline: 16px; }
|
||||
.rv-sample-note { margin-left: 0; }
|
||||
.rv-tabs { padding-inline: 16px; gap: 18px; }
|
||||
.rv-operation-summary { flex-direction: column; padding: 16px; }
|
||||
.rv-operations { padding: 16px; }
|
||||
.rv-workbench > .rv-index .rv-records { grid-template-columns: minmax(0,1fr); }
|
||||
.rv-focus > .rv-content, .rv-focus > .rv-index { padding-inline: 16px; }
|
||||
.rv-focus-intro { padding-inline: 16px; }
|
||||
.rv-back { margin-left: 16px; }
|
||||
.rv-record-emblem { display: none; }
|
||||
.rv-runtime { flex-wrap: wrap; }
|
||||
.rv-button { min-height: 44px; }
|
||||
.rv-field :is(input,textarea,select) { font-size: 1rem; }
|
||||
}
|
||||
/* Review controls are outside the product shell and always explicitly labeled. */
|
||||
.rv-lab { position: fixed; z-index: 100; bottom: 12px; left: 50%; transform: translateX(-50%); width: max-content; max-width: calc(100% - 16px); padding: 9px 12px; background: oklch(27% .012 355); color: oklch(97% .004 20); border: 1px solid oklch(45% .012 355); border-radius: 9px; box-shadow: 0 6px 28px oklch(20% .01 355 / .2); }
|
||||
.rv-lab-main { display: flex; align-items: center; gap: 9px; }
|
||||
.rv-lab-main button { display: grid; place-items: center; width: 32px; height: 32px; background: oklch(34% .012 355); border: 1px solid oklch(48% .012 355); border-radius: 5px; }
|
||||
.rv-lab-tag { font-size: .55rem; font-weight: 800; letter-spacing: .08em; padding-right: 6px; }
|
||||
.rv-lab select { color: inherit; background: oklch(34% .012 355); border: 1px solid oklch(48% .012 355); border-radius: 4px; padding: 7px; font-size: .72rem; max-width: 100%; }
|
||||
.rv-lab-help { font-size: .6rem; color: oklch(80% .01 355); }
|
||||
.rv-lab details { font-size: .65rem; }
|
||||
.rv-lab summary { cursor: pointer; text-align: center; padding-top: 5px; color: oklch(83% .01 355); }
|
||||
.rv-lab-options { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; padding-top: 14px; max-width: 620px; }
|
||||
.rv-lab-options label { display: grid; gap: 4px; }
|
||||
.rv-lab-options p { margin: 0; color: oklch(83% .01 355); max-width: 52ch; }
|
||||
.rv-lab-options .rv-button { background: oklch(38% .012 355); border-color: oklch(50% .012 355); color: inherit; }
|
||||
.rv-sr { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap; border: 0; }
|
||||
@media(max-width: 680px) { .rv-lab-tag, .rv-lab-help { display: none; } .rv-lab-options { max-width: 290px; } }
|
||||
@media(prefers-reduced-motion: reduce) { .rv-host *, .rv-lab * { scroll-behavior: auto !important; } }
|
||||
@@ -0,0 +1,13 @@
|
||||
import path from "node:path";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
export default defineConfig(({ command }) => {
|
||||
if (command !== "serve") throw new Error("Review prototype is development-only.");
|
||||
return {
|
||||
root: path.resolve(__dirname, "prototypes/administration-review"),
|
||||
plugins: [react()],
|
||||
appType: "spa",
|
||||
server: { host: "127.0.0.1", port: 5175, strictPort: true },
|
||||
};
|
||||
});
|
||||
Reference in New Issue
Block a user