diff --git a/docs/architecture-audit-2026-10.md b/docs/architecture-audit-2026-10.md index d08c9da..deccb3a 100644 --- a/docs/architecture-audit-2026-10.md +++ b/docs/architecture-audit-2026-10.md @@ -94,12 +94,15 @@ Download. Core по канону «не знает ни о ком». `findMaxColorsWithin`, `findQualityWithin`. Это доменная логика, не ввод-вывод (у файла 19 экспортов). -### M7. `clamp` продублирован 5 раз +### M7. `clamp` продублирован 5 раз — СДЕЛАНО (2026-10) -Идентичная локальная функция: `core/alpha.ts:167`, `core/background.ts:136`, -`core/color.ts:239`, `core/convolution.ts:181`, `registry-schema.ts:408`. Плюс -`clampInt` дважды: `convolution.ts:185` и `geometry.ts:288`. Прямое нарушение -инварианта «сначала искать». +Создан `web/src/lib/core/math.ts` (`clamp`, `clampInt`, `clamp01`, `clampByte` — +round-семантика `clampByte` и trunc-семантика `clampInt` сохранены раздельно); +локальные копии удалены из `core/alpha.ts`, `core/background.ts`, +`core/color.ts`, `core/convolution.ts`, `core/geometry.ts`, `core/palette.ts`, +`core/pixel-fx.ts`. Вариант с опциональными границами в `registry-schema.ts` +переведён на общий `clamp` через `?? -Infinity / ?? Infinity`. Копия в +`palette.test.ts` оставлена — тесты независимы. ### M8. Два параллельных hex-парсера @@ -108,12 +111,11 @@ Download. Core по канону «не знает ни о ком». (`affine.ts:31`, `geometry.ts:20`, `morphology.ts:123,150`), другая — второй (`masks.ts:25,98`, `pixel-fx.ts:195`, `registry/tools/generate.ts:36`). -### M9. Гард `localStorage` продублирован 4 раза +### M9. Гард `localStorage` продублирован 4 раза — СДЕЛАНО (2026-10) -Идентичный `typeof localStorage === "undefined" ? null : localStorage`: -`chains.svelte.ts:22-24`, `favorites.svelte.ts:8`, `i18n/locale.svelte.ts:12`, -`theme.svelte.ts:7-9`. Общий `storage()`-хелпер даст одну точку -SSR-безопасности. +Создан `web/src/lib/storage.ts` (`storage()` + `safeSetItem`); локальные гарды +удалены из `chains.svelte.ts`, `favorites.svelte.ts`, `i18n/locale.svelte.ts`, +`theme.svelte.ts`. ### M10. Логика темы дублируется между `app.html` и `theme.svelte.ts` — ОТКЛОНЕНО @@ -172,12 +174,11 @@ production). `clamp(var(--space-brand), 30vh, 60vh)` в `PreviewTile.svelte:96` и `SchemaResultTile.svelte:129`; магические `30vh/60vh` не в токенах. -### L7. `localStorage.setItem` без try/catch вне app.html +### L7. `localStorage.setItem` без try/catch вне app.html — СДЕЛАНО (2026-10) -`theme.svelte.ts:29`, `chains.svelte.ts`, `favorites.svelte.ts`, -`locale.svelte.ts`: в privacy-режимах `setItem` бросает; только inline-скрипт -`app.html:9-20` обёрнут в try/catch. SSR не ломается (typeof-гарды есть), но -клик по переключателю темы может уронить обработчик. +Все `setItem` в `theme.svelte.ts`, `chains.svelte.ts`, `favorites.svelte.ts`, +`locale.svelte.ts` переведены на `safeSetItem` из `web/src/lib/storage.ts` +(best-effort persistence: бросок в privacy-режиме проглатывается). ### L8. Переросшие файлы групп реестра и core diff --git a/web/src/lib/chains.svelte.ts b/web/src/lib/chains.svelte.ts index 166b1bb..ff8e726 100644 --- a/web/src/lib/chains.svelte.ts +++ b/web/src/lib/chains.svelte.ts @@ -2,6 +2,7 @@ import { formatStamp } from "./core/datefmt"; import type { OutputMime } from "./core/io"; import type { PixelImage } from "./core/types"; import { t } from "./i18n/t"; +import { safeSetItem, storage } from "./storage"; import { CHAIN_VERSION, parseChain, @@ -19,10 +20,6 @@ export interface NamedChain { const STORE_KEY = "chains:named"; -function storage(): Storage | null { - return typeof localStorage === "undefined" ? null : localStorage; -} - export function autoChainName(date = new Date()): string { return t("savedChains.autoName", { stamp: formatStamp(date, "YYYY-MM-DD hh:mm"), @@ -92,7 +89,7 @@ function readAll(): NamedChain[] { } function writeAll(chains: NamedChain[]): void { - storage()?.setItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains))); + safeSetItem(STORE_KEY, JSON.stringify(serializeNamedChains(chains))); } /** Newest first. */ diff --git a/web/src/lib/core/alpha.ts b/web/src/lib/core/alpha.ts index bcd908e..fe90798 100644 --- a/web/src/lib/core/alpha.ts +++ b/web/src/lib/core/alpha.ts @@ -1,5 +1,6 @@ import { createPixelImage, type PixelImage } from "./types"; import { ToolError } from "./errors"; +import { clamp } from "./math"; const MAX_COLOR_DISTANCE = Math.sqrt(3 * 255 * 255); @@ -163,7 +164,3 @@ export function parseHex(hex: string): [number, number, number] { parseInt(digits.slice(4, 6), 16), ]; } - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} diff --git a/web/src/lib/core/background.ts b/web/src/lib/core/background.ts index 000b5be..ca1c2d5 100644 --- a/web/src/lib/core/background.ts +++ b/web/src/lib/core/background.ts @@ -1,5 +1,6 @@ import { parseHex } from "./alpha"; import { createPixelImage, type PixelImage } from "./types"; +import { clamp } from "./math"; export type BackgroundOptions = { color: string; @@ -132,7 +133,3 @@ export function backgroundMaskPreview( } return out; } - -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} diff --git a/web/src/lib/core/color.ts b/web/src/lib/core/color.ts index 101d1f8..752b9e3 100644 --- a/web/src/lib/core/color.ts +++ b/web/src/lib/core/color.ts @@ -1,5 +1,6 @@ import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { ToolError } from "./errors"; +import { clamp } from "./math"; export type RgbChannel = "red" | "green" | "blue"; @@ -236,10 +237,6 @@ export function rgbToHex(r: number, g: number, b: number): string { return `#${byte(r)}${byte(g)}${byte(b)}`; } -function clamp(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, value)); -} - export function gammaCorrection(img: PixelImage, value: number): PixelImage { const g = clamp(value, 0.1, 5); const lut = new Uint8ClampedArray(256); diff --git a/web/src/lib/core/convolution.ts b/web/src/lib/core/convolution.ts index 610b50f..a69b7b7 100644 --- a/web/src/lib/core/convolution.ts +++ b/web/src/lib/core/convolution.ts @@ -1,5 +1,6 @@ import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; import { ToolError } from "./errors"; +import { clamp, clampInt } from "./math"; type Plane = Float64Array; @@ -177,11 +178,3 @@ function boxesForGauss(sigma: number, boxes: number): number[] { } return sizes; } - -function clamp(v: number, min: number, max: number): number { - return Math.min(max, Math.max(min, v)); -} - -function clampInt(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, Math.trunc(value))); -} diff --git a/web/src/lib/core/geometry.ts b/web/src/lib/core/geometry.ts index 38bad64..68952a9 100644 --- a/web/src/lib/core/geometry.ts +++ b/web/src/lib/core/geometry.ts @@ -1,6 +1,7 @@ import { parseHex } from "./alpha"; import { ToolError } from "./errors"; import { clonePixelImage, createPixelImage, type PixelImage } from "./types"; +import { clampInt } from "./math"; export function expandCanvas( img: PixelImage, @@ -285,10 +286,6 @@ export function sampleBilinear( return result; } -function clampInt(value: number, min: number, max: number): number { - return Math.min(max, Math.max(min, Math.trunc(value))); -} - export type Anchor9 = | "top-left" | "top-center" diff --git a/web/src/lib/core/math.ts b/web/src/lib/core/math.ts new file mode 100644 index 0000000..20c44fd --- /dev/null +++ b/web/src/lib/core/math.ts @@ -0,0 +1,15 @@ +export function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +export function clampInt(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, Math.trunc(value))); +} + +export function clamp01(value: number): number { + return clamp(value, 0, 1); +} + +export function clampByte(value: number): number { + return value < 0 ? 0 : value > 255 ? 255 : Math.round(value); +} diff --git a/web/src/lib/core/palette.ts b/web/src/lib/core/palette.ts index 2f89d88..f495832 100644 --- a/web/src/lib/core/palette.ts +++ b/web/src/lib/core/palette.ts @@ -1,6 +1,7 @@ import { ToolError } from "./errors"; import type { PixelImage } from "./types"; import { createPixelImage } from "./types"; +import { clamp01 } from "./math"; export type Rgb = { r: number; g: number; b: number }; export type Hsl = { h: number; s: number; l: number }; @@ -180,10 +181,6 @@ export function sortPalette(hexes: string[], key: SortKey): string[] { .map((s) => s.h); } -function clamp01(v: number): number { - return Math.min(1, Math.max(0, v)); -} - /** Horizontal equal swatch columns (strip) or a grid of ~square cells (grid). */ export function renderSwatches( colors: string[], diff --git a/web/src/lib/core/pixel-fx.ts b/web/src/lib/core/pixel-fx.ts index 3f01c02..06cabfe 100644 --- a/web/src/lib/core/pixel-fx.ts +++ b/web/src/lib/core/pixel-fx.ts @@ -2,6 +2,7 @@ import { hexToRgb } from "./palette"; import type { PixelImage } from "./types"; import { createPixelImage } from "./types"; import { gaussianBlur } from "./convolution"; +import { clampByte } from "./math"; /** Deterministic PRNG (mulberry32): same seed gives the same result. */ export function mulberry32(seed: number): () => number { @@ -15,10 +16,6 @@ export function mulberry32(seed: number): () => number { }; } -function clampByte(v: number): number { - return v < 0 ? 0 : v > 255 ? 255 : Math.round(v); -} - /** Averages each blockSize x blockSize block into a single color. */ export function pixelate(img: PixelImage, blockSize: number): PixelImage { const bs = Math.max(1, Math.round(blockSize)); diff --git a/web/src/lib/favorites.svelte.ts b/web/src/lib/favorites.svelte.ts index fc62847..3394bf0 100644 --- a/web/src/lib/favorites.svelte.ts +++ b/web/src/lib/favorites.svelte.ts @@ -1,13 +1,10 @@ import { SvelteSet } from "svelte/reactivity"; +import { safeSetItem, storage } from "./storage"; const KEY = "fav-tools"; const slugs = new SvelteSet(); -function storage(): Storage | null { - return typeof localStorage === "undefined" ? null : localStorage; -} - export function initFavorites(): void { const raw = storage()?.getItem(KEY); if (!raw) return; @@ -31,7 +28,7 @@ export function toggleFav(slug: string): void { } else { slugs.add(slug); } - storage()?.setItem(KEY, JSON.stringify([...slugs])); + safeSetItem(KEY, JSON.stringify([...slugs])); } /** Favorite pages from the list (order preserved). */ diff --git a/web/src/lib/i18n/locale.svelte.ts b/web/src/lib/i18n/locale.svelte.ts index 7d4b065..06c50d5 100644 --- a/web/src/lib/i18n/locale.svelte.ts +++ b/web/src/lib/i18n/locale.svelte.ts @@ -1,6 +1,7 @@ import { BASE_LOCALE, isLocale, type Dict, type Locale } from "./dict"; import { ru } from "./ru"; import { en } from "./en"; +import { safeSetItem, storage } from "../storage"; const STORAGE_KEY = "locale"; @@ -8,10 +9,6 @@ const DICTS: Record = { ru, en }; let locale = $state(BASE_LOCALE); -function storage(): Storage | null { - return typeof localStorage === "undefined" ? null : localStorage; -} - export function getLocale(): Locale { return locale; } @@ -39,7 +36,7 @@ export function getMergedDict(): Dict { export function setLocale(next: Locale): void { locale = next; - storage()?.setItem(STORAGE_KEY, next); + safeSetItem(STORAGE_KEY, next); syncLangAttr(); } diff --git a/web/src/lib/registry-schema.ts b/web/src/lib/registry-schema.ts index 68eee7f..3c43d7a 100644 --- a/web/src/lib/registry-schema.ts +++ b/web/src/lib/registry-schema.ts @@ -6,6 +6,7 @@ import type { TextFont } from "./core/domText"; import type { Position9 } from "./core/textdraw"; +import { clamp } from "./core/math"; /** * Optional field of every spec: the field's label key in the dictionary. @@ -405,12 +406,6 @@ export function resolveLayoutGroups

( return named; } -function clamp(value: number, min?: number, max?: number): number { - if (min !== undefined && value < min) return min; - if (max !== undefined && value > max) return max; - return value; -} - /** Field cap: from source dimensions under maxFromSource, else static. */ export function effectiveMax( spec: NumberSpec, @@ -444,7 +439,11 @@ export function clampSourceAwareMaxes

( if (spec.kind === "number" && spec.maxFromSource) { const v = out[key as string]; if (typeof v !== "number" || !Number.isFinite(v)) continue; - const clamped = clamp(v, spec.min, effectiveMax(spec, source)); + const clamped = clamp( + v, + spec.min ?? -Infinity, + effectiveMax(spec, source) ?? Infinity, + ); if (clamped !== v) { out[key as string] = clamped; changed = true; @@ -456,12 +455,12 @@ export function clampSourceAwareMaxes

( } const width = clamp( v.width, - spec.min, + spec.min ?? -Infinity, dimensionAxisMax(spec, "width", source), ); const height = clamp( v.height, - spec.min, + spec.min ?? -Infinity, dimensionAxisMax(spec, "height", source), ); if (width !== v.width || height !== v.height) { @@ -583,7 +582,7 @@ export function sanitizeSchemaParams

( spec.kind === "number" ? effectiveMax(spec, context?.source) : spec.max; - out[key] = clamp(n, spec.min, max); + out[key] = clamp(n, spec.min ?? -Infinity, max ?? Infinity); break; } case "select": diff --git a/web/src/lib/storage.ts b/web/src/lib/storage.ts new file mode 100644 index 0000000..ec129b2 --- /dev/null +++ b/web/src/lib/storage.ts @@ -0,0 +1,12 @@ +export function storage(): Storage | null { + return typeof localStorage === "undefined" ? null : localStorage; +} + +// setItem throws in privacy modes; persistence is best-effort. +export function safeSetItem(key: string, value: string): void { + try { + storage()?.setItem(key, value); + } catch { + // ignore + } +} diff --git a/web/src/lib/theme.svelte.ts b/web/src/lib/theme.svelte.ts index 071db80..4768c69 100644 --- a/web/src/lib/theme.svelte.ts +++ b/web/src/lib/theme.svelte.ts @@ -1,13 +1,11 @@ +import { safeSetItem } from "./storage"; + export type ThemeChoice = "light" | "dark"; const STORAGE_KEY = "theme"; let theme = $state("light"); -function storage(): Storage | null { - return typeof localStorage === "undefined" ? null : localStorage; -} - function systemTheme(): ThemeChoice { if (typeof window === "undefined" || !window.matchMedia) return "light"; return window.matchMedia("(prefers-color-scheme: dark)").matches @@ -26,7 +24,7 @@ export function getTheme(): ThemeChoice { export function setTheme(next: ThemeChoice): void { theme = next; - storage()?.setItem(STORAGE_KEY, next); + safeSetItem(STORAGE_KEY, next); apply(); }