diff options
| author | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
|---|---|---|
| committer | Somhairle H. Marisol <[email protected]> | 2026-09-21 00:58:15 +0800 |
| commit | 7b29dbff7e43e87741476f47df17ad1582b16546 (patch) | |
| tree | 4c9d0832b4e4edd77e59e9cb0a88abf5e729a9f9 /public | |
| parent | 47add3088d50f12c5f48591f26c53cdb59d8283b (diff) | |
| download | somhairles-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.js | 392 | ||||
| -rw-r--r-- | public/SomhairlesDream.Shared/Artifact.js | 277 | ||||
| -rw-r--r-- | public/SomhairlesDream.Shared/Domain.js | 114 | ||||
| -rw-r--r-- | public/SomhairlesDream.Shared/Replay.js | 29 | ||||
| -rw-r--r-- | public/index.html | 190 | ||||
| -rw-r--r-- | public/styles.css | 692 |
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; + } +} |
