feat: fix tools pages, dom audit fix

This commit is contained in:
2026-08-29 09:08:09 +05:00
parent b695f5d58c
commit 9ef6ae03c6
8 changed files with 530 additions and 506 deletions
+2 -1
View File
@@ -8,7 +8,8 @@
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build",
"format": "pnpm --dir web format",
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html"
"html": "pnpm --dir refs build && node scripts/extract-static.mjs refs/.next/server/app refs-html",
"dom-audit": "pnpm --dir web refs-dom-audit"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
+15 -221
View File
@@ -1,5 +1,5 @@
{
"generatedAt": "2026-08-29T02:50:59.952Z",
"generatedAt": "2026-08-29T04:04:14.643Z",
"reports": [
{
"route": "/preview/demo",
@@ -30,233 +30,27 @@
{
"route": "/preview/tools/linear-gradient-png",
"ref": "gradient.html",
"deltas": [
{
"path": "body > main:1 > div:2 > section:1",
"type": "tagMismatch",
"ours": "section",
"ref": "div"
},
{
"path": "body > main:1 > div:2 > section:1",
"type": "textMismatch",
"ours": "",
"ref": "PNG PROCESSING SINGLE TOOL"
},
{
"path": "body > main:1 > div:2 > section:1 > section:1",
"type": "added",
"tag": "section",
"text": "",
"nodes": 145
},
{
"path": "body > main:1 > div:2 > section:1 > span:1",
"type": "removed",
"tag": "span",
"text": "/",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2",
"type": "tagMismatch",
"ours": "section",
"ref": "div"
},
{
"path": "body > main:1 > div:2 > section:2 > header:1",
"type": "added",
"tag": "header",
"text": "",
"nodes": 5
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > div:1",
"type": "added",
"tag": "div",
"text": "",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > div:2",
"type": "added",
"tag": "div",
"text": "",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > h1:1",
"type": "removed",
"tag": "h1",
"text": "Gradient background.",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > p:2",
"type": "removed",
"tag": "p",
"text": "Create a clean, export-ready gradient with precise control over color, direction and transparency.",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > span:2",
"type": "removed",
"tag": "span",
"text": "LIVE PREVIEW",
"nodes": 2
},
{
"path": "body > main:1 > div:2 > div:3",
"type": "removed",
"tag": "div",
"text": "",
"nodes": 66
},
{
"path": "body > main:1 > footer:3 > span:2",
"type": "added",
"tag": "span",
"text": "pipeline is local-only",
"nodes": 2
},
{
"path": "body > main:1 > footer:3 > span:2",
"type": "removed",
"tag": "span",
"text": "gradient tool · local-only",
"nodes": 1
}
],
"deltas": [],
"counts": {
"added": 5,
"removed": 6,
"tagMismatch": 2,
"textMismatch": 1,
"nodesAdded": 154,
"nodesRemoved": 72
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
},
{
"route": "/preview/tools/remove-background-png",
"ref": "background-remover.html",
"deltas": [
{
"path": "body > main:1 > div:2 > section:1",
"type": "tagMismatch",
"ours": "section",
"ref": "div"
},
{
"path": "body > main:1 > div:2 > section:1",
"type": "textMismatch",
"ours": "",
"ref": "PNG PROCESSING SINGLE TOOL"
},
{
"path": "body > main:1 > div:2 > section:1 > section:1",
"type": "added",
"tag": "section",
"text": "",
"nodes": 138
},
{
"path": "body > main:1 > div:2 > section:1 > span:1",
"type": "removed",
"tag": "span",
"text": "/",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2",
"type": "tagMismatch",
"ours": "section",
"ref": "div"
},
{
"path": "body > main:1 > div:2 > section:2 > header:1",
"type": "added",
"tag": "header",
"text": "",
"nodes": 5
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > div:1",
"type": "added",
"tag": "div",
"text": "",
"nodes": 4
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > div:2",
"type": "added",
"tag": "div",
"text": "",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > div:3",
"type": "added",
"tag": "div",
"text": "",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > h1:1",
"type": "removed",
"tag": "h1",
"text": "Remove background.",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > div:2 > p:2",
"type": "removed",
"tag": "p",
"text": "Select a background color and tune the edge detection. Changes are processed automatically in your browser.",
"nodes": 1
},
{
"path": "body > main:1 > div:2 > section:2 > span:2",
"type": "removed",
"tag": "span",
"text": "LIVE PREVIEW",
"nodes": 2
},
{
"path": "body > main:1 > div:2 > div:3",
"type": "removed",
"tag": "div",
"text": "",
"nodes": 71
},
{
"path": "body > main:1 > footer:3 > span:2",
"type": "added",
"tag": "span",
"text": "pipeline is local-only",
"nodes": 2
},
{
"path": "body > main:1 > footer:3 > span:2",
"type": "removed",
"tag": "span",
"text": "background remover · local-only",
"nodes": 1
},
{
"path": "body > div:2",
"type": "added",
"tag": "div",
"text": "",
"nodes": 1
}
],
"deltas": [],
"counts": {
"added": 7,
"removed": 6,
"tagMismatch": 2,
"textMismatch": 1,
"nodesAdded": 152,
"nodesRemoved": 77
"added": 0,
"removed": 0,
"tagMismatch": 0,
"textMismatch": 0,
"nodesAdded": 0,
"nodesRemoved": 0
}
}
]
+9 -37
View File
@@ -1,6 +1,6 @@
# DOM audit (phase A)
_2026-08-29T02:50:59.953Z_
_2026-08-29T04:04:14.645Z_
## Сводка
@@ -8,8 +8,8 @@ _2026-08-29T02:50:59.953Z_
|---|---|---|---|---|---|
| `/preview/demo` | demo.html | 0 | 0 | 0 | 0 |
| `/preview/list-tools` | list-tools.html | 0 | 0 | 0 | 0 |
| `/preview/tools/linear-gradient-png` | gradient.html | 5 | 6 | 2 | 1 |
| `/preview/tools/remove-background-png` | background-remover.html | 7 | 6 | 2 | 1 |
| `/preview/tools/linear-gradient-png` | gradient.html | 0 | 0 | 0 | 0 |
| `/preview/tools/remove-background-png` | background-remover.html | 0 | 0 | 0 | 0 |
## /preview/demo (vs demo.html)
@@ -31,45 +31,17 @@ _расхождений нет_
## /preview/tools/linear-gradient-png (vs gradient.html)
- added: **5** (≈154 узл.), removed: **6** (≈72 узл.), tag: **2**, text: **1**
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 14)
### Расхождения (топ 0)
- `body > main:1 > div:2 > section:1` **tag**: ours `section` → ref `div`
- `body > main:1 > div:2 > section:1` **text**: ours `` → ref `PNG PROCESSING SINGLE TOOL`
- `body > main:1 > div:2 > section:1 > section:1` **added** (`section` · 145 узл.)
- `body > main:1 > div:2 > section:1 > span:1` **removed** (`span` "/" · 1 узл.)
- `body > main:1 > div:2 > section:2` **tag**: ours `section` → ref `div`
- `body > main:1 > div:2 > section:2 > header:1` **added** (`header` · 5 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > div:1` **added** (`div` · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > div:2` **added** (`div` · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > h1:1` **removed** (`h1` "Gradient background." · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > p:2` **removed** (`p` "Create a clean, export-ready gradient with precise control over color, direction and transparency." · 1 узл.)
- `body > main:1 > div:2 > section:2 > span:2` **removed** (`span` "LIVE PREVIEW" · 2 узл.)
- `body > main:1 > div:2 > div:3` **removed** (`div` · 66 узл.)
- `body > main:1 > footer:3 > span:2` **added** (`span` "pipeline is local-only" · 2 узл.)
- `body > main:1 > footer:3 > span:2` **removed** (`span` "gradient tool · local-only" · 1 узл.)
_расхождений нет_
## /preview/tools/remove-background-png (vs background-remover.html)
- added: **7** (≈152 узл.), removed: **6** (≈77 узл.), tag: **2**, text: **1**
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
### Расхождения (топ 16)
### Расхождения (топ 0)
- `body > main:1 > div:2 > section:1` **tag**: ours `section` → ref `div`
- `body > main:1 > div:2 > section:1` **text**: ours `` → ref `PNG PROCESSING SINGLE TOOL`
- `body > main:1 > div:2 > section:1 > section:1` **added** (`section` · 138 узл.)
- `body > main:1 > div:2 > section:1 > span:1` **removed** (`span` "/" · 1 узл.)
- `body > main:1 > div:2 > section:2` **tag**: ours `section` → ref `div`
- `body > main:1 > div:2 > section:2 > header:1` **added** (`header` · 5 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > div:1` **added** (`div` · 4 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > div:2` **added** (`div` · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > div:3` **added** (`div` · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > h1:1` **removed** (`h1` "Remove background." · 1 узл.)
- `body > main:1 > div:2 > section:2 > div:2 > p:2` **removed** (`p` "Select a background color and tune the edge detection. Changes are processed automatically in your browser." · 1 узл.)
- `body > main:1 > div:2 > section:2 > span:2` **removed** (`span` "LIVE PREVIEW" · 2 узл.)
- `body > main:1 > div:2 > div:3` **removed** (`div` · 71 узл.)
- `body > main:1 > footer:3 > span:2` **added** (`span` "pipeline is local-only" · 2 узл.)
- `body > main:1 > footer:3 > span:2` **removed** (`span` "background remover · local-only" · 1 узл.)
- `body > div:2` **added** (`div` · 1 узл.)
_расхождений нет_
+30 -18
View File
@@ -6,7 +6,6 @@
import { chromium } from "playwright";
import { spawn } from "node:child_process";
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { pathToFileURL } from "node:url";
import { resolve, join } from "node:path";
const args = (() => {
@@ -145,6 +144,9 @@ const snapshot = (page) =>
const walk = (el) => {
const tag = el.tagName.toLowerCase();
if (["script", "style", "noscript", "template"].includes(tag)) return null;
// SvelteKit ін'єктить #svelte-announcer (aria-live) прямо в <body>;
// у рефа (Next.js) його немає — це фреймворк-шум, не збіг дизайну.
if (el.id === "svelte-announcer") return null;
// Пропускаем не отображаемые узлы (Next-боилерплейт: <div hidden>,
// оверлеи), чтобы они не конкурировали за матчинг с реальным контентом.
if (el.hasAttribute("hidden")) return null;
@@ -261,19 +263,29 @@ function tally(deltas) {
return c;
}
async function auditOne(page, target) {
async function auditOne(browser, target) {
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
const refUrl = pathToFileURL(resolve(REFS_DIR, target.ref)).href;
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
await page.goto(oursUrl, { waitUntil: "load" });
await page.evaluate(() => document.fonts && document.fonts.ready);
await page.waitForTimeout(300);
await page.waitForTimeout(600);
const ours = await snapshot(page);
await page.close();
await page.goto(refUrl, { waitUntil: "load" });
await page.evaluate(() => document.fonts && document.fonts.ready);
await page.waitForTimeout(300);
const ref = await snapshot(page);
// Реф грузим через goto(file://) в ИЗОЛИРОВАННОМ контексте: так http-страница
// и статический реф не делят один execution context (переход http → file в
// одной странице уничтожал контекст и ронял прогон). setContent не годится —
// он не воспроизводит DOM рефа (губит 1 узел), поэтому используем goto.
const refUrl = "file:///" + resolve(REFS_DIR, target.ref).replace(/\\/g, "/");
const ctx = await browser.newContext();
const refPage = await ctx.newPage();
await refPage.goto(refUrl, { waitUntil: "load" });
await refPage.waitForTimeout(400);
const ref = await snapshot(refPage);
await ctx.close();
const deltas = [];
diff(ours, ref, "body", deltas);
@@ -348,18 +360,18 @@ async function main() {
}
const browser = await chromium.launch();
const page = await browser.newPage({
viewport: { width: 1440, height: 900 },
deviceScaleFactor: 1,
});
const reports = [];
try {
for (const target of targets) {
const rep = await auditOne(page, target);
reports.push(rep);
console.log(
`[dom-audit] ${rep.route}: added=${rep.counts.added} removed=${rep.counts.removed} tag=${rep.counts.tagMismatch} text=${rep.counts.textMismatch}`,
);
try {
const rep = await auditOne(browser, target);
reports.push(rep);
console.log(
`[dom-audit] ${rep.route}: added=${rep.counts.added} removed=${rep.counts.removed} tag=${rep.counts.tagMismatch} text=${rep.counts.textMismatch}`,
);
} catch (e) {
console.error(`[dom-audit] ${target.route} failed: ${e.message}`);
}
}
mkdirSync(resolve(WEB, "audit"), { recursive: true });
writeFileSync(
+281
View File
@@ -0,0 +1,281 @@
<script lang="ts">
import type { ToolEntry } from "$lib/registry";
import {
getPreviewView,
initForm,
type FieldValue,
type PreviewToolView,
type SegmentedField,
} from "$lib/preview/tool-views";
import { Copy, Download, Check, RotateCcw, ChevronDown } from "@lucide/svelte";
interface Props {
tool: ToolEntry;
}
let { tool }: Props = $props();
const view = $derived(getPreviewView(tool));
let form = $state<Record<string, FieldValue>>(view ? initForm(view) : {});
$effect(() => {
if (view) form = initForm(view);
});
function reset() {
if (view) form = initForm(view);
}
const num = (v: FieldValue) => v as number;
const str = (v: FieldValue) => v as string;
const bool = (v: FieldValue) => v as boolean;
const pair = (v: FieldValue) => v as { from: string; to: string };
const grad = $derived.by(() => {
if (!view || view.preview !== "gradient") return null;
const typeSel = num(form["type"]);
const type = (view.fields.find((f) => f.id === "type") as SegmentedField).options[typeSel];
const cp = pair(form["stops"]);
const dir = num(form["dir"]);
const op = num(form["op"]);
const css =
type === "Radial"
? `radial-gradient(circle, ${cp.from} 0%, ${cp.to} 100%)`
: `linear-gradient(${dir}deg, ${cp.from} 0%, ${cp.to} 100%)`;
return { type, cp, dir, op, css, code: `background: ${css};\nopacity: ${op / 100};` };
});
const DIRS = [0, 45, 90, 135, 180, 225, 270, 315];
const DIR_GLYPHS = ["→", "↗", "↑", "↖", "←", "↙", "↓", "↘"];
const SUBJECT_BG = "linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)";
const SUBJECT_CLIP = "polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)";
const CHECKER = "repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px";
</script>
{#if view}
<div class="tool-page">
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
<div class="tool-title">
<div>
<h1>{view.title}</h1>
<p class="lede">{view.lede}</p>
</div>
<span class="tool-status"><i></i> LIVE PREVIEW</span>
</div>
<div class={view.layoutClass}>
<section class="settings-panel">
<div class="panel-heading">
<div>
<span class="label">{view.panelLabel}</span>
<strong>{view.panelStrong}</strong>
</div>
<span class="step-type">{view.stepType}</span>
</div>
{#each view.fields as f}
{#if f.kind === "segmented"}
{@const sel = num(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="segmented wide">
{#each f.options as opt, i}
<button class={i === sel ? "selected" : ""} onclick={() => (form[f.id] = i)}>
{opt}
</button>
{/each}
</div>
</div>
{:else if f.kind === "color-pair"}
{@const cp = pair(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="color-row">
<div class="color-field">
<span class="swatch" style="background:{cp.from}"></span>
<input
value={cp.from}
oninput={(e) => (form[f.id] = { from: e.currentTarget.value, to: cp.to })}
/>
</div>
<span class="stop-arrow"></span>
<div class="color-field">
<span class="swatch" style="background:{cp.to}"></span>
<input
value={cp.to}
oninput={(e) => (form[f.id] = { from: cp.from, to: e.currentTarget.value })}
/>
</div>
</div>
<div class="gradient-bar" style="background:{grad?.css}"></div>
</div>
{:else if f.kind === "slider" || f.kind === "direction"}
{@const v = num(form[f.id])}
<div class="setting-group">
<label>{f.label} <output>{v}{f.space ? " " : ""}{f.suffix}</output></label>
<input
type="range"
min={f.min}
max={f.max}
value={v}
oninput={(e) => (form[f.id] = Number(e.currentTarget.value))}
/>
{#if f.kind === "direction"}
<div class="direction-grid">
{#each DIRS as a, i}
<button class={a === v ? "selected" : ""} onclick={() => (form[f.id] = a)}>
{a}° {DIR_GLYPHS[i]}
</button>
{/each}
</div>
{:else if f.hints}
<div class="range-hints">
<span>{f.hints[0]}</span>
<span>{f.hints[1]}</span>
</div>
{/if}
</div>
{:else if f.kind === "color"}
{@const v = str(form[f.id])}
<div class="setting-group">
<label>{f.label}</label>
<div class="color-field">
<span class="swatch" style="background:{v}"></span>
<input value={v} oninput={(e) => (form[f.id] = e.currentTarget.value)} />
{#if f.native}
<input
class="native-color"
type="color"
aria-label="Choose background color"
value={v}
oninput={(e) => (form[f.id] = e.currentTarget.value)}
/>
{/if}
</div>
{#if f.reference}
<div class="color-reference">
<span style="background:{v}"></span> sampled from image background
</div>
{/if}
</div>
{:else if f.kind === "toggle"}
{@const on = bool(form[f.id])}
<div class="setting-group toggle-group">
<label>
<span>{f.label}</span>
<b class="toggle" class:on onclick={() => (form[f.id] = !on)}></b>
</label>
<p>{f.note}</p>
</div>
{/if}
{/each}
<div class="settings-footer">
<button class="reset-btn" onclick={reset}>
<RotateCcw size={14} /> Reset
</button>
<span class="auto-note"><i></i> updates automatically</span>
</div>
</section>
{#if view.preview === "gradient"}
<section class={view.previewClass}>
<div class="preview-toolbar">
<div>
<span class="label">{view.toolbarLabel}</span>
<strong>{view.fileName}</strong>
</div>
<div class="preview-actions">
<button class="secondary-btn"><Copy size={15} /> Copy CSS</button>
<button class="download-btn"
><Download size={15} /> {view.downloadLabel} <ChevronDown size={14} /></button
>
</div>
</div>
<div class="large-canvas">
<div
class="gradient-art"
style="background:{grad?.css};opacity:{grad ? grad.op / 100 : 1}"
>
<div class="art-mark">PNG</div>
<span>easy-png-tools</span>
</div>
</div>
<div class="code-block">
<div>
<span class="label">GENERATED CSS</span>
<button class="icon-btn" aria-label="Copy CSS"><Copy size={14} /></button>
</div>
<pre>{grad?.code}</pre>
</div>
</section>
{:else}
<section class={view.previewClass}>
<div class="preview-toolbar">
<div>
<span class="label">{view.toolbarLabel}</span>
<strong>{view.fileName}</strong>
</div>
<div class="preview-actions">
<span class="processed"><Check size={14} /> processed</span>
<button class="download-btn"><Download size={15} /> Download result</button>
</div>
</div>
<div class="comparison-grid">
<div class="image-card">
<div class="image-label"><span>SOURCE</span><b>original.png</b></div>
<div class="remover-canvas source-canvas">
<div
class="subject subject-source"
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
>
<span>OBJECT</span>
</div>
<span class="canvas-size">1200 × 800</span>
</div>
</div>
<div class="image-card">
<div class="image-label"><span>RESULT</span><b>removed-bg.png</b></div>
<div class="remover-canvas" style="background:{CHECKER}">
<div
class="subject"
style="background:{SUBJECT_BG};opacity:1;clip-path:{SUBJECT_CLIP}"
>
<span>PNG</span>
</div>
<span class="canvas-size">1200 × 800</span>
</div>
</div>
</div>
<div class="result-meta">
<span>FORMAT <b>PNG-24</b></span>
<span>ALPHA <b>ENABLED</b></span>
<span>SIMILARITY <b>72 %</b></span>
</div>
</section>
{/if}
</div>
</div>
{:else}
<div class="tool-page">
<div class="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div>
<div class="tool-title">
<div>
<h1>{tool.title}</h1>
<p class="lede">{tool.description}</p>
</div>
<span class="tool-status"><i></i> LIVE PREVIEW</span>
</div>
<div class="tool-layout">
<section class="settings-panel">
<div class="panel-heading">
<div>
<span class="label">{tool.category.toUpperCase()} SETTINGS</span>
<strong>Configure output</strong>
</div>
<span class="step-type">TOOL 01</span>
</div>
<div class="settings-footer">
<span class="auto-note"><i></i> preview coming soon</span>
</div>
</section>
</div>
</div>
{/if}
+178
View File
@@ -0,0 +1,178 @@
import type { ToolEntry } from "$lib/registry";
export type SegmentedField = {
id: string;
kind: "segmented";
label: string;
options: string[];
selected: number;
};
export type ColorPairField = {
id: string;
kind: "color-pair";
label: string;
from: string;
to: string;
};
export type SliderField = {
id: string;
kind: "slider" | "direction";
label: string;
value: number;
min: number;
max: number;
suffix: string;
space?: boolean;
hints?: [string, string];
};
export type ColorField = {
id: string;
kind: "color";
label: string;
value: string;
native?: boolean;
reference?: boolean;
};
export type ToggleField = {
id: string;
kind: "toggle";
label: string;
checked: boolean;
note: string;
};
export type FieldDef =
| SegmentedField
| ColorPairField
| SliderField
| ColorField
| ToggleField;
export interface PreviewToolView {
title: string;
lede: string;
panelLabel: string;
panelStrong: string;
stepType: string;
toolbarLabel: string;
fileName: string;
downloadLabel: string;
layoutClass: string;
previewClass: string;
preview: "gradient" | "comparison";
fields: FieldDef[];
}
export type FieldValue = number | string | boolean | { from: string; to: string };
function initValue(f: FieldDef): FieldValue {
switch (f.kind) {
case "segmented":
return f.selected;
case "color-pair":
return { from: f.from, to: f.to };
case "color":
return f.value;
case "slider":
case "direction":
return f.value;
case "toggle":
return f.checked;
}
}
export function initForm(view: PreviewToolView): Record<string, FieldValue> {
const form: Record<string, FieldValue> = {};
for (const f of view.fields) form[f.id] = initValue(f);
return form;
}
const gradient: PreviewToolView = {
title: "Gradient background.",
lede: "Create a clean, export-ready gradient with precise control over color, direction and transparency.",
panelLabel: "GRADIENT SETTINGS",
panelStrong: "Configure output",
stepType: "TOOL 01",
toolbarLabel: "OUTPUT PREVIEW",
fileName: "gradient.png",
downloadLabel: "Download PNG",
layoutClass: "gradient-layout",
previewClass: "gradient-preview",
preview: "gradient",
fields: [
{
id: "type",
kind: "segmented",
label: "GRADIENT TYPE",
options: ["Linear", "Radial"],
selected: 0,
},
{
id: "stops",
kind: "color-pair",
label: "COLOR STOPS",
from: "#1769D2",
to: "#00A8C7",
},
{ id: "dir", kind: "direction", label: "DIRECTION", value: 135, min: 0, max: 360, suffix: "°", space: true },
{ id: "op", kind: "slider", label: "OPACITY", value: 100, min: 0, max: 100, suffix: "%", space: true },
],
};
const background: PreviewToolView = {
title: "Remove background.",
lede: "Select a background color and tune the edge detection. Changes are processed automatically in your browser.",
panelLabel: "REMOVER SETTINGS",
panelStrong: "Configure detection",
stepType: "TOOL 02",
toolbarLabel: "SOURCE / RESULT",
fileName: "comparison.png",
downloadLabel: "Download result",
layoutClass: "remover-layout",
previewClass: "remover-preview",
preview: "comparison",
fields: [
{
id: "color",
kind: "color",
label: "BACKGROUND COLOR",
value: "#E8EEF2",
native: true,
reference: true,
},
{
id: "sim",
kind: "slider",
label: "COLOR SIMILARITY",
value: 72,
min: 0,
max: 100,
suffix: "%",
space: true,
hints: ["strict edges", "more removal"],
},
{
id: "outer",
kind: "toggle",
label: "OUTER COLOR ONLY",
checked: true,
note: "Only remove connected background pixels from the edges.",
},
{
id: "mask",
kind: "toggle",
label: "SHOW MASK",
checked: false,
note: "Preview the detected transparency mask.",
},
],
};
export const PREVIEW_TOOL_VIEWS: Record<string, PreviewToolView> = {
"linear-gradient-png": gradient,
"remove-background-png": background,
};
export function getPreviewView(tool: ToolEntry): PreviewToolView | undefined {
return PREVIEW_TOOL_VIEWS[tool.id];
}
+9 -1
View File
@@ -34,6 +34,12 @@
let crumb = $derived(toCrumb($page.url.pathname));
let status = $derived(toStatus($page.url.pathname));
const FOOTER_NOTE: Record<string, string> = {
"/preview/tools/linear-gradient-png": "gradient tool · local-only",
"/preview/tools/remove-background-png": "background remover · local-only",
};
let footerNote = $derived(FOOTER_NOTE[$page.url.pathname] ?? "pipeline is local-only");
const CRUMB: Record<string, string> = {
"/preview/demo": "WORKSPACE",
"/preview/list-tools": "CATALOG",
@@ -63,7 +69,9 @@
{#if $page.url.pathname !== "/preview/list-tools"}
<footer class="preview-footer">
<span>easy-png-tools <b>v2.4.0</b></span>
<span><Link2 size={13} /> pipeline is local-only</span>
<span
>{#if footerNote === "pipeline is local-only"}<Link2 size={13} /> {/if}{footerNote}</span
>
<span>© 2024</span>
</footer>
{/if}
+6 -228
View File
@@ -1,211 +1,23 @@
<script lang="ts">
import { browser } from "$app/environment";
import { untrack } from "svelte";
import {
getTool,
defaultParams,
isChainable,
TOOLS,
} from "$lib/registry";
import { encode, downloadBlob, toDataUrl, decodeBytes } from "$lib/core/io";
import type { PixelImage } from "$lib/core/types";
import WorkspaceLayout from "$lib/components/kit/WorkspaceLayout.svelte";
import { getTool } from "$lib/registry";
import ToolView from "$lib/components/kit/ToolView.svelte";
import EmptyState from "$lib/components/kit/EmptyState.svelte";
import { SlidersHorizontal as ToolIcon } from "@lucide/svelte";
import SettingsPanel from "./SettingsPanel.svelte";
import PreviewPanel from "./PreviewPanel.svelte";
type ParamValue = string | number | boolean;
interface ChainStep {
toolId: string;
params: Record<string, ParamValue>;
}
interface Props {
data: { id: string };
}
let { data }: Props = $props();
const initialTool = untrack(() => getTool(data.id));
let steps = $state<ChainStep[]>(
initialTool
? [
{
toolId: initialTool.id,
params: defaultParams(initialTool) as Record<string, ParamValue>,
},
]
: [],
);
let sourceImg = $state<PixelImage | null>(null);
let sourceUrl = $state("");
let sourceName = $state("");
let stepOutputs = $state<{ toolId: string; title: string; url: string }[]>(
[],
);
let resultImg = $state<PixelImage | null>(null);
let resultUrl = $state("");
let chainError = $state("");
let processing = $state(false);
const chainOptions = TOOLS.filter(isChainable).map((t) => ({
value: t.id,
label: t.title,
}));
let addId = $state(chainOptions[0]?.value ?? "");
const firstTool = $derived(steps[0] ? getTool(steps[0].toolId) : undefined);
const needsSource = $derived(
firstTool ? firstTool.sourceMode !== "none" : false,
);
$effect(() => {
const t = getTool(data.id);
if (t) {
steps = [
{
toolId: t.id,
params: defaultParams(t) as Record<string, ParamValue>,
},
];
sourceImg = null;
sourceUrl = "";
sourceName = "";
}
});
$effect(() => {
if (!browser) return;
const chain = steps.map((s) => ({
tool: getTool(s.toolId),
params: { ...s.params },
}));
const src = sourceImg;
if (!chain.length || chain.some((c) => !c.tool)) {
resultImg = null;
resultUrl = "";
stepOutputs = [];
return;
}
const first = chain[0].tool!;
if (first.sourceMode !== "none" && !src) {
resultImg = null;
resultUrl = "";
stepOutputs = [];
return;
}
void runPipeline(chain, src);
});
async function runPipeline(
chain: {
tool: ReturnType<typeof getTool>;
params: Record<string, ParamValue>;
}[],
src: PixelImage | null,
) {
processing = true;
try {
let current: PixelImage | null = src;
const outs: { toolId: string; title: string; url: string }[] = [];
for (const c of chain) {
const tool = c.tool!;
const resolve = (x: PixelImage | Promise<PixelImage>) =>
x instanceof Promise ? x : Promise.resolve(x);
let out: PixelImage;
if (!current && tool.sourceMode === "none" && tool.generate) {
out = await resolve(tool.generate(c.params));
} else if (current && tool.run) {
out = await resolve(tool.run(current, c.params));
} else if (!current && src && tool.run) {
out = await resolve(tool.run(src, c.params));
} else {
continue;
}
current = out;
outs.push({ toolId: tool.id, title: tool.title, url: toDataUrl(out) });
}
stepOutputs = outs;
resultImg = current;
resultUrl = current ? toDataUrl(current) : "";
chainError = "";
} catch (e) {
chainError = String(e);
} finally {
processing = false;
}
}
async function onFile(file: File) {
if (!browser) return;
try {
const bytes = new Uint8Array(await file.arrayBuffer());
const img = await decodeBytes(bytes);
sourceImg = img;
sourceName = file.name;
sourceUrl = toDataUrl(img);
chainError = "";
} catch (e) {
chainError = String(e);
}
}
function clearSource() {
sourceImg = null;
sourceUrl = "";
sourceName = "";
}
function setStepParam(index: number, id: string, value: ParamValue) {
steps[index].params[id] = value;
}
function addStep() {
const tool = getTool(addId);
if (!tool) return;
steps = [
...steps,
{
toolId: tool.id,
params: defaultParams(tool) as Record<string, ParamValue>,
},
];
}
function removeStep(index: number) {
steps = steps.filter((_, i) => i !== index);
}
async function downloadResult() {
if (!resultImg || !firstTool) return;
const blob = await encode(resultImg, "image/png");
const base = sourceName ? sourceName.replace(/\.[^.]+$/, "") : firstTool.id;
downloadBlob(blob, `${base}-pipeline.png`);
}
const resultMeta = $derived(
resultImg
? [
{ caption: "STEPS", value: String(steps.length) },
{
caption: "DIMENSIONS",
value: `${resultImg.width} × ${resultImg.height} px`,
},
{ caption: "FORMAT", value: "PNG-24" },
]
: [],
);
const tool = $derived(getTool(data.id));
</script>
<svelte:head>
<title>easy-png-tools / {firstTool?.title ?? "Tool"}</title>
<title>easy-png-tools / {tool?.title ?? "Tool"}</title>
<meta name="robots" content="noindex, nofollow" />
</svelte:head>
{#if !firstTool}
{#if !tool}
<div class="notfound">
<EmptyState
title="Tool not found"
@@ -214,41 +26,7 @@
/>
</div>
{:else}
<WorkspaceLayout
columns="minmax(0, 1fr) minmax(0, 1.1fr)"
gap="40px"
padding="48px clamp(24px, 4vw, 72px) 72px"
maxWidth="1680px"
stickyTop="84px"
padMobile="32px 16px 48px"
>
{#snippet settings()}
<SettingsPanel
{steps}
{chainOptions}
{addId}
onAddStep={addStep}
onSetAddId={(v) => (addId = v)}
onRemoveStep={removeStep}
onSetParam={setStepParam}
/>
{/snippet}
{#snippet preview()}
<PreviewPanel
{needsSource}
{sourceImg}
{sourceUrl}
{stepOutputs}
{resultUrl}
{resultMeta}
{processing}
{chainError}
onFile={onFile}
onClearSource={clearSource}
onDownload={downloadResult}
/>
{/snippet}
</WorkspaceLayout>
<ToolView {tool} />
{/if}
<style>