From f70ba6bfb1cc67c64a3665614f1f53ac83d3a82f Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Wed, 19 Aug 2026 22:20:27 +0330 Subject: [PATCH 01/15] Spike whether UXP renders sliders and which icon technique survives The v0.16 artist-friendly control work rests on two things this panel has never shipped: a range input, and an icon that is not a bitmap img. UXP has already refused visibility:hidden, flex gap in compact panels, and sticky reflow, so a browser check proves nothing. This renders the candidates in Settings so one Photoshop screenshot answers all four questions at once. Delete as a unit with src/ui/spikeArtistControls.ts once the design lands. Co-Authored-By: Claude Opus 4.6 --- src/styles.css | 111 +++++++++++++++++++++++++++++++ src/ui/App.ts | 4 ++ src/ui/appMarkup.ts | 4 +- src/ui/spikeArtistControls.ts | 120 ++++++++++++++++++++++++++++++++++ 4 files changed, 238 insertions(+), 1 deletion(-) create mode 100644 src/ui/spikeArtistControls.ts diff --git a/src/styles.css b/src/styles.css index 1555004..dc42210 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7902,3 +7902,114 @@ body, .openlayer-preview-panel-import-status.is-error { color: #e06c6c; } + +/* ========================================================================== + SPIKE: artist controls probe (v0.16) + Delete with src/ui/spikeArtistControls.ts once the control design is decided. + Deliberately NOT scoped to .theme-compact -- the point is to see the host's + own rendering, not the panel's existing look. + ========================================================================== */ + +.spike-controls .spike-row { + display: block; + margin-bottom: 14px; +} + +.spike-controls .spike-tag { + display: block; + font-size: var(--fs-micro); + text-transform: uppercase; + letter-spacing: 0.06em; + color: #9a9a9a; + margin-bottom: 6px; +} + +/* B: does appearance:none plus a custom track/thumb take effect at all? */ +.spike-controls .spike-range-styled { + appearance: none; + -webkit-appearance: none; + width: 100%; + height: 4px; + border-radius: 2px; + background: linear-gradient(90deg, #d9a441 0%, #d9a441 50%, #4a4a4a 50%, #4a4a4a 100%); + outline: none; +} + +.spike-controls .spike-range-styled::-webkit-slider-thumb { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + border-radius: 50%; + background: #f0f0f0; + border: 2px solid #d9a441; +} + +.spike-controls .spike-range-styled::-webkit-slider-runnable-track { + height: 4px; + border-radius: 2px; +} + +/* C: do flanking icons sit on the slider's centre line? + Uses margins, not flex gap -- gap is inert in compact UXP panels. */ +.spike-controls .spike-slider-line { + display: flex; + align-items: center; +} + +.spike-controls .spike-slider-line .spike-range-styled { + margin: 0 8px; +} + +.spike-controls .spike-endpoint { + display: inline-flex; + align-items: center; + color: #cfcfcf; + flex: 0 0 auto; +} + +/* D: four icon techniques, same nominal 16px box. */ +.spike-controls .spike-icon-strip { + display: flex; + align-items: flex-start; +} + +.spike-controls .spike-icon-cell { + display: inline-flex; + flex-direction: column; + align-items: center; + color: #cfcfcf; + margin-right: 14px; +} + +.spike-controls .spike-icon-cell em { + font-size: var(--fs-nano); + font-style: normal; + color: #8a8a8a; + margin-top: 4px; +} + +.spike-controls .spike-icon-datauri { + display: block; + width: 16px; + height: 16px; + background-repeat: no-repeat; + background-position: center; + background-image: url('data:image/svg+xml;utf8,'); +} + +.spike-controls .spike-icon-glyph { + display: block; + font-size: 16px; + line-height: 16px; + font-weight: normal; +} + +.spike-controls .spike-icon-css { + display: block; + width: 12px; + height: 12px; + margin: 2px; + border: 1.5px solid #cfcfcf; + border-radius: 3px; +} diff --git a/src/ui/App.ts b/src/ui/App.ts index bc387a0..f49f896 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -167,6 +167,8 @@ import { runModelDownloadSpike, summarizeSpike } from "./spikeModelDownload"; +// SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. +import { wireSpikeArtistControls } from "./spikeArtistControls"; import { createUpscaleResizePlan, formatUpscaleScale, @@ -1268,6 +1270,8 @@ export function renderApp(rootElement: HTMLElement) { applyTheme(elements, preferences.theme || DEFAULT_THEME); fillCheckpointOptions(elements, FALLBACK_CHECKPOINTS, preferences.checkpointName || FALLBACK_CHECKPOINTS[0]); ensureCoreSelectDefaults(elements); + // SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. + wireSpikeArtistControls(rootElement); const actionHandlers: ActionHandlers = { check: createActionRunner(elements, "check", handleCheckComfy), diff --git a/src/ui/appMarkup.ts b/src/ui/appMarkup.ts index 0dd9c4b..077d07d 100644 --- a/src/ui/appMarkup.ts +++ b/src/ui/appMarkup.ts @@ -1,5 +1,7 @@ import { listRunnableWorkflowPresets, listWorkflowPresets } from "../comfy/presetRegistry"; import { NO_LORA_VALUE } from "../comfy/loraCompatibility"; +// SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. +import { SPIKE_ARTIST_CONTROLS_MARKUP } from "./spikeArtistControls"; import { APP_VERSION, DEFAULT_CFG, @@ -480,7 +482,7 @@ export function createAppMarkup() { - +${SPIKE_ARTIST_CONTROLS_MARKUP}
Status report diff --git a/src/ui/spikeArtistControls.ts b/src/ui/spikeArtistControls.ts new file mode 100644 index 0000000..4eb61a3 --- /dev/null +++ b/src/ui/spikeArtistControls.ts @@ -0,0 +1,120 @@ +/** + * SPIKE: does UXP render and style a slider, and which icon technique survives? + * + * Delete this file, its markup block in appMarkup.ts, its CSS block in + * styles.css, and its wiring in App.ts once v0.16.0's control design is decided. + * + * Everything the v0.16 artist-friendly control work rests on is unproven in this + * host: the panel has never shipped a single `` or any icon + * that was not a bitmap ``. UXP has already refused `visibility:hidden`, + * flex `gap` in compact panels, and sticky reflow, so "it works in a browser" is + * not evidence. This renders the candidates side by side in the real panel so + * one screenshot answers all of it. + * + * Questions, in the order they matter: + * A. Does a bare range input appear at all, and can it be dragged? + * B. Does `appearance: none` + custom track/thumb styling take effect? + * C. Do the endpoint icons flanking a slider line up with it? + * D. Which icon technique renders: inline SVG, data-URI SVG, unicode, or CSS? + */ + +export const SPIKE_ARTIST_CONTROLS_MARKUP = ` + +
+
+ Spike: Artist Controls + v0.16 probe +
+ +
+ A. bare range, no CSS + +
+ +
+ B. styled range + +
+ +
+ C. endpoint icons + styled range +
+ + + +
+
+ +
+ D. icon techniques +
+ + + inline svg + + + + data-uri + + + + unicode + + + + css shape + +
+
+ +
Drag a slider to test event delivery.
+
+`; + +/** + * Reports what the host actually did, so a screenshot carries the answer. + * + * Reads back the computed appearance rather than trusting that the CSS applied: + * UXP silently drops declarations it does not implement, so a styled-looking + * rule in the stylesheet proves nothing on its own. + */ +export function wireSpikeArtistControls(root: ParentNode): void { + const readout = root.querySelector("#spike-controls-readout"); + if (!readout) { + return; + } + + const ranges = Array.from( + root.querySelectorAll('input[type="range"]') + ); + + if (ranges.length === 0) { + readout.textContent = + "No range inputs found in the DOM at all -- UXP did not construct them."; + return; + } + + const describeSupport = () => { + const probe = ranges[0]; + // An unsupported input type falls back to "text" in the `type` property on + // hosts that do not implement it, which is the cheapest reliable tell. + const keptType = probe.type === "range"; + return `type kept as range: ${keptType ? "yes" : `NO (reports "${probe.type}")`}`; + }; + + const report = (note: string) => { + const values = ranges.map((range) => `${range.id.replace("spike-range-", "")}=${range.value}`); + readout.textContent = `${describeSupport()} | ${values.join(" ")} | ${note}`; + }; + + for (const range of ranges) { + range.addEventListener("input", () => report("input event fired")); + range.addEventListener("change", () => report("change event fired")); + } + + report("no drag yet"); +} From 090672c6e3d6085d617d31d400e2345c30995d5a Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 20 Aug 2026 01:08:16 +0330 Subject: [PATCH 02/15] Spike round 2: work with UXP's native slider instead of against it Round 1 settled the premise and killed two assumptions. type="range" renders, drags and fires change events, so the slider plan holds. But UXP ignores step -- sliders with the HTML default of 1 returned 40.541, 47.147 and 45.675 -- and appearance:none with ::-webkit-slider-thumb did not take, leaving the bare native slider looking better than the styled one. A data-URI SVG background rendered nothing at all, while inline SVG, unicode and CSS shapes all worked. Round 2 therefore stops replacing the widget and asks whether accent-color can brand it, whether an explicit step quantises, and whether the label-as-prefix row survives a narrow panel. snapToStep is carried by tests rather than the spike, because quantisation is now mandatory in the real controls: without it a steps field submits 20.4. Co-Authored-By: Claude Opus 4.6 --- src/styles.css | 101 ++++--------------- src/ui/spikeArtistControls.ts | 143 +++++++++++++-------------- tests/ui/spikeArtistControls.test.ts | 41 ++++++++ 3 files changed, 131 insertions(+), 154 deletions(-) create mode 100644 tests/ui/spikeArtistControls.test.ts diff --git a/src/styles.css b/src/styles.css index dc42210..5e456ea 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7904,10 +7904,14 @@ body, } /* ========================================================================== - SPIKE: artist controls probe (v0.16) + SPIKE: artist controls probe, round 2 (v0.16) Delete with src/ui/spikeArtistControls.ts once the control design is decided. - Deliberately NOT scoped to .theme-compact -- the point is to see the host's - own rendering, not the panel's existing look. + + Round 1 proved appearance:none + ::-webkit-slider-thumb does NOT take in UXP + -- the gradient track never painted and the thumb collapsed to a notch, so + the bare native slider looked BETTER than the styled one. Round 2 therefore + leaves the native widget alone and only tries accent-color, which is the + one branding hook that does not require replacing the thumb. ========================================================================== */ .spike-controls .spike-row { @@ -7924,92 +7928,25 @@ body, margin-bottom: 6px; } -/* B: does appearance:none plus a custom track/thumb take effect at all? */ -.spike-controls .spike-range-styled { - appearance: none; - -webkit-appearance: none; +.spike-controls input[type="range"] { width: 100%; - height: 4px; - border-radius: 2px; - background: linear-gradient(90deg, #d9a441 0%, #d9a441 50%, #4a4a4a 50%, #4a4a4a 100%); - outline: none; -} - -.spike-controls .spike-range-styled::-webkit-slider-thumb { - appearance: none; - -webkit-appearance: none; - width: 14px; - height: 14px; - border-radius: 50%; - background: #f0f0f0; - border: 2px solid #d9a441; -} - -.spike-controls .spike-range-styled::-webkit-slider-runnable-track { - height: 4px; - border-radius: 2px; -} - -/* C: do flanking icons sit on the slider's centre line? - Uses margins, not flex gap -- gap is inert in compact UXP panels. */ -.spike-controls .spike-slider-line { - display: flex; - align-items: center; -} - -.spike-controls .spike-slider-line .spike-range-styled { - margin: 0 8px; -} - -.spike-controls .spike-endpoint { - display: inline-flex; - align-items: center; - color: #cfcfcf; - flex: 0 0 auto; -} - -/* D: four icon techniques, same nominal 16px box. */ -.spike-controls .spike-icon-strip { - display: flex; - align-items: flex-start; -} - -.spike-controls .spike-icon-cell { - display: inline-flex; - flex-direction: column; - align-items: center; - color: #cfcfcf; - margin-right: 14px; } -.spike-controls .spike-icon-cell em { - font-size: var(--fs-nano); - font-style: normal; - color: #8a8a8a; - margin-top: 4px; +/* F: the cheapest possible branding of a native widget. */ +.spike-controls .spike-accent { + accent-color: #d9a441; } -.spike-controls .spike-icon-datauri { +/* H: Krita AI's row shape -- label and value on one line as a prefix, slider + beneath, so a parameter costs one text row instead of a label row plus a + control row. Margins, not flex gap: gap is inert in compact UXP panels. */ +.spike-controls .spike-prefix-row { display: block; - width: 16px; - height: 16px; - background-repeat: no-repeat; - background-position: center; - background-image: url('data:image/svg+xml;utf8,'); } -.spike-controls .spike-icon-glyph { +.spike-controls .spike-prefix-label { display: block; - font-size: 16px; - line-height: 16px; - font-weight: normal; -} - -.spike-controls .spike-icon-css { - display: block; - width: 12px; - height: 12px; - margin: 2px; - border: 1.5px solid #cfcfcf; - border-radius: 3px; + font-size: var(--fs-xs); + color: #e0e0e0; + margin-bottom: 4px; } diff --git a/src/ui/spikeArtistControls.ts b/src/ui/spikeArtistControls.ts index 4eb61a3..4f0ad31 100644 --- a/src/ui/spikeArtistControls.ts +++ b/src/ui/spikeArtistControls.ts @@ -1,120 +1,119 @@ /** - * SPIKE: does UXP render and style a slider, and which icon technique survives? + * SPIKE round 2: how do we get a usable slider out of UXP's NATIVE range widget? * * Delete this file, its markup block in appMarkup.ts, its CSS block in * styles.css, and its wiring in App.ts once v0.16.0's control design is decided. * - * Everything the v0.16 artist-friendly control work rests on is unproven in this - * host: the panel has never shipped a single `` or any icon - * that was not a bitmap ``. UXP has already refused `visibility:hidden`, - * flex `gap` in compact panels, and sticky reflow, so "it works in a browser" is - * not evidence. This renders the candidates side by side in the real panel so - * one screenshot answers all of it. + * Round 1 settled the premise and killed two assumptions: + * - `type="range"` survives, renders, drags, and fires change events. Good. + * - UXP IGNORES `step`. With no step attribute (HTML default 1) it returned + * 40.541, 47.147, 45.675. Continuous floats. So quantisation is ours to do. + * - `appearance:none` + `::-webkit-slider-thumb` did NOT take. The gradient + * track never painted and the thumb collapsed to a faint notch. The BARE + * native slider looked better than the styled one, so round 2 works with + * the native widget instead of against it. + * - Inline SVG, unicode and CSS shapes all render. A data-URI SVG background + * renders NOTHING -- do not use that technique anywhere in the panel. * - * Questions, in the order they matter: - * A. Does a bare range input appear at all, and can it be dragged? - * B. Does `appearance: none` + custom track/thumb styling take effect? - * C. Do the endpoint icons flanking a slider line up with it? - * D. Which icon technique renders: inline SVG, data-URI SVG, unicode, or CSS? + * Round 2 questions: + * E. Does an EXPLICIT step attribute quantise, or is step ignored outright? + * F. Does `accent-color` brand the native slider? (cheapest possible win) + * G. Can JS snapping produce clean values from a continuous input? + * H. Does the label-as-prefix row survive a narrow panel? (Krita AI's trick) */ export const SPIKE_ARTIST_CONTROLS_MARKUP = `
- Spike: Artist Controls + Spike: Artist Controls 2 v0.16 probe
- A. bare range, no CSS - + E1. explicit step="1" (0-100) +
- B. styled range - + E2. explicit step="0.05" (0-1, denoise shape) +
- C. endpoint icons + styled range -
- - - -
+ F. accent-color on native widget + +
+ +
+ G. JS-snapped to whole steps (4-60) + +
Steps: 20
- D. icon techniques -
- - - inline svg - - - - data-uri - - - - unicode - - - - css shape - + H. label-as-prefix row (Krita AI trick) +
+ Strength (denoise): 60% +
-
Drag a slider to test event delivery.
+
Drag each slider.
`; -/** - * Reports what the host actually did, so a screenshot carries the answer. - * - * Reads back the computed appearance rather than trusting that the CSS applied: - * UXP silently drops declarations it does not implement, so a styled-looking - * rule in the stylesheet proves nothing on its own. - */ +/** Rounds a continuous UXP range value onto the control's declared step grid. */ +export function snapToStep(rawValue: number, min: number, max: number, step: number): number { + if (!Number.isFinite(rawValue) || step <= 0) { + return rawValue; + } + const clamped = Math.min(max, Math.max(min, rawValue)); + const snapped = min + Math.round((clamped - min) / step) * step; + // Re-round to the step's own precision so 0.30000000000000004 does not escape. + const decimals = (String(step).split(".")[1] || "").length; + return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)); +} + export function wireSpikeArtistControls(root: ParentNode): void { const readout = root.querySelector("#spike-controls-readout"); if (!readout) { return; } - const ranges = Array.from( - root.querySelectorAll('input[type="range"]') - ); - + const ranges = Array.from(root.querySelectorAll('input[type="range"]')); if (ranges.length === 0) { - readout.textContent = - "No range inputs found in the DOM at all -- UXP did not construct them."; + readout.textContent = "No range inputs in the DOM at all."; return; } - const describeSupport = () => { - const probe = ranges[0]; - // An unsupported input type falls back to "text" in the `type` property on - // hosts that do not implement it, which is the cheapest reliable tell. - const keptType = probe.type === "range"; - return `type kept as range: ${keptType ? "yes" : `NO (reports "${probe.type}")`}`; - }; + const snappedInput = root.querySelector("#spike-snapped"); + const snappedReadout = root.querySelector("#spike-snapped-readout"); + const prefixInput = root.querySelector("#spike-prefix"); + const prefixReadout = root.querySelector("#spike-prefix-readout"); - const report = (note: string) => { - const values = ranges.map((range) => `${range.id.replace("spike-range-", "")}=${range.value}`); - readout.textContent = `${describeSupport()} | ${values.join(" ")} | ${note}`; + const report = () => { + // Whether an explicit step actually quantised is the whole question, so + // report the raw values verbatim rather than anything already rounded. + const parts = ranges + .filter((range) => range.id === "spike-step-int" || range.id === "spike-step-frac") + .map((range) => `${range.id.replace("spike-step-", "")}=${range.value}`); + readout.textContent = `raw with explicit step: ${parts.join(" ")}`; }; for (const range of ranges) { - range.addEventListener("input", () => report("input event fired")); - range.addEventListener("change", () => report("change event fired")); + range.addEventListener("input", () => { + report(); + if (snappedInput && snappedReadout && range === snappedInput) { + const steps = snapToStep(Number(snappedInput.value), 4, 60, 1); + snappedReadout.textContent = `Steps: ${steps}`; + } + if (prefixInput && prefixReadout && range === prefixInput) { + const pct = snapToStep(Number(prefixInput.value), 0, 100, 1); + prefixReadout.textContent = `Strength (denoise): ${pct === 0 ? "Off" : `${pct}%`}`; + } + }); } - report("no drag yet"); + report(); } diff --git a/tests/ui/spikeArtistControls.test.ts b/tests/ui/spikeArtistControls.test.ts new file mode 100644 index 0000000..ebeb319 --- /dev/null +++ b/tests/ui/spikeArtistControls.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from "vitest"; +import { snapToStep } from "../../src/ui/spikeArtistControls"; + +/** + * SPIKE support, delete with src/ui/spikeArtistControls.ts. + * + * These cases are not hypothetical: UXP ignores a range input's `step` and + * hands back continuous floats. Round 1 of the spike measured 40.541, 47.147 + * and 45.675 from sliders whose step was the HTML default of 1. Quantising is + * therefore ours to do, and submitting 20.4 steps to ComfyUI is the bug this + * prevents. + */ +describe("snapToStep", () => { + it("quantises the raw floats UXP actually returned in the spike", () => { + expect(snapToStep(40.541, 0, 100, 1)).toBe(41); + expect(snapToStep(47.147, 0, 100, 1)).toBe(47); + expect(snapToStep(45.675, 0, 100, 1)).toBe(46); + }); + + it("snaps a denoise-shaped control to its 0.05 grid without float dust", () => { + expect(snapToStep(0.617, 0, 1, 0.05)).toBe(0.6); + expect(snapToStep(0.63, 0, 1, 0.05)).toBe(0.65); + // 0.3 is the classic binary-float trap: 6 * 0.05 is 0.30000000000000004. + expect(snapToStep(0.29, 0, 1, 0.05)).toBe(0.3); + }); + + it("clamps to the declared range rather than trusting the host", () => { + expect(snapToStep(-12, 0, 100, 1)).toBe(0); + expect(snapToStep(240, 0, 100, 1)).toBe(100); + }); + + it("respects a range that does not start at zero", () => { + // Steps sliders start at the preset's floor, e.g. Klein's 4. + expect(snapToStep(4.4, 4, 60, 1)).toBe(4); + expect(snapToStep(19.6, 4, 60, 1)).toBe(20); + }); + + it("leaves a non-finite value alone instead of inventing a number", () => { + expect(snapToStep(Number.NaN, 0, 100, 1)).toBeNaN(); + }); +}); From 40951e777026e3e4a4606fb0940a5a56252baeba Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 20 Aug 2026 01:44:18 +0330 Subject: [PATCH 03/15] Extract CSS colour tokens so a theme is an override, not a fork v0.16 wants a second theme, but there is effectively only one theme to build it on: the stylesheet measured 9 unprefixed base rules against 1,158 .theme-compact rules, with 559 hardcoded hex values against 24 colour tokens. Adding a third theme by hand would mean duplicating those 1,158 rules, which is exactly how Classic v0.4 died. Define 41 semantic tokens on :root and route the stylesheet through them so a theme becomes ~39 token overrides. Every replacement is either an exact match for the value already there, or a near-match within 1-3 units per channel, so this is intended to be visually inert. New names cover the gaps the old set had no word for: text-soft, text-secondary, link, surface-alt, surface-strong, hover, hover-border, accent-border, accent-bright, accent-glow. Coverage goes from near-zero to 50% (328 var() references against 331 remaining literals). The remainder is mostly one-off decorative colour -- per-workflow category swatches and legacy near-greys -- which is a deliberate stopping point: those do not belong to the theme and tokenising them would invent vocabulary we have no use for yet. Token definitions are skipped by the pass, as are hex values inside var() fallbacks, so var(--ol-text, #e6e6e6) keeps its fallback intact. docs/css-audit.md is regenerated by npm run audit-css. Co-Authored-By: Claude Opus 5 --- docs/css-audit.md | 26 +-- src/styles.css | 395 ++++++++++++++++++++++++++-------------------- 2 files changed, 237 insertions(+), 184 deletions(-) diff --git a/docs/css-audit.md b/docs/css-audit.md index 7625a23..37c9759 100644 --- a/docs/css-audit.md +++ b/docs/css-audit.md @@ -8,27 +8,27 @@ the numbers come from parsing the stylesheet and cross-referencing every class n | | | |---|---:| -| Lines | 7451 | -| Rule blocks | 929 | -| Distinct selectors | 678 | +| Lines | 8006 | +| Rule blocks | 985 | +| Distinct selectors | 744 | | Selectors declared more than once | 261 | -| `!important` declarations | 1049 | -| …of those, inside a `theme-compact` rule | 1046 | +| `!important` declarations | 1080 | +| …of those, inside a `theme-compact` rule | 1077 | ## The two themes `theme-compact` and `theme-classic` are both selectable in Settings ("Compact Adobe Dark" and "Classic v0.4"). Compact is the default and the shipped look. Classic is not a dead code path — -it is what the 309 unscoped rules render. +it is what the 355 unscoped rules render. | | rules | lines | |---|---:|---:| -| Scoped to `.theme-compact` | 611 | 5798 | +| Scoped to `.theme-compact` | 621 | 5926 | | Scoped to `.theme-classic` | 9 | 76 | -| Unscoped (base — what Classic renders) | 309 | 2443 | +| Unscoped (base — what Classic renders) | 355 | 2926 | -65.8% of all rules are compact overrides and they carry -99.7% of the `!important` in the file. **137** base selectors have a +63.0% of all rules are compact overrides and they carry +99.7% of the `!important` in the file. **150** base selectors have a `theme-compact` counterpart. That last number is the override tax, and it is the mechanism behind the trap recorded in @@ -64,7 +64,7 @@ concluding a base rule is what renders. ## Unreferenced classes 28 class names appear in the stylesheet and nowhere in `src/` or `scripts/`. Rules that match -only those names span **630 lines across 83 rule blocks** — roughly 8.5% of the file. +only those names span **630 lines across 83 rule blocks** — roughly 7.9% of the file. ### `ol-*` (the pre-v0.5 naming) @@ -81,7 +81,7 @@ only those names span **630 lines across 83 rule blocks** — roughly 8.5% of th `is-${card.state}` (the diagnostics summary cards). A text search calls them unused. They are not. Do not delete these: -`.is-available`, `.is-coming-soon`, `.is-experimental`, `.is-future`, `.is-missing-model`, `.is-missing-node`, `.is-missing-workflow`, `.is-ready`, `.is-setup`, `.is-setup-required` +`.is-available`, `.is-coming-soon`, `.is-experimental`, `.is-future`, `.is-missing-model`, `.is-missing-node`, `.is-missing-workflow`, `.is-ready`, `.is-setup-error`, `.is-setup-required` ## What the numbers say @@ -89,7 +89,7 @@ not. Do not delete these: touched. It still needs a real Photoshop pass, because the check is "no literal mention in the source", and only the host can prove nothing regressed. 2. **Consolidation is a bigger job than it looks, and it is not deletion.** Both themes ship, - so the 137 shadowed selectors cannot simply collapse into one rule; that work is merging + so the 150 shadowed selectors cannot simply collapse into one rule; that work is merging two designs, and it belongs behind a decision about whether Classic still earns its place. 3. **The `!important` count is a symptom, not the disease.** 99.7% of it sits in compact overrides fighting base rules. It shrinks when the theme layering is fixed, not by editing diff --git a/src/styles.css b/src/styles.css index 5e456ea..6af7a32 100644 --- a/src/styles.css +++ b/src/styles.css @@ -1,5 +1,6 @@ :root { color-scheme: dark; + /* Type scale — Photoshop UI default (adobe-clean @ 12px) */ --ol-font: adobe-clean, "Source Sans Pro", "Segoe UI", -apple-system, BlinkMacSystemFont, sans-serif; @@ -12,10 +13,62 @@ --fs-lg: 16px; --fs-xl: 18px; --fs-2xl: 22px; + + /* Surface scale (dark → light) */ + --ol-input-bg: #202020; + --ol-bg: #2b2b2b; + --ol-field-bg: #242424; + --ol-panel-soft: #292929; + --ol-panel: #333333; + --ol-surface: #2f2f2f; + --ol-surface-alt: #303030; + --ol-surface-strong: #333333; + --ol-surface-soft: #343434; + --ol-panel-raised: #383838; + --ol-section-bg: #535353; + + /* Border / line scale */ + --ol-border-soft: #3a3a3a; + --ol-border-subtle: #3d3d3d; + --ol-line-soft: #3f3f3f; + --ol-hover: #424242; + --ol-hover-border: #444444; + --ol-border: #4a4a4a; + --ol-line: #4a4a4a; + + /* Text scale (bright → dim) */ + --ol-text: #eeeeee; + --ol-text-soft: #f0f0f0; + --ol-text-secondary: #e6e6e6; + --ol-link: #d7dde6; + --ol-muted: #bdbdbd; + --ol-muted-soft: #858585; + --ol-dim: #969696; + + /* Accent (gold) */ + --ol-accent: #d7b64b; + --ol-accent-soft: #5b4b18; + --ol-accent-border: #d7b64b; + --ol-accent-bright: #ffd95c; + --ol-accent-glow: #fff4c7; + + /* Status */ + --ol-success: #21c842; + --ol-danger: #de5959; + --ol-warning: #f59e0b; + --ol-blue: #2563eb; + --ol-green: #16873e; + --ol-orange: #d97706; + --ol-orange-hover: #b45309; + + /* Layout */ + --ol-radius: 5px; + --ol-gap: 8px; + font-family: var(--ol-font); font-size: var(--fs-sm); - background: #2b2b2b; - color: #eeeeee; + background: var(--ol-bg); + color: var(--ol-text); font-synthesis: none; text-rendering: optimizeLegibility; } @@ -41,7 +94,7 @@ body { margin: 0; min-width: 0; min-height: 100vh; - background: #2b2b2b; + background: var(--ol-panel); overflow-y: auto; } @@ -59,7 +112,7 @@ a { } a:hover { - color: #d7dde6; + color: var(--ol-link); } .app-shell { @@ -74,7 +127,7 @@ a:hover { overflow-x: hidden; overflow-y: auto; padding: 18px 18px 28px; - background: #2b2b2b; + background: var(--ol-panel); } .app-header { @@ -110,7 +163,7 @@ a:hover { .app-title { margin: 0; overflow: hidden; - color: #f2f2f2; + color: var(--ol-text-soft); font-size: var(--fs-2xl); font-weight: 700; letter-spacing: 0; @@ -218,8 +271,8 @@ a:hover { border: 1px solid #707070; border-radius: 12px; padding: 11px 12px; - background: #454545; - color: #f1f1f1; + background: var(--ol-hover-border); + color: var(--ol-text-soft); text-align: left; cursor: pointer; user-select: none; @@ -231,7 +284,7 @@ a:hover { } .tool-card.is-available:hover { - border-color: #d7b64b; + border-color: var(--ol-accent-border); background: #4d4d4d; } @@ -254,20 +307,20 @@ a:hover { } .tool-card.is-experimental:hover { - border-color: #d7b64b; + border-color: var(--ol-accent-border); background: #4d4d4d; } .tool-card.is-available .icon-glyph { border-color: #f0c83f; background: #5b4b18; - color: #ffd95c; + color: var(--ol-accent-bright); } .tool-card.is-experimental .icon-glyph { border-color: #f0c83f; background: #5b4b18; - color: #ffd95c; + color: var(--ol-accent-bright); } .tool-card.is-coming-soon .icon-glyph { @@ -289,7 +342,7 @@ a:hover { height: 24px; flex: 0 0 auto; margin-right: 8px; - color: #f1f1f1; + color: var(--ol-text-soft); vertical-align: top; } @@ -299,7 +352,7 @@ a:hover { height: 24px; border: 1px solid #8a8a8a; border-radius: 6px; - color: #f0f0f0; + color: var(--ol-text-soft); font-size: var(--fs-micro); font-weight: 800; line-height: 22px; @@ -381,7 +434,7 @@ a:hover { .tool-card[data-tool-id="settings"] .icon-glyph { border-color: #8e8e8e; background: #3b3b3b; - color: #ededed; + color: var(--ol-text-soft); } .tool-card-body { @@ -393,7 +446,7 @@ a:hover { .tool-title { display: block; overflow: hidden; - color: #f3f3f3; + color: var(--ol-text-soft); font-size: var(--fs-md); font-weight: 760; line-height: 1.25; @@ -402,11 +455,11 @@ a:hover { } .tool-card.is-available .tool-title { - color: #fff4c7; + color: var(--ol-accent-glow); } .tool-card.is-experimental .tool-title { - color: #fff4c7; + color: var(--ol-accent-glow); } .tool-subtitle { @@ -429,11 +482,11 @@ a:hover { } .tool-status.available { - color: #ffd95c; + color: var(--ol-accent-bright); } .tool-status.experimental { - color: #ffd95c; + color: var(--ol-accent-bright); } .tool-status.coming-soon { @@ -447,7 +500,7 @@ a:hover { align-items: center; justify-content: space-between; gap: 10px; - border-bottom: 1px solid #424242; + border-bottom: 1px solid var(--ol-hover); padding-bottom: 10px; } @@ -468,7 +521,7 @@ a:hover { border: 1px solid #f0c83f; border-radius: 6px; background: #5b4b18; - color: #ffd95c; + color: var(--ol-accent-bright); font-size: var(--fs-micro); font-weight: 800; line-height: 20px; @@ -477,7 +530,7 @@ a:hover { .screen-title { overflow: hidden; - color: #f3f3f3; + color: var(--ol-text-soft); font-size: var(--fs-md); font-weight: 760; text-overflow: ellipsis; @@ -489,11 +542,11 @@ a:hover { width: auto; align-items: center; gap: 6px; - border: 1px solid #555555; + border: 1px solid var(--ol-section-bg); border-radius: 8px; padding: 6px 9px; - background: #353535; - color: #e6e6e6; + background: var(--ol-surface-soft); + color: var(--ol-text-secondary); cursor: pointer; font-size: var(--fs-sm); font-weight: 700; @@ -505,7 +558,7 @@ a:hover { .back-button:hover { border-color: #707070; - background: #404040; + background: var(--ol-line-soft); } .back-button .icon-glyph { @@ -524,7 +577,7 @@ a:hover { border-radius: 10px; margin: 0 0 20px; padding: 12px; - background: #333333; + background: var(--ol-surface-strong); } .settings-button-stack { @@ -568,7 +621,7 @@ a:hover { .settings-list div { display: block; min-width: 0; - border-bottom: 1px solid #444444; + border-bottom: 1px solid var(--ol-hover-border); padding: 0 0 8px; margin: 0 0 8px; color: #bdbdbd; @@ -586,7 +639,7 @@ a:hover { max-width: 100%; margin-top: 3px; overflow-wrap: anywhere; - color: #e7e7e7; + color: var(--ol-text-secondary); font-weight: 700; line-height: 1.35; text-align: left; @@ -603,10 +656,10 @@ a:hover { .hardware-recommendations, .model-stack-note { - border: 1px solid #494949; + border: 1px solid var(--ol-line); border-radius: 8px; padding: 9px 10px; - background: #2d2d2d; + background: var(--ol-surface-alt); color: #c8c8c8; } @@ -632,7 +685,7 @@ a:hover { border-radius: 8px; margin-bottom: 8px; padding: 9px 10px; - background: #2d2d2d; + background: var(--ol-surface-alt); } .workflow-health-group { @@ -683,7 +736,7 @@ a:hover { display: block; min-width: 0; overflow: hidden; - color: #eeeeee; + color: var(--ol-text); font-size: var(--fs-sm); font-weight: 760; line-height: 1.2; @@ -719,7 +772,7 @@ a:hover { overflow: hidden; border-radius: 4px; padding: 3px 7px; - background: #414141; + background: var(--ol-line-soft); color: #d0d0d0; font-size: var(--fs-micro); font-weight: 700; @@ -810,10 +863,10 @@ a:hover { height: 22px; min-height: 0; margin: 0 4px 4px 0; - border: 1px solid #4a4a4a; + border: 1px solid var(--ol-line); border-radius: 11px; padding: 0 10px; - background: #333333; + background: var(--ol-surface-strong); color: #b4b4b4; font-size: var(--fs-sm); line-height: 20px; @@ -821,8 +874,8 @@ a:hover { .setup-filter-chip.is-active { border-color: #6a6a6a; - background: #4a4a4a; - color: #f0f0f0; + background: var(--ol-line); + color: var(--ol-text-soft); } .setup-row-subtitle { @@ -888,7 +941,7 @@ a:hover { .setup-row-action.is-install { border-color: #d28a25; background: #c97920; - color: #ffffff; + color: var(--ol-text); font-weight: 760; } @@ -932,7 +985,7 @@ a:hover { .setup-install-headline { margin-bottom: 6px; - color: #f1f1f1; + color: var(--ol-text-soft); font-size: var(--fs-sm); font-weight: 700; line-height: 1.3; @@ -1000,7 +1053,7 @@ a:hover { border-radius: 12px; margin-bottom: 18px; padding: 12px; - background: #333333; + background: var(--ol-surface-strong); } .generator-panel { @@ -1015,7 +1068,7 @@ a:hover { } .source-panel { - background: #303030; + background: var(--ol-surface-alt); } .image-to-image-view .generator-panel { @@ -1063,12 +1116,12 @@ a:hover { margin: 0 4px 8px; min-height: 32px; border-color: #6a6a6a; - background: #3d3d3d; + background: var(--ol-border-subtle); font-size: var(--fs-sm); } .source-action-button:hover:not(:disabled):not(.is-disabled) { - border-color: #d7b64b; + border-color: var(--ol-accent-border); background: #484236; } @@ -1081,7 +1134,7 @@ a:hover { border: 1px solid #4f4f4f; border-radius: 10px; padding: 8px; - background: #292929; + background: var(--ol-panel-soft); } .source-thumb-frame { @@ -1093,12 +1146,12 @@ a:hover { align-items: center; justify-content: center; overflow: hidden; - border: 1px solid #555555; + border: 1px solid var(--ol-section-bg); border-radius: 8px; background: linear-gradient(45deg, rgba(255, 255, 255, 0.04) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.04) 25%, transparent 25%), - #242424; + var(--ol-bg); background-size: 14px 14px; } @@ -1124,7 +1177,7 @@ a:hover { .source-title { display: block; overflow: hidden; - color: #f0f0f0; + color: var(--ol-text-soft); font-size: var(--fs-sm); font-weight: 760; line-height: 1.25; @@ -1336,7 +1389,7 @@ a:hover { border-radius: 8px; padding: 8px 9px; margin: 0 4px 8px; - background: #2d2d2d; + background: var(--ol-surface-alt); } .diagnostic-summary-card span { @@ -1352,7 +1405,7 @@ a:hover { .diagnostic-summary-card strong { display: block; margin-top: 3px; - color: #f0f0f0; + color: var(--ol-text-soft); font-size: var(--fs-xl); line-height: 1; } @@ -1374,7 +1427,7 @@ a:hover { } .diagnostic-muted { - color: #9a9a9a; + color: var(--ol-muted); } .diagnostic-detail-toggle { @@ -1429,8 +1482,8 @@ a:hover { border: 1px solid #5a5a5a; border-radius: 8px; outline: none; - background: #242424; - color: #f1f1f1; + background: var(--ol-bg); + color: var(--ol-text-soft); transition: border-color 140ms ease, background 140ms ease, @@ -1467,7 +1520,7 @@ a:hover { border-style: dashed; border-color: #5a5a5a; background: #1f1f1f; - color: #9a9a9a; + color: var(--ol-muted); cursor: not-allowed; } @@ -1475,7 +1528,7 @@ a:hover { .select:focus, .textarea:focus { border-color: #7d9ed8; - background: #202020; + background: var(--ol-input-bg); box-shadow: 0 0 0 2px rgba(125, 158, 216, 0.2); } @@ -1488,7 +1541,7 @@ a:hover { border-radius: 8px; padding: 7px 12px; background: #3a3a3a; - color: #f1f1f1; + color: var(--ol-text-soft); cursor: pointer; font-weight: 650; text-align: center; @@ -1502,7 +1555,7 @@ a:hover { .button:hover:not(:disabled):not(.is-disabled) { border-color: #727272; - background: #444444; + background: var(--ol-hover-border); } .button:active:not(:disabled):not(.is-disabled) { @@ -1512,8 +1565,8 @@ a:hover { .button:disabled, .button.is-disabled { cursor: not-allowed; - border-color: #444444; - background: #303030; + border-color: var(--ol-hover-border); + background: var(--ol-surface-alt); color: #8c8c8c; opacity: 1; } @@ -1531,7 +1584,7 @@ a:hover { min-height: 38px; border-color: #d28a25; background: #c97920; - color: #ffffff; + color: var(--ol-text); font-weight: 760; } @@ -1549,7 +1602,7 @@ a:hover { flex: 1 1 190px; border-color: #d28a25; background: #c97920; - color: #ffffff; + color: var(--ol-text); font-weight: 760; } @@ -1561,7 +1614,7 @@ a:hover { .button-import-blue { border-color: #5e8fd6; background: #3f73b8; - color: #ffffff; + color: var(--ol-text); } .button-import-blue:hover:not(:disabled):not(.is-disabled) { @@ -1576,8 +1629,8 @@ a:hover { .button-generate.is-disabled, .button-import.is-disabled { cursor: not-allowed; - border-color: #444444; - background: #303030; + border-color: var(--ol-hover-border); + background: var(--ol-surface-alt); color: #8c8c8c; opacity: 1; } @@ -1612,7 +1665,7 @@ a:hover { .disclosure-button.is-active { border-color: #8c7840; background: #413a2a; - color: #fff4c7; + color: var(--ol-accent-glow); } .experimental-toggle { @@ -1625,7 +1678,7 @@ a:hover { } .experimental-toggle.is-active { - border-color: #d7b64b; + border-color: var(--ol-accent-border); background: #4b3f1e; color: #ffe087; } @@ -1682,14 +1735,14 @@ a:hover { border: 1px solid #565656; border-radius: 8px; padding: 8px 10px; - background: #2d2d2d; + background: var(--ol-surface-alt); } .status-text { min-width: 0; margin-right: 12px; overflow: hidden; - color: #e4e4e4; + color: var(--ol-text-secondary); font-size: var(--fs-md); line-height: 1.35; overflow-wrap: anywhere; @@ -1699,7 +1752,7 @@ a:hover { flex: 0 0 auto; border-radius: 999px; padding: 4px 8px; - background: #414141; + background: var(--ol-line-soft); color: #c7c7c7; font-size: var(--fs-sm); font-weight: 700; @@ -1731,7 +1784,7 @@ a:hover { } .diagnostics-line { - color: #9a9a9a; + color: var(--ol-muted); font-size: var(--fs-sm); line-height: 1.35; } @@ -1772,7 +1825,7 @@ a:hover { background: linear-gradient(45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), linear-gradient(-45deg, rgba(255, 255, 255, 0.035) 25%, transparent 25%), - #303030; + var(--ol-surface-alt); background-size: 20px 20px; } @@ -1809,7 +1862,7 @@ a:hover { .source-meta { flex: 0 0 auto; overflow: hidden; - border-top: 1px solid #454545; + border-top: 1px solid var(--ol-hover-border); padding: 7px 9px; color: #bfbfbf; font-size: var(--fs-sm); @@ -1919,7 +1972,7 @@ a:hover { width: 100%; border-color: #676767; background: #3a3a3a; - color: #e6e6e6; + color: var(--ol-text-secondary); font-size: var(--fs-sm); } @@ -1938,13 +1991,13 @@ a:hover { .auto-import-toggle.is-active { border-color: #f0c83f; background: #5b4b18; - color: #ffd95c; + color: var(--ol-accent-bright); } .auto-import-toggle.is-disabled { cursor: not-allowed; - border-color: #444444; - background: #303030; + border-color: var(--ol-hover-border); + background: var(--ol-surface-alt); color: #8c8c8c; } @@ -1956,14 +2009,14 @@ a:hover { border-radius: 10px; padding: 8px 11px; background: #3b3b3b; - color: #f1f1f1; + color: var(--ol-text-soft); cursor: pointer; user-select: none; } .settings-shortcut:hover { - border-color: #d7b64b; - background: #454545; + border-color: var(--ol-accent-border); + background: var(--ol-hover-border); } .settings-shortcut:active { @@ -1972,7 +2025,7 @@ a:hover { .shortcut-label { display: block; - color: #fff4c7; + color: var(--ol-accent-glow); font-size: var(--fs-md); font-weight: 760; line-height: 1.18; @@ -2008,7 +2061,7 @@ a:hover { border: 1px solid #5c5c5c; border-radius: 10px; padding: 9px; - background: #3d3d3d; + background: var(--ol-border-subtle); } .history-thumb { @@ -2030,7 +2083,7 @@ a:hover { .history-title { overflow: hidden; - color: #f2f2f2; + color: var(--ol-text-soft); font-size: var(--fs-sm); font-weight: 760; line-height: 1.25; @@ -2071,7 +2124,7 @@ a:hover { border: 1px solid #505050; border-radius: 10px; padding: 18px 12px; - background: #303030; + background: var(--ol-surface-alt); color: #a8a8a8; font-size: var(--fs-sm); text-align: center; @@ -2084,7 +2137,7 @@ a:hover { gap: 0; justify-content: flex-end; margin-top: auto; - border-top: 1px solid #424242; + border-top: 1px solid var(--ol-hover); padding-top: 6px; color: #8f8f8f; font-size: var(--fs-sm); @@ -2109,7 +2162,7 @@ a:hover { } .footer-link:hover { - color: #d7dde6; + color: var(--ol-link); } @media (max-width: 520px) { @@ -2433,7 +2486,7 @@ a:hover { .app-shell.theme-compact .tool-card.is-available:hover, .app-shell.theme-compact .tool-card.is-experimental:hover { - background: #383838; + background: var(--ol-panel-raised); } .app-shell.theme-compact .tool-card.is-coming-soon { @@ -2594,7 +2647,7 @@ a:hover { .app-shell.theme-compact .section-heading .label, .app-shell.theme-compact .label { - color: #e7e7e7; + color: var(--ol-text-secondary); font-size: var(--fs-sm); } @@ -2779,7 +2832,7 @@ a:hover { border: 1px solid #464646; border-radius: 5px; padding: 7px; - background: #303030; + background: var(--ol-surface-alt); } .app-shell.theme-compact .home-view { @@ -2797,7 +2850,7 @@ a:hover { width: 100%; min-width: 0; margin: 0 0 7px; - border-bottom: 1px solid #3f3f3f; + border-bottom: 1px solid var(--ol-line-soft); } .app-shell.theme-compact .home-section:last-child { @@ -2873,7 +2926,7 @@ a:hover { .app-shell.theme-compact .tool-card.is-available:hover, .app-shell.theme-compact .tool-card.is-experimental:hover { - background: #373737; + background: var(--ol-panel-raised); } .app-shell.theme-compact .ol-row-icon, @@ -3345,11 +3398,11 @@ a:hover { flex: 0 0 auto; flex-direction: column; gap: 6px; - border: 1px solid #424242; + border: 1px solid var(--ol-hover); border-radius: 5px; margin: 0 0 7px !important; padding: 6px; - background: #303030; + background: var(--ol-surface-alt); } .app-shell.theme-compact .panel-section > *, @@ -3721,7 +3774,7 @@ a:hover { .app-shell.theme-compact .tool-card.is-coming-soon .tool-title, .app-shell.theme-compact .tool-card.is-coming-soon .tool-subtitle, .app-shell.theme-compact .tool-card.is-coming-soon .icon-svg { - color: #9a9a9a !important; + color: var(--ol-muted) !important; } .app-shell.theme-compact .tool-card.is-coming-soon:hover { @@ -3969,7 +4022,7 @@ a:hover { border-radius: 3px; margin: 0; padding: 0; - background: #343434; + background: var(--ol-surface-soft); color: #bdbdbd; font-size: var(--fs-xs); line-height: 1; @@ -3978,8 +4031,8 @@ a:hover { .app-shell.theme-compact .info-toggle:hover, .app-shell.theme-compact .info-toggle.is-active { border-color: #7a7a7a; - background: #3d3d3d; - color: #f0f0f0; + background: var(--ol-border-subtle); + color: var(--ol-text-soft); } .app-shell.theme-compact .info-panel[hidden] { @@ -3989,11 +4042,11 @@ a:hover { .app-shell.theme-compact .info-panel, .app-shell.theme-compact .compatibility-note.info-panel { max-height: 74px; - border: 1px solid #4b4b4b; + border: 1px solid var(--ol-line); border-radius: 4px; margin: 1px 0 0 !important; padding: 5px 6px; - background: #2b2b2b; + background: var(--ol-panel); color: #cfcfcf; font-size: var(--fs-xs); line-height: 1.3; @@ -4153,8 +4206,8 @@ a:hover { min-width: 0; border-radius: 3px; padding: 3px 7px !important; - color: #f0f0f0 !important; - background-color: #242424; + color: var(--ol-text-soft) !important; + background-color: var(--ol-bg); font-family: var(--ol-font) !important; font-size: var(--fs-sm) !important; font-weight: 500; @@ -4165,12 +4218,12 @@ a:hover { overflow: hidden; text-overflow: ellipsis; opacity: 1 !important; - -webkit-text-fill-color: #f0f0f0; + -webkit-text-fill-color: var(--ol-text-soft); } .app-shell.theme-compact .select option { - color: #f0f0f0; - background: #242424; + color: var(--ol-text-soft); + background: var(--ol-bg); } .app-shell.theme-compact .input, @@ -4201,7 +4254,7 @@ a:hover { border: 1px solid var(--ol-line); border-radius: 3px; padding: 0; - background: #343434; + background: var(--ol-surface-soft); color: #dcdcdc; font-size: var(--fs-sm) !important; font-weight: 700; @@ -4220,8 +4273,8 @@ a:hover { .app-shell.theme-compact .info-toggle:hover, .app-shell.theme-compact .info-toggle.is-active { border-color: #7a7a7a; - background: #3d3d3d; - color: #ffffff; + background: var(--ol-border-subtle); + color: var(--ol-text); } .app-shell.theme-compact .field-label-row { @@ -4297,7 +4350,7 @@ a:hover { min-height: 54px; padding: 7px 8px 8px; border-bottom: 1px solid var(--ol-border-soft); - background: #2b2b2b; + background: var(--ol-panel); } .app-shell.theme-compact .brand-lockup { @@ -4316,7 +4369,7 @@ a:hover { .app-shell.theme-compact .app-title { margin: 0; - color: #f0f0f0; + color: var(--ol-text-soft); font-size: var(--fs-lg); font-weight: 760; letter-spacing: 0; @@ -4396,7 +4449,7 @@ a:hover { border: 1px solid #565656; border-radius: var(--ol-radius); padding: 3px 9px; - background: #333333; + background: var(--ol-surface-strong); color: #eeeeee; font-size: var(--fs-sm); font-weight: 650; @@ -4417,7 +4470,7 @@ a:hover { min-width: 0; max-width: 58%; gap: 7px; - color: #f0f0f0; + color: var(--ol-text-soft); } .app-shell.theme-compact .screen-kicker, @@ -4442,7 +4495,7 @@ a:hover { .app-shell.theme-compact .screen-title { min-width: 0; overflow: hidden; - color: #f3f3f3; + color: var(--ol-text-soft); font-size: var(--fs-md); font-weight: 760; line-height: 18px; @@ -4638,7 +4691,7 @@ a:hover { .app-shell.theme-compact #capture-prompt-layer-source { border-color: var(--ol-blue-border); background: linear-gradient(#3377c3, var(--ol-blue)); - color: #ffffff; + color: var(--ol-text); } .app-shell.theme-compact #capture-canvas-source, @@ -4649,7 +4702,7 @@ a:hover { .app-shell.theme-compact #capture-prompt-canvas-source { border-color: var(--ol-green-border); background: linear-gradient(#278346, var(--ol-green)); - color: #ffffff; + color: var(--ol-text); } .app-shell.theme-compact .source-card { @@ -4670,7 +4723,7 @@ a:hover { flex: 0 0 44px; border: 1px solid #575757; border-radius: 4px; - background: #202020; + background: var(--ol-input-bg); } .app-shell.theme-compact .source-thumb-frame img { @@ -4769,7 +4822,7 @@ a:hover { border: 1px solid #565656; border-radius: var(--ol-radius); padding: 5px 10px; - background: #393939; + background: var(--ol-panel-raised); color: #eeeeee; font-family: var(--ol-font); font-size: var(--fs-sm); @@ -4780,7 +4833,7 @@ a:hover { .app-shell.theme-compact .button:hover:not(:disabled):not(.is-disabled) { border-color: #6c6c6c; - background: #414141; + background: var(--ol-line-soft); } .app-shell.theme-compact .button-primary, @@ -4794,7 +4847,7 @@ a:hover { min-height: 34px; border-color: var(--ol-orange-border); background: linear-gradient(#e18d24, var(--ol-orange)); - color: #ffffff; + color: var(--ol-text); font-size: var(--fs-sm); font-weight: 780; } @@ -4806,8 +4859,8 @@ a:hover { .app-shell.theme-compact .button-generate.is-disabled, .app-shell.theme-compact .button-import.is-disabled { - border-color: #454545; - background: #303030; + border-color: var(--ol-hover-border); + background: var(--ol-surface-alt); color: #8d8d8d; } @@ -4849,7 +4902,7 @@ a:hover { min-height: 18px; border-color: #5a5a5a; border-radius: 50%; - background: #383838; + background: var(--ol-panel-raised); color: #dcdcdc; } @@ -4921,7 +4974,7 @@ a:hover { .app-shell.theme-compact .status-text { min-width: 0; overflow: hidden; - color: #efefef; + color: var(--ol-text-soft); font-size: var(--fs-sm); line-height: 16px; text-overflow: ellipsis; @@ -4934,7 +4987,7 @@ a:hover { border: 0; border-radius: 999px; padding: 3px 9px; - background: #444444; + background: var(--ol-hover-border); color: #d0d0d0; font-size: var(--fs-sm); font-weight: 750; @@ -4991,7 +5044,7 @@ a:hover { .app-shell.theme-compact .button-import-blue { border-color: var(--ol-blue-border); background: linear-gradient(#3d7fc8, var(--ol-blue)); - color: #ffffff; + color: var(--ol-text); } .app-shell.theme-compact .auto-import-toggle.is-active { @@ -5018,7 +5071,7 @@ a:hover { margin-top: auto; border-top: 1px solid var(--ol-border-soft); padding: 8px 10px 10px; - background: #292929; + background: var(--ol-panel-soft); color: var(--ol-muted); font-size: var(--fs-sm); line-height: 15px; @@ -5042,7 +5095,7 @@ a:hover { border-radius: var(--ol-radius); padding: 3px 5px; background: transparent; - color: #f0f0f0; + color: var(--ol-text-soft); font-size: var(--fs-sm); font-weight: 800; text-decoration: none; @@ -5050,8 +5103,8 @@ a:hover { } .app-shell.theme-compact .footer-link:hover { - background: #353535; - color: #ffffff; + background: var(--ol-surface-soft); + color: var(--ol-text); } @media (max-width: 380px) { @@ -5557,7 +5610,7 @@ a:hover { .app-shell.theme-compact #capture-prompt-layer-source { border-color: #467fc3; background: linear-gradient(#3478c2, #2d68a8); - color: #ffffff; + color: var(--ol-text); } .app-shell.theme-compact #capture-canvas-source, @@ -5568,7 +5621,7 @@ a:hover { .app-shell.theme-compact #capture-prompt-canvas-source { border-color: #2f8a4b; background: linear-gradient(#278243, #216d39); - color: #ffffff; + color: var(--ol-text); } .app-shell.theme-compact .field-label-row { @@ -5602,7 +5655,7 @@ a:hover { margin: 0; padding: 0 !important; background: #363636; - color: #e6e6e6; + color: var(--ol-text-secondary); } .app-shell.theme-compact .info-toggle-glyph { @@ -6185,7 +6238,7 @@ a:hover { padding: 4px 8px !important; border: 1px solid var(--ol-border) !important; border-radius: 5px !important; - background: #303030 !important; + background: var(--ol-surface-alt) !important; color: var(--ol-text) !important; font-size: var(--fs-sm) !important; line-height: 16px !important; @@ -6346,7 +6399,7 @@ a:hover { padding: 3px 6px !important; overflow: visible !important; border-radius: 5px !important; - color: #fff !important; + color: var(--ol-text) !important; font-size: var(--fs-sm) !important; font-weight: 760 !important; line-height: 13px !important; @@ -6476,12 +6529,12 @@ a:hover { border: 1px solid #666 !important; border-radius: 50% !important; padding: 0 !important; - background: #353535 !important; + background: var(--ol-surface-soft) !important; } .app-shell.theme-compact .info-toggle-glyph { display: block !important; - color: #e4e4e4 !important; + color: var(--ol-text-secondary) !important; font-size: var(--fs-sm) !important; font-weight: 800 !important; line-height: 16px !important; @@ -6646,7 +6699,7 @@ a:hover { border-radius: 6px !important; padding: 5px 8px !important; background: var(--ol-orange) !important; - color: #fff !important; + color: var(--ol-text) !important; font-size: var(--fs-sm) !important; font-weight: 760 !important; line-height: 17px !important; @@ -6753,7 +6806,7 @@ a:hover { .app-shell.theme-compact .footer-link { flex: 0 0 auto !important; margin-left: auto !important; - color: #ffffff !important; + color: var(--ol-text) !important; font-size: var(--fs-sm) !important; font-weight: 760 !important; } @@ -6983,7 +7036,7 @@ body, .app-shell.theme-compact .preview-panel, .app-shell.theme-compact .source-card, .app-shell.theme-compact .status-bar { - background: #535353 !important; + background: var(--ol-section-bg) !important; } .app-shell.theme-compact .generator-panel, @@ -7028,7 +7081,7 @@ body, width: 42% !important; height: 100% !important; border-radius: inherit !important; - background: linear-gradient(90deg, #1c7ed6, #f59e0b, #22c55e) !important; + background: linear-gradient(90deg, #1c7ed6, var(--ol-warning), #22c55e) !important; } .app-shell.theme-compact .status-progress.is-active > span { @@ -7045,19 +7098,19 @@ body, } /* v0.5.3 color placement and motion fixes: - #535353 belongs to the outer Photoshop panel shell, not inside sections. */ + var(--ol-section-bg) belongs to the outer Photoshop panel shell, not inside sections. */ html, body, #root, .app-shell.theme-compact { - background: #535353 !important; + background: var(--ol-section-bg) !important; } .app-shell.theme-compact .app-header, .app-shell.theme-compact .brand-icon, .app-shell.theme-compact .app-footer, .app-shell.theme-compact .ol-footer { - background: #535353 !important; + background: var(--ol-section-bg) !important; } .app-shell.theme-compact .home-tool-section, @@ -7203,7 +7256,7 @@ body, .app-shell.theme-compact .ol-row-desc { font-size: var(--fs-micro); line-height: 1.15; - color: #9a9a9a; + color: var(--ol-muted); } .app-shell.theme-compact .home-status-row { @@ -7246,12 +7299,12 @@ body, /* #2: dashboard blends into the panel shell color. */ .app-shell.theme-compact .home-view { - background: #535353; + background: var(--ol-section-bg); } /* #1: pinned screen header uses the shell color so it blends. */ .app-shell.theme-compact .screen-nav { - background: #535353; + background: var(--ol-section-bg); } /* #11: the Workflow group header is a collapsible dropdown again. */ @@ -7313,10 +7366,10 @@ body, height: 22px !important; min-height: 0 !important; margin: 0 4px 4px 0 !important; - border: 1px solid #4a4a4a !important; + border: 1px solid var(--ol-line) !important; border-radius: 11px !important; padding: 0 10px !important; - background: #333333 !important; + background: var(--ol-surface-strong) !important; color: #b4b4b4 !important; font-size: var(--fs-sm) !important; font-weight: 600 !important; @@ -7325,8 +7378,8 @@ body, .app-shell.theme-compact button.setup-filter-chip[aria-pressed="true"] { border-color: #6a6a6a !important; - background: #4a4a4a !important; - color: #f0f0f0 !important; + background: var(--ol-line) !important; + color: var(--ol-text-soft) !important; } /* #4: brief green pulse behind import-success text. */ @@ -7377,7 +7430,7 @@ body, z-index: 8; margin: 0 -10px 8px -8px; padding: 8px 10px 0 8px; - background: #535353; + background: var(--ol-section-bg); } .app-shell.theme-compact .screen-head .screen-nav { @@ -7560,7 +7613,7 @@ body, .app-shell.theme-compact .setup-row-action.is-install { border-color: #d28a25 !important; background: #c97920 !important; - color: #ffffff !important; + color: var(--ol-text) !important; font-weight: 760 !important; } @@ -7713,7 +7766,7 @@ body, flex-direction: column; gap: 8px; padding: 10px; - background: #2b2b2b; + background: var(--ol-panel); color: #eeeeee; font-family: var(--ol-font); font-size: var(--fs-sm); @@ -7730,7 +7783,7 @@ body, .openlayer-preview-panel-badge { overflow: hidden; - color: #9a9a9a; + color: var(--ol-muted); font-size: var(--fs-sm); letter-spacing: 0.02em; text-overflow: ellipsis; @@ -7749,11 +7802,11 @@ body, max-width: 132px; flex: 0 1 auto; padding: 2px 6px; - border: 1px solid #4a4a4a; + border: 1px solid var(--ol-line); border-radius: 3px; margin-left: auto; - background: #383838; - color: #d7dde6; + background: var(--ol-panel-raised); + color: var(--ol-link); cursor: pointer; font-family: inherit; font-size: var(--fs-sm); @@ -7761,23 +7814,23 @@ body, .openlayer-preview-panel-pin:hover { border-color: #5f5f5f; - background: #424242; + background: var(--ol-hover); } .openlayer-preview-panel-fit { flex: 0 0 auto; padding: 3px 9px; - border: 1px solid #4a4a4a; + border: 1px solid var(--ol-line); border-radius: 3px; - background: #383838; - color: #d7dde6; + background: var(--ol-panel-raised); + color: var(--ol-link); cursor: pointer; font-size: var(--fs-sm); } .openlayer-preview-panel-fit:hover { border-color: #5f5f5f; - background: #424242; + background: var(--ol-hover); } /* The viewing surface. Checkerboard so transparent results read correctly, and @@ -7793,8 +7846,8 @@ body, border-radius: 4px; background-color: #323232; background-image: - linear-gradient(45deg, #2b2b2b 25%, transparent 25%, transparent 75%, #2b2b2b 75%), - linear-gradient(45deg, #2b2b2b 25%, transparent 25%, transparent 75%, #2b2b2b 75%); + linear-gradient(45deg, var(--ol-panel) 25%, transparent 25%, transparent 75%, var(--ol-panel) 75%), + linear-gradient(45deg, var(--ol-panel) 25%, transparent 25%, transparent 75%, var(--ol-panel) 75%); background-position: 0 0, 8px 8px; background-size: 16px 16px; } @@ -7848,10 +7901,10 @@ body, .openlayer-preview-panel-auto-import { flex: 0 0 auto; padding: 4px 10px; - border: 1px solid #4a4a4a; + border: 1px solid var(--ol-line); border-radius: 3px; - background: #383838; - color: #d7dde6; + background: var(--ol-panel-raised); + color: var(--ol-link); cursor: pointer; font-family: inherit; font-size: var(--fs-sm); @@ -7870,7 +7923,7 @@ body, .openlayer-preview-panel-auto-import:hover { border-color: #5f5f5f; - background: #424242; + background: var(--ol-hover); } .openlayer-preview-panel-auto-import.is-active { @@ -7884,8 +7937,8 @@ body, disabled (a run is active) is exactly when someone will jab at it. */ .openlayer-preview-panel-import:disabled, .openlayer-preview-panel-import:disabled:hover { - border-color: #3f3f3f; - background: #333333; + border-color: var(--ol-line-soft); + background: var(--ol-surface-strong); color: #6f6f6f; cursor: default; } @@ -7893,7 +7946,7 @@ body, .openlayer-preview-panel-import-status { overflow: hidden; flex: 1 1 auto; - color: #9a9a9a; + color: var(--ol-muted); font-size: var(--fs-sm); text-overflow: ellipsis; white-space: nowrap; @@ -7924,7 +7977,7 @@ body, font-size: var(--fs-micro); text-transform: uppercase; letter-spacing: 0.06em; - color: #9a9a9a; + color: var(--ol-muted); margin-bottom: 6px; } From 2a440e09963cf647c8303fe8926fb96f9bf6cab4 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 20 Aug 2026 01:44:56 +0330 Subject: [PATCH 04/15] Spike round 3: dark slider track, and record what rounds 1-2 settled Photoshop results from round 2, all measured on the real panel: E. An explicit step DOES quantise -- step="1" returned 44, step="0.05" returned 0.55. Round 1's continuous floats (40.541, 47.147) came from relying on the IMPLICIT default step, which UXP does not apply. The round 1 note claiming "UXP ignores step" was too broad; corrected. F. accent-color does NOT take. Combined with round 1 killing appearance:none + ::-webkit-slider-thumb, there is no cheap recolour of the native slider at all. Branding has to live in the label row. G. JS snapping produces clean values. Keeper. H. The Krita AI label-as-prefix row reads well in a narrow panel. Keeper. Round 3 asks the one untried route: does background on the range ELEMENT paint the track? UXP's native default is #535353, which floats a light bar on the #2b2b2b that .panel-section resolves to in the compact theme -- Mehran flagged the mismatch on sight. The rule uses var(--ol-panel) rather than a literal so the match survives a theme change. The three probe rows are rebuilt as the controls we actually intend to ship -- Detail (steps), Strength (denoise), Guidance (CFG) -- each with a declared step, snapToStep on top, and the dual naming from the research pass. Testing the real shape is worth more than testing another abstract widget, and it puts the "Off" special-case in front of a real reader. snapToStep stays regardless of E: preset and persisted-preference values are not bound by any control's step and still need quantising before they are shown. Co-Authored-By: Claude Opus 5 --- src/styles.css | 30 ++++--- src/ui/spikeArtistControls.ts | 149 +++++++++++++++++++++------------- 2 files changed, 113 insertions(+), 66 deletions(-) diff --git a/src/styles.css b/src/styles.css index 6af7a32..d49e75a 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7957,14 +7957,23 @@ body, } /* ========================================================================== - SPIKE: artist controls probe, round 2 (v0.16) + SPIKE: artist controls probe, round 3 (v0.16) Delete with src/ui/spikeArtistControls.ts once the control design is decided. - Round 1 proved appearance:none + ::-webkit-slider-thumb does NOT take in UXP - -- the gradient track never painted and the thumb collapsed to a notch, so - the bare native slider looked BETTER than the styled one. Round 2 therefore - leaves the native widget alone and only tries accent-color, which is the - one branding hook that does not require replacing the thumb. + Settled by rounds 1-2 in real Photoshop -- do not re-test these: + - appearance:none + ::-webkit-slider-thumb does NOT take. The bare native + slider looked BETTER than the styled one, so we work with the widget. + - An EXPLICIT step attribute DOES quantise (int=44, frac=0.55). Round 1's + continuous floats came from relying on the implicit default step, which + UXP ignores. Always declare step. + - accent-color does NOT take. There is no cheap recolour of the native + track or thumb, so branding lives in the label row, not the control. + + Round 3 question: + I. Does `background` on the range ELEMENT paint the track? The pseudo- + element route is dead, but the element's own box may still honour it. + UXP's native track default is #535353, which reads as a light bar + floating on the #2b2b2b section behind it. ========================================================================== */ .spike-controls .spike-row { @@ -7981,13 +7990,12 @@ body, margin-bottom: 6px; } +/* I: sink the track into the section instead of floating a light bar on it. + var(--ol-panel) is #2b2b2b -- the exact value .panel-section resolves to in + the compact theme, so a match here stays a match if the theme changes. */ .spike-controls input[type="range"] { width: 100%; -} - -/* F: the cheapest possible branding of a native widget. */ -.spike-controls .spike-accent { - accent-color: #d9a441; + background: var(--ol-panel); } /* H: Krita AI's row shape -- label and value on one line as a prefix, slider diff --git a/src/ui/spikeArtistControls.ts b/src/ui/spikeArtistControls.ts index 4f0ad31..d5e67a4 100644 --- a/src/ui/spikeArtistControls.ts +++ b/src/ui/spikeArtistControls.ts @@ -1,25 +1,32 @@ /** - * SPIKE round 2: how do we get a usable slider out of UXP's NATIVE range widget? + * SPIKE round 3: how do we get a usable slider out of UXP's NATIVE range widget? * * Delete this file, its markup block in appMarkup.ts, its CSS block in * styles.css, and its wiring in App.ts once v0.16.0's control design is decided. * - * Round 1 settled the premise and killed two assumptions: - * - `type="range"` survives, renders, drags, and fires change events. Good. - * - UXP IGNORES `step`. With no step attribute (HTML default 1) it returned - * 40.541, 47.147, 45.675. Continuous floats. So quantisation is ours to do. - * - `appearance:none` + `::-webkit-slider-thumb` did NOT take. The gradient + * SETTLED in real Photoshop by rounds 1-2. Do not re-test these: + * - `type="range"` survives, renders, drags, and fires change events. + * - An EXPLICIT `step` DOES quantise: step="1" gave 44, step="0.05" gave 0.55. + * Round 1's continuous floats (40.541, 47.147) came from relying on the + * IMPLICIT default step, which UXP ignores. Always declare step explicitly. + * - `appearance:none` + `::-webkit-slider-thumb` does NOT take. The gradient * track never painted and the thumb collapsed to a faint notch. The BARE - * native slider looked better than the styled one, so round 2 works with - * the native widget instead of against it. + * native slider looked better, so we work with the widget, not against it. + * - `accent-color` does NOT take. There is no cheap recolour of the native + * track or thumb, so branding has to live in the label row, not the control. + * - JS snapping produces clean values, and the Krita AI label-as-prefix row + * reads well in a narrow docked panel. Both are keepers. * - Inline SVG, unicode and CSS shapes all render. A data-URI SVG background * renders NOTHING -- do not use that technique anywhere in the panel. * - * Round 2 questions: - * E. Does an EXPLICIT step attribute quantise, or is step ignored outright? - * F. Does `accent-color` brand the native slider? (cheapest possible win) - * G. Can JS snapping produce clean values from a continuous input? - * H. Does the label-as-prefix row survive a narrow panel? (Krita AI's trick) + * Round 3 question: + * I. Does `background` on the range ELEMENT paint the track? The pseudo- + * element route is dead, but the element's own box may still honour it. + * UXP's native default track is #535353, a light bar floating on #2b2b2b. + * + * `snapToStep` survives the spike regardless: explicit step covers the widget, + * but values also arrive from presets and persisted preferences, which are not + * bound by the control's step and still need quantising before they are shown. */ export const SPIKE_ARTIST_CONTROLS_MARKUP = ` @@ -31,31 +38,26 @@ export const SPIKE_ARTIST_CONTROLS_MARKUP = `
- E1. explicit step="1" (0-100) - -
- -
- E2. explicit step="0.05" (0-1, denoise shape) - -
- -
- F. accent-color on native widget - + I1. Steps -- Fast .. Detailed +
+ Detail (steps): 20 + +
- G. JS-snapped to whole steps (4-60) - -
Steps: 20
+ I2. Strength -- Off .. Rebuild +
+ Strength (denoise): 60% + +
- H. label-as-prefix row (Krita AI trick) + I3. Guidance -- Loose .. Literal
- Strength (denoise): 60% - + Guidance (CFG): 4.0 +
@@ -75,6 +77,48 @@ export function snapToStep(rawValue: number, min: number, max: number, step: num return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)); } +/** Formats a snapped value the way the real control will: artist word, jargon + * in parentheses, and a plain word instead of a number at a meaningful end. */ +type SpikeFormatter = (value: number) => string; + +interface SpikeControl { + inputId: string; + labelId: string; + min: number; + max: number; + step: number; + format: SpikeFormatter; +} + +const SPIKE_CONTROLS: SpikeControl[] = [ + { + inputId: "spike-steps", + labelId: "spike-prefix-steps", + min: 4, + max: 60, + step: 1, + format: (value) => `Detail (steps): ${value}`, + }, + { + inputId: "spike-strength", + labelId: "spike-prefix-strength", + min: 0, + max: 100, + step: 5, + // "Off" rather than "0%" -- at the minimum the control does nothing, and + // the word says that where the number only implies it. + format: (value) => `Strength (denoise): ${value === 0 ? "Off" : `${value}%`}`, + }, + { + inputId: "spike-guidance", + labelId: "spike-prefix-guidance", + min: 1, + max: 12, + step: 0.5, + format: (value) => `Guidance (CFG): ${value.toFixed(1)}`, + }, +]; + export function wireSpikeArtistControls(root: ParentNode): void { const readout = root.querySelector("#spike-controls-readout"); if (!readout) { @@ -87,33 +131,28 @@ export function wireSpikeArtistControls(root: ParentNode): void { return; } - const snappedInput = root.querySelector("#spike-snapped"); - const snappedReadout = root.querySelector("#spike-snapped-readout"); - const prefixInput = root.querySelector("#spike-prefix"); - const prefixReadout = root.querySelector("#spike-prefix-readout"); - const report = () => { - // Whether an explicit step actually quantised is the whole question, so - // report the raw values verbatim rather than anything already rounded. - const parts = ranges - .filter((range) => range.id === "spike-step-int" || range.id === "spike-step-frac") - .map((range) => `${range.id.replace("spike-step-", "")}=${range.value}`); - readout.textContent = `raw with explicit step: ${parts.join(" ")}`; + // Round 3 is about the track paint, but keep printing raw values so a + // regression in step quantisation cannot pass unnoticed. + const parts = SPIKE_CONTROLS.map((control) => { + const input = root.querySelector(`#${control.inputId}`); + return input ? `${control.inputId.replace("spike-", "")}=${input.value}` : ""; + }).filter(Boolean); + readout.textContent = `raw: ${parts.join(" ")}`; }; - for (const range of ranges) { - range.addEventListener("input", () => { + for (const control of SPIKE_CONTROLS) { + const input = root.querySelector(`#${control.inputId}`); + const label = root.querySelector(`#${control.labelId}`); + if (!input || !label) { + continue; + } + const sync = () => { + const value = snapToStep(Number(input.value), control.min, control.max, control.step); + label.textContent = control.format(value); report(); - if (snappedInput && snappedReadout && range === snappedInput) { - const steps = snapToStep(Number(snappedInput.value), 4, 60, 1); - snappedReadout.textContent = `Steps: ${steps}`; - } - if (prefixInput && prefixReadout && range === prefixInput) { - const pct = snapToStep(Number(prefixInput.value), 0, 100, 1); - prefixReadout.textContent = `Strength (denoise): ${pct === 0 ? "Off" : `${pct}%`}`; - } - }); + }; + input.addEventListener("input", sync); + sync(); } - - report(); } From dcd94cd1284837ebca979e8e5978e354299f7b06 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 20 Aug 2026 01:57:04 +0330 Subject: [PATCH 05/15] Add Artist-Friendly Dark as a token override, and retire the spike Third theme, offered in Settings alongside Compact Adobe Dark and Classic v0.4. Compact is deliberately untouched: the only styles.css changes are the deleted spike block and the appended theme block, so every existing compact rule is byte-identical. The load-bearing decision is in applyTheme. Artist-Friendly Dark KEEPS the theme-compact class and stacks theme-artist on top rather than standing on its own. The compact rules are the stylesheet -- 1,158 of them against 9 unprefixed base rules -- so a theme with its own class inherits almost nothing and has to re-state the entire panel. That is how Classic v0.4 ended up a veneer over two elements, and it is what the colour token pass was for: the theme is now 40 token overrides and nothing else. The class is mirrored onto body because html/body/#root paint the shell behind the panel from --ol-section-bg and are ancestors of the app shell, so a token set only on the shell cannot reach them. Palette intent: warm the neutrals off pure grey, drop the chrome contrast so the artwork is the brightest thing on screen, and soften #ffffff text to a warm off-white. The outer shell moves from #535353 to #3a3634 -- compact currently frames the panel in something brighter than the image inside it. Theme handling had no test coverage at all. normalizeTheme replaces two hand-written ternaries that silently mapped anything unknown to compact, and the class stacking is asserted directly, because that failure is invisible: the panel still renders, just missing rules nobody notices until a screenshot. The spike is deleted now that all its questions are answered (round 3 confirmed background on the range element does not paint the track either, so there is no CSS route to the native slider's colours). snapToStep is promoted to src/utils with its tests -- explicit step covers the widget, but preset and persisted values are bound by nothing and still need quantising before display. Co-Authored-By: Claude Opus 5 --- src/styles.css | 117 +++++++------ src/ui/App.ts | 33 +++- src/ui/appMarkup.ts | 4 +- src/ui/spikeArtistControls.ts | 158 ------------------ src/utils/preferences.ts | 20 ++- src/utils/snapToStep.ts | 23 +++ .../snapToStep.test.ts} | 13 +- tests/utils/theme.test.ts | 67 ++++++++ 8 files changed, 209 insertions(+), 226 deletions(-) delete mode 100644 src/ui/spikeArtistControls.ts create mode 100644 src/utils/snapToStep.ts rename tests/{ui/spikeArtistControls.test.ts => utils/snapToStep.test.ts} (73%) create mode 100644 tests/utils/theme.test.ts diff --git a/src/styles.css b/src/styles.css index d49e75a..16b1c82 100644 --- a/src/styles.css +++ b/src/styles.css @@ -7957,57 +7957,78 @@ body, } /* ========================================================================== - SPIKE: artist controls probe, round 3 (v0.16) - Delete with src/ui/spikeArtistControls.ts once the control design is decided. - - Settled by rounds 1-2 in real Photoshop -- do not re-test these: - - appearance:none + ::-webkit-slider-thumb does NOT take. The bare native - slider looked BETTER than the styled one, so we work with the widget. - - An EXPLICIT step attribute DOES quantise (int=44, frac=0.55). Round 1's - continuous floats came from relying on the implicit default step, which - UXP ignores. Always declare step. - - accent-color does NOT take. There is no cheap recolour of the native - track or thumb, so branding lives in the label row, not the control. - - Round 3 question: - I. Does `background` on the range ELEMENT paint the track? The pseudo- - element route is dead, but the element's own box may still honour it. - UXP's native track default is #535353, which reads as a light bar - floating on the #2b2b2b section behind it. - ========================================================================== */ + THEME: Artist-Friendly Dark (v0.16) -.spike-controls .spike-row { - display: block; - margin-bottom: 14px; -} + This block is the whole theme. It is only token overrides, and that is the + point: the compact rules ARE the stylesheet (1,158 of them against 9 + unprefixed base rules), so Artist-Friendly Dark keeps the .theme-compact + class and stacks .theme-artist on top rather than standing alone. Dropping + the compact class would inherit almost nothing and force the whole panel to + be re-stated -- which is how Classic v0.4 became a veneer. -.spike-controls .spike-tag { - display: block; - font-size: var(--fs-micro); - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--ol-muted); - margin-bottom: 6px; -} + Specificity is deliberately equal to .app-shell.theme-compact (0,2,0), so + these win on source order. Keep this block LAST in the file. -/* I: sink the track into the section instead of floating a light bar on it. - var(--ol-panel) is #2b2b2b -- the exact value .panel-section resolves to in - the compact theme, so a match here stays a match if the theme changes. */ -.spike-controls input[type="range"] { - width: 100%; - background: var(--ol-panel); -} + body.theme-artist is listed because html/body/#root paint the shell behind + the panel from --ol-section-bg and are ancestors of the app shell, so a + token set only on the shell cannot reach them. -/* H: Krita AI's row shape -- label and value on one line as a prefix, slider - beneath, so a parameter costs one text row instead of a label row plus a - control row. Margins, not flex gap: gap is inert in compact UXP panels. */ -.spike-controls .spike-prefix-row { - display: block; -} + Intent: warm the neutrals off pure grey, drop the chrome contrast so the + image is the brightest thing on screen, and soften pure-white text to a + warm off-white that is easier to sit in front of for hours. + ========================================================================== */ -.spike-controls .spike-prefix-label { - display: block; - font-size: var(--fs-xs); - color: #e0e0e0; - margin-bottom: 4px; +body.theme-artist, +.app-shell.theme-artist { + /* Surfaces: warm-tinted neutrals instead of pure grey. */ + --ol-input-bg: #1e1c1a; + --ol-field-bg: #1e1c1a; + --ol-bg: #232120; + --ol-panel-soft: #262422; + --ol-panel: #2b2927; + --ol-surface: #2f2c2a; + --ol-surface-alt: #322f2c; + --ol-surface-strong: #35322e; + --ol-surface-soft: #383431; + --ol-panel-raised: #3b3733; + + /* The outer Photoshop shell. Compact runs this at #535353, which frames the + panel in something brighter than the artwork inside it. */ + --ol-section-bg: #3a3634; + + /* Borders: present but quiet. Alpha keeps them correct over any surface. */ + --ol-border-soft: rgba(255, 255, 255, 0.06); + --ol-border-subtle: rgba(255, 255, 255, 0.08); + --ol-line-soft: rgba(255, 255, 255, 0.09); + --ol-border: rgba(255, 255, 255, 0.12); + --ol-line: rgba(255, 255, 255, 0.12); + --ol-hover: #3f3b37; + --ol-hover-border: #4a453f; + + /* Text: warm off-white, never #ffffff. */ + --ol-text: #f2efe9; + --ol-text-soft: #e9e5dd; + --ol-text-secondary: #ded9d0; + --ol-link: #d9d3c8; + --ol-muted: #a9a39a; + --ol-muted-soft: #8b867d; + --ol-dim: #928d85; + + /* Accent: the same gold, slightly richer against warm surfaces. */ + --ol-accent: #e0a83c; + --ol-accent-soft: #4a3c1a; + --ol-accent-border: #e0a83c; + --ol-accent-bright: #ffd36b; + --ol-accent-glow: #fff0cc; + + /* Status: kept clearly distinct, warmed to match. */ + --ol-success: #4ec26a; + --ol-danger: #e06a6a; + --ol-warning: #eba13c; + --ol-blue: #4076c4; + --ol-green: #2f7d47; + --ol-orange: #d98b2a; + --ol-orange-hover: #b8701c; + + --ol-radius: 7px; } diff --git a/src/ui/App.ts b/src/ui/App.ts index f49f896..f3b9b44 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -167,8 +167,6 @@ import { runModelDownloadSpike, summarizeSpike } from "./spikeModelDownload"; -// SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. -import { wireSpikeArtistControls } from "./spikeArtistControls"; import { createUpscaleResizePlan, formatUpscaleScale, @@ -220,6 +218,7 @@ import { loadAgentBridgeSettings, loadOpenLayerPreferences, OpenLayerTheme, + normalizeTheme, OpenLayerPreferences, saveAgentBridgeSettings, saveOpenLayerPreferences @@ -1270,8 +1269,6 @@ export function renderApp(rootElement: HTMLElement) { applyTheme(elements, preferences.theme || DEFAULT_THEME); fillCheckpointOptions(elements, FALLBACK_CHECKPOINTS, preferences.checkpointName || FALLBACK_CHECKPOINTS[0]); ensureCoreSelectDefaults(elements); - // SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. - wireSpikeArtistControls(rootElement); const actionHandlers: ActionHandlers = { check: createActionRunner(elements, "check", handleCheckComfy), @@ -5739,19 +5736,39 @@ function savePreferencesFromElements( } function readThemeSelection(elements: AppElements): OpenLayerTheme { - return elements.settingsThemeSelect.value === "classic" ? "classic" : "compact"; + return normalizeTheme(elements.settingsThemeSelect.value); } +const THEME_LABELS: Record = { + compact: "Compact Adobe Dark", + artist: "Artist-Friendly Dark", + classic: "Classic v0.4" +}; + function applyTheme(elements: AppElements, theme: OpenLayerTheme) { - const nextTheme = theme === "classic" ? "classic" : "compact"; + const nextTheme = normalizeTheme(theme); elements.settingsThemeSelect.value = nextTheme; - elements.appShell.classList.toggle("theme-compact", nextTheme === "compact"); + + // Artist-Friendly Dark KEEPS the compact class and stacks theme-artist on + // top of it. The compact rules are the stylesheet -- 1,158 of them against + // 9 unprefixed base rules -- so a theme that drops the compact class + // inherits almost nothing and has to re-state the whole panel. That is how + // Classic v0.4 ended up a veneer, and the token pass exists precisely so a + // theme can be an override instead. + const usesCompactLayout = nextTheme === "compact" || nextTheme === "artist"; + elements.appShell.classList.toggle("theme-compact", usesCompactLayout); elements.appShell.classList.toggle("theme-classic", nextTheme === "classic"); + elements.appShell.classList.toggle("theme-artist", nextTheme === "artist"); + + // html/body/#root paint the shell behind the panel from --ol-section-bg, and + // they are ancestors of the app shell rather than descendants, so a token + // override scoped to the shell cannot reach them. Mirror the class onto body. + elements.appShell.ownerDocument?.body?.classList.toggle("theme-artist", nextTheme === "artist"); } function getThemeLabel(theme: OpenLayerTheme) { - return theme === "classic" ? "Classic v0.4" : "Compact Adobe Dark"; + return THEME_LABELS[normalizeTheme(theme)]; } function updateSettingsReport(elements: AppElements) { diff --git a/src/ui/appMarkup.ts b/src/ui/appMarkup.ts index 077d07d..ce4217a 100644 --- a/src/ui/appMarkup.ts +++ b/src/ui/appMarkup.ts @@ -1,7 +1,5 @@ import { listRunnableWorkflowPresets, listWorkflowPresets } from "../comfy/presetRegistry"; import { NO_LORA_VALUE } from "../comfy/loraCompatibility"; -// SPIKE, delete with src/ui/spikeArtistControls.ts once control design is decided. -import { SPIKE_ARTIST_CONTROLS_MARKUP } from "./spikeArtistControls"; import { APP_VERSION, DEFAULT_CFG, @@ -482,7 +480,6 @@ export function createAppMarkup() {
-${SPIKE_ARTIST_CONTROLS_MARKUP}
Status report @@ -540,6 +537,7 @@ ${SPIKE_ARTIST_CONTROLS_MARKUP} Panel theme diff --git a/src/ui/spikeArtistControls.ts b/src/ui/spikeArtistControls.ts deleted file mode 100644 index d5e67a4..0000000 --- a/src/ui/spikeArtistControls.ts +++ /dev/null @@ -1,158 +0,0 @@ -/** - * SPIKE round 3: how do we get a usable slider out of UXP's NATIVE range widget? - * - * Delete this file, its markup block in appMarkup.ts, its CSS block in - * styles.css, and its wiring in App.ts once v0.16.0's control design is decided. - * - * SETTLED in real Photoshop by rounds 1-2. Do not re-test these: - * - `type="range"` survives, renders, drags, and fires change events. - * - An EXPLICIT `step` DOES quantise: step="1" gave 44, step="0.05" gave 0.55. - * Round 1's continuous floats (40.541, 47.147) came from relying on the - * IMPLICIT default step, which UXP ignores. Always declare step explicitly. - * - `appearance:none` + `::-webkit-slider-thumb` does NOT take. The gradient - * track never painted and the thumb collapsed to a faint notch. The BARE - * native slider looked better, so we work with the widget, not against it. - * - `accent-color` does NOT take. There is no cheap recolour of the native - * track or thumb, so branding has to live in the label row, not the control. - * - JS snapping produces clean values, and the Krita AI label-as-prefix row - * reads well in a narrow docked panel. Both are keepers. - * - Inline SVG, unicode and CSS shapes all render. A data-URI SVG background - * renders NOTHING -- do not use that technique anywhere in the panel. - * - * Round 3 question: - * I. Does `background` on the range ELEMENT paint the track? The pseudo- - * element route is dead, but the element's own box may still honour it. - * UXP's native default track is #535353, a light bar floating on #2b2b2b. - * - * `snapToStep` survives the spike regardless: explicit step covers the widget, - * but values also arrive from presets and persisted preferences, which are not - * bound by the control's step and still need quantising before they are shown. - */ - -export const SPIKE_ARTIST_CONTROLS_MARKUP = ` - -
-
- Spike: Artist Controls 2 - v0.16 probe -
- -
- I1. Steps -- Fast .. Detailed -
- Detail (steps): 20 - -
-
- -
- I2. Strength -- Off .. Rebuild -
- Strength (denoise): 60% - -
-
- -
- I3. Guidance -- Loose .. Literal -
- Guidance (CFG): 4.0 - -
-
- -
Drag each slider.
-
-`; - -/** Rounds a continuous UXP range value onto the control's declared step grid. */ -export function snapToStep(rawValue: number, min: number, max: number, step: number): number { - if (!Number.isFinite(rawValue) || step <= 0) { - return rawValue; - } - const clamped = Math.min(max, Math.max(min, rawValue)); - const snapped = min + Math.round((clamped - min) / step) * step; - // Re-round to the step's own precision so 0.30000000000000004 does not escape. - const decimals = (String(step).split(".")[1] || "").length; - return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)); -} - -/** Formats a snapped value the way the real control will: artist word, jargon - * in parentheses, and a plain word instead of a number at a meaningful end. */ -type SpikeFormatter = (value: number) => string; - -interface SpikeControl { - inputId: string; - labelId: string; - min: number; - max: number; - step: number; - format: SpikeFormatter; -} - -const SPIKE_CONTROLS: SpikeControl[] = [ - { - inputId: "spike-steps", - labelId: "spike-prefix-steps", - min: 4, - max: 60, - step: 1, - format: (value) => `Detail (steps): ${value}`, - }, - { - inputId: "spike-strength", - labelId: "spike-prefix-strength", - min: 0, - max: 100, - step: 5, - // "Off" rather than "0%" -- at the minimum the control does nothing, and - // the word says that where the number only implies it. - format: (value) => `Strength (denoise): ${value === 0 ? "Off" : `${value}%`}`, - }, - { - inputId: "spike-guidance", - labelId: "spike-prefix-guidance", - min: 1, - max: 12, - step: 0.5, - format: (value) => `Guidance (CFG): ${value.toFixed(1)}`, - }, -]; - -export function wireSpikeArtistControls(root: ParentNode): void { - const readout = root.querySelector("#spike-controls-readout"); - if (!readout) { - return; - } - - const ranges = Array.from(root.querySelectorAll('input[type="range"]')); - if (ranges.length === 0) { - readout.textContent = "No range inputs in the DOM at all."; - return; - } - - const report = () => { - // Round 3 is about the track paint, but keep printing raw values so a - // regression in step quantisation cannot pass unnoticed. - const parts = SPIKE_CONTROLS.map((control) => { - const input = root.querySelector(`#${control.inputId}`); - return input ? `${control.inputId.replace("spike-", "")}=${input.value}` : ""; - }).filter(Boolean); - readout.textContent = `raw: ${parts.join(" ")}`; - }; - - for (const control of SPIKE_CONTROLS) { - const input = root.querySelector(`#${control.inputId}`); - const label = root.querySelector(`#${control.labelId}`); - if (!input || !label) { - continue; - } - const sync = () => { - const value = snapToStep(Number(input.value), control.min, control.max, control.step); - label.textContent = control.format(value); - report(); - }; - input.addEventListener("input", sync); - sync(); - } -} diff --git a/src/utils/preferences.ts b/src/utils/preferences.ts index 8d036af..168f1af 100644 --- a/src/utils/preferences.ts +++ b/src/utils/preferences.ts @@ -1,4 +1,20 @@ -export type OpenLayerTheme = "compact" | "classic"; +/** + * Panel themes, in the order they are offered in Settings. + * + * "artist" is not a standalone stylesheet: it renders as the compact theme + * with a set of token overrides on top, so it inherits every compact layout + * rule. See applyTheme in App.ts for the class stacking that makes that work. + */ +export const OPEN_LAYER_THEMES = ["compact", "artist", "classic"] as const; + +export type OpenLayerTheme = (typeof OPEN_LAYER_THEMES)[number]; + +/** Coerces untrusted input (stored prefs, a select value) to a known theme. */ +export function normalizeTheme(value: unknown): OpenLayerTheme { + return OPEN_LAYER_THEMES.includes(value as OpenLayerTheme) + ? (value as OpenLayerTheme) + : "compact"; +} export type OpenLayerPreferences = { serverUrl: string; @@ -222,5 +238,5 @@ function readString(value: unknown) { } function readTheme(value: unknown): OpenLayerTheme { - return value === "classic" ? "classic" : "compact"; + return normalizeTheme(value); } diff --git a/src/utils/snapToStep.ts b/src/utils/snapToStep.ts new file mode 100644 index 0000000..f6df322 --- /dev/null +++ b/src/utils/snapToStep.ts @@ -0,0 +1,23 @@ +/** + * Quantises a value onto a control's declared step grid. + * + * UXP applies an explicit `step` attribute correctly, so a slider the user + * drags already returns clean values. This exists for the values that do NOT + * come from a control: workflow preset recommendations and persisted + * preferences are plain numbers, bound by nothing, and would otherwise print + * as 0.30000000000000004 or land between two legal stops. + * + * Measured UXP behaviour behind this lives in the openlayer-uxp-slider-findings + * memory: an IMPLICIT default step is ignored, which is why every range input + * in the panel must declare `step` even when the value looks like the default. + */ +export function snapToStep(rawValue: number, min: number, max: number, step: number): number { + if (!Number.isFinite(rawValue) || step <= 0) { + return rawValue; + } + const clamped = Math.min(max, Math.max(min, rawValue)); + const snapped = min + Math.round((clamped - min) / step) * step; + // Re-round to the step's own precision so float dust does not escape. + const decimals = (String(step).split(".")[1] || "").length; + return Number(Math.min(max, Math.max(min, snapped)).toFixed(decimals)); +} diff --git a/tests/ui/spikeArtistControls.test.ts b/tests/utils/snapToStep.test.ts similarity index 73% rename from tests/ui/spikeArtistControls.test.ts rename to tests/utils/snapToStep.test.ts index ebeb319..01f0f16 100644 --- a/tests/ui/spikeArtistControls.test.ts +++ b/tests/utils/snapToStep.test.ts @@ -1,13 +1,12 @@ import { describe, expect, it } from "vitest"; -import { snapToStep } from "../../src/ui/spikeArtistControls"; +import { snapToStep } from "../../src/utils/snapToStep"; /** - * SPIKE support, delete with src/ui/spikeArtistControls.ts. - * - * These cases are not hypothetical: UXP ignores a range input's `step` and - * hands back continuous floats. Round 1 of the spike measured 40.541, 47.147 - * and 45.675 from sliders whose step was the HTML default of 1. Quantising is - * therefore ours to do, and submitting 20.4 steps to ComfyUI is the bug this + * The float cases here are not hypothetical. Spike round 1 measured 40.541, + * 47.147 and 45.675 back from UXP sliders that relied on the IMPLICIT default + * step, which UXP does not apply. Declaring step explicitly fixed the widget, + * but preset recommendations and persisted preferences are still plain numbers + * bound by nothing -- and submitting 20.4 steps to ComfyUI is the bug this * prevents. */ describe("snapToStep", () => { diff --git a/tests/utils/theme.test.ts b/tests/utils/theme.test.ts new file mode 100644 index 0000000..1808a02 --- /dev/null +++ b/tests/utils/theme.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { normalizeTheme, OPEN_LAYER_THEMES, OpenLayerTheme } from "../../src/utils/preferences"; + +describe("normalizeTheme", () => { + it("accepts every theme the picker offers", () => { + for (const theme of OPEN_LAYER_THEMES) { + expect(normalizeTheme(theme)).toBe(theme); + } + }); + + it("falls back to compact for anything unrecognised", () => { + // Stored preferences outlive releases: a theme removed in a later version + // must not leave the panel with no theme class at all. + expect(normalizeTheme("artist-friendly-dark")).toBe("compact"); + expect(normalizeTheme("")).toBe("compact"); + expect(normalizeTheme(undefined)).toBe("compact"); + expect(normalizeTheme(null)).toBe("compact"); + expect(normalizeTheme(42)).toBe("compact"); + expect(normalizeTheme({ theme: "artist" })).toBe("compact"); + }); + + it("keeps compact as the default so an upgrade does not restyle the panel", () => { + expect(OPEN_LAYER_THEMES[0]).toBe("compact"); + }); +}); + +/** + * Mirrors applyTheme's class stacking. Artist-Friendly Dark is compact plus + * token overrides, so it MUST keep the compact class -- the compact rules are + * the stylesheet, and a theme that drops them inherits the nine unprefixed + * base rules instead. Encoded here because the failure is silent: the panel + * still renders, just unstyled in ways nobody notices until a screenshot. + */ +function themeClassesFor(theme: OpenLayerTheme): string[] { + const normalized = normalizeTheme(theme); + const classes: string[] = []; + if (normalized === "compact" || normalized === "artist") { + classes.push("theme-compact"); + } + if (normalized === "classic") { + classes.push("theme-classic"); + } + if (normalized === "artist") { + classes.push("theme-artist"); + } + return classes; +} + +describe("theme class stacking", () => { + it("gives artist the compact class so it inherits the compact rules", () => { + expect(themeClassesFor("artist")).toEqual(["theme-compact", "theme-artist"]); + }); + + it("leaves compact exactly as it was", () => { + expect(themeClassesFor("compact")).toEqual(["theme-compact"]); + }); + + it("never mixes classic with compact", () => { + expect(themeClassesFor("classic")).toEqual(["theme-classic"]); + }); + + it("applies a theme class for every offered theme", () => { + for (const theme of OPEN_LAYER_THEMES) { + expect(themeClassesFor(theme).length).toBeGreaterThan(0); + } + }); +}); From f00524eab1fbc1b202e183edd43f1bc88f6ae0e0 Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Thu, 20 Aug 2026 02:16:03 +0330 Subject: [PATCH 06/15] Give Artist-Friendly Dark a slider face for the numeric parameters Steps, CFG, denoise, sketch influence and live-paint strength become sliders in Artist-Friendly Dark. Compact Adobe Dark still shows the same number boxes it always has. One control, two faces. The number input stays the single source of truth and stays in the DOM in every theme; the slider is injected beside it, writes through to it, and re-dispatches input and change so every handler bound before it existed fires exactly as before. Nothing is rebound and no view code is refactored -- CSS alone decides which face is painted. The alternative, building a separate artist control, would mean two implementations of every parameter and guaranteed drift, which is the one thing the earlier "sliders in both themes" decision was there to prevent. Labels lead with the artist word and keep the jargon searchable, per the naming decision: "Detail (steps)", "Guidance (CFG)", "Strength (denoise)". Denoise reads as a percentage, because 60% is a quantity an artist already has a model for and 0.6 is not. Slider ranges are deliberately narrower than the typed input allows -- steps stop at 60 where the field accepts 150, CFG at 12 where it accepts 30 -- because a slider that spends most of its travel in territory nobody uses is a worse control than a short one. widenToFit extends the range rather than clamping when a real value lands outside it, so switching theme can never silently rewrite a setting. That is SwarmUI's ViewMax. Every range declares step explicitly: UXP does not apply the implicit default and returns continuous floats without it. snapToStep runs on top because preset recommendations and restored preferences are plain numbers bound by no control. Adds jsdom, scoped to the new test file by docblock rather than switched on for the suite. The existing DOM-touching tests stub their two calls to keep jsdom out of a pure-logic suite, and that stays true; but here the DOM contract IS the thing worth testing, and a hand-rolled fake would end up testing the fake. Co-Authored-By: Claude Opus 5 --- package-lock.json | 520 ++++++++++++++++++++++++++++++++ package.json | 1 + src/styles.css | 43 +++ src/ui/App.ts | 9 + src/ui/artistControls.ts | 201 ++++++++++++ tests/ui/artistControls.test.ts | 183 +++++++++++ 6 files changed, 957 insertions(+) create mode 100644 src/ui/artistControls.ts create mode 100644 tests/ui/artistControls.test.ts diff --git a/package-lock.json b/package-lock.json index 00dfd27..30dfb28 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "@eslint/js": "^10.0.1", "eslint": "^10.8.0", "globals": "^17.7.0", + "jsdom": "^30.0.1", "typescript": "^5.7.2", "typescript-eslint": "^8.65.0", "vite": "^6.0.7", @@ -19,6 +20,192 @@ "zod": "^4.4.3" } }, + "node_modules/@asamuzakjp/css-color": { + "version": "6.0.7", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-6.0.7.tgz", + "integrity": "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@csstools/css-calc": "^3.3.0", + "@csstools/css-color-parser": "^4.1.10", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-8.3.2.tgz", + "integrity": "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2" + }, + "engines": { + "node": "^22.13.0 || >=24.0.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.1.tgz", + "integrity": "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.3.0.tgz", + "integrity": "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.2.0.tgz", + "integrity": "sha512-5+5LEmFuY1AjXdYhmgjTJogtQnP1evJ1zrBZGUNZ0thkpwnnmKxcHdAMn/OtFjAb25zA+jKDVYVRl+5G7rjv1A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.1.1", + "@csstools/css-calc": "^3.3.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.8.tgz", + "integrity": "sha512-CpMLjAvwQg3BL5S0IeqsZNMH7EQrEWi0kLKOC13ZBF0ZwERiLWlibNPJr8G1kdU3Ms/r2KiNrF81pUh2HwAHdg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -589,6 +776,24 @@ "node": "^20.19.0 || ^22.13.0 || >=24" } }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, "node_modules/@humanfs/core": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", @@ -1461,6 +1666,16 @@ "node": "18 || 20 || >=22" } }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/brace-expansion": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz", @@ -1506,6 +1721,49 @@ "node": ">= 8" } }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1524,6 +1782,13 @@ } } }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, "node_modules/deep-is": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", @@ -1531,6 +1796,19 @@ "dev": true, "license": "MIT" }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-module-lexer": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", @@ -1899,6 +2177,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1942,6 +2233,13 @@ "node": ">=0.10.0" } }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -1949,6 +2247,47 @@ "dev": true, "license": "ISC" }, + "node_modules/jsdom": { + "version": "30.0.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-30.0.1.tgz", + "integrity": "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^6.0.5", + "@asamuzakjp/dom-selector": "^8.3.0", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.7", + "@exodus/bytes": "^1.15.1", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.5.2", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.2", + "undici": "^8.9.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^17.1.0", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^22.22.2 || ^24.15.0 || >=26.0.0" + }, + "peerDependencies": { + "canvas": "^3.2.3" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, "node_modules/json-buffer": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2010,6 +2349,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -2020,6 +2369,13 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, "node_modules/minimatch": { "version": "10.2.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", @@ -2133,6 +2489,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/path-exists": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", @@ -2229,6 +2598,16 @@ "node": ">=6" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -2274,6 +2653,19 @@ "fsevents": "~2.3.2" } }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, "node_modules/semver": { "version": "7.8.5", "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", @@ -2341,6 +2733,13 @@ "dev": true, "license": "MIT" }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -2385,6 +2784,52 @@ "node": ">=14.0.0" } }, + "node_modules/tldts": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.10.tgz", + "integrity": "sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.10" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.10", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.10.tgz", + "integrity": "sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.2.tgz", + "integrity": "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/ts-api-utils": { "version": "2.5.0", "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", @@ -2449,6 +2894,16 @@ "typescript": ">=4.8.4 <6.1.0" } }, + "node_modules/undici": { + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.10.0.tgz", + "integrity": "sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -2624,6 +3079,54 @@ } } }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-17.1.0.tgz", + "integrity": "sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.15.1", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^22.14.0 || >=24.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2667,6 +3170,23 @@ "node": ">=0.10.0" } }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index d27c5c8..2ce88fb 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "@eslint/js": "^10.0.1", "eslint": "^10.8.0", "globals": "^17.7.0", + "jsdom": "^30.0.1", "typescript": "^5.7.2", "typescript-eslint": "^8.65.0", "vite": "^6.0.7", diff --git a/src/styles.css b/src/styles.css index 16b1c82..6c1b383 100644 --- a/src/styles.css +++ b/src/styles.css @@ -8032,3 +8032,46 @@ body.theme-artist, --ol-radius: 7px; } + +/* ========================================================================== + Artist-Friendly Dark: the slider face of the numeric parameter fields. + + One control, two faces. The number input is the source of truth and stays + in the DOM in every theme; these rules only decide which face is painted. + Compact Adobe Dark never matches any of this and is untouched. + + Row shape is Krita AI's: the label carries the value as a prefix + ("Strength (denoise): 60%") and the track sits under it, so a parameter + costs one text row plus a track instead of a label row beside a box. + Margins, not flex gap -- gap is inert in compact UXP panels. + ========================================================================== */ + +.artist-label, +.artist-slider { + display: none; +} + +.app-shell.theme-artist .field.has-artist-slider { + display: block; +} + +/* The original label and the number box are the compact face. */ +.app-shell.theme-artist .field.has-artist-slider > .label, +.app-shell.theme-artist .field.has-artist-slider > .input { + display: none; +} + +.app-shell.theme-artist .field.has-artist-slider > .artist-label { + display: block; + margin-bottom: 4px; + color: var(--ol-text-secondary); + font-size: var(--fs-xs); + font-weight: 620; + line-height: 1.2; +} + +.app-shell.theme-artist .field.has-artist-slider > .artist-slider { + display: block; + width: 100%; + margin-bottom: 2px; +} diff --git a/src/ui/App.ts b/src/ui/App.ts index f3b9b44..2a265b5 100644 --- a/src/ui/App.ts +++ b/src/ui/App.ts @@ -167,6 +167,7 @@ import { runModelDownloadSpike, summarizeSpike } from "./spikeModelDownload"; +import { syncArtistControls, wireArtistControls } from "./artistControls"; import { createUpscaleResizePlan, formatUpscaleScale, @@ -1266,6 +1267,9 @@ export function renderApp(rootElement: HTMLElement) { }); const preferences = loadOpenLayerPreferences(); applyPreferences(elements, preferences); + // Inject the slider face before the theme is applied so a panel restored + // into Artist-Friendly Dark paints sliders on its first frame. + wireArtistControls(rootElement); applyTheme(elements, preferences.theme || DEFAULT_THEME); fillCheckpointOptions(elements, FALLBACK_CHECKPOINTS, preferences.checkpointName || FALLBACK_CHECKPOINTS[0]); ensureCoreSelectDefaults(elements); @@ -5765,6 +5769,11 @@ function applyTheme(elements: AppElements, theme: OpenLayerTheme) { // they are ancestors of the app shell rather than descendants, so a token // override scoped to the shell cannot reach them. Mirror the class onto body. elements.appShell.ownerDocument?.body?.classList.toggle("theme-artist", nextTheme === "artist"); + + // Anything that assigned input.value without dispatching an event has left + // its slider stale. That is invisible until the slider becomes the visible + // face, so reconcile on every theme change. + syncArtistControls(elements.appShell); } function getThemeLabel(theme: OpenLayerTheme) { diff --git a/src/ui/artistControls.ts b/src/ui/artistControls.ts new file mode 100644 index 0000000..71dffa0 --- /dev/null +++ b/src/ui/artistControls.ts @@ -0,0 +1,201 @@ +import { snapToStep } from "../utils/snapToStep"; + +/** + * Artist-Friendly Dark's slider face for the numeric parameter fields. + * + * The number input stays the single source of truth. A slider is injected + * beside it, writes through to it, and re-dispatches input/change so every + * existing handler fires exactly as it did before -- no handler is rebound and + * no view code is refactored. Compact Adobe Dark keeps showing the number + * input and never sees the slider; CSS decides which face is visible, so there + * is only ever ONE control and the two faces cannot drift apart. + * + * UXP constraints this is built around (see openlayer-uxp-slider-findings): + * - an IMPLICIT step is ignored, so every range declares `step` explicitly; + * - the native track and thumb cannot be recoloured by any CSS route, so the + * branding lives in the label row rather than in the control. + */ + +export interface ArtistControlSpec { + /** id of the existing number input this slider drives. */ + inputId: string; + /** Artist word first, jargon in parentheses, per the naming decision. */ + label: string; + /** Soft slider range. The typed input keeps its own wider min/max. */ + softMin: number; + softMax: number; + step: number; + /** Renders the value shown in the label. */ + format: (value: number) => string; +} + +const percent = (value: number) => `${Math.round(value * 100)}%`; +const decimal = (places: number) => (value: number) => value.toFixed(places); + +/** + * Soft ranges deliberately narrower than the typed input allows: steps go to + * 150 and CFG to 30 in the markup, but a slider that spends 90% of its travel + * in territory nobody uses is a worse control than a narrow one. widenToFit + * below extends the range rather than clamping if a real value lands outside, + * so nothing is ever silently changed. This is SwarmUI's ViewMax idea. + */ +export const ARTIST_CONTROLS: ArtistControlSpec[] = [ + // Text to Image + { inputId: "steps", label: "Detail (steps)", softMin: 1, softMax: 60, step: 1, format: decimal(0) }, + { inputId: "cfg", label: "Guidance (CFG)", softMin: 1, softMax: 12, step: 0.5, format: decimal(1) }, + // Image to Image + { inputId: "img-steps", label: "Detail (steps)", softMin: 1, softMax: 60, step: 1, format: decimal(0) }, + { inputId: "img-cfg", label: "Guidance (CFG)", softMin: 1, softMax: 12, step: 0.5, format: decimal(1) }, + { inputId: "img-denoise", label: "Strength (denoise)", softMin: 0.05, softMax: 1, step: 0.05, format: percent }, + // Sketch to Image + { inputId: "sketch-steps", label: "Detail (steps)", softMin: 1, softMax: 60, step: 1, format: decimal(0) }, + { inputId: "sketch-cfg", label: "Guidance (CFG)", softMin: 1, softMax: 12, step: 0.5, format: decimal(1) }, + { inputId: "sketch-denoise", label: "Strength (denoise)", softMin: 0.05, softMax: 1, step: 0.05, format: percent }, + { + inputId: "sketch-control-strength", + label: "Sketch influence", + softMin: 0, + softMax: 2, + step: 0.05, + format: decimal(2) + }, + // Inpaint + { inputId: "inpaint-steps", label: "Detail (steps)", softMin: 1, softMax: 60, step: 1, format: decimal(0) }, + { inputId: "inpaint-cfg", label: "Guidance (CFG)", softMin: 1, softMax: 12, step: 0.5, format: decimal(1) }, + { inputId: "inpaint-denoise", label: "Strength (denoise)", softMin: 0.05, softMax: 1, step: 0.05, format: percent }, + // Outpaint + { inputId: "outpaint-steps", label: "Detail (steps)", softMin: 1, softMax: 60, step: 1, format: decimal(0) }, + { inputId: "outpaint-denoise", label: "Strength (denoise)", softMin: 0.05, softMax: 1, step: 0.05, format: percent }, + // Live painting + { inputId: "live-denoise", label: "Strength (denoise)", softMin: 0.2, softMax: 0.95, step: 0.05, format: percent } +]; + +/** + * Widens a soft range so it can represent `value`. + * + * A preset recommendation or a preference saved before the soft range existed + * can legitimately sit outside it. Clamping would silently rewrite the user's + * setting the moment they switched theme, so the range gives way instead. + */ +export function widenToFit( + value: number, + softMin: number, + softMax: number +): { min: number; max: number } { + if (!Number.isFinite(value)) { + return { min: softMin, max: softMax }; + } + return { min: Math.min(softMin, value), max: Math.max(softMax, value) }; +} + +/** Builds the label text for a control, e.g. "Strength (denoise): 60%". */ +export function formatArtistLabel(spec: ArtistControlSpec, value: number): string { + if (!Number.isFinite(value)) { + return `${spec.label}: --`; + } + return `${spec.label}: ${spec.format(value)}`; +} + +const SLIDER_SUFFIX = "-artist-slider"; +const LABEL_SUFFIX = "-artist-label"; + +function dispatch(target: HTMLElement, type: string): void { + // UXP's Event constructor is available, but guard anyway: a failure here + // would silently stop generation parameters from updating. + try { + target.dispatchEvent(new Event(type, { bubbles: true })); + } catch { + /* no-op: the value is already written, only observers miss the notice */ + } +} + +function syncSliderFromInput( + input: HTMLInputElement, + slider: HTMLInputElement, + label: HTMLElement, + spec: ArtistControlSpec +): void { + const raw = Number(input.value); + if (!Number.isFinite(raw)) { + label.textContent = formatArtistLabel(spec, Number.NaN); + return; + } + const { min, max } = widenToFit(raw, spec.softMin, spec.softMax); + slider.min = String(min); + slider.max = String(max); + slider.value = String(raw); + label.textContent = formatArtistLabel(spec, raw); +} + +/** + * Injects the slider face for every control whose number input is present. + * Safe to call once; returns how many controls were wired. + */ +export function wireArtistControls(root: ParentNode): number { + let wired = 0; + + for (const spec of ARTIST_CONTROLS) { + const input = root.querySelector(`#${spec.inputId}`); + if (!input) { + continue; + } + const field = input.parentElement; + if (!field || field.querySelector(`#${spec.inputId}${SLIDER_SUFFIX}`)) { + continue; + } + + const doc = input.ownerDocument; + const label = doc.createElement("span"); + label.className = "artist-label"; + label.id = `${spec.inputId}${LABEL_SUFFIX}`; + + const slider = doc.createElement("input"); + slider.type = "range"; + slider.className = "artist-slider"; + slider.id = `${spec.inputId}${SLIDER_SUFFIX}`; + // Explicit step is mandatory: UXP does not apply the implicit default and + // hands back continuous floats without it. + slider.step = String(spec.step); + slider.setAttribute("aria-label", spec.label); + + field.classList.add("has-artist-slider"); + field.insertBefore(label, input); + field.insertBefore(slider, input); + + slider.addEventListener("input", () => { + const value = snapToStep(Number(slider.value), Number(slider.min), Number(slider.max), spec.step); + input.value = String(value); + label.textContent = formatArtistLabel(spec, value); + // Drive the existing handlers rather than reimplementing them. + dispatch(input, "input"); + dispatch(input, "change"); + }); + + // Presets and restored preferences write straight to the number input. + input.addEventListener("input", () => syncSliderFromInput(input, slider, label, spec)); + input.addEventListener("change", () => syncSliderFromInput(input, slider, label, spec)); + + syncSliderFromInput(input, slider, label, spec); + wired += 1; + } + + return wired; +} + +/** + * Re-reads every number input into its slider. + * + * Code that assigns `input.value` without dispatching an event leaves the + * slider stale, which is invisible until the theme is switched and a wrong + * number appears. Calling this on theme change closes that gap cheaply. + */ +export function syncArtistControls(root: ParentNode): void { + for (const spec of ARTIST_CONTROLS) { + const input = root.querySelector(`#${spec.inputId}`); + const slider = root.querySelector(`#${spec.inputId}${SLIDER_SUFFIX}`); + const label = root.querySelector(`#${spec.inputId}${LABEL_SUFFIX}`); + if (input && slider && label) { + syncSliderFromInput(input, slider, label, spec); + } + } +} diff --git a/tests/ui/artistControls.test.ts b/tests/ui/artistControls.test.ts new file mode 100644 index 0000000..b870653 --- /dev/null +++ b/tests/ui/artistControls.test.ts @@ -0,0 +1,183 @@ +// @vitest-environment jsdom +// +// Scoped to this file on purpose. The suite runs on node because it is +// almost all pure logic, and previewHub/previewState stub the two DOM calls +// they need rather than pulling jsdom in globally. This module is different: +// what is worth testing IS the DOM contract -- that the slider writes through +// to the number input and re-dispatches input/change so handlers bound before +// it existed still fire. A hand-rolled fake would end up testing the fake. +import { beforeEach, describe, expect, it } from "vitest"; +import { + ARTIST_CONTROLS, + formatArtistLabel, + syncArtistControls, + widenToFit, + wireArtistControls +} from "../../src/ui/artistControls"; + +describe("widenToFit", () => { + it("leaves the soft range alone for a value inside it", () => { + expect(widenToFit(20, 1, 60)).toEqual({ min: 1, max: 60 }); + }); + + it("extends rather than clamps a value above the soft range", () => { + // Steps accepts up to 150. A saved 90 must survive a theme switch intact. + expect(widenToFit(90, 1, 60)).toEqual({ min: 1, max: 90 }); + }); + + it("extends rather than clamps a value below the soft range", () => { + expect(widenToFit(0, 1, 60)).toEqual({ min: 0, max: 60 }); + }); + + it("keeps the soft range when the value is not a number", () => { + expect(widenToFit(Number.NaN, 1, 60)).toEqual({ min: 1, max: 60 }); + }); +}); + +describe("formatArtistLabel", () => { + const steps = ARTIST_CONTROLS.find((control) => control.inputId === "steps")!; + const cfg = ARTIST_CONTROLS.find((control) => control.inputId === "cfg")!; + const denoise = ARTIST_CONTROLS.find((control) => control.inputId === "img-denoise")!; + + it("leads with the artist word and keeps the jargon searchable", () => { + expect(formatArtistLabel(steps, 8)).toBe("Detail (steps): 8"); + expect(formatArtistLabel(cfg, 1)).toBe("Guidance (CFG): 1.0"); + }); + + it("shows denoise as a percentage rather than a decimal", () => { + expect(formatArtistLabel(denoise, 0.6)).toBe("Strength (denoise): 60%"); + expect(formatArtistLabel(denoise, 1)).toBe("Strength (denoise): 100%"); + }); + + it("does not invent a number when the input is empty", () => { + expect(formatArtistLabel(steps, Number.NaN)).toBe("Detail (steps): --"); + }); +}); + +function buildField(id: string, attrs: Record): HTMLElement { + const field = document.createElement("label"); + field.className = "field"; + const label = document.createElement("span"); + label.className = "label"; + label.textContent = id; + const input = document.createElement("input"); + input.className = "input input-compact"; + input.type = "number"; + input.id = id; + for (const [key, value] of Object.entries(attrs)) { + input.setAttribute(key, value); + } + field.append(label, input); + return field; +} + +describe("wireArtistControls", () => { + let root: HTMLElement; + + beforeEach(() => { + root = document.createElement("div"); + root.append( + buildField("steps", { min: "1", max: "150", step: "1", value: "8" }), + buildField("img-denoise", { min: "0.05", max: "1", step: "0.05", value: "0.6" }) + ); + document.body.replaceChildren(root); + }); + + it("injects a slider and label beside each number input it finds", () => { + expect(wireArtistControls(root)).toBe(2); + + const slider = root.querySelector("#steps-artist-slider")!; + expect(slider.type).toBe("range"); + // UXP ignores an implicit step, so it must be on the element. + expect(slider.step).toBe("1"); + expect(root.querySelector("#steps-artist-label")!.textContent).toBe("Detail (steps): 8"); + }); + + it("leaves the number input in the DOM as the source of truth", () => { + wireArtistControls(root); + expect(root.querySelector("#steps")!.value).toBe("8"); + }); + + it("writes through to the number input and notifies existing handlers", () => { + wireArtistControls(root); + const input = root.querySelector("#steps")!; + const slider = root.querySelector("#steps-artist-slider")!; + + const seen: string[] = []; + input.addEventListener("input", () => seen.push("input")); + input.addEventListener("change", () => seen.push("change")); + + slider.value = "24"; + slider.dispatchEvent(new Event("input")); + + expect(input.value).toBe("24"); + // Handlers bound before the slider existed must still fire untouched. + expect(seen).toEqual(["input", "change"]); + }); + + it("quantises a dragged value onto the declared step", () => { + wireArtistControls(root); + const input = root.querySelector("#img-denoise")!; + const slider = root.querySelector("#img-denoise-artist-slider")!; + + slider.value = "0.63"; + slider.dispatchEvent(new Event("input")); + + expect(Number(input.value)).toBe(0.65); + expect(root.querySelector("#img-denoise-artist-label")!.textContent).toBe("Strength (denoise): 65%"); + }); + + it("follows the number input when a preset writes to it", () => { + wireArtistControls(root); + const input = root.querySelector("#steps")!; + + input.value = "30"; + input.dispatchEvent(new Event("change")); + + expect(root.querySelector("#steps-artist-slider")!.value).toBe("30"); + expect(root.querySelector("#steps-artist-label")!.textContent).toBe("Detail (steps): 30"); + }); + + it("widens the slider instead of clamping a value past the soft range", () => { + wireArtistControls(root); + const input = root.querySelector("#steps")!; + const slider = root.querySelector("#steps-artist-slider")!; + + // 90 is legal for the typed input but past the slider's soft max of 60. + input.value = "90"; + input.dispatchEvent(new Event("change")); + + expect(slider.max).toBe("90"); + expect(input.value).toBe("90"); + }); + + it("does not inject a second slider when called twice", () => { + wireArtistControls(root); + expect(wireArtistControls(root)).toBe(0); + expect(root.querySelectorAll("#steps-artist-slider")).toHaveLength(1); + }); + + it("skips controls whose field is not on this screen", () => { + const partial = document.createElement("div"); + partial.append(buildField("steps", { min: "1", max: "150", step: "1", value: "8" })); + expect(wireArtistControls(partial)).toBe(1); + }); +}); + +describe("syncArtistControls", () => { + it("recovers a slider left stale by a silent value assignment", () => { + const root = document.createElement("div"); + root.append(buildField("steps", { min: "1", max: "150", step: "1", value: "8" })); + document.body.replaceChildren(root); + wireArtistControls(root); + + // No event dispatched -- this is the case the sync exists for. + root.querySelector("#steps")!.value = "42"; + expect(root.querySelector("#steps-artist-slider")!.value).toBe("8"); + + syncArtistControls(root); + + expect(root.querySelector("#steps-artist-slider")!.value).toBe("42"); + expect(root.querySelector("#steps-artist-label")!.textContent).toBe("Detail (steps): 42"); + }); +}); From 6a50077e68301386a31ae4ae4a33adc3a67480ad Mon Sep 17 00:00:00 2001 From: MehranMarxian Date: Fri, 21 Aug 2026 21:59:46 +0330 Subject: [PATCH 07/15] Fix the artist sliders leaking into Compact, and the crash on theme switch Three faults from the Photoshop test, two of them mine to have caught. CRASH (steps 90 in Compact, then switch to Artist). Nothing in the app listens to the steps/cfg/denoise inputs -- generation reads .value directly at submit time -- so the input/change events being re-dispatched had no real consumer, while the module's own sync listener WAS subscribed to them. UXP is not a browser DOM and may echo a programmatic .value assignment back as an `input` event; unguarded, that is unbounded recursion, and a stack overflow inside UXP takes Photoshop with it. A re-entrancy latch now makes re-entry impossible by construction, and `change` fires once when a drag ends rather than on every pixel of travel. There is a test that simulates the echo and requires it to settle. LEAK INTO COMPACT. The compact sheet carries ... .settings-grid > .field > input { display: block !important; width: 96px !important } An injected IS an input, so it matched at (0,5,1) with !important and beat a plain `display: none` -- [hidden] would have lost too. Fixed twice over: the slider now sits inside an .artist-row wrapper, and the compact rules use child combinators so a grandchild never matches; and the row is only built while the artist theme is active, torn out entirely on the way back, so Compact's DOM is byte-identical to before this feature existed. Removing beats hiding here, which is the same lesson as the nav separator. OVERSIZED NUMBER BOXES. Same root cause -- the injected elements were extra children inside a `display: grid` field, so the grid grew tracks for them. With the compact face hidden and the row nested, the field lays out as one block again. Hiding the compact face still has to outrank rules at (0,5,0), (0,5,1) and (0,6,0), all !important. .field is sometimes a
and sometimes a
@@ -864,7 +864,7 @@ export function createAppMarkup() {
Seed - +
@@ -978,7 +978,7 @@ export function createAppMarkup() {
Seed - +
@@ -1080,7 +1080,7 @@ export function createAppMarkup() {
Seed - +
diff --git a/src/ui/seedDice.ts b/src/ui/seedDice.ts index b4db04c..5fcdaf3 100644 --- a/src/ui/seedDice.ts +++ b/src/ui/seedDice.ts @@ -1,10 +1,10 @@ /** - * Artist-Friendly Dark's dice button for the seed fields. + * Artist-Friendly Dark's "Roll" button for the seed fields. * * Nobody should have to type a 64-bit integer as the primary way to pick a - * seed. The number input stays -- it is still the fastest way to reproduce a - * seed you already know, e.g. from History -- but a dice button sits beside - * it and rolls a fresh one on click. + * seed. The text input stays -- it is still the fastest way to reproduce a + * seed you already know, e.g. from History -- but a Roll button sits beside + * it and generates a fresh one on click. * * Built only while Artist-Friendly Dark is active and torn down on the way * back, same lifecycle as the sliders in artistControls.ts, for the same @@ -25,22 +25,20 @@ const SEED_INPUT_IDS = ["seed", "img-seed", "sketch-seed", "inpaint-seed", "outpaint-seed"]; /** - * Rolled seeds stop at 2^31-1, NOT Number.MAX_SAFE_INTEGER. + * The seed fields are , not type="number", and that is + * load-bearing -- see the note in appMarkup.ts. A UXP number input clamps at + * roughly 214748.36 (2147483647 scaled by 1/10000), so every seed wider than + * six digits came back as that identical mangled string. Two rounds of capping + * the roll chased that ceiling without reaching it; the input type was the + * actual cause. * - * A UXP cannot hold a value above the signed 32-bit - * maximum. Anything larger comes back out as "214748.36" -- 2147483647 with a - * decimal point pushed in -- and it is the SAME mangled value every time, so - * rolling appeared to do nothing. That display bug predates the dice button - * (it was visible on the seed field in earlier screenshots) but the button - * made it reproducible on demand. - * - * This range is not a compromise: 2^31 seeds is the range most tools expose, - * and generation is unaffected either way -- readSeed in comfy/settings.ts - * still rolls its own full-width seed server-side when the field is blank. + * With a text input there is no clamp, so this matches the range + * createRandomSeed in comfy/settings.ts already uses server-side. One concept + * of "a seed", one range. */ -const MAX_ROLLED_SEED = 2147483647; +const MAX_ROLLED_SEED = Number.MAX_SAFE_INTEGER; -/** A fresh seed in [0, 2^31-1), the widest range a UXP number input can hold. */ +/** A fresh seed in [0, MAX_SAFE_INTEGER), matching the server-side roll. */ export function rollSeed(): number { return Math.floor(Math.random() * MAX_ROLLED_SEED); } @@ -49,16 +47,19 @@ const ROW_CLASS = "seed-row"; const FIELD_CLASS = "has-seed-dice"; /** - * The die face is built from three plain pips, not an icon. + * The button is labelled with a word, not drawn as a die. + * + * Two icon techniques were tried in Photoshop and BOTH painted an empty box: + * an inline assigned through innerHTML, and three CSS-shape pips + * positioned on the diagonal. A data-URI SVG background renders nothing at + * all, and emoji/geometric glyphs are already known-unreliable here (hence the + * plain-text caret in bindAdvancedToggles). Text is the one thing in this host + * that has never failed to render. * - * An inline assigned through innerHTML rendered as an empty box in - * Photoshop -- the button painted, the glyph did not. Emoji and geometric - * glyphs are already known-unreliable here (see the plain-text caret in - * bindAdvancedToggles), and a data-URI SVG background renders nothing at all. - * CSS shapes are the one icon technique the spike confirmed does work, so the - * button's own border is the die outline and these are its pips. + * It also happens to be the better control for the audience this theme is for: + * "Roll" states what the button does, where a die face has to be interpreted. */ -const PIP_COUNT = 3; +const BUTTON_LABEL = "Roll"; function dispatch(target: HTMLElement, type: string): void { try { @@ -86,11 +87,7 @@ function build(input: HTMLInputElement): void { button.setAttribute("aria-label", "Roll a new seed"); button.title = "Roll a new seed"; - for (let index = 0; index < PIP_COUNT; index += 1) { - const pip = doc.createElement("span"); - pip.className = `seed-pip seed-pip-${index + 1}`; - button.appendChild(pip); - } + button.textContent = BUTTON_LABEL; field.classList.add(FIELD_CLASS); field.insertBefore(row, input); diff --git a/tests/ui/seedDice.test.ts b/tests/ui/seedDice.test.ts index c04c285..19d517d 100644 --- a/tests/ui/seedDice.test.ts +++ b/tests/ui/seedDice.test.ts @@ -7,14 +7,15 @@ import { beforeEach, describe, expect, it } from "vitest"; import { rollSeed, setSeedDiceEnabled } from "../../src/ui/seedDice"; describe("rollSeed", () => { - it("stays inside what a UXP number input can actually hold", () => { - // Above 2^31-1 a UXP
-

Alpha v0.15.0 · testers wanted

+

Alpha v0.16.0 · testers wanted

Five minutes to
your first AI layer.

OpenLayer is an alpha. It works on the author's machine, and that is exactly the problem — @@ -53,7 +53,7 @@

Five minutes to
your first AI layer.
    @@ -93,8 +93,8 @@

    Install, connect, generate, import, report.

    1

    Install OpenLayer

    -

    Download openlayer-v0.15.0-alpha.ccx from the release and double-click it. Creative Cloud installs the panel — no developer tool, nothing to unzip. Creative Cloud will warn that the plugin is not verified by Adobe, because it is not from Adobe Exchange; that prompt is expected. Keep the file on the same drive as Photoshop. Then open Photoshop: Plugins > OpenLayer.

    -

    If double-clicking does nothing at all — a known Windows 11 quirk — the release also has openlayer-v0.15.0-alpha.zip, which loads through the UXP Developer Tool: Add Plugin, select the unzipped manifest.json, then Load. And if that is what you had to do, please say so in your report — the one-click path has only ever been verified on Windows 11 with Photoshop 2025.

    +

    Download openlayer-v0.16.0-alpha.ccx from the release and double-click it. Creative Cloud installs the panel — no developer tool, nothing to unzip. Creative Cloud will warn that the plugin is not verified by Adobe, because it is not from Adobe Exchange; that prompt is expected. Keep the file on the same drive as Photoshop. Then open Photoshop: Plugins > OpenLayer.

    +

    If double-clicking does nothing at all — a known Windows 11 quirk — the release also has openlayer-v0.16.0-alpha.zip, which loads through the UXP Developer Tool: Add Plugin, select the unzipped manifest.json, then Load. And if that is what you had to do, please say so in your report — the one-click path has only ever been verified on Windows 11 with Photoshop 2025.

@@ -202,7 +202,7 @@

Ready?