Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 37 additions & 0 deletions docs/studio/PROGRESS.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,43 @@ derivations to resolve rather than fixtures.

---

### 2026-08-19 — The Reflective redesign

Requested in review: cleaner, more intuitive controls; explanations behind a hoverable question
mark instead of paragraphs in every box; the look of reflective.org / simulator.reflective.org /
tomas-fe.vercel.app.

**Design sources, read rather than imagined.** The SAI simulator frontend
(`~/GitHub/reflective-simulator/sai-simulator-fe`) uses Radix Themes, Inter, lucide icons, a
zero-delay `CircleHelp` tooltip beside every control, and a builder-left/plots-right layout;
reflective.org contributes deep navy `#091834`, gold `#f1b80d` and steel `#466f8d`; tomas-fe the
warm paper ground (`#f5f0eb` on `#242220`). Plume Studio now composes exactly those pieces —
lucide-react is a real dependency because it is literally their icon set.

**What changed.**
- Every field's description and provenance moved behind a `CircleHelp` hover (a real `<button>`, so
keyboard focus opens it too). Section notes and panel explanations likewise; **visible text is
now data** — totals, extremes, mode lines — and prose is on demand.
- Stages render **controls left, graphs right** (sticky), the simulator's builder layout. The
stepper became icon pills (ThermometerSun, Wind, FlaskConical, Waves, CloudHail, Atom,
SlidersHorizontal, ClipboardCheck).
- Navy primary buttons, gold for overrides/markers, paper cards with soft shadows; dark mode is the
navy-tinted equivalent, not an automatic inversion.

**Two defects found by looking, one by re-running.**
- Dark mode's `button { color: dark }` rule outranked `.stage-tab`'s colour by specificity, so
inactive stage tabs (and help triggers) rendered dark-on-dark — illegible on first render.
Re-stated per-shape in the dark block, with a comment explaining the specificity trap.
- The smoke script had silently rotted twice over: it still typed into `plume_length_m` (derived
since 0.3.0 — the entered field is `given_track_length_m`) and predated commit-on-Enter, so its
edits filled drafts the server never heard about. Steps printed and nothing failed. Repaired
(setValue now presses Enter; fields renamed), and the full flow re-verified in the new UI:
override → stale tab → accept → review shows 1 change → schema refusals surface.

360 Python Tier-A, 58 vitest, both unchanged — the redesign is presentation over the same seams.

---

### 2026-08-19 — Task 1.1: ERA5 is in the product, the schema, and the wizard

Raised in use: *"I do not see ERA5 data being utilized"* — correct; the decisions existed and the
Expand Down
6 changes: 6 additions & 0 deletions studio/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Plume Studio</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=Inter:wght@400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
Expand Down
10 changes: 10 additions & 0 deletions studio/web/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions studio/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"test:watch": "vitest"
},
"dependencies": {
"lucide-react": "1.33.0",
"react": "19.2.0",
"react-dom": "19.2.0"
},
Expand Down
12 changes: 8 additions & 4 deletions studio/web/scripts/smoke.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ async function shot(name) {
/**
* Set a React-controlled input. Assigning `.value` does not notify React, so this uses the native
* setter and dispatches a bubbling input event -- which is what a real keystroke produces.
*
* Text inputs are DRAFTS: the config moves only on Enter or blur (the multi-digit typing fix), so
* this also presses Enter. Selects commit on change and ignore the extra keydown.
*/
const setValue = (selector, value) => `(() => {
const el = document.querySelector(${JSON.stringify(selector)});
Expand All @@ -90,6 +93,7 @@ const setValue = (selector, value) => `(() => {
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, ${JSON.stringify(String(value))});
el.dispatchEvent(new Event('input', { bubbles: true }));
el.dispatchEvent(new Event('change', { bubbles: true }));
el.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
return el.value;
})()`;

Expand Down Expand Up @@ -128,7 +132,7 @@ await sleep(2500);
console.log(" ", JSON.stringify(await evaluate(state)));

console.log("2. type a new plume length -> derived volume must follow");
await evaluate(setValue("#injection\\.plume_length_m", "20000"));
await evaluate(setValue("#injection\\.given_track_length_m", "20000"));
await sleep(1200);
let s = await evaluate(state);
console.log(" length:", s.length, "volume:", s.volume, "badges:", s.badges.join(","));
Expand All @@ -141,7 +145,7 @@ console.log(" volume:", s.volume, "badges:", s.badges.join(","), "stale:", s.s
await shot("drive-2-override");

console.log("4. move the input underneath it -> the override must go stale");
await evaluate(setValue("#injection\\.plume_length_m", "12000"));
await evaluate(setValue("#injection\\.given_track_length_m", "12000"));
await sleep(1400);
s = await evaluate(state);
console.log(" header:", s.headerStale, "| stale tabs:", s.staleTabs.join(","), "| badges:", s.badges.join(","));
Expand Down Expand Up @@ -174,14 +178,14 @@ await shot("drive-5-review");
console.log("7. a value the schema refuses must surface its message, not fail silently");
await evaluate(clickText("Environment"));
await sleep(900);
await evaluate(setValue("#site\\.temperature_k", "-5"));
await evaluate(setValue("#site\\.given_temperature_k", "-5"));
await sleep(1300);
s = await evaluate(state);
console.log(" error shown:", s.error ? s.error.replace(/\s+/g, " ").slice(0, 200) : "NONE (BAD)");
await shot("drive-6-validation");

console.log("8. and 9999 K is accepted, which is issue #91 -- recorded here, not asserted as good");
await evaluate(setValue("#site\\.temperature_k", "9999"));
await evaluate(setValue("#site\\.given_temperature_k", "9999"));
await sleep(1300);
s = await evaluate(state);
console.log(" error for 9999 K:", s.error ? "shown" : "NONE -- unbounded above, see #91");
Expand Down
87 changes: 60 additions & 27 deletions studio/web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,38 @@
* stage 3 cannot lose anything, because there was never a second copy to lose it from.
*/

import {
Atom,
ClipboardCheck,
CloudHail,
FlaskConical,
SlidersHorizontal,
ThermometerSun,
Waves,
Wind,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { api, ApiError, type ConfigState } from "./api";
import { Field } from "./Field";
import { HelpTip } from "./HelpTip";
import { Review } from "./Review";
import { PANELS_BY_STAGE } from "./panels";
import { type FieldSpec, fieldSpec } from "./schema";
import { valueAt } from "./schema";
import type { JsonSchema, LayoutManifest, ResolvedPayload, RunBrief } from "./types";

/** One lucide icon per stage, the way the SAI simulator's builder marks its sections. */
const STAGE_ICONS: Record<string, React.ComponentType<{ size?: number | string }>> = {
environment: ThermometerSun,
plume_volume: Wind,
initial_concentration: FlaskConical,
dilution: Waves,
background_aerosol: CloudHail,
background_species: Atom,
physics: SlidersHorizontal,
review: ClipboardCheck,
};

export function App() {
const [schema, setSchema] = useState<JsonSchema | null>(null);
const [layout, setLayout] = useState<LayoutManifest | null>(null);
Expand Down Expand Up @@ -216,14 +239,15 @@ export function App() {
const staleHere = s.sections.some((section) =>
section.fields.some((f) => staleByPath.has(f)),
);
const Icon = STAGE_ICONS[s.id];
return (
<button
key={s.id}
type="button"
className={`stage-tab${s.id === stage.id ? " current" : ""}${staleHere ? " has-stale" : ""}`}
onClick={() => goTo(s.id)}
>
<span className="num">{s.number}</span>
<span className="num">{Icon ? <Icon size={14} /> : s.number}</span>
<span className="name">{s.title}</span>
</button>
);
Expand Down Expand Up @@ -256,35 +280,44 @@ export function App() {
onSubmit={onSubmit}
/>
) : (
stage.sections.map((section) => (
<div className="section" key={section.title}>
<h3>{section.title}</h3>
{section.note ? <p className="note">{section.note}</p> : null}
<div className="fields">
{section.fields.map((path) => {
const spec = specs.get(path);
if (!spec) return null;
return (
<Field
key={path}
spec={spec}
value={valueAt(payload.config, path)}
overridden={Object.hasOwn(payload.overrides, path)}
stale={staleByPath.get(path)}
disabled={busy}
onChange={onChange}
onAccept={onAccept}
onKeep={onKeep}
/>
);
})}
</div>
<div className="stage-grid">
<div className="controls">
{stage.sections.map((section) => (
<div className="section" key={section.title}>
<h3>
{section.title}
{section.note ? (
<HelpTip label={`about ${section.title}`}>{section.note}</HelpTip>
) : null}
</h3>
<div className="fields">
{section.fields.map((path) => {
const spec = specs.get(path);
if (!spec) return null;
return (
<Field
key={path}
spec={spec}
value={valueAt(payload.config, path)}
overridden={Object.hasOwn(payload.overrides, path)}
stale={staleByPath.get(path)}
disabled={busy}
onChange={onChange}
onAccept={onAccept}
onKeep={onKeep}
/>
);
})}
</div>
</div>
))}
</div>
<div className="viz">
{StagePanel ? <StagePanel config={payload.config} load={loadPanel} /> : null}
</div>
))
</div>
)}

{StagePanel ? <StagePanel config={payload.config} load={loadPanel} /> : null}

{error ? <p className="error">{error}</p> : null}

<div className="nav-row">
Expand Down
14 changes: 10 additions & 4 deletions studio/web/src/Field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
*/

import { useEffect, useRef, useState } from "react";
import { HelpTip } from "./HelpTip";
import { type FieldSpec, coerce, display } from "./schema";
import type { StaleField } from "./types";

Expand Down Expand Up @@ -163,14 +164,19 @@ export function Field({
{spec.label}
{spec.unit ? <span className="unit"> ({spec.unit})</span> : null}
</label>
{stateLabel ? <span className={`badge badge-${stateLabel}`}>{stateLabel}</span> : null}
<span className="field-head-right">
{stateLabel ? <span className={`badge badge-${stateLabel}`}>{stateLabel}</span> : null}
{spec.description || provenanceNote(spec) ? (
<HelpTip label={`about ${spec.label}`}>
{spec.description ? <span className="tip-desc">{spec.description}</span> : null}
<span className="tip-prov">{provenanceNote(spec)}</span>
</HelpTip>
) : null}
</span>
</div>

{control(spec, value, disabled, emit)}

{spec.description ? <p className="field-desc">{spec.description}</p> : null}
<p className="field-prov">{provenanceNote(spec)}</p>

{stale ? (
<div className="stale-box">
<p>
Expand Down
34 changes: 34 additions & 0 deletions studio/web/src/HelpTip.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Copyright (C) 2026 University Corporation for Atmospheric Research
// SPDX-License-Identifier: Apache-2.0
/**
* The hover explanation: a CircleHelp icon that reveals the field's description and provenance.
*
* Requested in review: boxes should not carry walls of explanation text -- a question mark you can
* hover. The pattern (and the icon) come from the SAI simulator's ScenarioBuilder, which pairs
* every control with a zero-delay tooltip.
*
* A <button>, not a styled span: keyboard users reach it with Tab and the tip opens on focus, so
* hover is a convenience rather than the only door. No positioning library -- the tip anchors to
* the icon and flips are not needed at tooltip sizes on a scrolling page.
*/

import { CircleHelp } from "lucide-react";

export function HelpTip({
children,
label = "explanation",
}: {
children: React.ReactNode;
label?: string;
}) {
return (
<span className="helptip">
<button type="button" className="helptip-trigger" aria-label={label}>
<CircleHelp size={14} strokeWidth={2} />
</button>
<span className="helptip-body" role="tooltip">
{children}
</span>
</span>
);
}
Loading
Loading