summaryrefslogtreecommitdiff
path: root/public
diff options
context:
space:
mode:
authorSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
committerSomhairle H. Marisol <[email protected]>2026-09-21 00:58:15 +0800
commit7b29dbff7e43e87741476f47df17ad1582b16546 (patch)
tree4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /public
parent47add3088d50f12c5f48591f26c53cdb59d8283b (diff)
downloadsomhairles-dream-fsharp-7b29dbff7e43e87741476f47df17ad1582b16546.tar.gz
feat(pipeline): add runnable artifact pipeline increment
[Change Nature] - This commit adds the first runnable increment of the F# artifact pipeline: CLI, server, frontend bundle, stage fixture, and tests. It is feature work, not a bug fix. [New Capability] - CLI `run` executes the three-step Blender fixture through the process bridge and writes GLBs, step logs, events, and a manifest; `verify` validates a manifest without Blender. - Server exposes run start, run-scoped SSE, artifact manifest/file routes, and serves the Fable frontend from its build output. - Frontend starts runs, follows run-scoped SSE progress, and links the artifact manifest on completion. [Implementation] - Shared layer holds domain, run state, replay, and artifact contracts; Modeling holds the pipeline, 120s bridge timeout, and result-file protocol; stage/blender/fixture.py owns geometry in Python (bpy owns geometry, F# owns orchestration). - Fable bundle is regenerated into public/ and copied by the server project; solution includes all projects with warnings-as-errors. - README documents build, Fable, server, interpreter/env, fixture limitations, and current verification status. [Impact] - Verified: 40/40 solution tests pass; Release build 0 warnings/errors; real CLI run with bpy 5.0.1 produced 3 GLBs, 3 logs, and a verify-clean manifest; isolated-port server smoke flow succeeded. - Not yet verified: browser end-to-end behavior and production fidelity. Artifacts and caches are gitignored.
Diffstat (limited to 'public')
-rw-r--r--public/App.js392
-rw-r--r--public/SomhairlesDream.Shared/Artifact.js277
-rw-r--r--public/SomhairlesDream.Shared/Domain.js114
-rw-r--r--public/SomhairlesDream.Shared/Replay.js29
-rw-r--r--public/index.html190
-rw-r--r--public/styles.css692
6 files changed, 1694 insertions, 0 deletions
diff --git a/public/App.js b/public/App.js
new file mode 100644
index 0000000..262025a
--- /dev/null
+++ b/public/App.js
@@ -0,0 +1,392 @@
+import { Record } from "./fable_modules/fable-library-js.4.24.0/Types.js";
+import { class_type, record_type, bool_type, string_type } from "./fable_modules/fable-library-js.4.24.0/Reflection.js";
+import { Replay_frames } from "./SomhairlesDream.Shared/Replay.js";
+import { createAtom } from "./fable_modules/fable-library-js.4.24.0/Util.js";
+import { tryFind, item } from "./fable_modules/fable-library-js.4.24.0/Array.js";
+import { isNullOrWhiteSpace, printf, toText } from "./fable_modules/fable-library-js.4.24.0/String.js";
+import { MeshData_triangleIndices, MeshData_positionData } from "./SomhairlesDream.Shared/Domain.js";
+import { min, max } from "./fable_modules/fable-library-js.4.24.0/Double.js";
+import { defaultArg } from "./fable_modules/fable-library-js.4.24.0/Option.js";
+import { awaitPromise, startImmediate } from "./fable_modules/fable-library-js.4.24.0/Async.js";
+import { singleton } from "./fable_modules/fable-library-js.4.24.0/AsyncBuilder.js";
+
+export class RunSelection extends Record {
+ constructor(ProjectId, RunId, BaseId, TargetId, Render) {
+ super();
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.BaseId = BaseId;
+ this.TargetId = TargetId;
+ this.Render = Render;
+ }
+}
+
+export function RunSelection_$reflection() {
+ return record_type("SomhairlesDream.Frontend.App.RunSelection", [], RunSelection, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Render", bool_type]]);
+}
+
+export class ViewState extends Record {
+ constructor(Renderer, Scene, Camera, Group, Canvas) {
+ super();
+ this.Renderer = Renderer;
+ this.Scene = Scene;
+ this.Camera = Camera;
+ this.Group = Group;
+ this.Canvas = Canvas;
+ }
+}
+
+export function ViewState_$reflection() {
+ return record_type("SomhairlesDream.Frontend.App.ViewState", [], ViewState, () => [["Renderer", string_type], ["Scene", string_type], ["Camera", string_type], ["Group", string_type], ["Canvas", class_type("Browser.Types.HTMLCanvasElement", undefined)]]);
+}
+
+export const frames = Replay_frames();
+
+export let viewState = createAtom(undefined);
+
+export let selectedFrame = createAtom(item(0, frames));
+
+export let replayTimer = createAtom(undefined);
+
+export let replayIndex = createAtom(0);
+
+export let eventSource = createAtom(undefined);
+
+export function element(id) {
+ return document.getElementById(id);
+}
+
+export function setText(id, value) {
+ element(id).textContent = value;
+}
+
+export function setWidth(id, value) {
+ element(id).setAttribute("style", toText(printf("width: %s"))(value));
+}
+
+export function setStatusClass(status) {
+ const host = element("status-chip");
+ const array = ["idle", "queued", "running", "succeeded", "failed", "stale"];
+ array.forEach((name) => {
+ host.classList.remove(`status-${name}`);
+ });
+ host.classList.add(`status-${status}`);
+ host.setAttribute("data-status", status);
+}
+
+export function statusName(payload) {
+ if (payload.isStale) {
+ return "stale";
+ }
+ else {
+ const matchValue = payload.status;
+ let matchResult, status;
+ switch (matchValue) {
+ case "complete": {
+ matchResult = 0;
+ break;
+ }
+ case "idle": {
+ matchResult = 1;
+ status = matchValue;
+ break;
+ }
+ case "queued": {
+ matchResult = 1;
+ status = matchValue;
+ break;
+ }
+ case "running": {
+ matchResult = 1;
+ status = matchValue;
+ break;
+ }
+ case "failed": {
+ matchResult = 1;
+ status = matchValue;
+ break;
+ }
+ default:
+ matchResult = 2;
+ }
+ switch (matchResult) {
+ case 0:
+ return "succeeded";
+ case 1:
+ return status;
+ default:
+ return "unknown";
+ }
+ }
+}
+
+export function statusLabel(payload) {
+ const matchValue = payload.status;
+ const matchValue_1 = payload.isStale;
+ if (matchValue_1) {
+ return "心跳超时";
+ }
+ else {
+ switch (matchValue) {
+ case "idle":
+ return "待机";
+ case "queued":
+ return "排队中";
+ case "running":
+ return "运行中";
+ case "complete":
+ return "已完成";
+ case "failed":
+ return "失败";
+ default:
+ return "未知状态";
+ }
+ }
+}
+
+export function addMesh(view, frame) {
+ const geometry = new THREE.BufferGeometry();
+ const positions = MeshData_positionData(frame.Mesh);
+ const indices = MeshData_triangleIndices(frame.Mesh);
+ geometry.setAttribute("position", new THREE.Float32BufferAttribute(positions, 3));
+ geometry.setIndex(new THREE.Uint32BufferAttribute(indices, 1));
+ geometry.computeVertexNormals();
+ let color;
+ const matchValue = frame.Version | 0;
+ color = ((matchValue === 1) ? "#d7a948" : ((matchValue === 2) ? "#efe4c4" : "#a7c7c0"));
+ const material = new THREE.MeshStandardMaterial({
+ color: color,
+ roughness: 0.36,
+ metalness: 0.72,
+ });
+ const mesh = new THREE.Mesh(geometry, material);
+ view.Group.add(mesh);
+}
+
+export function renderFrame(frame) {
+ selectedFrame(frame);
+ if (viewState() != null) {
+ const view = viewState();
+ view.Group.clear();
+ addMesh(view, frame);
+ view.Renderer.render(view.Scene, view.Camera);
+ }
+}
+
+export function resizeView(view) {
+ const width = max(1, view.Canvas.clientWidth);
+ const height = max(1, view.Canvas.clientHeight);
+ view.Renderer.setSize(width, height, false);
+ renderFrame(selectedFrame());
+}
+
+export function initializeView() {
+ const canvas = document.getElementById("viewport-canvas");
+ const width = max(1, canvas.clientWidth);
+ const height = max(1, canvas.clientHeight);
+ const renderer = new THREE.WebGLRenderer({
+ canvas: canvas,
+ antialias: true,
+ alpha: true,
+ });
+ renderer.setPixelRatio(window.devicePixelRatio);
+ renderer.setSize(width, height, false);
+ const scene = new THREE.Scene();
+ scene.background = (new THREE.Color("#11171a"));
+ const camera = new THREE.PerspectiveCamera(34, (width / height), 0.1, 100);
+ camera.position.set(6.4, 4.6, 7.2);
+ camera.lookAt(0, 1.2, 0);
+ const group = new THREE.Group();
+ scene.add(group);
+ scene.add(new THREE.AmbientLight("#f0e6c8", 1.9));
+ const keyLight = new THREE.DirectionalLight("#f2c76b", 2.8);
+ keyLight.position.set(4, 7, 5);
+ scene.add(keyLight);
+ const view = new ViewState(renderer, scene, camera, group, canvas);
+ viewState(view);
+ renderFrame(selectedFrame());
+ window.addEventListener("resize", (_arg) => {
+ resizeView(view);
+ });
+ const animate = (_arg_1) => {
+ group.rotation.y = (group.rotation.y + 0.003);
+ renderer.render(scene, camera);
+ requestAnimationFrame(animate);
+ };
+ requestAnimationFrame(animate);
+}
+
+export function fallbackView() {
+ setText("viewport-note", "WebGL 视图待命 · 已保留重建数据");
+ element("viewport-panel").classList.add("viewport-fallback");
+}
+
+export function showFrameMetadata(frame) {
+ setText("mesh-label", frame.Label);
+ setText("timeline-current", toText(printf("V%02d · %s"))(frame.Version)(frame.Label));
+}
+
+export function setManifestLink(payload) {
+ const link = element("artifact-manifest-link");
+ const matchValue = payload.manifestUrl;
+ if (isNullOrWhiteSpace(matchValue)) {
+ link.removeAttribute("href");
+ link.classList.add("is-hidden");
+ }
+ else {
+ link.setAttribute("href", matchValue);
+ link.classList.remove("is-hidden");
+ }
+}
+
+export function updateState(payload) {
+ let arg, arg_3;
+ setStatusClass(statusName(payload));
+ setText("status-label", statusLabel(payload));
+ setText("run-message", ((payload.status === "failed") && !isNullOrWhiteSpace(payload.error)) ? payload.error : payload.message);
+ setText("project-id", payload.projectId);
+ setText("run-id", payload.runId);
+ const currentVersion = max(0, payload.currentStepIndex + 1) | 0;
+ setText("target-version", (arg = (payload.totalSteps | 0), toText(printf("V%02d"))(arg)));
+ setText("current-version", toText(printf("V%02d"))(currentVersion));
+ setText("version-readout", (arg_3 = (payload.totalSteps | 0), toText(printf("%02d / %02d"))(currentVersion)(arg_3)));
+ setText("updated-at", payload.updatedAt);
+ const progress = (payload.totalSteps <= 0) ? 0 : min(100, (currentVersion / payload.totalSteps) * 100);
+ setWidth("progress-fill", toText(printf("%.0f%%"))(progress));
+ const frame = defaultArg(tryFind((candidate) => (candidate.Version === currentVersion), frames), selectedFrame());
+ setManifestLink(payload);
+ showFrameMetadata(frame);
+ renderFrame(frame);
+}
+
+export function updateStreamState(message) {
+ setText("stream-state", message);
+}
+
+export function toggleReplay() {
+ if (replayTimer() == null) {
+ replayIndex(0);
+ setText("replay-button", "暂停重建");
+ setText("replay-state", "重建中 · 几何逐帧替换");
+ const tick = () => {
+ if (replayIndex() >= frames.length) {
+ if (replayTimer() == null) {
+ }
+ else {
+ clearInterval(replayTimer());
+ }
+ replayTimer(undefined);
+ setText("replay-button", "重建演示");
+ setText("replay-state", "重建完成 · 几何已更新");
+ }
+ else {
+ const frame = item(replayIndex(), frames);
+ showFrameMetadata(frame);
+ renderFrame(frame);
+ setText("replay-state", toText(printf("重建中 · V%02d %s"))(frame.Version)(frame.Label));
+ replayIndex(replayIndex() + 1);
+ }
+ };
+ tick();
+ replayTimer(setInterval(tick, 900));
+ }
+ else {
+ clearInterval(replayTimer());
+ replayTimer(undefined);
+ setText("replay-button", "重建演示");
+ setText("replay-state", "演示已暂停");
+ }
+}
+
+export function disconnectFromServer() {
+ if (eventSource() == null) {
+ }
+ else {
+ const source = eventSource();
+ source.close();
+ eventSource(undefined);
+ }
+}
+
+export function connectToServer(selection) {
+ disconnectFromServer();
+ const projectId = encodeURIComponent(selection.ProjectId);
+ const runId = encodeURIComponent(selection.RunId);
+ const source = new EventSource(toText(printf("/api/runs/events?projectId=%s&runId=%s"))(projectId)(runId));
+ eventSource(source);
+ source.onmessage = ((event) => {
+ updateState(JSON.parse(event.data));
+ updateStreamState("实时链路 · SSE 已连接");
+ });
+ source.onerror = ((_arg) => {
+ updateStreamState("实时链路 · 等待自动重连");
+ });
+}
+
+export function inputValue(id) {
+ return element(id).value;
+}
+
+export function selectedRun() {
+ return new RunSelection(inputValue("project-id-input"), inputValue("run-id-input"), inputValue("base-id-input"), inputValue("target-id-input"), element("run-render-input").checked);
+}
+
+export function startRun(selection) {
+ const requestBody = {
+ projectId: selection.ProjectId,
+ runId: selection.RunId,
+ baseId: selection.BaseId,
+ targetId: selection.TargetId,
+ render: selection.Render,
+ };
+ const requestOptions = {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(requestBody),
+ };
+ setText("run-message", "正在启动设计运行");
+ updateStreamState("实时链路 · 等待服务端确认");
+ startImmediate(singleton.Delay(() => singleton.TryWith(singleton.Delay(() => singleton.Bind(awaitPromise(fetch("/api/runs/start", requestOptions)), (_arg) => {
+ let arg;
+ const response = _arg;
+ if (response.ok) {
+ return singleton.Bind(awaitPromise(response.json()), (_arg_1) => {
+ updateState(_arg_1);
+ connectToServer(selection);
+ updateStreamState("实时链路 · SSE 已连接");
+ return singleton.Zero();
+ });
+ }
+ else {
+ setText("run-message", (arg = (response.status | 0), toText(printf("启动失败 · HTTP %d"))(arg)));
+ updateStreamState("实时链路 · 启动失败");
+ return singleton.Zero();
+ }
+ })), (_arg_2) => {
+ setText("run-message", "启动失败 · 无法连接服务端");
+ updateStreamState("实时链路 · 连接失败");
+ return singleton.Zero();
+ })));
+}
+
+export function boot() {
+ showFrameMetadata(item(0, frames));
+ try {
+ initializeView();
+ }
+ catch (matchValue) {
+ fallbackView();
+ }
+ element("replay-button").addEventListener("click", (_arg) => {
+ toggleReplay();
+ });
+ element("run-controls").addEventListener("submit", (event) => {
+ event.preventDefault();
+ startRun(selectedRun());
+ });
+}
+
+boot();
+
diff --git a/public/SomhairlesDream.Shared/Artifact.js b/public/SomhairlesDream.Shared/Artifact.js
new file mode 100644
index 0000000..389d39c
--- /dev/null
+++ b/public/SomhairlesDream.Shared/Artifact.js
@@ -0,0 +1,277 @@
+import { Union, Record } from "../fable_modules/fable-library-js.4.24.0/Types.js";
+import { bool_type, array_type, int64_type, union_type, option_type, int32_type, class_type, record_type, string_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js";
+import { isMatch, create } from "../fable_modules/fable-library-js.4.24.0/RegExp.js";
+import { FSharpResult$2 } from "../fable_modules/fable-library-js.4.24.0/Result.js";
+
+export class RunIds extends Record {
+ constructor(ProjectId, RunId, BaseId, TargetId) {
+ super();
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.BaseId = BaseId;
+ this.TargetId = TargetId;
+ }
+}
+
+export function RunIds_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunIds", [], RunIds, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type]]);
+}
+
+const Ids_resourcePattern = create("^[a-z0-9](?:[a-z0-9-]{0,62}[a-z0-9])?$", 512);
+
+const Ids_objectPattern = create("^[a-z0-9]+(?:[._-][a-z0-9]+)*$", 512);
+
+export function Ids_validateResourceId(value) {
+ if ((value == null) ? true : !isMatch(Ids_resourcePattern, value)) {
+ return new FSharpResult$2(1, ["resource id must contain only lower-case letters, digits, and hyphens"]);
+ }
+ else {
+ return new FSharpResult$2(0, [undefined]);
+ }
+}
+
+export function Ids_validateObjectId(value) {
+ if ((value == null) ? true : !isMatch(Ids_objectPattern, value)) {
+ return new FSharpResult$2(1, ["object id must use the dotted lower-case namespace"]);
+ }
+ else {
+ return new FSharpResult$2(0, [undefined]);
+ }
+}
+
+export class RunStart extends Record {
+ constructor(Ids, At, TotalSteps) {
+ super();
+ this.Ids = Ids;
+ this.At = At;
+ this.TotalSteps = (TotalSteps | 0);
+ }
+}
+
+export function RunStart_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunStart", [], RunStart, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["TotalSteps", int32_type]]);
+}
+
+export class RunHeartbeat extends Record {
+ constructor(Ids, At, Message) {
+ super();
+ this.Ids = Ids;
+ this.At = At;
+ this.Message = Message;
+ }
+}
+
+export function RunHeartbeat_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunHeartbeat", [], RunHeartbeat, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["Message", string_type]]);
+}
+
+export class RunCheckpoint extends Record {
+ constructor(Ids, At, StepId, StepIndex, TotalSteps, Message, ArtifactPath) {
+ super();
+ this.Ids = Ids;
+ this.At = At;
+ this.StepId = StepId;
+ this.StepIndex = (StepIndex | 0);
+ this.TotalSteps = (TotalSteps | 0);
+ this.Message = Message;
+ this.ArtifactPath = ArtifactPath;
+ }
+}
+
+export function RunCheckpoint_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunCheckpoint", [], RunCheckpoint, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["StepId", string_type], ["StepIndex", int32_type], ["TotalSteps", int32_type], ["Message", string_type], ["ArtifactPath", string_type]]);
+}
+
+export class RunComplete extends Record {
+ constructor(Ids, At, ManifestPath) {
+ super();
+ this.Ids = Ids;
+ this.At = At;
+ this.ManifestPath = ManifestPath;
+ }
+}
+
+export function RunComplete_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunComplete", [], RunComplete, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["ManifestPath", string_type]]);
+}
+
+export class RunFail extends Record {
+ constructor(Ids, At, StepId, Message) {
+ super();
+ this.Ids = Ids;
+ this.At = At;
+ this.StepId = StepId;
+ this.Message = Message;
+ }
+}
+
+export function RunFail_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunFail", [], RunFail, () => [["Ids", RunIds_$reflection()], ["At", class_type("System.DateTimeOffset")], ["StepId", option_type(string_type)], ["Message", string_type]]);
+}
+
+export class RunEvent extends Union {
+ constructor(tag, fields) {
+ super();
+ this.tag = tag;
+ this.fields = fields;
+ }
+ cases() {
+ return ["Start", "Heartbeat", "Checkpoint", "Complete", "Fail"];
+ }
+}
+
+export function RunEvent_$reflection() {
+ return union_type("SomhairlesDream.Shared.RunEvent", [], RunEvent, () => [[["Item", RunStart_$reflection()]], [["Item", RunHeartbeat_$reflection()]], [["Item", RunCheckpoint_$reflection()]], [["Item", RunComplete_$reflection()]], [["Item", RunFail_$reflection()]]]);
+}
+
+export class RunEventWire extends Record {
+ constructor(Kind, At, ProjectId, RunId, BaseId, TargetId, TotalSteps, StepId, StepIndex, Message, ArtifactPath, ManifestPath) {
+ super();
+ this.Kind = Kind;
+ this.At = At;
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.BaseId = BaseId;
+ this.TargetId = TargetId;
+ this.TotalSteps = TotalSteps;
+ this.StepId = StepId;
+ this.StepIndex = StepIndex;
+ this.Message = Message;
+ this.ArtifactPath = ArtifactPath;
+ this.ManifestPath = ManifestPath;
+ }
+}
+
+export function RunEventWire_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunEventWire", [], RunEventWire, () => [["Kind", string_type], ["At", class_type("System.DateTimeOffset")], ["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["TotalSteps", option_type(int32_type)], ["StepId", option_type(string_type)], ["StepIndex", option_type(int32_type)], ["Message", option_type(string_type)], ["ArtifactPath", option_type(string_type)], ["ManifestPath", option_type(string_type)]]);
+}
+
+function RunEventModule_ids(value) {
+ return [value.ProjectId, value.RunId, value.BaseId, value.TargetId];
+}
+
+export function RunEventModule_toWire(event) {
+ let patternInput;
+ switch (event.tag) {
+ case 1: {
+ const value_1 = event.fields[0];
+ patternInput = ["heartbeat", value_1.Ids, value_1.At, undefined, undefined, undefined, value_1.Message, undefined, undefined];
+ break;
+ }
+ case 2: {
+ const value_2 = event.fields[0];
+ patternInput = ["checkpoint", value_2.Ids, value_2.At, value_2.TotalSteps, value_2.StepId, value_2.StepIndex, value_2.Message, value_2.ArtifactPath, undefined];
+ break;
+ }
+ case 3: {
+ const value_3 = event.fields[0];
+ patternInput = ["complete", value_3.Ids, value_3.At, undefined, undefined, undefined, undefined, undefined, value_3.ManifestPath];
+ break;
+ }
+ case 4: {
+ const value_4 = event.fields[0];
+ patternInput = ["fail", value_4.Ids, value_4.At, undefined, value_4.StepId, undefined, value_4.Message, undefined, undefined];
+ break;
+ }
+ default: {
+ const value = event.fields[0];
+ patternInput = ["start", value.Ids, value.At, value.TotalSteps, undefined, undefined, undefined, undefined, undefined];
+ }
+ }
+ const patternInput_1 = RunEventModule_ids(patternInput[1]);
+ return new RunEventWire(patternInput[0], patternInput[2], patternInput_1[0], patternInput_1[1], patternInput_1[2], patternInput_1[3], patternInput[3], patternInput[4], patternInput[5], patternInput[6], patternInput[7], patternInput[8]);
+}
+
+export class ArtifactChange extends Record {
+ constructor(ObjectId, Kind, Summary) {
+ super();
+ this.ObjectId = ObjectId;
+ this.Kind = Kind;
+ this.Summary = Summary;
+ }
+}
+
+export function ArtifactChange_$reflection() {
+ return record_type("SomhairlesDream.Shared.ArtifactChange", [], ArtifactChange, () => [["ObjectId", string_type], ["Kind", string_type], ["Summary", string_type]]);
+}
+
+export class RenderArtifact extends Record {
+ constructor(ArtifactPath, Sha256, Bytes, ExportedAt) {
+ super();
+ this.ArtifactPath = ArtifactPath;
+ this.Sha256 = Sha256;
+ this.Bytes = Bytes;
+ this.ExportedAt = ExportedAt;
+ }
+}
+
+export function RenderArtifact_$reflection() {
+ return record_type("SomhairlesDream.Shared.RenderArtifact", [], RenderArtifact, () => [["ArtifactPath", string_type], ["Sha256", string_type], ["Bytes", int64_type], ["ExportedAt", class_type("System.DateTimeOffset")]]);
+}
+
+export class StepArtifact extends Record {
+ constructor(StepId, StepIndex, ArtifactPath, LogPath, Sha256, Bytes, ExportedAt, ObjectIds, VerifiedObjectIds, Changes, Render) {
+ super();
+ this.StepId = StepId;
+ this.StepIndex = (StepIndex | 0);
+ this.ArtifactPath = ArtifactPath;
+ this.LogPath = LogPath;
+ this.Sha256 = Sha256;
+ this.Bytes = Bytes;
+ this.ExportedAt = ExportedAt;
+ this.ObjectIds = ObjectIds;
+ this.VerifiedObjectIds = VerifiedObjectIds;
+ this.Changes = Changes;
+ this.Render = Render;
+ }
+}
+
+export function StepArtifact_$reflection() {
+ return record_type("SomhairlesDream.Shared.StepArtifact", [], StepArtifact, () => [["StepId", string_type], ["StepIndex", int32_type], ["ArtifactPath", string_type], ["LogPath", string_type], ["Sha256", string_type], ["Bytes", int64_type], ["ExportedAt", class_type("System.DateTimeOffset")], ["ObjectIds", array_type(string_type)], ["VerifiedObjectIds", array_type(string_type)], ["Changes", array_type(ArtifactChange_$reflection())], ["Render", option_type(RenderArtifact_$reflection())]]);
+}
+
+export class ArtifactManifest extends Record {
+ constructor(SchemaVersion, ProjectId, RunId, BaseId, TargetId, Status, StartedAt, CompletedAt, BlenderVersion, Steps) {
+ super();
+ this.SchemaVersion = (SchemaVersion | 0);
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.BaseId = BaseId;
+ this.TargetId = TargetId;
+ this.Status = Status;
+ this.StartedAt = StartedAt;
+ this.CompletedAt = CompletedAt;
+ this.BlenderVersion = BlenderVersion;
+ this.Steps = Steps;
+ }
+}
+
+export function ArtifactManifest_$reflection() {
+ return record_type("SomhairlesDream.Shared.ArtifactManifest", [], ArtifactManifest, () => [["SchemaVersion", int32_type], ["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Status", string_type], ["StartedAt", class_type("System.DateTimeOffset")], ["CompletedAt", class_type("System.DateTimeOffset")], ["BlenderVersion", string_type], ["Steps", array_type(StepArtifact_$reflection())]]);
+}
+
+export class RunSnapshot extends Record {
+ constructor(ProjectId, RunId, BaseId, TargetId, Status, CurrentStepId, CurrentStepIndex, TotalSteps, LastHeartbeatAt, UpdatedAt, Message, ManifestPath, ManifestUrl, Error$, IsStale) {
+ super();
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.BaseId = BaseId;
+ this.TargetId = TargetId;
+ this.Status = Status;
+ this.CurrentStepId = CurrentStepId;
+ this.CurrentStepIndex = (CurrentStepIndex | 0);
+ this.TotalSteps = (TotalSteps | 0);
+ this.LastHeartbeatAt = LastHeartbeatAt;
+ this.UpdatedAt = UpdatedAt;
+ this.Message = Message;
+ this.ManifestPath = ManifestPath;
+ this.ManifestUrl = ManifestUrl;
+ this.Error = Error$;
+ this.IsStale = IsStale;
+ }
+}
+
+export function RunSnapshot_$reflection() {
+ return record_type("SomhairlesDream.Shared.RunSnapshot", [], RunSnapshot, () => [["ProjectId", string_type], ["RunId", string_type], ["BaseId", string_type], ["TargetId", string_type], ["Status", string_type], ["CurrentStepId", option_type(string_type)], ["CurrentStepIndex", int32_type], ["TotalSteps", int32_type], ["LastHeartbeatAt", option_type(class_type("System.DateTimeOffset"))], ["UpdatedAt", class_type("System.DateTimeOffset")], ["Message", string_type], ["ManifestPath", option_type(string_type)], ["ManifestUrl", option_type(string_type)], ["Error", option_type(string_type)], ["IsStale", bool_type]]);
+}
+
diff --git a/public/SomhairlesDream.Shared/Domain.js b/public/SomhairlesDream.Shared/Domain.js
new file mode 100644
index 0000000..5384715
--- /dev/null
+++ b/public/SomhairlesDream.Shared/Domain.js
@@ -0,0 +1,114 @@
+import { Record } from "../fable_modules/fable-library-js.4.24.0/Types.js";
+import { bool_type, option_type, class_type, enum_type, array_type, string_type, int32_type, record_type, float64_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js";
+import { item, collect } from "../fable_modules/fable-library-js.4.24.0/Array.js";
+import { singleton, append, collect as collect_1, delay, toArray } from "../fable_modules/fable-library-js.4.24.0/Seq.js";
+import { rangeDouble } from "../fable_modules/fable-library-js.4.24.0/Range.js";
+import { op_Subtraction } from "../fable_modules/fable-library-js.4.24.0/DateOffset.js";
+
+export class Vector3 extends Record {
+ constructor(X, Y, Z) {
+ super();
+ this.X = X;
+ this.Y = Y;
+ this.Z = Z;
+ }
+}
+
+export function Vector3_$reflection() {
+ return record_type("SomhairlesDream.Shared.Vector3", [], Vector3, () => [["X", float64_type], ["Y", float64_type], ["Z", float64_type]]);
+}
+
+export class MeshSnapshot extends Record {
+ constructor(Version, Label, Vertices, Faces) {
+ super();
+ this.Version = (Version | 0);
+ this.Label = Label;
+ this.Vertices = Vertices;
+ this.Faces = Faces;
+ }
+}
+
+export function MeshSnapshot_$reflection() {
+ return record_type("SomhairlesDream.Shared.MeshSnapshot", [], MeshSnapshot, () => [["Version", int32_type], ["Label", string_type], ["Vertices", array_type(Vector3_$reflection())], ["Faces", array_type(array_type(int32_type))]]);
+}
+
+export class LiveSnapshot extends Record {
+ constructor(ProjectId, RunId, Status, CurrentVersion, TargetVersion, HeartbeatAt, UpdatedAt, Message, Mesh, NextDesignRunScheduled) {
+ super();
+ this.ProjectId = ProjectId;
+ this.RunId = RunId;
+ this.Status = (Status | 0);
+ this.CurrentVersion = (CurrentVersion | 0);
+ this.TargetVersion = (TargetVersion | 0);
+ this.HeartbeatAt = HeartbeatAt;
+ this.UpdatedAt = UpdatedAt;
+ this.Message = Message;
+ this.Mesh = Mesh;
+ this.NextDesignRunScheduled = NextDesignRunScheduled;
+ }
+}
+
+export function LiveSnapshot_$reflection() {
+ return record_type("SomhairlesDream.Shared.LiveSnapshot", [], LiveSnapshot, () => [["ProjectId", string_type], ["RunId", string_type], ["Status", enum_type("SomhairlesDream.Shared.RunStatus", int32_type, [["Idle", 0], ["Queued", 1], ["Running", 2], ["Succeeded", 3], ["Failed", 4], ["Stale", 5]])], ["CurrentVersion", int32_type], ["TargetVersion", int32_type], ["HeartbeatAt", option_type(class_type("System.DateTimeOffset"))], ["UpdatedAt", class_type("System.DateTimeOffset")], ["Message", string_type], ["Mesh", option_type(MeshSnapshot_$reflection())], ["NextDesignRunScheduled", bool_type]]);
+}
+
+export function MeshData_positionData(mesh) {
+ return collect((vertex) => (new Float64Array([vertex.X, vertex.Y, vertex.Z])), mesh.Vertices, Float64Array);
+}
+
+export function MeshData_triangleIndices(mesh) {
+ return collect((face) => {
+ if (face.length < 3) {
+ return new Int32Array([]);
+ }
+ else {
+ return toArray(delay(() => collect_1((index) => append(singleton(item(0, face)), delay(() => append(singleton(item(index, face)), delay(() => singleton(item(index + 1, face)))))), rangeDouble(1, 1, face.length - 2))));
+ }
+ }, mesh.Faces, Int32Array);
+}
+
+export function RunState_initial(projectId, runId, now) {
+ return new LiveSnapshot(projectId, runId, 0, 0, 0, undefined, now, "等待设计运行", undefined, false);
+}
+
+export function RunState_start(now, state) {
+ const targetVersion = (state.TargetVersion + 1) | 0;
+ return new LiveSnapshot(state.ProjectId, state.RunId, 2, state.CurrentVersion, targetVersion, now, now, `正在生成第 ${targetVersion} 版几何`, state.Mesh, false);
+}
+
+export function RunState_heartbeat(now, state) {
+ if (state.Status === 2) {
+ return new LiveSnapshot(state.ProjectId, state.RunId, state.Status, state.CurrentVersion, state.TargetVersion, now, now, state.Message, state.Mesh, state.NextDesignRunScheduled);
+ }
+ else {
+ return state;
+ }
+}
+
+export function RunState_statusAt(timeout, now, state) {
+ const matchValue = state.HeartbeatAt;
+ let matchResult, heartbeatAt_1;
+ if (matchValue != null) {
+ if ((state.Status === 2) && (op_Subtraction(now, matchValue) > timeout)) {
+ matchResult = 0;
+ heartbeatAt_1 = matchValue;
+ }
+ else {
+ matchResult = 1;
+ }
+ }
+ else {
+ matchResult = 1;
+ }
+ switch (matchResult) {
+ case 0:
+ return new LiveSnapshot(state.ProjectId, state.RunId, 5, state.CurrentVersion, state.TargetVersion, state.HeartbeatAt, now, "心跳超时,等待重连", state.Mesh, state.NextDesignRunScheduled);
+ default:
+ return state;
+ }
+}
+
+export function RunState_publish(now, mesh, state) {
+ return new LiveSnapshot(state.ProjectId, state.RunId, 3, mesh.Version, state.TargetVersion, now, now, `已发布${mesh.Label}`, mesh, false);
+}
+
diff --git a/public/SomhairlesDream.Shared/Replay.js b/public/SomhairlesDream.Shared/Replay.js
new file mode 100644
index 0000000..9ad2fa2
--- /dev/null
+++ b/public/SomhairlesDream.Shared/Replay.js
@@ -0,0 +1,29 @@
+import { Record } from "../fable_modules/fable-library-js.4.24.0/Types.js";
+import { record_type, string_type, int32_type } from "../fable_modules/fable-library-js.4.24.0/Reflection.js";
+import { MeshSnapshot, Vector3, MeshSnapshot_$reflection } from "./Domain.js";
+
+export class ReplayFrame extends Record {
+ constructor(Version, Label, Mesh) {
+ super();
+ this.Version = (Version | 0);
+ this.Label = Label;
+ this.Mesh = Mesh;
+ }
+}
+
+export function ReplayFrame_$reflection() {
+ return record_type("SomhairlesDream.Shared.ReplayFrame", [], ReplayFrame, () => [["Version", int32_type], ["Label", string_type], ["Mesh", MeshSnapshot_$reflection()]]);
+}
+
+function Replay_vertex(x, y, z) {
+ return new Vector3(x, y, z);
+}
+
+function Replay_frame(version, label, vertices, faces) {
+ return new ReplayFrame(version, label, new MeshSnapshot(version, label, vertices, faces));
+}
+
+export function Replay_frames() {
+ return [Replay_frame(1, "重建起点", [Replay_vertex(-2, 0, -1), Replay_vertex(2, 0, -1), Replay_vertex(2, 0, 1), Replay_vertex(-2, 0, 1), Replay_vertex(-1, 2, -0.5), Replay_vertex(1, 2, -0.5), Replay_vertex(1, 2, 0.5), Replay_vertex(-1, 2, 0.5)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([0, 4, 5]), new Int32Array([0, 5, 1]), new Int32Array([1, 5, 6]), new Int32Array([1, 6, 2]), new Int32Array([2, 6, 7]), new Int32Array([2, 7, 3]), new Int32Array([3, 7, 4]), new Int32Array([3, 4, 0])]), Replay_frame(2, "主承力骨架", [Replay_vertex(-2.5, 0, -1), Replay_vertex(2.5, 0, -1), Replay_vertex(2.5, 0, 1), Replay_vertex(-2.5, 0, 1), Replay_vertex(-1.5, 2.2, -0.7), Replay_vertex(1.5, 2.2, -0.7), Replay_vertex(1.5, 2.2, 0.7), Replay_vertex(-1.5, 2.2, 0.7), Replay_vertex(-0.45, 0, -0.45), Replay_vertex(0.45, 0, -0.45), Replay_vertex(0.45, 3, 0.45), Replay_vertex(-0.45, 3, 0.45)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([8, 9, 10]), new Int32Array([8, 10, 11]), new Int32Array([0, 4, 8]), new Int32Array([4, 11, 8]), new Int32Array([1, 9, 5]), new Int32Array([1, 5, 2]), new Int32Array([2, 5, 10]), new Int32Array([2, 10, 3]), new Int32Array([3, 10, 7]), new Int32Array([3, 7, 0])]), Replay_frame(3, "舱段重构", [Replay_vertex(-3, 0, -1.3), Replay_vertex(3, 0, -1.3), Replay_vertex(3, 0, 1.3), Replay_vertex(-3, 0, 1.3), Replay_vertex(-2, 2, -1), Replay_vertex(2, 2, -1), Replay_vertex(2, 2, 1), Replay_vertex(-2, 2, 1), Replay_vertex(-1, 3.4, -0.65), Replay_vertex(1, 3.4, -0.65), Replay_vertex(1, 3.4, 0.65), Replay_vertex(-1, 3.4, 0.65), Replay_vertex(-3.8, 0.6, 0), Replay_vertex(3.8, 0.6, 0)], [new Int32Array([0, 1, 2]), new Int32Array([0, 2, 3]), new Int32Array([4, 6, 5]), new Int32Array([4, 7, 6]), new Int32Array([8, 9, 10]), new Int32Array([8, 10, 11]), new Int32Array([0, 4, 8]), new Int32Array([0, 8, 12]), new Int32Array([1, 13, 5]), new Int32Array([1, 2, 13]), new Int32Array([2, 6, 10]), new Int32Array([2, 10, 13]), new Int32Array([3, 11, 7]), new Int32Array([3, 12, 11]), new Int32Array([3, 0, 12])])];
+}
+
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..47b6245
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,190 @@
+<!doctype html>
+<html lang="zh-CN">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <meta name="theme-color" content="#080b0d">
+ <title>Somhairle's Dream · 实时重建台</title>
+ <link rel="stylesheet" href="/styles.css">
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/build/three.min.js"></script>
+</head>
+<body>
+ <main class="shell" aria-labelledby="page-title">
+ <header class="masthead">
+ <div class="brand-lockup">
+ <span class="eyebrow">SOMHAIRLE'S DREAM / CONTROL SURFACE</span>
+ <h1 id="page-title">实时重建台</h1>
+ </div>
+ <div class="connection-readout" aria-live="polite">
+ <span class="signal-mark" aria-hidden="true"></span>
+ <span id="stream-state">实时链路 · 等待启动</span>
+ </div>
+ </header>
+
+ <section class="mission-strip" aria-label="当前任务">
+ <div>
+ <span class="eyebrow">PROJECT</span>
+ <strong id="project-id">heritage-001</strong>
+ </div>
+ <div>
+ <span class="eyebrow">RUN</span>
+ <strong id="run-id">run-001</strong>
+ </div>
+ <div class="mission-note">
+ <span class="eyebrow">MODE</span>
+ <strong>F# / LIVE GEOMETRY</strong>
+ </div>
+ </section>
+
+ <section class="workspace" aria-label="实时运行工作区">
+ <aside class="telemetry-panel panel">
+ <div class="panel-heading">
+ <span class="eyebrow">RUN TELEMETRY</span>
+ <div id="status-chip" class="status-chip status-idle" data-status="idle">
+ <span class="status-dot" aria-hidden="true"></span>
+ <span id="status-label">待机</span>
+ </div>
+ </div>
+ <p id="run-message" class="status-message">等待下一个设计运行</p>
+ <form id="run-controls" class="run-controls">
+ <div class="control-grid">
+ <label>
+ <span>项目 ID</span>
+ <input id="project-id-input" value="heritage-001" autocomplete="off">
+ </label>
+ <label>
+ <span>运行 ID</span>
+ <input id="run-id-input" value="run-001" autocomplete="off">
+ </label>
+ <label>
+ <span>基准 ID</span>
+ <input id="base-id-input" value="base-000" autocomplete="off">
+ </label>
+ <label>
+ <span>目标 ID</span>
+ <input id="target-id-input" value="target-003" autocomplete="off">
+ </label>
+ </div>
+ <div class="control-actions">
+ <label class="render-toggle">
+ <input id="run-render-input" type="checkbox">
+ <span>渲染预览 PNG</span>
+ </label>
+ <button id="run-start-button" class="run-start-button" type="submit">启动设计运行</button>
+ </div>
+ <a id="artifact-manifest-link" class="artifact-link is-hidden" target="_blank" rel="noreferrer">打开 artifact manifest</a>
+ </form>
+ <div class="version-block">
+ <div class="label-row">
+ <span>设计版本</span>
+ <strong id="version-readout">00 / 03</strong>
+ </div>
+ <div class="progress-track" aria-hidden="true">
+ <span id="progress-fill" class="progress-fill" style="width: 0%"></span>
+ </div>
+ </div>
+ <dl class="telemetry-list">
+ <div>
+ <dt>当前版本</dt>
+ <dd id="current-version">V00</dd>
+ </div>
+ <div>
+ <dt>目标版本</dt>
+ <dd id="target-version">V03</dd>
+ </div>
+ <div>
+ <dt>最后更新</dt>
+ <dd id="updated-at">等待心跳</dd>
+ </div>
+ </dl>
+ </aside>
+
+ <section id="viewport-panel" class="viewport-panel panel" aria-labelledby="viewport-title">
+ <div class="panel-heading viewport-heading">
+ <div>
+ <span class="eyebrow">GEOMETRY VIEWPORT</span>
+ <h2 id="viewport-title">结构重建视图</h2>
+ </div>
+ <span id="mesh-label" class="mesh-label">重建起点</span>
+ </div>
+ <div class="viewport-frame">
+ <canvas id="viewport-canvas" aria-label="设计几何三维视图"></canvas>
+ <div class="axis-readout" aria-hidden="true">X / Y / Z</div>
+ <div id="viewport-note" class="viewport-note">实时几何 · V00</div>
+ </div>
+ </section>
+
+ <aside class="event-panel panel" aria-labelledby="event-title">
+ <div class="panel-heading">
+ <div>
+ <span class="eyebrow">EVENT STREAM</span>
+ <h2 id="event-title">运行记录</h2>
+ </div>
+ <span class="event-count">LIVE</span>
+ </div>
+ <ol class="event-list">
+ <li class="event-item event-item-active">
+ <span class="event-index">01</span>
+ <div>
+ <strong>实时监视</strong>
+ <span>服务端心跳与版本状态</span>
+ </div>
+ </li>
+ <li class="event-item">
+ <span class="event-index">02</span>
+ <div>
+ <strong>目标检查</strong>
+ <span>等候下一次设计运行</span>
+ </div>
+ </li>
+ <li class="event-item">
+ <span class="event-index">03</span>
+ <div>
+ <strong>发布闸门</strong>
+ <span>验证通过后写入快照</span>
+ </div>
+ </li>
+ </ol>
+ </aside>
+ </section>
+
+ <section class="timeline panel" aria-labelledby="timeline-title">
+ <div class="timeline-header">
+ <div>
+ <span class="eyebrow">RECONSTRUCTION TIMELINE</span>
+ <h2 id="timeline-title">几何版本轨迹</h2>
+ </div>
+ <span id="replay-state" class="replay-state">可回放 · 三个确定性快照</span>
+ </div>
+ <div class="timeline-track" aria-label="几何版本">
+ <span class="timeline-line" aria-hidden="true"></span>
+ <div class="timeline-step timeline-step-complete">
+ <span class="timeline-dot" aria-hidden="true"></span>
+ <span class="timeline-version">V01</span>
+ <span class="timeline-label">重建起点</span>
+ </div>
+ <div class="timeline-step timeline-step-complete">
+ <span class="timeline-dot" aria-hidden="true"></span>
+ <span class="timeline-version">V02</span>
+ <span class="timeline-label">主承力骨架</span>
+ </div>
+ <div class="timeline-step">
+ <span class="timeline-dot" aria-hidden="true"></span>
+ <span class="timeline-version">V03</span>
+ <span class="timeline-label">舱段重构</span>
+ </div>
+ </div>
+ <div class="timeline-footer">
+ <span id="timeline-current">V01 · 重建起点</span>
+ <button id="replay-button" class="replay-button" type="button">重建演示</button>
+ </div>
+ </section>
+
+ <footer class="footer-note">
+ <span>状态快照 · 原子发布 · 心跳监视</span>
+ <span>OPEN CODE / F# FIRST</span>
+ </footer>
+ </main>
+ <script type="module" src="/App.js"></script>
+</body>
+</html>
diff --git a/public/styles.css b/public/styles.css
new file mode 100644
index 0000000..0ee02ef
--- /dev/null
+++ b/public/styles.css
@@ -0,0 +1,692 @@
+:root {
+ color-scheme: dark;
+ --ink: #080b0d;
+ --panel: #11171a;
+ --panel-raised: #161e21;
+ --line: #344146;
+ --line-bright: #627177;
+ --ivory: #f0eadb;
+ --muted: #9aa6a8;
+ --gold: #d7a948;
+ --gold-soft: #f0c976;
+ --mint: #a7c7c0;
+ --green: #76c39a;
+ --red: #dc827b;
+ --shadow: rgba(0, 0, 0, 0.28);
+}
+
+* {
+ box-sizing: border-box;
+}
+
+html {
+ min-width: 320px;
+ background: var(--ink);
+}
+
+body {
+ min-width: 320px;
+ margin: 0;
+ background: var(--ink);
+ color: var(--ivory);
+ font-family: "Fira Sans", "Segoe UI", sans-serif;
+ font-size: 16px;
+ line-height: 1.5;
+}
+
+button,
+body,
+input {
+ font: inherit;
+}
+
+button {
+ cursor: pointer;
+}
+
+button:focus-visible {
+ outline: 2px solid var(--gold-soft);
+ outline-offset: 3px;
+}
+
+.shell {
+ width: min(1480px, calc(100% - 48px));
+ margin: 0 auto;
+ padding: 34px 0 24px;
+}
+
+.masthead,
+.mission-strip,
+.panel-heading,
+.label-row,
+.timeline-header,
+.timeline-footer,
+.footer-note {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.masthead {
+ gap: 24px;
+ padding-bottom: 30px;
+ border-bottom: 1px solid var(--line);
+}
+
+.brand-lockup h1,
+.panel-heading h2,
+.timeline-header h2 {
+ margin: 0;
+ font-weight: 500;
+ letter-spacing: -0.025em;
+}
+
+.brand-lockup h1 {
+ font-size: clamp(2rem, 4vw, 3.7rem);
+ line-height: 1;
+}
+
+.panel-heading h2,
+.timeline-header h2 {
+ font-size: 1.12rem;
+}
+
+.eyebrow,
+.connection-readout,
+.event-count,
+.mesh-label,
+.replay-state,
+.timeline-version,
+.timeline-current,
+.footer-note,
+.axis-readout,
+.viewport-note {
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ letter-spacing: 0.07em;
+ text-transform: uppercase;
+}
+
+.eyebrow {
+ display: block;
+ margin-bottom: 7px;
+ color: var(--muted);
+ font-size: 0.66rem;
+ letter-spacing: 0.16em;
+}
+
+.connection-readout {
+ display: flex;
+ align-items: center;
+ gap: 10px;
+ color: var(--mint);
+ font-size: 0.72rem;
+ white-space: nowrap;
+}
+
+.signal-mark,
+.status-dot {
+ display: inline-block;
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: var(--gold);
+ box-shadow: 0 0 0 4px rgba(215, 169, 72, 0.12);
+}
+
+.mission-strip {
+ display: grid;
+ grid-template-columns: 1fr 1fr 2fr;
+ gap: 1px;
+ margin: 20px 0;
+ border: 1px solid var(--line);
+ background: var(--line);
+}
+
+.mission-strip > div {
+ min-height: 76px;
+ padding: 15px 18px;
+ background: var(--panel);
+}
+
+.mission-strip strong {
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.83rem;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+}
+
+.mission-note {
+ text-align: right;
+}
+
+.workspace {
+ display: grid;
+ grid-template-columns: minmax(200px, 0.72fr) minmax(360px, 1.8fr) minmax(220px, 0.82fr);
+ gap: 12px;
+}
+
+.panel {
+ border: 1px solid var(--line);
+ background: var(--panel);
+ box-shadow: 0 16px 36px var(--shadow);
+}
+
+.telemetry-panel,
+.event-panel {
+ min-height: 440px;
+ padding: 20px;
+}
+
+.panel-heading {
+ align-items: flex-start;
+ gap: 16px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 8px;
+ padding: 5px 9px;
+ border: 1px solid var(--line-bright);
+ color: var(--gold-soft);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+ white-space: nowrap;
+}
+
+.status-chip .status-dot {
+ width: 6px;
+ height: 6px;
+ box-shadow: none;
+}
+
+.status-running .status-dot,
+.status-succeeded .status-dot {
+ background: var(--green);
+}
+
+.status-failed .status-dot,
+.status-stale .status-dot {
+ background: var(--red);
+}
+
+.status-failed,
+.status-stale {
+ color: var(--red);
+}
+
+.status-message {
+ min-height: 56px;
+ margin: 22px 0 28px;
+ color: var(--ivory);
+ font-size: 1.25rem;
+ line-height: 1.35;
+}
+
+.is-hidden {
+ display: none !important;
+}
+
+.run-controls {
+ display: grid;
+ gap: 14px;
+ margin: 0 0 22px;
+ padding: 14px 0 20px;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.control-grid {
+ display: grid;
+ gap: 9px;
+}
+
+.control-grid label,
+.render-toggle {
+ display: grid;
+ gap: 4px;
+ color: var(--muted);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.62rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+
+.control-grid input {
+ width: 100%;
+ min-height: 31px;
+ padding: 5px 7px;
+ border: 1px solid var(--line-bright);
+ border-radius: 0;
+ background: var(--panel-raised);
+ color: var(--ivory);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+}
+
+.control-grid input:focus-visible {
+ outline: 2px solid var(--gold-soft);
+ outline-offset: 2px;
+}
+
+.control-actions {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+
+.render-toggle {
+ display: flex;
+ align-items: center;
+ gap: 7px;
+ letter-spacing: 0;
+ text-transform: none;
+}
+
+.render-toggle input {
+ accent-color: var(--gold);
+}
+
+.run-start-button,
+.artifact-link {
+ min-height: 34px;
+ padding: 7px 9px;
+ border: 1px solid var(--gold);
+ border-radius: 0;
+ background: transparent;
+ color: var(--gold-soft);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.62rem;
+ text-decoration: none;
+ transition: background 180ms ease, color 180ms ease;
+}
+
+.run-start-button:hover,
+.artifact-link:hover {
+ background: var(--gold);
+ color: var(--ink);
+}
+
+.artifact-link {
+ justify-self: start;
+}
+
+.version-block {
+ padding: 16px 0 22px;
+ border-top: 1px solid var(--line);
+ border-bottom: 1px solid var(--line);
+}
+
+.label-row {
+ color: var(--muted);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+}
+
+.label-row strong {
+ color: var(--gold-soft);
+ font-weight: 500;
+}
+
+.progress-track {
+ height: 4px;
+ margin-top: 14px;
+ background: #293236;
+}
+
+.progress-fill {
+ display: block;
+ height: 100%;
+ background: var(--gold);
+ transition: width 220ms ease;
+}
+
+.telemetry-list {
+ margin: 0;
+}
+
+.telemetry-list > div {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 12px;
+ padding: 13px 0;
+ border-bottom: 1px solid #253035;
+}
+
+.telemetry-list dt {
+ color: var(--muted);
+ font-size: 0.76rem;
+}
+
+.telemetry-list dd {
+ max-width: 60%;
+ margin: 0;
+ color: var(--ivory);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.69rem;
+ text-align: right;
+ word-break: break-word;
+}
+
+.viewport-panel {
+ min-height: 440px;
+ padding: 20px 20px 14px;
+}
+
+.viewport-heading {
+ align-items: center;
+}
+
+.mesh-label {
+ color: var(--gold-soft);
+ font-size: 0.68rem;
+ text-align: right;
+}
+
+.viewport-frame {
+ position: relative;
+ height: 370px;
+ margin-top: 16px;
+ overflow: hidden;
+ border: 1px solid var(--line);
+ background: #0b1012;
+}
+
+.viewport-frame::after {
+ position: absolute;
+ inset: 16px;
+ border: 1px solid rgba(167, 199, 192, 0.13);
+ content: "";
+ pointer-events: none;
+}
+
+#viewport-canvas {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.axis-readout,
+.viewport-note {
+ position: absolute;
+ z-index: 2;
+ color: var(--muted);
+ font-size: 0.6rem;
+ pointer-events: none;
+}
+
+.axis-readout {
+ right: 18px;
+ bottom: 16px;
+}
+
+.viewport-note {
+ left: 18px;
+ bottom: 16px;
+ color: var(--gold-soft);
+}
+
+.viewport-fallback .viewport-frame {
+ display: grid;
+ place-items: center;
+}
+
+.event-count {
+ color: var(--green);
+ font-size: 0.65rem;
+}
+
+.event-list {
+ display: grid;
+ gap: 0;
+ margin: 22px 0 0;
+ padding: 0;
+ list-style: none;
+}
+
+.event-item {
+ display: grid;
+ grid-template-columns: 32px 1fr;
+ gap: 10px;
+ padding: 15px 0;
+ border-bottom: 1px solid #253035;
+}
+
+.event-item-active {
+ border-left: 2px solid var(--gold);
+ padding-left: 10px;
+}
+
+.event-index {
+ color: var(--gold-soft);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+}
+
+.event-item strong,
+.event-item span {
+ display: block;
+}
+
+.event-item strong {
+ font-size: 0.82rem;
+ font-weight: 500;
+}
+
+.event-item div > span {
+ margin-top: 4px;
+ color: var(--muted);
+ font-size: 0.72rem;
+}
+
+.timeline {
+ margin-top: 12px;
+ padding: 20px;
+}
+
+.timeline-header {
+ align-items: flex-end;
+ gap: 16px;
+ padding-bottom: 18px;
+ border-bottom: 1px solid var(--line);
+}
+
+.replay-state {
+ color: var(--muted);
+ font-size: 0.65rem;
+ text-align: right;
+}
+
+.timeline-track {
+ position: relative;
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 10px;
+ padding: 30px 0 24px;
+}
+
+.timeline-line {
+ position: absolute;
+ top: 35px;
+ right: 8%;
+ left: 8%;
+ height: 1px;
+ background: var(--line-bright);
+}
+
+.timeline-step {
+ position: relative;
+ display: grid;
+ justify-items: center;
+ gap: 7px;
+ color: var(--muted);
+ text-align: center;
+}
+
+.timeline-dot {
+ z-index: 1;
+ width: 11px;
+ height: 11px;
+ border: 2px solid var(--line-bright);
+ background: var(--panel);
+}
+
+.timeline-step-complete .timeline-dot {
+ border-color: var(--gold);
+ background: var(--gold);
+}
+
+.timeline-version {
+ color: var(--gold-soft);
+ font-size: 0.68rem;
+}
+
+.timeline-label {
+ font-size: 0.78rem;
+}
+
+.timeline-footer {
+ padding-top: 14px;
+ border-top: 1px solid var(--line);
+ color: var(--ivory);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+}
+
+.replay-button {
+ min-height: 44px;
+ padding: 8px 16px;
+ border: 1px solid var(--gold);
+ border-radius: 0;
+ background: transparent;
+ color: var(--gold-soft);
+ font-family: "Fira Code", "SFMono-Regular", Consolas, monospace;
+ font-size: 0.68rem;
+ transition: background 180ms ease, color 180ms ease;
+}
+
+.replay-button:hover {
+ background: var(--gold);
+ color: var(--ink);
+}
+
+.footer-note {
+ padding-top: 18px;
+ color: #687579;
+ font-size: 0.59rem;
+}
+
+@media (max-width: 1080px) {
+ .workspace {
+ grid-template-columns: minmax(190px, 0.7fr) minmax(360px, 1.3fr);
+ }
+
+ .event-panel {
+ grid-column: 1 / -1;
+ min-height: 0;
+ }
+
+ .event-list {
+ grid-template-columns: repeat(3, 1fr);
+ gap: 16px;
+ }
+
+ .event-item {
+ border-bottom: 0;
+ border-top: 1px solid #253035;
+ }
+}
+
+@media (max-width: 700px) {
+ .shell {
+ width: min(100% - 28px, 560px);
+ padding-top: 20px;
+ }
+
+ .masthead {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 18px;
+ padding-bottom: 22px;
+ }
+
+ .connection-readout {
+ font-size: 0.64rem;
+ }
+
+ .mission-strip {
+ grid-template-columns: 1fr 1fr;
+ }
+
+ .mission-note {
+ grid-column: 1 / -1;
+ text-align: left;
+ }
+
+ .workspace {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .telemetry-panel,
+ .event-panel,
+ .viewport-panel {
+ min-height: 0;
+ }
+
+ .viewport-frame {
+ height: 320px;
+ }
+
+ .event-list {
+ grid-template-columns: 1fr;
+ }
+
+ .event-item {
+ border-bottom: 1px solid #253035;
+ }
+
+ .timeline-header,
+ .timeline-footer {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .timeline-footer {
+ gap: 14px;
+ }
+
+ .control-actions {
+ align-items: stretch;
+ flex-direction: column;
+ }
+
+ .run-start-button {
+ width: 100%;
+ }
+
+ .replay-button {
+ width: 100%;
+ }
+
+ .footer-note {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 6px;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ *,
+ *::before,
+ *::after {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ animation-duration: 0.01ms !important;
+ animation-iteration-count: 1 !important;
+ }
+}