feat: fix tools pages, dom audit fix
This commit is contained in:
+2
-1
@@ -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
@@ -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
@@ -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 узл.)
|
||||
_расхождений нет_
|
||||
|
||||
+26
-14
@@ -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);
|
||||
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(
|
||||
|
||||
@@ -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}
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user