mirror of
https://github.com/Ku6epXBOCTuK/easy-png-tools.git
synced 2026-09-14 21:46:35 +00:00
chore: cleanup - remove old references and audit scripts
This commit is contained in:
@@ -1,42 +0,0 @@
|
|||||||
# refs-html — статические снимки нового дизайна
|
|
||||||
|
|
||||||
Отформатированный HTML, вытащенный из Next-референса `refs/`, чтобы смотреть
|
|
||||||
макеты как референс по разметке и классам: открыл файл в браузере — увидел
|
|
||||||
дизайн.
|
|
||||||
|
|
||||||
## Файлы
|
|
||||||
|
|
||||||
| Файл | Маршрут в нашем приложении (audit) | Что показывает |
|
|
||||||
| ------------------------- | -------------------------------------- | --------------------------------------------------- |
|
|
||||||
| `demo.html` | `/preview/demo` | Пайплайн-воркспейс + панель превью |
|
|
||||||
| `list-tools.html` | `/preview/list-tools` | Каталог инструментов (группы, поиск, фильтры) |
|
|
||||||
| `gradient.html` | `/preview/tools/linear-gradient-png` | Инструмент «Градиент»: настройки + превью + CSS |
|
|
||||||
| `background-remover.html` | `/preview/tools/remove-background-png` | Инструмент «Удаление фона»: сравнение source/result |
|
|
||||||
|
|
||||||
> `index.html` не используется: в Next-рефе это зеркало `demo` (главная == `/easy-png-tools/demo`),
|
|
||||||
> а в нашем приложении `/preview` — своя страница (воркспейс). Файл не генерируется
|
|
||||||
> экстрактором (`scripts/extract-static.mjs` пропускает `index.html`) и исключён
|
|
||||||
> из аудита (`EXCLUDE` в `web/scripts/audit.mjs`).
|
|
||||||
|
|
||||||
## Особенности
|
|
||||||
|
|
||||||
- **Это статика без JS**: все `<script>` удалены, интерактивности нет.
|
|
||||||
Тумблеры/кнопки/слайдеры не кликаются — это витрина состояний по умолчанию.
|
|
||||||
- Разметка отформатирована (отступы), CSS темы заинлайнен и причёсан —
|
|
||||||
файлы автономны, работают с `file://`.
|
|
||||||
- Тема по умолчанию: `gradient.html` и `background-remover.html` отрисованы
|
|
||||||
в тёмной (так в коде рефа), `index.html`/`demo.html` — в светлой. Чтобы
|
|
||||||
посмотреть светлый вариант тёмных страниц — убрать класс `dark-mode` у
|
|
||||||
`.app-shell`; для тёмной главной — добавить его же.
|
|
||||||
- Источник правды по пикселям — код в `refs/`; здесь только витрина.
|
|
||||||
|
|
||||||
## Как перегенерировать
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd refs
|
|
||||||
pnpm install # если ещё не ставили
|
|
||||||
node node_modules\next\dist\bin\next build # output: 'export' включён в next.config.mjs
|
|
||||||
node extract-static.mjs .next\server\app ..\refs-html # форматирование, инлайн-CSS, без скриптов
|
|
||||||
```
|
|
||||||
|
|
||||||
Скрипт: `refs/extract-static.mjs`.
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
File diff suppressed because it is too large
Load Diff
-2910
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,15 +0,0 @@
|
|||||||
# v0 sandbox internal files
|
|
||||||
__v0_runtime_loader.js
|
|
||||||
__v0_devtools.tsx
|
|
||||||
__v0_jsx-dev-runtime.ts
|
|
||||||
.snowflake/
|
|
||||||
.v0-trash/
|
|
||||||
.vercel/
|
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
.env*.local
|
|
||||||
|
|
||||||
# Common ignores
|
|
||||||
node_modules
|
|
||||||
.next/
|
|
||||||
.DS_Store
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import TopBar from '@/components/top-bar'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Download, RotateCcw, Upload, Check } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function BackgroundRemoverPage() {
|
|
||||||
const [color, setColor] = useState('#E8EEF2')
|
|
||||||
const [similarity, setSimilarity] = useState(72)
|
|
||||||
const [outerOnly, setOuterOnly] = useState(true)
|
|
||||||
const [showMask, setShowMask] = useState(false)
|
|
||||||
const [downloaded, setDownloaded] = useState(false)
|
|
||||||
const previewBackground = showMask ? 'repeating-conic-gradient(#7b8791 0 25%, #cbd3da 0 50%) 50% / 28px 28px' : 'repeating-conic-gradient(#d7dce0 0 25%, #f3f5f6 0 50%) 50% / 28px 28px'
|
|
||||||
const subjectStyle = useMemo(() => ({ background: showMask ? '#596773' : 'linear-gradient(145deg,#1769d2 0 38%,#00a8c7 38% 66%,#bd7411 66%)', opacity: showMask ? .88 : 1, clipPath: `polygon(23% 11%, 73% 8%, 89% 30%, 79% 81%, 52% 93%, 17% 78%, 8% 39%)` }), [showMask])
|
|
||||||
const reset = () => { setColor('#E8EEF2'); setSimilarity(72); setOuterOnly(true); setShowMask(false) }
|
|
||||||
return <main className="app-shell">
|
|
||||||
<TopBar section="BACKGROUND REMOVER" status="AUTO PROCESSING" />
|
|
||||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Remove background.</h1><p className="lede">Select a background color and tune the edge detection. Changes are processed automatically in your browser.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
|
||||||
<div className="remover-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">REMOVER SETTINGS</span><strong>Configure detection</strong></div><span className="step-type">TOOL 02</span></div>
|
|
||||||
<div className="setting-group"><label>BACKGROUND COLOR</label><div className="color-field"><span className="swatch" style={{ background: color }} /><input value={color} onChange={e => setColor(e.target.value)} /><input className="native-color" type="color" value={color} onChange={e => setColor(e.target.value)} aria-label="Choose background color" /></div><div className="color-reference"><span style={{ background: color }} /> sampled from image background</div></div>
|
|
||||||
<div className="setting-group"><label>COLOR SIMILARITY <output>{similarity}%</output></label><input type="range" min="0" max="100" value={similarity} onChange={e => setSimilarity(Number(e.target.value))} /><div className="range-hints"><span>strict edges</span><span>more removal</span></div></div>
|
|
||||||
<div className="setting-group toggle-group"><label><span>OUTER COLOR ONLY</span><input type="checkbox" checked={outerOnly} onChange={e => setOuterOnly(e.target.checked)} /><b className="toggle" /></label><p>Only remove connected background pixels from the edges.</p></div>
|
|
||||||
<div className="setting-group toggle-group"><label><span>SHOW MASK</span><input type="checkbox" checked={showMask} onChange={e => setShowMask(e.target.checked)} /><b className="toggle" /></label><p>Preview the detected transparency mask.</p></div>
|
|
||||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
|
||||||
</section>
|
|
||||||
<section className="remover-preview"><div className="preview-toolbar"><div><span className="label">SOURCE / RESULT</span><strong>comparison.png</strong></div><div className="preview-actions"><span className="processed"><Check size={14} /> processed</span><button className="download-btn" onClick={() => setDownloaded(true)}><Download size={15} /> {downloaded ? 'Downloaded' : 'Download result'}</button></div></div><div className="comparison-grid"><div className="image-card"><div className="image-label"><span>SOURCE</span><b>original.png</b></div><div className="remover-canvas source-canvas"><div className="subject subject-source" style={subjectStyle}><span>OBJECT</span></div><span className="canvas-size">1200 × 800</span></div></div><div className="image-card"><div className="image-label"><span>RESULT</span><b>{showMask ? 'mask-preview.png' : 'removed-bg.png'}</b></div><div className="remover-canvas" style={{ background: previewBackground }}><div className="subject" style={subjectStyle}><span>{showMask ? 'MASK' : 'PNG'}</span></div><span className="canvas-size">1200 × 800</span></div></div></div><div className="result-meta"><span>FORMAT <b>PNG-24</b></span><span>ALPHA <b>{showMask ? 'MASK' : 'ENABLED'}</b></span><span>SIMILARITY <b>{similarity}%</b></span></div></section></div>
|
|
||||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>background remover · local-only</span><span>© 2024</span></footer>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { default } from '../../page'
|
|
||||||
@@ -1,33 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import TopBar from '@/components/top-bar'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { Check, ChevronDown, Copy, Download, RotateCcw } from 'lucide-react'
|
|
||||||
|
|
||||||
const directions = ['0° →', '45° ↗', '90° ↑', '135° ↖', '180° ←', '225° ↙', '270° ↓', '315° ↘']
|
|
||||||
|
|
||||||
export default function GradientPage() {
|
|
||||||
const [start, setStart] = useState('#1769D2')
|
|
||||||
const [end, setEnd] = useState('#00A8C7')
|
|
||||||
const [direction, setDirection] = useState(135)
|
|
||||||
const [opacity, setOpacity] = useState(100)
|
|
||||||
const [type, setType] = useState<'linear' | 'radial'>('linear')
|
|
||||||
const [copied, setCopied] = useState(false)
|
|
||||||
const gradient = useMemo(() => type === 'linear' ? `linear-gradient(${direction}deg, ${start} 0%, ${end} 100%)` : `radial-gradient(circle, ${start} 0%, ${end} 100%)`, [direction, end, start, type])
|
|
||||||
const css = `background: ${gradient};\nopacity: ${opacity / 100};`
|
|
||||||
const reset = () => { setStart('#1769D2'); setEnd('#00A8C7'); setDirection(135); setOpacity(100); setType('linear') }
|
|
||||||
const copyCss = async () => { await navigator.clipboard?.writeText(css); setCopied(true); setTimeout(() => setCopied(false), 1400) }
|
|
||||||
return <main className="app-shell">
|
|
||||||
<TopBar section="GRADIENT" status="LIVE PREVIEW" />
|
|
||||||
<div className="tool-page"><div className="eyebrow">PNG PROCESSING <span>/</span> SINGLE TOOL</div><div className="tool-title"><div><h1>Gradient background.</h1><p className="lede">Create a clean, export-ready gradient with precise control over color, direction and transparency.</p></div><span className="tool-status"><i /> LIVE PREVIEW</span></div>
|
|
||||||
<div className="gradient-layout"><section className="settings-panel"><div className="panel-heading"><div><span className="label">GRADIENT SETTINGS</span><strong>Configure output</strong></div><span className="step-type">TOOL 01</span></div>
|
|
||||||
<div className="setting-group"><label>GRADIENT TYPE</label><div className="segmented wide"><button className={type === 'linear' ? 'selected' : ''} onClick={() => setType('linear')}>Linear</button><button className={type === 'radial' ? 'selected' : ''} onClick={() => setType('radial')}>Radial</button></div></div>
|
|
||||||
<div className="setting-group"><label>COLOR STOPS</label><div className="color-row"><div className="color-field"><span className="swatch" style={{ background: start }} /><input value={start} onChange={e => setStart(e.target.value)} /></div><span className="stop-arrow">→</span><div className="color-field"><span className="swatch" style={{ background: end }} /><input value={end} onChange={e => setEnd(e.target.value)} /></div></div><div className="gradient-bar" style={{ background: gradient }} /></div>
|
|
||||||
<div className="setting-group"><label>DIRECTION <output>{direction}°</output></label><input type="range" min="0" max="360" value={direction} onChange={e => setDirection(Number(e.target.value))} /><div className="direction-grid">{directions.map((item, i) => <button key={item} className={direction === i * 45 ? 'selected' : ''} onClick={() => setDirection(i * 45)}>{item}</button>)}</div></div>
|
|
||||||
<div className="setting-group"><label>OPACITY <output>{opacity}%</output></label><input type="range" min="0" max="100" value={opacity} onChange={e => setOpacity(Number(e.target.value))} /></div>
|
|
||||||
<div className="settings-footer"><button className="reset-btn" onClick={reset}><RotateCcw size={14} /> Reset</button><span className="auto-note"><i /> updates automatically</span></div>
|
|
||||||
</section>
|
|
||||||
<section className="gradient-preview"><div className="preview-toolbar"><div><span className="label">OUTPUT PREVIEW</span><strong>gradient.png</strong></div><div className="preview-actions"><button className="secondary-btn" onClick={copyCss}>{copied ? <Check size={15} /> : <Copy size={15} />} {copied ? 'Copied' : 'Copy CSS'}</button><button className="download-btn"><Download size={15} /> Download PNG <ChevronDown size={14} /></button></div></div><div className="large-canvas"><div className="gradient-art" style={{ background: gradient, opacity: opacity / 100 }}><div className="art-mark">PNG</div><span>easy-png-tools</span></div></div><div className="code-block"><div><span className="label">GENERATED CSS</span><button className="icon-btn" onClick={copyCss} aria-label="Copy CSS"><Copy size={14} /></button></div><pre>{css}</pre></div></section></div>
|
|
||||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span>gradient tool · local-only</span><span>© 2024</span></footer>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import TopBar from '@/components/top-bar'
|
|
||||||
import Link from "next/link"
|
|
||||||
import { useMemo, useState } from "react"
|
|
||||||
import { ArrowUpRight, FileImage, Filter, Search, Sparkles } from "lucide-react"
|
|
||||||
|
|
||||||
const groups = [
|
|
||||||
{ name: "CONVERT", tools: [["Convert JPG to PNG", "Re-encode JPEG files as lossless PNG while preserving transparency.", "/easy-png-tools/gradient"], ["Convert WebP to PNG", "Turn WebP images into a universal PNG format for any workflow."], ["PNG to Base64", "Encode an image as a base64 string for embedding in code or styles."], ["PNG to Data URI", "Build a complete data URI ready for HTML and CSS."], ["Convert PNG to JPG", "Composite transparency over a selected backdrop and export JPEG."]] },
|
|
||||||
{ name: "TRANSPARENCY", tools: [["Remove background PNG", "Remove a solid background by color, tolerance, or edge-connected regions.", "/easy-png-tools/background-remover"], ["Extract alpha mask", "Turn the alpha channel into a clean black-and-white mask."], ["Round corners PNG", "Clip the image corners by a precise radius percentage."], ["Outline PNG", "Add a colored ring around opaque content with adjustable thickness."], ["Change PNG opacity", "Multiply the alpha channel while keeping the original colors unchanged."]] },
|
|
||||||
{ name: "COLOR", tools: [["Create gradient PNG", "Generate a smooth transition between two colors with direction controls.", "/easy-png-tools/gradient"], ["Grayscale PNG", "Convert the image to luminance-based shades of gray."], ["Invert colors PNG", "Invert every color channel while leaving alpha untouched."], ["Brightness & contrast", "Adjust brightness and contrast across a controlled range."], ["Temperature PNG", "Make an image warmer or cooler with a single precise control."]] },
|
|
||||||
{ name: "GEOMETRY", tools: [["Resize PNG", "Scale an image with bilinear interpolation and optional aspect lock."], ["Crop PNG", "Cut a rectangular area with exact coordinates and dimensions."], ["Rotate PNG", "Rotate by 90, 180, or 270 degrees without quality loss."], ["Flip PNG", "Mirror the image horizontally or vertically."], ["Add padding to PNG", "Expand the canvas on all sides by a chosen number of pixels."]] },
|
|
||||||
{ name: "FILTERS", tools: [["Blur PNG", "Apply a fast Gaussian-style blur with transparent edge handling."], ["Sharpen PNG", "Emphasize edges with an adjustable sharpening kernel."], ["Vignette PNG", "Smoothly darken the image edges while preserving the center."], ["JPEG artifacts", "Simulate low-quality JPEG recompression for testing."]] },
|
|
||||||
{ name: "ANALYZE", tools: [["PNG info", "Inspect dimensions, alpha presence, and unique color count."], ["Check grayscale", "Report whether the image contains only shades of gray."], ["Check transparency", "Detect transparent and semi-transparent pixels."], ["PNG orientation", "Classify the image as portrait, landscape, or square."]] },
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function ListToolsPage() {
|
|
||||||
const [query, setQuery] = useState("")
|
|
||||||
const [active, setActive] = useState("ALL")
|
|
||||||
const filtered = useMemo(() => groups.map(group => ({ ...group, tools: group.tools.filter(([name, description]) => (active === "ALL" || active === group.name) && `${name} ${description}`.toLowerCase().includes(query.toLowerCase())) })).filter(group => group.tools.length), [query, active])
|
|
||||||
return <main className="app-shell"><TopBar section="CATALOG" status="LOCAL MODE / READY" /><div className="catalog-page">
|
|
||||||
<div className="catalog-head"><div><div className="eyebrow">EASY-PNG-TOOLS / CATALOG</div><h1>Tool catalog</h1><p>Focused utilities for working with PNG. Inspect, transform, and export — locally in your browser.</p></div><div className="catalog-total"><b>32</b><span>TOOLS<br />AVAILABLE</span></div></div>
|
|
||||||
<div className="catalog-toolbar"><label className="catalog-search"><Search size={16} /><input aria-label="Search tools" placeholder="Search tools..." value={query} onChange={e => setQuery(e.target.value)} /></label><div className="catalog-filters"><Filter size={15} />{["ALL", ...groups.map(g => g.name)].map(name => <button key={name} className={active === name ? "active" : ""} onClick={() => setActive(name)}>{name}</button>)}</div></div>
|
|
||||||
<div className="catalog-groups">{filtered.map(group => <section className="catalog-group" key={group.name}><div className="group-title"><span>{group.name}</span><i>{String(group.tools.length).padStart(2, "0")} TOOLS</i></div><div className="tool-cards">{group.tools.map(([name, description, href], index) => <Link className="tool-card" href={href || "#"} key={name} onClick={e => { if (!href) e.preventDefault() }}><span className="tool-icon"><FileImage size={19} /></span><span className="tool-copy"><strong>{name}</strong><span>{description}</span></span><span className="tool-index">{String(index + 1).padStart(2, "0")}</span><ArrowUpRight size={16} className="tool-arrow" /></Link>)}</div></section>)}</div>
|
|
||||||
{!filtered.length && <div className="catalog-empty"><Sparkles size={18} /> No tools match your search.</div>}
|
|
||||||
<footer className="catalog-footer">ALL OPERATIONS RUN LOCALLY <span>•</span> YOUR FILES NEVER LEAVE THIS DEVICE</footer>
|
|
||||||
</div></main>
|
|
||||||
}
|
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,18 +0,0 @@
|
|||||||
import { Analytics } from '@vercel/analytics/next'
|
|
||||||
import type { Metadata, Viewport } from 'next'
|
|
||||||
import './globals.css'
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
|
||||||
title: 'easy-png-tools / Demo',
|
|
||||||
description: 'Technical workspace for building PNG processing pipelines.',
|
|
||||||
generator: 'easy-png-tools',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const viewport: Viewport = {
|
|
||||||
colorScheme: 'light',
|
|
||||||
themeColor: '#eef1f4',
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
|
||||||
return <html lang="ru" className="bg-background"><body className="antialiased">{children}{process.env.NODE_ENV === 'production' && <Analytics />}</body></html>
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
'use client'
|
|
||||||
|
|
||||||
import TopBar from '@/components/top-bar'
|
|
||||||
import { useMemo, useState } from 'react'
|
|
||||||
import { ArrowDownToLine, Check, ChevronDown, Download, GripVertical, Link2, MoreHorizontal, Plus, RotateCcw, Settings2, SlidersHorizontal, Upload, X } from 'lucide-react'
|
|
||||||
|
|
||||||
const initialSteps = [
|
|
||||||
{ id: 1, title: 'Gradient background', type: 'BACKGROUND' },
|
|
||||||
{ id: 2, title: 'Remove background', type: 'TRANSFORM' },
|
|
||||||
{ id: 3, title: 'Add outline', type: 'STYLE' },
|
|
||||||
{ id: 4, title: 'Round corners', type: 'STYLE' },
|
|
||||||
]
|
|
||||||
|
|
||||||
function PreviewTile({ label, sublabel, style, active = false }: { label: string; sublabel: string; style: React.CSSProperties; active?: boolean }) {
|
|
||||||
return <div className={`preview-tile ${active ? 'active' : ''}`}><div className="tile-canvas"><div className="image-preview" style={style}><span className="sample-icon">PNG</span><span>easy-png-tools</span></div></div><div className="tile-label"><span>{label}</span><b>{sublabel}</b></div></div>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Page() {
|
|
||||||
const [steps, setSteps] = useState(initialSteps)
|
|
||||||
const [angle, setAngle] = useState(135)
|
|
||||||
const [radius, setRadius] = useState(18)
|
|
||||||
const [outline, setOutline] = useState(2)
|
|
||||||
const [language, setLanguage] = useState('RU')
|
|
||||||
const [showIntermediate, setShowIntermediate] = useState(true)
|
|
||||||
const [gradient, setGradient] = useState('#DCEBFF')
|
|
||||||
const gradientStyle = useMemo(() => ({ background: `linear-gradient(${angle}deg, ${gradient}, #8BC8F5)` }), [angle, gradient])
|
|
||||||
const finalStyle = { ...gradientStyle, borderRadius: radius, boxShadow: `0 0 0 ${outline}px #16202B` }
|
|
||||||
|
|
||||||
return <main className="app-shell">
|
|
||||||
<TopBar section="WORKSPACE" status="AUTO PIPELINE" />
|
|
||||||
<div className="page-grid">
|
|
||||||
<section className="workspace"><div className="eyebrow">PNG PROCESSING <span>/</span> WORKSPACE</div><div className="title-row"><div><h1>Build your image pipeline.</h1><p className="lede">Chain simple tools together. Every change is processed automatically and previewed at each stage.</p></div><div className="file-chip"><Upload size={15} /><span>source.png</span><b>1.8 MB</b></div></div>
|
|
||||||
<div className="pipeline-head"><div><span className="label">PROCESSING PIPELINE</span><strong>{steps.length} active steps <em>• LIVE</em></strong></div><button className="add-btn" onClick={() => setSteps([...steps, { id: Date.now(), title: 'New adjustment', type: 'STYLE' }])}><Plus size={15} /> Add tool</button></div>
|
|
||||||
<div className="steps-list">{steps.map((step, index) => <article className="step-card" key={step.id}><div className="step-index">{String(index + 1).padStart(2, '0')}</div><GripVertical className="drag" size={16} /><div className="step-body"><div className="step-heading"><div><span className="step-type">{step.type}</span><h2>{step.title}</h2></div><div className="step-tools"><span className="check"><Check size={12} /> AUTO</span><button aria-label="Remove step" onClick={() => setSteps(steps.filter(item => item.id !== step.id))}><X size={16} /></button><MoreHorizontal size={17} /></div></div>{index === 0 ? <div className="controls"><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch" style={{ background: gradient }} /><input value={gradient} onChange={e => setGradient(e.target.value)} aria-label="Gradient color" /><ChevronDown size={14} /></div></div><div className="control-block"><label>DIRECTION <output>{angle}°</output></label><input type="range" min="0" max="360" value={angle} onChange={e => setAngle(Number(e.target.value))} /></div><div className="control-block"><label>OPACITY <output>100%</output></label><div className="segmented"><button className="selected">100</button><button>75</button><button>50</button><button>25</button></div></div></div> : index === 2 ? <div className="controls compact"><div className="control-block"><label>WIDTH <output>{outline}px</output></label><input type="range" min="0" max="8" value={outline} onChange={e => setOutline(Number(e.target.value))} /></div><div className="control-block"><label>COLOR</label><div className="color-field"><span className="swatch dark" /><input value="#16202B" readOnly /></div></div></div> : index === 3 ? <div className="controls compact"><div className="control-block"><label>RADIUS <output>{radius}px</output></label><input type="range" min="0" max="48" value={radius} onChange={e => setRadius(Number(e.target.value))} /></div><div className="toggle-row"><span>Preserve aspect ratio</span><button className="toggle on" aria-label="Preserve aspect ratio"><i /></button></div></div> : <div className="transform-note"><SlidersHorizontal size={15} /> Automatic subject detection enabled</div>}</div></article>)}</div>
|
|
||||||
<div className="pipeline-footer"><button className="reset-btn" onClick={() => setSteps(initialSteps)}><RotateCcw size={14} /> Reset pipeline</button><span className="auto-note"><i /> changes are applied automatically</span></div>
|
|
||||||
</section>
|
|
||||||
<section className="preview-panel"><div className="preview-top"><div><span className="label">PIPELINE OUTPUTS</span><strong>Visual history</strong></div><div className="preview-actions"><button className="history-toggle top-toggle" onClick={() => setShowIntermediate(!showIntermediate)} aria-expanded={showIntermediate}>{showIntermediate ? 'Hide intermediate' : 'Show intermediate'} <ChevronDown size={15} className={showIntermediate ? 'rotated' : ''} /></button><button className="download-btn"><Download size={16} /> Download result <ArrowDownToLine size={14} /></button><div className="preview-meta"><div><span>DIMENSIONS</span><b>1200 × 800 px</b></div><div><span>FORMAT</span><b>PNG-24</b></div><div><span>SIZE</span><b>1.2 MB</b></div></div><button className="icon-btn" aria-label="Preview settings"><Settings2 size={17} /></button></div></div><div className="preview-stack"><PreviewTile label="SOURCE" sublabel="original.png · 1200 × 800" style={{ background: '#8d9aa5' }} />{showIntermediate && <><PreviewTile label="STEP 01" sublabel="gradient applied" style={gradientStyle} /><PreviewTile label="STEP 02" sublabel="background removed" style={{ ...gradientStyle, clipPath: 'inset(10% 8% 10% 8% round 12px)' }} /><PreviewTile label="STEP 03" sublabel="outline added" style={{ ...gradientStyle, boxShadow: `0 0 0 ${outline}px #16202B` }} /></>}<PreviewTile label="FINAL OUTPUT" sublabel="ready · PNG-24" style={finalStyle} active /></div><p className="preview-note">Output is generated in-browser. Your files never leave this device.</p></section>
|
|
||||||
</div><footer className="footer"><span>easy-png-tools <b>v2.4.0</b></span><span><Link2 size={13} /> pipeline is local-only</span><span>© 2024</span></footer>
|
|
||||||
</main>
|
|
||||||
}
|
|
||||||
@@ -1,21 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://ui.shadcn.com/schema.json",
|
|
||||||
"style": "base-nova",
|
|
||||||
"rsc": true,
|
|
||||||
"tsx": true,
|
|
||||||
"tailwind": {
|
|
||||||
"config": "",
|
|
||||||
"css": "app/globals.css",
|
|
||||||
"baseColor": "neutral",
|
|
||||||
"cssVariables": true,
|
|
||||||
"prefix": ""
|
|
||||||
},
|
|
||||||
"aliases": {
|
|
||||||
"components": "@/components",
|
|
||||||
"utils": "@/lib/utils",
|
|
||||||
"ui": "@/components/ui",
|
|
||||||
"lib": "@/lib",
|
|
||||||
"hooks": "@/hooks"
|
|
||||||
},
|
|
||||||
"iconLibrary": "lucide"
|
|
||||||
}
|
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
"use client"
|
|
||||||
|
|
||||||
import Link from "next/link"
|
|
||||||
import { CircleHelp, Moon, Sun } from "lucide-react"
|
|
||||||
import { usePathname } from "next/navigation"
|
|
||||||
import { useEffect, useState } from "react"
|
|
||||||
|
|
||||||
const links = [
|
|
||||||
["Workspace", "/"],
|
|
||||||
["Catalog", "/easy-png-tools/list-tools"],
|
|
||||||
["Gradient", "/easy-png-tools/gradient"],
|
|
||||||
["Background remover", "/easy-png-tools/background-remover"],
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export function TopBar({ section = "WORKSPACE", status = "LOCAL MODE / READY" }: { section?: string; status?: string }) {
|
|
||||||
const pathname = usePathname()
|
|
||||||
const [dark, setDark] = useState(false)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const stored = window.localStorage.getItem("ep-theme")
|
|
||||||
const isDark = stored === "dark"
|
|
||||||
setDark(isDark)
|
|
||||||
document.documentElement.classList.toggle("dark-mode", isDark)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const toggleTheme = () => {
|
|
||||||
const next = !dark
|
|
||||||
setDark(next)
|
|
||||||
document.documentElement.classList.toggle("dark-mode", next)
|
|
||||||
window.localStorage.setItem("ep-theme", next ? "dark" : "light")
|
|
||||||
}
|
|
||||||
|
|
||||||
return <header className="topbar"><Link href="/" className="brand"><span className="brand-mark">EP</span><span>easy-png-tools</span><span className="version">/ {section}</span></Link><nav className="top-nav" aria-label="Primary navigation">{links.map(([label, href]) => <Link key={href} className={pathname === href ? "active" : ""} href={href}>{label}</Link>)}</nav><div className="top-actions"><span className="status"><i /> {status}</span><button className="icon-btn" aria-label="Help"><CircleHelp size={17} /></button><button className="icon-btn" aria-label="Toggle theme" onClick={toggleTheme}>{dark ? <Sun size={17} /> : <Moon size={17} />}</button><div className="language"><button className="active">RU</button><span>/</span><button>EN</button></div></div></header>
|
|
||||||
}
|
|
||||||
|
|
||||||
export default TopBar
|
|
||||||
@@ -1,58 +0,0 @@
|
|||||||
import { Button as ButtonPrimitive } from '@base-ui/react/button'
|
|
||||||
import { cva, type VariantProps } from 'class-variance-authority'
|
|
||||||
|
|
||||||
import { cn } from '@/lib/utils'
|
|
||||||
|
|
||||||
const buttonVariants = cva(
|
|
||||||
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
|
||||||
{
|
|
||||||
variants: {
|
|
||||||
variant: {
|
|
||||||
default: 'bg-primary text-primary-foreground [a]:hover:bg-primary/80',
|
|
||||||
outline:
|
|
||||||
'border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50',
|
|
||||||
secondary:
|
|
||||||
'bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground',
|
|
||||||
ghost:
|
|
||||||
'hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50',
|
|
||||||
destructive:
|
|
||||||
'bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40',
|
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
|
||||||
},
|
|
||||||
size: {
|
|
||||||
default:
|
|
||||||
'h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
||||||
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
|
|
||||||
lg: 'h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2',
|
|
||||||
icon: 'size-8',
|
|
||||||
'icon-xs':
|
|
||||||
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
|
|
||||||
'icon-sm':
|
|
||||||
'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
|
|
||||||
'icon-lg': 'size-9',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
defaultVariants: {
|
|
||||||
variant: 'default',
|
|
||||||
size: 'default',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
function Button({
|
|
||||||
className,
|
|
||||||
variant = 'default',
|
|
||||||
size = 'default',
|
|
||||||
...props
|
|
||||||
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
|
|
||||||
return (
|
|
||||||
<ButtonPrimitive
|
|
||||||
data-slot="button"
|
|
||||||
className={cn(buttonVariants({ variant, size, className }))}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Button, buttonVariants }
|
|
||||||
@@ -1,6 +0,0 @@
|
|||||||
import { clsx, type ClassValue } from 'clsx'
|
|
||||||
import { twMerge } from 'tailwind-merge'
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs))
|
|
||||||
}
|
|
||||||
Vendored
-7
@@ -1,7 +0,0 @@
|
|||||||
/// <reference types="next" />
|
|
||||||
/// <reference types="next/image-types/global" />
|
|
||||||
import "./.next/types/routes.d.ts";
|
|
||||||
import "./.next/types/root-params.d.ts";
|
|
||||||
|
|
||||||
// NOTE: This file should not be edited
|
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
|
||||||
@@ -1,11 +0,0 @@
|
|||||||
/** @type {import('next').NextConfig} */
|
|
||||||
const nextConfig = {
|
|
||||||
typescript: {
|
|
||||||
ignoreBuildErrors: true,
|
|
||||||
},
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default nextConfig
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "my-project",
|
|
||||||
"version": "0.1.0",
|
|
||||||
"private": true,
|
|
||||||
"scripts": {
|
|
||||||
"dev": "next dev",
|
|
||||||
"build": "next build",
|
|
||||||
"start": "next start"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"@base-ui/react": "^1.5.0",
|
|
||||||
"@vercel/analytics": "1.6.1",
|
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^1.16.0",
|
|
||||||
"next": "16.3.0",
|
|
||||||
"react": "^19",
|
|
||||||
"react-dom": "^19",
|
|
||||||
"shadcn": "^4.8.0",
|
|
||||||
"tailwind-merge": "^3.3.1",
|
|
||||||
"tw-animate-css": "^1.4.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@tailwindcss/postcss": "^4.3.3",
|
|
||||||
"@types/node": "^24",
|
|
||||||
"@types/react": "^19",
|
|
||||||
"@types/react-dom": "^19",
|
|
||||||
"postcss": "^8.5",
|
|
||||||
"tailwindcss": "^4.3.3",
|
|
||||||
"typescript": "5.7.3"
|
|
||||||
},
|
|
||||||
"pnpm": {
|
|
||||||
"overrides": {
|
|
||||||
"hono": "4.12.25"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Generated
-3928
File diff suppressed because it is too large
Load Diff
@@ -1,2 +0,0 @@
|
|||||||
allowBuilds:
|
|
||||||
msw: set this to true or false
|
|
||||||
@@ -1,8 +0,0 @@
|
|||||||
/** @type {import('postcss-load-config').Config} */
|
|
||||||
const config = {
|
|
||||||
plugins: {
|
|
||||||
'@tailwindcss/postcss': {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
export default config
|
|
||||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.0 KiB |
@@ -1 +0,0 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
|
||||||
|
Before Width: | Height: | Size: 3.2 KiB |
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
|
||||||
"allowJs": true,
|
|
||||||
"target": "ES6",
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"strict": true,
|
|
||||||
"noEmit": true,
|
|
||||||
"esModuleInterop": true,
|
|
||||||
"module": "esnext",
|
|
||||||
"moduleResolution": "bundler",
|
|
||||||
"resolveJsonModule": true,
|
|
||||||
"isolatedModules": true,
|
|
||||||
"jsx": "react-jsx",
|
|
||||||
"incremental": true,
|
|
||||||
"plugins": [
|
|
||||||
{
|
|
||||||
"name": "next"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"paths": {
|
|
||||||
"@/*": ["./*"]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"include": [
|
|
||||||
"next-env.d.ts",
|
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx",
|
|
||||||
".next/types/**/*.ts",
|
|
||||||
".next/dev/types/**/*.ts"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
|
||||||
}
|
|
||||||
@@ -1,252 +0,0 @@
|
|||||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync, statSync, existsSync } from "node:fs";
|
|
||||||
import { join, dirname, basename, extname } from "node:path";
|
|
||||||
import { fileURLToPath } from "node:url";
|
|
||||||
|
|
||||||
// Usage: node extract-static.mjs <source-out-dir> <dest-dir>
|
|
||||||
// e.g. node extract-static.mjs .next\server\app ..\refs-html
|
|
||||||
|
|
||||||
const root = process.argv[2];
|
|
||||||
const dest = process.argv[3];
|
|
||||||
|
|
||||||
// --- inject a minimal theme toggle (extraction strips all <script> tags, so the
|
|
||||||
// ref pages would otherwise have no working dark-mode switch) ---
|
|
||||||
const THEME_TOGGLE = `
|
|
||||||
<script>
|
|
||||||
(function () {
|
|
||||||
var btn = document.querySelector('button[aria-label="Toggle theme"]');
|
|
||||||
var shell = document.querySelector('.app-shell');
|
|
||||||
if (!btn || window.__themeToggleInjected) return;
|
|
||||||
window.__themeToggleInjected = true;
|
|
||||||
btn.addEventListener('click', function () {
|
|
||||||
document.documentElement.classList.toggle('dark-mode');
|
|
||||||
if (shell) shell.classList.toggle('dark-mode');
|
|
||||||
});
|
|
||||||
})();
|
|
||||||
</script>
|
|
||||||
`;
|
|
||||||
|
|
||||||
function injectThemeToggle(html) {
|
|
||||||
if (html.includes("__themeToggleInjected")) return html;
|
|
||||||
const tag = "</body>";
|
|
||||||
if (html.includes(tag)) return html.replace(tag, THEME_TOGGLE + "\n" + tag);
|
|
||||||
if (html.includes("</html>")) return html.replace("</html>", THEME_TOGGLE + "\n</html>");
|
|
||||||
return html + "\n" + THEME_TOGGLE;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- CSS: find the compiled chunk and pretty-print it ---
|
|
||||||
const cssChunks = join(root, "..", "..", "static", "chunks");
|
|
||||||
|
|
||||||
// --- fonts: inline @font-face + copy .woff2 next to the pages ---
|
|
||||||
// The Next reference never actually loads its `--font-sans/--font-mono`
|
|
||||||
// ("IBM Plex Sans"/"IBM Plex Mono"), so the extracted snapshots render with
|
|
||||||
// the system fallback. Self-host the fontsource in a `assets/fonts/` dir next
|
|
||||||
// to the HTML so the refs show the intended typefaces.
|
|
||||||
|
|
||||||
const FONT_ASSETS_DIR = "assets/fonts";
|
|
||||||
|
|
||||||
// fontsource packages relative to `web/` — same weights as design2.css imports.
|
|
||||||
const FONT_SOURCES = [
|
|
||||||
"ibm-plex-sans/400.css",
|
|
||||||
"ibm-plex-sans/500.css",
|
|
||||||
"ibm-plex-sans/600.css",
|
|
||||||
"ibm-plex-sans/700.css",
|
|
||||||
"ibm-plex-mono/400.css",
|
|
||||||
"ibm-plex-mono/500.css",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Resolve `node_modules/@fontsource/<pkg>/<weight>.css`:
|
|
||||||
// 1. sibling repo dir (root `web/…` or standalone `refs/…` layouts);
|
|
||||||
// 2. upward walk from this script (monorepo with hoisted root deps).
|
|
||||||
function findFontsourceRoot() {
|
|
||||||
const here = dirname(fileURLToPath(import.meta.url));
|
|
||||||
const candidates = [join(here, "..", "web", "node_modules", "@fontsource")];
|
|
||||||
for (let dir = here; ; ) {
|
|
||||||
candidates.push(join(dir, "node_modules", "@fontsource"));
|
|
||||||
const parent = dirname(dir);
|
|
||||||
if (parent === dir) break;
|
|
||||||
dir = parent;
|
|
||||||
}
|
|
||||||
for (const candidate of candidates) {
|
|
||||||
if (existsSync(join(candidate, FONT_SOURCES[0]))) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildFontFaces(dest) {
|
|
||||||
const fontsource = findFontsourceRoot();
|
|
||||||
if (!fontsource) {
|
|
||||||
console.warn("WARN: @fontsource not found (web/node_modules missing?) — skipping font embedding");
|
|
||||||
return { style: "", copied: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
const seen = new Set();
|
|
||||||
const raw = [];
|
|
||||||
for (const rel of FONT_SOURCES) {
|
|
||||||
const pkg = dirname(rel); // e.g. ibm-plex-sans
|
|
||||||
const name = basename(rel, extname(rel)); // e.g. 400.css -> 400
|
|
||||||
const file = join(fontsource, pkg, `${name}.css`);
|
|
||||||
if (!existsSync(file)) continue;
|
|
||||||
raw.push([file, readFileSync(file, "utf8"), pkg]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Drop duplicate @font-face blocks (each `<name>.css` repeats across weights).
|
|
||||||
let css = raw.map(([, text]) => text.replace(/\/\*[^*]*\*\//g, "").trim()).join("\n");
|
|
||||||
css = css.replace(/@font-face\s*\{[\s\S]*?\}/g, (block) =>
|
|
||||||
seen.has(block) ? "" : (seen.add(block), block)
|
|
||||||
);
|
|
||||||
|
|
||||||
// Copy the woff2 files and point the css at `assets/fonts/`. Drop the woff
|
|
||||||
// entries — modern browsers only need woff2 (dev-only static snapshots).
|
|
||||||
const copied = [];
|
|
||||||
const destFonts = join(dest, FONT_ASSETS_DIR);
|
|
||||||
css = css.replace(/url\(\.\/files\/([^)]+\.woff2)\)\s*format\('woff2'\)\s*,\s*url\(\.\/files\/[^)]*\.woff\)\s*format\('woff'\)/g, (_m, f) => {
|
|
||||||
const src = (() => {
|
|
||||||
for (const [, , pkg] of raw) {
|
|
||||||
const candidate = join(fontsource, pkg, "files", f);
|
|
||||||
if (existsSync(candidate)) return candidate;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
})();
|
|
||||||
if (!src) {
|
|
||||||
console.warn(`WARN: font file not found: ${f}`);
|
|
||||||
return "";
|
|
||||||
}
|
|
||||||
mkdirSync(destFonts, { recursive: true });
|
|
||||||
writeFileSync(join(destFonts, f), readFileSync(src));
|
|
||||||
if (!copied.includes(f)) copied.push(f);
|
|
||||||
return `url(${FONT_ASSETS_DIR}/${f}) format('woff2')`;
|
|
||||||
});
|
|
||||||
|
|
||||||
return { style: "<style>\n" + css.trim() + "\n</style>", copied };
|
|
||||||
}
|
|
||||||
const cssFile = readdirSync(cssChunks)
|
|
||||||
.filter((f) => f.endsWith(".css"))
|
|
||||||
.sort((a, b) => statSync(join(cssChunks, b)).size - statSync(join(cssChunks, a)).size)[0];
|
|
||||||
const css = readFileSync(join(cssChunks, cssFile), "utf8");
|
|
||||||
|
|
||||||
const prettyCss = css
|
|
||||||
.replace(/\r/g, "")
|
|
||||||
.replace(/\{/g, " {\n ")
|
|
||||||
.replace(/;/g, ";\n ")
|
|
||||||
.replace(/\}/g, "\n}\n")
|
|
||||||
.replace(/\n{3,}/g, "\n\n")
|
|
||||||
.trim();
|
|
||||||
|
|
||||||
// --- conservative html formatter ---
|
|
||||||
|
|
||||||
const VOID = new Set([
|
|
||||||
"area",
|
|
||||||
"base",
|
|
||||||
"br",
|
|
||||||
"col",
|
|
||||||
"embed",
|
|
||||||
"hr",
|
|
||||||
"img",
|
|
||||||
"input",
|
|
||||||
"link",
|
|
||||||
"meta",
|
|
||||||
"param",
|
|
||||||
"source",
|
|
||||||
"track",
|
|
||||||
"wbr",
|
|
||||||
]);
|
|
||||||
const RAW = new Set(["pre", "textarea", "script", "style"]);
|
|
||||||
|
|
||||||
function fmtHtml(html) {
|
|
||||||
const tokens = html.match(/<!--[\s\S]*?-->|<!doctype[^>]*>|<\/?[a-zA-Z][^>]*>|[^<]+|</gi) || [];
|
|
||||||
const out = [];
|
|
||||||
let depth = 0;
|
|
||||||
let rawTag = null;
|
|
||||||
|
|
||||||
for (const tk of tokens) {
|
|
||||||
if (rawTag) {
|
|
||||||
out.push(tk);
|
|
||||||
if (new RegExp(`</${rawTag}\\s*>`, "i").test(tk)) {
|
|
||||||
depth--;
|
|
||||||
rawTag = null;
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const isOpen = /^<[a-zA-Z]/.test(tk);
|
|
||||||
const isClose = /^<\//.test(tk);
|
|
||||||
let name = "";
|
|
||||||
if (isOpen || isClose) {
|
|
||||||
name = (tk.match(/^<\/?([a-zA-Z0-9-]+)/) || [])[1]?.toLowerCase() || "";
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isOpen && RAW.has(name)) {
|
|
||||||
out.push("\n" + " ".repeat(depth) + tk);
|
|
||||||
if (!/\/>$/.test(tk)) {
|
|
||||||
depth++;
|
|
||||||
rawTag = name;
|
|
||||||
}
|
|
||||||
} else if (isOpen && !RAW.has(name)) {
|
|
||||||
out.push("\n" + " ".repeat(depth) + tk);
|
|
||||||
if (!/\/>$/.test(tk) && !VOID.has(name)) depth++;
|
|
||||||
} else if (isClose) {
|
|
||||||
depth = Math.max(0, depth - 1);
|
|
||||||
out.push("\n" + " ".repeat(depth) + tk);
|
|
||||||
} else if (/^</.test(tk)) {
|
|
||||||
out.push("\n" + " ".repeat(depth) + tk);
|
|
||||||
} else {
|
|
||||||
const t = tk.replace(/\s+/g, " ");
|
|
||||||
if (t.trim()) out.push(t);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
.join("")
|
|
||||||
.replace(/^\n/, "")
|
|
||||||
.replace(/\n{3,}/g, "\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- auto-discover pages by walking the export dir for *.html ---
|
|
||||||
function walk(dir, base = "") {
|
|
||||||
const found = [];
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
const full = join(dir, entry);
|
|
||||||
const rel = base ? join(base, entry) : entry;
|
|
||||||
if (statSync(full).isDirectory()) {
|
|
||||||
found.push(...walk(full, rel));
|
|
||||||
} else if (entry.endsWith(".html")) {
|
|
||||||
found.push(rel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pages = walk(root)
|
|
||||||
// skip Next internals
|
|
||||||
.filter((rel) => !basename(rel).startsWith("_") && !basename(rel).startsWith("404"))
|
|
||||||
// `index.html` ╨╜╨░╨╝ ╨╜╨╡ ╨╜╤Г╨╢╨╡╨╜ ╨║╨░╨║ ╤А╨╡╤Д, ╨┐╨╛╤Н╤В╨╛╨╝╤Г ╨╜╨╡ ╨│╨╡╨╜╨╡╤А╨╕╤А╤Г╨╡╨╝ ╨╡╨│╨╛.
|
|
||||||
.filter((rel) => basename(rel).toLowerCase() !== "index.html")
|
|
||||||
.map((rel) => [rel, basename(rel)]);
|
|
||||||
|
|
||||||
console.log(`discovered ${pages.length} page(s):`, pages.map((p) => p[1]).join(", "));
|
|
||||||
|
|
||||||
const { style: fontFacesStyle, copied: fontFiles } = buildFontFaces(dest);
|
|
||||||
if (fontFiles.length) {
|
|
||||||
console.log(`fonts: ${fontFiles.length} file(s) -> ${join(dest, FONT_ASSETS_DIR)}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const [src, out] of pages) {
|
|
||||||
let html = readFileSync(join(root, src), "utf8");
|
|
||||||
|
|
||||||
// strip scripts — pure static snapshot
|
|
||||||
html = html.replace(/<script[\s\S]*?<\/script>/g, "");
|
|
||||||
html = html.replace(/<link[^>]+rel="preload"[^>]+as="script"[^>]*>/g, "");
|
|
||||||
|
|
||||||
// inline compiled css instead of linking /_next/...
|
|
||||||
html = html.replace(/<link[^>]+rel="stylesheet"[^>]*>/g, () => "<style>\n" + prettyCss + "\n</style>");
|
|
||||||
|
|
||||||
// fonts first, so any later @font-face overrides win for the same family
|
|
||||||
if (fontFacesStyle) html = html.replace("<head>", "<head>\n " + fontFacesStyle.trim());
|
|
||||||
|
|
||||||
html = fmtHtml(html);
|
|
||||||
html = injectThemeToggle(html);
|
|
||||||
|
|
||||||
const file = join(dest, out);
|
|
||||||
mkdirSync(dirname(file), { recursive: true });
|
|
||||||
writeFileSync(file, html);
|
|
||||||
console.log("written:", file);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,57 +0,0 @@
|
|||||||
{
|
|
||||||
"generatedAt": "2026-08-29T04:04:14.643Z",
|
|
||||||
"reports": [
|
|
||||||
{
|
|
||||||
"route": "/preview/demo",
|
|
||||||
"ref": "demo.html",
|
|
||||||
"deltas": [],
|
|
||||||
"counts": {
|
|
||||||
"added": 0,
|
|
||||||
"removed": 0,
|
|
||||||
"tagMismatch": 0,
|
|
||||||
"textMismatch": 0,
|
|
||||||
"nodesAdded": 0,
|
|
||||||
"nodesRemoved": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/preview/list-tools",
|
|
||||||
"ref": "list-tools.html",
|
|
||||||
"deltas": [],
|
|
||||||
"counts": {
|
|
||||||
"added": 0,
|
|
||||||
"removed": 0,
|
|
||||||
"tagMismatch": 0,
|
|
||||||
"textMismatch": 0,
|
|
||||||
"nodesAdded": 0,
|
|
||||||
"nodesRemoved": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/preview/tools/linear-gradient-png",
|
|
||||||
"ref": "gradient.html",
|
|
||||||
"deltas": [],
|
|
||||||
"counts": {
|
|
||||||
"added": 0,
|
|
||||||
"removed": 0,
|
|
||||||
"tagMismatch": 0,
|
|
||||||
"textMismatch": 0,
|
|
||||||
"nodesAdded": 0,
|
|
||||||
"nodesRemoved": 0
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route": "/preview/tools/remove-background-png",
|
|
||||||
"ref": "background-remover.html",
|
|
||||||
"deltas": [],
|
|
||||||
"counts": {
|
|
||||||
"added": 0,
|
|
||||||
"removed": 0,
|
|
||||||
"tagMismatch": 0,
|
|
||||||
"textMismatch": 0,
|
|
||||||
"nodesAdded": 0,
|
|
||||||
"nodesRemoved": 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
# DOM audit (phase A)
|
|
||||||
|
|
||||||
_2026-08-29T04:04:14.645Z_
|
|
||||||
|
|
||||||
## Сводка
|
|
||||||
|
|
||||||
| route | ref | added | removed | tag | text |
|
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| `/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 | 0 | 0 | 0 | 0 |
|
|
||||||
| `/preview/tools/remove-background-png` | background-remover.html | 0 | 0 | 0 | 0 |
|
|
||||||
|
|
||||||
## /preview/demo (vs demo.html)
|
|
||||||
|
|
||||||
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
|
||||||
|
|
||||||
### Расхождения (топ 0)
|
|
||||||
|
|
||||||
_расхождений нет_
|
|
||||||
|
|
||||||
|
|
||||||
## /preview/list-tools (vs list-tools.html)
|
|
||||||
|
|
||||||
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
|
||||||
|
|
||||||
### Расхождения (топ 0)
|
|
||||||
|
|
||||||
_расхождений нет_
|
|
||||||
|
|
||||||
|
|
||||||
## /preview/tools/linear-gradient-png (vs gradient.html)
|
|
||||||
|
|
||||||
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
|
||||||
|
|
||||||
### Расхождения (топ 0)
|
|
||||||
|
|
||||||
_расхождений нет_
|
|
||||||
|
|
||||||
|
|
||||||
## /preview/tools/remove-background-png (vs background-remover.html)
|
|
||||||
|
|
||||||
- added: **0** (≈0 узл.), removed: **0** (≈0 узл.), tag: **0**, text: **0**
|
|
||||||
|
|
||||||
### Расхождения (топ 0)
|
|
||||||
|
|
||||||
_расхождений нет_
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
// Runs audit-cdp.mjs across the commonly used desktop / tablet / phone
|
|
||||||
// resolutions. Same CLI options pass through, e.g.:
|
|
||||||
// node scripts/audit-cdp-responsive.mjs --route /preview/demo --viewport 390x844
|
|
||||||
import { spawnSync } from "node:child_process";
|
|
||||||
|
|
||||||
const VIEWPORTS = ["2560x1440", "1920x1080", "1440x900", "768x1024", "390x844"];
|
|
||||||
|
|
||||||
const args = [];
|
|
||||||
let viewportSeen = false;
|
|
||||||
for (let i = 0; i < process.argv.length; i++) {
|
|
||||||
const v = process.argv[i];
|
|
||||||
if (v === "--viewport") {
|
|
||||||
viewportSeen = true;
|
|
||||||
i++;
|
|
||||||
} else args.push(v);
|
|
||||||
}
|
|
||||||
if (!viewportSeen) args.push("--viewport", VIEWPORTS.join(","));
|
|
||||||
|
|
||||||
const r = spawnSync(
|
|
||||||
process.execPath,
|
|
||||||
["scripts/audit-cdp.mjs", ...args.slice(2)],
|
|
||||||
{
|
|
||||||
stdio: "inherit",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
if (r.error) {
|
|
||||||
console.error(r.error.message);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
process.exit(r.status ?? 1);
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,523 +0,0 @@
|
|||||||
// Фаза B: CSS/визуальный аудит. Для каждого preview-маршрута снимает
|
|
||||||
// нормализованное дерево элементов с ВЫЧИСЛЕННЫМИ стилями (и геометрией)
|
|
||||||
// нашей страницы и соответствующего refs-html файла, выравнивает деревья
|
|
||||||
// по структуре (как в audit-dom — теги нормализованы, классы игнорируются,
|
|
||||||
// матчинг по ключу), и для совпавших узлов сравнивает стили + rect.
|
|
||||||
// Дополнительно сравнивает design-токены (CSS-переменные) light/dark.
|
|
||||||
// Запуск: pnpm refs-audit (опции: --route <path> --ref <file> --no-serve --ours <url>)
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
||||||
import { resolve, join } from "node:path";
|
|
||||||
|
|
||||||
const args = (() => {
|
|
||||||
const a = {};
|
|
||||||
for (let i = 0; i < process.argv.length; i++) {
|
|
||||||
const v = process.argv[i];
|
|
||||||
if (v === "--no-serve") a.noServe = true;
|
|
||||||
else if (v === "--ours") a.ours = process.argv[++i];
|
|
||||||
else if (v === "--ref") a.ref = process.argv[++i];
|
|
||||||
else if (v === "--route") a.route = process.argv[++i];
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const WEB = process.cwd();
|
|
||||||
const PORT = 5179;
|
|
||||||
const REFS_DIR = resolve(WEB, "../refs-html");
|
|
||||||
|
|
||||||
// Поля вычисленного стиля, которые сравниваем (визуально значимые).
|
|
||||||
const STYLE_FIELDS = [
|
|
||||||
"fontFamily",
|
|
||||||
"fontSize",
|
|
||||||
"fontWeight",
|
|
||||||
"fontStyle",
|
|
||||||
"lineHeight",
|
|
||||||
"letterSpacing",
|
|
||||||
"textAlign",
|
|
||||||
"textTransform",
|
|
||||||
"color",
|
|
||||||
"backgroundColor",
|
|
||||||
"display",
|
|
||||||
"flexDirection",
|
|
||||||
"alignItems",
|
|
||||||
"justifyContent",
|
|
||||||
"gap",
|
|
||||||
"gridTemplateColumns",
|
|
||||||
"padding",
|
|
||||||
"margin",
|
|
||||||
"borderRadius",
|
|
||||||
"borderTopWidth",
|
|
||||||
"borderTopColor",
|
|
||||||
"borderTopStyle",
|
|
||||||
"width",
|
|
||||||
"height",
|
|
||||||
"boxShadow",
|
|
||||||
"opacity",
|
|
||||||
];
|
|
||||||
|
|
||||||
// Динамические сегменты: явный список значений + сопоставление id → файл рефа.
|
|
||||||
const DYNAMIC = {
|
|
||||||
"tools/[id]": {
|
|
||||||
ids: ["linear-gradient-png", "remove-background-png"],
|
|
||||||
refFor: (id) =>
|
|
||||||
id === "linear-gradient-png"
|
|
||||||
? "gradient.html"
|
|
||||||
: id === "remove-background-png"
|
|
||||||
? "background-remover.html"
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const EXCLUDE = new Set(["/preview"]);
|
|
||||||
const refNameFor = (route) => {
|
|
||||||
if (route === "/preview") return "index.html";
|
|
||||||
const last = route.split("/").filter(Boolean).pop();
|
|
||||||
return `${last}.html`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function discoverRoutes() {
|
|
||||||
const base = resolve(WEB, "src/routes/preview");
|
|
||||||
const out = [];
|
|
||||||
const walk = (dir) => {
|
|
||||||
let ents;
|
|
||||||
try {
|
|
||||||
ents = readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const e of ents) {
|
|
||||||
const p = join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name === "+page.svelte") {
|
|
||||||
const rel = p.slice(base.length).replace(/\\/g, "/");
|
|
||||||
const parts = rel.split("/").filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
out.push(parts.length ? `/preview/${parts.join("/")}` : "/preview");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(base);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refsList() {
|
|
||||||
try {
|
|
||||||
return readdirSync(REFS_DIR).filter((f) => f.endsWith(".html"));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const refExists = (name) => refsList().includes(name);
|
|
||||||
|
|
||||||
function buildTargets() {
|
|
||||||
if (args.route) {
|
|
||||||
const ref = args.ref || refNameFor(args.route);
|
|
||||||
return [{ route: args.route, ref }];
|
|
||||||
}
|
|
||||||
const targets = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const add = (route, ref) => {
|
|
||||||
if (!ref || seen.has(route)) return;
|
|
||||||
if (!refExists(ref)) {
|
|
||||||
console.warn(`[css-audit] ref not found, skipped: ${route} -> ${ref}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(route);
|
|
||||||
targets.push({ route, ref });
|
|
||||||
};
|
|
||||||
for (const route of discoverRoutes()) {
|
|
||||||
if (route.includes("[")) continue;
|
|
||||||
if (EXCLUDE.has(route)) {
|
|
||||||
console.log(`[css-audit] excluded (no relevant ref): ${route}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
add(route, refNameFor(route));
|
|
||||||
}
|
|
||||||
for (const [seg, cfg] of Object.entries(DYNAMIC)) {
|
|
||||||
for (const id of cfg.ids) {
|
|
||||||
add(`/preview/${seg.replace("[id]", id)}`, cfg.refFor(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const routed = new Set(targets.map((t) => t.ref));
|
|
||||||
for (const f of refsList()) {
|
|
||||||
if (!routed.has(f)) console.warn(`[css-audit] ref без маршрута: ${f}`);
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitFor = async (url, ms = 60000) => {
|
|
||||||
const t = Date.now();
|
|
||||||
while (Date.now() - t < ms) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (r.ok) return;
|
|
||||||
if (r.status >= 400) {
|
|
||||||
throw new Error(
|
|
||||||
`route ${url} returned HTTP ${r.status} (server is up, page failed to render)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("returned HTTP")) throw e;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
throw new Error("dev server not up: " + url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Теги-обёртки нормализуем к "box", чтобы выравнивать по сути, а не по
|
|
||||||
// конкретному тегу (наш div/main vs рефовый div/main). Семантические — как есть.
|
|
||||||
const STRUCT_TAGS = new Set([
|
|
||||||
"div",
|
|
||||||
"main",
|
|
||||||
"section",
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"ul",
|
|
||||||
"li",
|
|
||||||
"form",
|
|
||||||
"fieldset",
|
|
||||||
]);
|
|
||||||
const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
|
|
||||||
const keyOf = (n) =>
|
|
||||||
n.children.length === 0 ? `${normTag(n.tag)}|${n.text}` : normTag(n.tag);
|
|
||||||
|
|
||||||
const snapshot = (page) =>
|
|
||||||
page.evaluate((styleFields) => {
|
|
||||||
const sig = (el) => {
|
|
||||||
const s = getComputedStyle(el);
|
|
||||||
const o = {};
|
|
||||||
for (const f of styleFields) o[f] = s[f];
|
|
||||||
return o;
|
|
||||||
};
|
|
||||||
const rectOf = (el) => {
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return {
|
|
||||||
x: Math.round(r.x),
|
|
||||||
y: Math.round(r.y),
|
|
||||||
w: Math.round(r.width),
|
|
||||||
h: Math.round(r.height),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
const readTokens = () => {
|
|
||||||
const s = getComputedStyle(document.documentElement);
|
|
||||||
const out = {};
|
|
||||||
for (const k of s)
|
|
||||||
if (k.startsWith("--")) out[k] = s.getPropertyValue(k).trim();
|
|
||||||
return out;
|
|
||||||
};
|
|
||||||
const tokensLight = readTokens();
|
|
||||||
const prev = document.documentElement.getAttribute("data-theme");
|
|
||||||
document.documentElement.setAttribute("data-theme", "dark");
|
|
||||||
const tokensDark = readTokens();
|
|
||||||
if (prev) document.documentElement.setAttribute("data-theme", prev);
|
|
||||||
else document.documentElement.removeAttribute("data-theme");
|
|
||||||
|
|
||||||
const walk = (el) => {
|
|
||||||
const tag = el.tagName.toLowerCase();
|
|
||||||
if (["script", "style", "noscript", "template"].includes(tag))
|
|
||||||
return null;
|
|
||||||
if (el.id === "svelte-announcer") return null;
|
|
||||||
if (el.hasAttribute("hidden")) return null;
|
|
||||||
const cs = getComputedStyle(el);
|
|
||||||
if (cs.display === "none" || cs.visibility === "hidden") return null;
|
|
||||||
let ownText = "";
|
|
||||||
for (const c of el.childNodes)
|
|
||||||
if (c.nodeType === 3) ownText += c.textContent;
|
|
||||||
ownText = ownText.replace(/\s+/g, " ").trim();
|
|
||||||
const node = {
|
|
||||||
tag,
|
|
||||||
text: ownText,
|
|
||||||
style: sig(el),
|
|
||||||
rect: rectOf(el),
|
|
||||||
children: [],
|
|
||||||
};
|
|
||||||
if (tag === "svg") return node;
|
|
||||||
for (const child of el.children) {
|
|
||||||
const cn = walk(child);
|
|
||||||
if (cn) node.children.push(cn);
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return { tokensLight, tokensDark, tree: walk(document.body) };
|
|
||||||
}, STYLE_FIELDS);
|
|
||||||
|
|
||||||
const TOK_NOISE = /^(--tw-|--lightningcss-|--default-)/;
|
|
||||||
|
|
||||||
function compareStyle(ours, ref, path, out) {
|
|
||||||
if (!ours || !ref) return;
|
|
||||||
if (ours.tag !== ref.tag) {
|
|
||||||
out.push({ path, type: "tagMismatch", ours: ours.tag, ref: ref.tag });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (ours.text && ref.text && ours.text !== ref.text) {
|
|
||||||
out.push({ path, type: "textMismatch", ours: ours.text, ref: ref.text });
|
|
||||||
}
|
|
||||||
const deltas = {};
|
|
||||||
for (const f of STYLE_FIELDS) {
|
|
||||||
const ov = (ours.style[f] ?? "").toString();
|
|
||||||
const rv = (ref.style[f] ?? "").toString();
|
|
||||||
if (ov !== rv) deltas[f] = { ours: ov, ref: rv };
|
|
||||||
}
|
|
||||||
const dr = ours.rect;
|
|
||||||
const rr = ref.rect;
|
|
||||||
if (
|
|
||||||
Math.abs(dr.x - rr.x) > 2 ||
|
|
||||||
Math.abs(dr.y - rr.y) > 2 ||
|
|
||||||
Math.abs(dr.w - rr.w) > 2 ||
|
|
||||||
Math.abs(dr.h - rr.h) > 2
|
|
||||||
)
|
|
||||||
deltas.rect = { ours: dr, ref: rr };
|
|
||||||
if (Object.keys(deltas).length)
|
|
||||||
out.push({ path, type: "style", tag: ours.tag, text: ours.text, deltas });
|
|
||||||
|
|
||||||
// Выравниваем детей по ключу (жадно), рекурсивно сравнивая стили.
|
|
||||||
const bByKey = new Map();
|
|
||||||
ref.children.forEach((c, idx) => {
|
|
||||||
const k = keyOf(c);
|
|
||||||
if (!bByKey.has(k)) bByKey.set(k, []);
|
|
||||||
bByKey.get(k).push(idx);
|
|
||||||
});
|
|
||||||
const used = new Set();
|
|
||||||
for (let i = 0; i < ours.children.length; i++) {
|
|
||||||
const aNode = ours.children[i];
|
|
||||||
const cands = bByKey.get(keyOf(aNode));
|
|
||||||
let bIdx = -1;
|
|
||||||
if (cands)
|
|
||||||
for (const ci of cands)
|
|
||||||
if (!used.has(ci)) {
|
|
||||||
bIdx = ci;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (bIdx >= 0) {
|
|
||||||
used.add(bIdx);
|
|
||||||
compareStyle(
|
|
||||||
aNode,
|
|
||||||
ref.children[bIdx],
|
|
||||||
`${path} > ${aNode.tag}:${i + 1}`,
|
|
||||||
out,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
out.push({
|
|
||||||
path: `${path} > ${aNode.tag}:${i + 1}`,
|
|
||||||
type: "added",
|
|
||||||
tag: aNode.tag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let j = 0; j < ref.children.length; j++) {
|
|
||||||
if (used.has(j)) continue;
|
|
||||||
out.push({
|
|
||||||
path: `${path} > ${ref.children[j].tag}:${j + 1}`,
|
|
||||||
type: "removed",
|
|
||||||
tag: ref.children[j].tag,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function tokDiff(a, b) {
|
|
||||||
const keys = new Set([...Object.keys(a), ...Object.keys(b)]);
|
|
||||||
const rows = [];
|
|
||||||
for (const k of keys) {
|
|
||||||
if (TOK_NOISE.test(k)) continue;
|
|
||||||
const av = a[k] ?? "—";
|
|
||||||
const bv = b[k] ?? "—";
|
|
||||||
if (av !== bv) rows.push({ key: k, ours: av, ref: bv });
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function auditOne(browser, target) {
|
|
||||||
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
|
||||||
|
|
||||||
const page = await browser.newPage({
|
|
||||||
viewport: { width: 1440, height: 900 },
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
await page.goto(oursUrl, { waitUntil: "load" });
|
|
||||||
await page.evaluate(() => document.fonts.ready);
|
|
||||||
// Рефы — только светлые; наше приложение по умолчанию может рендерить
|
|
||||||
// тёмную тему (app.html смотрит prefers-color-scheme). Нормализуем ОБЕ
|
|
||||||
// стороны к light, чтобы аудит мерил реальные расхождения дизайна, а не
|
|
||||||
// инверсию темы.
|
|
||||||
await page.evaluate(() => {
|
|
||||||
document.documentElement.dataset.theme = "light";
|
|
||||||
});
|
|
||||||
await page.waitForTimeout(500);
|
|
||||||
const ours = await snapshot(page);
|
|
||||||
await page.close();
|
|
||||||
|
|
||||||
// Реф грузим в ИЗОЛИРОВАННОМ контексте через goto(file://): переход
|
|
||||||
// http → file в одной странице уничтожает execution context и роняет прогон.
|
|
||||||
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.evaluate(() => document.fonts.ready);
|
|
||||||
await refPage.evaluate(() => {
|
|
||||||
document.documentElement.dataset.theme = "light";
|
|
||||||
});
|
|
||||||
await refPage.waitForTimeout(400);
|
|
||||||
const ref = await snapshot(refPage);
|
|
||||||
await ctx.close();
|
|
||||||
|
|
||||||
const deltas = [];
|
|
||||||
compareStyle(ours.tree, ref.tree, "body", deltas);
|
|
||||||
const tokenDiffs = {
|
|
||||||
light: tokDiff(ours.tokensLight, ref.tokensLight),
|
|
||||||
dark: tokDiff(ours.tokensDark, ref.tokensDark),
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
route: target.route,
|
|
||||||
ref: target.ref,
|
|
||||||
deltas,
|
|
||||||
tokenDiffs,
|
|
||||||
counts: tally(deltas, tokenDiffs),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function tally(deltas, tokenDiffs) {
|
|
||||||
const c = {
|
|
||||||
tagMismatch: 0,
|
|
||||||
textMismatch: 0,
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
styleDiffs: 0,
|
|
||||||
rectDiffs: 0,
|
|
||||||
fieldDiffs: 0,
|
|
||||||
};
|
|
||||||
for (const d of deltas) {
|
|
||||||
if (d.type === "style") {
|
|
||||||
c.styleDiffs++;
|
|
||||||
c.fieldDiffs += Object.keys(d.deltas).length;
|
|
||||||
if (d.deltas.rect) c.rectDiffs++;
|
|
||||||
} else if (d.type in c) c[d.type]++;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
...c,
|
|
||||||
tokenLight: tokenDiffs.light.length,
|
|
||||||
tokenDark: tokenDiffs.dark.length,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMarkdown(reports) {
|
|
||||||
const tokTable = (rows) =>
|
|
||||||
rows.length
|
|
||||||
? `| token | ours | ref |\n|---|---|---|\n` +
|
|
||||||
rows.map((r) => `| \`${r.key}\` | ${r.ours} | ${r.ref} |`).join("\n")
|
|
||||||
: "_все совпадают_";
|
|
||||||
const elBlock = (el) => {
|
|
||||||
const d = Object.entries(el.deltas)
|
|
||||||
.map(([k, v]) => {
|
|
||||||
if (k === "rect") {
|
|
||||||
const f = (r) => `${r.x},${r.y} ${r.w}x${r.h}`;
|
|
||||||
return ` - **rect**: \`${f(v.ours)}\` → \`${f(v.ref)}\``;
|
|
||||||
}
|
|
||||||
return ` - **${k}**: \`${v.ours}\` → \`${v.ref}\``;
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
return `### \`${el.path}\`${el.text ? ` — "${el.text}"` : ""}\n${d}`;
|
|
||||||
};
|
|
||||||
const summary =
|
|
||||||
`# CSS audit (phase B)\n\n_${new Date().toISOString()}_\n\n` +
|
|
||||||
`## Сводка\n\n| route | ref | tokL | tokD | style (el) | fields | rect | +struct | -struct | tag | text |\n|---|---|---|---|---|---|---|---|---|---|---|\n` +
|
|
||||||
reports
|
|
||||||
.map(
|
|
||||||
(r) =>
|
|
||||||
`| \`${r.route}\` | ${r.ref} | ${r.counts.tokenLight} | ${r.counts.tokenDark} | ${r.counts.styleDiffs} | ${r.counts.fieldDiffs} | ${r.counts.rectDiffs} | ${r.counts.added} | ${r.counts.removed} | ${r.counts.tagMismatch} | ${r.counts.textMismatch} |`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
const sections = reports
|
|
||||||
.map((r) => {
|
|
||||||
const styleDiffs = r.deltas.filter((d) => d.type === "style");
|
|
||||||
const sorted = [...styleDiffs].sort(
|
|
||||||
(x, y) => Object.keys(y.deltas).length - Object.keys(x.deltas).length,
|
|
||||||
);
|
|
||||||
const els = sorted.length
|
|
||||||
? sorted.slice(0, 150).map(elBlock).join("\n\n")
|
|
||||||
: "_расхождений стилей нет_";
|
|
||||||
const struct = r.deltas
|
|
||||||
.filter((d) => d.type !== "style")
|
|
||||||
.map(
|
|
||||||
(d) => `- \`${d.path}\` — **${d.type}**${d.tag ? ` (${d.tag})` : ""}`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
return (
|
|
||||||
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
|
|
||||||
`- Токены light: **${r.counts.tokenLight}**, dark: **${r.counts.tokenDark}**\n` +
|
|
||||||
`- Стиль-расхождений: **${r.counts.styleDiffs}** элементов / **${r.counts.fieldDiffs}** полей (из них геометрия: ${r.counts.rectDiffs})\n` +
|
|
||||||
`- Структурные: +${r.counts.added} / -${r.counts.removed} / tag ${r.counts.tagMismatch} / text ${r.counts.textMismatch}\n\n` +
|
|
||||||
`### Токены — Light\n\n${tokTable(r.tokenDiffs.light)}\n\n` +
|
|
||||||
`### Токены — Dark\n\n${tokTable(r.tokenDiffs.dark)}\n\n` +
|
|
||||||
`### Расхождения стилей (топ ${Math.min(150, sorted.length)})\n\n${els}\n\n` +
|
|
||||||
`### Структурные расхождения\n\n` +
|
|
||||||
(struct || "_—_")
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
return summary + sections + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const targets = buildTargets();
|
|
||||||
if (!targets.length) {
|
|
||||||
console.error("no audit targets (refs-html/*.html missing?)");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`[css-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
|
|
||||||
|
|
||||||
let server;
|
|
||||||
if (!args.noServe) {
|
|
||||||
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
||||||
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
|
|
||||||
cwd: WEB,
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
});
|
|
||||||
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const reports = [];
|
|
||||||
try {
|
|
||||||
for (const target of targets) {
|
|
||||||
try {
|
|
||||||
const rep = await auditOne(browser, target);
|
|
||||||
reports.push(rep);
|
|
||||||
console.log(
|
|
||||||
`[css-audit] ${rep.route}: tokL=${rep.counts.tokenLight} tokD=${rep.counts.tokenDark} style=${rep.counts.styleDiffs} fields=${rep.counts.fieldDiffs} rect=${rep.counts.rectDiffs} +struct=${rep.counts.added} -struct=${rep.counts.removed}`,
|
|
||||||
);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[css-audit] ${target.route} failed: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
mkdirSync(resolve(WEB, "audit"), { recursive: true });
|
|
||||||
writeFileSync(
|
|
||||||
resolve(WEB, "audit/audit-report.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{ generatedAt: new Date().toISOString(), reports },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
writeFileSync(resolve(WEB, "audit/audit-report.md"), toMarkdown(reports));
|
|
||||||
console.log(`report: web/audit/audit-report.md`);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
if (server) {
|
|
||||||
try {
|
|
||||||
if (process.platform === "win32")
|
|
||||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
else server.kill("SIGTERM");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@@ -1,415 +0,0 @@
|
|||||||
// Фаза A: структурный (DOM) аудит. Для каждого preview-маршрута снимает
|
|
||||||
// нормализованное дерево элементов нашей страницы и соответствующего refs-html
|
|
||||||
// файла, сравнивает по структурному пути (без IGNORE) и пишет отчёт о
|
|
||||||
// расхождениях (added / removed / tagMismatch / textMismatch).
|
|
||||||
// Запуск: pnpm refs-dom-audit (опции: --route <path> --ref <file> --no-serve --ours <url>)
|
|
||||||
import { chromium } from "playwright";
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
|
|
||||||
import { resolve, join } from "node:path";
|
|
||||||
|
|
||||||
const args = (() => {
|
|
||||||
const a = {};
|
|
||||||
for (let i = 0; i < process.argv.length; i++) {
|
|
||||||
const v = process.argv[i];
|
|
||||||
if (v === "--no-serve") a.noServe = true;
|
|
||||||
else if (v === "--ours") a.ours = process.argv[++i];
|
|
||||||
else if (v === "--ref") a.ref = process.argv[++i];
|
|
||||||
else if (v === "--route") a.route = process.argv[++i];
|
|
||||||
}
|
|
||||||
return a;
|
|
||||||
})();
|
|
||||||
|
|
||||||
const WEB = process.cwd();
|
|
||||||
const PORT = 5179;
|
|
||||||
const REFS_DIR = resolve(WEB, "../refs-html");
|
|
||||||
|
|
||||||
// Динамические сегменты: явный список значений + сопоставление id → файл рефа.
|
|
||||||
const DYNAMIC = {
|
|
||||||
"tools/[id]": {
|
|
||||||
ids: ["linear-gradient-png", "remove-background-png"],
|
|
||||||
refFor: (id) =>
|
|
||||||
id === "linear-gradient-png"
|
|
||||||
? "gradient.html"
|
|
||||||
: id === "remove-background-png"
|
|
||||||
? "background-remover.html"
|
|
||||||
: null,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
// Маршруты без релевантного рефа (refs-html/index.html — зеркало demo из Next, не реф).
|
|
||||||
const EXCLUDE = new Set(["/preview"]);
|
|
||||||
|
|
||||||
const refNameFor = (route) => {
|
|
||||||
if (route === "/preview") return "index.html";
|
|
||||||
const last = route.split("/").filter(Boolean).pop();
|
|
||||||
return `${last}.html`;
|
|
||||||
};
|
|
||||||
|
|
||||||
function discoverRoutes() {
|
|
||||||
const base = resolve(WEB, "src/routes/preview");
|
|
||||||
const out = [];
|
|
||||||
const walk = (dir) => {
|
|
||||||
let ents;
|
|
||||||
try {
|
|
||||||
ents = readdirSync(dir, { withFileTypes: true });
|
|
||||||
} catch {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
for (const e of ents) {
|
|
||||||
const p = join(dir, e.name);
|
|
||||||
if (e.isDirectory()) walk(p);
|
|
||||||
else if (e.name === "+page.svelte") {
|
|
||||||
const rel = p.slice(base.length).replace(/\\/g, "/");
|
|
||||||
const parts = rel.split("/").filter(Boolean);
|
|
||||||
parts.pop();
|
|
||||||
out.push(parts.length ? `/preview/${parts.join("/")}` : "/preview");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
walk(base);
|
|
||||||
return out;
|
|
||||||
}
|
|
||||||
|
|
||||||
function refsList() {
|
|
||||||
try {
|
|
||||||
return readdirSync(REFS_DIR).filter((f) => f.endsWith(".html"));
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
function refExists(name) {
|
|
||||||
return refsList().includes(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildTargets() {
|
|
||||||
if (args.route) {
|
|
||||||
const ref = args.ref || refNameFor(args.route);
|
|
||||||
return [{ route: args.route, ref }];
|
|
||||||
}
|
|
||||||
const targets = [];
|
|
||||||
const seen = new Set();
|
|
||||||
const add = (route, ref) => {
|
|
||||||
if (!ref || seen.has(route)) return;
|
|
||||||
if (!refExists(ref)) {
|
|
||||||
console.warn(`[dom-audit] ref not found, skipped: ${route} -> ${ref}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
seen.add(route);
|
|
||||||
targets.push({ route, ref });
|
|
||||||
};
|
|
||||||
for (const route of discoverRoutes()) {
|
|
||||||
if (route.includes("[")) continue;
|
|
||||||
if (EXCLUDE.has(route)) {
|
|
||||||
console.log(`[dom-audit] excluded (no relevant ref): ${route}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
add(route, refNameFor(route));
|
|
||||||
}
|
|
||||||
for (const [seg, cfg] of Object.entries(DYNAMIC)) {
|
|
||||||
for (const id of cfg.ids) {
|
|
||||||
add(`/preview/${seg.replace("[id]", id)}`, cfg.refFor(id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const routed = new Set(targets.map((t) => t.ref));
|
|
||||||
for (const f of refsList()) {
|
|
||||||
if (!routed.has(f)) console.warn(`[dom-audit] ref без маршрута: ${f}`);
|
|
||||||
}
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
const waitFor = async (url, ms = 60000) => {
|
|
||||||
const t = Date.now();
|
|
||||||
while (Date.now() - t < ms) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(url);
|
|
||||||
if (r.ok) return;
|
|
||||||
if (r.status >= 400) {
|
|
||||||
throw new Error(
|
|
||||||
`route ${url} returned HTTP ${r.status} (server is up, page failed to render)`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
if (e instanceof Error && e.message.includes("returned HTTP")) throw e;
|
|
||||||
}
|
|
||||||
await new Promise((r) => setTimeout(r, 500));
|
|
||||||
}
|
|
||||||
throw new Error("dev server not up: " + url);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Нормализованное дерево элементов: тег, отсортированные классы, собственный
|
|
||||||
// текст (без SVG-иконок), дети. Пропускаем служебное; SVG не спускаемся внутрь.
|
|
||||||
const snapshot = (page) =>
|
|
||||||
page.evaluate(() => {
|
|
||||||
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;
|
|
||||||
const cs = getComputedStyle(el);
|
|
||||||
if (cs.display === "none" || cs.visibility === "hidden") return null;
|
|
||||||
const classes = [...el.classList].sort();
|
|
||||||
let ownText = "";
|
|
||||||
for (const c of el.childNodes)
|
|
||||||
if (c.nodeType === 3) ownText += c.textContent;
|
|
||||||
ownText = ownText.replace(/\s+/g, " ").trim();
|
|
||||||
const node = { tag, classes, ownText, children: [] };
|
|
||||||
if (tag === "svg") return node;
|
|
||||||
for (const child of el.children) {
|
|
||||||
const cn = walk(child);
|
|
||||||
if (cn) node.children.push(cn);
|
|
||||||
}
|
|
||||||
return node;
|
|
||||||
};
|
|
||||||
return walk(document.body);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Ключ сопоставления: тег + собственный текст (классы НЕ входят — у нас свои
|
|
||||||
// имена по решению №2). Чисто-структурные обёртки (div/main/section/…)
|
|
||||||
// нормализуем к общему токену, чтобы выравнивать дерево по сути, а не по
|
|
||||||
// конкретному тегу оболочки (наш `.preview-root`/div vs рефовый `main`/`.app-shell`).
|
|
||||||
// Семантические теги (header/footer/nav/span/button…) оставляем как есть.
|
|
||||||
const STRUCT_TAGS = new Set([
|
|
||||||
"div",
|
|
||||||
"main",
|
|
||||||
"section",
|
|
||||||
"article",
|
|
||||||
"aside",
|
|
||||||
"ul",
|
|
||||||
"li",
|
|
||||||
"form",
|
|
||||||
"fieldset",
|
|
||||||
]);
|
|
||||||
const normTag = (tag) => (STRUCT_TAGS.has(tag) ? "box" : tag);
|
|
||||||
// Контейнеры (есть дочерние элементы) матчим по тегу, игнорируя собственный
|
|
||||||
// текст — иначе малейшее отличие текста в обёртке каскадом роняет всё поддерево.
|
|
||||||
// Листья (нет дочерних элементов) матчим по тегу + тексту.
|
|
||||||
const keyOf = (n) =>
|
|
||||||
n.children.length === 0 ? `${normTag(n.tag)}|${n.ownText}` : normTag(n.tag);
|
|
||||||
|
|
||||||
function subtreeCount(node) {
|
|
||||||
let c = 1;
|
|
||||||
for (const c0 of node.children) c += subtreeCount(c0);
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
// Несовпавшее поддерево — одна запись с числом узлов, без рекурсивного
|
|
||||||
// засорения отчёта. (Каскад внутри уже учтён как один факт расхождения.)
|
|
||||||
function reportUnmatched(node, path, type, out) {
|
|
||||||
out.push({
|
|
||||||
path,
|
|
||||||
type,
|
|
||||||
tag: node.tag,
|
|
||||||
text: node.ownText,
|
|
||||||
nodes: subtreeCount(node),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function diffNodes(a, b, path, out) {
|
|
||||||
if (a.tag !== b.tag)
|
|
||||||
out.push({ path, type: "tagMismatch", ours: a.tag, ref: b.tag });
|
|
||||||
if (a.ownText !== b.ownText)
|
|
||||||
out.push({ path, type: "textMismatch", ours: a.ownText, ref: b.ownText });
|
|
||||||
matchChildren(a.children, b.children, path, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Выравниваем детей по ключу (жадно, в порядке), чтобы одно расхождение не
|
|
||||||
// каскадировало на всех потомков. Несовпавшие — added/removed целиком поддеревом.
|
|
||||||
function matchChildren(ac, bc, path, out) {
|
|
||||||
const bByKey = new Map();
|
|
||||||
bc.forEach((c, idx) => {
|
|
||||||
const k = keyOf(c);
|
|
||||||
if (!bByKey.has(k)) bByKey.set(k, []);
|
|
||||||
bByKey.get(k).push(idx);
|
|
||||||
});
|
|
||||||
const used = new Set();
|
|
||||||
for (let i = 0; i < ac.length; i++) {
|
|
||||||
const aNode = ac[i];
|
|
||||||
const k = keyOf(aNode);
|
|
||||||
const cands = bByKey.get(k);
|
|
||||||
let bIdx = -1;
|
|
||||||
if (cands)
|
|
||||||
for (const ci of cands)
|
|
||||||
if (!used.has(ci)) {
|
|
||||||
bIdx = ci;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (bIdx >= 0) {
|
|
||||||
used.add(bIdx);
|
|
||||||
diffNodes(aNode, bc[bIdx], `${path} > ${aNode.tag}:${i + 1}`, out);
|
|
||||||
} else {
|
|
||||||
reportUnmatched(aNode, `${path} > ${aNode.tag}:${i + 1}`, "added", out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (let j = 0; j < bc.length; j++) {
|
|
||||||
if (used.has(j)) continue;
|
|
||||||
reportUnmatched(bc[j], `${path} > ${bc[j].tag}:${j + 1}`, "removed", out);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Точка входа: сравниваем деревья от body.
|
|
||||||
function diff(ours, ref, path, out) {
|
|
||||||
if (!ours || !ref) return;
|
|
||||||
diffNodes(ours, ref, path, out);
|
|
||||||
}
|
|
||||||
|
|
||||||
function tally(deltas) {
|
|
||||||
const c = {
|
|
||||||
added: 0,
|
|
||||||
removed: 0,
|
|
||||||
tagMismatch: 0,
|
|
||||||
textMismatch: 0,
|
|
||||||
nodesAdded: 0,
|
|
||||||
nodesRemoved: 0,
|
|
||||||
};
|
|
||||||
for (const d of deltas) {
|
|
||||||
if (d.type in c) c[d.type]++;
|
|
||||||
if (d.type === "added") c.nodesAdded += d.nodes ?? 1;
|
|
||||||
if (d.type === "removed") c.nodesRemoved += d.nodes ?? 1;
|
|
||||||
}
|
|
||||||
return c;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function auditOne(browser, target) {
|
|
||||||
const oursUrl = args.ours || `http://127.0.0.1:${PORT}${target.route}`;
|
|
||||||
|
|
||||||
const page = await browser.newPage({
|
|
||||||
viewport: { width: 1440, height: 900 },
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
});
|
|
||||||
await page.goto(oursUrl, { waitUntil: "load" });
|
|
||||||
await page.waitForTimeout(600);
|
|
||||||
const ours = await snapshot(page);
|
|
||||||
await page.close();
|
|
||||||
|
|
||||||
// Реф грузим через 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);
|
|
||||||
return {
|
|
||||||
route: target.route,
|
|
||||||
ref: target.ref,
|
|
||||||
deltas,
|
|
||||||
counts: tally(deltas),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function toMarkdown(reports) {
|
|
||||||
const summary =
|
|
||||||
`# DOM audit (phase A)\n\n_${new Date().toISOString()}_\n\n` +
|
|
||||||
`## Сводка\n\n| route | ref | added | removed | tag | text |\n|---|---|---|---|---|---|\n` +
|
|
||||||
reports
|
|
||||||
.map(
|
|
||||||
(r) =>
|
|
||||||
`| \`${r.route}\` | ${r.ref} | ${r.counts.added} | ${r.counts.removed} | ${r.counts.tagMismatch} | ${r.counts.textMismatch} |`,
|
|
||||||
)
|
|
||||||
.join("\n");
|
|
||||||
const sections = reports
|
|
||||||
.map((r) => {
|
|
||||||
const deltas = r.deltas.slice(0, 150);
|
|
||||||
const body = deltas.length
|
|
||||||
? deltas
|
|
||||||
.map((d) => {
|
|
||||||
if (d.type === "textMismatch")
|
|
||||||
return `- \`${d.path}\` **text**: ours \`${d.ours}\` → ref \`${d.ref}\``;
|
|
||||||
if (d.type === "tagMismatch")
|
|
||||||
return `- \`${d.path}\` **tag**: ours \`${d.ours}\` → ref \`${d.ref}\``;
|
|
||||||
if (d.type === "added")
|
|
||||||
return `- \`${d.path}\` **added** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
|
||||||
if (d.type === "removed")
|
|
||||||
return `- \`${d.path}\` **removed** (\`${d.tag}\`${d.text ? ` "${d.text}"` : ""} · ${d.nodes} узл.)`;
|
|
||||||
return "";
|
|
||||||
})
|
|
||||||
.join("\n")
|
|
||||||
: "_расхождений нет_";
|
|
||||||
const more =
|
|
||||||
r.deltas.length > deltas.length
|
|
||||||
? `\n\n_…и ещё ${r.deltas.length - deltas.length} (см. json)_`
|
|
||||||
: "";
|
|
||||||
return (
|
|
||||||
`\n\n## ${r.route} (vs ${r.ref})\n\n` +
|
|
||||||
`- added: **${r.counts.added}** (≈${r.counts.nodesAdded} узл.), removed: **${r.counts.removed}** (≈${r.counts.nodesRemoved} узл.), tag: **${r.counts.tagMismatch}**, text: **${r.counts.textMismatch}**\n\n` +
|
|
||||||
`### Расхождения (топ ${Math.min(150, r.deltas.length)})\n\n${body}${more}`
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.join("\n");
|
|
||||||
return summary + sections + "\n";
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const targets = buildTargets();
|
|
||||||
if (!targets.length) {
|
|
||||||
console.error("no audit targets (refs-html/*.html missing?)");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
console.log(`[dom-audit] targets: ${targets.map((t) => t.route).join(", ")}`);
|
|
||||||
|
|
||||||
let server;
|
|
||||||
if (!args.noServe) {
|
|
||||||
const bin = process.platform === "win32" ? "pnpm.cmd" : "pnpm";
|
|
||||||
server = spawn(bin, ["dev", "--port", String(PORT), "--strictPort"], {
|
|
||||||
cwd: WEB,
|
|
||||||
stdio: "ignore",
|
|
||||||
shell: true,
|
|
||||||
detached: process.platform !== "win32",
|
|
||||||
});
|
|
||||||
await waitFor(args.ours || `http://127.0.0.1:${PORT}${targets[0].route}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const browser = await chromium.launch();
|
|
||||||
const reports = [];
|
|
||||||
try {
|
|
||||||
for (const target of targets) {
|
|
||||||
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(
|
|
||||||
resolve(WEB, "audit/dom-report.json"),
|
|
||||||
JSON.stringify(
|
|
||||||
{ generatedAt: new Date().toISOString(), reports },
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
writeFileSync(resolve(WEB, "audit/dom-report.md"), toMarkdown(reports));
|
|
||||||
console.log(`report: web/audit/dom-report.md`);
|
|
||||||
} finally {
|
|
||||||
await browser.close();
|
|
||||||
if (server) {
|
|
||||||
try {
|
|
||||||
if (process.platform === "win32")
|
|
||||||
spawn("taskkill", ["/pid", String(server.pid), "/f", "/t"], {
|
|
||||||
stdio: "ignore",
|
|
||||||
});
|
|
||||||
else server.kill("SIGTERM");
|
|
||||||
} catch {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user