diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md
index 84bc009..f33fec4 100644
--- a/docs/architecture-audit-2026-10.md
+++ b/docs/architecture-audit-2026-10.md
@@ -145,17 +145,19 @@ round-семантика `clampByte` и trunc-семантика `clampInt` со
## Low
-### L1. Флаг `running` общий для `run()` и `download()`
+### L1. Флаг `running` общий для `run()` и `download()` — СДЕЛАНО (2026-10)
-`SchemaToolView.svelte:124,433,469`: параллельный auto-run и клик по download
-гоняют один `$state`; индикация и `disabled` ведут себя некорректно.
+В `schema-tool-runner.svelte.ts` флаги разделены: `running` (run) и
+`downloading` (download); `download()` больше не затирает индикацию
+параллельного auto-run. Компонент передаёт в превью
+`busy = running || downloading`.
-### L2. Дублирование dismiss-логики поповеров
+### L2. Дублирование dismiss-логики поповеров — СДЕЛАНО (2026-10)
-`SchemaDownload.svelte:62-76` и `ToolPickerButton.svelte:25-39` — побуквенно
-одинаковые ~15 строк: pointerdown-outside + Escape + подписка на `window` в
-`$effect`. Кандидат на shared action (`clickOutside`/`popoverDismiss`). Сами
-эффекты корректны.
+Общий хелпер `onDismiss(root, close)` в `web/src/lib/components/ui/dismiss.ts`
+(pointerdown-outside + Escape + отписка); `SchemaDownload.svelte` и
+`ToolPickerButton.svelte` переведены на него — по ~15 одинаковых строк удалено
+из каждого.
### L3. Несогласованный API `Toggle`
@@ -178,10 +180,11 @@ round-семантика `clampByte` и trunc-семантика `clampInt` со
`["linear-gradient-png","resize-png","quantize-png"]` (kit — витрина, не
production).
-### L6. Дублирование CSS-значения превью-тайла
+### L6. Дублирование CSS-значения превью-тайла — СДЕЛАНО (2026-10)
-`clamp(var(--space-brand), 30vh, 60vh)` в `PreviewTile.svelte:96` и
-`SchemaResultTile.svelte:129`; магические `30vh/60vh` не в токенах.
+Новый токен `--size-tile-canvas-min: clamp(var(--space-brand), 30vh, 60vh)` в
+`app.css`; `PreviewTile.svelte` и `SchemaResultTile.svelte` используют
+`var(--size-tile-canvas-min)`.
### L7. `localStorage.setItem` без try/catch вне app.html — СДЕЛАНО (2026-10)
diff --git a/web/src/app.css b/web/src/app.css
index 96a3827..4633ea0 100644
--- a/web/src/app.css
+++ b/web/src/app.css
@@ -151,6 +151,7 @@
--size-preview-canvas-min-height-s: 180px;
--size-preview-canvas-min-height-l: 360px;
+ --size-tile-canvas-min: clamp(var(--space-brand), 30vh, 60vh);
--size-panel-min-height: 220px;
--size-tool-icon: 42px;
diff --git a/web/src/lib/components/schema/PreviewTile.svelte b/web/src/lib/components/schema/PreviewTile.svelte
index 79c6359..7fc657e 100644
--- a/web/src/lib/components/schema/PreviewTile.svelte
+++ b/web/src/lib/components/schema/PreviewTile.svelte
@@ -93,7 +93,7 @@
display: flex;
align-items: center;
justify-content: center;
- min-height: clamp(var(--space-brand), 30vh, 60vh);
+ min-height: var(--size-tile-canvas-min);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: hidden;
diff --git a/web/src/lib/components/schema/SchemaDownload.svelte b/web/src/lib/components/schema/SchemaDownload.svelte
index ebd72aa..ca7a444 100644
--- a/web/src/lib/components/schema/SchemaDownload.svelte
+++ b/web/src/lib/components/schema/SchemaDownload.svelte
@@ -1,6 +1,7 @@
diff --git a/web/src/lib/components/schema/SchemaResultTile.svelte b/web/src/lib/components/schema/SchemaResultTile.svelte
index 69d62f8..f6b6490 100644
--- a/web/src/lib/components/schema/SchemaResultTile.svelte
+++ b/web/src/lib/components/schema/SchemaResultTile.svelte
@@ -126,7 +126,7 @@
ломал parts-grid в одну колонку. */
width: 100%;
align-self: stretch;
- min-height: clamp(var(--space-brand), 30vh, 60vh);
+ min-height: var(--size-tile-canvas-min);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
overflow: auto;
diff --git a/web/src/lib/components/schema/SchemaToolView.svelte b/web/src/lib/components/schema/SchemaToolView.svelte
index 9fbba8e..00ec979 100644
--- a/web/src/lib/components/schema/SchemaToolView.svelte
+++ b/web/src/lib/components/schema/SchemaToolView.svelte
@@ -142,6 +142,8 @@
});
const { run, download, copyText, downloadText } = runner;
const running = $derived(runner.running);
+ // Display-level busy: either a run or a download is in flight.
+ const busy = $derived(running || runner.downloading);
const result = $derived(runner.result);
const fileResult = $derived(runner.fileResult);
const textResult = $derived(runner.textResult);
@@ -499,7 +501,7 @@
toolId={lastTool.id}
{inputMode}
resultKind={displayResultKind}
- {running}
+ running={busy}
error={errorText}
{aligned}
ontogglealign={() => (aligned = !aligned)}
@@ -561,7 +563,7 @@
{source}
{sourceFiles}
{textSource}
- {running}
+ running={busy}
resultKind={displayResultKind}
{result}
{resultNote}
diff --git a/web/src/lib/components/schema/ToolPickerButton.svelte b/web/src/lib/components/schema/ToolPickerButton.svelte
index b4a00dd..6e3048a 100644
--- a/web/src/lib/components/schema/ToolPickerButton.svelte
+++ b/web/src/lib/components/schema/ToolPickerButton.svelte
@@ -1,5 +1,6 @@
diff --git a/web/src/lib/components/schema/schema-tool-runner.svelte.ts b/web/src/lib/components/schema/schema-tool-runner.svelte.ts
index d16067c..febcf2e 100644
--- a/web/src/lib/components/schema/schema-tool-runner.svelte.ts
+++ b/web/src/lib/components/schema/schema-tool-runner.svelte.ts
@@ -40,7 +40,10 @@ export interface SchemaToolRunnerCtx {
}
interface RunnerState {
+ // run() and download() have separate flags: a parallel auto-run and a
+ // download click must not clobber each other's indicator/disabled state.
running: boolean;
+ downloading: boolean;
// Generation of the latest run(): a stale in-flight chain (params changed
// while the worker was busy) must not overwrite newer results.
runGen: number;
@@ -138,7 +141,7 @@ async function run(s: RunnerState, ctx: SchemaToolRunnerCtx) {
async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
if (!ctx.schema()) return;
- s.running = true;
+ s.downloading = true;
s.displayError = null;
try {
if (s.fileResult) {
@@ -157,7 +160,7 @@ async function download(s: RunnerState, ctx: SchemaToolRunnerCtx) {
} catch (e) {
s.displayError = toDisplayError(e);
} finally {
- s.running = false;
+ s.downloading = false;
}
}
@@ -198,6 +201,7 @@ function clearResults(s: RunnerState) {
export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
const s = $state({
running: false,
+ downloading: false,
runGen: 0,
result: null,
fileResult: null,
@@ -213,6 +217,9 @@ export function createSchemaToolRunner(ctx: SchemaToolRunnerCtx) {
get running() {
return s.running;
},
+ get downloading() {
+ return s.downloading;
+ },
get result() {
return s.result;
},
diff --git a/web/src/lib/components/ui/dismiss.ts b/web/src/lib/components/ui/dismiss.ts
new file mode 100644
index 0000000..f27890f
--- /dev/null
+++ b/web/src/lib/components/ui/dismiss.ts
@@ -0,0 +1,19 @@
+// Popover dismiss: close on pointerdown outside the root and on Escape.
+// Subscribe inside `$effect` while the popover is open; returns unsubscribe.
+export function onDismiss(
+ root: () => HTMLElement | null,
+ close: () => void,
+): () => void {
+ function onPointerDown(e: PointerEvent) {
+ if (!root()?.contains(e.target as Node)) close();
+ }
+ function onKey(e: KeyboardEvent) {
+ if (e.key === "Escape") close();
+ }
+ window.addEventListener("pointerdown", onPointerDown);
+ window.addEventListener("keydown", onKey);
+ return () => {
+ window.removeEventListener("pointerdown", onPointerDown);
+ window.removeEventListener("keydown", onKey);
+ };
+}