| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import { memo, useEffect, useRef, useState } from "react"; |
| import { barScale, renderableSpec, specKindName } from "./scriptViews"; |
| import type { DrawableSpec, ScriptRun, ScriptView as ScriptViewRecord } from "./scriptViews"; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| function SpecTable({ spec }: { spec: DrawableSpec }) { |
| return ( |
| <table className="cg-script-table"> |
| <thead> |
| <tr>{(spec.columns ?? []).map((c, i) => <th key={`${c}:${i}`} scope="col">{c}</th>)}</tr> |
| </thead> |
| <tbody> |
| {(spec.rows ?? []).map((row, r) => ( |
| <tr key={r}>{row.map((cell, c) => <td key={c}>{cell}</td>)}</tr> |
| ))} |
| </tbody> |
| </table> |
| ); |
| } |
|
|
| function SpecMetrics({ spec }: { spec: DrawableSpec }) { |
| return ( |
| <div className="cg-script-metrics"> |
| {(spec.items ?? []).map((item, i) => ( |
| <div className="cg-script-metric" key={`${item.label}:${i}`}> |
| <div className="cg-script-metric-label">{item.label}</div> |
| <div className="cg-script-metric-value">{item.value}</div> |
| {item.note ? <div className="cg-script-metric-note">{item.note}</div> : null} |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| function SpecBars({ spec }: { spec: DrawableSpec }) { |
| const series = spec.series ?? []; |
| const scale = barScale(series); |
| return ( |
| <div className="cg-script-bars"> |
| {series.map((s, i) => ( |
| <div className="cg-script-bar" key={`${s.label}:${i}`}> |
| <span>{s.label}</span> |
| <span className="cg-script-bar-track"> |
| {/* ⚠ The ONE computed style on this surface, and it is arithmetic on a NUMBER the |
| spec reader already validated as finite, clamped here. Never a string from the |
| wire, which is what would make this an injection point. */} |
| <span |
| className="cg-script-bar-fill" |
| style={{ width: `${Math.min(100, Math.round((Math.abs(s.value) / scale) * 100))}%` }} |
| /> |
| </span> |
| <span className="cg-script-bar-value">{s.display}</span> |
| </div> |
| ))} |
| </div> |
| ); |
| } |
|
|
| |
| function ScriptRefusal({ run }: { run: ScriptRun }) { |
| return ( |
| <div className="cg-script-refusal" role="status"> |
| <h4>This script did not produce a view</h4> |
| <p>{run.error ?? "The run ended without emitting anything."}</p> |
| <dl> |
| <dt>Reason</dt> |
| <dd>{run.code}</dd> |
| {run.limit ? <><dt>Subject</dt><dd>{run.limit.subject}</dd></> : null} |
| {run.limit ? <><dt>Effect</dt><dd>{run.limit.effect}</dd></> : null} |
| {run.limit ? <><dt>Cause</dt><dd>{run.limit.cause}</dd></> : null} |
| {run.limit ? <><dt>What to do</dt><dd>{run.limit.recommendation}</dd></> : null} |
| </dl> |
| </div> |
| ); |
| } |
|
|
| export function ScriptOutput({ run }: { run: ScriptRun | null }) { |
| if (run === null) |
| return <p className="cg-script-empty">Run the script to see what it draws.</p>; |
| const drawable = run.ok ? renderableSpec(run.spec) : null; |
| const claimed = specKindName(run.spec); |
| return ( |
| <> |
| {!run.ok ? <ScriptRefusal run={run} /> : null} |
| {run.ok && drawable === null ? ( |
| <div className="cg-script-refusal" role="status"> |
| <h4>This build cannot draw that shape yet</h4> |
| <p> |
| {claimed |
| ? `The script emitted a view of kind "${claimed}", which this version does not |
| render. It knows table, metrics, bars and text.` |
| : `The script emitted a view with no recognisable kind. This version renders |
| table, metrics, bars and text.`} |
| </p> |
| </div> |
| ) : null} |
| {drawable !== null ? ( |
| <> |
| {drawable.title ? <h3 className="cg-script-title">{drawable.title}</h3> : null} |
| {drawable.kind === "table" ? <SpecTable spec={drawable} /> : null} |
| {drawable.kind === "metrics" ? <SpecMetrics spec={drawable} /> : null} |
| {drawable.kind === "bars" ? <SpecBars spec={drawable} /> : null} |
| {drawable.kind === "text" ? <p className="cg-script-text">{drawable.body}</p> : null} |
| </> |
| ) : null} |
| {run.stdout ? ( |
| <pre className="cg-script-stdout"> |
| {run.stdout} |
| {run.truncated ? "\n[output truncated]" : ""} |
| </pre> |
| ) : null} |
| </> |
| ); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| export const ScriptView = memo(function ScriptView({ |
| view, |
| running, |
| run, |
| saving, |
| readOnly = false, |
| onRun, |
| onSave, |
| }: { |
| view: ScriptViewRecord | null; |
| running: boolean; |
| run: ScriptRun | null; |
| saving?: boolean; |
| readOnly?: boolean; |
| onRun: (draft: string) => void; |
| onSave?: (source: string) => void; |
| }) { |
| const [draft, setDraft] = useState(view?.source ?? ""); |
| const loadedId = useRef<string | null>(null); |
| useEffect(() => { |
| |
| |
| if (!view || loadedId.current === view.id) return; |
| loadedId.current = view.id; |
| setDraft(view.source); |
| }, [view]); |
| const dirty = view !== null && draft !== view.source; |
| if (view === null) |
| return ( |
| <div className="cg-script"> |
| <p className="cg-script-empty cg-script-empty--pad"> |
| This script view could not be opened. |
| </p> |
| </div> |
| ); |
| return ( |
| <div className="cg-script"> |
| <div className="cg-script-body"> |
| <section className="cg-script-pane" aria-label="Script source"> |
| <div className="cg-script-pane-head"> |
| <span>Code</span> |
| <span className="cg-script-meta"> |
| {`v${view.version}`} |
| {dirty ? ", unsaved changes" : ""} |
| </span> |
| <span className="cg-script-spacer" /> |
| <button |
| type="button" |
| className="cg-script-ghost" |
| disabled={!dirty || saving === true || readOnly || onSave === undefined} |
| onClick={() => onSave?.(draft)} |
| > |
| {saving === true ? "Saving" : "Save version"} |
| </button> |
| <button |
| type="button" |
| className="cg-script-run" |
| disabled={running} |
| onClick={() => onRun(draft)} |
| > |
| {running ? "Running" : "Run"} |
| </button> |
| </div> |
| <textarea |
| className="cg-script-code" |
| value={draft} |
| spellCheck={false} |
| readOnly={readOnly} |
| aria-label="Script source" |
| onChange={(e) => setDraft(e.target.value)} |
| /> |
| </section> |
| <section className="cg-script-pane" aria-label="Script output"> |
| <div className="cg-script-pane-head"> |
| <span>Output</span> |
| <span className="cg-script-spacer" /> |
| {run !== null ? ( |
| <span className="cg-script-meta"> |
| {run.ms > 0 ? `${run.ms} ms` : ""} |
| </span> |
| ) : null} |
| </div> |
| <div className="cg-script-out"> |
| <ScriptOutput run={run} /> |
| </div> |
| </section> |
| </div> |
| </div> |
| ); |
| }); |
|
|