Layer stacking
This commit is contained in:
232
ui/app.js
232
ui/app.js
@@ -1,232 +0,0 @@
|
||||
const shaderSelect = document.getElementById("shader-select");
|
||||
const mixSlider = document.getElementById("mix-slider");
|
||||
const bypassToggle = document.getElementById("bypass-toggle");
|
||||
const reloadButton = document.getElementById("reload-button");
|
||||
const resetParametersButton = document.getElementById("reset-parameters-button");
|
||||
const runtimeStatus = document.getElementById("runtime-status");
|
||||
const videoStatus = document.getElementById("video-status");
|
||||
const compileStatus = document.getElementById("compile-status");
|
||||
const parameterForm = document.getElementById("parameter-form");
|
||||
|
||||
let appState = null;
|
||||
let websocket = null;
|
||||
|
||||
function createKv(target, values) {
|
||||
target.innerHTML = "";
|
||||
values.forEach(([key, value]) => {
|
||||
const dt = document.createElement("dt");
|
||||
dt.textContent = key;
|
||||
const dd = document.createElement("dd");
|
||||
dd.textContent = value;
|
||||
target.append(dt, dd);
|
||||
});
|
||||
}
|
||||
|
||||
function postJson(path, payload) {
|
||||
return fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function renderParameters(shader) {
|
||||
parameterForm.innerHTML = "";
|
||||
if (!shader) {
|
||||
return;
|
||||
}
|
||||
|
||||
shader.parameters.forEach((parameter) => {
|
||||
const section = document.createElement("section");
|
||||
section.className = "parameter";
|
||||
|
||||
const label = document.createElement("label");
|
||||
label.textContent = parameter.label;
|
||||
section.appendChild(label);
|
||||
|
||||
const valueLabel = document.createElement("div");
|
||||
valueLabel.className = "parameter__value";
|
||||
|
||||
const sendValue = (value) => {
|
||||
postJson("/api/update-parameter", {
|
||||
shaderId: shader.id,
|
||||
parameterId: parameter.id,
|
||||
value,
|
||||
});
|
||||
};
|
||||
|
||||
if (parameter.type === "float") {
|
||||
const range = document.createElement("input");
|
||||
range.type = "range";
|
||||
range.min = parameter.min?.[0] ?? 0;
|
||||
range.max = parameter.max?.[0] ?? 1;
|
||||
range.step = parameter.step?.[0] ?? 0.01;
|
||||
range.value = parameter.value;
|
||||
const number = document.createElement("input");
|
||||
number.type = "number";
|
||||
number.min = range.min;
|
||||
number.max = range.max;
|
||||
number.step = range.step;
|
||||
number.value = parameter.value;
|
||||
const pair = document.createElement("div");
|
||||
pair.className = "parameter__pair";
|
||||
pair.append(range, number);
|
||||
section.append(pair, valueLabel);
|
||||
const update = (value) => {
|
||||
valueLabel.textContent = Number(value).toFixed(3);
|
||||
range.value = value;
|
||||
number.value = value;
|
||||
};
|
||||
update(parameter.value);
|
||||
range.addEventListener("input", () => update(range.value));
|
||||
range.addEventListener("change", () => sendValue(Number(range.value)));
|
||||
number.addEventListener("change", () => {
|
||||
update(number.value);
|
||||
sendValue(Number(number.value));
|
||||
});
|
||||
} else if (parameter.type === "vec2" || parameter.type === "color") {
|
||||
const pair = document.createElement("div");
|
||||
pair.className = "parameter__pair";
|
||||
const values = parameter.value.slice();
|
||||
values.forEach((component, index) => {
|
||||
const input = document.createElement("input");
|
||||
input.type = "number";
|
||||
input.step = parameter.step?.[index] ?? 0.01;
|
||||
input.min = parameter.min?.[index] ?? "";
|
||||
input.max = parameter.max?.[index] ?? "";
|
||||
input.value = component;
|
||||
input.addEventListener("change", () => {
|
||||
values[index] = Number(input.value);
|
||||
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
|
||||
sendValue(values);
|
||||
});
|
||||
pair.appendChild(input);
|
||||
});
|
||||
valueLabel.textContent = values.map((value) => Number(value).toFixed(3)).join(", ");
|
||||
section.append(pair, valueLabel);
|
||||
} else if (parameter.type === "bool") {
|
||||
const toggle = document.createElement("input");
|
||||
toggle.type = "checkbox";
|
||||
toggle.checked = parameter.value;
|
||||
valueLabel.textContent = parameter.value ? "Enabled" : "Disabled";
|
||||
toggle.addEventListener("change", () => {
|
||||
valueLabel.textContent = toggle.checked ? "Enabled" : "Disabled";
|
||||
sendValue(toggle.checked);
|
||||
});
|
||||
section.append(toggle, valueLabel);
|
||||
} else if (parameter.type === "enum") {
|
||||
const select = document.createElement("select");
|
||||
parameter.options.forEach((option) => {
|
||||
const item = document.createElement("option");
|
||||
item.value = option.value;
|
||||
item.textContent = option.label;
|
||||
if (option.value === parameter.value) {
|
||||
item.selected = true;
|
||||
}
|
||||
select.appendChild(item);
|
||||
});
|
||||
valueLabel.textContent = parameter.value;
|
||||
select.addEventListener("change", () => {
|
||||
valueLabel.textContent = select.value;
|
||||
sendValue(select.value);
|
||||
});
|
||||
section.append(select, valueLabel);
|
||||
}
|
||||
|
||||
parameterForm.appendChild(section);
|
||||
});
|
||||
}
|
||||
|
||||
function renderState(state) {
|
||||
appState = state;
|
||||
const shaders = state.shaders || [];
|
||||
const activeShaderId = state.runtime.activeShaderId;
|
||||
const activeShader = shaders.find((shader) => shader.id === activeShaderId) || shaders[0];
|
||||
const performance = state.performance || {};
|
||||
const frameBudgetMs = Number(performance.frameBudgetMs || 0);
|
||||
const renderMs = Number(performance.renderMs || 0);
|
||||
const smoothedRenderMs = Number(performance.smoothedRenderMs || 0);
|
||||
const budgetUsedPercent = Number(performance.budgetUsedPercent || 0);
|
||||
|
||||
shaderSelect.innerHTML = "";
|
||||
shaders.forEach((shader) => {
|
||||
const option = document.createElement("option");
|
||||
option.value = shader.id;
|
||||
option.textContent = shader.name;
|
||||
if (shader.id === activeShaderId) {
|
||||
option.selected = true;
|
||||
}
|
||||
shaderSelect.appendChild(option);
|
||||
});
|
||||
|
||||
mixSlider.value = state.runtime.mixAmount ?? 1;
|
||||
bypassToggle.checked = Boolean(state.runtime.bypass);
|
||||
compileStatus.textContent = state.runtime.compileMessage || "No compiler output.";
|
||||
resetParametersButton.disabled = !activeShader || activeShader.parameters.length === 0;
|
||||
|
||||
createKv(runtimeStatus, [
|
||||
["Active Shader", activeShader?.name || "None"],
|
||||
["Auto Reload", state.app.autoReload ? "On" : "Off"],
|
||||
["Control URL", `http://127.0.0.1:${state.app.serverPort}`],
|
||||
["Compile Status", state.runtime.compileSucceeded ? "Ready" : "Error"],
|
||||
["Render Time", `${renderMs.toFixed(2)} ms`],
|
||||
["Smoothed Time", `${smoothedRenderMs.toFixed(2)} ms`],
|
||||
["Frame Budget", `${frameBudgetMs.toFixed(2)} ms`],
|
||||
["Budget Used", `${budgetUsedPercent.toFixed(1)}%`],
|
||||
]);
|
||||
|
||||
createKv(videoStatus, [
|
||||
["Signal", state.video.hasSignal ? "Present" : "Missing"],
|
||||
["Mode", state.video.modeName || "Unknown"],
|
||||
["Resolution", `${state.video.width || 0} x ${state.video.height || 0}`],
|
||||
]);
|
||||
|
||||
renderParameters(activeShader);
|
||||
}
|
||||
|
||||
async function loadInitialState() {
|
||||
const response = await fetch("/api/state");
|
||||
renderState(await response.json());
|
||||
}
|
||||
|
||||
function connectWebSocket() {
|
||||
const protocol = location.protocol === "https:" ? "wss" : "ws";
|
||||
websocket = new WebSocket(`${protocol}://${location.host}/ws`);
|
||||
websocket.onmessage = (event) => {
|
||||
try {
|
||||
renderState(JSON.parse(event.data));
|
||||
} catch (error) {
|
||||
console.error("Failed to parse state update", error);
|
||||
}
|
||||
};
|
||||
websocket.onclose = () => {
|
||||
setTimeout(connectWebSocket, 1000);
|
||||
};
|
||||
}
|
||||
|
||||
shaderSelect.addEventListener("change", () => {
|
||||
postJson("/api/select-shader", { shaderId: shaderSelect.value });
|
||||
});
|
||||
|
||||
mixSlider.addEventListener("change", () => {
|
||||
postJson("/api/set-mix", { mixAmount: Number(mixSlider.value) });
|
||||
});
|
||||
|
||||
bypassToggle.addEventListener("change", () => {
|
||||
postJson("/api/set-bypass", { bypass: bypassToggle.checked });
|
||||
});
|
||||
|
||||
reloadButton.addEventListener("click", () => {
|
||||
postJson("/api/reload", {});
|
||||
});
|
||||
|
||||
resetParametersButton.addEventListener("click", () => {
|
||||
const activeShaderId = appState?.runtime?.activeShaderId;
|
||||
if (!activeShaderId) {
|
||||
return;
|
||||
}
|
||||
|
||||
postJson("/api/reset-parameters", { shaderId: activeShaderId });
|
||||
});
|
||||
|
||||
loadInitialState().then(connectWebSocket);
|
||||
@@ -1,53 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Video Shader Host</title>
|
||||
<link rel="stylesheet" href="/styles.css">
|
||||
</head>
|
||||
<body>
|
||||
<main class="layout">
|
||||
<section class="toolbar">
|
||||
<div class="toolbar__group">
|
||||
<label for="shader-select">Shader</label>
|
||||
<select id="shader-select"></select>
|
||||
</div>
|
||||
<div class="toolbar__group toolbar__group--wide">
|
||||
<label for="mix-slider">Mix</label>
|
||||
<input id="mix-slider" type="range" min="0" max="1" step="0.01">
|
||||
</div>
|
||||
<label class="toggle">
|
||||
<input id="bypass-toggle" type="checkbox">
|
||||
<span>Bypass</span>
|
||||
</label>
|
||||
<button id="reload-button" type="button">Reload Shader</button>
|
||||
</section>
|
||||
|
||||
<section class="status-grid">
|
||||
<div class="panel">
|
||||
<h2>Runtime</h2>
|
||||
<dl id="runtime-status" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<h2>Video</h2>
|
||||
<dl id="video-status" class="kv"></dl>
|
||||
</div>
|
||||
<div class="panel panel--full">
|
||||
<h2>Compiler</h2>
|
||||
<pre id="compile-status"></pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel__header">
|
||||
<h2>Parameters</h2>
|
||||
<button id="reset-parameters-button" type="button">Reset Parameters</button>
|
||||
</div>
|
||||
<form id="parameter-form" class="parameter-grid"></form>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Video Shader Host</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1726
ui/package-lock.json
generated
Normal file
1726
ui/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
ui/package.json
Normal file
20
ui/package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "video-shader-control-ui",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"lucide-react": "^0.511.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"vite": "^5.4.11"
|
||||
}
|
||||
}
|
||||
639
ui/src/App.jsx
Normal file
639
ui/src/App.jsx
Normal file
@@ -0,0 +1,639 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { GripVertical, Trash2 } from "lucide-react";
|
||||
|
||||
function KvList({ values }) {
|
||||
return (
|
||||
<dl className="kv">
|
||||
{values.map(([key, value]) => (
|
||||
<FragmentRow key={key} label={key} value={value} />
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
function FragmentRow({ label, value }) {
|
||||
return (
|
||||
<>
|
||||
<dt>{label}</dt>
|
||||
<dd>{value}</dd>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function postJson(path, payload) {
|
||||
return fetch(path, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
function moveItem(array, fromIndex, toIndex) {
|
||||
if (fromIndex < 0 || fromIndex >= array.length || toIndex < 0 || toIndex >= array.length) {
|
||||
return array;
|
||||
}
|
||||
|
||||
const copy = [...array];
|
||||
const [item] = copy.splice(fromIndex, 1);
|
||||
copy.splice(toIndex, 0, item);
|
||||
return copy;
|
||||
}
|
||||
|
||||
function formatNumber(value, digits = 3) {
|
||||
return Number(value ?? 0).toFixed(digits);
|
||||
}
|
||||
|
||||
function formatParameterValue(parameterType, value) {
|
||||
if (parameterType === "float") {
|
||||
return formatNumber(value);
|
||||
}
|
||||
if (parameterType === "vec2" || parameterType === "color") {
|
||||
return (value ?? []).map((item) => formatNumber(item)).join(", ");
|
||||
}
|
||||
if (parameterType === "bool") {
|
||||
return value ? "Enabled" : "Disabled";
|
||||
}
|
||||
return `${value ?? ""}`;
|
||||
}
|
||||
|
||||
function valuesMatch(left, right) {
|
||||
return JSON.stringify(left) === JSON.stringify(right);
|
||||
}
|
||||
|
||||
function ParameterField({ parameter, onParameterChange }) {
|
||||
const [draftValue, setDraftValue] = useState(parameter.value);
|
||||
|
||||
useEffect(() => {
|
||||
setDraftValue(parameter.value);
|
||||
}, [parameter.value]);
|
||||
|
||||
const sendValue = (value) => {
|
||||
setDraftValue(value);
|
||||
onParameterChange(parameter.id, value);
|
||||
};
|
||||
|
||||
const label = <label>{parameter.label}</label>;
|
||||
const isPending = !valuesMatch(draftValue, parameter.value);
|
||||
const appliedValueText = formatParameterValue(parameter.type, parameter.value);
|
||||
|
||||
if (parameter.type === "float") {
|
||||
return (
|
||||
<section className="parameter">
|
||||
{label}
|
||||
<div className="parameter__pair">
|
||||
<input
|
||||
type="range"
|
||||
min={parameter.min?.[0] ?? 0}
|
||||
max={parameter.max?.[0] ?? 1}
|
||||
step={parameter.step?.[0] ?? 0.01}
|
||||
value={draftValue}
|
||||
onChange={(event) => sendValue(Number(event.target.value))}
|
||||
/>
|
||||
<input
|
||||
type="number"
|
||||
min={parameter.min?.[0] ?? ""}
|
||||
max={parameter.max?.[0] ?? ""}
|
||||
step={parameter.step?.[0] ?? 0.01}
|
||||
value={draftValue}
|
||||
onChange={(event) => sendValue(Number(event.target.value))}
|
||||
/>
|
||||
</div>
|
||||
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
|
||||
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (parameter.type === "vec2" || parameter.type === "color") {
|
||||
const componentCount = parameter.type === "color" ? 4 : 2;
|
||||
const values = [...(draftValue ?? [])];
|
||||
while (values.length < componentCount) {
|
||||
values.push(0);
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="parameter">
|
||||
{label}
|
||||
<div className="parameter__pair">
|
||||
{Array.from({ length: componentCount }, (_, index) => (
|
||||
<input
|
||||
key={index}
|
||||
type="number"
|
||||
min={parameter.min?.[index] ?? ""}
|
||||
max={parameter.max?.[index] ?? ""}
|
||||
step={parameter.step?.[index] ?? 0.01}
|
||||
value={values[index]}
|
||||
onChange={(event) => {
|
||||
const next = [...values];
|
||||
next[index] = Number(event.target.value);
|
||||
sendValue(next);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
|
||||
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (parameter.type === "bool") {
|
||||
return (
|
||||
<section className="parameter">
|
||||
{label}
|
||||
<label className="toggle toggle--field">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(draftValue)}
|
||||
onChange={(event) => sendValue(event.target.checked)}
|
||||
/>
|
||||
<span>{draftValue ? "Enabled" : "Disabled"}</span>
|
||||
</label>
|
||||
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
|
||||
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
if (parameter.type === "enum") {
|
||||
return (
|
||||
<section className="parameter">
|
||||
{label}
|
||||
<select value={draftValue} onChange={(event) => sendValue(event.target.value)}>
|
||||
{parameter.options.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className={`parameter__value${isPending ? " parameter__value--pending" : ""}`}>
|
||||
{isPending ? `Applied: ${appliedValueText}` : appliedValueText}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function LayerCard({
|
||||
layer,
|
||||
index,
|
||||
shaders,
|
||||
expanded,
|
||||
isDragging,
|
||||
isDropTarget,
|
||||
onToggleExpanded,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onRemove,
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={`layer-card${expanded ? " layer-card--expanded" : ""}${isDragging ? " layer-card--dragging" : ""}${isDropTarget ? " layer-card--drop-target" : ""}`}
|
||||
onDragOver={(event) => {
|
||||
event.preventDefault();
|
||||
onDragOver(layer.id);
|
||||
}}
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
onDrop(event, layer.id, index);
|
||||
}}
|
||||
>
|
||||
<div className="layer-card__header">
|
||||
<div className="layer-card__meta">
|
||||
<button
|
||||
type="button"
|
||||
className="layer-card__drag-handle"
|
||||
title="Drag to reorder"
|
||||
aria-label="Drag to reorder"
|
||||
draggable
|
||||
onDragStart={(event) => {
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
event.dataTransfer.setData("text/plain", layer.id);
|
||||
event.stopPropagation();
|
||||
onDragStart(layer.id);
|
||||
}}
|
||||
onDragEnd={(event) => {
|
||||
event.stopPropagation();
|
||||
onDragEnd();
|
||||
}}
|
||||
>
|
||||
<GripVertical size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
<span className="layer-card__index">{index + 1}</span>
|
||||
<button type="button" className="layer-card__title" onClick={() => onToggleExpanded(layer.id)}>
|
||||
{layer.shaderName}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="layer-card__actions">
|
||||
<label className="toggle toggle--compact">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(layer.bypass)}
|
||||
onChange={(event) =>
|
||||
postJson("/api/layers/set-bypass", {
|
||||
layerId: layer.id,
|
||||
bypass: event.target.checked,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>Bypass</span>
|
||||
</label>
|
||||
|
||||
<button type="button" onClick={() => onToggleExpanded(layer.id)}>
|
||||
{expanded ? "Hide" : "Controls"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="icon-button"
|
||||
title="Remove layer"
|
||||
aria-label="Remove layer"
|
||||
onClick={() => onRemove(layer.id)}
|
||||
>
|
||||
<Trash2 size={16} strokeWidth={1.75} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{expanded ? (
|
||||
<div className="layer-card__body">
|
||||
<div className="layer-card__field">
|
||||
<label htmlFor={`shader-${layer.id}`}>Shader</label>
|
||||
<select
|
||||
id={`shader-${layer.id}`}
|
||||
value={layer.shaderId}
|
||||
onChange={(event) =>
|
||||
postJson("/api/layers/set-shader", {
|
||||
layerId: layer.id,
|
||||
shaderId: event.target.value,
|
||||
})
|
||||
}
|
||||
>
|
||||
{shaders.map((shader) => (
|
||||
<option key={shader.id} value={shader.id}>
|
||||
{shader.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="layer-card__subheader">
|
||||
<h3>Parameters</h3>
|
||||
<button
|
||||
type="button"
|
||||
disabled={layer.parameters.length === 0}
|
||||
onClick={() => postJson("/api/layers/reset-parameters", { layerId: layer.id })}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{layer.parameters.length > 0 ? (
|
||||
<div className="parameter-grid">
|
||||
{layer.parameters.map((parameter) => (
|
||||
<ParameterField
|
||||
key={`${layer.id}:${parameter.id}:${JSON.stringify(parameter.value)}`}
|
||||
parameter={parameter}
|
||||
onParameterChange={(parameterId, value) =>
|
||||
updateLayerParameterOptimistically(layer.id, parameterId, value)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="muted">This shader does not expose any user parameters.</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function App() {
|
||||
const [appState, setAppState] = useState(null);
|
||||
const [pendingShaderId, setPendingShaderId] = useState("");
|
||||
const [presetName, setPresetName] = useState("");
|
||||
const [selectedPresetName, setSelectedPresetName] = useState("");
|
||||
const [expandedLayerIds, setExpandedLayerIds] = useState([]);
|
||||
const [dragLayerId, setDragLayerId] = useState(null);
|
||||
const [dropTargetLayerId, setDropTargetLayerId] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
let socket;
|
||||
let retryTimer;
|
||||
|
||||
const connectWebSocket = () => {
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
socket = new WebSocket(`${protocol}://${window.location.host}/ws`);
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const nextState = JSON.parse(event.data);
|
||||
if (mounted) {
|
||||
setAppState(nextState);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to parse state update", error);
|
||||
}
|
||||
};
|
||||
socket.onclose = () => {
|
||||
if (mounted) {
|
||||
retryTimer = window.setTimeout(connectWebSocket, 1000);
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
fetch("/api/state")
|
||||
.then((response) => response.json())
|
||||
.then((state) => {
|
||||
if (mounted) {
|
||||
setAppState(state);
|
||||
connectWebSocket();
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load initial state", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
if (retryTimer) {
|
||||
window.clearTimeout(retryTimer);
|
||||
}
|
||||
if (socket) {
|
||||
socket.close();
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const layers = appState?.layers ?? [];
|
||||
const shaders = appState?.shaders ?? [];
|
||||
const performance = appState?.performance ?? {};
|
||||
const runtime = appState?.runtime ?? {};
|
||||
const video = appState?.video ?? {};
|
||||
const app = appState?.app ?? {};
|
||||
const stackPresets = appState?.stackPresets ?? [];
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingShaderId && shaders.length > 0) {
|
||||
setPendingShaderId(shaders[0].id);
|
||||
} else if (pendingShaderId && !shaders.some((shader) => shader.id === pendingShaderId)) {
|
||||
setPendingShaderId(shaders[0]?.id ?? "");
|
||||
}
|
||||
}, [pendingShaderId, shaders]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedPresetName && stackPresets.length > 0) {
|
||||
setSelectedPresetName(stackPresets[0]);
|
||||
} else if (selectedPresetName && !stackPresets.includes(selectedPresetName)) {
|
||||
setSelectedPresetName(stackPresets[0] ?? "");
|
||||
}
|
||||
}, [selectedPresetName, stackPresets]);
|
||||
|
||||
useEffect(() => {
|
||||
const layerIds = new Set(layers.map((layer) => layer.id));
|
||||
setExpandedLayerIds((current) => current.filter((layerId) => layerIds.has(layerId)));
|
||||
}, [layers]);
|
||||
|
||||
const expandedSet = useMemo(() => new Set(expandedLayerIds), [expandedLayerIds]);
|
||||
|
||||
function updateLayerParameterOptimistically(layerId, parameterId, value) {
|
||||
postJson("/api/layers/update-parameter", {
|
||||
layerId,
|
||||
parameterId,
|
||||
value,
|
||||
});
|
||||
}
|
||||
|
||||
function toggleExpanded(layerId) {
|
||||
setExpandedLayerIds((current) =>
|
||||
current.includes(layerId) ? current.filter((id) => id !== layerId) : [...current, layerId],
|
||||
);
|
||||
}
|
||||
|
||||
function removeLayer(layerId) {
|
||||
setExpandedLayerIds((current) => current.filter((id) => id !== layerId));
|
||||
postJson("/api/layers/remove", { layerId });
|
||||
}
|
||||
|
||||
function handleDrop(event, targetLayerId, targetIndex) {
|
||||
const sourceLayerId = event.dataTransfer.getData("text/plain") || dragLayerId;
|
||||
if (!sourceLayerId || sourceLayerId === targetLayerId) {
|
||||
setDragLayerId(null);
|
||||
setDropTargetLayerId(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setAppState((current) => {
|
||||
if (!current?.layers) {
|
||||
return current;
|
||||
}
|
||||
|
||||
const sourceIndex = current.layers.findIndex((layer) => layer.id === sourceLayerId);
|
||||
const destinationIndex = current.layers.findIndex((layer) => layer.id === targetLayerId);
|
||||
if (sourceIndex < 0 || destinationIndex < 0 || sourceIndex === destinationIndex) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
layers: moveItem(current.layers, sourceIndex, destinationIndex),
|
||||
};
|
||||
});
|
||||
|
||||
postJson("/api/layers/reorder", {
|
||||
layerId: sourceLayerId,
|
||||
targetIndex,
|
||||
});
|
||||
setDragLayerId(null);
|
||||
setDropTargetLayerId(null);
|
||||
}
|
||||
|
||||
if (!appState) {
|
||||
return (
|
||||
<main className="layout">
|
||||
<section className="panel">
|
||||
<h2>Loading</h2>
|
||||
<p className="muted">Waiting for control state from the native host.</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="layout">
|
||||
<section className="toolbar">
|
||||
<div className="toolbar__group toolbar__group--wide">
|
||||
<label htmlFor="preset-name">Save Stack</label>
|
||||
<div className="toolbar__inline">
|
||||
<input
|
||||
id="preset-name"
|
||||
type="text"
|
||||
placeholder="Preset name"
|
||||
value={presetName}
|
||||
onChange={(event) => setPresetName(event.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!presetName.trim()}
|
||||
onClick={() => {
|
||||
const trimmedName = presetName.trim();
|
||||
if (!trimmedName) {
|
||||
return;
|
||||
}
|
||||
postJson("/api/stack-presets/save", { presetName: trimmedName });
|
||||
setSelectedPresetName(trimmedName.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, ""));
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="toolbar__group toolbar__group--wide">
|
||||
<label htmlFor="preset-select">Recall Stack</label>
|
||||
<div className="toolbar__inline">
|
||||
<select
|
||||
id="preset-select"
|
||||
value={selectedPresetName}
|
||||
onChange={(event) => setSelectedPresetName(event.target.value)}
|
||||
>
|
||||
{stackPresets.length === 0 ? <option value="">No presets</option> : null}
|
||||
{stackPresets.map((preset) => (
|
||||
<option key={preset} value={preset}>
|
||||
{preset}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
disabled={!selectedPresetName}
|
||||
onClick={() => {
|
||||
if (selectedPresetName) {
|
||||
postJson("/api/stack-presets/load", { presetName: selectedPresetName });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Recall
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" onClick={() => postJson("/api/reload", {})}>
|
||||
Reload Shader
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<section className="status-grid">
|
||||
<div className="panel">
|
||||
<h2>Runtime</h2>
|
||||
<KvList
|
||||
values={[
|
||||
["Layer Count", `${runtime.layerCount || 0}`],
|
||||
["Auto Reload", app.autoReload ? "On" : "Off"],
|
||||
["Control URL", `http://127.0.0.1:${app.serverPort}`],
|
||||
["Compile Status", runtime.compileSucceeded ? "Ready" : "Error"],
|
||||
["Render Time", `${formatNumber(performance.renderMs, 2)} ms`],
|
||||
["Smoothed Time", `${formatNumber(performance.smoothedRenderMs, 2)} ms`],
|
||||
["Frame Budget", `${formatNumber(performance.frameBudgetMs, 2)} ms`],
|
||||
["Budget Used", `${formatNumber(performance.budgetUsedPercent, 1)}%`],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel">
|
||||
<h2>Video</h2>
|
||||
<KvList
|
||||
values={[
|
||||
["Signal", video.hasSignal ? "Present" : "Missing"],
|
||||
["Mode", video.modeName || "Unknown"],
|
||||
["Resolution", `${video.width || 0} x ${video.height || 0}`],
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="panel panel--full">
|
||||
<h2>Compiler</h2>
|
||||
<pre>{runtime.compileMessage || "No compiler output."}</pre>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="panel">
|
||||
<div className="panel__header">
|
||||
<h2>Layers</h2>
|
||||
<p className="muted">Drag layers to reorder them. Each layer processes the output of the one above it.</p>
|
||||
</div>
|
||||
|
||||
<div className="layer-stack">
|
||||
{layers.map((layer, index) => (
|
||||
<LayerCard
|
||||
key={layer.id}
|
||||
layer={layer}
|
||||
index={index}
|
||||
shaders={shaders}
|
||||
expanded={expandedSet.has(layer.id)}
|
||||
isDragging={dragLayerId === layer.id}
|
||||
isDropTarget={dropTargetLayerId === layer.id}
|
||||
onToggleExpanded={toggleExpanded}
|
||||
onDragStart={setDragLayerId}
|
||||
onDragEnd={() => {
|
||||
setDragLayerId(null);
|
||||
setDropTargetLayerId(null);
|
||||
}}
|
||||
onDragOver={setDropTargetLayerId}
|
||||
onDrop={handleDrop}
|
||||
onRemove={removeLayer}
|
||||
/>
|
||||
))}
|
||||
|
||||
<div className="layer-card layer-card--add">
|
||||
<div className="layer-card__header">
|
||||
<div className="layer-card__meta">
|
||||
<span className="layer-card__index">+</span>
|
||||
<div className="layer-card__title layer-card__title--static">Add Layer</div>
|
||||
</div>
|
||||
<div className="layer-card__actions">
|
||||
<button
|
||||
type="button"
|
||||
disabled={!pendingShaderId}
|
||||
onClick={() => {
|
||||
if (pendingShaderId) {
|
||||
postJson("/api/layers/add", { shaderId: pendingShaderId });
|
||||
}
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="layer-card__body">
|
||||
<div className="layer-card__field">
|
||||
<label htmlFor="add-layer-select">Shader</label>
|
||||
<select
|
||||
id="add-layer-select"
|
||||
value={pendingShaderId}
|
||||
onChange={(event) => setPendingShaderId(event.target.value)}
|
||||
>
|
||||
{shaders.map((shader) => (
|
||||
<option key={shader.id} value={shader.id}>
|
||||
{shader.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
10
ui/src/main.jsx
Normal file
10
ui/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./styles.css";
|
||||
|
||||
ReactDOM.createRoot(document.getElementById("root")).render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
325
ui/src/styles.css
Normal file
325
ui/src/styles.css
Normal file
@@ -0,0 +1,325 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background: #111318;
|
||||
color: #edf1f7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body,
|
||||
#root {
|
||||
min-height: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #111318;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
select,
|
||||
pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
label,
|
||||
h2,
|
||||
h3 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select,
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #38445b;
|
||||
background: #0f131a;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #22314a;
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
cursor: default;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #c9d5ea;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.status-grid,
|
||||
.parameter-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
justify-content: end;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #181c24;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel__header button {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: #94a4c2;
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.layer-stack {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.layer-card {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
padding: 14px;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
background: #131720;
|
||||
transition: border-color 120ms ease, background 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.layer-card:hover {
|
||||
border-color: #42516b;
|
||||
}
|
||||
|
||||
.layer-card--expanded {
|
||||
border-color: #5d81c3;
|
||||
background: #151d2a;
|
||||
box-shadow: inset 0 0 0 1px rgba(93, 129, 195, 0.25);
|
||||
}
|
||||
|
||||
.layer-card--dragging {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.layer-card--drop-target {
|
||||
border-color: #8db0ee;
|
||||
box-shadow: 0 0 0 2px rgba(141, 176, 238, 0.2);
|
||||
}
|
||||
|
||||
.layer-card__header,
|
||||
.layer-card__meta,
|
||||
.layer-card__actions,
|
||||
.layer-card__subheader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.layer-card__header,
|
||||
.layer-card__subheader {
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.layer-card__meta,
|
||||
.layer-card__actions {
|
||||
min-width: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
width: 36px;
|
||||
min-width: 36px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.layer-card__index {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
background: #22314a;
|
||||
color: #c9d5ea;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.layer-card__title {
|
||||
width: auto;
|
||||
min-width: 0;
|
||||
flex: 1 1 auto;
|
||||
text-align: left;
|
||||
background: #1e2a3f;
|
||||
}
|
||||
|
||||
.layer-card__title--static {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.layer-card__drag-handle {
|
||||
color: #94a4c2;
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.layer-card__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.layer-card__actions button {
|
||||
width: auto;
|
||||
min-width: 72px;
|
||||
}
|
||||
|
||||
.layer-card__body {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.layer-card--add {
|
||||
border-style: dashed;
|
||||
}
|
||||
|
||||
.layer-card__field {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.layer-card__subheader button {
|
||||
width: auto;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.parameter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
background: #131720;
|
||||
}
|
||||
|
||||
.parameter__value {
|
||||
color: #94a4c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.parameter__value--pending {
|
||||
color: #d3b26a;
|
||||
}
|
||||
|
||||
.parameter__pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
.toggle--compact {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.toggle--field {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.muted {
|
||||
margin: 0;
|
||||
color: #94a4c2;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.toolbar,
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.layer-card__header,
|
||||
.layer-card__subheader {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.layer-card__actions,
|
||||
.layer-card__actions button,
|
||||
.layer-card__field select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
169
ui/styles.css
169
ui/styles.css
@@ -1,169 +0,0 @@
|
||||
:root {
|
||||
color-scheme: dark;
|
||||
font-family: "Segoe UI", sans-serif;
|
||||
background: #111318;
|
||||
color: #edf1f7;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: #111318;
|
||||
}
|
||||
|
||||
.layout {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
display: grid;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.status-grid,
|
||||
.parameter-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
grid-template-columns: minmax(220px, 2fr) minmax(220px, 3fr) auto auto;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.toolbar__group {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toolbar__group--wide {
|
||||
min-width: 260px;
|
||||
}
|
||||
|
||||
.panel {
|
||||
background: #181c24;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.panel--full {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.panel__header button {
|
||||
width: auto;
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.kv {
|
||||
display: grid;
|
||||
grid-template-columns: 160px 1fr;
|
||||
gap: 8px 12px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kv dt {
|
||||
color: #94a4c2;
|
||||
}
|
||||
|
||||
.kv dd {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.parameter-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||
}
|
||||
|
||||
.parameter {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid #2a3140;
|
||||
border-radius: 8px;
|
||||
background: #131720;
|
||||
}
|
||||
|
||||
.parameter__value {
|
||||
color: #94a4c2;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.parameter__pair {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
label,
|
||||
h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
input,
|
||||
select,
|
||||
button,
|
||||
pre {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
input[type="range"] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
input[type="number"],
|
||||
select,
|
||||
button {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid #38445b;
|
||||
background: #0f131a;
|
||||
color: inherit;
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
button {
|
||||
cursor: pointer;
|
||||
background: #22314a;
|
||||
}
|
||||
|
||||
.toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 36px;
|
||||
}
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
white-space: pre-wrap;
|
||||
color: #c9d5ea;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.toolbar {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
13
ui/vite.config.js
Normal file
13
ui/vite.config.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import { defineConfig } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "dist",
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
host: "127.0.0.1",
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user