diff --git a/README.md b/README.md index 4bc47a7a..22644243 100644 --- a/README.md +++ b/README.md @@ -470,7 +470,7 @@ Optional fallback token behavior: - Server-side effect moderation/generation endpoints treat submitted/generated code as inert text only (validated/stored/returned), and never execute that code in the server environment. This keeps server secrets (for example `process.env` values) out of reach of model-generated effects. - Before any generated runtime code is compiled for preview/review, the client applies a safety scanner that rejects obviously dangerous primitives (`process.env`, `document.cookie`, browser storage APIs, network APIs like `fetch`/`XMLHttpRequest`/`sendBeacon`, and dynamic code loaders such as `eval`/`Function`/`import()`/`require()`). - Generated effect payloads now also include optional parameter control metadata (`params`) and concise docs (`docs`). After approval, these generated controls are exposed in the debug panel + editor parameter pickers just like built-in effects. -- The effect generator modal now keeps the initial prompt editor focused and roomy, then reveals preview/code/param sections only after a successful generation. While generating, it shows approved community effects in a carousel with previous/next controls, and the eventual submission uses the current preview param values as defaults automatically (no separate “set defaults” step). +- The effect generator modal now keeps the initial prompt editor focused and roomy, then reveals preview/code/param sections only after a successful generation. While generating, it shows approved community effects in a carousel with previous/next controls plus live parameter controls so users can tweak those community effects while waiting, and the eventual submission uses the current preview param values as defaults automatically (no separate “set defaults” step). - Effect generation and moderation preview now use `public/songloop.ogg` as a dedicated seamless background loop (low volume), while a synthetic preview timeline keeps generated effects animating continuously even as the audio loops. - After approving or denying from `/effect-review.html`, the page now offers a **Next effect** button whenever additional pending effects are still in the moderation queue. - The client also normalizes escaped code payloads (for example strings containing literal `\\n`) before compiling preview/runtime effects. diff --git a/index.html b/index.html index 2ff990d9..003e057b 100644 --- a/index.html +++ b/index.html @@ -105,6 +105,11 @@
Generating your effect…
Enjoy approved community effects while Codex cooks your idea.
+ diff --git a/src/main.ts b/src/main.ts index 948b424b..39218048 100644 --- a/src/main.ts +++ b/src/main.ts @@ -155,6 +155,9 @@ const effectIdeaBusyModal = document.querySelector("#effect-idea const effectIdeaBusySpinner = document.querySelector("#effect-idea-busy-spinner"); const effectIdeaBusyTitle = document.querySelector("#effect-idea-busy-title"); const effectIdeaBusyCopy = document.querySelector("#effect-idea-busy-copy"); +const effectIdeaBusyControls = document.querySelector("#effect-idea-busy-controls"); +const effectIdeaBusyControlsGrid = document.querySelector("#effect-idea-busy-controls-grid"); +const effectIdeaBusyControlsEmpty = document.querySelector("#effect-idea-busy-controls-empty"); const effectIdeaRetryButton = document.querySelector("#effect-idea-retry"); const effectIdeaGeneratedSections = document.querySelector("#effect-idea-generated-sections"); const effectIdeaControls = document.querySelector("#effect-idea-controls"); @@ -222,7 +225,12 @@ let effectIdeaPreviewFrame = 0; let effectIdeaPreviewEffect: ReturnType | null = null; let effectIdeaPreviewParams: Record = {}; let effectIdeaCountdownTimer = 0; -let effectIdeaCarouselEntries: Array<{ name: string; effect: ReturnType; params: Record }> = []; +let effectIdeaCarouselEntries: Array<{ + name: string; + effect: ReturnType; + params: Record; + controls: GeneratedEffectParam[]; +}> = []; let effectIdeaCarouselIndex = 0; let effectIdeaAudioPreview = new EffectPreviewAudioController(EFFECT_PREVIEW_AUDIO_SRC); let availableEffectNames = getEffectRegistryKeys(); @@ -595,11 +603,13 @@ function setEffectIdeaBusyState(mode: "hidden" | "busy" | "error", message?: str if (mode === "busy") { effectIdeaBusyTitle.textContent = "Generating your effect…"; effectIdeaBusyCopy.textContent = "Enjoy approved community effects while Codex cooks your idea."; + effectIdeaBusyControls?.classList.remove("hidden"); return; } if (mode === "error") { effectIdeaBusyTitle.textContent = "Generation hiccup"; effectIdeaBusyCopy.textContent = message ?? "That attempt did not compile cleanly. Retry to generate a fresh variation."; + effectIdeaBusyControls?.classList.add("hidden"); } } @@ -626,6 +636,7 @@ function applyEffectIdeaCarouselEntry(): void { } effectIdeaPreviewEffect = entry.effect; effectIdeaPreviewParams = { ...entry.params }; + renderEffectIdeaBusyControls(entry.controls); stopEffectIdeaPreview(); previewGeneratedIdea(); } @@ -645,7 +656,8 @@ async function hydrateEffectIdeaCarousel(): Promise { return [{ name: entry.name, effect: compileRuntimeEffect(entry.runtimeCode), - params: getGeneratedEffectDefaultParams(entry.params) + params: getGeneratedEffectDefaultParams(entry.params), + controls: entry.params ?? [] }]; } catch { return []; @@ -659,6 +671,79 @@ async function hydrateEffectIdeaCarousel(): Promise { effectIdeaCarouselIndex = 0; } +function renderEffectIdeaBusyControls(params: GeneratedEffectParam[]): void { + if (!effectIdeaBusyControls || !effectIdeaBusyControlsGrid || !effectIdeaBusyControlsEmpty) { + return; + } + const hasControls = params.length > 0; + effectIdeaBusyControls.classList.toggle("hidden", !hasControls); + effectIdeaBusyControlsGrid.innerHTML = ""; + effectIdeaBusyControlsEmpty.classList.toggle("hidden", hasControls); + if (!hasControls) { + return; + } + + params.forEach((param) => { + const field = document.createElement("label"); + field.classList.add("debug-field"); + const label = document.createElement("span"); + label.textContent = param.label; + field.appendChild(label); + + if (param.type === "select") { + const select = document.createElement("select"); + select.dataset.effectIdeaParam = param.key; + (param.options ?? []).forEach((option) => { + const optionEl = document.createElement("option"); + optionEl.value = option.value; + optionEl.textContent = option.label; + select.appendChild(optionEl); + }); + select.value = String(effectIdeaPreviewParams[param.key] ?? ""); + select.addEventListener("change", () => { + effectIdeaPreviewParams[param.key] = select.value; + }); + field.appendChild(select); + effectIdeaBusyControlsGrid.appendChild(field); + return; + } + + const input = document.createElement("input"); + input.dataset.effectIdeaParam = param.key; + if (param.type === "toggle") { + input.type = "checkbox"; + input.checked = Number(effectIdeaPreviewParams[param.key]) !== 0; + input.addEventListener("change", () => { + effectIdeaPreviewParams[param.key] = input.checked ? 1 : 0; + }); + field.appendChild(input); + effectIdeaBusyControlsGrid.appendChild(field); + return; + } + + input.type = "number"; + if (param.min !== undefined) { + input.min = String(param.min); + } + if (param.max !== undefined) { + input.max = String(param.max); + } + if (param.step !== undefined) { + input.step = String(param.step); + } + input.value = String(effectIdeaPreviewParams[param.key] ?? 0); + input.addEventListener("input", () => { + const numeric = Number(input.value); + if (!Number.isFinite(numeric)) { + return; + } + effectIdeaPreviewParams[param.key] = numeric; + }); + field.appendChild(input); + effectIdeaBusyControlsGrid.appendChild(field); + }); +} + function runEffectIdeaSuccessCountdown(onDone: () => void): void { if (!effectIdeaCountdown) { onDone(); @@ -824,6 +909,7 @@ function setEffectIdeaModalVisible(visible: boolean): void { effectIdeaPreviewEffect = null; effectIdeaCarouselEntries = []; effectIdeaCarouselIndex = 0; + renderEffectIdeaBusyControls([]); if (effectIdeaNameInput) { effectIdeaNameInput.value = ""; } diff --git a/src/style.css b/src/style.css index 0f0dd8bb..48144c69 100644 --- a/src/style.css +++ b/src/style.css @@ -1474,6 +1474,30 @@ body, text-align: center; } +.effect-idea-busy-controls { + width: 100%; + display: grid; + gap: 0.35rem; + margin-top: 0.2rem; +} + +.effect-idea-busy-controls-label { + color: rgba(232, 247, 255, 0.86); + font-size: 0.66rem; + letter-spacing: 0.07em; + text-transform: uppercase; +} + +#effect-idea-busy-controls-grid { + grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr)); + gap: 0.4rem; +} + +#effect-idea-busy-controls-grid .debug-field { + background: rgba(4, 10, 18, 0.65); + padding: 0.3rem 0.4rem; +} + #effect-idea-name { border: 1px solid rgba(142, 249, 255, 0.22); background: rgba(6, 12, 20, 0.9); diff --git a/src/style.test.ts b/src/style.test.ts index 03703ca6..61d8b2ed 100644 --- a/src/style.test.ts +++ b/src/style.test.ts @@ -26,9 +26,11 @@ describe("effect idea modal styling", () => { expect(indexHtml).toContain('id="effect-idea-generated-sections" class="hidden"'); expect(indexHtml).toContain('id="effect-idea-busy-modal"'); + expect(indexHtml).toContain('id="effect-idea-busy-controls-grid"'); expect(indexHtml).toContain('id="effect-idea-input" rows="8"'); expect(css).toContain("#effect-idea-input"); expect(css).toContain("min-height: 10.5rem;"); + expect(css).toContain(".effect-idea-busy-controls"); }); });