diff --git a/README.md b/README.md
index 7f547f2..0936160 100644
--- a/README.md
+++ b/README.md
@@ -1,4 +1,4 @@
-# LUMEN — Generative Shader Studio
+# LUMEN, the generative shader studio
**[Try it live →](https://leonxlnx.github.io/lumenshaders/)**
@@ -21,28 +21,34 @@ Requires a browser with WebGL2 (Chrome, Edge, Firefox).
## Controls
-- **Randomize** (or press `R`): new style, palette, form, lighting and seed, tuned per art style so results stay good.
+- **Randomize** (or press `R`): new style, palette, form, lighting and seed, tuned per art style so results stay good. Sometimes it invents a brand-new synth style.
- **Style**: 9 art modes, each with its own renderer. "Keep style" locks the mode while randomizing.
+- **Synth styles**: "New synth style" blends two renderers with a random mix operation (organic mask, screen, multiply, radial, diagonal) into a completely new look, with a blend slider. "Save style" keeps favorites in your browser as one-click chips.
+- **Gradient sets**: the Set button renders 4 to 12 variations of the current design (same style and palette, different seeds) and downloads them as a ZIP of PNGs, ideal for using consistent art across one website.
- **Color**: 16 curated palettes plus a harmonic palette generator, 4 editable colors + background, hue / saturation / exposure.
- **Form**: seed, zoom, fbm detail, domain warp, turbulence, anisotropic stretch.
- **Lighting**: intensity, gloss, light angle, iridescence, glow, contrast.
- **Texture**: film grain, halftone/glyph density, ridge count, chromatic aberration, vignette, softness.
- **Motion**: loop length and travel distance. Loops are seamless by construction (the noise field is sampled along a closed circle).
-- **Share**: every design serializes to a compact code (`LMN1.…`). Copy it, send it to anyone, and pasting it into the Share box recreates the exact design — style, colors, seed, all parameters. "Copy link" produces a URL that loads the design directly.
+- **Share**: every design serializes to a compact code (`LMN1.…`). Copy it, send it to anyone, and pasting it into the Share box recreates the exact design: style, colors, seed, all parameters. "Copy link" produces a URL that loads the design directly.
- `Space` pauses, `S` saves a PNG.
+Full guide: [docs page](https://leonxlnx.github.io/lumenshaders/docs.html).
+
## Export
+Every export button opens a dialog with a live preview and its settings before downloading.
+
- **PNG** up to 3840×2160, rendered offscreen at full quality.
-- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer — no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p–1440p) and exact length (1–8 loops or 5–60 seconds).
+- **Video** (WebM VP9/VP8) encoded offline with **WebCodecs** (`VideoEncoder`) and muxed by a built-in dependency-free WebM writer: no `MediaRecorder`, no realtime capture, no tab crashes. Configurable fps (24/30/60), resolution (720p–1440p) and exact length (1–8 loops or 5–60 seconds).
- **GIF** rendered deterministically frame by frame, encoded in-page with a dependency-free GIF89a encoder (median-cut palette + ordered dithering). Loop forever or play once.
## Files
-- `index.html` / `styles.css` — app shell and design system
-- `js/shaders.js` — the uber fragment shader with all 9 modes
-- `js/engine.js` — WebGL2 engine and render loop
-- `js/gifenc.js` — GIF encoder (quantizer + LZW)
-- `js/webmmux.js` — WebM/Matroska muxer for WebCodecs output
-- `js/exporter.js` — PNG / video / GIF pipelines
-- `js/palettes.js`, `js/ui.js`, `js/main.js` — palettes, control builders, state and randomizer
+- `index.html` / `styles.css`: app shell and design system
+- `js/shaders.js`: the uber fragment shader with all 9 modes
+- `js/engine.js`: WebGL2 engine and render loop
+- `js/gifenc.js`: GIF encoder (quantizer + LZW)
+- `js/webmmux.js`: WebM/Matroska muxer for WebCodecs output
+- `js/exporter.js`: PNG / video / GIF pipelines
+- `js/palettes.js`, `js/ui.js`, `js/main.js`: palettes, control builders, state and randomizer
diff --git a/assets/apple-touch-icon.png b/assets/apple-touch-icon.png
new file mode 100644
index 0000000..c1d0052
Binary files /dev/null and b/assets/apple-touch-icon.png differ
diff --git a/assets/favicon.png b/assets/favicon.png
new file mode 100644
index 0000000..f66bdf1
Binary files /dev/null and b/assets/favicon.png differ
diff --git a/assets/icon-512.png b/assets/icon-512.png
new file mode 100644
index 0000000..dd9265f
Binary files /dev/null and b/assets/icon-512.png differ
diff --git a/assets/og.png b/assets/og.png
new file mode 100644
index 0000000..d1a5a1c
Binary files /dev/null and b/assets/og.png differ
diff --git a/docs.html b/docs.html
new file mode 100644
index 0000000..2911d90
--- /dev/null
+++ b/docs.html
@@ -0,0 +1,174 @@
+
+
+
+
Rendering
-
—
+
...
Cancel
@@ -90,7 +109,9 @@
+
+
diff --git a/js/engine.js b/js/engine.js
index c3f22d6..38bef3d 100644
--- a/js/engine.js
+++ b/js/engine.js
@@ -17,7 +17,8 @@ var Engine = (function () {
"u_scale", "u_complex", "u_warp", "u_flow", "u_stretch",
"u_light", "u_gloss", "u_lightAngle", "u_irid", "u_glow",
"u_grain", "u_cell", "u_lines", "u_ca", "u_vig", "u_soft",
- "u_travel"
+ "u_travel",
+ "u_synth", "u_modeB", "u_mixOp", "u_blend"
];
function compile(type, src) {
@@ -106,6 +107,11 @@ var Engine = (function () {
gl.uniform1f(uniforms.u_soft, P.soft);
gl.uniform1f(uniforms.u_travel, P.travel);
+
+ gl.uniform1i(uniforms.u_synth, P.synthOn ? 1 : 0);
+ gl.uniform1i(uniforms.u_modeB, P.modeB | 0);
+ gl.uniform1i(uniforms.u_mixOp, P.mixOp | 0);
+ gl.uniform1f(uniforms.u_blend, P.blend);
}
function renderAt(phase) {
diff --git a/js/exporter.js b/js/exporter.js
index 63ea107..8046f8a 100644
--- a/js/exporter.js
+++ b/js/exporter.js
@@ -91,7 +91,7 @@ var Exporter = (function () {
async function exportVideo(P, aspect) {
if (busy) return;
if (typeof VideoEncoder === "undefined" || typeof VideoFrame === "undefined") {
- UI.toast("This browser has no WebCodecs support \u2014 use a current Chrome, Edge or Firefox");
+ UI.toast("This browser has no WebCodecs support, use a current Chrome, Edge or Firefox");
return;
}
busy = true;
diff --git a/js/main.js b/js/main.js
index f6e0061..698c34d 100644
--- a/js/main.js
+++ b/js/main.js
@@ -36,6 +36,7 @@ var P = {
light: 1.175, gloss: 44, lightAngle: 235, irid: 0.012, glow: 0.471,
grain: 0.024, cell: 113, lines: 67, ca: 0.018, vig: 0.079, soft: 1.14,
travel: 0.72, loop: 7.5,
+ synthOn: false, modeB: 2, mixOp: 0, blend: 0.6,
lockStyle: false,
imgRes: "2160", vidRes: "1080", vidFps: "30", vidLen: "l2",
gifW: "640", gifFps: 25, gifDither: true, gifLoop: true,
@@ -134,14 +135,65 @@ function randomizeKeys(keys) {
function newSeed() { P.seed = Math.floor(rnd() * 10000); }
+/* pairs that blend into something coherent instead of mud */
+var SYNTH_PAIRS = [
+ [0, 2], [0, 3], [0, 5], [1, 2], [1, 4], [2, 5], [2, 6], [3, 4],
+ [3, 5], [4, 5], [4, 6], [2, 7], [3, 8], [0, 8], [1, 5], [4, 8]
+];
+
+function generateSynthStyle() {
+ var pair = SYNTH_PAIRS[Math.floor(rnd() * SYNTH_PAIRS.length)];
+ var flip = rnd() < 0.5;
+ P.mode = flip ? pair[1] : pair[0];
+ P.modeB = flip ? pair[0] : pair[1];
+ P.mixOp = Math.floor(rnd() * 5);
+ P.blend = 0.4 + rnd() * 0.55;
+ P.synthOn = true;
+}
+
+function synthName() {
+ return "SYN " + MODES[P.mode].name.slice(0, 3).toUpperCase() + "+" +
+ MODES[P.modeB].name.slice(0, 3).toUpperCase();
+}
+
function randomizeAll() {
- if (!P.lockStyle) P.mode = Math.floor(rnd() * MODES.length);
+ if (!P.lockStyle) {
+ if (rnd() < 0.38) {
+ generateSynthStyle();
+ } else {
+ P.synthOn = false;
+ P.mode = Math.floor(rnd() * MODES.length);
+ }
+ }
randomizePalette();
randomizeKeys(FORM_KEYS.concat(LIGHT_KEYS, TEXTURE_KEYS, MOTION_KEYS));
newSeed();
refreshAll();
}
+/* ---------------- saved styles (localStorage) ---------------- */
+
+var STYLES_KEY = "lumen-styles-v1";
+
+function loadSavedStyles() {
+ try { return JSON.parse(localStorage.getItem(STYLES_KEY)) || []; }
+ catch (e) { return []; }
+}
+function persistSavedStyles(list) {
+ try { localStorage.setItem(STYLES_KEY, JSON.stringify(list)); } catch (e) {}
+}
+function saveCurrentStyle() {
+ var list = loadSavedStyles();
+ var base = P.synthOn ? synthName() : MODES[P.mode].name;
+ var name = base + " " + String(Math.round(P.seed)).padStart(4, "0");
+ list.unshift({ name: name, code: encodeDesign(), ts: Date.now() });
+ if (list.length > 24) list.length = 24;
+ persistSavedStyles(list);
+ renderSavedStyles();
+ UI.toast("Style saved: " + name);
+}
+var renderSavedStyles = function () {};
+
/* ---------------- design codes (share) ---------------- */
var SHARE_NUMS = [
@@ -153,10 +205,11 @@ var SHARE_NUMS = [
];
function encodeDesign() {
- var arr = [1, P.mode, Math.round(P.seed),
+ var arr = [2, P.mode, Math.round(P.seed),
P.c1.slice(1), P.c2.slice(1), P.c3.slice(1), P.c4.slice(1), P.bg.slice(1),
P.aspect];
SHARE_NUMS.forEach(function (k) { arr.push(Math.round(P[k] * 1000) / 1000); });
+ arr.push(P.synthOn ? 1 : 0, P.modeB | 0, P.mixOp | 0, Math.round(P.blend * 1000) / 1000);
var b64 = btoa(JSON.stringify(arr))
.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
return "LMN1." + b64;
@@ -169,7 +222,10 @@ function decodeDesign(code) {
var b64 = code.slice(5).replace(/-/g, "+").replace(/_/g, "/");
while (b64.length % 4) b64 += "=";
var arr = JSON.parse(atob(b64));
- if (!Array.isArray(arr) || arr[0] !== 1 || arr.length !== 9 + SHARE_NUMS.length) return false;
+ var baseLen = 9 + SHARE_NUMS.length;
+ var isV1 = Array.isArray(arr) && arr[0] === 1 && arr.length === baseLen;
+ var isV2 = Array.isArray(arr) && arr[0] === 2 && arr.length === baseLen + 4;
+ if (!isV1 && !isV2) return false;
var hexOk = function (h) { return /^[0-9a-fA-F]{6}$/.test(h); };
if (![arr[3], arr[4], arr[5], arr[6], arr[7]].every(hexOk)) return false;
@@ -183,6 +239,15 @@ function decodeDesign(code) {
var v = Number(arr[9 + i]);
if (isFinite(v)) P[k] = v;
});
+ if (isV2) {
+ P.synthOn = !!arr[baseLen];
+ P.modeB = Math.min(Math.max(Math.round(arr[baseLen + 1]) || 0, 0), MODES.length - 1);
+ P.mixOp = Math.min(Math.max(Math.round(arr[baseLen + 2]) || 0, 0), 4);
+ var bl = Number(arr[baseLen + 3]);
+ P.blend = isFinite(bl) ? Math.min(Math.max(bl, 0), 1) : 0.6;
+ } else {
+ P.synthOn = false;
+ }
activePreset = -1;
setPresetActive(-1);
refreshAll();
@@ -226,10 +291,56 @@ function buildRail() {
/* STYLE */
var sStyle = UI.section(rail, "Style", function () {
+ P.synthOn = false;
P.mode = Math.floor(rnd() * MODES.length);
refreshAll();
});
- reg(UI.modeGrid(sStyle, MODES, get("mode"), function (v) { P.mode = v; updateMeta(); }));
+ reg(UI.modeGrid(sStyle, MODES, function () { return P.synthOn ? -1 : P.mode; },
+ function (v) { P.mode = v; P.synthOn = false; refreshAll(); }));
+
+ /* synth: generated styles */
+ var synthRow = UI.el("div", "share-row synth-row", sStyle);
+ var btnSynth = UI.el("button", "mini-btn", synthRow);
+ btnSynth.innerHTML = '
New synth style';
+ btnSynth.addEventListener("click", function () {
+ generateSynthStyle();
+ newSeed();
+ refreshAll();
+ UI.toast("Generated " + synthName());
+ });
+ var btnSave = UI.el("button", "mini-btn", synthRow);
+ btnSave.innerHTML = '
Save style';
+ btnSave.addEventListener("click", saveCurrentStyle);
+
+ var synthCtl = UI.el("div", "synth-ctl", sStyle);
+ reg(UI.slider(synthCtl, { label: "Synth blend", min: 0, max: 1, step: 0.01, fmt: fmt2,
+ get: get("blend"), set: function (v) { P.blend = v; updateMeta(); } }));
+ reg(function () { synthCtl.style.display = P.synthOn ? "" : "none"; });
+
+ var savedWrap = UI.el("div", "saved-styles", sStyle);
+ renderSavedStyles = function () {
+ savedWrap.innerHTML = "";
+ var list = loadSavedStyles();
+ list.forEach(function (st, i) {
+ var chip = UI.el("button", "saved-chip", savedWrap);
+ var label = UI.el("span", null, chip);
+ label.textContent = st.name;
+ var del = UI.el("span", "saved-del", chip);
+ del.innerHTML = "×";
+ del.addEventListener("click", function (ev) {
+ ev.stopPropagation();
+ var l = loadSavedStyles();
+ l.splice(i, 1);
+ persistSavedStyles(l);
+ renderSavedStyles();
+ });
+ chip.addEventListener("click", function () {
+ if (decodeDesign(st.code)) UI.toast("Loaded " + st.name);
+ });
+ });
+ };
+ renderSavedStyles();
+
reg(UI.lockRow(sStyle, { label: "Keep style when randomizing", get: get("lockStyle"), set: set("lockStyle") }));
/* COLOR */
@@ -286,7 +397,7 @@ function buildRail() {
var btnCopyCode = UI.el("button", "mini-btn", shareRow);
btnCopyCode.textContent = "Copy design code";
btnCopyCode.addEventListener("click", function () {
- copyText(encodeDesign(), "Design code copied \u2014 paste it anywhere");
+ copyText(encodeDesign(), "Design code copied, paste it anywhere");
});
var btnCopyLink = UI.el("button", "mini-btn", shareRow);
btnCopyLink.textContent = "Copy link";
@@ -315,36 +426,28 @@ function buildRail() {
e.stopPropagation();
});
- /* EXPORT */
+ /* EXPORT: buttons open a dialog with preview and settings */
var sExp = UI.section(rail, "Export", null);
- reg(UI.selectRow(sExp, { label: "Image size", options: [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]], get: get("imgRes"), set: set("imgRes") }));
- reg(UI.selectRow(sExp, { label: "Video size", options: [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]], get: get("vidRes"), set: set("vidRes") }));
- reg(UI.selectRow(sExp, { label: "Video fps", options: [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]], get: get("vidFps"), set: set("vidFps") }));
- reg(UI.selectRow(sExp, { label: "Video length", options: [
- ["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"],
- ["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"]
- ], get: get("vidLen"), set: set("vidLen") }));
- reg(UI.selectRow(sExp, { label: "GIF width", options: [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]], get: get("gifW"), set: set("gifW") }));
- reg(UI.selectRow(sExp, { label: "GIF fps", options: [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]], get: function () { return String(P.gifFps); }, set: function (v) { P.gifFps = parseInt(v, 10); } }));
- reg(UI.toggleRow(sExp, { label: "GIF dithering", get: get("gifDither"), set: set("gifDither") }));
- reg(UI.toggleRow(sExp, { label: "GIF loop forever", get: get("gifLoop"), set: set("gifLoop") }));
-
var grid = UI.el("div", "export-grid", sExp);
- UI.exportButton(grid, "Save image", "PNG",
+ UI.exportButton(grid, "Image", "PNG",
'
',
- function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
- UI.exportButton(grid, "Record video", "WEBM",
+ function () { Modals.openExport("png"); });
+ UI.exportButton(grid, "Video", "WEBM",
'
',
- function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
- UI.exportButton(grid, "Render loop GIF", "GIF",
+ function () { Modals.openExport("video"); });
+ UI.exportButton(grid, "Looping GIF", "GIF",
'
',
- function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
+ function () { Modals.openExport("gif"); });
+ UI.exportButton(grid, "Gradient set", "ZIP",
+ '
',
+ function () { Modals.openSetGenerator(); });
}
/* ---------------- stage / meta ---------------- */
function updateMeta() {
- document.getElementById("meta-mode").textContent = MODES[P.mode].full;
+ document.getElementById("meta-mode").textContent =
+ P.synthOn ? synthName() : MODES[P.mode].full;
document.getElementById("meta-seed").textContent = "seed " + String(Math.round(P.seed)).padStart(4, "0");
document.getElementById("meta-loop").textContent = P.loop.toFixed(1) + "s loop";
var s = Engine.size();
@@ -414,6 +517,20 @@ document.addEventListener("DOMContentLoaded", function () {
}
}
+ /* intro: elements pop in big and settle into place, one after another */
+ (function intro() {
+ var items = [];
+ document.querySelectorAll(".topbar > *").forEach(function (n) { items.push(n); });
+ items.push(document.querySelector(".canvas-frame"));
+ items.push(document.querySelector(".stage-meta"));
+ document.querySelectorAll(".rail-section").forEach(function (n) { items.push(n); });
+ items.forEach(function (n, i) {
+ if (n) n.style.setProperty("--intro-d", (i * 65) + "ms");
+ });
+ document.body.classList.add("intro");
+ setTimeout(function () { document.body.classList.remove("intro"); }, items.length * 65 + 1100);
+ })();
+
Engine.onFps(function (fps) {
document.getElementById("meta-fps").textContent = fps + " fps";
});
@@ -422,9 +539,10 @@ document.addEventListener("DOMContentLoaded", function () {
document.getElementById("btn-play").addEventListener("click", function () {
setPlayingUI(!Engine.isPlaying());
});
- document.getElementById("btn-export-png").addEventListener("click", function () { Exporter.exportPNG(P, ASPECTS[P.aspect]); });
- document.getElementById("btn-export-video").addEventListener("click", function () { Exporter.exportVideo(P, ASPECTS[P.aspect]); });
- document.getElementById("btn-export-gif").addEventListener("click", function () { Exporter.exportGIF(P, ASPECTS[P.aspect]); });
+ document.getElementById("btn-export-png").addEventListener("click", function () { Modals.openExport("png"); });
+ document.getElementById("btn-export-video").addEventListener("click", function () { Modals.openExport("video"); });
+ document.getElementById("btn-export-gif").addEventListener("click", function () { Modals.openExport("gif"); });
+ document.getElementById("btn-set").addEventListener("click", function () { Modals.openSetGenerator(); });
document.addEventListener("keydown", function (e) {
var tag = (e.target.tagName || "").toLowerCase();
diff --git a/js/modals.js b/js/modals.js
new file mode 100644
index 0000000..dfe8851
--- /dev/null
+++ b/js/modals.js
@@ -0,0 +1,261 @@
+/* Modal system: export dialog with preview and settings, and the set
+ generator that produces N consistent variations of the current design. */
+
+var Modals = (function () {
+
+ function el(tag, cls, parent) {
+ var e = document.createElement(tag);
+ if (cls) e.className = cls;
+ if (parent) parent.appendChild(e);
+ return e;
+ }
+
+ var root = null;
+
+ function open(title, subtitle) {
+ close();
+ root = el("div", "modal-backdrop", document.body);
+ var card = el("div", "modal-card", root);
+ var head = el("div", "modal-head", card);
+ var tWrap = el("div", null, head);
+ var t = el("div", "modal-title", tWrap);
+ t.textContent = title;
+ if (subtitle) {
+ var s = el("div", "modal-sub mono", tWrap);
+ s.textContent = subtitle;
+ }
+ var x = el("button", "modal-close", head);
+ x.innerHTML = '
';
+ x.addEventListener("click", close);
+ root.addEventListener("click", function (e) { if (e.target === root) close(); });
+ var body = el("div", "modal-body", card);
+ return body;
+ }
+
+ function close() {
+ if (root) { root.remove(); root = null; }
+ }
+
+ function snapshotInto(canvas2d, aspect) {
+ var src = Engine.canvas();
+ var ctx = canvas2d.getContext("2d");
+ ctx.fillStyle = "#000";
+ ctx.fillRect(0, 0, canvas2d.width, canvas2d.height);
+ ctx.drawImage(src, 0, 0, canvas2d.width, canvas2d.height);
+ }
+
+ /* ---------- export dialog ---------- */
+
+ function selectField(parent, label, options, value, onChange) {
+ var row = el("div", "field-row", parent);
+ var lab = el("span", "ctl-label", row);
+ lab.textContent = label;
+ var sel = el("select", null, row);
+ options.forEach(function (o) {
+ var op = el("option", null, sel);
+ op.value = o[0]; op.textContent = o[1];
+ });
+ sel.value = value;
+ sel.addEventListener("change", function () { onChange(sel.value); });
+ return sel;
+ }
+
+ function toggleField(parent, label, value, onChange) {
+ var row = el("div", "toggle-row", parent);
+ var lab = el("span", "ctl-label", row);
+ lab.textContent = label;
+ var tg = el("button", "toggle" + (value ? " on" : ""), row);
+ tg.addEventListener("click", function () {
+ var on = !tg.classList.contains("on");
+ tg.classList.toggle("on", on);
+ onChange(on);
+ });
+ }
+
+ function openExport(kind) {
+ var titles = { png: "Export image", video: "Export video", gif: "Export GIF" };
+ var body = open(titles[kind], MODES[P.mode].full + " \u00b7 seed " + Math.round(P.seed));
+
+ /* live preview */
+ var prevWrap = el("div", "modal-preview", body);
+ var pv = el("canvas", null, prevWrap);
+ var ar = ASPECTS[P.aspect];
+ pv.width = 480; pv.height = Math.round(480 / ar);
+ snapshotInto(pv, ar);
+ var pvTimer = setInterval(function () {
+ if (!document.body.contains(pv)) { clearInterval(pvTimer); return; }
+ snapshotInto(pv, ar);
+ }, 120);
+ var pvMeta = el("div", "modal-preview-meta mono", prevWrap);
+
+ var form = el("div", "modal-form", body);
+
+ function metaText() {
+ if (kind === "png") {
+ var h = parseInt(P.imgRes, 10);
+ return Math.round(h * ar) + " \u00d7 " + h + " px";
+ }
+ if (kind === "video") {
+ var vh = parseInt(P.vidRes, 10);
+ var fps = parseInt(P.vidFps, 10);
+ var sec = (String(P.vidLen).charAt(0) === "s")
+ ? parseInt(String(P.vidLen).slice(1), 10)
+ : P.loop * parseInt(String(P.vidLen).slice(1), 10);
+ return Math.round(vh * ar) + " \u00d7 " + vh + " \u00b7 " + fps + " fps \u00b7 " +
+ sec.toFixed(1) + "s \u00b7 " + Math.round(sec * fps) + " frames";
+ }
+ var gw = parseInt(P.gifW, 10);
+ return gw + " \u00d7 " + Math.round(gw / ar) + " \u00b7 " + P.gifFps + " fps \u00b7 " +
+ Math.round(P.loop * P.gifFps) + " frames \u00b7 " + P.loop.toFixed(1) + "s loop";
+ }
+ function refreshMeta() { pvMeta.textContent = metaText(); }
+ refreshMeta();
+
+ if (kind === "png") {
+ selectField(form, "Resolution", [["1080", "1920 \u00d7 1080"], ["1440", "2560 \u00d7 1440"], ["2160", "3840 \u00d7 2160"]],
+ P.imgRes, function (v) { P.imgRes = v; refreshMeta(); });
+ } else if (kind === "video") {
+ selectField(form, "Resolution", [["720", "720p"], ["1080", "1080p"], ["1440", "1440p"]],
+ P.vidRes, function (v) { P.vidRes = v; refreshMeta(); });
+ selectField(form, "Frame rate", [["24", "24 fps"], ["30", "30 fps"], ["60", "60 fps"]],
+ P.vidFps, function (v) { P.vidFps = v; refreshMeta(); });
+ selectField(form, "Length", [
+ ["l1", "1 loop"], ["l2", "2 loops"], ["l3", "3 loops"], ["l4", "4 loops"], ["l6", "6 loops"], ["l8", "8 loops"],
+ ["s5", "5 seconds"], ["s10", "10 seconds"], ["s15", "15 seconds"], ["s30", "30 seconds"], ["s60", "60 seconds"]
+ ], P.vidLen, function (v) { P.vidLen = v; refreshMeta(); });
+ } else {
+ selectField(form, "Width", [["360", "360 px"], ["480", "480 px"], ["640", "640 px"], ["800", "800 px"]],
+ P.gifW, function (v) { P.gifW = v; refreshMeta(); });
+ selectField(form, "Frame rate", [["15", "15 fps"], ["20", "20 fps"], ["25", "25 fps"], ["30", "30 fps"]],
+ String(P.gifFps), function (v) { P.gifFps = parseInt(v, 10); refreshMeta(); });
+ toggleField(form, "Dithering", P.gifDither, function (v) { P.gifDither = v; });
+ toggleField(form, "Loop forever", P.gifLoop, function (v) { P.gifLoop = v; });
+ }
+
+ var actions = el("div", "modal-actions", body);
+ var dl = el("button", "btn btn-primary modal-dl", actions);
+ dl.innerHTML = '
Download ' +
+ (kind === "png" ? "PNG" : kind === "video" ? "WebM" : "GIF");
+ dl.addEventListener("click", function () {
+ close();
+ var a = ASPECTS[P.aspect];
+ if (kind === "png") Exporter.exportPNG(P, a);
+ else if (kind === "video") Exporter.exportVideo(P, a);
+ else Exporter.exportGIF(P, a);
+ });
+ }
+
+ /* ---------- set generator ---------- */
+
+ function setSeeds(base, n) {
+ var seeds = [];
+ for (var i = 0; i < n; i++) seeds.push((base + 73 + i * 911) % 10000);
+ return seeds;
+ }
+
+ async function openSetGenerator() {
+ var body = open("Gradient set", "consistent variations of the current design");
+ var info = el("div", "modal-note", body);
+ info.textContent = "Same style, palette and settings with different seeds. Use a set for hero, cards and section backgrounds that visually belong together.";
+
+ var form = el("div", "modal-form", body);
+ var state = { count: 6, res: "1080" };
+ selectField(form, "Variations", [["4", "4"], ["6", "6"], ["8", "8"], ["12", "12"]],
+ "6", function (v) { state.count = parseInt(v, 10); build(); });
+ selectField(form, "PNG size", [["720", "1280 \u00d7 720"], ["1080", "1920 \u00d7 1080"], ["2160", "3840 \u00d7 2160"]],
+ "1080", function (v) { state.res = v; });
+
+ var grid = el("div", "set-grid", body);
+ var actions = el("div", "modal-actions", body);
+ var dl = el("button", "btn btn-primary modal-dl", actions);
+ dl.innerHTML = '
Download set as ZIP';
+
+ var seeds = [];
+
+ async function build() {
+ grid.innerHTML = "";
+ seeds = setSeeds(Math.round(P.seed), state.count);
+ var ar = ASPECTS[P.aspect];
+
+ var origSeed = P.seed;
+ var prev = Engine.size();
+ var wasPlaying = Engine.isPlaying();
+ Engine.suspend();
+ Engine.setPlaying(false);
+ Engine.setSize(320, 2 * Math.round(320 / ar / 2));
+
+ for (var i = 0; i < seeds.length; i++) {
+ P.seed = seeds[i];
+ Engine.renderAt(0.3);
+ var tile = el("button", "set-tile", grid);
+ var c = el("canvas", null, tile);
+ c.width = 320; c.height = Math.round(320 / ar);
+ c.getContext("2d").drawImage(Engine.canvas(), 0, 0, c.width, c.height);
+ var lab = el("span", "set-tile-label mono", tile);
+ lab.textContent = "#" + String(seeds[i]).padStart(4, "0");
+ (function (sd) {
+ tile.addEventListener("click", function () {
+ P.seed = sd;
+ refreshAll();
+ UI.toast("Applied seed " + sd);
+ close();
+ });
+ })(seeds[i]);
+ await new Promise(function (r) { setTimeout(r, 0); });
+ }
+
+ P.seed = origSeed;
+ Engine.setSize(prev[0], prev[1]);
+ Engine.setPlaying(wasPlaying);
+ Engine.resume();
+ }
+
+ dl.addEventListener("click", async function () {
+ dl.disabled = true;
+ dl.textContent = "Rendering\u2026";
+ var ar = ASPECTS[P.aspect];
+ var h = parseInt(state.res, 10);
+ var w = 2 * Math.round(h * ar / 2);
+
+ var origSeed = P.seed;
+ var prev = Engine.size();
+ var wasPlaying = Engine.isPlaying();
+ Engine.suspend();
+ Engine.setPlaying(false);
+ Engine.setSize(w, h);
+
+ var entries = [];
+ for (var i = 0; i < seeds.length; i++) {
+ P.seed = seeds[i];
+ Engine.renderAt(0.3);
+ var blob = await new Promise(function (res) { Engine.canvas().toBlob(res, "image/png"); });
+ if (blob) {
+ entries.push({
+ name: "lumen-set-" + String(i + 1).padStart(2, "0") + "-seed" + String(seeds[i]).padStart(4, "0") + ".png",
+ data: new Uint8Array(await blob.arrayBuffer())
+ });
+ }
+ dl.textContent = "Rendering " + (i + 1) + "/" + seeds.length + "\u2026";
+ await new Promise(function (r) { setTimeout(r, 0); });
+ }
+
+ P.seed = origSeed;
+ Engine.setSize(prev[0], prev[1]);
+ Engine.setPlaying(wasPlaying);
+ Engine.resume();
+
+ var zip = ZipWriter.build(entries);
+ var a = document.createElement("a");
+ a.href = URL.createObjectURL(zip);
+ a.download = "lumen-set-" + MODES[P.mode].key + ".zip";
+ a.click();
+ setTimeout(function () { URL.revokeObjectURL(a.href); }, 4000);
+ UI.toast("Set saved: " + entries.length + " PNGs (" + w + "\u00d7" + h + ")");
+ close();
+ });
+
+ await build();
+ }
+
+ return { openExport: openExport, openSetGenerator: openSetGenerator, close: close };
+})();
diff --git a/js/shaders.js b/js/shaders.js
index c9ad2e2..21f64cf 100644
--- a/js/shaders.js
+++ b/js/shaders.js
@@ -29,6 +29,12 @@ uniform float u_light, u_gloss, u_lightAngle, u_irid, u_glow;
uniform float u_grain, u_cell, u_lines, u_ca, u_vig, u_soft;
uniform float u_travel;
+/* synth: procedural style combinator */
+uniform int u_synth; // 0 = single mode, 1 = blend two modes
+uniform int u_modeB;
+uniform int u_mixOp; // 0 noise mask, 1 screen, 2 multiply, 3 radial, 4 diagonal
+uniform float u_blend;
+
out vec4 fragColor;
#define TAU 6.28318530718
@@ -404,23 +410,55 @@ vec3 sceneMosaic(vec2 uv){
/* ---------------- dispatch + post ---------------- */
-vec3 scene(vec2 uv){
- if (u_mode == 0) return sceneChrome(uv);
- if (u_mode == 1) return sceneSilk(uv);
- if (u_mode == 2) return sceneBloom(uv);
- if (u_mode == 3) return sceneAura(uv);
- if (u_mode == 4) return sceneRays(uv);
- if (u_mode == 5) return sceneHalftone(uv);
- if (u_mode == 6) return sceneGlyphs(uv);
- if (u_mode == 7) return sceneReeded(uv);
+vec3 sceneFor(int m, vec2 uv){
+ if (m == 0) return sceneChrome(uv);
+ if (m == 1) return sceneSilk(uv);
+ if (m == 2) return sceneBloom(uv);
+ if (m == 3) return sceneAura(uv);
+ if (m == 4) return sceneRays(uv);
+ if (m == 5) return sceneHalftone(uv);
+ if (m == 6) return sceneGlyphs(uv);
+ if (m == 7) return sceneReeded(uv);
return sceneMosaic(uv);
}
+vec3 scene(vec2 uv){
+ vec3 a = sceneFor(u_mode, uv);
+ if (u_synth == 0) return a;
+
+ vec3 b = sceneFor(u_modeB, uv);
+ float asp = u_res.x/u_res.y;
+ vec2 c = (uv - 0.5)*vec2(asp, 1.0);
+
+ if (u_mixOp == 1) { /* screen: layered light */
+ return mix(a, 1.0 - (1.0 - a)*(1.0 - b), u_blend);
+ }
+ if (u_mixOp == 2) { /* multiply with lift */
+ return mix(a, a*b*1.6 + a*0.12, u_blend);
+ }
+ if (u_mixOp == 3) { /* radial: B grows from center */
+ float m = smoothstep(0.15, 0.85, length(c)*1.15);
+ return mix(b, a, mix(1.0, m, u_blend));
+ }
+ if (u_mixOp == 4) { /* soft diagonal split */
+ float ang = TAU*hash11(u_seed*0.091 + 5.0);
+ float m = smoothstep(-0.45, 0.45, cos(ang)*c.x + sin(ang)*c.y);
+ return mix(a, b, m*u_blend);
+ }
+ /* default: organic noise mask */
+ float m = fbm(c*1.6 + SO()*0.7 + LT()*0.5);
+ m = smoothstep(0.32, 0.68, m);
+ return mix(a, b, m*u_blend);
+}
+
void main(){
vec2 uv = gl_FragCoord.xy/u_res;
vec3 col;
- if (u_ca > 0.004){
+ /* CA re-renders the scene per channel; with synth blending that would
+ inline the full mode dispatch 6 times and break the D3D linker, so
+ aberration only applies to single-mode renders */
+ if (u_ca > 0.004 && u_synth == 0){
vec2 off = (uv-0.5)*u_ca*0.016;
col = vec3(scene(uv-off).r, scene(uv).g, scene(uv+off).b);
} else {
diff --git a/js/zip.js b/js/zip.js
new file mode 100644
index 0000000..fca0b7d
--- /dev/null
+++ b/js/zip.js
@@ -0,0 +1,91 @@
+/* Minimal store-only (no compression) ZIP writer for batch PNG export. */
+
+var ZipWriter = (function () {
+
+ var CRC_TABLE = (function () {
+ var t = new Uint32Array(256);
+ for (var n = 0; n < 256; n++) {
+ var c = n;
+ for (var k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
+ t[n] = c >>> 0;
+ }
+ return t;
+ })();
+
+ function crc32(buf) {
+ var c = 0xFFFFFFFF;
+ for (var i = 0; i < buf.length; i++) {
+ c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8);
+ }
+ return (c ^ 0xFFFFFFFF) >>> 0;
+ }
+
+ function dosDateTime() {
+ var d = new Date();
+ var time = (d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1);
+ var date = ((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate();
+ return { time: time, date: date };
+ }
+
+ /* entries: [{ name: string, data: Uint8Array }] -> Blob */
+ function build(entries) {
+ var parts = [];
+ var central = [];
+ var offset = 0;
+ var dt = dosDateTime();
+
+ entries.forEach(function (e) {
+ var nameBytes = new TextEncoder().encode(e.name);
+ var crc = crc32(e.data);
+ var local = new Uint8Array(30 + nameBytes.length);
+ var v = new DataView(local.buffer);
+ v.setUint32(0, 0x04034b50, true);
+ v.setUint16(4, 20, true); // version needed
+ v.setUint16(6, 0x0800, true); // utf8 flag
+ v.setUint16(8, 0, true); // store
+ v.setUint16(10, dt.time, true);
+ v.setUint16(12, dt.date, true);
+ v.setUint32(14, crc, true);
+ v.setUint32(18, e.data.length, true);
+ v.setUint32(22, e.data.length, true);
+ v.setUint16(26, nameBytes.length, true);
+ v.setUint16(28, 0, true);
+ local.set(nameBytes, 30);
+ parts.push(local, e.data);
+
+ var cd = new Uint8Array(46 + nameBytes.length);
+ var c = new DataView(cd.buffer);
+ c.setUint32(0, 0x02014b50, true);
+ c.setUint16(4, 20, true);
+ c.setUint16(6, 20, true);
+ c.setUint16(8, 0x0800, true);
+ c.setUint16(10, 0, true);
+ c.setUint16(12, dt.time, true);
+ c.setUint16(14, dt.date, true);
+ c.setUint32(16, crc, true);
+ c.setUint32(20, e.data.length, true);
+ c.setUint32(24, e.data.length, true);
+ c.setUint16(28, nameBytes.length, true);
+ c.setUint32(42, offset, true);
+ cd.set(nameBytes, 46);
+ central.push(cd);
+
+ offset += local.length + e.data.length;
+ });
+
+ var cdSize = 0;
+ central.forEach(function (c) { cdSize += c.length; });
+
+ var end = new Uint8Array(22);
+ var ev = new DataView(end.buffer);
+ ev.setUint32(0, 0x06054b50, true);
+ ev.setUint16(8, entries.length, true);
+ ev.setUint16(10, entries.length, true);
+ ev.setUint32(12, cdSize, true);
+ ev.setUint32(16, offset, true);
+
+ return new Blob(parts.concat(central, [end]), { type: "application/zip" });
+ }
+
+ return { build: build };
+})();
diff --git a/styles.css b/styles.css
index e7ccc51..3f2c647 100644
--- a/styles.css
+++ b/styles.css
@@ -6,22 +6,24 @@
:root {
--bg: #09090b;
--bg-stage: #0c0c0f;
- --bg-rail: #101013;
- --bg-raised: #16161a;
- --bg-hover: #1c1c21;
- --line: #222228;
- --line-soft: #1a1a1f;
+ --bg-rail: rgba(17, 17, 21, 0.82);
+ --bg-raised: rgba(255, 255, 255, 0.045);
+ --bg-hover: rgba(255, 255, 255, 0.09);
+ --line: rgba(255, 255, 255, 0.10);
+ --line-soft: rgba(255, 255, 255, 0.06);
+ --glass-hi: rgba(255, 255, 255, 0.14);
--text: #ececef;
--text-mid: #9b9ba4;
--text-dim: #5e5e68;
--focus: #d7d7de;
- --radius-s: 6px;
- --radius-m: 9px;
+ --radius-s: 9px;
+ --radius-m: 13px;
+ --radius-l: 18px;
--font-ui: "Inter", -apple-system, "Segoe UI", sans-serif;
--font-display: "Space Grotesk", var(--font-ui);
--font-mono: "IBM Plex Mono", ui-monospace, "Cascadia Mono", monospace;
- --topbar-h: 52px;
- --rail-w: 308px;
+ --topbar-h: 54px;
+ --rail-w: 312px;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
@@ -51,7 +53,9 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
justify-content: space-between;
padding: 0 14px 0 18px;
border-bottom: 1px solid var(--line-soft);
- background: var(--bg);
+ background: rgba(12, 12, 15, 0.75);
+ backdrop-filter: blur(18px) saturate(1.2);
+ -webkit-backdrop-filter: blur(18px) saturate(1.2);
position: relative;
z-index: 5;
}
@@ -82,27 +86,48 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
display: inline-flex;
align-items: center;
gap: 7px;
- height: 32px;
- padding: 0 13px;
+ height: 33px;
+ padding: 0 14px;
border-radius: var(--radius-s);
border: 1px solid var(--line);
- background: var(--bg-raised);
+ background: linear-gradient(180deg, rgba(255,255,255,0.07), rgba(255,255,255,0.025));
+ box-shadow: inset 0 1px 0 var(--glass-hi), 0 1px 3px rgba(0,0,0,0.3);
color: var(--text);
font-size: 12.5px;
font-weight: 500;
- transition: background 130ms ease, border-color 130ms ease, transform 80ms ease;
+ text-decoration: none;
+ transition: background 160ms ease, border-color 160ms ease, transform 140ms cubic-bezier(.34,1.56,.64,1), box-shadow 160ms ease;
white-space: nowrap;
}
-.btn svg { width: 14px; height: 14px; flex: none; }
-.btn:hover { background: var(--bg-hover); border-color: #2e2e36; }
-.btn:active { transform: translateY(1px); }
+.btn svg { width: 14px; height: 14px; flex: none; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
+.btn:hover {
+ background: linear-gradient(180deg, rgba(255,255,255,0.12), rgba(255,255,255,0.05));
+ border-color: rgba(255,255,255,0.20);
+ transform: translateY(-1px);
+ box-shadow: inset 0 1px 0 var(--glass-hi), 0 6px 16px -6px rgba(0,0,0,0.55);
+}
+.btn:hover svg { transform: scale(1.18) rotate(-4deg); }
+.btn:active { transform: translateY(0.5px) scale(0.98); }
.btn-primary {
- background: var(--text);
- border-color: var(--text);
+ background: linear-gradient(180deg, #ffffff, #dfdfe6);
+ border-color: rgba(255,255,255,0.85);
color: #0a0a0c;
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.9), 0 2px 8px -2px rgba(255,255,255,0.18);
}
-.btn-primary:hover { background: #fff; border-color: #fff; }
+.btn-primary:hover {
+ background: linear-gradient(180deg, #ffffff, #f2f2f6);
+ border-color: #fff;
+ box-shadow: inset 0 1px 0 #fff, 0 8px 22px -6px rgba(255,255,255,0.28);
+}
+
+.btn-ghost {
+ background: transparent;
+ border-color: transparent;
+ box-shadow: none;
+ color: var(--text-mid);
+}
+.btn-ghost:hover { color: var(--text); background: var(--bg-hover); border-color: var(--line); }
.icon-btn {
width: 28px; height: 28px;
@@ -165,11 +190,12 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
#view {
max-width: 100%;
max-height: 100%;
- border-radius: 10px;
+ border-radius: var(--radius-l);
box-shadow:
- 0 0 0 1px rgba(255,255,255,0.055),
- 0 24px 70px -18px rgba(0,0,0,0.85),
- 0 6px 22px -8px rgba(0,0,0,0.6);
+ 0 0 0 1px rgba(255,255,255,0.07),
+ inset 0 1px 0 rgba(255,255,255,0.08),
+ 0 30px 80px -20px rgba(0,0,0,0.9),
+ 0 8px 26px -10px rgba(0,0,0,0.65);
background: #000;
}
@@ -190,14 +216,23 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
width: var(--rail-w);
flex: none;
background: var(--bg-rail);
+ backdrop-filter: blur(22px) saturate(1.25);
+ -webkit-backdrop-filter: blur(22px) saturate(1.25);
border-left: 1px solid var(--line-soft);
+ box-shadow: inset 1px 0 0 rgba(255,255,255,0.03);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
- scrollbar-color: #26262d transparent;
+ scrollbar-color: rgba(255,255,255,0.14) transparent;
}
-.rail::-webkit-scrollbar { width: 8px; }
-.rail::-webkit-scrollbar-thumb { background: #26262d; border-radius: 4px; border: 2px solid var(--bg-rail); }
+.rail::-webkit-scrollbar { width: 9px; }
+.rail::-webkit-scrollbar-thumb {
+ background: rgba(255,255,255,0.12);
+ border-radius: 5px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+}
+.rail::-webkit-scrollbar-thumb:hover { background: rgba(255,255,255,0.22); background-clip: content-box; }
.rail-section { border-bottom: 1px solid var(--line-soft); padding: 14px 16px 17px; }
.rail-section:last-child { border-bottom: none; padding-bottom: 28px; }
@@ -242,16 +277,21 @@ button { font: inherit; color: inherit; cursor: pointer; background: none; borde
padding: 9px 2px 8px;
border-radius: var(--radius-s);
border: 1px solid var(--line-soft);
- background: var(--bg-raised);
+ background: linear-gradient(180deg, rgba(255,255,255,0.04), rgba(255,255,255,0.012));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.05);
color: var(--text-dim);
- transition: border-color 130ms ease, color 130ms ease, background 130ms ease;
+ transition: border-color 160ms ease, color 160ms ease, background 160ms ease,
+ transform 180ms cubic-bezier(.34,1.56,.64,1);
}
-.mode-card svg { width: 26px; height: 18px; }
+.mode-card svg { width: 26px; height: 18px; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
.mode-card span { font-size: 10px; font-weight: 500; letter-spacing: 0.02em; }
-.mode-card:hover { color: var(--text-mid); border-color: #2c2c33; }
+.mode-card:hover { color: var(--text-mid); border-color: rgba(255,255,255,0.16); transform: translateY(-1.5px); }
+.mode-card:hover svg { transform: scale(1.15); }
+.mode-card:active { transform: scale(0.96); }
.mode-card.active {
- border-color: #4a4a55;
- background: #1d1d23;
+ border-color: rgba(255,255,255,0.34);
+ background: linear-gradient(180deg, rgba(255,255,255,0.13), rgba(255,255,255,0.05));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.2), 0 4px 14px -6px rgba(0,0,0,0.6);
color: var(--text);
}
@@ -494,18 +534,25 @@ select { cursor: pointer; appearance: none; padding-right: 22px;
align-items: center;
gap: 10px;
width: 100%;
- height: 38px;
- padding: 0 13px;
+ height: 40px;
+ padding: 0 14px;
border-radius: var(--radius-s);
border: 1px solid var(--line);
- background: var(--bg-raised);
+ background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
+ box-shadow: inset 0 1px 0 rgba(255,255,255,0.08);
color: var(--text);
font-size: 12.5px;
font-weight: 500;
- transition: background 130ms ease, border-color 130ms ease;
+ transition: background 160ms ease, border-color 160ms ease,
+ transform 160ms cubic-bezier(.34,1.56,.64,1), box-shadow 160ms ease;
}
-.export-btn svg { width: 14px; height: 14px; color: var(--text-mid); }
-.export-btn:hover { background: var(--bg-hover); border-color: #2e2e36; }
+.export-btn svg { width: 14px; height: 14px; color: var(--text-mid); transition: transform 220ms cubic-bezier(.34,1.56,.64,1), color 160ms ease; }
+.export-btn:hover {
+ background: linear-gradient(180deg, rgba(255,255,255,0.11), rgba(255,255,255,0.04));
+ border-color: rgba(255,255,255,0.2);
+ transform: translateX(2px);
+}
+.export-btn:hover svg { transform: scale(1.2); color: var(--text); }
.export-btn .ext {
margin-left: auto;
font-family: var(--font-mono);
@@ -599,6 +646,192 @@ select { cursor: pointer; appearance: none; padding-right: 22px;
.canvas-frame { padding: 18px; }
}
+/* ---------- synth + saved styles ---------- */
+
+.synth-row { margin-top: 10px; }
+.synth-ctl { margin-top: 8px; }
+.mini-btn svg { width: 12px; height: 12px; margin-right: 2px; transition: transform 220ms cubic-bezier(.34,1.56,.64,1); }
+.mini-btn:hover svg { transform: rotate(8deg) scale(1.15); }
+
+.saved-styles { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 10px; }
+.saved-styles:empty { display: none; }
+.saved-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ height: 25px;
+ padding: 0 5px 0 10px;
+ border-radius: 13px;
+ border: 1px solid var(--line);
+ background: linear-gradient(180deg, rgba(255,255,255,0.06), rgba(255,255,255,0.02));
+ color: var(--text-mid);
+ font-family: var(--font-mono);
+ font-size: 10px;
+ letter-spacing: 0.03em;
+ transition: color 150ms ease, border-color 150ms ease, transform 150ms cubic-bezier(.34,1.56,.64,1);
+}
+.saved-chip:hover { color: var(--text); border-color: rgba(255,255,255,0.22); transform: translateY(-1px); }
+.saved-del {
+ width: 15px; height: 15px;
+ display: inline-flex; align-items: center; justify-content: center;
+ border-radius: 50%;
+ font-size: 11px;
+ color: var(--text-dim);
+ transition: background 130ms ease, color 130ms ease;
+}
+.saved-del:hover { background: rgba(255,80,80,0.18); color: #ff8d8d; }
+
+/* ---------- modals ---------- */
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 40;
+ background: rgba(5,5,8,0.6);
+ backdrop-filter: blur(10px) saturate(1.1);
+ -webkit-backdrop-filter: blur(10px) saturate(1.1);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 24px;
+ animation: backdrop-in 220ms ease;
+}
+@keyframes backdrop-in { from { opacity: 0; } to { opacity: 1; } }
+
+.modal-card {
+ width: min(560px, 100%);
+ max-height: 88vh;
+ overflow-y: auto;
+ background: rgba(20, 20, 25, 0.92);
+ backdrop-filter: blur(26px) saturate(1.3);
+ -webkit-backdrop-filter: blur(26px) saturate(1.3);
+ border: 1px solid rgba(255,255,255,0.12);
+ border-radius: var(--radius-l);
+ box-shadow:
+ inset 0 1px 0 rgba(255,255,255,0.12),
+ 0 40px 110px -24px rgba(0,0,0,0.95);
+ padding: 20px 22px 22px;
+ animation: card-in 320ms cubic-bezier(.22,1.3,.36,1);
+ scrollbar-width: thin;
+ scrollbar-color: rgba(255,255,255,0.14) transparent;
+}
+@keyframes card-in {
+ from { opacity: 0; transform: scale(1.06) translateY(10px); }
+ to { opacity: 1; transform: scale(1) translateY(0); }
+}
+.modal-card::-webkit-scrollbar { width: 9px; }
+.modal-card::-webkit-scrollbar-thumb {
+ background: rgba(255,255,255,0.12);
+ border-radius: 5px;
+ border: 3px solid transparent;
+ background-clip: content-box;
+}
+
+.modal-head {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ margin-bottom: 16px;
+}
+.modal-title {
+ font-family: var(--font-display);
+ font-weight: 600;
+ font-size: 16px;
+ letter-spacing: 0.01em;
+}
+.modal-sub { font-size: 10.5px; color: var(--text-dim); margin-top: 3px; }
+.modal-close {
+ width: 28px; height: 28px;
+ display: inline-flex; align-items: center; justify-content: center;
+ border-radius: 8px;
+ color: var(--text-dim);
+ transition: background 130ms ease, color 130ms ease, transform 160ms ease;
+}
+.modal-close svg { width: 11px; height: 11px; }
+.modal-close:hover { background: var(--bg-hover); color: var(--text); transform: rotate(90deg); }
+
+.modal-preview {
+ position: relative;
+ border-radius: var(--radius-m);
+ overflow: hidden;
+ margin-bottom: 16px;
+ box-shadow: 0 0 0 1px rgba(255,255,255,0.08), 0 12px 30px -12px rgba(0,0,0,0.7);
+}
+.modal-preview canvas { display: block; width: 100%; height: auto; }
+.modal-preview-meta {
+ position: absolute;
+ left: 10px; bottom: 8px;
+ font-size: 10px;
+ color: rgba(255,255,255,0.92);
+ background: rgba(8,8,12,0.55);
+ backdrop-filter: blur(8px);
+ -webkit-backdrop-filter: blur(8px);
+ padding: 4px 9px;
+ border-radius: 7px;
+ letter-spacing: 0.04em;
+}
+
+.modal-form { display: flex; flex-direction: column; gap: 2px; margin-bottom: 14px; }
+.modal-note {
+ font-size: 11.5px;
+ color: var(--text-mid);
+ line-height: 1.55;
+ margin-bottom: 14px;
+}
+.modal-actions { display: flex; justify-content: flex-end; }
+.modal-dl { height: 38px; padding: 0 18px; font-size: 13px; }
+.modal-dl svg { width: 14px; height: 14px; }
+.modal-dl:disabled { opacity: 0.55; pointer-events: none; }
+
+/* ---------- set generator grid ---------- */
+
+.set-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 8px;
+ margin-bottom: 16px;
+}
+.set-tile {
+ position: relative;
+ border-radius: var(--radius-s);
+ overflow: hidden;
+ border: 1px solid rgba(255,255,255,0.1);
+ padding: 0;
+ transition: transform 180ms cubic-bezier(.34,1.56,.64,1), border-color 160ms ease, box-shadow 160ms ease;
+}
+.set-tile canvas { display: block; width: 100%; height: auto; }
+.set-tile:hover {
+ transform: scale(1.045);
+ border-color: rgba(255,255,255,0.45);
+ box-shadow: 0 10px 24px -10px rgba(0,0,0,0.8);
+ z-index: 1;
+}
+.set-tile-label {
+ position: absolute;
+ left: 6px; bottom: 5px;
+ font-size: 9px;
+ color: rgba(255,255,255,0.9);
+ background: rgba(8,8,12,0.55);
+ padding: 2px 6px;
+ border-radius: 5px;
+}
+
+/* ---------- intro stagger animation ---------- */
+
+@keyframes intro-pop {
+ 0% { opacity: 0; transform: scale(1.5) translateY(-6px); filter: blur(5px); }
+ 60% { opacity: 1; filter: blur(0); }
+ 100% { opacity: 1; transform: scale(1) translateY(0); filter: blur(0); }
+}
+.intro .topbar > *,
+.intro .rail-section,
+.intro .stage-meta,
+.intro .canvas-frame {
+ animation: intro-pop 640ms cubic-bezier(.22,1.25,.36,1) both;
+ animation-delay: var(--intro-d, 0ms);
+}
+.intro .canvas-frame { animation-duration: 800ms; }
+
@media (prefers-reduced-motion: reduce) {
* { transition-duration: 0.01ms !important; animation-duration: 0.01ms !important; }
}