Compare commits

..

3 Commits

Author SHA1 Message Date
jan
d2ca1a2aef Tighten web surfaces and clean handoff 2026-04-20 21:12:52 +02:00
jan
ed1e4b49ab Update Qwen 14B local environment handoff 2026-04-20 17:46:30 +02:00
jan
202b86aa72 Add Qwen 14B handoff documentation 2026-04-20 17:39:21 +02:00
14 changed files with 933 additions and 389 deletions

View File

@@ -44,6 +44,7 @@ The current baseline is intentionally strict about unresolved hardware facts. `U
- [Architecture](docs/architecture.md)
- [Host API](docs/host_api.md)
- [Local Software-Only Runbook](docs/local_software_only_runbook.md)
- [Qwen 14B Handoff](docs/qwen14b_handoff.md)
- [Show-Control Primitives](docs/show_control_primitives.md)
- [Pattern Matrix v1](docs/pattern_matrix_v1.md)
- [External Control Bridge](docs/external_control_bridge.md)

View File

@@ -22,7 +22,7 @@ impl Default for TickSchedule {
logic_hz: 120,
frame_synthesis_hz: 60,
network_send_hz: 60,
preview_hz: 15,
preview_hz: 60,
}
}
}
@@ -54,12 +54,12 @@ impl RealtimeEngine {
mode: ValidationMode,
) -> ValidationReport {
let mut report = project.validate(mode);
if self.schedule.preview_hz >= self.schedule.frame_synthesis_hz {
if self.schedule.preview_hz > self.schedule.frame_synthesis_hz {
report.issues.push(ValidationIssue {
severity: ValidationSeverity::Warning,
code: "preview_rate_too_high",
path: "runtime.schedule.preview_hz".to_string(),
message: "preview rate should stay below frame synthesis rate".to_string(),
message: "preview rate should not exceed frame synthesis rate".to_string(),
});
}
report

View File

@@ -317,7 +317,7 @@ fn render_pattern_leds(
let speed = scene_scalar(scene, "speed", 0.45).max(0.05);
let tempo_multiplier = scene_scalar(scene, "tempo_multiplier", 1.0).clamp(0.25, 8.0);
let intensity = scene_scalar(scene, "intensity", 1.0).clamp(0.0, 1.0);
let fade = scene_scalar(scene, "fade", 0.35).clamp(0.0, 1.0);
let fade = scene_scalar(scene, "fade", 0.0).clamp(0.0, 1.0);
let pattern_brightness = scene_scalar(scene, "brightness", 1.0).clamp(0.0, 2.0);
let block_size = scene_scalar(scene, "block_size", 1.0).max(0.1);
let on_width = scene_scalar(scene, "on_width", 1.0).clamp(0.1, 2.0);
@@ -543,16 +543,31 @@ fn render_pattern_leds(
_ => vec![primary; led_count],
};
if fade > 0.0 && colors.len() > 2 {
if fade > 0.0 && colors.len() > 2 && smoothing_allowed_for_pattern(pattern_id) {
colors = smooth_led_sequence(colors, fade);
}
colors
.into_iter()
.map(|color| color.scale((intensity * pattern_brightness).clamp(0.0, 1.0)))
.map(|color| {
color
.scale(quantize_intensity(
(intensity * pattern_brightness).clamp(0.0, 1.0),
))
.quantize_10_percent()
})
.collect()
}
fn smoothing_allowed_for_pattern(pattern_id: &str) -> bool {
matches!(pattern_id, "breathing")
}
fn quantize_intensity(value: f32) -> f32 {
let clamped = value.clamp(0.0, 1.0);
(clamped * 10.0).round() / 10.0
}
fn canonical_pattern_id(pattern_id: &str) -> &str {
match pattern_id {
"solid_color" => "solid",
@@ -715,7 +730,7 @@ fn choose_pair(
) -> (RgbColor, RgbColor) {
let primary = scene_text(scene, "primary_color", "#4D7CFF");
let secondary = scene_text(scene, "secondary_color", "#0E1630");
let palette_name = scene_text(scene, "palette", "Laser Club");
let palette_name = scene_text(scene, "palette", "Deep Blue");
let seed_amount = amount + panel_row as f32 * 0.13 + panel_col as f32 * 0.07;
match color_mode {
"palette" => (
@@ -770,159 +785,129 @@ fn sample_palette_list(colors: &[RgbColor], amount: f32) -> RgbColor {
}
fn named_palette(name: &str) -> Option<&'static [RgbColor]> {
const LASER_CLUB: [RgbColor; 4] = [
RgbColor {
r: 0,
g: 240,
b: 255,
},
RgbColor {
r: 0,
g: 140,
b: 255,
},
RgbColor {
r: 106,
g: 0,
b: 255,
},
RgbColor { r: 6, g: 8, b: 20 },
];
const AFTERHOURS: [RgbColor; 4] = [
RgbColor {
r: 247,
g: 37,
b: 133,
},
RgbColor {
r: 181,
g: 23,
b: 158,
},
RgbColor {
r: 114,
g: 9,
b: 183,
},
RgbColor { r: 20, g: 3, b: 26 },
];
const VOLTAGE: [RgbColor; 4] = [
RgbColor {
r: 0,
g: 229,
b: 255,
},
RgbColor {
r: 0,
g: 179,
b: 255,
},
RgbColor {
r: 58,
g: 134,
b: 255,
},
RgbColor { r: 5, g: 10, b: 20 },
];
const MAGENTA_DRIVE: [RgbColor; 4] = [
RgbColor {
r: 255,
g: 0,
b: 110,
},
RgbColor {
r: 255,
g: 77,
b: 166,
},
RgbColor {
r: 122,
g: 0,
b: 255,
},
RgbColor { r: 18, g: 3, b: 24 },
];
const WAREHOUSE_HEAT: [RgbColor; 4] = [
RgbColor {
r: 255,
g: 90,
b: 31,
},
RgbColor {
r: 255,
g: 158,
b: 0,
},
RgbColor {
r: 255,
g: 208,
b: 0,
},
RgbColor { r: 20, g: 6, b: 0 },
];
const UV_RIOT: [RgbColor; 4] = [
RgbColor {
r: 122,
g: 0,
b: 255,
},
RgbColor {
r: 177,
g: 0,
b: 255,
},
RgbColor {
r: 255,
g: 0,
b: 168,
},
RgbColor { r: 16, g: 0, b: 20 },
];
const REDLINE: [RgbColor; 4] = [
RgbColor {
r: 255,
g: 45,
b: 85,
},
RgbColor {
r: 255,
g: 106,
b: 0,
},
RgbColor {
r: 255,
g: 176,
b: 0,
},
RgbColor { r: 22, g: 4, b: 6 },
];
const SODIUM_HAZE: [RgbColor; 4] = [
RgbColor {
r: 255,
g: 122,
b: 0,
},
RgbColor {
r: 255,
g: 176,
b: 0,
},
RgbColor {
r: 255,
g: 216,
b: 107,
},
RgbColor { r: 18, g: 7, b: 0 },
];
const CANDLE: [RgbColor; 1] = [RgbColor {
r: 255,
g: 147,
b: 41,
}];
const TUNGSTEN_40W: [RgbColor; 1] = [RgbColor {
r: 255,
g: 197,
b: 143,
}];
const TUNGSTEN_100W: [RgbColor; 1] = [RgbColor {
r: 255,
g: 214,
b: 170,
}];
const HALOGEN: [RgbColor; 1] = [RgbColor {
r: 255,
g: 241,
b: 224,
}];
const CARBON_ARC: [RgbColor; 1] = [RgbColor {
r: 255,
g: 250,
b: 244,
}];
const HIGH_NOON_SUN: [RgbColor; 1] = [RgbColor {
r: 255,
g: 255,
b: 251,
}];
const OVERCAST_SKY: [RgbColor; 1] = [RgbColor {
r: 201,
g: 226,
b: 255,
}];
const CLEAR_BLUE_SKY: [RgbColor; 1] = [RgbColor {
r: 64,
g: 156,
b: 255,
}];
const DEEP_BLUE: [RgbColor; 1] = [RgbColor {
r: 0,
g: 71,
b: 255,
}];
const ELECTRIC_CYAN: [RgbColor; 1] = [RgbColor {
r: 0,
g: 216,
b: 255,
}];
const EMERALD: [RgbColor; 1] = [RgbColor {
r: 0,
g: 200,
b: 83,
}];
const ACID_LIME: [RgbColor; 1] = [RgbColor {
r: 174,
g: 234,
b: 0,
}];
const AMBER: [RgbColor; 1] = [RgbColor {
r: 255,
g: 157,
b: 0,
}];
const SIGNAL_RED: [RgbColor; 1] = [RgbColor {
r: 255,
g: 45,
b: 45,
}];
const HOT_MAGENTA: [RgbColor; 1] = [RgbColor {
r: 255,
g: 0,
b: 168,
}];
const VIOLET: [RgbColor; 1] = [RgbColor {
r: 122,
g: 60,
b: 255,
}];
const WARM_WHITE: [RgbColor; 1] = [RgbColor {
r: 255,
g: 214,
b: 170,
}];
const NEUTRAL_WHITE: [RgbColor; 1] = [RgbColor {
r: 255,
g: 241,
b: 224,
}];
const COOL_WHITE: [RgbColor; 1] = [RgbColor {
r: 201,
g: 226,
b: 255,
}];
const BLACKLIGHT_VIOLET: [RgbColor; 1] = [RgbColor {
r: 167,
g: 0,
b: 255,
}];
match name {
"Magenta Drive" => Some(&MAGENTA_DRIVE),
"Warehouse Heat" => Some(&WAREHOUSE_HEAT),
"UV Riot" => Some(&UV_RIOT),
"Redline" => Some(&REDLINE),
"Sodium Haze" => Some(&SODIUM_HAZE),
"Afterhours" => Some(&AFTERHOURS),
"Voltage" => Some(&VOLTAGE),
"Laser Club" => Some(&LASER_CLUB),
"Candle" => Some(&CANDLE),
"Tungsten 40W" => Some(&TUNGSTEN_40W),
"Tungsten 100W" => Some(&TUNGSTEN_100W),
"Halogen" => Some(&HALOGEN),
"Carbon Arc" => Some(&CARBON_ARC),
"High Noon Sun" => Some(&HIGH_NOON_SUN),
"Overcast Sky" => Some(&OVERCAST_SKY),
"Clear Blue Sky" => Some(&CLEAR_BLUE_SKY),
"Deep Blue" => Some(&DEEP_BLUE),
"Electric Cyan" => Some(&ELECTRIC_CYAN),
"Emerald" => Some(&EMERALD),
"Acid Lime" => Some(&ACID_LIME),
"Amber" => Some(&AMBER),
"Signal Red" => Some(&SIGNAL_RED),
"Hot Magenta" => Some(&HOT_MAGENTA),
"Violet" => Some(&VIOLET),
"Warm White" => Some(&WARM_WHITE),
"Neutral White" => Some(&NEUTRAL_WHITE),
"Cool White" => Some(&COOL_WHITE),
"Blacklight Violet" => Some(&BLACKLIGHT_VIOLET),
_ => None,
}
}
@@ -1853,12 +1838,12 @@ fn common_motion_parameters(extra_keys: Vec<&str>) -> Vec<SceneParameterSpec> {
scalar_spec("speed", "Speed", 0.05, 8.0, 0.05, 0.45),
scalar_spec("intensity", "Intensity", 0.0, 1.0, 0.01, 1.0),
scalar_spec("brightness", "Brightness", 0.0, 2.0, 0.01, 1.0),
scalar_spec("fade", "Smoothing", 0.0, 1.0, 0.01, 0.35),
scalar_spec("fade", "Smoothing", 0.0, 1.0, 0.01, 0.0),
scalar_spec("tempo_multiplier", "Tempo Multiplier", 0.25, 8.0, 0.05, 1.0),
text_spec("color_mode", "Color Mode", "dual"),
text_spec("primary_color", "Primary Color", "#4D7CFF"),
text_spec("secondary_color", "Secondary Color", "#0E1630"),
text_spec("palette", "Palette", "Laser Club"),
text_spec("palette", "Palette", "Deep Blue"),
];
for key in extra_keys {
@@ -1960,6 +1945,14 @@ impl RgbColor {
}
}
fn quantize_10_percent(self) -> Self {
Self {
r: quantize_channel_10_percent(self.r),
g: quantize_channel_10_percent(self.g),
b: quantize_channel_10_percent(self.b),
}
}
fn complementary(self) -> Self {
Self {
r: 255u8.saturating_sub(self.r),
@@ -1998,6 +1991,12 @@ impl RgbColor {
}
}
fn quantize_channel_10_percent(value: u8) -> u8 {
(((value as f32 / 255.0) * 10.0).round() * 25.5)
.round()
.clamp(0.0, 255.0) as u8
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -277,6 +277,7 @@ fn handle_websocket(
let mut sequence = 1u64;
let mut last_event_millis = None::<u64>;
let mut last_event_signatures = Vec::<(Option<String>, String)>::new();
let mut last_streamed_preview = None::<crate::dto::ApiPreviewSnapshot>;
loop {
let snapshot = service.snapshot();
send_stream_message(
@@ -286,13 +287,22 @@ fn handle_websocket(
ApiStreamMessage::Snapshot(ApiStateSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot)),
)?;
sequence += 1;
let preview_payload = crate::dto::ApiPreviewSnapshot::from_snapshot(&snapshot);
if last_streamed_preview
.as_ref()
.map(|previous| previous != &preview_payload)
.unwrap_or(true)
{
send_stream_message(
&mut stream,
sequence,
snapshot.generated_at_millis,
ApiStreamMessage::Preview(preview_payload.clone()),
)?;
sequence += 1;
last_streamed_preview = Some(preview_payload);
}
let mut new_events = snapshot
.recent_events

View File

@@ -147,6 +147,28 @@ fn web_ui_browser_smoke_serves_shell_assets_and_stream_bootstrap() {
server.shutdown();
}
#[test]
fn technical_surface_script_guards_missing_recent_events_in_state_snapshot() {
let server = start_server();
let technical_js = send_http_request(server.local_addr(), "GET", "/technical.js", None);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(technical_js.status_code, 200);
assert!(technical_js
.body
.contains("function snapshotRecentEvents(snapshot)"));
assert!(technical_js
.body
.contains("Array.isArray(snapshot?.recent_events) ? snapshot.recent_events : []"));
assert!(technical_js
.body
.contains("const recentEvents = snapshotRecentEvents(appState.snapshot);"));
assert!(state_body["state"].get("recent_events").is_none());
server.shutdown();
}
#[test]
fn state_preview_and_snapshot_endpoints_are_versioned_and_separated() {
let server = start_server();
@@ -268,6 +290,40 @@ fn technical_surface_commands_update_backend_node_targets_and_panel_mapping() {
server.shutdown();
}
#[test]
fn technical_surface_can_disable_output_again_after_enabling_it() {
let server = start_server();
let enable_mode = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_output_backend_mode","payload":{"mode":"ddp_wled"}}}"#,
);
let enable_output = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":true}}}"#,
);
let disable_output = send_command_json(
server.local_addr(),
r#"{"command":{"type":"set_live_output_enabled","payload":{"enabled":false}}}"#,
);
assert_eq!(enable_mode.status_code, 200);
assert_eq!(enable_output.status_code, 200);
assert_eq!(disable_output.status_code, 200);
let state = send_http_request(server.local_addr(), "GET", "/api/v1/state", None);
let state_body: Value = serde_json::from_str(&state.body).expect("state json");
assert_eq!(state_body["state"]["technical"]["backend_mode"], "ddp_wled");
assert_eq!(state_body["state"]["technical"]["output_enabled"], false);
assert_eq!(
state_body["state"]["technical"]["live_status"],
"DDP (WLED) selected - output disabled"
);
server.shutdown();
}
#[test]
fn discovery_scan_endpoint_returns_structured_results_and_rejects_invalid_subnets() {
let server = start_server();

View File

@@ -42,7 +42,7 @@ The current delivery order is intentionally software-first:
- Logic tick target: 120 Hz
- Frame synthesis target: 60 Hz
- Network send target: 40-60 Hz, profile dependent
- Preview target: 10-15 Hz
- Preview target: up to 60 Hz when the active surface can render it cleanly, otherwise 30 Hz fallback
Preview and telemetry are explicitly degradable. Realtime output is not.

121
docs/codex_worklog.md Normal file
View File

@@ -0,0 +1,121 @@
# Codex Worklog
## 2026-04-20 - Creative Surface kompakter und Preview-FPS-Pruefung
- Geaenderte Dateien:
- `web/v1/styles.css`
- `web/v1/app.js`
- `crates/infinity_host/src/runtime.rs`
- `docs/architecture.md`
- Fachliche Aenderungen:
- Creative Surface kompakter aufgestellt, damit die 18 Preview-Panels im 6x3-Raster besser vollstaendig sichtbar bleiben.
- Frontend-Preview-Drosselung von ca. 11 fps entfernt und auf eine ruhige `requestAnimationFrame`-basierte Taktung mit Host-orientiertem Ziel-FPS umgestellt.
- Host-Preview-Schedule von 15 Hz auf 60 Hz angehoben, ohne neue Architektur einzuziehen.
- Dokumentation des Preview-Ziels auf den aktuellen Stand gebracht.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler API-Check gegen temporaeren Host auf `127.0.0.1:9002` mit bestaetigtem `engine.preview_hz = 60`
- kein echter Browser-Handtest in dieser CLI-Umgebung moeglich
- Bewusste Abweichungen zur Python-Version:
- Kein Canvas-Preview-Rewrite in diesem Schritt; die bestehende DOM/CSS-Preview bleibt erhalten und wird nur verschlankt sowie render-seitig entdrosselt.
## 2026-04-20 - Diskretere Preview-Wirkung und weniger Preview-Clutter
- Geaenderte Dateien:
- `crates/infinity_host/src/scene.rs`
- `crates/infinity_host_api/src/server.rs`
- `web/v1/app.js`
- `web/v1/index.html`
- Fachliche Aenderungen:
- Normale Pattern-Intensitaet vor Preview-Ausgabe auf 10%-Stufen quantisiert.
- Normales Preview-Smoothing standardmaessig stark reduziert; nur `breathing` darf weiterhin ueber den bestehenden `fade`-Pfad weich bleiben.
- Preview-Stream im WebSocket sendet Preview nur noch bei geaendertem Payload; der bestehende Snapshot-Heartbeat bleibt fuer den API-Vertrag erhalten.
- Master Brightness in den Header verschoben; linke Brightness-Sektion zeigt nur noch pattern-spezifische Helligkeitsparameter.
- Den sichtbaren `speed`-Slider aus der Creative Surface entfernt; BPM oben plus `tempo_multiplier` bleiben die Geschwindigkeitsbedienung.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- Bewusste Abweichungen zur Python-Version:
- Keine vollstaendige Python-Pattern-Engine-Portierung; stattdessen gezielte Diskretisierung und Stream-Beruhigung innerhalb der bestehenden Rust-Host-/Web-Architektur.
- Kein echter Browser-Handtest in dieser CLI-Umgebung moeglich.
## 2026-04-20 - Creative Surface Redesign und Control-Fix (gezielt)
- Geaenderte Dateien:
- `web/v1/index.html`
- `web/v1/app.js`
- `web/v1/styles.css`
- `crates/infinity_host/src/scene.rs`
- Fachliche Aenderungen:
- Redundante globale Brightness-Steuerung in der linken Spalte entfernt; Master Brightness bleibt oben im Header.
- Sichtbaren `speed`-Slider in der Creative Surface entfernt; Bedienung ueber BPM oben plus `tempo_multiplier` in den Pattern-Parametern.
- `palette`, `color_mode`, `direction`, `mirror` als feste Controls (keine freien Textfelder) in der Web-UI beibehalten/abgesichert.
- Palette auf zwei feste Gruppen mit den vorgegebenen Namen und Hex-Werten umgestellt; Host-Palette-Mapping auf dieselben IDs umgestellt.
- Layout gezielt entkapselt: weniger harte Rahmen, leichtere Rails, kompaktere Topbar, mehr zusammenhaengende Arbeitsflaeche.
- Preview-Grid auf feste 6 Spalten gesetzt und Tiles quadratisch gemacht (`aspect-ratio: 1 / 1`) fuer ein klares 3x6-Raster.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- Browser-Handtest:
- Versuch via lokalem Headless-Firefox auf `127.0.0.1:9002` ist in dieser Umgebung fehlgeschlagen (`cannot open display: :0` / Firefox Headless-Crash).
- Bewusste Abweichungen zur Python-Version:
- Die neue, fest vorgegebene Palette-Liste ersetzt die alten Python-Palettenamen; das ist eine inhaltliche Vorgabe dieses Arbeitsschritts.
## 2026-04-20 - Palette ersetzen, linken Brightness-Slider entfernen, linke Rail entzerren
- Geaenderte Dateien:
- `web/v1/index.html`
- `web/v1/app.js`
- `web/v1/styles.css`
- Fachliche Aenderungen:
- Die Creative-Surface-Palette bleibt auf die kuratierte WS2812-Liste mit exakt vorgegebenen Namen und Hex-Werten beschraenkt.
- Den unteren Brightness-Bereich in der linken Spalte vollstaendig entfernt; globale Helligkeit bleibt ausschliesslich im Header.
- Den dynamisch aus Host-Parametern kommenden `brightness`-Control in der linken Rail ausgefiltert.
- Die linke Rail moderat verbreitert und Parameterfelder so nachgezogen, dass Dropdowns, Labels und Farbwerte weniger gequetscht sind.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler CLI-Smoke gegen temporaeren Host auf `127.0.0.1:9002` via `/api/v1/state` und `/api/v1/preview`
- Bewusste Abweichungen zur Python-Version:
- Keine; dieser Schritt korrigiert nur die Web-Darstellung und blendet den global redundanten Brightness-Slider links aus.
## 2026-04-20 - Technical Surface null-safe gegen fehlende recent_events
- Geaenderte Dateien:
- `web/v1/technical.js`
- `crates/infinity_host_api/tests/contract.rs`
- `docs/codex_worklog.md`
- Fachliche Aenderungen:
- Technical Surface gegen unvollstaendige oder startende Snapshot-Daten robuster gemacht.
- `recent_events`, `system`, `technical`, `nodes` und `panels` werden in der UI jetzt ueber null-safe Helper mit Fallbacks gelesen.
- Direkter Zugriff auf `snapshot.recent_events.map(...)` entfernt, damit `/technical` nicht mehr crasht, wenn das Feld fehlt.
- Contract-/UI-Smoke-Test ergaenzt, der absichert, dass das ausgelieferte `technical.js` den Guard fuer fehlende `recent_events` enthaelt, waehrend `/api/v1/state` das Feld weiterhin weglassen darf.
- Gelaufene Tests:
- `cargo fmt --check`
- `cargo test -q -p infinity_host_api --test contract`
- lokaler Host-Smoke auf `127.0.0.1:9011` mit erfolgreichem Abruf von `/` und `/api/v1/state`
- Bewusste Abweichungen zur Python-Version:
- Keine; das ist ein reiner Stabilitaets-/Robustheitsfix in der bestehenden Web-Oberflaeche.
## 2026-04-20 - Browser-Smoke-Runner und Output-Disable-Fix
- Geaenderte Dateien:
- `web/v1/technical.js`
- `crates/infinity_host_api/tests/contract.rs`
- `scripts/codex_browser_smoke.sh`
- `docs/codex_worklog.md`
- Fachliche Aenderungen:
- Technical-Output-Controls gegen Polling-/Apply-Rennen stabilisiert, indem waehrend `saveOutputSettings()` ein `outputSaving`-Zustand gesetzt wird.
- Output-Controls werden waehrend des Speicherns kurz deaktiviert und der Draft wird in dieser Zeit nicht von Polling-Snapshots ueberschrieben.
- Contract-Test ergaenzt, der absichert, dass `output_enabled` nach vorherigem Aktivieren wieder sauber auf `false` gesetzt werden kann.
- Kleinen lokalen Browser-/Route-Smoke-Runner fuer Codex angelegt, der eine Kurzinstanz startet und `/`, `/technical` sowie `/technical.js` selbst prueft.
- Gelaufene Tests:
- `cargo test -q -p infinity_host_api --test contract`
- `scripts/codex_browser_smoke.sh 9012`
- Bewusste Abweichungen zur Python-Version:
- Keine; der Schritt behebt einen Web-Workflow-Bug und verbessert nur den lokalen Debug-Pfad.

204
docs/qwen14b_handoff.md Normal file
View File

@@ -0,0 +1,204 @@
# Qwen 14B Handoff
## Zweck
Diese Datei ist die schnelle Uebergabe fuer ein kleineres Modell wie Qwen 14B. Sie soll den aktuellen Projektstand, die stabile Architekturgrenze und die naechsten sicheren Arbeitspfade kompakt erklaeren, ohne dass zuerst das ganze Repo rekonstruiert werden muss.
## Aktueller Stand
- Host-Core ist die zentrale Runtime und bleibt die einzige Kernarchitektur.
- API v1 ist die verbindliche Aussenkante fuer State, Preview, Snapshot, Catalog, Commands und Event-Stream.
- Die Creative Surface lebt in `web/v1/` und ist die operatorische Web-Oberflaeche.
- Die Technical Surface in `web/v1/technical.html` plus `web/v1/technical.js` ist die aktuelle technische Web-Oberflaeche.
- Die Desktop-GUI in `crates/infinity_host_ui/` existiert weiter als technische Engineering-/Diagnoseflaeche, ist aber nicht der primaere aktuelle UI-Arbeitspfad.
- Persistenz, Recovery und Runtime-Show-Store sind vorhanden.
- Show-Control-v1-Primitive sind faktisch eingefroren und dokumentiert.
- Ein generischer externer Control-Pfad ist vorhanden, aber bewusst nicht grandMA-spezifisch.
## Frisch umgesetzt in dieser Arbeitsphase
- Creative Surface wurde kompakter gemacht, damit alle 18 Panels im 3x6-Raster gleichzeitig sichtbar bleiben.
- Preview- und normale Pattern-Wirkung wurden diskreter gemacht:
- Preview-Ziel im Host liegt jetzt bei bis zu 60 Hz.
- Normale Preview-/Pattern-Helligkeit ist auf 10%-Stufen quantisiert.
- Preview-Updates und WebSocket-Preview-Frames werden nur noch bei inhaltlicher Aenderung weitergeschoben.
- Die feste WS2812-Palette wurde in Host und Web-UI hinterlegt; alte freie/abweichende Palettennamen wurden ersetzt.
- Globaler Master-Brightness sitzt nur noch im Header; redundante globale Brightness-Bedienung links ist entfernt.
- Die Technical Surface ist robuster gegen unvollstaendige Snapshots, insbesondere fehlende `recent_events`.
- Fuer Codex gibt es jetzt einen lokalen Route-/Browser-Smoke-Runner:
- `scripts/codex_browser_smoke.sh`
- prueft `/`, `/technical` und `/technical.js` gegen eine frische Kurzinstanz
- ersetzt noch keinen vollgerenderten Headless-Browser mit DOM-Interaktion
## Lokale Umgebung auf diesem Rechner
- Arbeitsverzeichnis des Rust-Projekts: `/home/jan/Documents/RFP/Infinity_Vis_Rust`
- Dieses Repo ist hier ein echter Git-Clone mit `.git`.
- Rust-Toolchain ist lokal vorhanden:
- `cargo 1.95.0 (f2d3ce0bd 2026-03-21)`
- `rustc 1.95.0 (59807616e 2026-04-14)`
- Gewuenschte Toolchain laut [rust-toolchain.toml](/home/jan/Documents/RFP/Infinity_Vis_Rust/rust-toolchain.toml:1):
- Channel: `stable`
- Components: `rustfmt`, `clippy`
- Laufzeitpersistenz liegt standardmaessig in [data/runtime_state.json](/home/jan/Documents/RFP/Infinity_Vis_Rust/data/runtime_state.json:1)
- Das alte Python-Referenzprojekt soll lokal daneben liegen unter:
- `/home/jan/Documents/RFP/RFP_Infinity-Vis`
## Lokaler Startpfad
Aus dem Rust-Repo heraus:
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
Danach:
- Creative Surface: `http://127.0.0.1:9001/`
- Technical Surface: `http://127.0.0.1:9001/technical`
- State API: `http://127.0.0.1:9001/api/v1/state`
- Preview API: `http://127.0.0.1:9001/api/v1/preview`
- WebSocket: `ws://127.0.0.1:9001/api/v1/stream`
## Nicht neu interpretieren
Diese Grenzen sind verbindlich:
- keine neue Parallelarchitektur neben Host-Core plus API
- keine direkte grandMA-Kopplung in den Kern
- keine Hardwarevalidierung als Hauptfokus
- Creative Surface bleibt kreative/operatorische Oberflaeche
- Desktop-GUI oder Technical Surface bleibt technische Betriebsoberflaeche
Wenn Verhalten unklar ist, gilt:
1. erst alte Referenz dokumentarisch vergleichen
2. dann an bestehende Host-/API-Semantik anpassen
3. nicht stillschweigend neue Bedienlogik erfinden
## Wo der Arbeitsfortschritt festgehalten ist
Die wichtigsten Fortschrittsanker liegen hier:
- [README.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/README.md:1)
Der Einstieg, grobe Struktur und verlinkte Referenzdokumente.
- [docs/show_control_primitives.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/show_control_primitives.md:1)
Die stabile interne Show-Control-v1-Semantik inklusive staged/direct-Trennung, Fehlercodes und Event-Auswirkungen.
- [docs/pattern_matrix_v1.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/pattern_matrix_v1.md:1)
Alter Python-Bezug versus neue Host-Pattern-IDs, Parameterbasis und UI-Arbeitsmodi.
- [docs/external_control_bridge.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/external_control_bridge.md:1)
Zielbild und Regeln fuer die generische externe Steuerkante.
- [docs/control_ownership.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/control_ownership.md:1)
Konflikt- und Ownership-Regeln zwischen Web-UI, technischer GUI und externen Control-Quellen.
- [docs/local_software_only_runbook.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/local_software_only_runbook.md:1)
Reproduzierbarer software-only Startpfad.
- [docs/codex_worklog.md](/home/jan/Documents/RFP/Infinity_Vis_Rust/docs/codex_worklog.md:1)
Knapper Verlauf der letzten Codex-Arbeitspakete inklusive Tests, Abweichungen und lokalen Smoke-Checks.
- [crates/infinity_host/tests/show_control_v1_golden.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/tests/show_control_v1_golden.rs:1)
Golden-Trace- und Replay-Schutz fuer die zentrale v1-Semantik.
- Git-Historie:
- `a56cecb` `Software-only show-control readiness baseline`
- `07c52db` `Stabilize control surface and external bridge v1`
## Projektstruktur
- `crates/infinity_host/`
Kernruntime, Simulation, Show-Store, Pattern-Logik, externe Control-Semantik.
- `crates/infinity_host_api/`
HTTP- und WebSocket-API v1, DTO-Mapping, Contract-Tests.
- `crates/infinity_host_ui/`
Desktop-Engineering-Oberflaeche fuer technische Konfiguration und Diagnose.
- `crates/infinity_config/`
Projektkonfiguration und Validierung.
- `crates/infinity_protocol/`
Gemeinsame Protokoll- und Modelltypen.
- `web/v1/`
Creative Surface und Technical Surface im Browser.
- `docs/`
Architektur-, Runbook-, Ownership-, Pattern- und API-Dokumentation.
- `scripts/`
Kleine Starthelfer fuer lokalen software-only Betrieb.
- `data/runtime_state.json`
Laufzeitpersistenz fuer Host-State, Runtime-Presets, Gruppen und Snapshots.
## Wichtigste Dateien nach Thema
### Host und Semantik
- [control.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/control.rs:1)
- [simulation.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/simulation.rs:1)
- [show_store.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/show_store.rs:1)
- [scene.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/scene.rs:1)
- [external_control.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/external_control.rs:1)
- [external_bridge.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host/src/external_bridge.rs:1)
### API
- [dto.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/dto.rs:1)
- [server.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/server.rs:1)
- [websocket.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/src/websocket.rs:1)
- [contract.rs](/home/jan/Documents/RFP/Infinity_Vis_Rust/crates/infinity_host_api/tests/contract.rs:1)
### Web-UI
- [index.html](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/index.html:1)
- [app.js](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/app.js:1)
- [styles.css](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/styles.css:1)
- [technical.html](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.html:1)
- [technical.js](/home/jan/Documents/RFP/Infinity_Vis_Rust/web/v1/technical.js:1)
- [codex_browser_smoke.sh](/home/jan/Documents/RFP/Infinity_Vis_Rust/scripts/codex_browser_smoke.sh:1)
## Was aktuell als stabil gelten soll
- API v1 nach aussen nicht unkoordiniert brechen.
- Show-Control-v1-Primitive nicht still veraendern.
- Fehlercodes und Event-Semantik nicht neu mischen.
- `Test/Edit` bleibt direkt.
- `Show/Event` bleibt staged plus Commit ueber `Go` oder `Fade Go`.
- Preview-Only und Offline-Status ehrlich anzeigen, keine Fake-Nodes.
- Creative Surface nicht mit technischen Mapping-Details ueberladen.
- Technical Surface muss gegen partielle/startende State-Snapshots robust bleiben.
## Sichere Arbeitsreihenfolge fuer weitere Aenderungen
1. Zuerst relevante Docs lesen, nicht direkt Code uminterpretieren.
2. Dann Contract-Tests und Golden-Replays als Schutzplanken ansehen.
3. Danach gezielt nur in der betroffenen Schicht arbeiten:
- Pattern-/Runtime-Logik: `crates/infinity_host/`
- API-Vertrag: `crates/infinity_host_api/`
- Operator-UI: `web/v1/`
4. Danach passende Tests laufen lassen.
## Empfohlene Minimalpruefungen
```bash
. "$HOME/.cargo/env"
cargo test -q -p infinity_host
cargo test -q -p infinity_host_api --test contract
scripts/codex_browser_smoke.sh 9012
```
Fuer lokalen software-only Start:
```bash
. "$HOME/.cargo/env"
cargo run -p infinity_host_api -- --config config/project.example.toml --bind 127.0.0.1:9001 --runtime-state data/runtime_state.json
```
## Offene Vorsichtspunkte
- Das alte Python-Projekt ist derzeit nicht lokal neben dem Repo ausgecheckt. Fuer echte 1:1-Conformance-Vergleiche sollte es bewusst lokal daneben geklont werden.
- Das Runbook enthaelt noch einen veralteten Hinweis aus der frueheren Arbeitsbaum-Phase; aktuell ist das Repo wieder ein echter Git-Clone mit `.git`.
- Die grosse Pattern-Conformance gegen das alte Python-Projekt ist noch nicht vollstaendig als separater, systematischer Abgleich abgeschlossen.
- Ein echter gerenderter Headless-Browser-Runner mit DOM-Interaktion ist lokal noch nicht sauber eingerichtet; vorhanden ist aktuell nur der Route-/Asset-Smoke ueber `scripts/codex_browser_smoke.sh`.
## Kurzbriefing fuer das naechste Modell
Wenn du dieses Projekt weiterbearbeitest:
- halte Host-Core plus API als einzige Grundarchitektur stabil
- behandle die bestehende Show-Control-v1-Semantik als Aussenkante
- nimm das alte Python-Projekt als Primarreferenz fuer UX und Pattern-Verhalten, nicht fuer technische Architektur
- veraendere diskrete UI-Controls, Fehlercodes oder staged/direct-Semantik nicht stillschweigend
- bevor du groessere UI- oder Pattern-Aenderungen machst, vergleiche erst gegen die vorhandenen Docs und Tests

View File

@@ -1,4 +1,3 @@
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]

48
scripts/codex_browser_smoke.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
REPO_DIR="/home/jan/Documents/RFP/Infinity_Vis_Rust"
PORT="${1:-9011}"
BASE_URL="http://127.0.0.1:${PORT}"
RUNTIME_STATE="/tmp/infinity_vis_runtime_codex_browser_${PORT}.json"
cd "$REPO_DIR"
. "$HOME/.cargo/env"
cargo run -q -p infinity_host_api -- \
--config config/project.example.toml \
--bind "127.0.0.1:${PORT}" \
--runtime-state "$RUNTIME_STATE" &
SERVER_PID=$!
cleanup() {
if kill -0 "$SERVER_PID" >/dev/null 2>&1; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" >/dev/null 2>&1 || true
fi
}
trap cleanup EXIT INT TERM
for _attempt in $(seq 1 40); do
if curl -fsS "${BASE_URL}/api/v1/state" >/dev/null 2>&1; then
break
fi
sleep 0.25
done
echo "Smoke-checking ${BASE_URL}/"
curl -fsS "${BASE_URL}/" >/tmp/infinity_vis_creative_${PORT}.html >/dev/null
echo "Smoke-checking ${BASE_URL}/technical"
curl -fsS "${BASE_URL}/technical" >/tmp/infinity_vis_technical_${PORT}.html >/dev/null
echo "Smoke-checking ${BASE_URL}/technical.js"
curl -fsS "${BASE_URL}/technical.js" >/tmp/infinity_vis_technical_${PORT}.js >/dev/null
echo "Creative Surface and Technical Surface were served successfully on ${BASE_URL}"
echo "Saved smoke artifacts to /tmp/infinity_vis_creative_${PORT}.html and /tmp/infinity_vis_technical_${PORT}.html"
if command -v xdg-open >/dev/null 2>&1; then
echo "Optional manual open:"
echo " xdg-open ${BASE_URL}/"
echo " xdg-open ${BASE_URL}/technical"
fi

View File

@@ -1,9 +1,9 @@
(function () {
const PREVIEW_RENDER_INTERVAL_MS = 90;
const PREVIEW_RENDER_INTERVAL_MS = 16;
const STATE_RENDER_INTERVAL_MS = 180;
const POSITION_ORDER = { top: 0, middle: 1, bottom: 2 };
const COLOR_PARAM_KEYS = new Set(["color_mode", "palette", "primary_color", "secondary_color"]);
const BRIGHTNESS_PARAM_KEYS = new Set(["brightness"]);
const HIDDEN_PARAMETER_KEYS = new Set(["speed", "brightness"]);
const TEMPO_BPM_MIN = 10;
const TEMPO_BPM_MAX = 300;
const TEMPO_BPM_DEFAULT = 120;
@@ -46,16 +46,60 @@
palette: {
control: "enum",
label: "Palette",
default_value: "Laser Club",
default_value: "Deep Blue",
groups: [
{
label: "White / Temperature Presets",
options: [
{ value: "Candle", label: "Candle (#FF9329)" },
{ value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" },
{ value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" },
{ value: "Halogen", label: "Halogen (#FFF1E0)" },
{ value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" },
{ value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" },
{ value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" },
{ value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" },
],
},
{
label: "Club / Show Presets",
options: [
{ value: "Deep Blue", label: "Deep Blue (#0047FF)" },
{ value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" },
{ value: "Emerald", label: "Emerald (#00C853)" },
{ value: "Acid Lime", label: "Acid Lime (#AEEA00)" },
{ value: "Amber", label: "Amber (#FF9D00)" },
{ value: "Signal Red", label: "Signal Red (#FF2D2D)" },
{ value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" },
{ value: "Violet", label: "Violet (#7A3CFF)" },
{ value: "Warm White", label: "Warm White (#FFD6AA)" },
{ value: "Neutral White", label: "Neutral White (#FFF1E0)" },
{ value: "Cool White", label: "Cool White (#C9E2FF)" },
{ value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" },
],
},
],
options: [
{ value: "Laser Club", label: "Laser Club" },
{ value: "Magenta Drive", label: "Magenta Drive" },
{ value: "Warehouse Heat", label: "Warehouse Heat" },
{ value: "UV Riot", label: "UV Riot" },
{ value: "Redline", label: "Redline" },
{ value: "Sodium Haze", label: "Sodium Haze" },
{ value: "Afterhours", label: "Afterhours" },
{ value: "Voltage", label: "Voltage" },
{ value: "Candle", label: "Candle (#FF9329)" },
{ value: "Tungsten 40W", label: "Tungsten 40W (#FFC58F)" },
{ value: "Tungsten 100W", label: "Tungsten 100W (#FFD6AA)" },
{ value: "Halogen", label: "Halogen (#FFF1E0)" },
{ value: "Carbon Arc", label: "Carbon Arc (#FFFAF4)" },
{ value: "High Noon Sun", label: "High Noon Sun (#FFFFFB)" },
{ value: "Overcast Sky", label: "Overcast Sky (#C9E2FF)" },
{ value: "Clear Blue Sky", label: "Clear Blue Sky (#409CFF)" },
{ value: "Deep Blue", label: "Deep Blue (#0047FF)" },
{ value: "Electric Cyan", label: "Electric Cyan (#00D8FF)" },
{ value: "Emerald", label: "Emerald (#00C853)" },
{ value: "Acid Lime", label: "Acid Lime (#AEEA00)" },
{ value: "Amber", label: "Amber (#FF9D00)" },
{ value: "Signal Red", label: "Signal Red (#FF2D2D)" },
{ value: "Hot Magenta", label: "Hot Magenta (#FF00A8)" },
{ value: "Violet", label: "Violet (#7A3CFF)" },
{ value: "Warm White", label: "Warm White (#FFD6AA)" },
{ value: "Neutral White", label: "Neutral White (#FFF1E0)" },
{ value: "Cool White", label: "Cool White (#C9E2FF)" },
{ value: "Blacklight Violet", label: "Blacklight Violet (#A700FF)" },
],
},
direction: {
@@ -171,11 +215,11 @@
previewLayoutSignature: null,
viewOutputSignature: null,
stateTimer: null,
previewTimer: null,
lastStateRenderAt: 0,
lastPreviewRenderAt: 0,
stateRenderQueued: false,
previewRenderQueued: false,
previewAnimationFrame: 0,
eventFilterSignature: null,
},
};
@@ -225,7 +269,6 @@
snapshotList: document.getElementById("snapshot-list"),
motionParams: document.getElementById("motion-params"),
colorParams: document.getElementById("color-params"),
brightnessParams: document.getElementById("brightness-params"),
previewGrid: document.getElementById("preview-grid"),
summaryCards: document.getElementById("summary-cards"),
selectedTileCard: document.getElementById("selected-tile-card"),
@@ -565,15 +608,28 @@
return;
}
apiState.ui.previewRenderQueued = true;
const now = window.performance.now();
const waitMs = Math.max(0, PREVIEW_RENDER_INTERVAL_MS - (now - apiState.ui.lastPreviewRenderAt));
window.setTimeout(() => {
const scheduleFrame = () => {
apiState.ui.previewAnimationFrame = window.requestAnimationFrame(() => {
const now = window.performance.now();
if (now - apiState.ui.lastPreviewRenderAt < PREVIEW_RENDER_INTERVAL_MS) {
scheduleFrame();
return;
}
apiState.ui.previewAnimationFrame = 0;
apiState.ui.previewRenderQueued = false;
apiState.ui.lastPreviewRenderAt = now;
renderPreview(false);
renderSelectedTile();
renderSnapshotJson();
});
};
if (apiState.ui.previewAnimationFrame) {
apiState.ui.previewRenderQueued = false;
apiState.ui.lastPreviewRenderAt = window.performance.now();
renderPreview(false);
renderSelectedTile();
renderSnapshotJson();
}, waitMs);
return;
}
scheduleFrame();
}
async function handlePrimitive(primitive, options) {
@@ -1120,7 +1176,6 @@
if (!scene || !global) {
dom.motionParams.innerHTML = "";
dom.colorParams.innerHTML = "";
dom.brightnessParams.innerHTML = "";
apiState.ui.parameterCards.clear();
return;
}
@@ -1128,13 +1183,14 @@
if (
!force &&
(dom.motionParams.contains(document.activeElement) ||
dom.colorParams.contains(document.activeElement) ||
dom.brightnessParams.contains(document.activeElement))
dom.colorParams.contains(document.activeElement))
) {
return;
}
const parameters = scene.parameters || [];
const parameters = (scene.parameters || []).filter(
(parameter) => !HIDDEN_PARAMETER_KEYS.has(parameter.key)
);
const signature = parameters
.map((parameter) => `${parameter.key}:${parameter.kind}`)
.join("|");
@@ -1144,7 +1200,6 @@
apiState.ui.parameterCards.clear();
dom.motionParams.innerHTML = "";
dom.colorParams.innerHTML = "";
dom.brightnessParams.innerHTML = "";
parameters.forEach((parameter) => {
const card = createParameterCard(parameter, global);
@@ -1165,9 +1220,6 @@
if (COLOR_PARAM_KEYS.has(parameter.key)) {
return dom.colorParams;
}
if (BRIGHTNESS_PARAM_KEYS.has(parameter.key)) {
return dom.brightnessParams;
}
return dom.motionParams;
}
@@ -1251,13 +1303,28 @@
card.appendChild(readout);
} else if (controlKind === "enum") {
primaryInput = document.createElement("select");
const options = contract && Array.isArray(contract.options) ? contract.options : [];
options.forEach((option) => {
const node = document.createElement("option");
node.value = option.value;
node.textContent = option.label;
primaryInput.appendChild(node);
});
const groupedOptions = contract && Array.isArray(contract.groups) ? contract.groups : [];
if (groupedOptions.length) {
groupedOptions.forEach((group) => {
const groupNode = document.createElement("optgroup");
groupNode.label = group.label;
group.options.forEach((option) => {
const node = document.createElement("option");
node.value = option.value;
node.textContent = option.label;
groupNode.appendChild(node);
});
primaryInput.appendChild(groupNode);
});
} else {
const options = contract && Array.isArray(contract.options) ? contract.options : [];
options.forEach((option) => {
const node = document.createElement("option");
node.value = option.value;
node.textContent = option.label;
primaryInput.appendChild(node);
});
}
primaryInput.addEventListener("change", async (event) => {
const optionValue = event.target.value;
const payloadValue =
@@ -1550,8 +1617,8 @@
dom.previewUpdated.textContent = `${apiState.previewResponse.generated_at_millis} ms`;
dom.previewGrid.className = `preview-grid preview-grid-mode-${apiState.ui.previewMode}`;
const columnCount = uniqueNodeIds(panels).length;
dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(112px, 1fr))`;
const columnCount = 6;
dom.previewGrid.style.gridTemplateColumns = `repeat(${columnCount}, minmax(0, 1fr))`;
const layoutSignature = panels.map((panel) => panel.key).join("|");
if (force || layoutSignature !== apiState.ui.previewLayoutSignature) {
@@ -2437,40 +2504,16 @@
const sampleColors = (panel.sample_led_hex || [])
.map((hex) => normalizeColorHex(hex))
.filter((hex) => isHexColorString(hex));
const palette = sampleColors.length
? sampleColors
: [normalizeColorHex(panel.representative_color_hex)];
if (palette.length === 1) {
return TILE_LED_GEOMETRY.map(() => palette[0]);
if (!sampleColors.length) {
return TILE_LED_GEOMETRY.map(() => normalizeColorHex(panel.representative_color_hex));
}
return TILE_LED_GEOMETRY.map((_led, index) => {
const factor = TILE_LED_GEOMETRY.length === 1 ? 0 : index / (TILE_LED_GEOMETRY.length - 1);
return interpolatePalette(palette, factor);
const sourceIndex = Math.round((index / Math.max(1, TILE_LED_GEOMETRY.length - 1)) * (sampleColors.length - 1));
return sampleColors[sourceIndex] || normalizeColorHex(panel.representative_color_hex);
});
}
function interpolatePalette(palette, factor) {
const scaled = factor * (palette.length - 1);
const lowerIndex = Math.floor(scaled);
const upperIndex = Math.min(palette.length - 1, Math.ceil(scaled));
if (lowerIndex === upperIndex) {
return palette[lowerIndex];
}
return mixHexColors(palette[lowerIndex], palette[upperIndex], scaled - lowerIndex);
}
function mixHexColors(leftHex, rightHex, factor) {
const left = parseHexColor(leftHex);
const right = parseHexColor(rightHex);
return rgbToHex(
Math.round(lerp(left.r, right.r, factor)),
Math.round(lerp(left.g, right.g, factor)),
Math.round(lerp(left.b, right.b, factor))
);
}
function parseHexColor(hex) {
const normalized = normalizeColorHex(hex).slice(1);
return {
@@ -2486,9 +2529,10 @@
function previewLedOpacity(energyPercent) {
if (!energyPercent) {
return "0.18";
return "0.1";
}
return String(Math.min(1, 0.42 + energyPercent / 160));
const steppedPercent = Math.round(Math.max(0, Math.min(100, energyPercent)) / 10) * 10;
return String(Math.max(0.1, steppedPercent / 100));
}
function escapeHtml(value) {

View File

@@ -26,6 +26,12 @@
</select>
</label>
<label class="toolbar-control toolbar-control-inline">
<span class="toolbar-label">Master</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
<strong id="brightness-value">0%</strong>
</label>
<button id="go-button" class="toolbar-button" type="button">Go</button>
<button id="fade-go-button" class="toolbar-button toolbar-button-primary" type="button">
Fade Go
@@ -83,18 +89,6 @@
<div id="color-params" class="parameter-stack"></div>
</section>
<section class="dock-section">
<div class="dock-header">
<h2>Brightness</h2>
<p>Global intensity plus pattern-level brightness controls.</p>
</div>
<label class="control-field">
<span>Master Brightness</span>
<input id="brightness-slider" type="range" min="0" max="1" step="0.01" />
<strong id="brightness-value">0%</strong>
</label>
<div id="brightness-params" class="parameter-stack"></div>
</section>
</aside>
<section class="workspace-stage">

View File

@@ -1,25 +1,25 @@
:root {
color-scheme: dark;
--bg: #1e1e1e;
--bg-elevated: #252526;
--bg-elevated-2: #2d2d30;
--bg: #151920;
--bg-elevated: #1b212c;
--bg-elevated-2: #202838;
--bg-stage: #0d1016;
--bg-stage-2: #11151d;
--line: #3c3c3c;
--line-soft: #2f2f33;
--text: #cccccc;
--line: rgba(182, 202, 227, 0.14);
--line-soft: rgba(182, 202, 227, 0.08);
--text: #c9d2df;
--text-strong: #f3f6fb;
--muted: #8f99a5;
--muted: #95a1b2;
--accent: #007acc;
--accent-strong: #094771;
--accent-strong: #0a4f8d;
--accent-soft: rgba(0, 122, 204, 0.18);
--warning: #d6a04d;
--danger: #c63b1e;
--danger-soft: rgba(198, 59, 30, 0.18);
--success: #1f8b63;
--success-soft: rgba(31, 139, 99, 0.18);
--shadow: 0 18px 48px rgba(0, 0, 0, 0.34);
--tile-shadow: 0 14px 34px rgba(0, 0, 0, 0.38);
--shadow: 0 10px 26px rgba(0, 0, 0, 0.2);
--tile-shadow: 0 8px 18px rgba(0, 0, 0, 0.22);
--radius-sm: 3px;
--radius-md: 4px;
--radius-lg: 6px;
@@ -110,26 +110,26 @@ input[type="range"] {
display: flex;
align-items: center;
justify-content: flex-start;
padding: 6px 10px;
padding: 3px 7px;
}
.topbar-creative .topbar-actions {
width: 100%;
display: flex;
flex-wrap: wrap;
gap: 6px;
gap: 3px;
align-items: center;
}
.topbar-creative .toolbar-control,
.topbar-creative .toolbar-group,
.topbar-creative .toolbar-button {
min-height: 30px;
padding: 4px 8px;
min-height: 26px;
padding: 2px 6px;
}
.topbar-creative .toolbar-control-fade {
min-width: 222px;
min-width: 184px;
}
.topbar-brand,
@@ -166,10 +166,10 @@ input[type="range"] {
.toolbar-control {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 34px;
padding: 6px 8px;
background: #1f1f1f;
gap: 6px;
min-height: 28px;
padding: 3px 6px;
background: rgba(17, 22, 31, 0.72);
border: 1px solid var(--line);
}
@@ -178,13 +178,13 @@ input[type="range"] {
}
.toolbar-control-fade {
min-width: 250px;
min-width: 192px;
}
#tempo-bpm-input,
#transition-seconds-input {
width: 82px;
min-width: 82px;
width: 68px;
min-width: 68px;
}
.toolbar-label {
@@ -198,10 +198,10 @@ input[type="range"] {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 100px;
min-height: 24px;
padding: 4px 10px;
font-size: 0.77rem;
min-width: 88px;
min-height: 22px;
padding: 3px 8px;
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.06em;
@@ -245,9 +245,9 @@ input[type="range"] {
}
.toolbar-button {
min-height: 34px;
padding: 7px 14px;
background: var(--bg-elevated-2);
min-height: 28px;
padding: 4px 8px;
background: rgba(17, 22, 31, 0.74);
color: var(--text);
border: 1px solid var(--line);
}
@@ -287,9 +287,9 @@ a.toolbar-button {
.workspace {
display: grid;
grid-template-columns: 270px minmax(0, 1fr) 310px;
gap: 8px;
padding: 8px;
grid-template-columns: 252px minmax(0, 1fr) 236px;
gap: 4px;
padding: 4px;
min-height: 0;
}
@@ -300,7 +300,7 @@ a.toolbar-button {
.workspace-rail {
display: grid;
gap: 8px;
gap: 4px;
align-content: start;
overflow: auto;
padding-right: 2px;
@@ -308,27 +308,29 @@ a.toolbar-button {
.workspace-stage {
display: grid;
grid-template-rows: minmax(0, 1fr) 220px;
gap: 8px;
grid-template-rows: minmax(0, 1fr) 148px;
gap: 4px;
min-width: 0;
}
.stage-panel,
.dock-section {
background: var(--bg-elevated);
border: 1px solid var(--line);
box-shadow: var(--shadow);
border: 1px solid transparent;
box-shadow: none;
}
.dock-section {
padding: 8px;
padding: 5px;
background: color-mix(in srgb, var(--bg-elevated) 86%, transparent);
}
.stage-panel {
display: grid;
gap: 10px;
padding: 8px;
gap: 6px;
padding: 5px;
min-height: 0;
background: color-mix(in srgb, var(--bg-elevated-2) 74%, transparent);
}
.stage-panel-preview {
@@ -343,14 +345,14 @@ a.toolbar-button {
.stage-header {
display: flex;
justify-content: space-between;
gap: 12px;
gap: 8px;
align-items: flex-start;
}
.dock-header h2,
.stage-header h2 {
margin: 0;
font-size: 0.96rem;
font-size: 0.84rem;
font-weight: 700;
color: var(--text-strong);
}
@@ -361,8 +363,8 @@ a.toolbar-button {
.panel-meta,
.info-detail,
.event-meta {
margin: 3px 0 0;
font-size: 0.78rem;
margin: 1px 0 0;
font-size: 0.68rem;
color: var(--muted);
}
@@ -385,12 +387,12 @@ a.toolbar-button {
.compact-form-two,
.parameter-card label {
display: grid;
gap: 8px;
gap: 6px;
}
.control-field span,
.parameter-card span {
font-size: 0.78rem;
font-size: 0.72rem;
color: var(--muted);
}
@@ -408,13 +410,13 @@ a.toolbar-button {
.summary-cards,
.info-list {
display: grid;
gap: 8px;
gap: 6px;
}
.button-stack,
.button-row {
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
}
@@ -423,7 +425,7 @@ a.toolbar-button {
}
.compact-form {
margin-top: 10px;
margin-top: 8px;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
}
@@ -433,7 +435,7 @@ a.toolbar-button {
}
.compact-form-two {
margin-top: 10px;
margin-top: 8px;
grid-template-columns: repeat(2, minmax(0, 1fr));
align-items: center;
}
@@ -442,7 +444,7 @@ a.toolbar-button {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 34px;
min-height: 30px;
padding: 0 6px;
color: var(--muted);
}
@@ -450,28 +452,28 @@ a.toolbar-button {
.pending-status-row {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
gap: 6px;
}
.mini-status {
padding: 8px 10px;
padding: 6px 8px;
background: #1f1f1f;
border: 1px solid var(--line);
}
.preview-stage {
min-height: 0;
padding: 10px 10px;
padding: 4px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 14%),
linear-gradient(180deg, var(--bg-stage) 0%, var(--bg-stage-2) 100%);
border: 1px solid #20252f;
border: 1px solid rgba(182, 202, 227, 0.08);
overflow: auto;
}
.preview-grid {
display: grid;
gap: 10px;
gap: 4px;
min-height: 100%;
align-content: center;
}
@@ -479,12 +481,13 @@ a.toolbar-button {
.preview-tile {
position: relative;
display: block;
min-height: 168px;
padding: 8px;
background: linear-gradient(180deg, #1b1b1b 0%, #111318 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
aspect-ratio: 1 / 1;
min-height: 0;
padding: 4px;
background: linear-gradient(180deg, #151a24 0%, #10141d 100%);
border: 1px solid rgba(182, 202, 227, 0.12);
color: var(--text-strong);
box-shadow: var(--tile-shadow);
box-shadow: 0 3px 8px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
@@ -492,11 +495,11 @@ a.toolbar-button {
content: "";
position: absolute;
inset: auto -15% -40% auto;
width: 62%;
height: 62%;
width: 52%;
height: 52%;
background: radial-gradient(circle, var(--tile-glow, #4d7cff), transparent 72%);
pointer-events: none;
opacity: 0.16;
opacity: 0.08;
}
.preview-tile:hover {
@@ -504,9 +507,9 @@ a.toolbar-button {
}
.preview-tile.is-selected {
outline: 2px solid var(--accent);
outline-offset: -2px;
border-color: rgba(0, 122, 204, 0.7);
outline: 1px solid var(--accent);
outline-offset: -1px;
border-color: rgba(0, 122, 204, 0.5);
}
.preview-tile.is-panel-test {
@@ -520,12 +523,12 @@ a.toolbar-button {
.tile-preview-shell {
position: relative;
height: 100%;
min-height: 148px;
border-radius: 4px;
min-height: 0;
border-radius: 3px;
background:
linear-gradient(180deg, rgba(255, 255, 255, 0.02), transparent 16%),
linear-gradient(180deg, #060912 0%, #080d18 100%);
border: 1px solid rgba(255, 255, 255, 0.03);
border: 1px solid rgba(182, 202, 227, 0.08);
}
.tile-led-ring {
@@ -535,10 +538,10 @@ a.toolbar-button {
.tile-led {
position: absolute;
width: 4px;
height: 4px;
margin-left: -2px;
margin-top: -2px;
width: 3px;
height: 3px;
margin-left: -1.5px;
margin-top: -1.5px;
border-radius: 999px;
background: var(--tile-color, #4d7cff);
opacity: var(--led-opacity, 0.85);
@@ -546,16 +549,16 @@ a.toolbar-button {
.tile-overlay {
position: absolute;
inset: 10px;
inset: 4px;
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 14px 10px 12px;
padding: 5px 4px 4px;
pointer-events: none;
}
.tile-label {
font-size: clamp(1.15rem, 1vw + 0.8rem, 2rem);
font-size: clamp(0.72rem, 0.32vw + 0.62rem, 1rem);
font-weight: 700;
line-height: 1;
color: rgba(237, 243, 255, 0.94);
@@ -563,7 +566,7 @@ a.toolbar-button {
.tile-caption {
margin-top: auto;
font-size: 1rem;
font-size: 0.68rem;
color: rgba(214, 224, 238, 0.76);
}
@@ -766,9 +769,9 @@ a.toolbar-button {
.tile-meta {
display: flex;
justify-content: space-between;
gap: 10px;
gap: 8px;
align-items: end;
font-size: 0.74rem;
font-size: 0.58rem;
color: rgba(214, 224, 238, 0.64);
text-transform: uppercase;
letter-spacing: 0.05em;
@@ -803,10 +806,10 @@ a.toolbar-button {
}
.preview-grid-mode-leds .tile-led {
width: 4.6px;
height: 4.6px;
margin-left: -2.3px;
margin-top: -2.3px;
width: 3.4px;
height: 3.4px;
margin-left: -1.7px;
margin-top: -1.7px;
}
.preview-grid-mode-tile .tile-preview-shell {
@@ -826,9 +829,9 @@ a.toolbar-button {
.event-item,
.selected-tile-card,
.info-row {
padding: 10px;
background: #1f1f1f;
border: 1px solid var(--line);
padding: 6px;
background: rgba(17, 22, 31, 0.7);
border: 1px solid rgba(182, 202, 227, 0.1);
}
.summary-card span {
@@ -861,7 +864,7 @@ a.toolbar-button {
.list-item-meta {
display: flex;
gap: 8px;
gap: 6px;
flex-wrap: wrap;
font-size: 0.76rem;
color: var(--muted);
@@ -879,7 +882,7 @@ a.toolbar-button {
.parameter-card {
display: grid;
gap: 8px;
gap: 6px;
}
.parameter-card.is-staged {
@@ -891,12 +894,20 @@ a.toolbar-button {
justify-content: space-between;
gap: 8px;
align-items: baseline;
flex-wrap: wrap;
}
.parameter-key {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
overflow-wrap: anywhere;
}
.parameter-card select,
.parameter-card input[type="text"],
.parameter-card input[type="number"] {
min-width: 0;
}
.parameter-readout {
@@ -906,13 +917,13 @@ a.toolbar-button {
.color-input-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr);
grid-template-columns: 50px minmax(0, 1fr);
gap: 8px;
align-items: center;
}
.color-input-row input[type="color"] {
width: 44px;
width: 50px;
height: 34px;
padding: 0;
border: 1px solid var(--line);
@@ -921,11 +932,11 @@ a.toolbar-button {
.selected-tile-card {
display: grid;
gap: 8px;
gap: 6px;
}
.selected-tile-title {
font-size: 1.08rem;
font-size: 0.98rem;
font-weight: 700;
color: var(--text-strong);
}
@@ -933,13 +944,13 @@ a.toolbar-button {
.selected-tile-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
gap: 6px;
}
.info-row {
display: grid;
gap: 4px;
padding: 8px 10px;
padding: 6px 8px;
}
.info-label {
@@ -959,7 +970,7 @@ a.toolbar-button {
}
.pending-card {
padding: 10px;
padding: 8px;
background: rgba(214, 160, 77, 0.08);
border: 1px solid rgba(214, 160, 77, 0.2);
}
@@ -971,7 +982,7 @@ a.toolbar-button {
}
.primitive-error-banner {
padding: 10px;
padding: 8px;
background: var(--danger-soft);
border: 1px solid rgba(198, 59, 30, 0.4);
color: #ffb09e;
@@ -984,8 +995,8 @@ a.toolbar-button {
.event-filter-bar {
display: grid;
grid-template-columns: 170px minmax(0, 1fr);
gap: 8px;
grid-template-columns: 132px minmax(0, 1fr);
gap: 4px;
}
.event-list {
@@ -995,7 +1006,7 @@ a.toolbar-button {
.event-item {
display: grid;
gap: 6px;
gap: 4px;
}
.event-item strong {
@@ -1038,9 +1049,9 @@ a.toolbar-button {
.snapshot-json {
margin: 8px 0 0;
max-height: 260px;
max-height: 170px;
overflow: auto;
padding: 10px;
padding: 8px;
background: #111317;
border: 1px solid #20252f;
color: #dde7f5;
@@ -1053,8 +1064,8 @@ a.toolbar-button {
}
.empty-state {
padding: 12px;
border: 1px dashed var(--line);
padding: 8px;
border: 1px dashed rgba(182, 202, 227, 0.2);
color: var(--muted);
}
@@ -1062,13 +1073,18 @@ a.toolbar-button {
display: none !important;
}
select optgroup {
background: #141923;
color: #cdd7e5;
}
@media (max-width: 1460px) {
.topbar {
grid-template-columns: 1fr;
}
.workspace {
grid-template-columns: 248px minmax(0, 1fr) 280px;
grid-template-columns: 192px minmax(0, 1fr) 212px;
}
.summary-cards {
@@ -1091,7 +1107,7 @@ a.toolbar-button {
}
.workspace-stage {
grid-template-rows: minmax(480px, auto) auto;
grid-template-rows: minmax(360px, auto) auto;
}
.workspace-rail,

View File

@@ -28,6 +28,7 @@ const appState = {
snapshot: null,
outputDraft: null,
outputDirty: false,
outputSaving: false,
nodeDrafts: new Map(),
panelDrafts: new Map(),
discovery: {
@@ -181,17 +182,22 @@ async function loadState() {
}
function syncDraftsFromState(snapshot) {
elements.projectName.textContent = `${snapshot.system.project_name} | ${snapshot.system.topology_label}`;
const system = snapshotSystem(snapshot);
const technical = snapshotTechnical(snapshot);
const nodes = snapshotNodes(snapshot);
const panels = snapshotPanels(snapshot);
if (!appState.outputDirty) {
elements.projectName.textContent = `${system.project_name} | ${system.topology_label}`;
if (!appState.outputDirty && !appState.outputSaving) {
appState.outputDraft = {
backend_mode: snapshot.technical.backend_mode,
output_enabled: snapshot.technical.output_enabled,
output_fps: snapshot.technical.output_fps,
backend_mode: technical.backend_mode,
output_enabled: technical.output_enabled,
output_fps: technical.output_fps,
};
}
for (const node of snapshot.nodes) {
for (const node of nodes) {
const existing = appState.nodeDrafts.get(node.node_id);
if (!existing || !existing.dirty) {
appState.nodeDrafts.set(node.node_id, {
@@ -201,7 +207,7 @@ function syncDraftsFromState(snapshot) {
}
}
for (const panel of snapshot.panels) {
for (const panel of panels) {
const key = panelKey(panel.node_id, panel.panel_position);
const existing = appState.panelDrafts.get(key);
if (!existing || !existing.dirty) {
@@ -255,20 +261,22 @@ function renderTopbar() {
return;
}
const onlineCount = snapshot.nodes.filter((node) => node.connection === "online").length;
const technical = snapshotTechnical(snapshot);
const nodes = snapshotNodes(snapshot);
const onlineCount = nodes.filter((node) => node.connection === "online").length;
setChip(
elements.backendPill,
backendModeLabel(snapshot.technical.backend_mode),
snapshot.technical.backend_mode === "preview_only" ? "idle" : "live"
backendModeLabel(technical.backend_mode),
technical.backend_mode === "preview_only" ? "idle" : "live"
);
setChip(
elements.outputPill,
snapshot.technical.output_enabled ? "enabled" : "disabled",
snapshot.technical.output_enabled ? "success" : "warning"
technical.output_enabled ? "enabled" : "disabled",
technical.output_enabled ? "success" : "warning"
);
setChip(
elements.nodesPill,
`${onlineCount}/${snapshot.nodes.length} online`,
`${onlineCount}/${nodes.length} online`,
onlineCount > 0 ? "success" : "warning"
);
}
@@ -278,7 +286,9 @@ function renderSummaryCards() {
elements.summaryGrid.innerHTML = "";
return;
}
const { technical, nodes, panels } = appState.snapshot;
const technical = snapshotTechnical(appState.snapshot);
const nodes = snapshotNodes(appState.snapshot);
const panels = snapshotPanels(appState.snapshot);
const onlineCount = nodes.filter((node) => node.connection === "online").length;
const enabledOutputs = panels.filter((panel) => panel.enabled).length;
const liveOutputs = panels.filter(
@@ -313,10 +323,15 @@ function renderOutputControls() {
elements.backendModeSelect.value = appState.outputDraft.backend_mode;
elements.outputEnabledInput.checked = Boolean(appState.outputDraft.output_enabled);
elements.outputFpsInput.value = String(appState.outputDraft.output_fps);
elements.liveStatus.textContent = appState.snapshot.technical.live_status;
elements.liveStatus.className = `status-banner ${bannerLevelForTechnical(appState.snapshot.technical)}`;
const technical = snapshotTechnical(appState.snapshot);
elements.liveStatus.textContent = technical.live_status;
elements.liveStatus.className = `status-banner ${bannerLevelForTechnical(technical)}`;
elements.backendSemantics.textContent = backendSemanticsText(appState.outputDraft);
elements.saveOutputSettingsButton.disabled = !appState.outputDirty;
const controlsDisabled = appState.outputSaving;
elements.backendModeSelect.disabled = controlsDisabled;
elements.outputEnabledInput.disabled = controlsDisabled;
elements.outputFpsInput.disabled = controlsDisabled;
elements.saveOutputSettingsButton.disabled = controlsDisabled || !appState.outputDirty;
}
function renderNodeTable() {
@@ -325,7 +340,7 @@ function renderNodeTable() {
return;
}
elements.nodeTableBody.innerHTML = appState.snapshot.nodes
elements.nodeTableBody.innerHTML = snapshotNodes(appState.snapshot)
.map((node) => {
const draft = ensureNodeDraft(node.node_id);
return `
@@ -368,7 +383,7 @@ function renderPanelTable() {
return;
}
elements.panelTableBody.innerHTML = appState.snapshot.panels
elements.panelTableBody.innerHTML = snapshotPanels(appState.snapshot)
.map((panel) => {
const key = panelKey(panel.node_id, panel.panel_position);
const draft = ensurePanelDraft(key, panel);
@@ -505,7 +520,8 @@ function renderEvents() {
return;
}
elements.eventList.innerHTML = appState.snapshot.recent_events
const recentEvents = snapshotRecentEvents(appState.snapshot);
elements.eventList.innerHTML = recentEvents
.map(
(event) => `
<article class="event-entry">
@@ -605,11 +621,11 @@ async function applyDiscoveryAssignment(ip) {
}
async function saveOutputSettings() {
if (!appState.snapshot) {
if (!appState.snapshot || appState.outputSaving) {
return;
}
ensureOutputDraft();
const current = appState.snapshot.technical;
const current = snapshotTechnical(appState.snapshot);
const draft = appState.outputDraft;
const commands = [];
@@ -638,16 +654,24 @@ async function saveOutputSettings() {
return;
}
for (const command of commands) {
const response = await sendCommand(command.type, command.payload);
if (!response.ok) {
return;
}
}
appState.outputSaving = true;
renderOutputControls();
appState.outputDirty = false;
setFeedback("success", "Backend/output settings applied.");
await loadState();
try {
for (const command of commands) {
const response = await sendCommand(command.type, command.payload);
if (!response.ok) {
return;
}
}
appState.outputDirty = false;
setFeedback("success", "Backend/output settings applied.");
await loadState();
} finally {
appState.outputSaving = false;
renderOutputControls();
}
}
async function saveNode(nodeId) {
@@ -773,6 +797,34 @@ function ensureOutputDraft() {
}
}
function snapshotSystem(snapshot) {
return snapshot?.system ?? {
project_name: "Infinity Vis",
topology_label: "Technical surface waiting for snapshot",
};
}
function snapshotTechnical(snapshot) {
return snapshot?.technical ?? {
backend_mode: "preview_only",
output_enabled: false,
output_fps: 40,
live_status: "Waiting for technical state...",
};
}
function snapshotNodes(snapshot) {
return Array.isArray(snapshot?.nodes) ? snapshot.nodes : [];
}
function snapshotPanels(snapshot) {
return Array.isArray(snapshot?.panels) ? snapshot.panels : [];
}
function snapshotRecentEvents(snapshot) {
return Array.isArray(snapshot?.recent_events) ? snapshot.recent_events : [];
}
function ensureNodeDraft(nodeId) {
const existing = appState.nodeDrafts.get(nodeId);
if (existing) {
@@ -806,12 +858,12 @@ function assignedNodeForIp(ip) {
if (!appState.snapshot || !ip) {
return null;
}
const match = appState.snapshot.nodes.find((node) => node.reserved_ip === ip);
const match = snapshotNodes(appState.snapshot).find((node) => node.reserved_ip === ip);
return match ? match.node_id : null;
}
function renderNodeOptions(selectedNodeId) {
const nodes = appState.snapshot ? appState.snapshot.nodes : [];
const nodes = snapshotNodes(appState.snapshot);
return nodes
.map((node) => {
const selected = node.node_id === selectedNodeId ? "selected" : "";