Compare commits

..
368 Commits
Author SHA1 Message Date
github-actions[bot] 225a496ac7 chore(version): v0.2.0 2026-10-04 20:33:10 +00:00
Ku6epXBOCTuK 6b946bfb08 feat: add pipeline flow 2026-10-05 01:31:38 +05:00
Ku6epXBOCTuK e87bc1d715 feat: add select format to download button 2026-10-04 23:43:06 +05:00
Ku6epXBOCTuK 864f9cdc9f feat: upload source by ctrl+v and dropzone 2026-10-04 22:30:21 +05:00
Ku6epXBOCTuK 91150a14bf feat: add size presets for tools 2026-10-04 21:59:18 +05:00
Ku6epXBOCTuK 4d9cd2cfcb fix: keep aspect ratio flow 2026-10-04 20:52:47 +05:00
Ku6epXBOCTuK e73986fefd fix: update range slider field component 2026-10-04 20:34:40 +05:00
Ku6epXBOCTuK 5743b9fb3f feat: touched fields dont reset on change source image 2026-10-04 15:51:47 +05:00
Ku6epXBOCTuK 08608b878a docs: reorder backlog 2026-10-04 15:09:10 +05:00
Ku6epXBOCTuK 409c98ca29 refactor: reuse badge component 2026-10-04 13:13:06 +05:00
Ku6epXBOCTuK 62bdd5a7c5 fix: add title from i18n 2026-10-04 11:24:17 +05:00
Ku6epXBOCTuK f40a7c89ae test: fix tool flow inherit url 2026-10-04 11:24:11 +05:00
github-actions[bot] 260a738ce4 chore(version): v0.1.1 2026-10-04 05:53:29 +00:00
Ku6epXBOCTuK e1fc11cb54 fix: google analytics only on production 2026-10-04 10:51:53 +05:00
Ku6epXBOCTuK 1db83d12ff fix: shapes offset calculate 2026-10-04 10:06:40 +05:00
github-actions[bot] ddab996b50 chore(version): v0.1.0 2026-10-04 04:19:14 +00:00
Ku6epXBOCTuK 5990310e99 ci: update ci: version bump, changelog 2026-10-04 09:16:54 +05:00
Ku6epXBOCTuK c4eab38866 refactor: separate components 2026-10-04 08:48:14 +05:00
Ku6epXBOCTuK f89811c116 fix: show version in footer from manifest file 2026-10-04 07:30:58 +05:00
Ku6epXBOCTuK 1d337f065b docs: review all 2026-10-04 07:18:50 +05:00
Ku6epXBOCTuK 8cb3f1a8f0 docs: update to slug - id changes 2026-09-27 17:04:41 +05:00
Ku6epXBOCTuK 7b7e12fafc feat: change id - remove png intents 2026-09-27 16:48:20 +05:00
Ku6epXBOCTuK d09c954694 feat: move routes to slugs 2026-09-27 16:35:27 +05:00
Ku6epXBOCTuK 9776e1d325 feat: separate i18n dict pages 2026-09-27 16:24:31 +05:00
Ku6epXBOCTuK 7a3498c173 feat: separate slug pages from tools 2026-09-27 16:10:28 +05:00
Ku6epXBOCTuK 931ee27b05 refactor: add slug to tools 2026-09-27 15:18:35 +05:00
Ku6epXBOCTuK 2fc7801162 docs: update plans, backlog 2026-09-27 11:11:13 +05:00
Ku6epXBOCTuK e703451474 chore: update lint rules, some docs terms 2026-09-27 09:20:59 +05:00
Ku6epXBOCTuK 82207c82f2 docs: update a lot of plans, readmes, descriptions 2026-09-27 09:10:26 +05:00
Ku6epXBOCTuK 9c6f223b13 chore: add prettier check 2026-09-26 23:09:37 +05:00
Ku6epXBOCTuK fcf4d8156b docs: update plans, backlog, move todo\fixme from code comments 2026-09-26 22:57:09 +05:00
Ku6epXBOCTuK b59a434c65 chore: add new lint rule - check hardcoded text 2026-09-26 21:23:50 +05:00
Ku6epXBOCTuK 7bca9f3a3a docs: update plans 2026-09-26 20:29:58 +05:00
Ku6epXBOCTuK 6a44a3d018 chore: update lint rules 2026-09-26 20:10:35 +05:00
Ku6epXBOCTuK 67fdef4363 chore: lint rules for scripts 2026-09-26 16:46:52 +05:00
Ku6epXBOCTuK bd941d5ac5 test: for plugin and lint 2026-09-26 16:14:36 +05:00
Ku6epXBOCTuK 8801c3dd91 chore: remove unused css tokens 2026-09-26 15:58:57 +05:00
Ku6epXBOCTuK e2bd786d46 docs: update plans 2026-09-26 15:36:49 +05:00
Ku6epXBOCTuK 73bcbcc26a test: add coverage 2026-09-26 15:31:36 +05:00
Ku6epXBOCTuK ad01ae92d4 ci: update docs, verify on push 2026-09-26 15:04:05 +05:00
Ku6epXBOCTuK 0073d3d4cc test: update fixtures flow, add different browsers 2026-09-26 12:12:44 +05:00
Ku6epXBOCTuK 6ab59897d1 refactor: separate schema preview logic 2026-09-26 09:25:00 +05:00
Ku6epXBOCTuK cdb23fe1c1 refactor: separate schema fields logic 2026-09-26 09:08:28 +05:00
Ku6epXBOCTuK ca016ada43 test: for tools - generators, text and verdicts, smoke 2026-09-26 04:39:01 +05:00
Ku6epXBOCTuK 468beec998 test: runtime fixtures - bad files 2026-09-26 04:00:44 +05:00
Ku6epXBOCTuK 712fd3cb2e test: add download mime, quality verification 2026-09-26 03:33:09 +05:00
Ku6epXBOCTuK 746d6a6c8f test: add download zip flow 2026-09-26 02:54:01 +05:00
Ku6epXBOCTuK 0933f1cdea refactor: executor protocol, fallback, debounce 2026-09-26 02:20:34 +05:00
Ku6epXBOCTuK 5f77c097e2 feat: add source aware resolver, update reset, update generator 2026-09-25 19:01:43 +05:00
Ku6epXBOCTuK 5313567e70 test: less fragile - use semantic locators\helpers 2026-09-25 17:40:58 +05:00
Ku6epXBOCTuK 16d4288912 feat: remove v1 routes and libs 2026-09-25 16:39:58 +05:00
Ku6epXBOCTuK 473b62ef53 docs: update codebase quality plan 2026-09-25 16:02:29 +05:00
Ku6epXBOCTuK c1d2957ba5 chore: update lint rules 2026-09-25 15:52:30 +05:00
Ku6epXBOCTuK ebb2176a2c chore: update verify tasks, add ci jobs 2026-09-25 15:00:30 +05:00
Ku6epXBOCTuK acf9f19f1b docs: large update docs 2026-09-25 14:20:44 +05:00
Ku6epXBOCTuK c187619933 docs: add codebase quality plan 2026-09-25 14:00:35 +05:00
Ku6epXBOCTuK becf03f416 fix: update crop tool max sizes - 2000x1200 2026-09-18 18:00:52 +05:00
Ku6epXBOCTuK 393f1a6835 chore: add google analytics scripts 2026-09-18 15:20:00 +05:00
Ku6epXBOCTuK 5ce8681a24 docs: update plan for field audit 2026-09-18 15:19:39 +05:00
Ku6epXBOCTuK d24ee0a448 chore: add i18n dict consistency lint rule 2026-09-14 07:40:06 +05:00
Ku6epXBOCTuK 44cfca92ce fix: use base path for favicon 2026-09-13 13:16:02 +05:00
Ku6epXBOCTuK d0857c5d45 test: update tests 2026-09-13 13:07:10 +05:00
Ku6epXBOCTuK c2f3838485 fix: worker use only i18n keys instead of full i18n 2026-09-13 12:44:51 +05:00
Ku6epXBOCTuK 49d0a9444e docs: audit tools 2026-09-13 09:31:08 +05:00
Ku6epXBOCTuK 5167b0a9b1 docs: update backlog 2026-09-13 09:31:03 +05:00
Ku6epXBOCTuK b3e2735fc3 test: update i18n tests 2026-09-13 09:29:28 +05:00
Ku6epXBOCTuK 371387ce81 fix: update i18n - update registry for tools 2026-09-13 09:04:05 +05:00
Ku6epXBOCTuK 08c9fbce3b fix: update i18n - update schema fields descriptions 2026-09-13 07:16:46 +05:00
Ku6epXBOCTuK 4de42b1e40 fix: update i18n - tool group descriptions 2026-09-13 06:47:14 +05:00
Ku6epXBOCTuK 372babc3b7 fix: update i18n - text verdicts from analyze tools 2026-09-13 06:38:58 +05:00
Ku6epXBOCTuK 4b44bc439c fix: update i18n - fields components 2026-09-13 06:25:53 +05:00
Ku6epXBOCTuK 6c88a23e48 fix: update i18n - schema components 2026-09-13 06:21:28 +05:00
Ku6epXBOCTuK a4c85c0814 fix: update i18n - catalog, nav, common components 2026-09-13 05:44:26 +05:00
Ku6epXBOCTuK e06970997e fix: update i18n text in pages and layout 2026-09-13 05:36:38 +05:00
Ku6epXBOCTuK 1097186487 fix: update lang toggle component 2026-09-13 05:29:53 +05:00
Ku6epXBOCTuK abfcbbfac2 docs: update plan and backlog 2026-09-13 05:25:56 +05:00
Ku6epXBOCTuK 9a27388119 feat: add new dics\translations 2026-09-13 05:11:40 +05:00
Ku6epXBOCTuK 3c10147ad8 style: fix formatting 2026-09-13 04:36:07 +05:00
Ku6epXBOCTuK a71487d30b docs: update dict for i18n 2026-09-13 04:23:41 +05:00
Ku6epXBOCTuK 71b5c90a4a docs: add audit tools 2026-09-12 21:48:39 +05:00
Ku6epXBOCTuK ce33201302 chore: update isolation plugin 2026-09-12 20:55:59 +05:00
Ku6epXBOCTuK dda5711a33 test: remove v1 test, later need impl new ones 2026-09-12 19:48:22 +05:00
Ku6epXBOCTuK 939038ffc0 refactor: change v1 to import own copy of i18n 2026-09-12 19:31:59 +05:00
Ku6epXBOCTuK de2a229545 chore: copy i18n to v1 for separate 2026-09-12 19:19:22 +05:00
Ku6epXBOCTuK 9fdd960d81 docs: update backlog 2026-09-12 18:57:26 +05:00
Ku6epXBOCTuK 7aefdc3848 chore: cleanup - remove old references and audit scripts 2026-09-12 18:54:11 +05:00
Ku6epXBOCTuK 5cd2a586ce fix: restart tool after change source 2026-09-12 18:45:59 +05:00
Ku6epXBOCTuK ec585e1ee6 docs: update backlog 2026-09-12 18:42:30 +05:00
Ku6epXBOCTuK f2829de216 fix: update design - colors, remove texts 2026-09-12 18:39:50 +05:00
Ku6epXBOCTuK 0c2de3f191 fix: disable download button while running jobs, auto run generator jobs 2026-09-12 18:22:05 +05:00
Ku6epXBOCTuK 35df076732 fix: separate preview tile component 2026-09-12 17:17:25 +05:00
Ku6epXBOCTuK 05165031c3 docs: add i18n update plan 2026-09-12 16:27:14 +05:00
Ku6epXBOCTuK 0b107c7d93 fix: source image checker background 2026-09-12 15:46:44 +05:00
Ku6epXBOCTuK 24cd9f78c6 docs: update docs 2026-09-12 15:24:30 +05:00
Ku6epXBOCTuK d14e14ac1a test: add new split tool tests 2026-09-12 15:24:24 +05:00
Ku6epXBOCTuK b91c1ab7ee feat: add icon for split tool, update docs 2026-09-12 15:24:23 +05:00
Ku6epXBOCTuK 2be8871930 feat: add ui for new split tool 2026-09-12 15:24:18 +05:00
Ku6epXBOCTuK adb578540f feat: add executor support for multifiles result 2026-09-12 15:24:17 +05:00
Ku6epXBOCTuK a7a42d0091 feat: add split to parts to geometry registry 2026-09-12 15:24:11 +05:00
Ku6epXBOCTuK e1c3d0a95f feat: add error texts 2026-09-12 14:49:07 +05:00
Ku6epXBOCTuK ceee9adead feat: add download zip with result files 2026-09-12 14:48:47 +05:00
Ku6epXBOCTuK d94c5d1297 feat: add split to parts core tool 2026-09-12 14:44:45 +05:00
Ku6epXBOCTuK ee5f660572 feat: add files tool result 2026-09-12 14:43:33 +05:00
Ku6epXBOCTuK 4826fc6ad8 chore: add fflate deps 2026-09-12 14:41:38 +05:00
Ku6epXBOCTuK 1e47fdaf4a docs: add split into parts plan 2026-09-12 14:41:17 +05:00
Ku6epXBOCTuK 8451769efd ci: add githup pages deploy script 2026-09-12 13:32:39 +05:00
Ku6epXBOCTuK cda88547c4 docs: update plans 2026-09-12 13:27:14 +05:00
Ku6epXBOCTuK d8a612fbc4 test: add png fixtures for manual tests" 2026-09-11 17:30:27 +05:00
Ku6epXBOCTuK fa5f7660c7 chore: update lint rules 2026-09-11 07:39:51 +05:00
Ku6epXBOCTuK 87bd4482a3 docs: update plans, backlog and other docs 2026-09-11 06:46:45 +05:00
Ku6epXBOCTuK 1651167a57 chore: move audits to archive, update manifests 2026-09-11 06:26:50 +05:00
Ku6epXBOCTuK 098897cddd chore: update lint rules, add tests 2026-09-11 06:26:42 +05:00
Ku6epXBOCTuK 75f6891e7c refactor: update path registry-new to registry 2026-09-10 21:14:53 +05:00
Ku6epXBOCTuK 0160b316c9 test: update routes in tests 2026-09-10 20:40:49 +05:00
Ku6epXBOCTuK 33e3f3c4f0 feat: rename old -> v1 folder 2026-09-10 20:23:45 +05:00
Ku6epXBOCTuK 1e5e096d5d feat: flatten lib/preview -> lib folder 2026-09-10 20:15:21 +05:00
Ku6epXBOCTuK f8a1da6ff7 feat: flatten components/kit -> components folder 2026-09-10 20:08:15 +05:00
Ku6epXBOCTuK f7623ab3bb fix: update topbar links 2026-09-10 20:00:59 +05:00
Ku6epXBOCTuK 45dc54b6aa feat: move preview pages to root 2026-09-10 19:59:40 +05:00
Ku6epXBOCTuK 85effce7d9 fix: v1 layout 2026-09-10 19:53:25 +05:00
Ku6epXBOCTuK e970b47720 refactor: set root layout, rename css, fix links in lint rules \ etc 2026-09-10 19:28:22 +05:00
Ku6epXBOCTuK b6318f03ab feat: move old root routes to /v1 routes 2026-09-10 19:28:22 +05:00
Ku6epXBOCTuK b36a865a0c docs: update plans 2026-09-10 19:28:17 +05:00
Ku6epXBOCTuK 3834dadd03 refactor: move old libs and components to old folder 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK bf2f8e57c8 docs: update plans 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK a91347ca81 docs: update unify plan 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK 50a9276113 test: update registry tests 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK d6706abea2 feat: update filter tools to new types 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK bea010dd90 feat: update alpha and colors tools to new types 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK 2937974fe7 feat: update geometry tools to new types 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK 1e85fe36a6 feat: update text tools to new types 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK 60c7d61dab feat: update convert and analyze tools to new types 2026-09-10 12:01:54 +05:00
Ku6epXBOCTuK 6b0b0646d5 feat: update generate tool to new types 2026-09-10 12:01:53 +05:00
Ku6epXBOCTuK 9dccdd99a0 style: fix prettier ignore\config 2026-09-10 12:01:53 +05:00
Ku6epXBOCTuK b6db1c8f19 feat: update schema components 2026-09-10 12:01:49 +05:00
Ku6epXBOCTuK ba8a518861 feat: update executor for tools 2026-09-10 12:01:42 +05:00
Ku6epXBOCTuK 292e53c9c7 feat: update tools types 2026-09-10 08:21:20 +05:00
Ku6epXBOCTuK e9bc59f818 docs: add tools unify plan 2026-09-10 08:18:34 +05:00
Ku6epXBOCTuK 84230b17ff docs: update plans 2026-09-10 07:44:31 +05:00
Ku6epXBOCTuK 64e35f4fdf style: format docs markdown 2026-09-09 04:13:13 +05:00
Ku6epXBOCTuK 46da54140c docs: udpate backlog 2026-09-08 22:41:17 +05:00
Ku6epXBOCTuK 420d03f58c fix: css fixes 2026-09-08 18:29:56 +05:00
Ku6epXBOCTuK 3f21f1c024 test: add e2e test, add manual test checklist 2026-09-08 11:03:05 +05:00
Ku6epXBOCTuK 9a7b7c178b docs: add testing preview plan 2026-09-08 09:35:41 +05:00
Ku6epXBOCTuK e3d50f2128 refactor: update css vars lint rules and fix them 2026-09-08 07:18:57 +05:00
Ku6epXBOCTuK 2d24f51fd5 refactor: fix lint css vars rules 2026-09-08 07:02:54 +05:00
Ku6epXBOCTuK 5a4fbe7ac6 chore: update css lint rules, remove unused files 2026-09-08 06:20:25 +05:00
Ku6epXBOCTuK 6f1771cb07 fix: lint errors 2026-09-07 12:35:25 +05:00
Ku6epXBOCTuK 987f4535c8 fix: update tool card component - route resolve issue 2026-09-07 12:30:32 +05:00
Ku6epXBOCTuK 20e07fae85 chore: update pallete test - contrast lint 2026-09-07 12:30:25 +05:00
Ku6epXBOCTuK a71252dc0c docs: add new tech debt plan 2026-09-07 11:29:45 +05:00
Ku6epXBOCTuK 751e8d97a0 docs: update plans\docs - actual info 2026-09-07 11:19:15 +05:00
Ku6epXBOCTuK a1438e6f7f fix: resolve always text input issue, separate to simple components 2026-09-07 10:48:45 +05:00
Ku6epXBOCTuK 5884f4bd82 docs: update plan 2026-09-07 10:39:50 +05:00
Ku6epXBOCTuK e5b76ca3e1 feat: add text tools 2026-09-07 10:38:36 +05:00
Ku6epXBOCTuK a49785764b feat: add text core tools and views 2026-09-07 10:25:17 +05:00
Ku6epXBOCTuK c0cebdc81f feat: add output format for download 2026-09-07 10:20:01 +05:00
Ku6epXBOCTuK 37a4a0576c fix: add compress\decrease colors presets 2026-09-07 10:16:52 +05:00
Ku6epXBOCTuK b3d460a951 feat: add generate tools 2026-09-07 10:16:12 +05:00
Ku6epXBOCTuK 6be9d10c42 feat: add multiple colors field 2026-09-07 10:15:49 +05:00
Ku6epXBOCTuK cc70750727 feat: add analyze, generate, text tools and update plan 2026-09-07 09:09:29 +05:00
Ku6epXBOCTuK e2735c52ab feat: add alpha tools 2026-09-07 08:49:37 +05:00
Ku6epXBOCTuK d383cf29e3 feat: add geometry tools 2026-09-07 08:40:33 +05:00
Ku6epXBOCTuK bfad0c7d02 feat: add filters tools 2026-09-07 08:30:50 +05:00
Ku6epXBOCTuK 6750d5a373 feat: add colors tools 2026-09-07 03:07:46 +05:00
Ku6epXBOCTuK 6cbb9f95f0 docs: update plans 2026-09-07 00:17:32 +05:00
Ku6epXBOCTuK e57faddfde refactor: isolate old and preview versions, add lint rule 2026-09-05 19:41:31 +05:00
Ku6epXBOCTuK e662d1df24 feat: add layout groupt to tools 2026-09-05 17:47:41 +05:00
Ku6epXBOCTuK d48262e482 feat: add tool schema layout 2026-09-05 17:30:34 +05:00
Ku6epXBOCTuK cad9bf6cfd feat: add gradient control component 2026-09-05 17:19:03 +05:00
Ku6epXBOCTuK b99ccf5d78 feat: add plate (under text) field component 2026-09-05 16:55:00 +05:00
Ku6epXBOCTuK 0a7c4a84c9 feat: add font style field component 2026-09-05 16:46:15 +05:00
Ku6epXBOCTuK f24ccf9240 feat: add position9 field component 2026-09-05 16:36:48 +05:00
Ku6epXBOCTuK 0c28925673 feat: add offset field component 2026-09-05 16:24:55 +05:00
Ku6epXBOCTuK 743ae817ee refactor: update field schema types 2026-09-05 16:16:10 +05:00
Ku6epXBOCTuK 19f2e13b88 feat: add two colors field control 2026-09-05 16:09:39 +05:00
Ku6epXBOCTuK 4a13bfbd16 refactor: separate schema fields components 2026-09-05 15:56:21 +05:00
Ku6epXBOCTuK f286044ab9 feat: update use dimension tools 2026-09-05 15:11:26 +05:00
Ku6epXBOCTuK 2601aa5b5c feat: add dimension tools for new flow 2026-09-05 10:06:25 +05:00
Ku6epXBOCTuK 1e9b35406d feat: add registry tools for new preview flow 2026-09-04 19:09:47 +05:00
Ku6epXBOCTuK 520a1e1d37 feat: double executor to use with preview 2026-09-04 18:17:35 +05:00
Ku6epXBOCTuK a19d3db929 feat: add new registry for preview design 2026-09-04 17:37:55 +05:00
Ku6epXBOCTuK 58e75f2334 refactor: separate registry file 2026-09-04 17:07:38 +05:00
Ku6epXBOCTuK 1c746299c9 feat: add single tool schema rendering 2026-09-04 16:12:43 +05:00
Ku6epXBOCTuK d1a386f92f fix: update tools list - show all tools 2026-09-03 16:29:35 +05:00
Ku6epXBOCTuK 1813383724 docs: update plan 2026-09-03 16:29:05 +05:00
Ku6epXBOCTuK 1ae4b36c9c feat: add new registry schema for new ui 2026-09-03 15:58:20 +05:00
Ku6epXBOCTuK a57acd1ff2 docs: add plan for composite params 2026-09-03 05:13:43 +05:00
Ku6epXBOCTuK 6ec3e75df4 refactor: move form fields to separate folder 2026-09-02 21:11:43 +05:00
Ku6epXBOCTuK a24a182d18 fix: update segmented component styles 2026-09-02 20:48:09 +05:00
Ku6epXBOCTuK 1e63eaa03b fix: increase font size 2026-09-02 20:19:40 +05:00
Ku6epXBOCTuK e9de7a4bf8 refactor: move components 2026-09-02 20:08:05 +05:00
Ku6epXBOCTuK 7e94205866 refactor: update panel component 2026-09-02 19:55:52 +05:00
Ku6epXBOCTuK 2f758a88af refactor: update panel component 2026-09-02 19:33:34 +05:00
Ku6epXBOCTuK 991fe69381 refactor: separate top bar component 2026-09-02 19:31:04 +05:00
Ku6epXBOCTuK 11c4ccece4 refactor: update footer component 2026-09-02 18:49:55 +05:00
Ku6epXBOCTuK 8a6f87938d refactor: icon and download button use button component 2026-09-02 18:37:43 +05:00
Ku6epXBOCTuK 3d0f8ebb10 fix: update field component 2026-09-02 17:54:31 +05:00
Ku6epXBOCTuK 28ff9d3ee1 chore: update lint rules - css property shorthand fix 2026-09-02 17:47:13 +05:00
Ku6epXBOCTuK ef74cabf1e fix: update download button style 2026-09-02 17:37:45 +05:00
Ku6epXBOCTuK c590bdf91a fix: update button component styles 2026-09-02 17:22:47 +05:00
Ku6epXBOCTuK 4794523084 fix: update css variables, update badge component 2026-09-02 10:07:31 +05:00
Ku6epXBOCTuK 98a4bacd23 chore: update css rules, add hct plugin 2026-09-02 09:51:14 +05:00
Ku6epXBOCTuK 1310c51543 chore: add design tokens\css lint rules 2026-09-02 00:14:29 +05:00
Ku6epXBOCTuK 191ca71553 fix: update some styles 2026-09-01 21:16:54 +05:00
Ku6epXBOCTuK d38d51c7d9 docs: add readme, license 2026-09-01 15:20:28 +05:00
Ku6epXBOCTuK 749b97cb4f fix: change style, demo page visual looks as done 2026-08-31 18:07:34 +05:00
Ku6epXBOCTuK d73b9056d7 chore: update audit cdp - group margin\padding\gap 2026-08-31 13:33:25 +05:00
Ku6epXBOCTuK 1907fb9106 chore: update cdp audit script 2026-08-31 12:31:07 +05:00
Ku6epXBOCTuK 80def1f380 fix: resolve new audit styles 2026-08-31 10:38:35 +05:00
Ku6epXBOCTuK 95f1eeeecc chore: update audit cdp script - css var fixes, add grid styles 2026-08-31 10:23:46 +05:00
Ku6epXBOCTuK 9cd86104fc chore: update cdp parsing css layers 2026-08-31 09:31:47 +05:00
Ku6epXBOCTuK 0b13d4c9e8 fix: update font styles 2026-08-31 09:14:33 +05:00
Ku6epXBOCTuK ecc8efcc45 chore: update html extract script - inject fonts 2026-08-31 08:52:26 +05:00
Ku6epXBOCTuK 586c66a159 fix: update style, resolve old css leaks issue 2026-08-31 08:31:33 +05:00
Ku6epXBOCTuK b7d66f0430 chore: update cdp audit script 2026-08-31 07:07:09 +05:00
Ku6epXBOCTuK bbd5532529 fix: resolve style issues 2026-08-31 03:25:42 +05:00
Ku6epXBOCTuK 6cefd5c0a1 chore: update cdp audit script 2026-08-31 03:02:29 +05:00
Ku6epXBOCTuK 8eff9a9483 fix: step fields styles 2026-08-31 02:41:57 +05:00
Ku6epXBOCTuK 2c70c445d5 fix: update styles 2026-08-31 01:25:46 +05:00
Ku6epXBOCTuK 6c8f7b2d8b fix: update preview styles 2026-08-30 22:40:32 +05:00
Ku6epXBOCTuK 86b1c813e7 fix: update top bar styles 2026-08-30 22:40:25 +05:00
Ku6epXBOCTuK b0f0b79ee0 chore: cdp audit - fix hover\focus\active pseudo classes 2026-08-30 21:45:47 +05:00
Ku6epXBOCTuK 11b4c87486 chore: fix cdp audit css selectors specifity 2026-08-30 21:21:31 +05:00
Ku6epXBOCTuK 87dbb200d9 chore: update audit script - different resolutions 2026-08-30 20:59:16 +05:00
Ku6epXBOCTuK 2d72848e8e chore: update audit format - show really different styles 2026-08-30 19:16:18 +05:00
Ku6epXBOCTuK 453a5908e6 chore: add chrome devtools protocol audit 2026-08-30 19:16:13 +05:00
Ku6epXBOCTuK dbeffac082 feat: css audit fixes 2026-08-30 04:11:17 +05:00
Ku6epXBOCTuK 2ef4709038 feat: WIP: add css fixes 2026-08-29 11:07:02 +05:00
Ku6epXBOCTuK 1831c3d100 feat: add refs css audit 2026-08-29 10:33:32 +05:00
Ku6epXBOCTuK 9ef6ae03c6 feat: fix tools pages, dom audit fix 2026-08-29 09:08:09 +05:00
Ku6epXBOCTuK b695f5d58c feat: add preview catalog and catalog group and header 2026-08-29 07:54:45 +05:00
Ku6epXBOCTuK 7578a185b6 feat: fix list tools page to correspond refs 2026-08-29 07:44:41 +05:00
Ku6epXBOCTuK d29ec73322 feat: fix demo page to correspond refs 2026-08-29 07:23:31 +05:00
Ku6epXBOCTuK 9ff732f2cf chore: add new audit-dom script and make dom audit 2026-08-29 06:39:56 +05:00
Ku6epXBOCTuK b19ddbe45d docs: add new audit plan - resolve old audit flow 2026-08-29 05:57:11 +05:00
Ku6epXBOCTuK 795c03ac87 chore: update refs audit 2026-08-28 21:27:13 +05:00
Ku6epXBOCTuK 0d95acba89 fix: resolve design issues from refs audit 2026-08-28 19:56:50 +05:00
Ku6epXBOCTuK 6959abb159 fix: remove tile label doubling 2026-08-28 17:44:07 +05:00
Ku6epXBOCTuK 86e7b7250d refactor: update pages and refs audit 2026-08-28 17:03:30 +05:00
Ku6epXBOCTuK c0f48ecd97 feat: add new simple components 2026-08-28 17:02:52 +05:00
Ku6epXBOCTuK d30deddce0 docs: update plans and refs audit 2026-08-28 16:46:17 +05:00
Ku6epXBOCTuK 7aa50eee6c docs: update refs html extract script 2026-08-28 14:55:57 +05:00
Ku6epXBOCTuK ada96b3534 style: fix formatting via prettier 2026-08-28 14:29:54 +05:00
Ku6epXBOCTuK 10278981ba docs: update refs, update script 2026-08-28 14:27:44 +05:00
Ku6epXBOCTuK 5093b50f5c fix: update design with references 2026-08-28 13:35:09 +05:00
Ku6epXBOCTuK 65f12c931b feat: some refactor, fix some design 2026-08-28 12:30:27 +05:00
Ku6epXBOCTuK 40eee984da feat: add link to preview design 2026-08-28 11:50:50 +05:00
Ku6epXBOCTuK d373cc0daf feat: update tool page - add step card steps 2026-08-28 11:43:26 +05:00
Ku6epXBOCTuK a16a7b92c7 feat: add dropzone, update tool page 2026-08-28 10:55:51 +05:00
Ku6epXBOCTuK f990ad6767 feat: add single tool pages, wiring with toolcard 2026-08-28 08:05:20 +05:00
Ku6epXBOCTuK f4216e6868 feat: separate topbar, fix important styles 2026-08-27 23:58:57 +05:00
Ku6epXBOCTuK bcdfc4cf12 chore: add audit script for compare with refs 2026-08-27 23:52:32 +05:00
Ku6epXBOCTuK adcc255107 feat: add demo page - preselected pipeline 2026-08-27 22:44:58 +05:00
Ku6epXBOCTuK 73118ea393 feat: add list tools page 2026-08-27 22:11:04 +05:00
Ku6epXBOCTuK e9578fc15e feat: add main page 2026-08-27 22:06:16 +05:00
Ku6epXBOCTuK 06d185add6 feat: add ui kit page 2026-08-27 21:35:01 +05:00
Ku6epXBOCTuK 8437c95a28 feat: add ui components 2026-08-27 21:10:25 +05:00
Ku6epXBOCTuK ac75665deb feat: add ui components 2026-08-27 20:57:34 +05:00
Ku6epXBOCTuK bc5a5d13c4 chore: add prettier, eslint, fix some components 2026-08-27 20:34:16 +05:00
Ku6epXBOCTuK 0acfa3aeb9 feat: add new design primitives 2026-08-27 19:41:32 +05:00
Ku6epXBOCTuK 418a091505 feat: add new design2.css, add fonts 2026-08-27 19:23:30 +05:00
Ku6epXBOCTuK f08af41568 refactor: move old design to separate layout tree 2026-08-27 19:22:28 +05:00
Ku6epXBOCTuK dfb2311e32 docs: update re-design plan 2026-08-27 18:53:42 +05:00
Ku6epXBOCTuK 84534b2f58 docs: add new design references 2026-08-27 16:02:08 +05:00
Ku6epXBOCTuK 83d3ce1198 feat: add compression tools 2026-08-26 18:06:03 +05:00
Ku6epXBOCTuK 501d2dda4e feat: add quantize tools 2026-08-26 17:31:24 +05:00
Ku6epXBOCTuK 2b67414e79 feat: add text and generation tools 2026-08-26 16:08:27 +05:00
Ku6epXBOCTuK 56ebe5b53b feat: add pixel effects core tools 2026-08-26 14:13:38 +05:00
Ku6epXBOCTuK 3c1c5b2b3a fix: update search flow - first tool search 2026-08-26 12:34:41 +05:00
Ku6epXBOCTuK 84cc56cc6c feat: add geometry masks 2026-08-26 12:31:44 +05:00
Ku6epXBOCTuK d65adcd16c feat: add shape masks 2026-08-26 11:45:14 +05:00
Ku6epXBOCTuK 553601acc1 feat: add masks core tools 2026-08-26 09:26:26 +05:00
Ku6epXBOCTuK 5c37eb2e7b feat: add channel tools 2026-08-26 08:38:30 +05:00
Ku6epXBOCTuK db02cbd165 feat: add pallette tools 2026-08-26 07:38:54 +05:00
Ku6epXBOCTuK 11fc160997 docs: update plans 2026-08-26 07:27:40 +05:00
Ku6epXBOCTuK 116d7c72aa feat: add watermarks and text tools 2026-08-26 07:21:43 +05:00
Ku6epXBOCTuK b277f3c66d docs: update plan, add tool map 2026-08-26 06:32:40 +05:00
Ku6epXBOCTuK 54af7626d6 feat: add text and date draw tools 2026-08-26 05:43:03 +05:00
Ku6epXBOCTuK 38e7cd20d2 feat: add text draw core 2026-08-26 00:22:28 +05:00
Ku6epXBOCTuK 2e980fb115 docs: add text-wave plan 2026-08-26 00:05:17 +05:00
Ku6epXBOCTuK 6e0647dfbf feat: add demo page 2026-08-25 18:44:03 +05:00
Ku6epXBOCTuK 148e4b1957 feat: inline tool page - parameters beetween source and result images 2026-08-25 15:48:16 +05:00
Ku6epXBOCTuK d70cbad0d2 refactor: separate tool page for A\B testing 2026-08-25 15:22:20 +05:00
Ku6epXBOCTuK 365af6ef95 docs: update plans 2026-08-25 14:58:47 +05:00
Ku6epXBOCTuK 5e512f0bdd fix: some small css fixes 2026-08-25 14:44:31 +05:00
Ku6epXBOCTuK f1f5bd4409 feat: add theme switcher, dark theme 2026-08-25 14:32:13 +05:00
Ku6epXBOCTuK 5b22409d41 docs: update plans 2026-08-25 12:09:07 +05:00
Ku6epXBOCTuK 96b8c00129 fix: get names from categories 2026-08-25 11:44:36 +05:00
Ku6epXBOCTuK 6d770dc642 feat: add base path for deploy 2026-08-25 10:36:10 +05:00
Ku6epXBOCTuK b296b6fa20 chore: update favicon 2026-08-25 09:57:13 +05:00
Ku6epXBOCTuK 486b99dbb8 refactor: add error messages i18n 2026-08-25 09:49:15 +05:00
Ku6epXBOCTuK c04a1fc1dc feat: search use both languages and remove diacrites 2026-08-25 09:07:57 +05:00
Ku6epXBOCTuK 6787201425 fix: base en 2026-08-25 08:58:38 +05:00
Ku6epXBOCTuK 396036802c feat: use i18n in components 2026-08-25 08:32:43 +05:00
Ku6epXBOCTuK 74a18d91a7 feat: base english language, also fallback to english 2026-08-25 08:32:19 +05:00
Ku6epXBOCTuK 2301b0e165 feat: move all common messages to i18n (ru\en) 2026-08-25 05:45:34 +05:00
Ku6epXBOCTuK 8255d33098 feat: add i18n - dict, en\ru, state 2026-08-25 05:29:58 +05:00
Ku6epXBOCTuK 3c0ffa23bc docs: update plan 2026-08-25 00:23:31 +05:00
Ku6epXBOCTuK d64eaa0e93 test: add smoke tests 2026-08-24 18:51:38 +05:00
Ku6epXBOCTuK ebc3c33c12 fix: resolve transform core tools, add tests 2026-08-24 18:51:11 +05:00
Ku6epXBOCTuK 8745812a5f feat: add tool registry, icons 2026-08-24 17:41:08 +05:00
Ku6epXBOCTuK 0909e14cde feat: add io extensions - svg, jpeg 2026-08-24 17:15:17 +05:00
Ku6epXBOCTuK 7ef4b908e2 feat: add core tools - transfor, light, effects 2026-08-24 17:09:39 +05:00
Ku6epXBOCTuK b6e157e5da docs: update plan 2026-08-24 13:22:52 +05:00
Ku6epXBOCTuK a2dd288851 feat: add tools into registry, add icons 2026-08-24 13:01:06 +05:00
Ku6epXBOCTuK cb5ccf6da8 feat: add alpha and morphology core tools 2026-08-24 12:02:42 +05:00
Ku6epXBOCTuK b434bf3ef3 docs: update plans 2026-08-24 11:10:07 +05:00
Ku6epXBOCTuK 200e971df5 fix: use toolcard as links at list page 2026-08-24 10:03:46 +05:00
Ku6epXBOCTuK 5e44a45457 feat: add tool search cards 2026-08-24 09:40:58 +05:00
Ku6epXBOCTuK 76f1770286 feat: add tool popularity and icons 2026-08-24 09:16:29 +05:00
Ku6epXBOCTuK 68476c3b8c docs: update plans 2026-08-24 08:49:25 +05:00
Ku6epXBOCTuK 702b96261d feat: combine tool in single card 2026-08-24 08:31:13 +05:00
Ku6epXBOCTuK 95ee598875 chore: add root script 2026-08-24 07:47:50 +05:00
Ku6epXBOCTuK 46af693082 docs: complete plan 2026-08-24 07:47:34 +05:00
Ku6epXBOCTuK 9fbb90b641 feat: move tool executor to web worker 2026-08-24 07:39:03 +05:00
Ku6epXBOCTuK c14301130f feat: add registry - background, blur, sharpen 2026-08-24 07:33:41 +05:00
Ku6epXBOCTuK fd5236c19b feat: add background and convolution core tools 2026-08-24 07:25:41 +05:00
Ku6epXBOCTuK 3d7371492f docs: add new plan 2026-08-24 07:04:22 +05:00
Ku6epXBOCTuK 1b3bad46d1 refactor: use standalone tool run executor 2026-08-24 06:53:18 +05:00
Ku6epXBOCTuK 474c1156aa docs: update plans 2026-08-24 06:52:40 +05:00
Ku6epXBOCTuK 91b3492231 feat: restore old toolchain button 2026-08-24 06:35:01 +05:00
Ku6epXBOCTuK 3a5acfbc45 refactor: change show mask flow - checkbox in params 2026-08-24 06:02:32 +05:00
Ku6epXBOCTuK 306c26a360 feat: add chain tools workflow 2026-08-24 05:52:07 +05:00
Ku6epXBOCTuK c93a8e493e feat: add tool search component, add chainable flag 2026-08-23 21:55:45 +05:00
Ku6epXBOCTuK ce7f5ff680 docs: update plan 2026-08-23 21:41:37 +05:00
Ku6epXBOCTuK 8532eb98c3 feat: add step picker\list\editor 2026-08-23 20:12:43 +05:00
Ku6epXBOCTuK 23c985bde9 feat: add pipeline for multi instrument flow 2026-08-23 15:45:29 +05:00
Ku6epXBOCTuK 8632e2e1cf docs: update plan 2026-08-23 15:22:00 +05:00
Ku6epXBOCTuK 6fae98b25c feat: add registry 2026-08-23 12:41:17 +05:00
Ku6epXBOCTuK f9a4973b47 feat: add core operations 2026-08-23 11:38:18 +05:00
Ku6epXBOCTuK 7fa2c52bb8 feat: add core operations 2026-08-23 11:14:33 +05:00
Ku6epXBOCTuK 17b0c68eb5 feat: add text\hex conver tools 2026-08-23 10:40:46 +05:00
Ku6epXBOCTuK 3b18d0c6f8 docs: add common tools and components 2026-08-23 08:20:14 +05:00
Ku6epXBOCTuK b48a8adfa7 docs: update plans 2026-08-23 08:05:55 +05:00
Ku6epXBOCTuK 96c37aabc5 feat: add color picker glass 2026-08-23 07:29:29 +05:00
Ku6epXBOCTuK 7b799969c4 feat: add color picker 2026-08-23 06:12:37 +05:00
Ku6epXBOCTuK 3701fc8045 feat: add slider field 2026-08-23 05:18:10 +05:00
Ku6epXBOCTuK 7e391a420b docs: update plans 2026-08-23 04:54:40 +05:00
Ku6epXBOCTuK aa5372b771 feat: add show mask workflow 2026-08-23 04:06:02 +05:00
Ku6epXBOCTuK d0ee4ec77f feat: reactive tool flow, with debounce 2026-08-22 21:06:50 +05:00
Ku6epXBOCTuK 5560435b55 refactor: separate tool page 2026-08-22 20:52:03 +05:00
Ku6epXBOCTuK dcc7c346c4 docs: update docs - plan, archive 2026-08-22 20:33:01 +05:00
Ku6epXBOCTuK da92815b3a test: add io tests 2026-08-22 19:03:22 +05:00
Ku6epXBOCTuK caf71ec8f0 feat: update drop file flow 2026-08-22 17:57:38 +05:00
Ku6epXBOCTuK f576701baa feat: add ctrl+v flow 2026-08-22 16:49:26 +05:00
Ku6epXBOCTuK 4c5b52a9ad fix: update layout 2026-08-22 16:28:01 +05:00
Ku6epXBOCTuK 7afd07e8c1 refactor: dropzone update 2026-08-22 15:04:49 +05:00
Ku6epXBOCTuK 06c6716cf5 docs: add update ux plan 2026-08-22 14:59:05 +05:00
Ku6epXBOCTuK a9234d27de refactor: remove unused styles 2026-08-22 14:40:39 +05:00
Ku6epXBOCTuK c7b56bbda7 feat: update - use design system components 2026-08-22 14:34:27 +05:00
Ku6epXBOCTuK 15e2e010ec feat: add design system components 2026-08-22 14:02:06 +05:00
Ku6epXBOCTuK 3620c5fc30 feat: add design system tokens 2026-08-22 13:16:05 +05:00
Ku6epXBOCTuK c90f887d7d docs: add design-system plan 2026-08-22 12:23:37 +05:00
Ku6epXBOCTuK cd158b188a docs: update backlog and mvp plan 2026-08-22 12:02:03 +05:00
Ku6epXBOCTuK da55328491 fix: update user input flow (keep aspect ratio) 2026-08-22 11:33:10 +05:00
Ku6epXBOCTuK 4b7086693c fix: resolve loading image error 2026-08-22 10:37:37 +05:00
Ku6epXBOCTuK 08038ede0d chore: add run script 2026-08-22 10:37:04 +05:00
Ku6epXBOCTuK 3ec11999d8 feat: add route pages 2026-08-22 10:25:35 +05:00
Ku6epXBOCTuK c0225552e4 feat: add svelte components 2026-08-22 10:20:18 +05:00
Ku6epXBOCTuK 719bda2e22 feat: add instruments registry 2026-08-22 10:06:49 +05:00
Ku6epXBOCTuK 9ccc9f94ac feat: core instruments 2026-08-22 09:49:53 +05:00
Ku6epXBOCTuK 4ec2b418fd feat: core types and io 2026-08-22 09:29:56 +05:00
Ku6epXBOCTuK 85f595f14b feat: initial sveltekit 2026-08-22 09:16:10 +05:00
Ku6epXBOCTuK d71f8648f5 docs: add mvp plan 2026-08-22 09:07:22 +05:00
Ku6epXBOCTuK 6c9f9470c8 chore: add package.json 2026-08-13 09:54:03 +05:00
Ku6epXBOCTuK 72ad64010a docs: add roadmap 2026-08-02 10:56:17 +05:00
Ku6epXBOCTuK fdf7e2a3cd docs: initial idea description 2026-08-02 06:20:12 +05:00
318 changed files with 39214 additions and 64 deletions
@@ -0,0 +1,23 @@
name: "Setup Cocogitto"
description: "Installs a specific version of Cocogitto and cleans up the workspace"
inputs:
version:
description: "Cocogitto version to install"
required: true
default: "7.0.0"
runs:
using: "composite"
steps:
- name: Install Cocogitto
shell: bash
run: |
VERSION="${{ inputs.version }}"
URL="https://github.com/cocogitto/cocogitto/releases/download/${VERSION}/cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
curl -LOf "$URL"
tar -xzf "cocogitto-${VERSION}-x86_64-unknown-linux-musl.tar.gz"
sudo mv $(find . -name cog -type f -executable) /usr/local/bin/
git clean -fd
cog --version
+77
View File
@@ -0,0 +1,77 @@
name: Bump version
on:
push:
branches: [main]
workflow_dispatch:
permissions:
contents: write
concurrency:
group: bump
cancel-in-progress: false
jobs:
verify:
uses: ./.github/workflows/verify.yml
bump:
needs: verify
runs-on: ubuntu-latest
outputs:
bumped: ${{ steps.check-bump.outputs.bumped }}
new_version: ${{ steps.get-tag.outputs.version }}
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Setup Cocogitto
uses: ./.github/actions/setup-cocogitto
- name: Check if bump is needed
id: check-bump
run: |
BUMP_OUTPUT=$(cog bump --auto --dry-run 2>&1)
if [[ "$BUMP_OUTPUT" == *"No conventional commits"* ]]; then
echo "No bump needed."
echo "bumped=false" >> "$GITHUB_OUTPUT"
else
echo "Changes detected, proceeding..."
echo "bumped=true" >> "$GITHUB_OUTPUT"
fi
- name: Bump and push
if: steps.check-bump.outputs.bumped == 'true'
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
cog bump --auto
git push origin main --tags
- name: Get new version
id: get-tag
if: steps.check-bump.outputs.bumped == 'true'
run: |
VERSION=$(git describe --tags --abbrev=0)
echo "Detected version: $VERSION"
echo "version=$VERSION" >> "$GITHUB_OUTPUT"
release:
needs: bump
if: needs.bump.outputs.bumped == 'true'
uses: ./.github/workflows/release.yml
with:
version: ${{ needs.bump.outputs.new_version }}
secrets: inherit
+69
View File
@@ -0,0 +1,69 @@
name: Deploy to GitHub Pages
on:
push:
branches:
- main
workflow_dispatch:
permissions:
contents: read
pages: write
id-token: write
concurrency:
group: pages
cancel-in-progress: true
jobs:
verify:
uses: ./.github/workflows/verify.yml
build:
needs: verify
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Configure Pages
uses: actions/configure-pages@v5
- name: Install dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Build
working-directory: web
run: pnpm build
env:
BASE_PATH: /easy-png-tools
PUBLIC_GA_ENABLED: "true"
- name: Upload Pages artifact
uses: actions/upload-pages-artifact@v3
with:
path: web/build
deploy:
needs: build
runs-on: ubuntu-latest
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy
id: deployment
uses: actions/deploy-pages@v4
+52
View File
@@ -0,0 +1,52 @@
name: Quality
on:
push:
branches:
- main
pull_request:
workflow_dispatch:
permissions:
contents: read
concurrency:
group: quality-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verify:
uses: ./.github/workflows/verify.yml
e2e:
name: E2E (non-blocking baseline)
needs: verify
continue-on-error: true
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: web/pnpm-lock.yaml
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Install Playwright browsers
working-directory: web
run: pnpm exec playwright install --with-deps chromium firefox webkit
- name: Run E2E
working-directory: web
run: pnpm test:e2e
+58
View File
@@ -0,0 +1,58 @@
name: GitHub Release
on:
workflow_call:
inputs:
version:
required: true
type: string
workflow_dispatch:
inputs:
version:
description: "Версия для релиза (например, v0.2.0)"
required: true
type: string
permissions:
contents: write
jobs:
release:
runs-on: ubuntu-latest
steps:
- name: Generate token
id: generate_token
uses: actions/create-github-app-token@v1
with:
app-id: ${{ secrets.XBOCT_BOT_ID }}
private-key: ${{ secrets.XBOCT_BOT_PRIVATE_KEY }}
- name: Checkout
uses: actions/checkout@v4
with:
token: ${{ steps.generate_token.outputs.token }}
fetch-depth: 0
- name: Setup Cocogitto
uses: ./.github/actions/setup-cocogitto
- name: Sync and reset
run: |
git fetch origin main --tags --force
git reset --hard origin/main
- name: Get changelog
run: |
cog changelog "${{ inputs.version }}" > GITHUB_CHANGELOG.md
if [ ! -s GITHUB_CHANGELOG.md ]; then
echo "Release ${{ inputs.version }}" > GITHUB_CHANGELOG.md
echo "::warning title=Changelog::Changelog was empty, used default text."
fi
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ inputs.version }}
name: Release ${{ inputs.version }}
body_path: GITHUB_CHANGELOG.md
token: ${{ steps.generate_token.outputs.token }}
+39
View File
@@ -0,0 +1,39 @@
name: Verify
on:
workflow_call:
permissions:
contents: read
jobs:
verify:
name: Fast quality gate
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: ^11.20.0
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 24
cache: pnpm
cache-dependency-path: |
pnpm-lock.yaml
web/pnpm-lock.yaml
- name: Install root dependencies
run: pnpm install --frozen-lockfile
- name: Install web dependencies
working-directory: web
run: pnpm install --frozen-lockfile
- name: Run quality gate
run: pnpm verify
+9
View File
@@ -0,0 +1,9 @@
node_modules
# Отчёты разовых аудитов и артефакты прогонов
web/audit/cdp-audit*.txt
css-lint.txt
web/coverage
# Локальные планы с приватной информацией (выбранное имя домена) — не коммитить
docs/plan-domain.local.md
+7
View File
@@ -0,0 +1,7 @@
# Точечные исключения, без allowlist-`/*`: корневой `/*` глушил формат-он-сейв
# (весь `web/**` резолвился как ignored, дочерние конфиги не успевали примениться).
# Дочерние каталоги управляются своими конфигами/игнорами (web/, refs/).
node_modules
.git
refs-html
pnpm-lock.yaml
+3
View File
@@ -0,0 +1,3 @@
{
"proseWrap": "always"
}
+112
View File
@@ -0,0 +1,112 @@
# AGENTS.md
Краткий обязательный контракт. Правило: **у каждой темы ровно один источник
истины**; здесь только то, что агент обязан знать до чтения остального, и то,
что линтер не проверяет. Подробности — по ссылкам ниже.
## Стек и команды
- **SvelteKit** (`web/`) — основное приложение, Svelte 5 (runes).
- **pnpm** обязателен, npm не использовать. Команды — из корня (`pnpm build`,
`pnpm dev`, `pnpm verify`) или из `web/` с префиксом `pnpm --dir web`.
| Что | Команда |
| ----------------------- | ------------------------------------------------- |
| Полный быстрый gate | `pnpm verify` |
| Типы | `pnpm --dir web check` |
| Юнит-тесты | `pnpm --dir web test` |
| Тесты lint-правил | `pnpm --dir web test:rules` |
| Линтинг | `pnpm --dir web lint:all` |
| Только токены | `pnpm --dir web lint:tokens` |
| E2E | `pnpm --dir web test:e2e` |
| Форматирование | `pnpm format` (код + docs) |
| Проверка форматирования | `pnpm check:docs` + `pnpm --dir web format:check` |
## Что читать под задачу
Читать только нужную строку, а не все документы подряд.
| Задача | Документ |
| ---------------------------------------------- | ---------------------------------- |
| Слои, поток данных, новый инструмент/поле | `docs/architecture.md` |
| Какие проверки запускать, пороги, CI | `docs/quality-gates.md` |
| Правка самих гейтов: «почему так, а не иначе» | `docs/decisions.md` |
| Порядок работы, нагрузка на машину | `docs/agent-workflow.md` |
| Locator-ы, fixtures, browser matrix | `docs/testing-strategy.md` |
| Детали lint-правил и токенов | `web/eslint-plugins/README.md` |
| Задачи и идеи | `docs/backlog.md` |
| Актуальный список инструментов | `docs/tools-map.md` |
| Правка полей и раскладки по волнам | `docs/plan-fields-audit-fixes.md` |
| Ручная приёмка UI | `docs/checklist-manual-testing.md` |
| Направления развития, фазы 1–11 | `docs/roadmap.md` |
| SEO/GEO-план (адреса, `noindex`, sitemap) | `docs/plan-seo.md` |
| API/PWA/шрифты | `docs/plan-platform.md` |
| Переименование бренда и покупка домена | `docs/plan-domain.local.md` † |
| Сырые UX-находки по инструментам (вход аудита) | `docs/tools-audit.md` |
| Архивирование и удаление старых документов | `docs/archive/README.md` |
| Индекс остальных документов | `docs/README.md` |
† `docs/plan-domain.local.md` вне git, в клоне отсутствует — в этом случае
задачу ищут по `docs/plan-seo.md` §6.
## Инварианты, которые линтер не проверяет
- **Сначала искать.** Новая константа, тип или утилита не заводится, пока не
найдено существующее определение — по ключу в соседних модулях и по всему
`web/src/`.
- **Комментарий объясняет «почему»**, а не пересказывает код. `TODO`/`FIXME` —
только для зарегистрированной задачи: ссылка на `docs/backlog.md` и критерий
закрытия.
- **Один шаг — одна проблема**, примерно до 500 строк; не смешивать lint, i18n,
UI и рефакторинг.
- **Не коммитить.** Коммит и пуш делает разработчик после ревью.
- **Не запускать тяжёлое без спроса.** Playwright — только по явной команде
пользователя, один прогон, без циклов повторов и фонового запуска.
- **Не писать изменяемые числа** (количество инструментов, тестов, локалей,
проценты покрытия): вместо числа — ссылка на источник или команда. Число
допустимо, только если его проверяет конфиг или тест. Обоснование и границы
автоматизации — `docs/decisions.md`, раздел 3.
- **Результат CI смотреть по отдельным job**: у `e2e` стоит `continue-on-error`,
поэтому общий conclusion может быть зелёным при красном e2e
(`docs/decisions.md`, раздел 10).
## Границы изменений
- Production-UI — `web/src/routes/**` и `web/src/lib/components/**`.
- `web/src/lib/core/**` и `web/src/lib/theme.svelte.ts` — общие модули: их
правка проверяется unit-тестами и smoke-сценариями production-потребителей.
- Направление зависимостей, точки расширения и рецепт правки (инструмент, вид
поля, маршрут, токен) — `docs/architecture.md`, разделы 1–2, 7, 8.
## Что проверяется линтером
`pnpm --dir web lint:all` — ESLint с `--max-warnings=0` (warn-only правила тоже
блокируют gate), Stylelint и token audit. Синтаксические ограничения здесь не
дублируются: полный список правил и префиксы токенов —
`web/eslint-plugins/README.md`; новое правило сначала появляется в warn-only
режиме с fixtures и тестами (`test:rules`).
- **E2E линтером не покрыт:** порядок locator-ов, допустимые `data-testid`,
`test.fixme` — `docs/testing-strategy.md`.
## Дизайн
Источник правды по визуальному языку — код: `web/src/app.css` (дизайн-токены и
глобальные стили), `web/src/lib/components/**` и витрина `/kit`. Новый дизайн
живёт в `app.css` и применяется ко всем production-маршрутам; повторяющийся
визуальный элемент — отдельный компонент, даже «просто div с двумя стилями»;
дизайн-токены — только из CSS-переменных, новый токен появляется вместе с
потребителем (`pnpm --dir web lint:tokens` сообщает о неиспользуемых). Приёмка —
`docs/checklist-manual-testing.md`.
## Документация
- Новый документ создаётся, только если темы нет в существующем; иначе
обновляется источник истины. У документа есть статус (`draft`, `active`,
`completed`, `archived`).
- Не дублировать длинные объяснения в нескольких файлах: короткая формулировка и
ссылка на канонический документ.
- **Backlog живёт открытыми задачами:** выполненные удаляются, а критичные для
проекта факты переносятся в тематический документ. Завершённые планы и
исследования — в `docs/archive/`, удаляются через 30 дней; процедура в
`docs/archive/README.md`.
+258
View File
@@ -0,0 +1,258 @@
# Changelog
All notable changes to this project will be documented in this file. See [conventional commits](https://www.conventionalcommits.org/) for commit guidelines.
- - -
## [v0.2.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/f40a7c89ae5615f2c16f1154116d35fdc0e53125..v0.2.0) - 2026-10-04
#### Features
- add pipeline flow - ([6b946bf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b946bfb08729ead2515e87406cb3efd22ca9859)) - Ku6epXBOCTuK
- add select format to download button - ([e87bc1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e87bc1d7159e91acbf297351710c367eae543011)) - Ku6epXBOCTuK
- upload source by ctrl+v and dropzone - ([864f9cd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/864f9cdc9f355292d688a46f1d0383a893d252d3)) - Ku6epXBOCTuK
- add size presets for tools - ([91150a1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91150a14bf8203627132f6d3a409109845ed749f)) - Ku6epXBOCTuK
- touched fields dont reset on change source image - ([5743b9f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5743b9fb3f1eb7f3b4f9b01e35c9a0f897ee7411)) - Ku6epXBOCTuK
#### Bug Fixes
- keep aspect ratio flow - ([4d9cd2c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4d9cd2cfcbe6491dfe2ee6074e8f8c1d8abeee74)) - Ku6epXBOCTuK
- update range slider field component - ([e73986f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e73986fefd7306391711a39f719e48f60f5d3292)) - Ku6epXBOCTuK
- add title from i18n - ([62bdd5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/62bdd5a7c582c74118e964058f84108eaf0cf4cc)) - Ku6epXBOCTuK
#### Refactoring
- reuse badge component - ([409c98c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/409c98ca295be7b4541148c9568ed9b89524e37a)) - Ku6epXBOCTuK
- - -
## [v0.1.1](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/1db83d12ff717bc2e282fe21d35d282f261147f5..v0.1.1) - 2026-10-04
#### Bug Fixes
- google analytics only on production - ([e1fc11c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1fc11cb5491a6f5be5c823140830672ea669a62)) - Ku6epXBOCTuK
- shapes offset calculate - ([1db83d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1db83d12ff717bc2e282fe21d35d282f261147f5)) - Ku6epXBOCTuK
- - -
## [v0.1.0](https://github.com/Ku6epXBOCTuK/easy-png-tools/compare/fdf7e2a3cd68d6693f1c5581796fafaf8939dad4..v0.1.0) - 2026-10-04
#### Features
- change id - remove png intents - ([7b7e12f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b7e12fafcbd7444be61426cd0b90104de9de40f)) - Ku6epXBOCTuK
- move routes to slugs - ([d09c954](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d09c95469494ec5f243f1981f6bc08b6e9fa6eb7)) - Ku6epXBOCTuK
- separate i18n dict pages - ([9776e1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9776e1d3251761347440ed43d57fef851b2af150)) - Ku6epXBOCTuK
- separate slug pages from tools - ([7a3498c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7a3498c173daa63f0a9ec88f09e10798525be9bd)) - Ku6epXBOCTuK
- add source aware resolver, update reset, update generator - ([5f77c09](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5f77c097e2d58d1826a10f05217af973c4de7e81)) - Ku6epXBOCTuK
- remove v1 routes and libs - ([16d4288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/16d42889120566e45008fa94285fd518b37f967f)) - Ku6epXBOCTuK
- add new dics\translations - ([9a27388](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9a27388119bb1b14bd759a830a9444be6c498e1b)) - Ku6epXBOCTuK
- add icon for split tool, update docs - ([b91c1ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b91c1ab7ee5bad72a90d2c66543a2e04e8f8c70e)) - Ku6epXBOCTuK
- add ui for new split tool - ([2be8871](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2be887193084b0fc87f16c110264bad58dc7279c)) - Ku6epXBOCTuK
- add executor support for multifiles result - ([adb5785](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adb578540f95ccafd3d0e8c419760da1cb031dcb)) - Ku6epXBOCTuK
- add split to parts to geometry registry - ([a7a42d0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a7a42d0091af3cc8e61c16962c4000d5c0bd8bdd)) - Ku6epXBOCTuK
- add error texts - ([e1c3d0a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e1c3d0a95ffd1b1a1245a88bf8db247c28fc0b64)) - Ku6epXBOCTuK
- add download zip with result files - ([ceee9ad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ceee9adead94c437785ede7053e4563a7cbf5054)) - Ku6epXBOCTuK
- add split to parts core tool - ([d94c5d1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d94c5d1297bfbc35b08feab28b565eab152217f4)) - Ku6epXBOCTuK
- add files tool result - ([ee5f660](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ee5f660572676edf829faca3a238796468ab2c12)) - Ku6epXBOCTuK
- rename old -> v1 folder - ([33e3f3c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/33e3f3c4f035c0daeae8de687cd464b1572242e5)) - Ku6epXBOCTuK
- flatten lib/preview -> lib folder - ([1e5e096](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e5e096d5d61b5ca69aaf196928868ad67f2f4f5)) - Ku6epXBOCTuK
- flatten components/kit -> components folder - ([f8a1da6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f8a1da6ff79be1a22e0046db7c3701110479011a)) - Ku6epXBOCTuK
- move preview pages to root - ([45dc54b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/45dc54b6aa136f753de16856a2378fec5ecbd10f)) - Ku6epXBOCTuK
- move old root routes to /v1 routes - ([b6318f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6318f03ab60cea602606d88dd94ed3d0601dd0f)) - Ku6epXBOCTuK
- update filter tools to new types - ([d6706ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d6706abea22cd9fa587f377338200032cce0f38a)) - Ku6epXBOCTuK
- update alpha and colors tools to new types - ([bea010d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bea010dd907e1c5ffe0b3b2f43c5db6efac9d348)) - Ku6epXBOCTuK
- update geometry tools to new types - ([2937974](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2937974fe732690ca58e4d693e5c1a69ef4bfc90)) - Ku6epXBOCTuK
- update text tools to new types - ([1e85fe3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e85fe36a636a7ab4e4daf48c2877ba9e91eeccb)) - Ku6epXBOCTuK
- update convert and analyze tools to new types - ([60c7d61](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/60c7d61daba60f656522e3ab45ffb87149910e69)) - Ku6epXBOCTuK
- update generate tool to new types - ([6b0b064](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6b0b0646d51d3b3f74e4d132df2bfd44be411530)) - Ku6epXBOCTuK
- update schema components - ([b6db1c8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b6db1c8f19ceb15c6a5e81f4108c9a25dc180525)) - Ku6epXBOCTuK
- update executor for tools - ([ba8a518](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ba8a51886129d3c128d21ee625a8c3a0d16ae5b0)) - Ku6epXBOCTuK
- update tools types - ([292e53c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/292e53c9c76d4e02efa4990b5d62868a197d39c7)) - Ku6epXBOCTuK
- add text tools - ([e5b76ca](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e5b76ca3e11eb0313988ea1d49578adb6f24bf1e)) - Ku6epXBOCTuK
- add text core tools and views - ([a497857](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a49785764b39af1707be5990b5adcb43eb7356c9)) - Ku6epXBOCTuK
- add output format for download - ([c0cebdc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0cebdc81f229fc2fab57b40c48b2fb6dd84f598)) - Ku6epXBOCTuK
- add generate tools - ([b3d460a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b3d460a951f69b505b153ae2e1af5596f8321049)) - Ku6epXBOCTuK
- add multiple colors field - ([6be9d10](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6be9d10c42580a984b78e95fb0e317b2d4d6993a)) - Ku6epXBOCTuK
- add analyze, generate, text tools and update plan - ([cc70750](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cc70750727aaf5d5c758aa64fa577262a5197b9b)) - Ku6epXBOCTuK
- add alpha tools - ([e2735c5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e2735c52abf1e3968b24e334bb3d52c4e7f4dcf3)) - Ku6epXBOCTuK
- add geometry tools - ([d383cf2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d383cf29e30ad3722b754eb07cad10251af784d3)) - Ku6epXBOCTuK
- add filters tools - ([bfad0c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bfad0c7d020fe8e19a5919ed42011710b955a1df)) - Ku6epXBOCTuK
- add colors tools - ([6750d5a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6750d5a373c9f10b44fa75c7c407f12e2d8d6680)) - Ku6epXBOCTuK
- add layout groupt to tools - ([e662d1d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e662d1df248469f4e554b39a4e42d25a3296192d)) - Ku6epXBOCTuK
- add tool schema layout - ([d48262e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d48262e48269785c7edd414868fd952ff6d85f5f)) - Ku6epXBOCTuK
- add gradient control component - ([cad9bf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cad9bf6cfd48aef22d27610d735a3e76bd4baaab)) - Ku6epXBOCTuK
- add plate (under text) field component - ([b99ccf5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b99ccf5d7808bdd3bca7349b10e1b3858c7e56c3)) - Ku6epXBOCTuK
- add font style field component - ([0a7c4a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0a7c4a84c9009ab907579dc1750d8bd203d34f67)) - Ku6epXBOCTuK
- add position9 field component - ([f24ccf9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f24ccf924031c7384b22c83087401fbdfbfc4125)) - Ku6epXBOCTuK
- add offset field component - ([0c28925](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c28925673facc40500c3c5e8e63d94db617f94b)) - Ku6epXBOCTuK
- add two colors field control - ([19f2e13](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/19f2e13b8874b60bb0d34d2d0ce8a575bfd9fe5c)) - Ku6epXBOCTuK
- update use dimension tools - ([f286044](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f286044ab92773af36b712d847b4eccc1f31d81b)) - Ku6epXBOCTuK
- add dimension tools for new flow - ([2601aa5](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2601aa5b5c9e6d5c20a4df6d1eab2a0a68126f60)) - Ku6epXBOCTuK
- add registry tools for new preview flow - ([1e9b354](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e9b35406d8e3fd4071ba72d1dc6a043a0bf318f)) - Ku6epXBOCTuK
- double executor to use with preview - ([520a1e1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/520a1e1d37f4b63692ca906c06bdf694e297f0ca)) - Ku6epXBOCTuK
- add new registry for preview design - ([a19d3db](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a19d3db929f5d840900f1190c9efd10bb0595548)) - Ku6epXBOCTuK
- add single tool schema rendering - ([1c74629](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1c746299c9780a237cff2197c8b6b46310bc49d1)) - Ku6epXBOCTuK
- add new registry schema for new ui - ([1ae4b36](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1ae4b36c9c2b0268a39486c935220cad9290cfb7)) - Ku6epXBOCTuK
- css audit fixes - ([dbeffac](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/dbeffac082f56990fa1d08089b00e79da28b276c)) - Ku6epXBOCTuK
- WIP: add css fixes - ([2ef4709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2ef47090387fc47bd6c93365c7471532402e514d)) - Ku6epXBOCTuK
- add refs css audit - ([1831c3d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1831c3d10051510842fbf1a591c95d39cabf3ce4)) - Ku6epXBOCTuK
- fix tools pages, dom audit fix - ([9ef6ae0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ef6ae03c69a8b343bc8f05dac832876b9ea184e)) - Ku6epXBOCTuK
- add preview catalog and catalog group and header - ([b695f5d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/b695f5d58c2e78f7d076d175ae819811b874cfe1)) - Ku6epXBOCTuK
- fix list tools page to correspond refs - ([7578a18](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7578a185b64e48c970e27c18c580b4055ba5c23b)) - Ku6epXBOCTuK
- fix demo page to correspond refs - ([d29ec73](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d29ec73322d6caf3d7ebff3305b2cf20cb0f0fbb)) - Ku6epXBOCTuK
- add new simple components - ([c0f48ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0f48ecd9762f0c507933409525f5e22bf759a76)) - Ku6epXBOCTuK
- some refactor, fix some design - ([65f12c9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/65f12c931baec26732a6fc3f39284bd423a3af0b)) - Ku6epXBOCTuK
- add link to preview design - ([40eee98](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/40eee984daeb2d82af375ff322c66a6c0e7ca869)) - Ku6epXBOCTuK
- update tool page - add step card steps - ([d373cc0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d373cc0daf8672c1febb3c3a844cb62714ff1dc7)) - Ku6epXBOCTuK
- add dropzone, update tool page - ([a16a7b9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a16a7b92c737273b27fd2611a77c65b746d63231)) - Ku6epXBOCTuK
- add single tool pages, wiring with toolcard - ([f990ad6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f990ad67671662637acc6b890145a2d2b0109bf0)) - Ku6epXBOCTuK
- separate topbar, fix important styles - ([f4216e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f4216e6868f975e511395c729359109a9a001a5c)) - Ku6epXBOCTuK
- add demo page - preselected pipeline - ([adcc255](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/adcc2551073d0a57517b655239100d807e643fe7)) - Ku6epXBOCTuK
- add list tools page - ([73118ea](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/73118ea393c60d69108be3a08d72608c937465b5)) - Ku6epXBOCTuK
- add main page - ([e9578fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9578fc15e179b9ae5df3c0f9e1690f95b113a26)) - Ku6epXBOCTuK
- add ui kit page - ([06d185a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/06d185add66864662b0a173183f69c22078dc098)) - Ku6epXBOCTuK
- add ui components - ([8437c95](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8437c95a28343d6b24971d41776b8fd65e831810)) - Ku6epXBOCTuK
- add ui components - ([ac75665](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ac75665deb78d9b9459e4c34f4a9f9719a027c63)) - Ku6epXBOCTuK
- add new design primitives - ([0acfa3a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0acfa3aeb923fe7cbe95846055d2e199a62257e4)) - Ku6epXBOCTuK
- add new design2.css, add fonts - ([418a091](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/418a091505d80e4b2798a80740906d2d215c2488)) - Ku6epXBOCTuK
- add compression tools - ([83d3ce1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/83d3ce11986c908ea64c54c23bbfba0395cc838d)) - Ku6epXBOCTuK
- add quantize tools - ([501d2dd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/501d2dda4e186b2f7ece3f1be41b552393ca18a3)) - Ku6epXBOCTuK
- add text and generation tools - ([2b67414](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2b67414e79ef646b78bf6372d35e2262099d65a0)) - Ku6epXBOCTuK
- add pixel effects core tools - ([56ebe5b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/56ebe5b53be2bd797911faf285b5fe7b414358a1)) - Ku6epXBOCTuK
- add geometry masks - ([84cc56c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/84cc56cc6cf6627382d4f76f464c787a90181308)) - Ku6epXBOCTuK
- add shape masks - ([d65adcd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d65adcd16cb9bbd888b356784b46489e611f6ace)) - Ku6epXBOCTuK
- add masks core tools - ([553601a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/553601acc1ec4bb5a5fd74b50e60afb09070dd5d)) - Ku6epXBOCTuK
- add channel tools - ([5c37eb2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5c37eb2e7b6bc41a219550e8be237697266ce5a1)) - Ku6epXBOCTuK
- add pallette tools - ([db02cbd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/db02cbd16558a367d328184b96f570ec692e5d67)) - Ku6epXBOCTuK
- add watermarks and text tools - ([116d7c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/116d7c72aac7ba2463f249c962251ee9d4a56744)) - Ku6epXBOCTuK
- add text and date draw tools - ([54af762](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/54af7626d6615c379e841e38e528fc9572d11196)) - Ku6epXBOCTuK
- add text draw core - ([38e7cd2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/38e7cd20d28e7fc26d3cbfb3ec960a2b136d1202)) - Ku6epXBOCTuK
- add demo page - ([6e0647d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6e0647dfbf9eb68d980ae8d76d6affdc9f1965d6)) - Ku6epXBOCTuK
- inline tool page - parameters beetween source and result images - ([148e4b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/148e4b195739774c5e892dbfd16f5a83617b93dc)) - Ku6epXBOCTuK
- add theme switcher, dark theme - ([f1f5bd4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f1f5bd4409797cb6c28fdee55e3dbd73ac6ce5f0)) - Ku6epXBOCTuK
- add base path for deploy - ([6d770dc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6d770dc642cd6110bb4ab3d7af9c9364bb29d0bb)) - Ku6epXBOCTuK
- search use both languages and remove diacrites - ([c04a1fc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c04a1fc1dc4d266f47164e735668a53e14c2d61b)) - Ku6epXBOCTuK
- use i18n in components - ([3960368](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/396036802c1ee1e8fe52877826b22b9a3a427bee)) - Ku6epXBOCTuK
- base english language, also fallback to english - ([74a18d9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/74a18d91a77f3a049683f73122279eb9392a61df)) - Ku6epXBOCTuK
- move all common messages to i18n (ru\en) - ([2301b0e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2301b0e1654874744d2ddf72b878997aa461620b)) - Ku6epXBOCTuK
- add i18n - dict, en\ru, state - ([8255d33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8255d330988f9f6f98c51407e6e98613f7306150)) - Ku6epXBOCTuK
- add tool registry, icons - ([8745812](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8745812a5f077f715b003ca4022d20a72eb783ba)) - Ku6epXBOCTuK
- add io extensions - svg, jpeg - ([0909e14](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0909e14cde8ba6ab7249e54bc84bdc4a095cbf8d)) - Ku6epXBOCTuK
- add core tools - transfor, light, effects - ([7ef4b90](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7ef4b908e2f314e776ffd10f904085ac2eb4f3ea)) - Ku6epXBOCTuK
- add tools into registry, add icons - ([a2dd288](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a2dd288851572b9634664f647e5c76a7f8184869)) - Ku6epXBOCTuK
- add alpha and morphology core tools - ([cb5ccf6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cb5ccf6da8d3f21590e481a617507a9f899090e8)) - Ku6epXBOCTuK
- add tool search cards - ([5e44a45](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e44a454571e99a33d019f5eacfaed840a00c8c5)) - Ku6epXBOCTuK
- add tool popularity and icons - ([76f1770](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/76f1770286258097000ee18a9d11adc6b55ae08c)) - Ku6epXBOCTuK
- combine tool in single card - ([702b962](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/702b96261d0266fd904fa740f5a4bdab12ffa24e)) - Ku6epXBOCTuK
- move tool executor to web worker - ([9fbb90b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9fbb90b6418641154c40017ca03d7fd3bcec1452)) - Ku6epXBOCTuK
- add registry - background, blur, sharpen - ([c143011](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c14301130f23e9a4787f13d26b0bbf1a14bd084e)) - Ku6epXBOCTuK
- add background and convolution core tools - ([fd5236c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/fd5236c19b5a6daba78d8780d3c217e92d33f74f)) - Ku6epXBOCTuK
- restore old toolchain button - ([91b3492](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/91b3492231974d3b67b694a313c1f91dcf5b545d)) - Ku6epXBOCTuK
- add chain tools workflow - ([306c26a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/306c26a3602d64a6a24ed938f20c04882f4637c4)) - Ku6epXBOCTuK
- add tool search component, add chainable flag - ([c93a8e4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c93a8e493ef8464d1b1ee96869b6f99ddc0a583a)) - Ku6epXBOCTuK
- add step picker\list\editor - ([8532eb9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8532eb98c3f985ed4ee9ba0c7a51eabe0f86dc3c)) - Ku6epXBOCTuK
- add pipeline for multi instrument flow - ([23c985b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/23c985bde90cdbc24af5f1c137c0153e98658408)) - Ku6epXBOCTuK
- add registry - ([6fae98b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6fae98b25c9db23f27effdd6c73914cad5a9a647)) - Ku6epXBOCTuK
- add core operations - ([f9a4973](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f9a4973b471b19f2aeaa8b71bf56bff2fd4de18d)) - Ku6epXBOCTuK
- add core operations - ([7fa2c52](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7fa2c52bb83538898398460de5ed520a26372c02)) - Ku6epXBOCTuK
- add text\hex conver tools - ([17b0c68](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/17b0c68eb52cf8e4e495bd01754ba8e7d3130202)) - Ku6epXBOCTuK
- add color picker glass - ([96c37aa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96c37aabc568896f7a279069d0c65e6ff4f794bb)) - Ku6epXBOCTuK
- add color picker - ([7b79996](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7b799969c4bc0229de490921f29a15b84387ee21)) - Ku6epXBOCTuK
- add slider field - ([3701fc8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3701fc80451f94989d412a954715b1ffe9333836)) - Ku6epXBOCTuK
- add show mask workflow - ([aa5372b](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/aa5372b771ef0834c2221f045c27cdd4570a1c41)) - Ku6epXBOCTuK
- reactive tool flow, with debounce - ([d0ee4ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d0ee4ec77f7999655e55c79df50b98a68d3fab9a)) - Ku6epXBOCTuK
- update drop file flow - ([caf71ec](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/caf71ec8f0418ebe43d9e04b007e9c3379864afd)) - Ku6epXBOCTuK
- add ctrl+v flow - ([f576701](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f576701baabbd0172550efe18c422650e0d15ee1)) - Ku6epXBOCTuK
- update - use design system components - ([c7b56bb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c7b56bbda79b67d4e13c5062c55266c7b6de2c5c)) - Ku6epXBOCTuK
- add design system components - ([15e2e01](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/15e2e010ec3227dae0786ebf81afe71ed0c8b011)) - Ku6epXBOCTuK
- add design system tokens - ([3620c5f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3620c5fc309e840eea5ac6927534b50c2957928f)) - Ku6epXBOCTuK
- add route pages - ([3ec1199](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3ec11999d82c118b72416d11fd0aa6b2698359f6)) - Ku6epXBOCTuK
- add svelte components - ([c022555](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c0225552e4d5970b81a3dba51e357821da38977b)) - Ku6epXBOCTuK
- add instruments registry - ([719bda2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/719bda2e225331d04da35b7a35fb980b2af63f00)) - Ku6epXBOCTuK
- core instruments - ([9ccc9f9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9ccc9f94acb5cc9d6f5ab1c13a7b3641160e3edd)) - Ku6epXBOCTuK
- core types and io - ([4ec2b41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4ec2b418fd001bf0c0d95534cd38988b6a44cd1d)) - Ku6epXBOCTuK
- initial sveltekit - ([85f595f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85f595f14b9cd059723d4ae84265b6a6e1633a67)) - Ku6epXBOCTuK
#### Bug Fixes
- show version in footer from manifest file - ([f89811c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f89811c116fbff42d0323ed811104bdce06dc68d)) - Ku6epXBOCTuK
- update crop tool max sizes - 2000x1200 - ([becf03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/becf03f4165e744e05b4e39d906b72a4804f6094)) - Ku6epXBOCTuK
- use base path for favicon - ([44cfca9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/44cfca92ce2eb2cef5119fb3701719f78d97f579)) - Ku6epXBOCTuK
- worker use only i18n keys instead of full i18n - ([c2f3838](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c2f3838485a78b12246a480cda564feb5a19255e)) - Ku6epXBOCTuK
- update i18n - update registry for tools - ([371387c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/371387ce81569921f5e143daec81275b63a1bab4)) - Ku6epXBOCTuK
- update i18n - update schema fields descriptions - ([08c9fbc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/08c9fbce3b21a7530bba12d8cdaa77f4286e69ae)) - Ku6epXBOCTuK
- update i18n - tool group descriptions - ([4de42b1](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4de42b1e402a17f43992b132d8d3d97b869fc0d9)) - Ku6epXBOCTuK
- update i18n - text verdicts from analyze tools - ([372babc](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/372babc3b741e0fd5e10709b4b6e3e70589b6074)) - Ku6epXBOCTuK
- update i18n - fields components - ([4b44bc4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b44bc439ce2a36baf1c3f68fde8776d31ccb3f1)) - Ku6epXBOCTuK
- update i18n - schema components - ([6c88a23](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c88a23e4882bebd20e9bd7cc45c70f15818d5cb)) - Ku6epXBOCTuK
- update i18n - catalog, nav, common components - ([a4c85c0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a4c85c08140bd802bfb0dc2952ea475d4dca48f5)) - Ku6epXBOCTuK
- update i18n text in pages and layout - ([e069709](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e06970997e5a402139d2f2b9c78cab8310cad293)) - Ku6epXBOCTuK
- update lang toggle component - ([1097186](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/10971864872fb305bbcad4e6470b7afac8fa1312)) - Ku6epXBOCTuK
- restart tool after change source - ([5cd2a58](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5cd2a586ce70116730cc9ce11653943194992454)) - Ku6epXBOCTuK
- update design - colors, remove texts - ([f2829de](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f2829de216f48f0d2bc5b35c0a1bf34a51ad10e8)) - Ku6epXBOCTuK
- disable download button while running jobs, auto run generator jobs - ([0c2de3f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0c2de3f191cb9cfd10c4186cd16db7db2f8545bf)) - Ku6epXBOCTuK
- separate preview tile component - ([35df076](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/35df07673211f2734c4f52ab4e232580006efc9c)) - Ku6epXBOCTuK
- source image checker background - ([0b107c7](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b107c7d93a8dd27b8ad40c2c73a70518a45056f)) - Ku6epXBOCTuK
- update topbar links - ([f7623ab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f7623ab3bbc3d3998938e918e963219039f97f88)) - Ku6epXBOCTuK
- v1 layout - ([85effce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/85effce7d9ae940858fda6ed3f4b8336799ac93c)) - Ku6epXBOCTuK
- css fixes - ([420d03f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/420d03f58c3ed26f0dd5658cdc9391f881847ba1)) - Ku6epXBOCTuK
- lint errors - ([6f1771c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6f1771cb07da8b43b3ef79c8ebb4033f429b491c)) - Ku6epXBOCTuK
- update tool card component - route resolve issue - ([987f453](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/987f4535c8609e25ae27db192ba598683604803f)) - Ku6epXBOCTuK
- resolve always text input issue, separate to simple components - ([a1438e6](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a1438e6f7f227f7489465070c02abdbb495a9dd1)) - Ku6epXBOCTuK
- add compress\decrease colors presets - ([37a4a05](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/37a4a0576c505ee3a87c0886e92faa9521092097)) - Ku6epXBOCTuK
- update tools list - show all tools - ([d1a386f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d1a386f92f2e9e7fa859cef950ca68d5f3729194)) - Ku6epXBOCTuK
- update segmented component styles - ([a24a182](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a24a182d1813f9bc882d3dff2cbf58a0396c799c)) - Ku6epXBOCTuK
- increase font size - ([1e63eaa](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1e63eaa03bc7d4c6ee21eb88cd0dff4cb54b4922)) - Ku6epXBOCTuK
- update field component - ([3d0f8eb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3d0f8ebb100294b970e9b45a854126ca031df9d7)) - Ku6epXBOCTuK
- update download button style - ([ef74cab](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ef74cabf1e8bea77e88e0e29c802e02ee57eaf3d)) - Ku6epXBOCTuK
- update button component styles - ([c590bdf](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c590bdf91a896fab0fd8a3e347a67db639be26c7)) - Ku6epXBOCTuK
- update css variables, update badge component - ([4794523](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/47945230846bacc4063bf8fc21620fefe57f3099)) - Ku6epXBOCTuK
- update some styles - ([191ca71](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/191ca71553187b45efb6bc26fb278a1e10b93aeb)) - Ku6epXBOCTuK
- change style, demo page visual looks as done - ([749b97c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/749b97cb4f55b145067bc169da8d4d08848ee4e3)) - Ku6epXBOCTuK
- resolve new audit styles - ([80def1f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/80def1f38043b3fdd081fa8c8b3558c6c6f01ce5)) - Ku6epXBOCTuK
- update font styles - ([0b13d4c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0b13d4c9e829e5a0c1d2e8be210ae9ba659bbe39)) - Ku6epXBOCTuK
- update style, resolve old css leaks issue - ([586c66a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/586c66a159a9a4e30ae414070ffa83f06d9209ed)) - Ku6epXBOCTuK
- resolve style issues - ([bbd5532](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/bbd5532529257262faa133d4cd5e7dee0f943927)) - Ku6epXBOCTuK
- step fields styles - ([8eff9a9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8eff9a94836fa2a6219888a5599a3fa011699d9b)) - Ku6epXBOCTuK
- update styles - ([2c70c44](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2c70c445d5ec601945b842663cfbc81222fdc91c)) - Ku6epXBOCTuK
- update preview styles - ([6c8f7b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6c8f7b2d8b1eaca7f0cee708f0f1e81d0d59e25e)) - Ku6epXBOCTuK
- update top bar styles - ([86b1c81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86b1c813e72bc07f7f07e534b54faa4f6d69e142)) - Ku6epXBOCTuK
- resolve design issues from refs audit - ([0d95acb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0d95acba8933280b32e54a3f13d014aa464a2e71)) - Ku6epXBOCTuK
- remove tile label doubling - ([6959abb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6959abb15926edab7817006a42cfabfa436c04e8)) - Ku6epXBOCTuK
- update design with references - ([5093b50](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5093b50f5cfea72f6298e9dd5a2ae934e741b75a)) - Ku6epXBOCTuK
- update search flow - first tool search - ([3c1c5b2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c1c5b2b3ae6fe8bdf576a70acb6b449edaf0197)) - Ku6epXBOCTuK
- some small css fixes - ([5e512f0](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5e512f0bdd34f6329b54f111f81950acbebb2569)) - Ku6epXBOCTuK
- get names from categories - ([96b8c00](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/96b8c0012988e4586718a11a77419b7752edeb80)) - Ku6epXBOCTuK
- base en - ([6787201](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6787201425b87256369003b97df299704ed75975)) - Ku6epXBOCTuK
- resolve transform core tools, add tests - ([ebc3c33](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ebc3c33c12b58be7413378ff6e44677cf76f1a64)) - Ku6epXBOCTuK
- use toolcard as links at list page - ([200e971](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/200e971df503e6ba6a460bdd24f47cd617dbbc7c)) - Ku6epXBOCTuK
- update layout - ([4c5b52a](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4c5b52a9ad13aadd425e9c5571120f356bf631be)) - Ku6epXBOCTuK
- update user input flow (keep aspect ratio) - ([da55328](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/da5532849198d3040a56f71a69fe6af6ae5b988f)) - Ku6epXBOCTuK
- resolve loading image error - ([4b70866](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4b7086693cfd06ab88d47f22314913aa9326a470)) - Ku6epXBOCTuK
#### Refactoring
- separate components - ([c4eab38](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/c4eab388662b927aa5a6527be75350686e903865)) - Ku6epXBOCTuK
- add slug to tools - ([931ee27](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/931ee27b056a641b0adec58d0016347cd6afbc18)) - Ku6epXBOCTuK
- separate schema preview logic - ([6ab5989](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ab59897d10dd391c549a3c1e6d9df6fc7623288)) - Ku6epXBOCTuK
- separate schema fields logic - ([cdb23fe](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/cdb23fe1c113f5bcc41228b9508e68a65a2746b6)) - Ku6epXBOCTuK
- executor protocol, fallback, debounce - ([0933f1c](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/0933f1cdeab3998de628eb1a5443399304e088ae)) - Ku6epXBOCTuK
- change v1 to import own copy of i18n - ([939038f](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/939038ffc02fc618c5e53a8589a803417149f327)) - Ku6epXBOCTuK
- update path registry-new to registry - ([75f6891](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/75f6891e7c4285b4a63515b426f88f3fe87bb6ab)) - Ku6epXBOCTuK
- set root layout, rename css, fix links in lint rules \ etc - ([e970b47](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e970b477205ea02dc96c67dac19bbc36660aab85)) - Ku6epXBOCTuK
- move old libs and components to old folder - ([3834dad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3834dadd03f2946fa679e4cfb737d7cb486e588b)) - Ku6epXBOCTuK
- update css vars lint rules and fix them - ([e3d50f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e3d50f2128e5f27c834455f5c11de0dd6364d9fe)) - Ku6epXBOCTuK
- fix lint css vars rules - ([2d24f51](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2d24f51fd5bf94408f1ec515575fc700bfd74ae3)) - Ku6epXBOCTuK
- isolate old and preview versions, add lint rule - ([e57fadd](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e57faddfdef9e75312b5a81df56e6450fb82bff4)) - Ku6epXBOCTuK
- update field schema types - ([743ae81](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/743ae817ee47b8bf2826ee9876e046babd7d811f)) - Ku6epXBOCTuK
- separate schema fields components - ([4a13bfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/4a13bfbd1684d095d10f9403bde88359174456c7)) - Ku6epXBOCTuK
- separate registry file - ([58e75f2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/58e75f23345b956d4068ff16ef90805031eae1c8)) - Ku6epXBOCTuK
- move form fields to separate folder - ([6ec3e75](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/6ec3e75df4dc22728a234dab77c4bf69316ce9c1)) - Ku6epXBOCTuK
- move components - ([e9de7a4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/e9de7a4bf8244a278bb44e8eb84c35db7ee38965)) - Ku6epXBOCTuK
- update panel component - ([7e94205](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7e94205866f8c9d25ebb548f3c3fa87f42557e25)) - Ku6epXBOCTuK
- update panel component - ([2f758a8](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/2f758a88af234fc2ceefca024d6abfdd9ee14608)) - Ku6epXBOCTuK
- separate top bar component - ([991fe69](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/991fe69381e7018bb977991fb6ce2ba99c87c53d)) - Ku6epXBOCTuK
- update footer component - ([11c4cce](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/11c4ccece436aae2081c426d3e31fb272a464904)) - Ku6epXBOCTuK
- icon and download button use button component - ([8a6f879](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/8a6f87938d9179728e55642119ab575ff9891b93)) - Ku6epXBOCTuK
- update pages and refs audit - ([86e7b72](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/86e7b7250d5a35eabdd9c9a463ec7999fb7f641a)) - Ku6epXBOCTuK
- move old design to separate layout tree - ([f08af41](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/f08af41568f9694543703fdcc936118d8f20e342)) - Ku6epXBOCTuK
- separate tool page for A\B testing - ([d70cbad](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/d70cbad0d2d804386b2d5d53673553bf65ca323c)) - Ku6epXBOCTuK
- add error messages i18n - ([486b99d](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/486b99dbb8ec9383de36d2e6cd88c9fec08c7434)) - Ku6epXBOCTuK
- use standalone tool run executor - ([1b3bad4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/1b3bad46d1be4a04311f46400c078542026315ff)) - Ku6epXBOCTuK
- change show mask flow - checkbox in params - ([3a5acfb](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3a5acfbc452a2a2fc350dc4e803b0846473779c2)) - Ku6epXBOCTuK
- separate tool page - ([5560435](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/5560435b55cb232b978f4c20a813f3d964e66b79)) - Ku6epXBOCTuK
- dropzone update - ([7afd07e](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/7afd07e8c148a317456ee864685258e40cab484d)) - Ku6epXBOCTuK
- remove unused styles - ([a9234d2](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/a9234d27de7d19f3a0489ebe1da1d310371a4287)) - Ku6epXBOCTuK
#### Style
- fix formatting - ([3c10147](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/3c10147ad85650ed9e9f63850fc9581400e2d427)) - Ku6epXBOCTuK
- fix prettier ignore\config - ([9dccdd9](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/9dccdd99a0f7b6ef187627cf8ec4811b648bdd22)) - Ku6epXBOCTuK
- format docs markdown - ([64e35f4](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/64e35f4fdfedf331e79309c49ddab936b2a263da)) - Ku6epXBOCTuK
- fix formatting via prettier - ([ada96b3](https://github.com/Ku6epXBOCTuK/easy-png-tools/commit/ada96b3534a20a2c5c671cef3ca0146ada743afa)) - Ku6epXBOCTuK
- - -
Changelog generated by [cocogitto](https://github.com/cocogitto/cocogitto).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ku6epXBOCTuK
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+97 -4
View File
@@ -1,7 +1,100 @@
# Project renamed
# easy-png-tools
"Easy png tools" now named "Easy image tools" and moved to a new domain.
Browser-based image utility toolkit — no server, no data upload, everything runs
locally in your browser.
[New repository link](https://github.com/Ku6epXBOCTuK/easy-image-tools)
Inspired by online services like onlinepngtools.com, but fully offline-capable
and open source.
[New website link](https://easy-image-tools.com)
## Features
- **A full set of image tools** across categories: conversion,
transparency/alpha, color, channel decomposition, geometry, filters,
pixel-property masks, generation, text/watermarks, analysis & verdicts — the
current list lives in [`docs/tools-map.md`](docs/tools-map.md)
- **Web Worker execution** — heavy operations run off the main thread with
automatic fallback to direct calls
- **i18n** — full Russian and English localization
- **Dark/Light themes** — persisted to localStorage
- **Pure TypeScript core** — `lib/core/` operates on
`ImageData`/`Uint8ClampedArray` with no DOM dependency
- **Fully static** — no backend; the image never leaves your browser. The only
third-party request is a Google Analytics page-view counter — it never sees
the file or its contents; the boundary and its limits are in
`docs/plan-seo.md` §0
## Tech Stack
- **SvelteKit** (Svelte 5, runes mode) with static adapter — pure static export,
no server
- **Vite** + **TypeScript** (strict)
- **Vitest** for unit tests, **Playwright** for e2e
- **ESLint** (incl. local plugins) + Stylelint + Prettier
- **pnpm** (required, ^11.20.0)
- **Lucide** icons, **IBM Plex** fonts (self-hosted)
## Getting Started
### Prerequisites
- [pnpm](https://pnpm.io/) v11.20+
### Install & Run
```bash
pnpm install
pnpm dev
```
### Build
```bash
pnpm build
```
Static output is written to `web/build/`.
### Commands
Run from the repository root; `pnpm --dir web …` targets the app package.
```bash
pnpm install
pnpm dev # dev server
pnpm verify # full fast quality gate — the pre-review gate
pnpm build # static production export → web/build/
```
For the full command list and the "which checks to run for which change" matrix,
see [`AGENTS.md`](AGENTS.md) and
[`docs/quality-gates.md`](docs/quality-gates.md).
## Architecture
Everything is built around the **two-layer registry** (`web/src/lib/registry/`):
a tool is a self-contained implementation `{id, schema, input, result, run}`
(`registry/tools/`), and a page is `{slug, title, description, category, steps}`
(`registry/pages/`) that names the address and the tools to run — one tool can
back several pages. Pages and forms are generated from it, and all execution
goes through a single `executor.execute` entry point.
Core image processing lives in `web/src/lib/core/` and operates directly on
`ImageData`, with no browser API dependencies.
Layering, data flow, and the recipe for adding a tool or a field kind:
[`docs/architecture.md`](docs/architecture.md).
## Roadmap
See [`docs/roadmap.md`](docs/roadmap.md) for phases and
[`docs/plan-platform.md`](docs/plan-platform.md) for the API/PWA direction. The
current focus is SEO/GEO ([`docs/plan-seo.md`](docs/plan-seo.md)) plus the
product tasks in [`docs/backlog.md`](docs/backlog.md).
## For AI agents
Read [`AGENTS.md`](AGENTS.md) first — it is the mandatory short contract.
## License
[MIT](LICENSE)
+19
View File
@@ -0,0 +1,19 @@
branch_whitelist = ["main"]
ignore_merge_commits = true
tag_prefix = "v"
pre_bump_hooks = ["node scripts/sync-version.mjs {{version}}"]
[changelog]
path = "CHANGELOG.md"
template = "remote"
remote = "github.com"
repository = "easy-png-tools"
owner = "Ku6epXBOCTuK"
[commit_types]
build = { omit_from_changelog = true }
chore = { omit_from_changelog = true }
ci = { omit_from_changelog = true }
docs = { omit_from_changelog = true }
test = { omit_from_changelog = true }
+71
View File
@@ -0,0 +1,71 @@
# Документация проекта
> Статус: **active**. Индекс документов. Обязательный краткий контракт — в
> `AGENTS.md`, подробные правила — в самих документах.
## Порядок чтения
1. `AGENTS.md` — команды, инварианты и карта «что читать под задачу».
2. Этот файл — выбрать актуальный документ по теме.
3. Соответствующий план или checklist темы.
4. Исходники, конфигурация и тесты рядом с изменяемым кодом.
## Активные документы
| Документ | Когда читать | Статус |
| ---------------------------------- | -------------------------------------------------- | ------ |
| `AGENTS.md` | Всегда: контракт агента | active |
| `docs/README.md` | Индекс документов, правила ведения документации | active |
| `docs/architecture.md` | Слои, поток данных, новый инструмент/поле/маршрут | active |
| `docs/quality-gates.md` | Какие проверки запускать, пороги, coverage, CI | active |
| `docs/decisions.md` | Правка самих гейтов; чтение CI после пуша | active |
| `docs/agent-workflow.md` | Порядок работы, нагрузка на машину, протокол ревью | active |
| `docs/testing-strategy.md` | Правка e2e: locator-ы, fixtures, browser matrix | active |
| `docs/backlog.md` | Продуктовые, UX- и инфраструктурные задачи | active |
| `docs/roadmap.md` | Направления развития, фазы 1–11 | active |
| `docs/tools-map.md` | Актуальный список инструментов против реестра | active |
| `docs/tools-audit.md` | Сырые UX/UI-находки по инструментам (вход аудита) | active |
| `docs/plan-fields-audit-fixes.md` | Разбивка аудита на волны правок | draft |
| `docs/plan-seo.md` | SEO/GEO-план | active |
| `docs/plan-platform.md` | API/PWA/шрифты-план | active |
| `docs/plan-domain.local.md` | Чек-лист переименования бренда и покупки домена | active |
| `docs/checklist-manual-testing.md` | Ручная приёмка production UI | active |
| `web/eslint-plugins/README.md` | Правка lint-правил, токенов и их конфигов | active |
| `docs/archive/README.md` | Архивирование и удаление старых документов | active |
`docs/plan-domain.local.md` **исключён из git** через `.gitignore`: выбранное
имя домена не должно попасть в историю до покупки. В клоне файла нет — это
ожидаемо, и ссылки на него в `plan-seo.md` и `backlog.md` не разрешаются. Строка
в таблице оставлена, чтобы задачу R1/R2 можно было найти по индексу; сам
чек-лист лежит только на машине разработчика.
## Правила
- У каждой темы ровно один владелец: остальные документы ссылаются на него, а не
пересказывают. Новый документ создаётся, только если темы нет в существующем.
- У документа есть статус (`draft`, `active`, `completed`, `archived`) и
понятная область применения. Ссылки на конкретный файл или команду проверяются
при изменении источника истины.
- Подробные материалы живут в `docs/`, а не в корне. Не создавать отдельные
`docs/commands.md` или `docs/known-issues.md`: команды принадлежат
`quality-gates`, известные проблемы — backlog и checklist. `README.md` в корне
остаётся кратким обзором продукта, а не вторым руководством агента.
- `docs/backlog.md` содержит только открытые задачи: выполненные удаляются, а
критичные для проекта факты из них переносятся в тематический документ.
- `docs/archive/` — временная историческая справка, не источник критериев
приёмки; срок хранения 30 дней, файлы восстановимы из git history.
Единственный документ в архиве со статусом `active` —
`docs/archive/README.md`: он описывает процедуру архивирования, а не архивный
материал, поэтому не подлежит 30-дневному удалению.
- Ссылаясь на инструмент, сверяться с реестром: внутреннее имя (`id`) — в
`web/src/lib/registry/tools/`, адрес и имя страницы (`slug`) — в
`web/src/lib/registry/pages/`; исторические названия в старых планах не
действуют (`docs/architecture.md`, раздел 3).
- Поиск по `docs/` задевает `docs/archive/`: там вынесены завершённые планы и
исследования, поэтому grep по `docs/` даёт больше шума, чем пользы. Для правок
production-кода архив не нужен: grep по `docs/*.md` без `archive/`.
- Проверка ссылок в документации не автоматизирована; ожидаемо неразрешимыми
считаются ссылки на `docs/plan-domain.local.md` (файл вне git). Всё остальное
неразрешимое — ошибка.
- Протокол «остановиться и показать изменения» — `docs/agent-workflow.md`,
раздел 6.
+92
View File
@@ -0,0 +1,92 @@
# Workflow для агентов
> Статус: **active**. Порядок исследования, изменения и проверки. Обязательные
> инварианты — в `AGENTS.md`, этот документ не заменяет source of truth
> подсистемы.
## 1. Подготовка к задаче
1. Прочитать `AGENTS.md` и выбрать документ по карте «что читать под задачу».
2. Найти релевантный активный план, backlog или checklist.
3. Проверить `git status`: не перезаписывать чужие незакоммиченные изменения.
4. Определить границу изменения: production UI, shared-модуль или инфраструктура
— `AGENTS.md`, раздел «Границы изменений». Изменение shared-кода требует
проверки production-потребителей.
5. Не добавлять исключения линтера ради отдельных модулей: осознанные
отступления живут в конфиге правила (`allowPaths`, `allowWords`,
`ignoreMissingPatterns`, `allowDefaultProject` —
`web/eslint-plugins/README.md`), а не в самих проверяемых файлах.
## 2. Порядок изменения
- Сначала исправляются тесты или контракт, который фиксирует неверное поведение,
затем production-код — если это не отдельная продуктовая задача.
- Не начинать следующий этап плана в том же шаге: изменение атомарное, ревью
идёт между шагами (раздел 6).
## 3. Проверки
Матрица «изменение → минимальный набор проверок», состав `pnpm verify`, политика
warnings и чтение CI — в `docs/quality-gates.md`. Здесь только то, что относится
к работе агента.
`lint:all` роняет gate на любых ESLint warnings (`--max-warnings=0`).
Единственное, что он пропускает, — unused-token сообщения (неблокирующие).
Поэтому «команда прошла» не равно «нечего чинить»: нужно отдельно показать число
errors, warnings и token warnings.
E2E запускается отдельно от быстрых проверок, потому что поднимает build и
статический сервер. Для UI-изменений сначала выбирается минимальный
пользовательский поток, затем при необходимости расширяется smoke.
### Нагрузка на машину
Агент работает на компьютере пользователя, поэтому тяжёлые прогоны не
запускаются «по умолчанию»:
- Playwright-прогоны запускаются только по явной команде пользователя: один
прогон за раз, без циклов повторов и без фонового запуска.
- Локально `workers: 1` (`web/playwright.config.ts`). Browser matrix проходит
медленно, зато не забирает память и не мешает работе пользователя. Скорость
проверки уступает отзывчивости компьютера.
- Нельзя запускать тяжёлый прогон параллельно с другим прогоном или в момент,
когда пользователь работает с приложением.
- Диагностика флаков идёт от меньшего к большему: один spec, один проект,
`--workers=1`, и только затем полный прогон.
- Таймауты не подбираются под конкретную машину. Если проверка падает из-за
нехватки ресурсов, это фиксируется как ограничение окружения, а не лечится
увеличением `expect.timeout` (обоснование — `docs/decisions.md`, раздел 7).
- При необходимости повторного прогона сначала спрашивается пользователь.
## 4. Работа с тестами
Политика locator-ов, запрещённые зависимости, fixtures, browser matrix и
`test.fixme` — в `docs/testing-strategy.md`. Здесь только рабочая рутина:
- Поведенческий тест проверяет пользовательский контракт, а не текущую
реализацию.
- Спецификация вызывает общие операции из `web/e2e/helpers/page.ts`, а не
повторяет raw-селекторы.
- Не фиксировать абсолютное число инструментов, если проверка не связана с этим
инвариантом.
- Новые fixtures должны быть воспроизводимыми и не требовать ручного создания
больших бинарных файлов.
## 5. Документация
Порядок работы с документами и карта источников истины — `docs/README.md`.
Архивирование — `docs/archive/README.md`.
## 6. Протокол ревью между шагами
После каждого шага плана агент:
1. останавливается и не начинает следующий этап;
2. перечисляет изменённые файлы и принятые решения;
3. указывает выполненные проверки и их фактический результат;
4. отдельно перечисляет warnings, fixme, долги и вопросы;
5. ждёт подтверждения на продолжение.
Коммит и пуш делает разработчик. Следующий шаг начинается только после ревью
текущего изменения; после пуша результат CI читается по процедуре из
`docs/decisions.md`, раздел 10.
+255
View File
@@ -0,0 +1,255 @@
# Архитектура
> Статус: **active**. Описание текущего кода: границы модулей, поток данных от
> реестра до скачивания, точки расширения. Канонический источник истины — сам
> код; этот документ объясняет, **куда смотреть и что с чем связано**, и не
> дублирует сигнатуры.
## 1. Слои
```txt
web/src/lib/
registry/ # источник истины: инструменты и страницы
registry/tools/ # Tool: реализации (id), без адресов и текстов
registry/pages/ # Page: адрес, тексты, категория, список шагов
registry-schema.ts # декларативная схема параметров + санитизация дефолтов
catalog.ts # группировка страниц и счётчики для /list-tools
categories.ts # id/названия категорий
tool-icons.ts # slug страницы → иконка
core/ # операции над ImageData; без DOM, кроме io.ts/domText.ts
executor/ # единственная точка исполнения инструментов
i18n/ # словари ru/en, t(), состояние локали
components/ # UI по слоям: ui/ примитивы, layout/ каркас,
# catalog/ каталог, schema/ view инструмента,
# fields/ поля (+ fields/schema/ контролы схемы),
# display/ типографика и карточки, feedback/ состояния
zip.ts # сборка мультифайлового результата (1 → many)
theme.svelte.ts # light/dark + localStorage
web/src/routes/
+page.svelte # главная
list-tools/ # каталог
tools/[slug]/ # страница инструмента (адрес = slug страницы)
kit/ # витрина компонентов (production, но исключена из i18n-гейта)
```
Правило зависимостей: `components/**` и `routes/**` знают про `registry/**`,
`executor/**` и `core/**`; `core/**` не знает ни о ком; `registry/**` описывает
данные и знает только `core/**` и `registry-schema.ts`. Нарушение этого порядка
— повод не заводить импорт, а инвертировать зависимость.
Исключение из «core без DOM» — `core/io.ts` (декодирование файла, кодирование,
скачивание) и `core/domText.ts` (canvas-рендер текста и эмодзи). Они трогают
`document`, `createImageBitmap` и `HTMLCanvasElement`, поэтому не работают в
node-Vitest и исключены из coverage-гейта (`docs/quality-gates.md`, раздел
Coverage). Всё остальное в `core/**` — чистые функции над `ImageData`.
### Чего в коде нет
Страница исполняет ровно один шаг: загрузил файл → применила инструмент →
скачала результат. Отсутствуют:
- **Пайплайн / цепочки шагов** — модуля нет, состояния нет, localStorage для
него нет. Модель страницы уже хранит список `steps`, но исполняет первый шаг,
а `registry.test.ts` требует, чтобы шаг был ровно один: композиция формы из
полей двух инструментов — отдельная работа, описанная как фаза 11
`docs/roadmap.md`. Под неё зарезервированы
`components/display/StepCard.svelte` (сейчас импортируется только витриной
`/kit`) и ключи `chain.*`, `errors.badPipelineShape`,
`errors.pipelineVersion`, `errors.noSteps`.
- **Разделения страниц, которые делят один инструмент** — сводит все
convert-страницы на один инструмент; задача в `docs/backlog.md`. Пока
`registry.test.ts` не требует, чтобы на инструмент ссылались две страницы, а
на практике не ссылается ни одна.
Задачи по обоим пунктам — в `docs/backlog.md`; архитектурные решения по
пайплайну — `docs/roadmap.md`.
## 2. Поток данных
```txt
registry/{tools,pages}/*.ts
│
├─► catalog.ts ──► routes/list-tools (каталог страниц, поиск, фильтры)
│
└─► routes/tools/[slug]/+page.svelte
│ getPageBySlug(slug) → Page, getTool(steps[0].id) → Tool
▼
SchemaToolView.svelte оркестратор страницы
│ schema = tool.schema, тексты и имя файла — от page
├─► SchemaFields.svelte поля из schema.fields (FIELDS: kind → контрол)
├─► SchemaSourceTile/SchemaTextSource.svelte вход
└─► SchemaPreview + schema-preview-model.ts вид превью
│ buildSchemaPreviewModel(input) → { sourceValue, resultValue, formatValue, hasResult }
▼
executor.execute(ctx) единственная точка исполнения
│ сериализация параметров → worker → worker-handler → tool.run(ctx)
▼
core/* пиксели
│
▼
SchemaResultTile / SchemaTextResult + SchemaActions (download/copy)
│ кодирование по Tool.output (mime/ext/quality)
└─► zip.ts, если result === "files"
```
Ключевые решения:
- **Реестр — единственный источник истины.** Страница и форма строятся из
записей; новый инструмент = новая запись `Tool` + функция `run` и новая
страница `Page` на него.
- **Исполнение идёт только через `executor.execute`.** Компоненты не зовут
`tool.run` напрямую — иначе нельзя перенести тяжёлое в worker/wasm без правки
вызывающего кода.
- **Форма параметров не знает про инструмент.** Она рендерится из
`registry-schema.ts`, поэтому добавление вида поля не трогает страницы.
- **Модель превью отделена от вёрстки.** `schema-preview-model.ts` — чистая
функция, покрыта unit-тестами; `SchemaPreview.svelte` только рисует её вывод.
## 3. Инструмент и страница
Реестр двухслойный: реализация и то, что видит пользователь, лежат в разных
файлах. `Tool` (`web/src/lib/registry/types.ts`, записи в
`registry/tools/<group>.ts`):
| Поле | Смысл |
| ---------- | ------------------------------------------------------------------------------ |
| `id` | внутреннее имя инструмента, уникально; ключ `tools.<id>.*` и `toolId` в worker |
| `schema` | `toolSchema({ fields, layout?, meta? })` |
| `input` | `"image" \| "text" \| "none"` — что ждёт инструмент на входе |
| `result`? | `"image" \| "text" \| "verdict" \| "files"` — что отдаёт |
| `domOnly`? | инструмент требует DOM (canvas); превью-executor запускает его напрямую |
| `output`? | mime/ext/quality для скачивания (не у всех) |
| `run(ctx)` | единственный метод: `ctx` → `ToolResult` |
`Page` (записи в `registry/pages/<group>.ts`):
| Поле | Смысл |
| ------------- | ---------------------------------------------------------------------- |
| `slug` | уникальный адрес `/tools/<slug>`, имя скачиваемого файла и ключ иконки |
| `title` | EN-строка страницы, переводится через `pages.<slug>.title` |
| `description` | EN-строка страницы, переводится через `pages.<slug>.description` |
| `category` | id из `categories.ts`; по нему каталог группирует страницы |
| `steps` | упорядоченный список шагов `{ id, params? }` — ссылки на инструменты |
Кто по чему адресуется: `slug` — маршрут, имя файла, `TOOL_ICONS`, поиск по
каталогу; `id` — ключ `tools.<id>.*` (подписи полей, `options`, `results`),
`toolId` worker-протокола и запись пайплайна. Модель допускает, что один
инструмент обслуживает несколько страниц, а страница состоит из нескольких
инструментов; на практике пока каждая страница ссылается на свой инструмент, а
исполняется первый (и единственный) шаг.
Инструмент работает с любым поддержанным форматом, поэтому png-интент живёт
только в `slug`: `id: "crop"` → страница `slug: "crop-png"` → `crop-png.png`.
Правило имени `id`: снять суффикс `-png` и в середине имени (`png-to-base64` →
`to-base64`, `X-to-png` → `from-X`, `verify-is-png` → `verify-png`). Правило
относится только к `id`: у `slug` своя история и SEO-значение, поэтому
переименование `id` не трогает адреса, а переименование `slug` меняет адрес, имя
файла, ключ `pages.*`, иконку и запись в `docs/tools-map.md`. Слаги, сложившиеся
до разделения реестра, правилу не соответствуют (`convert-png-to-jpg`,
`change-png-opacity`, `png-is-grayscale`, `verify-is-png` и другие) — это не
ошибка, менять их без SEO-решения нельзя. Форму slug проверяет
`registry.test.ts` (только форма, не смысл).
Хелперы `genTool` / `imgTool` / `textGen` из `types.ts` задают `run` для типовых
случаев (приведение типа результата), чтобы запись не повторяла обвязку. `TOOLS`
и `PAGES` собираются в `registry/index.ts` из восьми групп; `getTool(id)` и
`getPageBySlug(slug)` — способы достать запись, `registry.test.ts` проверяет
уникальность обоих имён, существование инструмента под каждым шагом и то, что
шаг ровно один.
## 4. Схема параметров
`registry-schema.ts` — единственное место, где описаны виды полей.
- **Виды поля** (`fieldSpecs`): `number`, `slider`, `color`, `select`, `text`,
`checkbox`, `dimension`, `color-pair`, `colors`, `offset`, `position9`,
`font-style`, `plate`, `gradient`.
- **Фабрика `field.*`** создаёт `Field<T>`; UI-контрол подбирается в
`SchemaFields.svelte` по `spec.kind` через карту `FIELDS`.
- **`schema.layout`** группирует поля (`resolveLayoutGroups`); раскладка
разделов — единственное место, где живёт порядок и группировка.
- **Значения по умолчанию** даёт `defaultSchemaParams`; `applySourceDefaults`
подставляет размеры текущего source; `sanitizeSchemaParams` приводит мусор к
допустимому виду. Правка поведения полей идёт здесь, а не в компоненте.
Новый вид поля требует: спека + фабрика `field.*` → запись в `fieldSpecs` →
контрол (`components/fields/schema/`) → ключ в `FIELDS` → дефолт и санитизация →
i18n-подписи → тест в `registry-schema.test.ts`.
## 5. Исполнение
`executor/executor.ts` — фабрика `createExecutor({ workerFactory? })` плюс
готовый экспорт `execute`. Контракт асинхронный (`Promise`), параметры
сериализуются по `executor/protocol.ts`, исполнение уходит в
`executor.worker.ts`; `worker-handler.ts` разбирает сообщение и зовёт `run`.
Если worker недоступен — прямой вызов. UI импортирует `execute` из
`executor/index.ts`.
Отсюда следует: подключение wasm позже означает замену реализации **внутри**
исполнителя, а не правку компонентов.
## 6. i18n
Словари — `web/src/lib/i18n/` (`en.ts`, `ru.ts`, общий `dict.ts` с
`LOCALES`/`BASE_LOCALE`, `t.ts` с `t()`, `locale.svelte.ts` с состоянием
локали). Пользовательский текст живёт только в словарях и читается через `t()`.
Списки ключей и допустимых атрибутов для i18n-правил линтера лежат не здесь, а в
`web/eslint-plugins/i18n/lists.js`. Секции разделены по слоям:
`pages.<slug>.title` и `pages.<slug>.description` — тексты страницы (в базовой
локали секция пустая, EN берётся из реестра),
`tools.<id>.options/params/results` — подписи полей и тексты вердиктов
инструмента, `fields.*`/`groups.*` — общие подписи схемы. Инварианты паритета
ключей, плейсхолдеров и запрет хардкода проверяются линтером, детали —
`web/eslint-plugins/README.md`.
## 7. Точки расширения
| Задача | Где править |
| ------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Новый инструмент | `registry/tools/<group>.ts` + страница в `registry/pages/<group>.ts` + i18n + тесты (рецепт ниже) |
| Новая страница на готовом инструменте | запись `Page` со своим `slug` и текстами; новый код не нужен |
| Смена `id` | `registry/tools/*` + ключи `tools.*` в словарях + хардкод id в тестах; адреса, имена файлов, иконки и карта не меняются |
| Смена `slug` | `registry/pages/*` + `pages.*` в словарях + `tool-icons.ts` + `docs/tools-map.md` + хардкод slug в тестах и e2e; меняется адрес страницы — операция с SEO-последствиями, не часть обычной задачи |
| Новый вид поля | `registry-schema.ts` + контрол + `FIELDS` (раздел 4) |
| Новый маршрут/страница | `web/src/routes/**` + ссылка в каталоге |
| Новый визуальный элемент | компонент в `components/**` по образцу `ui/` или `layout/` |
| Новый дизайн-токен | `web/src/app.css` (только там) + потребитель сразу |
| Тяжёлая операция → worker/wasm | `executor/**`, без правок компонентов |
| Мультифайловый результат | `result: "files"` + `zip.ts` |
## 8. Рецепт: добавить инструмент
1. **Найти соседей.** Поиск по ключу в `registry/tools/<group>.ts` и по всему
`web/src/` — не заводить дублирующую константу, тип или утилиту.
2. **Выбрать группу** (`geometry`/`alpha`/`color`/`convert`/`filters`/
`generate`/`text`/`analyze`) и положить инструмент в её экспорт; при новой
категории — сначала `categories.ts`.
3. **Задать `id`** по правилу раздела 3: без png-интента (`crop`, `to-jpg`,
`from-base64`, `is-grayscale`).
4. **Описать схему** через `field.*`, задать `layout`, если поля нужно
сгруппировать. Качество кодирования (`quality`) — не поле страницы, а
настройка скачивания (`docs/plan-seo.md` §4, S1f).
5. **Написать `run`** поверх `core/*`; пиксельные операции — чистые функции в
`core/`, без DOM.
6. **Добавить страницу** в `registry/pages/<group>.ts`: `slug` с адресным
png-интентом (`crop-png`), тексты EN, `category`, `steps: [{ id }]`.
7. **Проверить обязательные контракты**: инструмент попадает в `TOOLS`, страница
в `PAGES`, `/tools/<slug>` prerender-ится, `input`/`result` согласованы с
тем, что делает `run`.
8. **Добавить i18n-ключи во все локали** сразу: `pages.<slug>.title` и
`pages.<slug>.description`, при наличии опций и вердиктов — `tools.<id>`.
9. **Тесты:** unit — в `registry/registry.test.ts` или рядом с `core`-функцией;
e2e-smoke — существующий spec по типу `input`/`result` (правила —
`docs/testing-strategy.md`).
10. **Обновить `docs/tools-map.md`:** новая страница обязательна в разделе
«Реализовано», формат строки — в шапке раздела. Карта перечисляет **`slug`
страниц** из `registry/pages/`, то есть адреса. Проверяет
`web/src/lib/registry/tools-map-doc.test.ts`: slug без страницы и страница
без записи в карте роняют тест; разделы «Можно добавить» и «Идеи»
проверяются в обратную сторону — реализованный slug в плане тоже роняет
тест, поэтому при переезде инструмента из плана в реестр его вынимают
оттуда. Обоснование и границы автоматизации — `docs/decisions.md`, раздел 4.
Ожидаемый минимум проверок: `pnpm --dir web check` и `pnpm --dir web test`; для
инструмента, меняющего пользовательский поток, — e2e (см. раздел 3
`docs/quality-gates.md`).
+59
View File
@@ -0,0 +1,59 @@
# Архив документов
> Статус: **active**. Здесь хранятся завершённые, отменённые и явно устаревшие
> документы как временная историческая справка. Этот README — единственный
> документ архива со статусом `active`: он описывает процедуру, а не архивный
> материал, поэтому не подлежит 30-дневному удалению.
## Назначение
Архив помогает быстро посмотреть, какое решение принималось раньше и почему. Он
не является источником текущих требований, критериев приёмки или команды. Для
актуальных правил используются `AGENTS.md`, `docs/README.md` и активные планы.
Если решение снова понадобится, его можно восстановить из git history или
создать новый активный документ: **архивные файлы удаляются, а не хранятся
вечно.** Удалённые планы восстановимы через
`git log --diff-filter=D -- name-only -- docs/archive/` и
`git show <commit>^:<path>`.
## Обязательные метаданные
При переносе документа в архив добавить в начало:
```text
> Статус: archived
> Архивирован: YYYY-MM-DD
> Удалить после: YYYY-MM-DD
> Причина: краткое объяснение, почему документ больше не активен
> Использование: только историческая справка
```
Дата `Удалить после` — дата архивирования плюс 30 дней.
## Порядок архивирования
1. Убедиться, что документ действительно завершён, отменён или заменён более
актуальным источником.
2. Убрать из него ссылки на несуществующие пути и артефакты прошлых волн.
3. Добавить обязательные метаданные.
4. Переместить файл в `docs/archive/`.
5. Найти оставшиеся входящие ссылки:
`git grep -n "<filename>" -- 'docs/*.md' AGENTS.md README.md web/eslint-plugins/README.md`.
Шаг 4 ломает их, и агент без этого шага напишет в новом документе ссылку на
удалённый файл. Ожидаемо неразрешимыми остаются ссылки на
`docs/plan-domain.local.md` — он вне git, это не ошибка (`docs/README.md`,
раздел «Правила»).
6. Не менять архивный документ под новые требования.
## Удаление
Через 30 дней после архивирования:
- проверить, что документ не нужен для текущей работы;
- найти и удалить оставшиеся ссылки на него из активных документов;
- удалить файл из `docs/archive/`.
До истечения срока архивный файл не превращается обратно в live-документ: если
он снова актуален, создаётся новый документ или возвращается версия из git
истории с новым статусом и датами.
+312
View File
@@ -0,0 +1,312 @@
# Анализ: набор PNG-утилит для браузера
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский разбор, не являющийся текущим планом Использование:
> только историческая справка
Проект: **easy-png-tools** — набор PNG-утилит, работающий полностью в браузере.
Вдохновлён идеей онлайн-сервисов «все PNG-операции в одном месте»: никаких
загрузок на сервер, всё считается локально. Стек: **SvelteKit +
@sveltejs/adapter-static** (статический экспорт, никакого сервера). Все данные
обрабатываются на клиенте, ничего не уходит в сеть.
Источник идеи: типовой перечень утилит, который встречается в онлайн-наборах
PNG-инструментов (~296 операций).
---
## 1. Ключевые технологии браузера
| Задача | Средство |
| ----------------------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Декодирование изображений | `<img>` / `createImageBitmap()` / `ImageDecoder` — браузер нативно декодирует PNG, JPEG, WebP, GIF, BMP, ICO, AVIF |
| Кодирование PNG/JPEG/WebP | `canvas.toBlob()` / `toDataURL()` — нативные кодировщики |
| Пиксельная обработка | `ImageData` + `Uint8ClampedArray` (RGBA), read-only canvas |
| Чтение/анализ PNG-структуры | Своя мини-библиотека чанков (IHDR/PLTE/tRNS) или `pngjs` |
| Сжатие/оптимизация PNG на байтовом уровне | `UPNG.js` (JS) либо `pngquant`/`oxipng` (WASM) |
| GIF-анимации (fade/reveal и т.п.) | `gifenc` / `gif.js` (JS), APNG — `UPNG` |
| Кодирование GIF/BMP на байтовом уровне | `gifenc`, простой ручной BMP-энкодер |
| Тяжёлая математика без блокировки UI | Web Workers, SharedArrayBuffer, WASM (zlib, квантование, ML) |
| Экономия памяти при больших изображениях | `ImageBitmap`, работа тайлами, downscale до декорирования |
**Вывод для архитектуры:** ~90% утилит — это одна и та же связка «декодировать →
прогнать через пиксельную функцию → закодировать». Разумно сделать общий ядро
(`core/pipeline.ts` + набор операций над `ImageData`) и тонькие страницы-обёртки
в SvelteKit. Каждая утилита = отдельный маршрут со своим UI (drag&drop слева,
превью, параметры, скачивание справа).
---
## 2. Уровень EASY — canvas + ImageData, простые попиксельные операции и композиция
Реализация: загрузка в canvas → `getImageData()` → цикл по пикселям /
перекомпоновка / отрисовка на выходной canvas → `toBlob('image/png')`. Без
сложных алгоритмов, каждая утилита — часы работы.
### 2.1 Конвертация форматов и текстовые представления
- **convert-png-to-jpg / convert-jpg-to-png / convert-webp-to-png /
convert-png-to-webp / convert-svg-to-png** — рисование на canvas + `toBlob()`
нужного типа
- **convert-bmp-to-png / convert-ico-to-png / convert-gif-to-png** (первый кадр)
— нативное декодирование браузером
- **convert-png-to-bmp** — BMP-формат простой, ~30 строк энкодера
- **convert-png-to-base64 / convert-base64-to-png / data-uri (в обе стороны)** —
`FileReader.readAsDataURL` + строка
- **convert-png-to-bytes / convert-bytes-to-png / convert-png-to-hex /
convert-hex-to-png / convert-png-to-rgb-values / convert-rgb-values-to-png** —
чтение/запись `ImageData` в текст
- **convert-png-to-gif** (один кадр) — `gifenc`, тривиально
### 2.2 Прозрачность и альфа-канал
- **create-transparent-png, remove-color-from-png(простая версия),
transparent-logo-maker, remove-logo/icon-background (по цвету)** — замена
цвета на прозрачность по порогу близости
- **remove-alpha-channel-from-png, fill-alpha-channel-in-png,
replace-alpha-channel-in-png, extract-alpha-channel-from-png,
invert-png-alpha-channel** — манипуляции только с A-компонентой
- **generate-png-alpha-mask, show-transparent-png-areas,
fill-transparent-png-pixels, convert-semi-transparent-png-to-opaque** — пороги
и заливки по альфе
- **harden-png-edges** — бинаризация альфы по порогу
### 2.3 Цвет: замена, тон, каналы, цветовые пространства
- **change-png-color, change-png-color-tone, change-png-opacity,
swap-png-colors, change-png-brightness, change-png-contrast,
convert-png-to-grayscale, add-sepia-tone-to-png, change-png-hue,
invert-png-colors, reverse-png-colors, convert-png-to-two-colors,
convert-png-to-black-and-white, create-monochrome-png,
generate-single-color-png** — попиксельные арифметики/преобразования
- **extract-png-color-channels, swap-rgba-color-channels,
split-png-into-rgb-components** — перестановка/выделение RGBA-каналов
- **convert-png-to-hsl/hsv/hsi/cmyk/ycbcr/lab-colors** — известные формулы
конвертации (Lab — чуть больше математики, но детерминировано)
- **generate-\*-color-palette
(complementary/monochromatic/analogous/triadic/tetradic),
generate-similar-color-shades, color-wheel-generator, mix-multiple-colors,
calculate-average-color, simulate-paint-colors, blend-two-colors,
step-between-two-colors, convert-color-names-to-png** — генераторы
палитр/смешение без работы с изображением
- **posterize-png, create-color-spectrum-png, create-multi-color-gradient-png,
generate-png-gradient-palette** — простые градиенты/квантование уровней
### 2.4 Геометрия, композиция, холст
- **resize-png, zoom-png, crop-png, cut-png, rotate-png, skew-png, shift-png,
change-png-canvas-size, change-png-aspect-ratio, fit-png-in-rectangle,
convert-landscape/portrait-png-to-portrait/landscape-png** — операции через
`CanvasRenderingContext2D` (`drawImage`, `rotate`, `setTransform`)
- **flip-png-horizontally/vertically, mirror-png, reverse-png** —
отражение/поворот на 180°
- **center-png, center-logo, center-icon** — центровка по bounding box
альфа-канала
- **duplicate-png, clone-png, multiply-png, create-png-tile,
create-symmetric-png, split-png-into-parts** — тайлинг/копирование
- **create-round-png, create-square-png, create-star-shaped-png,
create-wavy-shaped-png, round-png-corners** — маски-«штампы» на альфе
- **add-png-border, add-padding-to-png, add-space-around-png,
remove-padding-from-png, remove-space-around-png, remove-png-padding** —
расширение/усадка холста
- **add-png-background, add-signature/logo/icon/stamp-background** — подложка
под прозрачность
### 2.5 Текст, рамки, простые эффекты
- **add-text-to-png, convert-text-to-png, create-emoji-png, add-png-watermark,
wrap-png-in-polaroid, create-logo-from-text, create-icon-from-text/emoji,
create-custom-stamp, create-\*-stamp
(round/rect/green/red/number/date-time/monogram/from-text/emoji),
create-placeholder-png, create-empty-png, create-custom-png,
generate-colorful-png, generate-random-png, generate-white-noise-png,
generate-1x1-png, draw-png-grid** — рисование примитивов/случайных данных
- **add-noise-to-png, create-glitch-png, pixelate-png, censor-png,
erase-part-of-png, randomize-png-pixels** — шум/порча байтов/блоки/кисть
- **add-shadow-to-png** — смещение + лёгкое размытие + композиция
- **add-artifacts-to-png** — простой вариант: JPEG-раундтрип через canvas
### 2.6 Анализ и проверки
- **analyze-png, find-png-file-size, find-png-dimensions,
check-if-png-is-landscape-or-portrait, find-png-color-count,
check-if-png-is-grayscale, check-if-png-is-transparent,
verify-if-image-is-png, pick-png-color, pick-logo/icon-color, png-viewer,
test-png, preview-png-on-colorful-background,
test-png-on-multiple-backgrounds** — чтение заголовков/ImageData/гистограмм,
«пипетка» по клику
- **sort-colors** — сортировка списка палитры (не изображения)
### 2.7 Семейства signature/logo/icon/stamp — простые вариации
Почти все
«-maker/-color/-resize/-crop/-rotate/-bg/-space/-padding/-grayscale/-single-color/-white/-black/-semi-transparent/-shadow»
для signature/logo/icon/stamp — это переиспользование операций из разделов выше
с одним цветом/боксом. Отдельно перечислены только те, что требуют алгоритмов
(см. MEDIUM/HARD).
> Примерный объём: **≈180–190 утилит уровня EASY.**
---
## 3. Уровень MEDIUM — нужен настоящий алгоритм (свёртки, морфология, квантование, анимация)
Реализация: честные алгоритмы на `ImageData`, часто в Web Worker, при
необходимости — обвязка вокруг готовых JS-библиотек.
### 3.1 Фильтры и свёртки
- **blur-png, sharpen-png** — box/gaussian blur, ядро свёртки (упрощённо можно
`ctx.filter`, но качественно — своя свёртка)
- **refine-png-edges, feather-png-edges, clean-png-edges** — работа с
полупрозрачными краями: размытие альфы, де-контуринг, suppression
полупрозрачности
- **add-glow-effect-to-png** — расширение альфы + размытие + цветное наложение
### 3.2 Морфология и контуры (операции над альфа-маской)
- **add-stroke-to-png, add-outline-to-png, remove-stroke-from-png,
remove-outline-from-png, find-png-outline** — dilation/erosion по альфе,
извлечение контура
- **make-signature-thicker / make-signature-thinner, make-icon-thicker /
make-icon-thinner** — dilation/erosion
- **convert-outlined-icon-to-filled-icon** — flood-fill замкнутых областей;
**convert-filled-icon-to-outlined-icon** — эрозия
- **remove-png-border, remove-border-around-icon, remove-stamp-border** — детект
однотонной/декоративной рамки и вырезание
### 3.3 Квантование и цветовая кластеризация
- **quantize-png, decrease-png-color-count, set-custom-png-color-palette,
add-dithering-to-png** — median cut / octree / Wu + Floyd–Steinberg; готовые
JS-библиотеки (image-q и т.п.)
- **change-png-quality, create-low-quality-png** — симуляция потери качества
через квантование + JPEG-раундтрип
- **convert-png-to-color-blocks** — мультипликативный блок-эффект/квантование по
блокам
- **extract-png-color-palette, find-logo-color-scheme, extract-icon-colors** —
частотная гистограмма + выбор топ-N / кластеризация
- **create-unique/popular/multi-color/outlier-color-mask** — статистика
распределения цветов и выделение по маске
### 3.4 Выделение объектов по цвету / фону
- **remove-png-chroma-key** — порог по цвету + деспилл (убрать зелёную
окантовку)
- **remove-color-from-png (качественная версия), extract-color-from-png,
separate-png-colors, separate-logo-colors, separate-icon-colors** —
сегментация по близости цвета
- **convert-png-to-silhouette, convert-png-to-stencil** — порог + заливка
силуэта
- **extract-stamp-from-image (по цвету печати)** — хромакей/частотный фильтр по
цвету чернил
- **remove-png-background (вариант без ИИ)** — проверенный цветовой подход,
знакомый по популярным онлайн-наборам PNG-утилит. Работает хорошо на
однотонном/градиентном фоне и в ряде случаев удобнее ИИ. Опции и алгоритм:
- **Background Color** — выбор удаляемого цвета: пипеткой по изображению или
hex/RGB
- **Percentage (похожесть цвета)** — slider; 0% = убрать только точный
цвет, >0% = убрать соседние оттенки в окрестности цвета (в цветовом
пространстве)
- **Delete Outer Areas (галочка)** — режим «только внешние пиксели»: удаляются
только области, связанные с краями изображения (flood-fill/region-growing от
границ). Выключена = удалять по всему холсту. Именно эта опция позволяет
убрать фон, не задевая «пятна» того же цвета внутри объекта
- **Smooth Edge Line + Radius** — постобработка: полупрозрачная полоса
толщиной N px по границе удаления (сгладить переход)
- **Preview Deleted Background** — двухцветный предпросмотр маски (что
удаляется/что остаётся)
- Реализация: выбор цвета → порог по цветовому расстоянию → (опц.) flood-fill
от краёв → сглаживание краёв → маска поверх превью. Всё на `ImageData`, без
ML.
### 3.5 Геометрические искажения и пиксель-сортировка
- **add-whirl-to-png** — свирл/вихрь: обратное отображение координат
- **sort-png-pixels** — pixel sorting (сортировка строк/столбцов по
яркости/цвету)
- **upscale-png / downscale-png** — качественный ресемплинг (Lanczos, mipmap)
поверх базового canvas-resize
- **remove-signature-background, transparent-png-signature-maker,
clean-up-signature-edges, improve-low-quality-signature, clean-logo-edges,
clean-icon-edges, fix-low-quality-stamp,
convert-rubber-stamp-to-digital-stamp** — комбинация «цвет→прозрачность +
де-шум + морфология краёв»
- **convert-image/png/jpg/icon/logo/signature-to-stamp** — стилизация в
двухцветную «печать» (порог + зернистость + края)
### 3.6 Анимации и GIF-энкодинг
- **convert-png-to-gif, slowly-reveal-png, create-disappearing-png, fade-in-png,
fade-out-png, create-scrolling-png, create-png-sequence-from-gif** —
декомпозиция кадров GIF / генерация последовательности кадров +
`gifenc`/`UPNG` (APNG)
> Примерный объём: **≈71 утилита уровня MEDIUM.**
---
## 4. Уровень HARD — WASM, ML, длительная обработка, тонкая подгонка алгоритма
Реализация: WASM-модули, ML-модели в браузере, длительная обработка с
прогресс-барами.
- **compress-png / reduce-png-file-size / optimize-png** — сжатие на уровне
формата: нужен качественный PNG-энкодер с контролем zlib-фильтров/битовой
глубины и оптимизаторы. Решение: **pngquant / oxipng / zopfli (WASM)** +
подгонка под «качество→размер» (это главный кандидат на HARD — именно здесь
тюнинг бесконечен)
- **remove-png-background (вариант с ИИ)** — опциональное улучшение к
MEDIUM-версии: ML-сегментация (Rembg/DeepLab в WASM или ONNX) для произвольных
фото, где цветовой матчинг бессилен. Начинать можно без него — не-ИИ версия
уже даёт рабочий инструмент
- **extract-signature-from-image** — детект подписи на скане/фото с произвольным
фоном: де-шум, контраст, ML-детекция
- **convert-handwritten-signature-to-digital-signature** — очистка и «оцифровка»
почерка (ML, денойзинг, выравнивание)
- **extract-barcode-from-image** — декодирование штрих-кодов/QR: готовая
библиотека **zxing-wasm**, сопряжение с камерой/фото
> Итого **6 утилит уровня HARD** (все — опциональные улучшения/надстройки). Всё
> остальное (≈290) — EASY/MEDIUM.
---
## 5. Итоговая сводка
**EASY — ~185 утилит.** canvas + ImageData. Примеры: crop, resize, rotate,
конвертации, альфа-канал, палитры, текст, рамки, анализ.
**MEDIUM — ~71 утилита.** Алгоритмы, Web Workers, gifenc. Примеры: blur,
sharpen, морфология, квантование, дизеринг, pixel sorting, анимации GIF,
remove-background (без ИИ).
**HARD — 6 утилит.** WASM (pngquant/oxipng/zxing), ML (ONNX/Rembg). Примеры:
compress/optimize, remove-background (ИИ), extract-signature, barcode.
---
## 6. Рекомендации по реализации
1. **Единое ядро обработки** — `src/lib/core/`: загрузка файла → `ImageData`,
библиотека операций (`color.ts`, `alpha.ts`, `geometry.ts`, `filters.ts`,
`morphology.ts`, `quantize.ts`, `channels.ts`), экспорт через `toBlob`.
Каждая утилита — тонкая SvelteKit-страница + общие компоненты (DropZone,
Preview, Download).
2. **Порядок внедрения:** MVP = ~40 топовых EASY (crop, resize, rotate, convert,
alpha, grayscale, brightness, палитры, watermark, add-text, add-background,
pick-color, analyze). Затем остальные EASY → MEDIUM пакетами по темам
(фильтры, морфология, квантование, анимации). `remove-png-background` (без
ИИ) — отличная «витринная» MEDIUM-фича ранних этапов: не требует ML и
переиспользует `remove-color` + flood-fill + сглаживание краёв. HARD — в
последнюю очередь.
3. **Производительность:** вся пиксельная работа и тяжёлые алгоритмы — в Web
Worker; большие изображения декодировать сразу в downscale для превью;
прогресс-бары для MEDIUM/HARD.
4. **SEO/структура:** SvelteKit + static adapter — каждая утилита отдельный
маршрут (`/crop-png`, `/rotate-png`), что даёт удобные адреса и повторяет
распространённую структуру наборов PNG-инструментов.
5. **Семейства logo/icon/stamp/signature** — переиспользуют общие операции; в
коде это конфиг-опции той же страницы (например `/resize-png` и
`/resize-icon` = один компонент с разными подписями/дефолтами). Не плодить
дублирующий код.
+647
View File
@@ -0,0 +1,647 @@
# План: линтеры, тесты и качество кодовой базы
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> Причина: все этапы Q0–Q8 выполнены или закрыты решениями; рабочие части
> перенесены в `docs/quality-gates.md`, `docs/testing-strategy.md`,
> `docs/agent-workflow.md`, `AGENTS.md`, `web/eslint-plugins/README.md` и
> `docs/backlog.md`. Использование: только историческая справка
>
> Выполнены Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3; открытых шагов не осталось.
>
> Источник: `docs/backlog.md`, аудит конфигурации `web/`, e2e-тестов и
> инфраструктурных скриптов. Q0–Q5, Q6a–Q6f.5, Q7a–Q7e и Q8.1–Q8.3 выполнены.
> Решения, важные за пределами плана, живут в `docs/quality-gates.md`,
> `docs/testing-strategy.md` и `AGENTS.md`.
>
> Цель: сделать качество кодовой базы воспроизводимым, убрать ложную «зелёность»
> проверок, стабилизировать тестовые контракты и только после этого расширять
> автоматические правила.
## 1. Границы и принципы
1. Сначала stabilизировать существующие проверки и тестовые контракты, а не
добавлять новые сложные lint-правила.
2. Старый UI удалён: production работает на корневых маршрутах, а `/v1/**` и
v1-specific код больше не являются частью приложения.
3. Не добавлять новые v1-фичи, не чинить v1-баги и не расширять v1-тесты.
4. `core/` и `theme/` — общие модули. Их изменения проверяются unit-тестами и
smoke-сценариями production-потребителей.
5. Изменения делаются небольшими атомарными частями, в соответствии с
`AGENTS.md`.
6. Каждый новый lint-rule сначала появляется в report-only/warn-only режиме,
получает fixtures и тесты, и только после очистки baseline становится
blocking.
7. Детали реализации правил остаются в `web/eslint-plugins/README.md`;
`AGENTS.md` содержит краткие обязательные политики, понятные агенту без
дополнительных поисков, и ссылки на подробные документы.
### Политика удаления legacy v1
- Q3 — отдельная атомарная cleanup-задача; поддержка v1 не возвращается.
- Старые маршруты и UI-файлы удаляются без redirects и без совместимого
maintenance-режима.
- В рабочей кодовой базе не оставлять UI-v1-пути, исключения или документацию,
которые выглядят как действующий источник совместимости.
- Исторические упоминания допустимы только в `docs/archive/`.
- Будущий API-контракт `POST /v1/process` из `docs/plan-platform.md` не связан с
удалённым UI-v1 и остаётся в планах.
## 2. Текущее состояние quality-контура
- `pnpm verify` объединяет typecheck, unit, rule-тесты, lint, форматирование,
docs и production build; deploy выполняет его перед сборкой.
- `i18n/dict-consistency` даёт 0 warnings на текущем baseline; после Q7 вычищены
4 неиспользуемых design token, token audit тоже чист.
- E2E-контракт использует semantic locators и общий source-aware schema
resolver; активных known-issue `test.fixme` нет.
- `ToolError` хранится как key/vars и переводится только при выводе; смена
локали обновляет уже видимый alert.
- E2E гоняется в четырёх Playwright-проектах: полный Chromium, Firefox и WebKit
для browser-critical спецификаций и mobile Chromium 390×844. Job остаётся
non-blocking, число воркеров ограничено из-за памяти.
## 3. Этапы выполнения
### Q0. Зафиксировать baseline и синхронизировать документацию
**Статус:** готов к ревью. **Оценка:** S. **Зависимости:** нет.
- Проверить фактические команды и зафиксировать их в quality-gate документации.
- Вынести расширенный workflow агента в `docs/agent-workflow.md`, оставив в
`AGENTS.md` только обязательные инварианты и ссылки.
- Обновить `docs/backlog.md`:
- отметить dictionary linter как реализованный, но не доведённый до
zero-warning;
- убрать противоречие по кнопке Generate;
- синхронизировать числа инструментов и тестовых сценариев.
- Восстановить или исправить ссылку на раздел G в
`docs/checklist-manual-testing.md`.
- Убрать устаревшее утверждение про hardcoded debt в `kit` из `AGENTS.md`, если
оно больше не соответствует коду.
- Перенести явно устаревшие `docs/analysis.md` и `docs/sorting-backend.md` в
`docs/archive/`, добавить даты архивирования и удаления через 30 дней.
- Создать `docs/README.md` как индекс активных документов и карту источников
истины; подробные правила не дублировать в корневых файлах.
**Готово, когда:** один документ описывает текущие команды, актуальный baseline
и все известные `test.fixme` имеют ссылки на зарегистрированный баг.
### Q1. Единый quality gate и CI
**Статус:** готов к ревью. **Проверено:** `pnpm verify` проходит; E2E оставлен
отдельным non-blocking job. **Оценка:** S–M. **Зависимости:** Q0.
Добавить read-only `verify`-команду, которая запускает:
```text
pnpm --dir web check
pnpm --dir web test
pnpm --dir web test:rules
pnpm --dir web lint:all
pnpm --dir web exec prettier --check .
pnpm check:docs
pnpm --dir web build
```
E2E оставить отдельным тяжёлым гейтом:
```text
pnpm --dir web test:e2e
```
- Не дублировать вручную аргументы ESLint/Stylelint в нескольких скриптах;
оркестратор должен вызывать package scripts или единый общий модуль.
- Описать фактические команды и semantics ошибок/warnings в
`docs/quality-gates.md`; здесь нужна точная процедура, а не длинный дубль в
`AGENTS.md`.
- На первом этапе явно разделить `errors` и `warnings`.
- После очистки i18n baseline добавить строгий режим `--max-warnings=0`.
- Добавить отдельный workflow для pull request: typecheck, unit, rule-тесты,
lint, format и build.
- E2E сначала запускать отдельным job; сделать его blocking после закрытия
известных ошибок и стабилизации baseline. Условия перехода и текущая политика
описаны в `docs/quality-gates.md`, раздел «Политика e2e в CI».
**Готово, когда:** одна команда воспроизводит все быстрые проверки, а deploy не
может пройти при type/unit/lint/build-ошибке.
### Q2. Политика i18n и zero-warning baseline
**Статус:** готов к ревью. **Проверено:** i18n lint даёт 0 warnings, rule-тесты
и полный `pnpm verify` проходят. **Оценка:** M. **Зависимости:** Q0, Q1.
- `BASE_LOCALE` — единственный источник registry-English fallback для
`tools.*.title` и `tools.*.description`; остальные локали требуют физические
ключи.
- Временно неиспользуемые `tools.*.params` исключены pattern-based policy до
отдельной очистки мёртвого словаря; пустые значения и placeholders не
подавляются.
- Исправлены три реальных RU option-label gaps и удалены неиспользуемые
option-группы из `ru.ts`.
- Исправлен tie-break placeholder parity: при равенстве голосов выбирается
`BASE_LOCALE`.
- `lint-all` запускает ESLint с `--max-warnings=0`; unused-token сообщение
оставалось неблокирующим и закрыто в Q7.
- Plural-формы остаются отдельной задачей после Q2 и не смешиваются с этим
этапом.
### Q3. Удалить UI-v1 (cleanup завершён)
**Статус:** выполнен. **Проверено:** `pnpm verify` и production E2E smoke
проходят; после проверки обновлены конфиги и активные документы. **Оценка:** M.
**Зависимости:** Q0, Q1, Q2.
- Удалены `web/src/routes/v1/**`, `web/src/lib/v1/**`, `app_v1.css`, v1-specific
i18n/tests и fixtures.
- Удалены isolation plugin, его тест и все v1-исключения из lint-конфигураций.
- Убраны v1-ссылки из production-кода и активных документов; исторические
упоминания остаются в `docs/archive/`.
- Проверены production build, verify и отдельный E2E smoke production-маршрутов.
**Результат:** `/v1/**`, UI-v1-файлы и legacy-конфигурация отсутствуют;
следующий этап — Q5.
### Q4. Тестовый контракт e2e
**Статус:** выполнен. **Проверено:** `pnpm verify` и полный Playwright smoke
проходят; 105 тестов passed, 4 known-issue теста skipped. **Оценка:** M.
**Зависимости:** Q0–Q3.
- Добавлен `docs/testing-strategy.md` с locator, fixture, i18n и `test.fixme`
policy.
- В production markup добавлены только разрешённые hooks для source/result/
verdict/empty-state; текстовые элементы получили accessible labels, фильтры —
`aria-pressed`, а каталог — семантические heading/status landmarks.
- E2E переведён на `role`, label и stable `href`; удалены `TOTAL`/`GROUPS`,
exact fuzzy counts, internal CSS selectors, `alt`-locators и полные verdict
strings. Точные verdict-переводы перенесены в unit-тесты.
- Helpers централизованы в `web/e2e/helpers/page.ts`; исправлены stale
theme/source/locale checks. Theme fixme снят.
- Q5-фиксы resize/crop, generator, Reset и error-сценария не смешивались с этим
этапом.
**Результат:** изменение формулировки, класса или порядка элементов не ломает
поведенческий тест без изменения пользовательского контракта; следующий этап —
Q6.
### Q5. Закрыть известные preview-баги
**Статус:** выполнен. **Проверено:** unit, Q5 E2E smoke и полный `pnpm verify`
проходят; активных known-issue `test.fixme` нет. **Оценка:** M–L.
**Зависимости:** Q4.
- Добавлен общий opt-in `defaultFromSource` resolver в `registry-schema`: он
принимает source dimensions, обновляет только source-aware поля при upload и
используется в UI, executor и worker. `resize-png` и `crop-png` получают
размеры текущего source; static/generator/text schemas не затронуты.
- Reset сохраняет source/text, восстанавливает source-aware defaults и запускает
deferred auto-run через debounce.
- Генераторы проверены в auto-run без кнопки Generate; кнопка не добавляется.
- `ToolError` хранится как key/vars, переводится derived-значением при выводе и
обновляется при смене локали. E2E использует независимый invalid pixel-count
сценарий.
- Resize/crop, generator, Reset и localization fixme сняты; Q6a расширяет
покрытие, не смешиваясь с исправлением этих контрактов.
**Результат:** Q5 known issues закрыты и отражены в `docs/backlog.md` и
`docs/checklist-manual-testing.md`; Q6a выполнен, следующий этап — Q6b.
### Q6. Расширение unit и browser-покрытия
**Оценка:** M–L. **Зависимости:** Q4, Q5.
#### Q6a. Executor protocol, fallback и debounce
**Статус:** выполнен. Unit-тесты покрывают debounce, worker image/string/
verdict/files protocol, `ToolError` transport, constructor/postMessage/onerror/
malformed fallback, source-aware sanitization и DOM metadata. Browser smoke
проверяет rapid auto-run и no-worker fallback; deferred transport fallback
покрыт unit-тестами.
- `execute(tool, ctx)` сохраняет публичный API; `createExecutor` и protocol
handler являются внутренними test seams.
- `ToolError` не запускает direct retry повторно; transport/protocol failures
отключают worker и переходят в direct fallback.
- DOM-зависимые registry entries помечены `domOnly`, поэтому зелёный smoke не
скрывает нормальный worker fallback.
- `debounce` покрыт fake timers; E2E подтверждает последние значения при быстрых
последовательных изменениях генератора.
#### Q6b. FileResult → ZIP → download
**Статус:** выполнен. Q6a закрыл worker-транспорт; Q6b добавил проверяемый ZIP
seam, unit round-trip для `FileResult`, защиту пустого результата и error/busy
контракт download. Отдельный browser-сценарий скачивает архив и проверяет имена
частей и PNG-сигнатуры.
- `buildZipEntries` не зависит от DOM; `downloadZip` остаётся UI-обёрткой.
- `SchemaToolView` переиспользует `downloadBlob`, ловит ошибки кодирования и
блокирует повторный download на время сборки.
- E2E использует `downloadResultFile` и `unzipSync`, не добавляя новые
`data-testid`; files-инструменты не проходят через image-smoke.
#### Q6c. Output MIME/quality
**Статус:** выполнен. Q6c покрывает текущий фиксированный output-контракт
JPG/WebP/BMP, не превращаясь в UX-райз кнопки Download. Registry проверяет
согласованность MIME/extension/quality-поля, unit — границы quality, browser —
реальные сигнатуры файлов и разницу размера при изменении quality.
- `ToolEntry.output` остаётся декларативным описанием формата; новый селектор
форматов не добавляется.
- `validateOutputQuality` отделяет проверку 0..1 от DOM-рендеринга; JPEG/WebP
используют quality `1..100` из schema, BMP не имеет quality-параметра.
- E2E проверяет JPEG `FF D8 FF`, WebP `RIFF/WEBP`, BMP `BM` и PNG-фолбэк.
- Ошибки кодирования и unsupported MIME используют существующие i18n-ключи.
#### Q6d. Malformed и special PNG fixtures
**Статус:** выполнен. Runtime-fixtures покрывают CRC, truncated, palette и
16-bit без бинарных файлов в git. E2E фиксирует браузерный контракт: critical
CRC/truncated отклоняются без падения, ancillary CRC и хвост после `IEND`
допускаются, palette/16-bit декодируются. Unit фиксирует контракт анализаторов
для RGBA-данных, которые браузер отдаёт после нормализации.
- `web/e2e/helpers/fixtures.ts` собирает indexed, 16-bit, CRC-mutation,
truncation и tail-варианты из уже существующих `crc32`/`chunk`.
- Отклонение битых файлов — свойство декодера движка, а не единый продуктовый
контракт: Q6f.3 зафиксировал, что Firefox и WebKit декодируют `idat-cut` и
`truncated-no-iend`, а WebKit ещё и `crc-bad-idat`. Это ожидаемое поведение,
которое закреплено per-engine в `web/e2e/png-fixtures.spec.ts`.
- Обязательное требование при любом исходе: файл не роняет страницу, ошибка
показывается alert-ом, а не «пустым» результатом.
- Поведение `createImageBitmap` является частью browser-контракта, а не
самостоятельного PNG-парсера product-кода.
- Сырое сообщение браузера при decode-ошибке не локализуется в Q6d; это
отдельная задача в backlog.
#### Q6e. Registry-wide smoke
**Статус:** выполнен. Smoke расширен до всех 122 production tools из registry:
image→image, image→text, text→image, generator input→image и verdict. FileResult
и malformed/special PNG не дублируются — они уже покрыты Q6b/Q6d.
- `tools-smoke.spec.ts` расширен image/text-output кейсами, включая все
незакрытые color/geometry инструменты.
- `generators.spec.ts` использует единый data-harness для всех генераторов;
`single-color-png` сохраняет отдельную проверку auto-run/размеров.
- Text-input сценарии используют общий `renderText` helper, включая
`data-uri-to-png`.
- Component-тесты не добавлены: Vitest работает в node без DOM/Testing Library,
а текущий e2e-контракт уже покрывает поведение preview. Выбор test seam или
DOM-окружения перенесён в Q6f.
#### Q6f.1. Layout resolver seam
**Статус:** выполнен. Чистая логика группировки полей вынесена из
`SchemaFields.svelte` в `resolveLayoutGroups` и покрыта unit-тестами на
дедупликацию, неизвестные поля, пустые группы, `cols` и default-хвост.
- Компонент использует общий resolver, поэтому unit-тесты блокируют изменения
layout-логики без DOM/Testing Library.
- `SchemaPreview` view-model и DOM-компоненты не менялись; их seam остаётся
отдельным решением Q6f.2.
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
#### Q6f.2. SchemaPreview model seam
**Статус:** выполнен. Вычисления `sourceValue`, `resultValue`, `formatValue` и
`hasResult` вынесены в `buildSchemaPreviewModel`; Svelte-компонент использует
модель, а unit-тесты проверяют все input/result-ветки без DOM.
- Translator передаётся в модель явно, поэтому unit-тесты не зависят от locale
state и проверяют только view-model.
- image/text/verdict/files и пустые результаты покрыты в `pnpm verify`.
- Новые зависимости, vitest-конфиг и browser matrix не добавлялись.
#### Q6f.3. Browser matrix и mobile viewport
**Статус:** выполнен. Playwright запускает четыре проекта: полный Chromium,
Firefox и WebKit для browser-critical спецификаций и mobile Chromium 390×844 с
`hasTouch` для навигации, каталога, генераторов и pipeline.
- `web/playwright.config.ts`: `browserContractTests` — `pipeline.spec.ts`,
`png-fixtures.spec.ts`, `text-and-verdicts.spec.ts`; `mobileTests` —
`navigation.spec.ts`, `catalog.spec.ts`, `generators.spec.ts`,
`pipeline.spec.ts`.
- `workers` — 1 локально и 2 в CI. Browser matrix ограничена памятью: при 12
воркерах на машине с 16 GB RAM браузеры не успевают поднять worker, и тесты
падают без ошибок (проверено и в Chromium). Локально matrix поэтому медленная,
но почти не нагружает компьютер. Per-project лимита воркеров в Playwright нет.
- Per-project `expect.timeout` не задаётся: Firefox и WebKit отдают результат
так же быстро, а расхождения объясняются памятью, а не скоростью движка.
- Декодеры движков расходятся, и это зафиксировано как ожидаемое поведение:
`png-fixtures.spec.ts` хранит список движков, отклоняющих каждый битый файл, и
не пропускает проверки. В любом браузере файл не должен ронять страницу, а
отказ декодера показывается alert-ом.
- CI устанавливает `chromium firefox webkit`; job остаётся non-blocking.
- Локальный прогон: 276 passed, 0 skipped, 0 failed. Visual snapshots и coverage
thresholds не добавлялись — это Q6f.4.
#### Q6f.4. Coverage thresholds
**Статус:** выполнен для чистой логики. Vitest считает покрытие только по
`src/lib`, с явным списком исключений, и `pnpm verify` падает при падении ниже
порога.
- `web/vitest.config.ts`: provider `v8`, `reporter: text-summary`, пороги
statements 90, branches 82, functions 72, lines 91.
- Пороги поставлены на 1.5–2.5 п.п. ниже измеренного baseline (91.77 / 83.3 /
74.31 / 92.53), поэтому gate ловит регрессию, а не текущий долг.
- Scope — логика, которую node-Vitest вообще может исполнить. Исключены
браузерные модули `core/domText.ts` и `core/io.ts` (canvas,
`createImageBitmap`), data-модули `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts`,
rune-файлы `*.svelte.ts`, entry worker-а и type-only `components/define.ts`.
Причина каждого исключения зафиксирована в конфиге и в
`docs/testing-strategy.md`.
- `registry/*` (47–81% statements, 25–47% functions) в scope остаётся: это
обычная непокрытая логика, а не ограничение окружения. Покрывается e2e-smoke
Q6e, но unit-порог её не скрывает.
- `verify` теперь выполняет `test:coverage` вместо `test`: набор тестов тот же,
двойного прогона нет.
- Порог functions самый слабый (74.31% против 91.77%): добавление инструмента с
непокрытым `run` быстрее всего опускает именно его. Это ожидаемо, порог держит
запас.
- DOM-тесты `SchemaToolView`/controls сознательно не начаты: node-Vitest не
имеет DOM, а e2e уже покрывает поведение. Задача живёт в `docs/backlog.md`.
Не применять один одинаковый smoke-сценарий к image-, text-, generator- и
file-result-инструментам.
#### Q6f.5. Visual snapshots
**Статус:** решено не делать. Обоснование, чем закрыта потребность сейчас и при
каком условии стоит вернуться — в `docs/testing-strategy.md`, раздел 8.
### Q7. Укрепление lint-инфраструктуры
**Оценка:** M. **Зависимости:** Q1, Q2, Q3.
#### Q7a. Очистка неиспользуемых design tokens
**Статус:** выполнен. Token audit чист: `All app.css tokens are used somewhere`.
- Удалены `--color-text-accent-muted` (light и dark, иначе ломается parity),
`--space-text-s: 0`, `--size-content-max: 880px` и `--duration-m: 0.3s`.
- Проверено, что мёртвые токены — это пыль, а не признак хардкода: дублирующих
значений (`max-width`, `0.3s`, muted-цвет) в `src` нет. Потребители
`space-text-m/xl/2xl`, `size-content-max-wide` и `size-workspace-min`
используются, поэтому шкалы не тронуты.
- Шкалы остались неполными намеренно (`duration-s/l` без `m`, `space-text` без
`s`): мёртвый элемент шкалы хуже, чем «дырявая» шкала, а вернуть его можно
вместе с потребителем.
- Режим unused-аудита решён в Q7d: он остаётся предупреждением.
#### Q7b. Тесты token audit и PostCSS HCT-плагина
**Статус:** выполнен. У обоих не было ни одного теста, хотя HCT-плагин
(`web/scripts/postcss-hct.mjs`, 430 строк) пересчитывает всю палитру.
- `web/scripts/__tests__/postcss-hct.test.mjs` — 17 тестов: literal и
производный `hct()`, seed из `:root` и из hex, цепочка hct-токенов, refs
`h/c/t`, `calc/min/max/clamp`, обёртка hue, клампы tone/chroma, несколько
вызовов в одном объявлении, а также warn-and-keep для неизвестного seed,
циклической ссылки и битого тела `hct()`.
- `web/scripts/__tests__/token-audit.test.mjs` — 15 тестов на parity
(light-only, dark-only, derived, не-цвет), authorship (hct, color-mix,
seed-токены, хардкод с локацией, игнор не-цветов и обычных свойств), unused и
предикаты `isColorValue`/`isDerived`.
- Ожидания не зашивают вывод плагина, а сравнивают его с эквивалентной
литеральной записью: `hct(from var(--seed) h c calc(t + 10))` должен совпасть
с `hct(120 40 60)`, если seed — `hct(120 40 50)`. Иначе тест просто
зафиксировал бы текущее поведение.
- Единственный seam: `checkUnused(root, used = collectUsedTokens())` —
зависимость от чтения `src` стала инъецируемой, production-вызов не изменился.
- `vitest.config.ts` подхватывает `scripts/**/*.test.mjs`, поэтому скрипты
покрываются обычным прогоном и `verify`; `test:rules` не трогали.
- Проверка мутацией выявила дыру: первая версия тестов не ловила кламп tone в
ветке `from` (смена `clamp(t, 0, 100)` на `0, 90` проходила зелёной). Добавлен
отдельный тест на производный кламп; после этого мутация падает.
#### Q7c. Базовый JS recommended для инфраструктуры линтинга
**Статус:** выполнен. `eslint-plugins/**` и `scripts/**` больше не вне линта.
- `web/eslint.config.js`:
`toolingFiles = ["**/eslint-plugins/**/*.js", "**/scripts/**/*.mjs"]` получают
`js.configs.recommended` плюс `languageOptions` с `globals.node` (иначе
`no-undef` ругается на `console`, `process` и `URL` в Node-скриптах).
- Полные TS/svelte-наборы туда не подключаются: это `.mjs` и конфиги, а не
продуктовый код.
- Масштаб оказался небольшим: 28 проблем, из них 26 — `no-undef` на
Node-глобалах и только 2 настоящие: мёртвые импорты `existsSync` и `resolve` в
`scripts/serve-static.mjs`. Линтер сразу нашёл пыль в инфраструктуре, для
которой его раньше не было.
- `eslint-plugins/**` дал ноль проблем, а новые тесты `scripts/__tests__` тоже
попали под правило.
- Проверено пробой: после включения неиспользуемая переменная и вызов
неопределённого глобала в `scripts/` дают ошибки, то есть правило применилось,
а не молча пропустило файлы.
#### Q7d. Решения по оставшимся вопросам
**Статус:** закрыт тремя решениями.
1. **Обычный CSS под те же токен-правила, что Svelte styles — узко.** В `src`
всего один CSS-файл, и он же словарь токенов, поэтому переносить туда
`no-hardcoded-in-svelte` и подобные нечего: stylelint уже проверяет
`src/**/*.css` (префиксы, запрет `!important`, цвет только у brand-токенов),
а `check-tokens.mjs` дублирует проверку цвета. Реальный пробел был один —
`var()` может ссылаться на несуществующий токен, — его закрыл новый модуль
`web/scripts/token-audit/refs.mjs` (см. Q7e). Широкую унификацию механизмов
решено не делать: это лишний слой поверх дублирующихся проверок.
2. **Конфиги под рекомендации — только JS.** `*.config.js`
(`eslint`/`postcss`/`stylelint`) добавлены в `toolingFiles`; по измерению они
дают 0 проблем, поэтому это бесплатно. TS-конфиги (`vite`, `vitest`,
`playwright`) не подключены: им потребовался бы `tseslint`-парсер и запись
`vite.config.ts` в `allowDefaultProject`, а выигрыш близок к нулю. Попутно
удалена мёртвая запись `svelte.config.js` в `allowDefaultProject` — такого
файла нет.
3. **Unused-аудит остаётся предупреждением.** Правило «новый токен — вместе с
потребителем» закреплено в `AGENTS.md`. Причина: `verify` входит в
deploy-workflow, поэтому стилевой долг блокировал бы деплой сайта; кроме
того, чтение токена из JS (`getPropertyValue`) аудит не видит и дал бы ложное
срабатывание. Baseline чистый, пыль вычищена в Q7a.
#### Q7e. Проверка резолва `var()` в `app.css`
**Статус:** выполнен. Аудит падает, если `var(--x)` в `app.css` указывает на
токен, которого нет в этом же файле.
- `web/scripts/token-audit/refs.mjs` + вызов в `check-tokens.mjs`; режим —
ошибка, а не предупреждение, потому что это дефект (объявление молча
отбрасывается), а не стилистический долг.
- `var(--x, fallback)` не считается ошибкой: fallback легален без определения
токена.
- 7 тестов в `scripts/__tests__/token-audit.test.mjs`: резолв в обеих темах,
опечатка с локацией, fallback, дедуп повторов, ссылка внутри определения
токена, `var()` в обычном свойстве.
- Проверено на живом файле: временная опечатка `var(--color-bordr)` дала
`Unresolved var() references` и код выхода 1; `app.css` восстановлен байт в
байт.
### Q8. Новые автоматические правила
**Оценка:** L. **Зависимости:** Q4, Q7. **Разбивка:** три атомарных шага, каждый
заканчивается либо работающим warn-правилом с нулевым baseline, либо измерением
с записью вывода. Механически важно: `lint-all` запускает ESLint с
`--max-warnings=0`, поэтому warn-правило обязано приземлиться без срабатываний
на текущем коде.
#### Q8.1. Пользовательские тексты вне i18n
**Статус:** выполнен. Пилот `i18n/no-hardcoded-user-text` (warn) с нулевым
baseline; 2 реальных нарушения исправлены.
- Измерение до правила: 42 строковых атрибута пользовательского текста, все — в
`/kit`; вне кита текстовые узлы дали 2 находки. Итог после исключений: 2
настоящих бага и 2 бренда, которые план требовал исключить.
- `web/eslint-plugins/i18n/no-hardcoded-user-text.js`: узлы `SvelteText` плюс
строковые литералы пользовательских атрибутов; `<style>`/`<script>`
пропускаются.
- Исключения из плана реализованы списками, а не эвристиками: форматы/единицы/
имена файлов/версии/размеры, ALL CAPS идентификаторы (registry source
strings), `/kit` через scope, бренд через опцию `allowWords` (в конфиге, а не
в коде правила).
- Исправлены 2 бага: `ColorPairControl.svelte` показывал английские `From`/`To`.
Ключи `ui.from`/`ui.to` уже существовали в обоих словарях — компонент их
просто не использовал, поэтому новые ключи не понадобились.
- 30 тестов в `__tests__/no-hardcoded-user-text.test.ts`; мутацией проверено,
что правило не декоративное: с пустым `allowWords` бренд начинает репортиться.
- Известные слепые зоны, зафиксированные как границы пилота: текст, собранный в
скрипте и переданный выражением, и текст вне Svelte-шаблонов (например, в
атрибутах, приходящих из registry). Расширение поверхности — отдельная работа,
а не молчаливое расширение правила.
#### Q8.2. Дублирование HTML/Svelte-разметки
**Статус:** измерено, решение — правило не делать.
Одноразовый аудит (скрипт после измерения удалён) по 65 `.svelte` в
`components/**` и `routes/**`: при пороге 5 нормализованных строк — **одна**
пара, при пороге 3 строки — 17 пар, и все они шум (`</label>`, `{/each}`,
`{#if children}`). Единственная осмысленная находка — побайтово идентичные
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte`; она заведена в
`docs/backlog.md` (идея 16).
Почему автоматика отменена:
- построчное сравнение Svelte-разметки принципиально ненадёжно: одинаковый
контейнер с разным содержимым даёт ложное срабатывание, а намеренно общий
класс — ложное отсутствие сигнала;
- чтобы не ловить шум, порог пришлось бы поднять до 12+ строк, и тогда правило
срабатывает ровно один раз — на паре страниц, которые по замыслу должны
различаться; дальше нужна либо allowlist-запись, либо принудительный
рефакторинг;
- контроль остаётся на ревью: расхождение видно в диффе, а поведение ловит e2e
(`navigation.spec.ts` открывает все маршруты, `catalog.spec.ts` проверяет
каталог), а извлечение общей разметки уже закреплено конвенцией в `AGENTS.md`.
#### Q8.3. Согласованность комментариев
**Статус:** решение — автоматический lint не делать, это process policy.
Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` из кода перенесены в `docs/backlog.md` (7 записей), в коде не
осталось ни одного незарегистрированного. Правило-детектор не добавлялось.
## 4. Что описать в `AGENTS.md`
В `AGENTS.md` добавить только следующие неочевидные правила:
1. Какой набор команд считается полным quality gate и что означает успешный
результат при warnings.
2. Production-scope и политику удаления v1: новые изменения относятся только к
production UI; исторические UI-v1-упоминания остаются в архиве.
3. Политику e2e: semantic locators, допустимый `data-testid`, запрет
бессмысленных counts/classes/alt/exact prose.
4. Политику i18n: новые пользовательские тексты через i18n; registry source,
brand, formats и `/kit` — явные исключения.
5. Жизненный цикл `test.fixme`: только для зарегистрированного бага, с ссылкой;
исправление и удаление теста происходят вместе.
6. Комментарии и `TODO`/`FIXME` должны объяснять «почему» и иметь ссылку на
работу.
7. Shared-модули `core`/`theme` требуют проверки production-потребителей.
Синтаксические списки ESLint-правил и подробные токены остаются в
`web/eslint-plugins/README.md`.
### Политика документации для агентов
`AGENTS.md` не должен превращаться в полный справочник проекта. В нём оставляем
только обязательный рабочий контракт: команды, production-scope, ключевые
инварианты, исключения и ссылки на подробные документы. Если агенту нужно понять
причину или workflow, в `AGENTS.md` должна быть короткая понятная формулировка;
длинные примеры, точные процедуры, таблицы и критерии проверки переносятся в
`docs/` или в README рядом с соответствующей подсистемой.
Принцип выбора места документа:
1. **Поведение агента, которое нельзя пропустить** — кратко в `AGENTS.md`.
2. **Точная команда, скрипт, параметр или критерий качества** — в
`docs/quality-gates.md` или в README соответствующей подсистемы.
3. **Архитектурное решение, поток данных и границы модулей** — в
`docs/architecture-overview.md` или существующем плане.
4. **Реализация конкретного lint-правила и его fixtures** — только в
`web/eslint-plugins/README.md` и исходниках правила.
5. **Продуктовая задача, backlog и ручная приёмка** — в `docs/backlog.md`,
активном плане или `docs/checklist-manual-testing.md`.
Подробные документы не дублируются в нескольких местах: у документа должен быть
один источник истины, а остальные файлы ссылаются на него. Все подробные
материалы хранятся в `docs/`, чтобы не засорять корень репозитория.
Активный документ должен иметь статус (`draft`, `active`, `completed` или
`archived`) и понятную область применения. Архивные планы не используются как
текущие критерии приёмки без отдельной сверки с кодом. Новый документ создаётся
только когда темы нет в существующем файле; иначе обновляется существующий.
### Архивирование и срок хранения
Завершённые, отменённые или явно устаревшие документы переносятся в
`docs/archive/`, а не остаются в корне `docs/` как рабочие планы. При переносе
добавляются дата архивирования, дата удаления и причина. Архивный документ —
временная справка для быстрого просмотра прошлых решений, но не источник текущих
требований.
Срок хранения после архивирования — 30 дней. После этой даты документ можно
удалить после проверки ссылок; если он снова нужен, его восстанавливают из git
history или пересоздают как новый активный документ. Подробная процедура и
шаблон метаданных находятся в `docs/archive/README.md`.
### Какие документы подготовить
| Документ | Назначение | Когда создавать/обновлять |
| ------------------------------- | --------------------------------------------------------------------------------------- | --------------------------------------------------------------------- |
| `docs/README.md` | Индекс активных документов, карта источников истины и краткий порядок чтения для агента | Q0; при добавлении нового активного документа |
| `docs/agent-workflow.md` | Подробный workflow: исследование, изменение, проверка и обновление документации | Q0–Q1; только для процессов, не описанных кратко в `AGENTS.md` |
| `docs/quality-gates.md` | Команды проверки, semantics errors/warnings, порядок запуска и требования CI | Q1; при изменении scripts/config/CI |
| `docs/testing-strategy.md` | Слои тестов, locator policy, i18n assertions, fixtures, `test.fixme` и правила e2e | Q4; при изменении тестовой архитектуры |
| `docs/architecture-overview.md` | Карта `core`, registry, executor, production UI и shared-модулей | Q5 или перед крупным рефакторингом; не дублировать существующие планы |
| `web/eslint-plugins/README.md` | Детали custom lint rules, token policy и fixtures | Уже существует; менять только вместе с правилами |
| `docs/archive/README.md` | Политика временного архива, даты и удаления через 30 дней | Q0; обновлять при изменении архивной политики |
Не создавать отдельные `docs/commands.md`, `docs/known-issues.md` или копию
backlog: команды принадлежат `quality-gates`, известные проблемы — backlog и
checklist. `README.md` в корне остаётся кратким обзором продукта, а не вторым
руководством агента.
## 5. Порядок PR
1. **Q0** — синхронизация backlog/checklist/AGENTS.
2. **Q1** — `verify` и PR quality gate.
3. **Q2** — i18n parity и strict warning gate.
4. **Q3** — удалить v1 отдельной атомарной задачей.
5. **Q4** — тестовый контракт и stale-тесты для production UI.
6. **Q5** — preview-баги и снятие `fixme`.
7. **Q6** — расширенное покрытие.
8. **Q7** — тесты lint-инфраструктуры и unused tokens.
9. **Q8** — report-only duplicate/text audits; blocking — только после baseline.
Каждый PR должен быть атомарным и небольшим; не объединять `SchemaPreview`
refactor, новые i18n-правила и исправление продуктовых багов в одну работу.
Продуктовые направления, которые план не начинает (платформа PWA/API/WASM,
plural-формы, batch/архивы, region-инструменты), живут в `docs/backlog.md` и
`docs/roadmap.md`; границы этого плана — в разделе 1.
+335
View File
@@ -0,0 +1,335 @@
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> исторический план i18n с уже завершённым UI-v1; plural-формы перенесены в
> backlog. Использование: только историческая справка
# План: i18n в preview (новый UI)
> Статус: **частично выполнен** — базовая i18n-инфраструктура нового UI,
> локализация ошибок, `i18n/dict-consistency` и zero-warning baseline Q2
> реализованы; plural-формы остаются отдельной задачей. Актуальные решения и
> ограничения — в `docs/testing-strategy.md` и `docs/backlog.md`.
## 1. Что получается
Новый UI (`web/src/routes/**` кроме `v1`, `web/src/lib/components/**` кроме
`v1`) использует модуль `lib/i18n`: переключатель RU/EN в шапке работает по
локали, строки и ошибки локализованы. Остаточные задачи — довести паритет
словарей до zero-warning и спроектировать plural-формы.
До начала локализации **v1 изолируется**: у v1 появляется полная замороженная
копия `lib/i18n` → `lib/v1/i18n`, у нового UI — своя независимая ветка
`lib/i18n`, которую дальше меняем без оглядки на v1 (Фаза 0). Переключённая
локаль (localStorage) остаётся общей для обеих версий — язык переживает переход
между страницами.
## 2. Текущее состояние (исследование)
### Уже есть
- `lib/i18n/locale.svelte.ts` — состояние локали (runes), персист в
localStorage, обновление `document.documentElement.lang`.
- `lib/i18n/t.ts` — перевод по точечному ключу с фолбэком на базовую локаль и
интерполяцией `{var}`.
- `lib/i18n/dict.ts` — тип `Dict`, `BASE_LOCALE = "en"` (база — английский, ru —
перевод), `LOCALES = ["ru", "en"]`.
- `lib/i18n/ru.ts` (~1351 строка) и `en.ts` (188 строк) — полные переводы
инструментов v1 по id (title/description/params/options/results).
- `lib/i18n/tool-strings.ts` — хелперы для v1
(`toolTitle/toolDescription/ paramLabel/optionLabel/searchDoc`).
- `lib/i18n/matching.ts` — кросс-языковой поиск (ё→е, диакритика, скоринг).
- Тесты: `i18n.test.ts`, `tools-i18n.test.ts`, `matching.test.ts`,
`smoke-i18n.test.ts`, `search-coverage.test.ts`.
- `+layout.svelte` уже вызывает `initLocale()` на клиенте.
### Пробелы в новом UI
- `LangToggle.svelte` — заглушка с локальным `let lang = $state("RU")`, не
связана с `locale.svelte.ts`.
- Новый UI почти целиком на хардкод-английском:
- ~150+ строк по ~30 компонентам и маршрутам (заголовки, кнопки, аria,
плейсхолдеры, подписи секций);
- 122 инструмента с английскими `title`/`description` в `lib/registry/*.ts`;
- ~60+ подписей опций select;
- ~30 заголовков layout-групп (`title: "Canvas"`, `"Background"`, …);
- вердикты анализаторов — raw English строки (`"Yes — valid PNG signature."`),
при том что v1 возвращает ключи словаря (`"verifyYes"`) и рендерит их через
`tools[id].results`;
- `labelOf(id)` в `SchemaFields.svelte` генерирует подписи полей из field ID
(keepAspect → "Keep Aspect");
- категории в `catalog.ts` (`GROUP_LABELS`) — English uppercase, хотя в `dict`
уже есть ключи `categories.X`.
- Из всех новых компонентов `t()` вызывает только `SchemaToolView.svelte`, и
только для ошибок (`ToolError.key`).
### Совпадение регистров
- Новый registry и старый v1 registry используют **одинаковые id** инструментов
(resize-png, flip-png, …) — переводы title/description из v1 `ru.ts` служат
исходником для новой ветки `lib/i18n/ru.ts` (копирование данных при наполнении
Фазы 2, не общий модуль).
- **Расхождение**: param ids в ru.ts (`width`/`height`/`keepAspect`) не равны
field ids новоно schema (`size` dimension, `anchor`). Нужны новые переводы
полей.
- `ru.ts` tools[id].results для анализаторов уже содержит ключи (`verifyYes`,
`grayscaleNo`, `orientationPortrait` и т.д.).
### Архитектурные ограничения
- Изоляция old↔new (`isolation/no-mixed-imports`): сейчас `lib/i18n/` — shared.
По решению задачи (Фаза 0) i18n **форкается**: v1 получает полную копию
`lib/v1/i18n`, а `lib/i18n` переходит в ветку «new» (правило изоляции запретит
старому коду импортить новый i18n). До форка в общем модуле два скрытых
v1-импорта: `dict.ts` тянет `CategoryId` из `../v1/categories`,
`tool-strings.ts` — `$lib/v1/registry`; после форка оба живут только в копии.
Новому UI нужен параллельный `schema-tool-strings.ts` поверх `$lib/registry`.
- Tone detection в `SchemaTextResult.svelte`: `/^(yes|true)/i` по value —
сломается при русских вердиктах («Да», «Нет»).
- `tools-i18n.test.ts` покрывает только v1 TOOLS; нужен аналог для нового
registry.
- `known-issues.spec.ts`:
`test.fixme("error message is localized, not a raw i18n key")`.
## 3. Как делаем
Принципы:
- Мелкие атомарные коммиты (правило AGENTS.md); самому не коммитить — коммитит
разработчик после ревью.
- Изменения «снизу вверх»: сначала общий слой i18n, потом словари, потом UI.
- Каждый этап заканчивается зелёными `pnpm --dir web lint` +
`pnpm --dir web check` + тесты.
- Решения по §5 зафиксированы и учитываются с первых правок словарей.
## 4. Фазы
### Фаза 0: Форк i18n для v1 + подготовка общего слоя
Выполняется строго до остальных фаз, без изменения поведения UI. Цель — два
независимых i18n-слоя: v1 получает замороженную копию, новый UI — свою ветку,
которую дальше меняем свободно. На выходе фазы — зелёные `lint`, `check`,
`test`, `test:rules`.
- **0.1** Форк: скопировать `web/src/lib/i18n/` → `web/src/lib/v1/i18n/` (полный
снимок: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `tool-strings.ts` + все 5 тестов). v1 работает дальше только с
копией — поздние правки нового i18n её не затрагивают.
- **0.2** Привести копию к v1-канону (относительные пути от нового места):
- копия `dict.ts`: импорт `CategoryId` `../v1/categories` → `../categories` (=
`lib/v1/categories`);
- копии тестов (`tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts`): `../v1/registry` → `../registry` (=
`lib/v1/registry`);
- импорт `$lib/v1/registry` в копии `tool-strings.ts` остаётся валидным;
- перепроверить остальные относительные и `$lib`-пути в копии.
- **0.3** Переключить импорты старого UI: все `$lib/i18n/*` в `routes/v1/**` и
`lib/v1/components/**` (39 импортов в 23 файлах) → `$lib/v1/i18n/*`.
- **0.4** Очистить новый `lib/i18n/` от v1-специфики, иначе isolation поймает
new→old:
- удалить `tool-strings.ts` (завязан на v1-типы `ParamDef`/`ToolEntry`; для
нового UI вместо него `schema-tool-strings.ts`, см. 0.8);
- удалить v1-центричные тесты `tools-i18n.test.ts`, `search-coverage.test.ts`,
`smoke-i18n.test.ts` (они импортируют `../v1/registry`; живут только в
копии);
- остаются: `dict.ts`, `en.ts`, `ru.ts`, `t.ts`, `locale.svelte.ts`,
`matching.ts`, `i18n.test.ts`, `matching.test.ts`.
- **0.5** Обновить правило изоляции и конфиги:
- `isolation/no-mixed-imports.js`: добавить `lib/i18n/**` в `DEFAULT_NEW`
(копия по-прежнему old через `lib/v1/**`);
- `eslint-plugins/__tests__/no-mixed-imports.test.ts`: кейсы «old → i18n
чисто» (строки 83–89) и «lib/i18n — shared» (строки 99–105) обновить под
новую классификацию: old→новый i18n = нарушение; добавить фикстуру
`__fixtures__/src/lib/v1/i18n/t.ts` для кейса old-копия;
- `eslint.config.js`: в `allowDefaultProject` добавить фикстуру
`eslint-plugins/__fixtures__/src/lib/v1/i18n/t.ts`; поправить комментарий
изоляции «Общее (core/, i18n/, theme)» → «Общее (core/, theme)»;
- `eslint-plugins/README.md`: убрать i18n из списка shared (§ Isolation).
- **0.6** Новый `lib/i18n/dict.ts`: `CategoryId` переключить с
`../v1/categories` на `../categories` (новый структурно-идентичный тип) —
скрытых v1-зависимостей в новом i18n не остаётся.
- **0.7** Проверка форка: `pnpm --dir web lint`, `check`, `test` (v1-копия
тестов подхватывается `vitest include: src/**/*.test.ts`), `test:rules`;
смоук: v1-страницы и оба переключателя RU/EN работают как раньше, языки обеих
версий согласованы.
- **0.8** Добавить `lib/i18n/schema-tool-strings.ts` поверх `$lib/registry`
(новый тип `ToolEntry`):
- `toolTitle(tool)`, `toolDescription(tool)` с фолбэком на registry English;
- `fieldLabel(field, id)` — перевод по ключу из `fields`-секции словаря (см.
решение C), фолбэк на `labelOf(id)`;
- `groupLabel(title)` — перевод по ключу из `groups`-секции словаря (см.
решение A);
- `verdictText(toolId, key)` — рендер вердикта по ключу;
- `verdictTone(key)` — `success`|`danger`|`info` по имени ключа
(`*Yes`/`*No`/`*Portrait`/`*Landscape`) — не по строке.
- **0.9** Расширить тип `Dict` (только новая ветка `lib/i18n`):
- секция `preview` **не заводится**: строки нового UI живут в собственных
категориях — расширяем существующие (`catalog`, `header`, `dropZone`,
`sourceCard`, `resultCard`, `paramsCard`, `textResult`, `ui`) и заводим две
маленькие новые: `actions` (Generate / Open image / Download result…) и
`textSource` (placeholder, Try sample…);
- новая глобальная секция `fields: Record<string, string>` — переводы подписей
полей по ключу из схемы (решение C);
- новая глобальная секция `groups: Record<string, string>` — переводы
заголовков групп полей (решение A).
### Фаза 1: Словари — наполнение (только новая ветка `lib/i18n`, v1 не трогаем)
- **1.1** Заполнить `en.ts` и `ru.ts` секциями `actions`, `textSource`,
`fields`, `groups` и ключами недостающих строк в существующих секциях.
- **1.2** Проинвентаризировать разницу между 122 инструментами нового registry и
ключами `ru.tools`: для инструментов без перевода (нет в v1) добавить
title/description в ru.ts новой ветки (по категориям — несколько коммитов).
- **1.3** Добавить в `ru.ts` ключи `fields`/`groups` для используемых в схемах
ключей полей и заголовков групп (по ходу проставления ключей в schema, Фаза
5).
- **1.4** Добавить `tools[id].options[fieldId][value]` для select-полей, чьи
опции нужно локализовать.
- **1.5** Тест полноты (задел на Фазу 6): для каждого ключа label в schema есть
перевод в обоих словарях.
### Фаза 2: LangToggle → реальный locale
- **2.1** `LangToggle.svelte`: убрать локальный `lang`, подключить
`getLocale()`, `setLocale()`, `LOCALES`. Модель — language switch из
`routes/v1/+layout.svelte`.
- **2.2** Проверить e2e `navigation.spec.ts` «language toggle marks the active
button» — должен проходить без изменений.
### Фаза 3: Routes + layout
- **3.1** `+layout.svelte`: breadcrumbs через `t()`, динамический `lang`.
- **3.2** `+page.svelte`, `list-tools/+page.svelte`: footer-строка →
`t("header.footerNote")`.
- **3.3** `tools/[id]/+page.svelte`: «Tool not found», fallback title → `t()`.
### Фаза 4: Компоненты — основной блок (коммиты кучками по смыслу)
Все текстовые подписи в компонентах — информативные, сохраняем и переводим на
`t`/хелперы. Каждый коммит: замена строк + lint/check.
- `SchemaToolView.svelte`: title/description →
`toolTitle()`/`toolDescription()`, перевести `TOOL SETTINGS`, «Configure
output» и «This tool has no schema yet.».
- `SchemaSourceTile.svelte`: перевести figcaption `SOURCE`, «choose an
image»/«no source…» и alt.
- `SchemaResultTile.svelte`: перевести figcaption `RESULT`, «no result yet» и
alt; суффикс «parts» — в `PreviewTile`.
- `SchemaPreview.svelte`: перевести панельный лейбл `PREVIEW PANEL` и
meta-капшены `SOURCE / RESULT / FORMAT`; значения «parts»/«text» → `t()`.
- `SchemaActions.svelte`: Generating…, Generate, Open image, Download result.
- `SchemaFields.svelte`: Reset, «updates automatically», `labelOf(field.id)` →
`fieldLabel(field)`.
- `SchemaTextSource.svelte`: placeholder, Try sample, Render text.
- `SchemaTextResult.svelte`: перевести лейбл `RESULT` у вердикта, Copy /
Download .txt; tone detection → `verdictTone()`.
- `CatalogToolbar.svelte`: фильтры → `t("categories.all")` +
`t("categories.X")`, поисковый placeholder.
- `CatalogHeader.svelte`: перевести heading/lead и счётчик «TOOLS AVAILABLE».
- `CatalogGroup.svelte`: перевести суффикс счётчика «TOOLS».
- `Dropzone.svelte`: текст зоны → `t("dropZone.pickDefault")`.
- Schema field controls (по одному или группой): Width/Height, From/To, Backing
plate, Opacity, Font/Size/Color/Bold, Sans/Serif/Mono, Angle, «+ Add color»,
«Remove color».
- `TopNav.svelte`, `ThemeToggle.svelte`, `CodeBlock.svelte`, `StepCard.svelte`,
`Footer.svelte`.
### Фаза 5: Вердикты + ключи в schema
- **5.1** Анализаторы в `registry/analyze.ts`: возвращают ключи (`"verifyYes"`
вместо raw English).
- **5.2** `SchemaPreview.svelte`: рендер вердикта через
`verdictText(tool.id, key)`.
- **5.3** Layout groups в `registry/*.ts` (решение A): `title` → ключ
(`title: "groups.canvas"`); в `SchemaFields.svelte` — `groupLabel(title)`. В
`Dict` — глобальная секция `groups`.
- **5.4** Лейблы полей в `registry/*.ts` (решение C): на поле в схеме
проставляется `label`-ключ (`field.number({ label: "fields.thickness", … })`),
вывод в UI — `fieldLabel(field)` = `t(label)`. В `Dict` — глобальная секция
`fields`.
- **5.5** Категории в `catalog.ts`: `GROUP_LABELS` → `t("categories.X")` (ключи
уже есть в dict).
### Фаза 6: Тесты
- **6.1** `tools-i18n.test.ts` — полнота словарей для текущего registry:
title/description ru для всех инструментов, каждый `label`-ключ схемы есть в
`fields`, каждый `groups`-ключ есть в `groups`, без лишних ключей.
- **6.2** Фикс `known-issues.spec.ts` test.fixme — ошибки локализуются.
- **6.3** В новой ветке `lib/i18n` появится собственный `smoke-i18n.test.ts`
(аналог v1: переключение локали, `html lang`, контент без смеси языков) —
v1-копия теста не трогается.
### Фаза 7: Кросс-языковой поиск
Переключить поиск в новом каталоге с `toLowerCase()` на
`scoreDoc()`/`normalizeForSearch()` из `matching.ts` (входит в объём, см.
решение D). Последний шаг, аккуратный отдельный коммит.
### Фаза 8: Линтер словарей (i18n key linter)
Замена ручной проверки ключей на автоматическую. Инфраструктура уже есть:
`eslint-plugins/` умеет читать соседние файлы (`isolation/no-mixed-imports`) и
гоняться фикстурами (`test:rules`).
- **8.1** Новое правило `i18n/dict-consistency` в `web/eslint-plugins/`.
Применяется к файлам-словарям (конвенция: экспортируют `Dict`, лежат в
`lib/i18n/`); новые локали подхватываются автоматически — правка правила не
нужна.
- **8.2** Проверки — **только `warn`, никогда `error`** (пропущенный перевод не
валит сборку):
- **Паритет дерева ключей**: каждый ключ (dot-path `a.b.c`) присутствует во
всех остальных локалях, не только против BASE. Нет —
`missing key "x.y" in en`.
- **Пустые значения**: `""` и строка из одних пробелов —
`empty value "x.y" in en`.
- **Паритет плейсхолдеров**: у одинакового ключа набор `{name}` одинаковый во
всех локалях — поймать потерянную при переводе переменную.
- **8.3** Политика источников: `tools.*.title` и `tools.*.description` в
`BASE_LOCALE` намеренно берутся из registry English fallback; остальные локали
обязаны иметь физические переводы. Старый `tools.*.params` не используется
новым UI и пока исключён policy-pattern, а не списком id. Target — zero-warn
без молчаливых подавлений.
- **8.4** Тесты правила — фикстуры в `web/eslint-plugins/__fixtures__/`, док в
`web/eslint-plugins/README.md`; включение в `eslint.config.js` как `"warn"`.
### Фаза 9: Множественные числа (plural-формы)
- **9.1** Тип значения словаря расширяем: `string | PluralForms`, где
`PluralForms = { other: string } & Partial<{ one, few, many }>` по
CLDR-правилам локали (ru: обязательны `one/few/many`; `other` — всегда, как
фолбэк). Новая функция `tPlural(path, n)` в `lib/i18n/`: выбирает категорию
через `Intl.PluralRules(locale).select(n)` и подставляет её с интерполяцией
`{n}`.
- **9.2** Первые потребители: «часть/части/частей» (split-into-parts-png),
счётчики инструментов (`catalog.toolsCount`), «N параметров», «N файлов».
- **9.3** Паритет в линтере (Фаза 8): если ключ — plural-объект в одной локали,
он обязан быть plural-объектом во всех остальных (тип-паритет; string в одной
из локалей — warn). Набор форм между локалями не сравнивается дословно — он
проверяется по правилам конкретной локали (`other` обязателен; en: one/other,
ru: one/few/many).
- **9.4** Открытый вопрос на этапе реализации: неоднозначность «объект-секция»
(`Record<string, string>`) vs plural-объект в дереве словаря. Варианты:
отдельная секция `plurals` в словаре, конвенция имён ключей, вывод типа из
контекста. Фаза начинается с проработки этого вопроса, не с кода.
## 5. Решения
- **A. Layout groups**: заголовки групп храним i18n-ключами прямо в схеме
(`title: "groups.canvas"`), перевод — в глобальной секции `groups` словаря
(`Dict.groups: Record<string, string>`). В UI — `groupLabel(title)` =
`t(title)` с фолбэком на сырой ключ.
- **C. Лейблы полей**: в схеме задаём i18n-ключ
(`field.number({ label: "fields.thickness", … })`), в UI выводится перевод —
`fieldLabel(field)` = `t(label)`. Ключи — глобальная секция `fields` словаря,
фолбэк на текущий `labelOf(id)` для несопоставленных.
- **D. Объём**: переводим всё сразу — и каркас, и контент реестра, и вердикты, и
кросс-языковой поиск (Фаза 7). Крупные изменения кода разбиваем на небольшие
атомарные шаги/коммиты.
## 6. Проверки
- `pnpm --dir web lint`
- `pnpm --dir web exec svelte-check --tsconfig ./tsconfig.json`
- `pnpm --dir web test`
- `pnpm --dir web test:rules` (если менялся eslint/isolation-конфиг)
- Смоук: переключатель RU/EN на новом UI; страницы каталога/инструмента, ошибка,
вердикт — без смеси языков; `html lang` следует за локалью.
+380
View File
@@ -0,0 +1,380 @@
# План: переезд на новый дизайн (refs) — параллельная сборка
> Статус: **archived** Архивирован: 2026-09-26 Удалить после: 2026-10-26
> Причина: переезд на новый дизайн завершён (C17/C18/Q3), C20 и C21 выполнены,
> пункты полировки разобраны и разложены по `docs/backlog.md`. Источник правды
> по визуальному языку — `web/src/app.css`, `web/src/lib/components/**`, витрина
> `/kit` и приёмка в `docs/checklist-manual-testing.md`. Использование: только
> историческая справка
> Статус: C17 **выполнен**, C18 **выполнен**, Q3 (удаление старого UI)
> **выполнен**, C20 **выполнен**, C21 **выполнен**. Открытых шагов не осталось.
> Покупка домена НЕ была блокером C17: домен нужен для S1 (`plan-seo.md`) —
> canonical, sitemap, снятие `noindex`.
>
> Источники: `refs/` — Next/React-референс (правда по пикселям и интерактиву),
> `refs-html/` — статические HTML-снимки для быстрого просмотра в браузере.
>
> **Историческая стратегия перехода:** сначала новый UI собирался отдельно,
> затем стал production, после чего legacy-ветка была удалена Q3. Текущие
> критерии относятся только к production UI; старые решения и детали перехода
> остаются в архивных планах.
## 0. Почему меняем стратегию
- Переход выполнялся параллельной сборкой, чтобы новый UI можно было проверить
независимо; теперь production использует только новые маршруты и `app.css`.
- Дизайн-токены и глобальные стили живут в `web/src/app.css`; отдельные layout
для удалённого UI-v1 больше не нужны.
## 1. Источники и их оценка
### `refs-html/` — витрина разметки
Покрытие достаточное:
| Файл | Что показывает | Оценка |
| ------------------------- | ----------------------------------------- | ---------------- |
| `index.html` | Главная: pipeline-воркспейс + превью | ✅ |
| `demo.html` | Зеркало `index.html` (идентичны) | ⚠️ дубликат |
| `gradient.html` | Инструмент «Градиент»: настройки + превью | ✅ (тёмная тема) |
| `background-remover.html` | Инструмент «Удаление фона»: source/result | ✅ (тёмная тема) |
| `list-tools.html` | Каталог инструментов | ✅ |
- **Статика без JS** — интерактив (тумблеры/слайдеры/перетаскивание) брать из
кода `refs/`, не из HTML. HTML — только визуальная правда по разметке/классам.
- По 4 файлам собран инвентарь классов — это и есть спецификация kit'а:
`app-shell`, `page-grid`, `eyebrow`, `brand-mark`, `topbar`, `footer`,
`panel-heading`, `setting-group`, `settings-footer`, `settings-panel`,
`segmented`, `status`, `step-card` (`step-index`/`step-heading`/`step-type`/
`step-body`/`drag`), `color-field` (`swatch`/`native-color`),
`preview-toolbar`/`checker`
(`tile-canvas`/`remover-canvas`/`comparison-grid`), `result-meta`/`meta-row`,
`tool-card`/`image-card` (`tool-icon`/`tool-index`/ `tool-copy`),
`code-block`, `icon-btn`, `download-btn`, `reset-btn`, `lede`, `auto-note`,
`label`, `toggle`/`toggle-group`, `direction-grid`, `gradient-bar`.
- **Дыры в refs-html:** нет состояний empty/error/loading, нет глобальных
модалок/тултипов, i18n не представлен (все лейблы — английские брендовые).
Решения — см. §9.
## 2. Токены (фундамент нового дизайна)
Живут в `src/lib/styles/design2.css` как обычный `:root { … }` (плюс
`[data-theme='dark'] { … }`). Файл грузится **только** layout'ом новой ветки
(§5), поэтому глобально не конфликтует со старым `app.css`. Маппинг старых
токенов `web/src/app.css` → новые:
| Старый | Новый | Light | Dark |
| -------------- | -------------- | --------- | ------------------------ |
| `--bg` | `--background` | `#eef1f4` | `#11171d` |
| `--surface` | `--panel` | `#f8fafb` | `#182129` |
| `--text` | `--foreground` | `#17212b` | `#e8eef2` |
| `--text-muted` | `--muted` | `#6d7883` | `#91a0ac` |
| `--border` | `--line` | `#cbd3da` | `#33414c` |
| `--accent` | `--blue` | `#1769d2` | `#54a2ff` |
| — | `--cyan` | `#00a8c7` | `#00a8c7` |
| — | `--amber` | `#bd7411` | `#bd7411` |
| — | `--success` | `#25a96a` | `#25a96a` (статус-точки) |
| `--danger` | без изменений | `#e5484d` | `#ff7479` |
Прочее:
- Радиусы: один `--radius: 4px` (мягкость старой темы уходит).
- Шрифты: `IBM Plex Sans` + `IBM Plex Mono`, self-host через `@fontsource`
(offline-first, CDN нельзя). Mono — рабочий шрифт для лейблов/метаданных/
цифр; Sans — заголовки и текст. Задаются на `.app-shell` (корень новой ветки),
чтобы не зависеть от старых глобальных правил `body`.
- Фон-сетка (32px blueprint-grid) и шахматка канвасов — утилиты `.page-grid` и
`.checker-canvas` внутри `.ds2`, не токены.
- Тёмная тема — на `[data-theme='dark']` (инлайн-скрипт уже есть в `app.html`);
класс `.dark-mode` из рефа не переносим, используем наш механизм. На
переходный период **никаких алиасов старых токенов** (старые страницы сами
живут на своём `app.css`).
## 3. Базовые примитивы дизайн-системы (все — компоненты)
Правило фазы 1: **любой повторяющийся паттерн — компонент**, даже «просто div с
двумя стилями». Никакой копипасты классов между страницами. Каждый примитив ниже
— отдельный `.svelte` в `src/lib/components/kit/`.
- `MonoLabel` — микро-лейбл: mono 10px, uppercase, letter-spacing .1em, muted;
слот-модификатор accent (синие eyebrow/version).
- `StatusDot` / `StatusLine` — точка `--success` + подпись («AUTO PIPELINE»,
«LIVE PREVIEW»).
- `Panel` / `PanelHeading` / `SettingGroup` / `SettingsFooter` — панель с
1px-бордером, составные части с разделителями.
- `CheckerCanvas` — рабочая поверхность превью: тёмная подложка + шахматка.
- `MetaRow` / `MetaList` — пары «CAPTION значение».
- `Segmented` — сегмент-переключатель (язык, тип градиента, пресеты).
- `Badge` — тег типа шага (BACKGROUND/TRANSFORM/STYLE), чип «✓ AUTO».
- `StepCard` — нумерованная карточка этапа: индекс `01`, drag-handle, Badge
типа, заголовок, кнопка удаления; слот для параметров.
- `ToolCard` — карточка каталога (icon, index, title, copy, arrow).
- `ImageCard` / `PreviewTile` — label сверху + canvas-превью.
- `AppShell` — корневой контейнер `.app-shell` + `.page-grid` (обёртка новой
ветки маршрутов); задаёт шрифты и фон нового дизайна.
- `BlueprintGrid` — фоновая сетка (используется в `AppShell`).
- `IconButton`, `Icon` (обёртка `lucide-svelte`, 16–17px, muted).
## 4. Минимальный UI-kit (`src/lib/components/kit/`)
Контролы, поверх примитивов §3. API проектируем сразу под новый дизайн (обратной
совместимости со старым `ui/` не нужно — старые страницы его продолжают
использовать).
| Компонент | Назначение |
| ------------------------- | ----------------------------------------------------------------- |
| `Button.svelte` | `primary` (solid blue) / `secondary` (outline) / `ghost` + `icon` |
| `Field.svelte` | обёртка: `MonoLabel` + слот значения/контрола |
| `SliderField.svelte` | range + `<output>`, опц. строка подсказок |
| `ColorField.svelte` | swatch + hex-input + нативный picker |
| `TextField.svelte` | текстовый ввод в стиле дизайна |
| `SelectField.svelte` | селект в стиле дизайна |
| `CheckboxField.svelte` | чекбокс → switch-toggle 32×18 |
| `Toggle.svelte` | переключатель (для toggle-group) |
| `SegmentedControl.svelte` | на базе `Segmented` — RU/EN, Linear/Radial, пресеты |
| `Badge.svelte` | на базе `Badge` |
| `MetaList.svelte` | на базе `MetaRow` |
| `EmptyState.svelte` | пустое состояние под новый стиль |
| `CodeBlock.svelte` | блок кода (gradient-art / direction-grid) |
| `DownloadButton.svelte` | кнопка скачивания (можно переиспользовать идею старой) |
Иконки: `lucide-svelte` (те же пути, MIT), 16–17px, stroke, muted.
## 5. Изоляция старого и нового (критично)
Без префиксов и обёрток — только за счёт того, какие layout грузят какой CSS.
- **Корневой layout `src/routes/+layout.svelte`** перестаёт импортировать
`../app.css`. В нём остаётся только общая логика (init темы/locale) и
нейтральный `<div>{children}</div>` без глобальных стилей дизайна.
- **Старая ветка `src/routes/(old)/+layout.svelte`** импортирует `app.css` и
содержит текущую шапку/подвал (разметку из нынешнего корневого layout). Все
старые страницы переносятся внутрь `(old)/` (группа в скобках не меняет URL):
`(old)/+page.svelte`, `(old)/demo/...`, `(old)/list-tools/...`,
`(old)/tools/[id]/...`.
- **Новая ветка `src/routes/preview/+layout.svelte`** импортирует `design2.css`
и рендерит `<AppShell>`. Сюда же — новые страницы (§6).
- Благодаря code-splitting CSS в SvelteKit: на старых маршрутах грузится только
`app.css`, на `preview/*` — только `design2.css`. Скоуп стилей
Svelte-компонентов добавляет второй уровень изоляции. Никаких `.ds2`.
- Переключатель для ручной проверки: временный флаг `previewDesign` в
`localStorage` + кнопка в старой шапке («Preview v2»), ведущая на `/preview`.
На фазе 4 флаг убираем.
> Почему не `(new)`-группа с теми же путями вместо `preview/*`: две группы не
> могут одновременно определять один и тот же URL (`(old)/+page.svelte` и
> `(new)/+page.svelte` оба → `/` = конфликт маршрутов). Поэтому новая ветка
> живёт под префиксом `preview/`, а на фазе 4 файлы переносятся на реальные пути
> и группа `(old)` удаляется.
>
> Это совпадает с рекомендованным в документации SvelteKit паттерном: «put your
> entire app inside one or more groups except the routes that should not inherit
> the common layouts» — старое целиком в `(old)`, новое снаружи. Синтаксис
> `@`-breakout (`+layout@.svelte`, `+page@.svelte`) здесь не требуется:
> «выломаться» можно лишь до корня, а нам нужно, чтобы именно у корня НЕ было
> `app.css`; если позже захотим держать новые маршруты внутри группы, `@`
> пригодится для сброса родительского layout.
## 6. Маршрутизация: историческая схема перехода
Во время перехода UI-kit проверялся в отдельной ветке, затем его страницы были
перенесены на production-маршруты. Эта схема нужна только для восстановления
контекста C17; текущая структура описана в разделе 7, отдельных preview- или
legacy-layouts в production больше нет.
## 7. Фазы выполнения
- **C1–C16 — UI-kit и проверка нового дизайна.** Токены, компоненты и
интерактивные состояния приняты; детали перехода остаются в архивном
`plan-design-fix.md`.
- **C17/C18 — production-переход.** Новые страницы перенесены на реальные
корневые маршруты; `app.css` стал единственным production-стилем.
- **Q3/C19 — cleanup.** Удалены UI-v1, legacy CSS, v1-тесты, fixtures и
isolation-конфигурация; production build и verify проверяются отдельно.
- **C20 — следующий этап.** Финальный проход по брейкпоинтам и визуальной
приёмке production UI.
- **C21 — отдельная уборка.** Удалить `refs/` и `refs-html/`, если они больше не
нужны.
Критерии каждого этапа — зелёные `test`/`check`/`build`, отсутствие остаточных
legacy-путей в production-коде и ревью в браузере для UI-изменений.
## 8. Смоук (после фазы 3, контроль на фазе 4)
1. Светлая и тёмная тема: сетка фона, панели, шахматка канвасов читаются,
контраст mono-лейблов достаточный.
2. Инструмент с параметрами (градиент): slider/color/toggle в стиле
setting-groups, автозапуск не регрессировал.
3. Удаление фона: сравнение source/result, маска, пипетка работают на новой
шахматке.
4. Chain из 3+ инструментов: step-cards рендерятся, удаление/сброс работают.
5. Узкий экран 800px: настройки над превью, sticky выключается как в рефе.
6. RU/EN: новые лейблы локализованы; mono-лейблы рефа — либо ключи i18n, либо
осознанно английские брендовые элементы (см. §9).
## 9. Открытые вопросы
- **Имя финального каталога компонентов:** решено на C1 — писали в `kit/`, C17
разложил его в `components/` (подпапки fields/layout/ui). На C21 — только
навести порядок внутри `components/`, переименование не требуется.
- **Единый production CSS:** `app.css` подключается в корневом layout; отдельных
legacy-layouts и переключателей версий больше нет.
- **Mono-лейблы рефа на английском** (`PROCESSING PIPELINE`): завести i18n-
ключи, но допустить часть английских брендовых («приборная панель»).
- **Версия в шапке/футере:** из `package.json` через `define` или захардкодить —
мелочь, решить при сборке topbar.
- **`refs/` и `refs-html/` — одноразовый источник, НЕ поддерживаемый.** После
внедрения нового дизайна все правки дизайна делаются сразу в продакшен-коде
(`design2.css` + `kit/`), а не в `refs/`. Механизм синхронизации «ref → код»
не нужен и не создаётся. `refs/` и `refs-html/` — только историческая справка;
их можно удалить в C21, они не являются частью рантайма.
## 10. План исполнения (мелкие коммиты)
Ветка `feat/redesign`. Каждый шаг — отдельный коммит, цель < ~500 строк. Перед
стартом: `test`/`build` зелёные на базе (якорь регрессии).
### Шаг 0. Изоляция layout (без дизайна)
- [x] **[C1]** Сделать корневой `routes/+layout.svelte` нейтральным: убрать
`import '../app.css'`, оставить только init темы/locale и
`<div>{children}</div>`.
- [x] **[C2]** Создать `routes/(old)/+layout.svelte` (импорт `app.css` + старая
шапка/подвал) и перенести старые страницы (`+page`, `demo`, `list-tools`,
`tools/[id]`) в `(old)/`. URL не меняются. Проверка: старый сайт
идентичен. _(файлы — move, объём строк минимален)_
### Шаг 1. Фундамент нового дизайна
- [x] **[C3]** Создать `src/lib/styles/design2.css` (токены `:root` +
`[data-theme='dark']` по §2) и подключить `@fontsource/ibm-plex-sans` +
`@fontsource/ibm-plex-mono` (self-host, offline-first). подключить шрифты
в `design2.css` (self-host, offline-first).
- [x] **[C4]** _(слит в C3: шрифты нельзя подключить до создания `design2.css`)_
`[data-theme='dark']` (таблица §2). Без утилитарных классов.
- [x] **[C5]** Примитивы batch 1: `AppShell`, `BlueprintGrid`, `CheckerCanvas`,
`MonoLabel`, `StatusDot`/`StatusLine`.
- [x] **[C6]** Примитивы batch 2: `Panel`, `PanelHeading`, `SettingGroup`,
`SettingsFooter`, `MetaRow`/`MetaList`, `Segmented`, `Badge`, `StepCard`,
`ToolCard`, `ImageCard`/`PreviewTile`.
- [x] **[C7]** Контролы: `Icon`, `IconButton`, `Button`, `Field`, `SliderField`,
`ColorField`, `TextField`, `SelectField`, `CheckboxField`, `Toggle`,
`SegmentedControl` (= `Segmented` из C6), `EmptyState`, `CodeBlock`,
`DownloadButton`.
### Шаг 2. Витрина kit (проверка компонентов)
- [x] **[C8]** `preview/+layout.svelte` (импорт `design2.css` + `<AppShell>` +
переключатель темы) и временная `preview/kit` страница со всеми
состояниями (light/dark, hover/disabled, варианты Button/Segmented/Badge и
пр.)
### Шаг 3. Новые страницы
- [x] **[C9]** `/preview` — воркспейс (eyebrow, h1, lede, поиск, «открыть
последний»). Поиск фильтрует `TOOLS` из `$lib/registry`; карточки
результата пока ведут на `#` (реальные `/preview/tools/[id]` подключим на
C12, иначе prerender падает на 404).
- [x] **[C10]** `/preview/list-tools` — каталог карточек (группировка по
`CATEGORIES`, `ToolCard`). Ссылки пока `#` (реальные `/preview/tools/[id]`
подключим на C12).
- [x] **[C11]** `/preview/demo` — зеркало воркспейса (4 шага + панель превью),
статичный приёмочный стенд.
- [x] **[C12]** `/preview/tools/[id]` — data-driven settings-panel +
preview-panel (gradient live, остальные — плейсхолдер); `NumberField`,
`entries()` для prerender, ToolCard-ссылки подключены.
- [x] **[C13]** `/preview/tools/[id]` — comparison-grid для file-source
инструментов (`Dropzone` + before/after `CheckerCanvas`, `result-meta`);
охватывает background-remover и прочие.
- [x] **[C14]** `/preview/tools/[id]` — исполняемая цепочка: `StepCard`-шаги
(параметры inline) + «Add step» (chainable), живой прогон пайплайна на
исходнике, превью каждого шага + итог + Download.
### Шаг 4. Переключатель и проверка
- [x] **[C15]** В старой шапке ссылка «Preview v2» → `/preview`, ставит
`localStorage['easy-png-tools:preview-v2']='1'`.
- [x] **[C16]** База под дизайн: рабочий переключатель тем (persist в
localStorage, применяется на `<html>`; проверено — фон
`#eef1f4`↔`#11171d`), `TopBar` приведён к виду `demo.html` (бренд + синий
хлебный крош `/* страница */`, безрамочные иконки, язык — active =
`--foreground`/`--background`), страницы `/preview/*` на всю ширину (убран
`max-width`). Точечное выравнивание к рефу отложено: реф-дизайн
неконсистентен. (доказательство отсутствия регресса). Фиксы по результатам
— отдельными мелкими коммитами.
### Шаг 5. Сделать новый дизайн основным
> C17 — **выполнен** (`docs/archive/plan-c17.md`): production-маршруты
> перенесены в корень, `app.css` стал корневым. C18 (правки импортов/редиректов
> после переноса, проверка билда) — **выполнен**. Q3 удалил старые UI-маршруты и
> код. Остались C20 и C21.
- [x] **[C17]** Перенести `preview/*` → реальные маршруты (move), удалить
`(old)/`, корневой `+layout.svelte` импортирует `design2.css` + новый
shell. Убрать флаг/кнопку. **Снять `noindex, nofollow`** со страницы
инструмента (`preview/tools/[id]/+page.svelte`) — страницы становятся
боевыми лендингами ровно в момент переезда (SEO: `plan-seo.md`, S1a).
**Временное решение:** если домен ещё не куплен, `noindex` можно оставить
до S1 — тогда C17 делается без снятия `noindex`, а снятие происходит
вместе с подключением canonical в S1. **И обновить `newCode` в
`web/eslint.config.js`:** заменить `src/routes/preview/**` на
`src/routes/**` (после переезда все маршруты — новый дизайн), компонентный
glob `src/lib/components/kit/**` пока оставить. _(Итог: `newCode` обновлён
(`components/**`, `routes/**`), `noindex` оставлен до S1 — домен ещё не
куплен. Детали: `docs/archive/plan-c17.md`.)_
- [x] **[C18]** Правки импортов/редиректов после переноса, проверка билда.
_(Билд собирается без ошибок, линт/свелт-чека нет — выполнен.)_
### Шаг 6. Проверка нового дизайна
- [x] **[C20]** Финальный проход: grep по старым токенам и классам пуст (0
совпадений по `--bg`/`--surface`/`--text`/`--blue`/`--panel` в `web/src`),
`app.css` — единственный production-стиль, `components/` разложен как в
C17 (`fields`/`layout`/`ui`), production-маршруты и пользовательские
потоки покрыты e2e. Визуальная приёмка живёт в
`docs/checklist-manual-testing.md`.
### Шаг 7. Cleanup
- [x] **[C19/Q3]** Удалить старое: маршруты `routes/v1/**`, код `lib/v1/**`,
`app_v1.css`, v1-тесты, fixtures и isolation-плагин; убрать v1-исключения
из конфигурации и активной документации.
- [x] **[C21]** `refs/` и `refs-html/` удалены — каталогов в репозитории нет.
- [x] Навести порядок в `components/`: подпапки `fields`/`layout`/`ui`.
> C19/Q3 — отдельная cleanup-задача, не источник пользовательской ценности.
> После удаления проверяются только production-маршруты и общие модули.
>
> Если какой-то коммит тянет за собой > 500 строк (например, C6/C7), разбивать
> на под-коммиты по 2–4 компонента. Тесты/билд прогонять после каждого коммита,
> чтобы регрессия локализовалась одним шагом назад.
>
> **Атомарность и точки «поломки» между коммитами.**
>
> - C1+C2 уже сделаны вместе (связаны: нейтральный root без `(old)`-layout
> ломает старый сайт). Аналогично связанных пар больше нет.
> - C3 и C4 слиты: шрифты подключаются внутри `design2.css`, поэтому файл должен
> существовать в том же коммите.
> - Коммиты **C3→C16 не трогают старый сайт** `(old)/` — они только добавляют
> новые файлы/маршруты, сломать старый сайт на них нельзя.
> - **C17 — атомарный переключатель**: перенос `preview/*` на реальные пути +
> удаление `(old)/` + смена root-layout — в ОДНОМ коммите. Снятие `noindex`
> также в C17, **если домен уже куплен**; иначе — отложить на S1. Не дробить
> сам C17.
> - Очерёдность зависимостей: `design2.css` (C3) и kit-компоненты (C5–C7) должны
> существовать до страниц, которые их импортируют (C8–C14).
## Бэклог полировки — закрыт по итогам реализации
Пять пунктов проверены по коду: четыре сняты как выполненные, один перенесён в
`docs/backlog.md`.
- `Toggle` прямоугольный — выполнено (`border-radius: 0`).
- `Segmented`: последняя кнопка не растянута — выполнено (`flex: 1` у пунктов).
- Отступ в `DownloadButton` между текстом и size-чипом — снято как неактуальное:
size-чипа больше нет, компонент тонкая обёртка над `Button`.
- Паддинги в полях — выполнено: `TextField` и `SelectField` имеют
`padding: var(--space-s) var(--space-m)`.
- Радиусы бордеров против референсов — перенесено в `docs/backlog.md`.
+81
View File
@@ -0,0 +1,81 @@
# Хранить данные для сортировки по популярности инструментов
> Статус: archived Архивирован: 2026-09-25 Удалить после: 2026-10-25 Причина:
> ранний исследовательский вариант, не являющийся текущим планом Использование:
> только историческая справка
Идея выложить на Vercel и использовать Vercel KV (на базе Upstash Redis) — это
отличный, рабочий и очень быстрый в реализации вариант. Для 150 инструментов
объем данных будет крошечным, но здесь есть пара критически важных нюансов по
лимитам, о которых нужно знать на старте, чтобы бесплатный тариф не отключился в
первый же месяц. [1]
## ⚠️ Подводный камень: Лимиты Vercel KV на Free-тарифе
На бесплатном тарифе (Hobby) для Vercel KV действуют следующие ограничения: [2]
- 30 000 запросов в месяц (суммарно чтение + запись).
- Ограничение около 3 000 запросов в день. [2, 3]
Если делать запрос к базе на каждый просмотр страницы («посещение»), лимит в 30
тысяч улетит мгновенно при первой же паре сотен посетителей в день.
## 💡 Как сделать это правильно и бесплатно
Чтобы не выйти за рамки лимитов, архитектуру нужно построить экономно:
## 1. Чтение: Сортировка на этапе сборки (ISR / SSG)
Не нужно запрашивать KV из Edge-функции при каждом открытии сайта пользователем.
- Используйте Incremental Static Regeneration (ISR) (если вы делаете на Next.js)
или раз в сутки запускайте Cron-сид (Vercel Cron).
- Сайт раз в несколько часов запрашивает весь список из KV (всего 1 запрос на
получение общей структуры), сортирует инструменты и кеширует эту страницу как
статическую.
- Результат: 100 000 пользователей увидят отсортированный список, а вы потратите
всего пару запросов к KV в день.
## 2. Запись: Защита от накрутки и экономия («Добавление в избранное»)
«Избранное» — идеальная метрика для KV, так как её нажимают редко. А вот
«Посещения» лучше отправлять не на каждый клик, а пачками (батчингом) или
использовать для просмотров бесплатную Vercel Web Analytics (там лимит 25 000
событий в месяц обновляется отдельно).
---
## 💻 Оптимальная структура данных в Redis (KV)
Вместо 150 отдельных ключей, храните всё в одной хэш-таблице (Hash) или
сортированном множестве (Sorted Set). Это сэкономит количество запросов. Вариант
структуры через Hash:
```js
// Запись / инкремент (в API роуте Vercel)
import { kv } from "@vercel/kv";
//Когда пользователь зашел на инструмент 'crop-png'
await kv.hincrby("tools:visits", "crop-png", 1);
// Когда добавил в избранное
await kv.hincrby("tools:favorites", "crop-png", 1);
```
Как забрать всё одним запросом для сортировки:
```js
// Получаем сразу все просмотры (вернет объект вида { 'crop-png': '120', 'resize-png': '85' })
const allVisits = await kv.hgetall("tools:visits");
const allFavorites = await kv.hgetall("tools:favorites");
// Дальше соединяем в массив на клиенте/сервере и сортируем:
// Популярность = Просмотры + (Избранное * 5) <- так лайки весят больше
```
## ⚡ Альтернатива: Cloudflare Workers + KV
Если вы боитесь упереться в 30 000 запросов на Vercel, рассмотрите связку
Cloudflare Workers + Workers KV. Там на бесплатном тарифе дают: [4]
- 100 000 запросов на запись в сутки.
- 10 000 000 (10 млн) запросов на чтение в сутки.
- Для пет-проекта этого хватит навсегда, даже если вы будете делать запросы
напрямую с фронтенда без кеширования.
+271
View File
@@ -0,0 +1,271 @@
# Backlog
> Статус: **active**. Открытые задачи. Подробные планы, разбор решений и сырые
> находки — `docs/plan-seo.md`, `docs/plan-platform.md`,
> `docs/plan-fields-audit-fixes.md`, `docs/tools-audit.md`, `docs/decisions.md`.
## lint и прочие правила
- [ ] `tseslint.config(...)` deprecated: сигнатура
`(...configs: InfiniteDepthConfigWithExtends[]): ConfigArray` помечена
устаревшей, нужен переход на `defineConfig` из `eslint/config` (ESLint
core, а не `typescript-eslint`) — `web/eslint.config.js`.
- [ ] удалить мёртвые `tools.*.params` из root i18n после проверки потребителей;
до этого действует pattern-based policy-исключение
(`web/eslint-plugins/README.md`, раздел `dict-consistency`)
## Мелочи всякие
- [ ] названия инструментов - сейчас в pipeline инструмент называется "обрезать
png", но он работает с любыми картинками, наверное надо убрать. но в
заголовке - возможно надо все форматы указать (SEO задача?)
- [ ] кнопка добавить инструмент в pipeline - обдумать селектор с номальным
поиском, фильтром возможных инструментов и т.д.
- [ ] pipeline - промежуточные результаты
- [ ] pipeline - drag-n-drop инструментов
- [ ] split-into-parts-png - увеличить максимальные значения, плохо работает для
нарезки ассетов
- [ ] split-into-parts-png — режим «по размеру тайла»: указываешь размер тайла
(16×16, 32×32…, список `SIZE_PRESETS`), инструмент сам считает сетку —
сценарий «режу спрайтшит, знаю размер тайла, не знаю сетку» (новый режим:
схема + core, из задачи C3)
- [ ] e2e: `i18n.spec.ts` и `text-and-verdicts.spec.ts` дублируют локаторы из
`helpers/page.ts` вместо вызова — правило «спека зовёт helpers»
(`docs/testing-strategy.md`, раздел 5)
- [ ] e2e webkit: случайные падения по таймауту 5с (элемент не появляется),
каждый прогон падают разные тесты — webkit под Windows слишком медленный.
Рассмотреть увеличение timeout/expectTimeout для webkit-проекта в
`playwright.config.ts`. После фикса перепроверить `tool-flows.spec.ts`
«chain: remove step» — на webkit удаление шага не успевает за 5с (на
остальных движках стабильно)
- [ ] мёртвый i18n-ключ `noParams` («нет параметров — результат готов»): его
ничем не читают. Либо показывать подсказку для инструментов с пустой
схемой (FA-E1), либо удалить ключ из обеих локалей
- [ ] Пересмотреть тесты — убрать точные совпадения текстов и любые селекторы,
привязанные к разметке или svelte-классам (сейчас остались:
`tool-flows.spec.ts`, `schema-tool-strings.test.ts`)
- [ ] можно ли в тестах получать i18n ключ вместо текста? можно ли написать lint
правило такое (запретить toContain без i18n)
- [ ] нужен инструмент для удаления фона по выбранной точке - удалять внутри
выделенной области
- [ ] сделать конвертацию в смайлики\награды для твитча (6 размеров или выбор
какой размер - 112\56\28px или 72\36\18)
- [ ] сделать несколько проходное сжатие png для получения необходимого размера
файлов, посмотреть реализацию в
[https://github.com/tma02/twitch-emote-resizer](https://github.com/tma02/twitch-emote-resizer)
- [ ] неудобно настраивать ползунками, некоторые инструменты удобнее настроить
через drag-n-drop. звучит сложно, но надо придумать процесс
- [ ] убрать в тестах все нестандартные символы `→`, длинные тире, русский язык
и т.д., сделать lint правило
- [ ] комментарии на английском, lint правило
- [ ] уменьшить количество и многословность комментариев (lint правило?)
- [ ] Пересмотреть прозрачность в инструментах - Create empty PNG - прозрачность
должны быть настраиваемой 0-100%, а не переключателем
- [ ] обдумать объединение пар `inputMode + source` и `resultKind + result` в
единую структуру, позволит упростить некоторые вещи - например
`sourceValue` \ `resultValue` в `SchemaPreview.svelte`
- [ ] Рассмотреть необходимость всех этих пропсов в `SchemaPreview.svelte` они
дублируют друг друга `ontextsource`, `onrendertext`, `oncopytext`,
`ondownloadtxt`, `ondownload`,
- [ ] Переделать внешний вид инструментов без настроек, например Remove alpha
channel PNG "remove-alpha-channel-png". Сейчас выглядит слишком пустым.
- [ ] Пересмотреть поля - Plate, Position, Font Style - сейчас второй label
- [ ] Font Style - обдумать выбор шрифта (google fonts???) как организовать
шрифты с кириллицей? сделать галочку "только с кириллицей"??? (решение о
наборе и лицензиях: `docs/plan-platform.md` §4 — только OFL/Apache с
паспортом, семейства с кириллицей)
- [ ] поле ввода текста - сделать по умолчанию нормального размера, а не одну
строку для svg - форматирование или подсветку синтаксиса??? для bin\hex -
форматирование по колонкам??? для hex\bytes\rgba - пример в placeholder
- [ ] прикрутить кастомный color picker, добавить прозрачность (range?)
- [ ] проверить настройки range slider на crop-png - слишком большие значения
- [ ] обновить страницу kit или сделать storybook
- [ ] Свести convert-страницы (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`) на один инструмент `convert` — зависит от S1f
(`docs/plan-seo.md` §4). Мешает не только `quality` (он живёт в схеме
инструмента), а сама модель: `output` с mime/ext — свойство `Tool`, а
`PageStep` умеет только `{ id, params? }`, поэтому страницы на общий `id`
получат одинаковый формат вывода. Нужно научить шаг переопределять формат
вывода
- [ ] `PreviewTile`: высота плитки задана только `min-height: clamp(...)`,
поэтому она не следует за пропорциями результата — широкий результат
уезжает в узкую полосу, а высокий упирается в `max-height: 100vh`. Нужно,
чтобы соотношение сторон выбиралось по результату в диапазоне 16:9…9:16
(`web/src/lib/components/schema/PreviewTile.svelte`).
- [ ] Радиусы бордеров: `--radius-m` ненулевой и применён ко всем бордерам
(`app.css`, `SchemaToolView.svelte`). Решить, где скругления реально есть
в дизайне, а где должен быть 0
## Инструменты
- [ ] qr code to text, text to qr code
- [ ] bar code to text, text to bar code
## Переезд
- [ ] множественные числа (часть/части/частей; «N параметров», счётчики) —
функция `tPlural(path, n)` и форма `PluralForms` (обязательное `other`,
опциональные `one`/`few`/`many`); первые потребители — `part`/`parts` в
подписи результата и счётчики параметров. **Критерий приёмки:** у ключа с
plurals есть все формы, для которых нужен перевод, `other` никогда не
пустой, число подставляется в текст без склейки
## SEO/GEO
- [ ] переименование бренда + домен: чек-лист в локальном файле
`plan-domain.local.md` (в git не входит, в клоне отсутствует; подробности
и порядок — `docs/plan-seo.md` §6, R1/R2)
- [ ] снять `noindex` со страниц инструментов, когда будут sitemap, canonical и
контент (S1h; блокирует индексацию всего сайта) — `docs/plan-seo.md` §4
- [ ] форматные страницы по конкурентным слагам (`crop-jpeg`, …) — только по
вордстату с уникальным контентом; редиректы только для опечаток и
переименований уже опубликованных страниц; клонов с canonical не делать
(`docs/plan-seo.md` §5)
- [ ] sitemap.xml из реестра + `Sitemap:` в robots.txt (S1b/S1c)
- [ ] Seo.svelte: canonical + OG-теги, per-tool OG-картинки (S1d/S1e)
- [ ] контент страницы инструмента: seo-блок в словарях, how-to/FAQ/related (S2)
- [ ] JSON-LD (SoftwareApplication/FAQPage/Breadcrumb) + llms.txt (S3)
- [ ] дистрибуция: закрепить репо, alternative.to, Product Hunt, Reddit/HN (S4c)
- [ ] GSC: подключить домен, отправить sitemap, мониторинг индексации (S4a)
- [ ] GA4: cookieless-конфигурация, отключить рекламные audiences и signals,
`anonymize_ip` — сейчас в `web/src/routes/+layout.svelte` голый
`gtag('config')` (S4b)
## Платформа (API/PWA)
- [ ] PWA: manifest + service worker, оффлайн-режим, установка (после S1)
- [ ] API: спайк серверного ядра (нативный Rust vs edge-wasm), ключи, free tier
(после wasm-фаз 5–7)
- [ ] Шрифты: лицензирование (F1–F4) и DOM-free рендер (F5–F6) — до фазы 8
(`docs/plan-platform.md` §4)
## Качество production UI
- [ ] Component-тесты `SchemaToolView`/`SchemaPreview` требуют отдельного
решения: node Vitest не имеет DOM/canvas, а e2e уже покрывает поведение.
- [ ] Ошибка декодирования изображения из `createImageBitmap` пока показывается
сырым сообщением браузера; нужен `ToolError`-маппер и i18n-ключ.
## Идеи
Идеи 1–5 и 15 — про пайплайн (фаза 11 `docs/roadmap.md`). Идеи 6–14 и 16 — про
существующие инструменты и UI. Оценки (S/M/L) — оценка труда, не приоритет.
1. **Менять первый инструмент цепочки** — оценка M. Сейчас базовый инструмент
зафиксирован при открытии страницы; хочется заменить его на другой без
разборки цепочки: шаги и их параметры сохраняются, вход перечитывается.
Открытый вопрос: что делать с несовместимыми параметрами (text-source ↔
file).
2. **Сворачивать инструменты в chain** — оценка S. Тоггл сворачивания звена до
заголовка «Шаг n: название» (превью скрываются). Состояние свёрнутости
помнить в workspace-pipeline.
3. **Несколько цепочек: сохранение и загрузка** — оценка M. Именованные цепочки
в localStorage (список, создать/переименовать/удалить), быстрое переключение.
Формат экспортного JSON расширить опциональным полем имени; обратной
совместимости со старыми выгрузками не делаем — формат не устоялся: он
появится вместе с фазой 11 `docs/roadmap.md` и до её старта может измениться.
4. **Batch-обработка архивов выбранным chain** — оценка L. Вход — zip-архив
картинок (распаковка в браузере), прогон текущего или выбранного сохранённого
chain по каждому файлу, сборка результата обратно в zip для скачивания.
Зависит от идеи 3 (выбор цепочки) и от экспортного формата pipeline JSON;
прогресс и ошибки — пофайлово.
5. **Избранные инструменты (fav tools)** — оценка S/M. Тоггл-звёздочка на
карточках каталога, результатах поиска и в шапке страницы инструмента; список
slug в localStorage. Избранное показывается отдельной секцией сверху каталога
и поднимается в выдаче поиска (бонус к popularity при скоринге).
6. **Конвертация формата — НЕ отдельный инструмент, а выбор в кнопке Download**
— оценка M/L. Решение и продуктовое обоснование: `docs/plan-seo.md` §0 и
задача **S1f**; почему селектор не заменяет существующие convert-страницы —
`docs/plan-seo.md` §5. Механическая часть уже есть: `Tool.output`
(`OutputFormat`: mime/ext/qualityParamId) и кодирование по нему в
`SchemaToolView.download()` — `docs/architecture.md`, раздел 3. Что осталось
сверх S1f:
- экспорт с лимитом размера → «сжатие до целевого КБ»: для lossy это quality
в селекторе формата, для PNG — бинарный поиск по k из квантования. Нужна ли
отдельная страница под этот интент — открытый вопрос с вордстатом, имя не
выбрано (`docs/plan-seo.md` §5). Проработать вместе с достройкой
download-флоу.
7. **Страница `watermark-image-png` (водяной знак картинкой)** — оценка M. Нужна
overlay-механика: второй источник изображения (getOverlay/store), параметры
scale/opacity/position/margin/aspect. Решается вместе с вопросом, как второй
источник картинки вписывается в schema-driven preview (`SchemaToolView`).
8. **`png-info` (детальная информация о PNG)** — оценка M/L. Отдельный райз:
exif-теги, редактирование, структурированный вывод; нужен свой отдельный
случай в UI, не «ещё один text/verdict». Возможно нужна вообще отдельная
страница, несовместимая с другими инструментами: 1) она работает не с
изображением, а с метаданными из файла, 2) она не может быть частью цепочки,
т.к. результат не является изображением, 3) если надо изменить теги, никакого
форматирования и изменения в изображение вносить не надо, только в
метаинформацию.
9. **Region-инструменты** (censor/erase/pixelate-area/blur-area/sharpen-area/
reverse-area) — ждут UI выделения области на превью. Вне очереди.
10. **Мультифайловые инструменты-потребители** (gif-to-frames, separate-colors)
— механизм «результат = набор файлов» (1 → many) уже есть
(`docs/architecture.md`, раздел 7), первый потребитель —
`split-into-parts-png`. Остальные подключаются по мере нужды.
11. **Анимационные** (slow-reveal/fade/scrolling) — выход не PNG; отдельное
решение о формате.
12. **HARD-серия** (glitch, barcode, signature-extract, handwritten-digital).
13. **Контраст текста на акцентных кнопках — частично закрыт**. Тона
`--color-main`/`--color-accent` подняты под текущий порог `palette.test.ts`;
targeted-тест проходит. Light-тема всё ещё ниже AA 4.5 для обычного текста,
а `--color-danger` в light-теме на границе: после завершения редизайна
вернуть порог 4.5 и перепроверить оба токена.
14. **Отображение маски для инструментов (`runMask`)** — придумать и реализовать
процесс показа маски результата. **Контекст:** удалённый UI-v1 умел
показывать ч/б превью-маску «что будет вырезано» отдельным полем; при
переходе на единый `run(ctx)` этого поля не осталось — executor и
schema-превью зовут `run()` напрямую, маска не возвращается. **Идея 1:**
дать каждому `run` возможность вернуть (или инструмент — объявить) `runMask`
— ч/б изображение маски изменений либо иной подход (отдельное поле `mask` +
функция маски, второй результат, overlay). Оценка — на усмотрение при
проработке (S/M). Пока живём без неё. **Идея 2:** каждый `run` возвращает
два изображения — для отображения (показывает маску или сам результат) и для
передачи в следующий инструмент (всегда результат). Оценка — на усмотрение
при проработке (S/M). Пока живём без неё. Первые кандидаты на маску:
`remove-color-from-png`, `remove-background-png`.
15. **Набор файлов в пайплайне: 1 → many не должен быть терминальным** — оценка
L. **Сейчас** `execute` принимает один `source: PixelImage | undefined` и
возвращает один `ToolResult`, а `SchemaToolView` раскладывает вывод в
`result` / `fileResult` / `textResult` по `resultKind` — поэтому шаг с
`result: "files"` упирается в конец цепочки. **Направление:** следующий
инструмент применяется **ко всем картинкам** предыдущего результата
(fan-out), а many → 1 (вход = архив или несколько файлов) — обратная сторона
того же контракта. Терминальными остаются только результаты, которые не
картинка (text, verdict). Что обдумать:
- где проходит граница: один вызов `run(ctx)` на файл (имя части склеивается
в `FileResult.files`) или батч целиком на стороне исполнителя;
`executor/protocol.ts` `files` уже сериализуются, но при батче нужен
прогресс и отмена;
- частичные ошибки: один битый файл не должен ронять весь шаг — нужен
per-file отчёт, а не один `ToolError` на шаг;
- имена частей на каждом шаге и Download для набора (сейчас zip по
`page.slug`, `SchemaToolView.download()`);
- обратная сторона: распаковка zip в браузере, порядок файлов, поведение
many → 1 → many в одной цепочке. Зависит от идеи 10 (остальные потребители
1 → many) и идеи 4 (batch по архиву), `docs/roadmap.md` фаза 8
«batch-страница».
16. **Главная должна отличаться от каталога** — оценка S. Сейчас
`src/routes/+page.svelte` и `src/routes/list-tools/+page.svelte` побайтово
идентичны (совпадают script, шаблон и стили), при этом `/list-tools` —
рабочий маршрут: на него ссылается крошка в `+layout.svelte` и он открыт
e2e-сценариями `catalog.spec.ts`, `i18n.spec.ts`, `navigation.spec.ts`.
Замысел: главная — крупнее и с более простым поиском, без категорий; каталог
остаётся группировкой с фильтрами. Детектор дублирования разметки
сознательно не вводим — `docs/decisions.md`, раздел 1, поэтому расхождение
ловится на ревью.
+107
View File
@@ -0,0 +1,107 @@
# Чек-лист ручного тестирования production UI
> Статус: **active**. Вспомогательный документ к e2e-тестам (`web/e2e/`,
> `pnpm --dir web test:e2e`) и стратегии `docs/testing-strategy.md`.
> Автоматизированная часть закрывает «нет падений/ошибок»; здесь — то, что
> руками, и что Playwright не покрывает.
>
> Перед прогоном: `pnpm --dir web build`, поднять локально `web/` (dev или build
> → `node scripts/serve-static.mjs`).
>
> Критерий приёмки большинства пунктов: **нет зависаний, нет «залипания»,
> сообщения об ошибках человекочитаемые**.
## A. Краевые PNG-файлы
> Фикстуры для этого раздела генерируются скриптом: `pnpm generate:fixtures` →
> `tests/fixtures/manual-edge-cases/`.
- [ ] Огромное изображение (мегапиксели) — загрузка не висит, прогресс есть.
- [ ] PNG 1×1 — инструменты не падают (проверено авто: flip-png, см. suite).
- [ ] PNG с полупрозрачностью/чётким альфа-краёв (чёрно-белая шахматка) —
remove-background, feather-edges, clean-edges дают корректные края.
- [ ] Палитровый PNG (индексированные цвета) — конвертируется/анализируется
(авто-покрытие: `web/e2e/png-fixtures.spec.ts`).
- [ ] Анимированный/APNG, если попадётся — не ломает конвейер.
- [ ] Повреждённый/не-PNG файл — понятная ошибка, нет краша (авто-чека в
`tool-flows.spec.ts` и `png-fixtures.spec.ts`).
- [ ] PNG с битой CRC / 16-bit — как ведут себя анализаторы (ориентация, размер,
прозрачность); CRC/16-bit авто-покрыты в `png-fixtures.spec.ts`.
### Файлы (`tests/fixtures/manual-edge-cases/`)
| Файл | Описание |
| ------------------------ | ------------------------------------------------------------ |
| `1x1.png` | Минимальное изображение 1×1, сплошной красный |
| `1x1-transparent.png` | 1×1, полностью прозрачный (alpha=0) |
| `2x2-extreme.png` | 2×2: чёрный, белый, полупрозрачный красный, прозрачный синий |
| `1024x1024-checker.png` | 1024×1024, шахматка ≈1 мегапиксель |
| `1920x1080-gradient.png` | 1920×1080, градиент ≈2 мегапикселя |
| `alpha-checkerboard.png` | 64×64, чёрно-белая шахматка с чередующейся альфой |
| `alpha-gradient.png` | 128×64, альфа-градиент 0→255 по ширине |
| `sparse-alpha.png` | 100×100, 10% пикселей с alpha=0 |
| `all-black-opaque.png` | 64×64, сплошной чёрный |
| `all-white-opaque.png` | 64×64, сплошной белый |
| `all-transparent.png` | 64×64, полностью прозрачное |
| `palette-4colors.png` | 32×32, палитровый 4 цвета (RGBW) |
| `palette-256.png` | 64×64, палитровый полная палитра 256 цветов |
| `16bit-subtle.png` | 32×32, 16-bit RGBA, тонкий градиент |
| `noise-pattern.png` | 100×100, шумовая текстура |
| `1x4000-strip.png` | 1×4000, длинная вертикальная полоса |
| `4000x1-strip.png` | 4000×1, широкая горизонтальная полоса |
| `corrupt-bad-crc.png` | 4×4, повреждённый CRC в IDAT |
| `truncated.png` | Обрезанный файл (только PNG-заголовок + часть IHDR) |
| `fake-png.txt` | Текстовый файл «.png» — не-PNG |
## B. Края параметров
- [ ] Экстремальные значения слайдеров (0 и max) в blur/sharpen/pixelate — не
«залипает» running.
- [ ] Пустые/NaN поля (напр. width/height) — санитизация (silent repair), нет
красного alert-текста в виде `errors.*`.
- [ ] Невалидные цвета в плашках/градиентах (мисс-спелл `#xyz`, короткие hex,
именные цвета) — silent repair или понятная ошибка.
- [ ] Пустой text-source на text-инструменте — run пропускается, ошибок нет.
- [ ] Смена параметра после Reset — авто-перезапуск работает (Debounce 200 мс).
## C. Буфер обмена и drag-n-drop
- [ ] В реальном браузере кнопка copy на текстовом результате (png-to-base64 и
т.п.) кладёт текст в буфер (в Playwright не тестируем из-за permissions).
- [ ] Drag-n-drop файла на dropzone страницы инструмента (не только файл-диалог)
— UX-ощущения и корректность.
## D. Производительность и отзывчивость
- [ ] Долгие авто-рераны на больших картинках (`jpeg-artifacts-png`,
`dithering-png`, `quantize-png` на 4К) — UI не фризит, канселяция/дебаунс
срабатывает.
- [ ] Скролл страницы при длинных настройках (например, text-инструменты) — нет
залипаний, панели не прыгают.
- [ ] Вкладка не «съедает» память при 10+ повторах blur/sharpen на мегапикселе
(утечек blob-URL/bitmap быть не должно).
## E. Визуальный слой (доделываем по ходу)
- [ ] Сетки/выравнивание на брейкпоинтах 640 / 800 / 1100 (mobile/tablet/
desktop) на всех маршрутах: `/`, `/list-tools`, `/kit`, любая `/tools/*`.
- [ ] Тема light/dark: контраст вердиктов (Yes/No), подпись панели превью
(«Предпросмотр»), мета-инфо бликов не даёт.
- [ ] Иконки в каталоге на всех категориях — не «бьются» (missing icon).
- [ ] Язык RU/EN на превью-страницах — переключение без регресса рендера.
## F. Субъективная корректность
- [ ] dithering / two-colors / quantize — результат «по ощущениям» соответствует
описанию.
- [ ] watermark-tile / add-text / date-stamp на кириллице — рендер текста
корректный (не «кракозябры»).
- [ ] remove-background на сложной полупрозрачности — края не «звенят».
## Как долго
- Полный прогон: все разделы A–F, включая мегапиксели и 4К, в двух браузерах.
- Быстрый смоук: A (кроме мегапикселей), B (выборочно), C, F выборочно.
> Сценарии, закрытые e2e-тестами, в этот чек-лист не попадают: их покрытие видно
> по спецификациям в `web/e2e/`, политика e2e — `docs/testing-strategy.md`.
+209
View File
@@ -0,0 +1,209 @@
# Решения по гейтам и тестам
> Статус: **active**. Журнал решений «что сознательно НЕ вводим» и «как читаем
> результат CI». Только про гейты, линтеры, coverage, e2e и CI — **для обычных
> правок production-кода не нужен**. Читать, когда проектируется или меняется
> сам гейт, либо когда спрашивают «почему так, а не иначе». Оперативные команды
> и пороги — в `docs/quality-gates.md`, правила нагрузки на машину — в
> `docs/agent-workflow.md`, раздел 3.
Здесь лежат решения, чтобы не пересматривать их заново. Для каждого: причина и
то, что решение пересмотрело бы. В `docs/backlog.md` такие пункты не
дублируются: backlog живёт задачами, а не историей решений.
Числа ниже — разовые замеры, а не текущие значения: помечены датой. Актуальные
цифры даёт прогон (`docs/quality-gates.md`), по правилу `AGENTS.md` в документах
нет величин, которые меняются вместе с кодом.
## 1. Детектор дублирования разметки — не вводим
Ни свой AST-правило, ни готовая зависимость (jscpd, `eslint-plugin-sonarjs`).
- Замер по корпусу `.svelte` в `components/**` и `routes/**` (сентябрь 2026):
при пороге 5 нормализованных строк — одна осмысленная пара, при пороге 3 — 17
пар, и все они шум (`</label>`, `{/each}`, `{#if children}`). Семейство
schema-контролов делит максимум 4 строки — законное сходство by design.
- Причина: построчное сравнение Svelte-разметки принципиально ненадёжно —
одинаковый контейнер с разным содержимым даёт ложное срабатывание, а намеренно
общий класс — ложную тишину. Чтобы не ловить шум, порог пришлось бы поднять до
12+ строк, и тогда правило срабатывает ровно один раз.
- Контроль остаётся на ревью (расхождение видно в диффе) и на поведенческих
e2e-проверках; извлечение общей разметки закреплено конвенцией в `AGENTS.md`.
- Пересмотреть, если появится реально «уехавшая» копипаста в компонентах или
библиотека разрастётся на уровень композиций страниц.
## 2. Линтер согласованности комментариев — не вводим
Описания инструментов различаются осознанно, и ловить это текстовым правилом
дороже, чем смотреть на ревью. Действенная часть выполнена вручную: все
`TODO`/`FIXME` перенесены в backlog, незарегистрированных пометок в коде нет;
новые запрещены правилом в `AGENTS.md`.
## 3. Линтер «изменяемых чисел» в документации — не вводим
Правило «не фиксировать числа, которые меняются вместе с кодом» живёт в
`AGENTS.md` и держится на ревью. Автоматизация отклонена по замеру (сентябрь
2026): regex «число + существительное» на корпусе Markdown дал 15 совпадений, из
которых 14 — ложные («30 дней» политики архива, «9 опций» описания поля, «4
проекта» факт из `playwright.config.ts», цитата самого правила).
- Правило всё равно неполно: ловит «M инструментов», но не «инструментов — M»,
не «более 100», не «≈120»; на каждое обходное движение нужна правка правила, а
allowlist легитимных чисел становится вторым источником долга. Худший исход —
не «шумно», а «вроде гейт, а толку нет»: срабатывания на пустом месте
забейзелят, и через месяц правило перестанет ловить настоящие нарушения.
Видимое блокирующее правило, которое на деле ничего не проверяет, хуже
отсутствия правила.
- Соблюдение правила дешевле обхода: «список — в `docs/tools-map.md`» стоит
столько же символов, сколько цифра, но не требует обновления.
- Пересмотреть, если появится источник истины, из которого число можно
**вычислить** и сравнить автоматически, без разбора прозы. Такой случай есть и
решён отдельно — следующий пункт.
## 4. Сверка `docs/tools-map.md` с реестром — автоматизирована
Правило про числа не закрывает более опасный класс расхождений: документ не
устарел числом, а **перечисляет то, чего нет в коде**. В разделе «Реализовано»
карты лежали несуществующие страницы (`png-info`, `color-wheel-generator`,
`jpg-to-png`, `watermark-image-png`), которые агент принял бы за готовые
инструменты. Счёт при этом был верный — число такой класс не ловит.
Сверка множеств отвечает на вопрос однозначно и без allowlist, поэтому
автоматизирована: `web/src/lib/registry/tools-map-doc.test.ts` парсит раздел
«Реализовано» и сравнивает slug с `PAGES` в обе стороны (slug без страницы и
страница без записи), плюс ловит повтор slug между буллетами. Формат строки
карты — всё до первого тире, slug через `/` или `,` — закреплён в самом
документе и в тесте. Сверяются именно страницы, а не инструменты: карта
описывает то, что видит пользователь, а один инструмент может обслуживать
несколько страниц (см. `docs/architecture.md`, раздел 3).
Чтобы сломанный парсер не выглядел как успех, у теста есть отдельная проверка
«разбор не пустой»: если формат карты снова поменяется, тест упадёт с сообщением
про парсер, а не молча перестанет что-либо проверять. Входит в обычный
`pnpm --dir web test`; новых гейтов и конфигов не добавляет.
Граница автоматизации: парсер читает разделы «Реализовано», «Можно добавить» и
«Идеи», но проверяет их в разных направлениях. Раздел 1 сверяется с реестром
обеими сторонами (slug без страницы и страница без записи). Разделы 2 и 3
сверяются только в одну сторону: там slug'и намеренно несуществующие, поэтому
проверяется, что **ни один из них не является реальной страницей** —
реализованный инструмент, оставшийся в плане, роняет тест. Проверять «у плана
есть запись в реестре» нельзя по построению: список плановый, а не фактический.
Плановые разделы устроены прозой, поэтому их разбор другой: берутся все токены
нужной формы из всего буллита, а не только из «головы» до тире. Следствие для
авторов карты: реализованные страницы в планах называются прозой между
буллитами, а не внутри них.
## 5. Visual snapshots — не вводим
`toHaveScreenshot` сравнивает страницу с PNG-baseline из прошлого коммита, а не
с замыслом дизайна. При активно меняющемся визуальном языке это превращает
каждый дизайн-PR в регенерацию и ревью картинок, а baseline платформенные
(снимок с Windows не совпадёт на `ubuntu-latest`). Потребность закрывают
e2e-поведение, `lint:tokens` с token audit, i18n-гейт и ручной чеклист
`docs/checklist-manual-testing.md`.
Вернуться стоит, когда визуальный язык стабилизируется; тогда первым идёт `/kit`
в двух темах, а baseline рождаются на Linux.
## 6. Fail-режим для unused-token аудита — не вводим
Остаётся предупреждением: `verify` входит в deploy-workflow, поэтому стилевой
долг блокировал бы деплой сайта. Кроме того, аудит видит только ссылки
`var(--x)`, и чтение токена из JS дало бы ложное срабатывание. Правило «новый
токен — вместе с потребителем» живёт в `AGENTS.md`.
## 7. Per-project `expect.timeout` — не задаём
Firefox и WebKit отдают результат так же быстро, как Chromium: расхождения
объясняются памятью, а не скоростью движка. Таймаут под конкретную машину —
магическое число, которое придётся подстраивать на каждом раннере, поэтому
Playwright-конфиг остаётся с дефолтами, а проблема решается лимитом воркеров.
Практические правила — `docs/agent-workflow.md`, раздел 3.
## 8. Дублирование `verify` между workflow — сознательно
`Deploy to GitHub Pages` и `Quality` оба зовут `verify`: deploy проверяет
быстрый gate и собирает сайт, `Quality` прогоняет тот же `verify` и browser
matrix. По замеру (сентябрь 2026) `verify` — единицы минут, matrix — около трёх,
поэтому дублирование дешевле условных job-и, а matrix дёшево держать на каждом
PR. Актуальные длительности — в логе прогона. Ручной
`gh workflow run quality.yml` нужен только для перепрогона старого коммита.
## 9. Политика e2e в CI
Текущее состояние: `e2e` — non-blocking baseline, запускается на каждом пуше в
`main` и на каждом PR целиком (все проекты matrix). Ночного разделения нет: при
стоимости job около трёх минут оно не окупается, поэтому новые browser-critical
спецификации просто добавляются в `browserContractTests`.
`e2e` станет blocking, когда выполнятся три условия:
1. Условия matrix пересмотрены: решено, какие спецификации обязаны идти в
`browserContractTests` и `mobileTests`, нужен ли `png-fixtures` вне Chromium
и что делать с engine-специфичными расхождениями декодеров.
2. Накопились стабильные прогоны на раннере без падений по таймингу.
3. Вместе с `continue-on-error` снимается и сам trick с общим conclusion: иначе
зелёный прогон при красном `e2e` останется возможен.
Пока условия не выполнены, `e2e` остаётся non-blocking, а его статус читается по
отдельному job — см. следующий раздел.
## 10. Чтение результатов CI после пуша
Первый реальный прогон browser matrix происходит на runner, а не на локальной
машине: локально Firefox и WebKit проходят на 1 воркере, но состав, скорость и
память Linux-раннера отличаются. Поэтому после пуша разработчика:
1. дождаться завершения workflow и посмотреть, какие job вообще запустились;
2. смотреть статусы **отдельных job**, а не общий conclusion прогона: у `e2e`
стоит `continue-on-error: true`, поэтому прогон с упавшим e2e целиком
помечается как `success` — проверено на run `36225579363` (`E2E: failure` при
`conclusion: success`);
3. если `e2e` запускался и зелёный — сообщить фактические числа passed, failed,
skipped;
4. если `e2e` не запускался, сказать это прямо: matrix на раннере не проверена,
и её можно прогнать через `gh workflow run quality.yml`;
5. красный `e2e` только из-за ресурсов раннера фиксируется как ограничение
окружения: сначала проверяются один проект и один spec
(`docs/agent-workflow.md`, раздел 3), а не увеличиваются таймауты.
## 11. Тексты страниц: в базовой локали секция `pages` пустая
Заголовки и описания страниц переводятся (`pages.<slug>.*`), а EN-строка лежит в
реестре страниц — дублировать её в `en.ts` нельзя, иначе при правке реестра
английский текст разъедется со страницей, а `dict-consistency` этого не увидит.
Поэтому `en.ts` объявляет `pages: {}` — пустую секцию, а линтер правила
`i18n/dict-consistency` не считает это нарушением: в базовой локали отсутствие
`pages.*.title/description` разрешено опцией `baseLocaleFallback`, а полнота
перевода для остальных локалей проверяется строго и юнит-тестом
(`tools-i18n.test.ts`).
Альтернатива — `allowPaths: ["pages"]` — была бы слабее: она разрешила бы пустую
секцию `pages` в любой локали, то есть пропущенный перевод страницы прошёл бы
линтер молча. Пустая секция в EN и есть то, что делает правило точным.
## 12. Бамп версии и changelog — при пуше в main, а не в PR
Версионирование и changelog генерирует бот (`bump.yml`, cocogitto) на push в
`main`, а не на pull request.
- При PR-боте параллельные PR бампили бы одну и ту же версию и конфликтовали по
`CHANGELOG.md`; коммиты бота внутри чужого PR — шум, а версия росла бы за ещё
не влитый код. Репозиторий trunk-based: коммиты идут прямо в `main`, деплой и
так запускается на push.
- Цикл: push в `main` → `verify.yml` (переиспользуемый gate) → `cog bump --auto`
бампит версии в обоих `package.json` (футер сайта читает `web/package.json`),
обновляет `CHANGELOG.md`, ставит тег и пушит без `--skip-ci`, чтобы версионный
коммит заново запустил деплой и сайт показал актуальную версию. Зацикливание
исключено dry-run проверкой: после версионного коммита conventional-коммитов
после тега нет, бот выходит.
- Пуш идёт токеном GitHub App (`XBOCT_BOT_ID`/`XBOCT_BOT_PRIVATE_KEY`), а не
`GITHUB_TOKEN`: пуш стандартным токеном не запускает workflow, и деплой с
новой версией не случился бы.
- `concurrency: bump` без `cancel-in-progress`: два бампа параллельно запушили
бы конфликтующие версии.
- Пересмотреть, если появятся внешние контрибьюторы и поток пойдёт через PR —
тогда боту понадобится режим «черновик релиза в PR» или squash-стратегия с
conventional-заголовком PR.
+429
View File
@@ -0,0 +1,429 @@
# План: исправления по аудиту UX/UI
> Статус: **draft** — на ревью.
>
> Находки — `docs/tools-audit.md` (что плохо, с датой прохода). Этот документ
> задаёт **порядок правок** полей и раскладки волнами; сами задачи — в
> `docs/backlog.md`. Проверки и гейты — `docs/quality-gates.md`; точки касания
> при добавлении вида поля — `docs/architecture.md`, раздел 4.
>
> **Идентификаторы шагов — `FA-*` (fields-audit).** Буквы `C*`/`Q*` в других
> документах относятся к архивным волнам редизайна и здесь не используются.
>
> **Как читать волны.** Шаг с пометкой «реализовано» — закрытый, его правку
> можно не повторять; шаг без пометки — работа. Пометка стоит в первом абзаце
> шага, а не в заголовке, чтобы не потерять её при чтении оглавления. Задачи,
> которые в бэклоге, здесь не дублируются: этот документ задаёт **порядок**,
> backlog — **учёт** (`docs/backlog.md`).
>
> **В этом документе инструменты названы по `id`** (`registry/tools/`), а не по
> адресу страницы: поля и раскладка принадлежат схеме инструмента, а страница
> только ссылается на список шагов (`Page.steps`), где сейчас ровно один
> инструмент, а в будущем может быть несколько. Поэтому «у страницы `crop-png`
> есть поля x/y» неверно — поля есть у инструмента `crop`, а страница лишь
> показывает его. `id` не несёт png-интента: страница `crop-png` вызывает
> `id: "crop"`, страница `png-to-hsl` — `id: "to-hsl"` (модель —
> `docs/architecture.md`, раздел 3).
>
> **Как переносить пункты аудита.** `docs/tools-audit.md` назван по страницам,
> потому что это то, что видит пользователь. При переносе пункта в шаг мапить
> slug → `id` через `page.steps[0].id` в `registry/pages/`. Названия аудита вида
> `color-wheel-generator` и `webp-to-png` в реестре отсутствуют (реальная
> страница — `color-wheel-png`, инструмент — `color-wheel`).
## 0. Ключевое решение: select → buttons
Существующий `Segmented.svelte` — компонент с группированными кнопками (общая
рамка, разделители между сегментами). Для параметров инструментов он не
подходит: при 3+ опциях сегменты сжимаются, текст не читается.
**Решение:** новый компонент `ButtonsControl` — раздельные outline-кнопки с
отступами. Каждая опция — отдельная `<button>` со своей рамкой, активная
подсвечивается заливкой (`--color-main`). Не зависит от `Segmented`.
### Компонент `ButtonsControl`
```svelte
<!-- web/src/lib/components/fields/schema/ButtonsControl.svelte -->
<div class="buttons" role="group">
{#each options as opt}
<button
class="btn"
class:selected={value === opt.value}
aria-pressed={value === opt.value}
onclick={() => select(opt.value)}
>{opt.label}</button>
{/each}
</div>
```
```css
.buttons {
display: flex;
flex-wrap: wrap;
gap: var(--space-m);
}
.btn {
padding: var(--space-m) var(--space-l);
border: var(--size-border) solid var(--color-border);
border-radius: var(--radius-s);
font: var(--font-size-s) var(--font-mono);
color: var(--color-text-muted);
background: transparent;
cursor: pointer;
}
.btn.selected {
background: var(--color-main);
color: var(--color-background);
border-color: var(--color-main);
}
```
### Схема: `field.buttons()`
Новый тип в `registry-schema.ts`:
```ts
export interface ButtonsSpec<V extends string = string> extends FieldSpecBase {
kind: "buttons";
default: V;
options: { value: V; label: string }[];
}
```
Фабрика:
```ts
buttons: <V extends string>(s: Omit<ButtonsSpec<V>, "kind">): Field<V> => ({
spec: { kind: "buttons", ...s },
}),
```
Регистрация: добавить `"buttons": {} as ButtonsSpec` в `fieldSpecs`, добавить
`ButtonsControl` в `FIELDS` в `SchemaFields.svelte`, добавить дефолт/санитайз в
`sanitizeSchemaParams` и `defaultSchemaParams`.
---
## 1. Волна A — инфраструктура (1 коммит)
### FA-A1. Новый тип `buttons` в схеме
Файлы:
- `web/src/lib/registry-schema.ts` — `ButtonsSpec`, фабрика `field.buttons()`,
кейс в `sanitizeSchemaParams`, кейс в `defaultSchemaParams`
- `web/src/lib/components/fields/schema/ButtonsControl.svelte` — новый компонент
- `web/src/lib/components/SchemaFields.svelte` — импорт + запись в `FIELDS`
### FA-A2. Seed → input + randomize
Для `field.number()` с `kind: "seed"` (или новый подтип `field.seed()`) —
рендерить поле ввода + кнопку «Random» вместо range slider.
Решение: новый вид `field.seed()` (наследует от number, но рендерится иначе).
Или проще: опциональное поле `variant?: "seed"` в `NumberSpec` — тогда
`RangeControl` покажет input + кнопку.
Решение: **`variant: "seed"` в `NumberSpec`** — меньше новых типов.
Файлы:
- `web/src/lib/registry-schema.ts` — `NumberSpec.variant?: "seed"`
- `web/src/lib/components/fields/schema/RangeControl.svelte` — условный рендер
- `web/src/lib/registry/tools/filters.ts` — `randomizePixels`, `addNoiseTool`:
поле seed → `{ ...field.number(...), spec: { ...spec, variant: "seed" } }`
(или пересоздать через `field.seed()`)
---
## 2. Волна B — select → buttons (по файлам)
Каждый шаг — один коммит, < 500 строк. Меняем `field.select()` →
`field.buttons()` в перечисленных ниже схемах.
### FA-B1. geometry.ts
| Инструмент | Поле | Опции |
| ------------------- | -------- | ----------------------------------------- |
| rotate | angle | 90° / 180° / 270° |
| flip | axis | horizontal / vertical |
| swap-orientation | target | portrait / landscape |
| symmetric-copy | axis | vertical / horizontal |
| symmetric-copy | keepSide | left / right / top / bottom |
| change-aspect-ratio | ratio | 1:1 / 4:3 / 3:4 / 3:2 / 2:3 / 16:9 / 9:16 |
| change-aspect-ratio | mode | crop / pad |
**Исключение:** `change-canvas-size` (anchor, 9 опций) — оставить
`field.select()`, т.к. 9 кнопок в ряд не поместятся. Аналогично `position9` (уже
отдельный компонент `PositionControl`).
### FA-B2. color.ts
| Инструмент | Поле | Опции |
| -------------------- | --------- | ---------------------------------------------------- |
| dithering | pattern | floyd-steinberg / bayer |
| extract-channel | channel | red / green / blue |
| swap-channels | pair | r-g / r-b / g-b |
| decrease-color-count | maxColors | 2 / 4 / 8 / 16 / 32 / 44 / 64 / 96 / 128 / 192 / 256 |
**Исключение:** инструменты разложения каналов (`to-hsl`, `to-hsv`, `to-hsi`,
`to-cmyk`, `to-ycbcr`, `to-lab`, поле `component`) — 3-6 опций, но это
динамические инструменты, оставить `select`.
### FA-B3. analyze.ts
| Инструмент | Поле | Опции |
| --------------------- | ---- | ------------------ |
| show-transparent | mode | binary / highlight |
| show-grayscale-pixels | mode | (тот же) |
| show-color-pixels | mode | (тот же) |
| light-pixel-mask | mode | (тот же) |
| dark-pixel-mask | mode | (тот же) |
| unique-color-mask | mode | (тот же) |
Пять инструментов из шести берут `mode` из общего `maskBaseFields` — правка в
одном месте. Исключение — `show-transparent`: он переопределяет `mode` своими
опциями и дефолтом `highlight`, поэтому его `mode` правится отдельно.
### FA-B4. filters.ts
| Инструмент | Поле | Опции |
| ---------- | ---- | ------------ |
| add-noise | mode | mono / color |
### FA-B5. generate.ts
| Инструмент | Поле | Опции |
| -------------- | ------------ | --------------------- |
| color-spectrum | direction | horizontal / vertical |
| step-colors | layout | grid / strip |
| complementary | layout | grid / strip |
| triadic | layout | grid / strip |
| tetradic | layout | grid / strip |
| analogous | layout | grid / strip |
| monochromatic | layout | grid / strip |
| shades | layout | grid / strip |
| sort-colors | layout | grid / strip |
| mix-colors | (нет select) | — |
---
## 3. Волна C — обязательные фиксы (баги + дефолты)
### FA-C1. Source-aware defaults для dimension-полей — реализовано
Source-aware defaults реализованы: общий resolver в `registry-schema`
(`applySourceDefaults`). Для `resize` и `crop` дефолт после загрузки равен
размерам текущего source; до загрузки используется положительный fallback `1×1`.
Один-sided `0` у resize сохраняет режим auto. Проверено тестом
`registry.test.ts` («resize и crop получают размеры текущего source»).
### FA-C2. symmetric-copy: "keep side" не работает
Баг в `run()`: `keepSide` не учитывает `axis`. При `axis: "vertical"` (удвоение
ширины) работают только `left`/`right`, `top`/`bottom` не имеют смысла. Нужно:
либо фильтровать опции `keepSide` в зависимости от `axis` (reactive schema),
либо нормализовать в `run()`.
**Решение:** reactive — при смене `axis` сбрасывать `keepSide` на допустимое
значение. Пока достаточно в `run()`: если `axis === "vertical"` и `keepSide` ∈
{top, bottom} → заменить на `left`.
### FA-C3. Source-aware bounds (отдельный следующий шаг) — не начат
Source-aware **defaults** реализованы через `SchemaContext` и
`applySourceDefaults`. Динамические min/max для crop x/y, shift и resize не
реализованы и остаются отдельной задачей: bounds не следует смешивать с default
resolver.
**Что нужно спроектировать и завести:**
1. **Где берём размер.** `SchemaToolView` уже знает размер после `decodeFile()`
→ `PixelImage` (width/height); для bounds понадобится передать этот context в
`SchemaFields` и контролы.
2. **Как схеме описать зависимость.** В спеках добавить ссылку на размер
источника вместо жёстких чисел. Кандидат:
```ts
x: field.slider({
label: "fields.x",
bound: "sourceWidth", // min/max = ±sourceWidth
default: 0,
}),
```
`BoundSpec = "sourceWidth" | "sourceHeight" | "minSide" | "maxSide"`,
применимо к `NumberSpec`, `SliderSpec`, `OffsetSpec`, `DimensionSpec`.
3. **Что делает RangeControl.** При `bound` вычисляет min/max из текущего
размера источника; при смене картинки диапазон пересчитывается, текущее
значение пере-клампится.
4. **Первые потребители:** crop (x/y → ±sourceWidth/sourceHeight), shift
(offsetX/offsetY), resize (max → maxSide).
Это отдельный рефакторинг; source-aware defaults не заменяют bounds.
### FA-C4. verify-is-png: неверное название — решение принято, шаг не выполнен
Находка про **страницу**, а не про инструмент: страница `verify-is-png` обещает
проверку PNG-файла, а проверяет текст (base64 / data-uri). Сам инструмент назван
честно — `id: "verify-png"`, png-интент в нём означает входные данные. Адрес
страницы — `slug` `verify-is-png` (`docs/architecture.md`, раздел 3), поэтому
«неверное название» относится к `slug`, а не к `id`.
Варианты:
- `title` → «Verify PNG data», `description` → уточнить; `slug` и `id` не
трогать
- либо переименовать `slug` → `verify-png-data` с редиректом со старого URL
(нужен вордстат, иначе страница выпадает из индекса)
**Решение:** поменять title и description, имя страницы не трогать.
Переименование `slug` меняет адрес, имя файла, ключи `pages.*` и иконку, то есть
это операция с SEO-последствиями, а не строчка в реестре — она не делается в
одиночку, вместе с решением по `docs/plan-seo.md` §5.
---
## 4. Волна D — обязательные фиксы (новые параметры)
### FA-D1. remove-alpha-channel: выбор цвета фона
Сейчас захардкожен `#ffffff`. Добавить `field.color({ default: "#ffffff" })`.
Файл: `alpha.ts`, схема `removeAlphaChannelSchema`.
### FA-D2. extract-alpha-mask: галочка «инвертировать»
Добавить `field.checkbox({ label: "fields.invertMask", default: false })`. В
`run()`: если `invert`, вызвать `invertAlpha()` после `extractAlphaMask()`.
Файл: `alpha.ts`.
### FA-D3. change-canvas-size: position9 вместо select
`anchor` сейчас `field.select()` с 9 опциями → `field.position9()`.
Файл: `geometry.ts`. Уже есть `PositionControl`.
### FA-D4. Generate: добавить height
Инструменты без `size`/`height`:
- blend-two — добавить
`height: field.slider({ min: 1, max: 1024, default: 512 })`
- step-colors — аналогично
- complementary, triadic, tetradic, analogous, monochromatic, shades — все
используют `paletteBaseSchema` (общий объект), добавить `height` туда
- `sort-colors` — своя схема, `paletteBaseSchema` не использует, `height`
добавлять вручную
- mix-colors — добавить height
Файл: `generate.ts`.
### FA-D5. add-border: прозрачность цвета
Текущий `field.color()` не поддерживает alpha. Пока что: оставить как есть
(прозрачность не поддерживается нативным color picker). Отметить в аудите как
blocked.
---
## 5. Волна E — полировка (некритично)
### FA-E1. Плейсхолдер "нет параметров"
Инструменты с пустой схемой (`EmptyParams`) выглядят странно. Добавить
`EmptyState` или подсказку "No parameters — click Run".
Файл: `SchemaToolView.svelte` — показать подсказку если `schema.fields` пуст.
### FA-E2. quantize: пресеты
`field.slider` с max 64 → добавить пресеты (8, 16, 32, 64) как кнопки под
слайдером. Либо `field.buttons()` с 4 опциями вместо слайдера.
### FA-E3. trim-empty-space: 0-254 → проценты
Текущий `min: 0, max: 254` — нестандартно. Добавить переключатель px/% (аналог
шага FA-A1 — `variant: "alpha-threshold"` в NumberSpec).
### FA-E4. add-text: plate offset
Plate снизу имеет большой отступ — добавить галочку "compact plate" или
уменьшить дефолтный padding.
### FA-E5. from-emoji: выбор эмодзи
Поле `emoji` → добавить группу часто используемых эмодзи как кнопки-пресеты над
полем ввода.
---
## 6. Верификация
Проверки и их минимальный набор — `docs/quality-gates.md`, раздел «Быстрые
подмножества»; правила e2e — `docs/testing-strategy.md`. Для волн с изменением
пользовательского потока (новый контрол, новая раскладка) обязателен ручной
смоук в браузере: часть находок — про воспринимаемость, которую не ловит ни один
гейт. `pnpm verify` — перед ревью волны.
---
## 7. Не покрыто / требует решения
Эти пункты из аудита не попали в волны A–E. Требуют либо дополнительного
исследования, либо нормативного решения, либо отложены.
### 7.1 Требуют normative решения
- **Pixel ↔ Percent** (circle-mask, square-mask, star-mask, wavy-mask — %→px,
round-corners — px→%): нужен компонент переключателя единиц в слайдере.
Требует правки `RangeControl` + всех схем — в волны A–E не входит, нужен
отдельный этап.
- **Smoothing checkbox** для масок (circle-mask, square-mask, star-mask,
wavy-mask, round-corners, invert-alpha): нужен `field.checkbox("smoothing")` +
правка core (shapes.ts, alpha). Отложен — требует визуального тестирования.
- **Vignette**: выбор центра + цвет + feather — 3 новых параметра. Требует
normative решения по UI.
- **Remove-background**: выбор точки удаления — либо параметр, либо отдельный
инструмент.
- **Метод масштабирования в `resize`**: поля в схеме нет (только `size` и
`keepAspect`), поэтому это уже не правка селекта, а новое поле — нужен ли
выбор алгоритма (bilinear/bicubic/nearest)?
- **Placeholders**: "нет параметров" — какой текст/компонент показывать.
### 7.2 Операционные (тест-кейсы, описания)
- find-contour: расширять на толщину линии (требует правки core)
- make-thicker: расширять картинку на толщину линии
- make-thinner: уменьшать от краев, fix выступа
- feather-edges: расширять картинку на толщину линии
- clean-edges: найти тест-кейс, возможно увеличить максимум
- despeckle-alpha: найти более заметный тест-кейс
- close-holes: проверить поведение на больших полупрозрачных областях
- auto-contrast: найти тестовый кейс
- sharpen: добавить тест-кейс
- center-by-alpha: найти тест-кейс
- is-grayscale: улучшить видимость результата
### 7.3 Дизайн-вопросы
- add-padding vs add-border: разница неочевидна, нужен review
- change-aspect-ratio: не видно что меняется — рамка результата
- watermark-tile: заполняет не полностью
- sepia: нужна ли настройка силы эффекта?
- posterize: настройка для каждого канала отдельно?
- pixelate: разные алгоритмы?
- swap-channels: обдумать flow через более простые инструменты
### 7.4 Аудит полей (компоненты)
- Range slider: поле ввода + кнопки +/- + кнопка сброса
- Color picker: пипетка с картинки, прозрачность
- Width/height с keep-aspect-ratio: единый компонент
- Position offset: отдельные числовые поля X/Y (уже сделано в
`fields/schema/OffsetControl.svelte`)
+144
View File
@@ -0,0 +1,144 @@
# План: платформа — общее ядро, API и PWA
> **Статус: active.** Этот план добавляет к `docs/roadmap.md` то, чего там нет:
> **API** (фаза 9, возможная монетизация) и **PWA** (фаза 10, оффлайн +
> установка), плюс шрифты (раздел 4 — частично относится к фазе 8, поэтому
> решения фазы 8 здесь дублируются). WASM-ядро, эталоны и CLI — фазы 2–8
> `docs/roadmap.md`, они здесь не пересказываются. Оба трека стартуют после
> wasm-фаз; PWA не ждёт API и может идти раньше.
## 0. Решения
- **Одно ядро — три потребителя.** Rust-крейт `core/` (roadmap фаза 4) даёт:
wasm-пакет для сайта (фаза 5), нативный CLI (фаза 6), серверный слой API (фаза
9). Единый контракт — JSON-пайплайн `{id, params}` (заложен в roadmap §0 и
совпадает с `Page.steps`, реализуется в фазе 11); эталонные файлы и harness
(фазы 2, 3, 7) гарантируют одинаковый результат во всех рантаймах.
- **API — нативный Rust на сервере, не wasm.** В браузере wasm нужен ради
доставки и песочницы; на сервере крейт линкуется напрямую — быстрее и проще.
Исключение — edge-воркеры (см. спайк ниже): там исполняется тот же
wasm-артефакт фазы 5.
- **PWA не трогает SEO-схему.** Сайт остаётся полностью prerendered-статикой;
service worker добавляет оффлайн-слой поверх и не отвечает за HTML ботовам.
- **Монетизация не ломает позиционирование.** Браузерные инструменты —
бесплатные, картинка обрабатывается в браузере и никуда не отправляется.
Счётчик посещений GA4 этому не противоречит (границы — `docs/plan-seo.md` §0).
API — отдельный продукт для автоматизации/бизнеса. Файлы в API обрабатываются
в памяти: без хранения и логирования содержимого.
## 1. Фаза 9 — API (после фаз 5–7)
- [ ] **[A1]** Спайк сервера: (а) axum/actix + нативный `core/` на VPS против
(б) wasm-артефакт фазы 5 на edge (CF Workers). Сравнить: стоимость, лимиты
памяти/времени на больших картинках, сложность деплоя и rate-limiting.
Решение записать в §0 этого файла.
- [ ] **[A2]** Контракт: `POST /v1/process` — multipart (image) + JSON-пайплайн
→ изображение/архив; лимиты размера, коды ошибок; OpenAPI-спека.
- [ ] **[A3]** Ключи и лимиты: Bearer-ключи, free tier (N запросов/мес), rate
limiting по ключу; самостоятельная выдача/ротация ключей в кабинете —
позже.
- [ ] **[A4]** Биллинг: Stripe/LemonSqueezy (merchant of record), 2–3 тира по
запросам/размеру файла. Только после стабильного free tier.
- [ ] **[A5]** Документация как лендинг: `/api` — индексируемая страница
(туториал, примеры curl/JS), OpenAPI, ссылка из `llms.txt`. Dev-аудитория
— источник бэклинков (`docs/plan-seo.md`, S4c).
- Гейт: один и тот же пайплайн даёт пиксельно идентичный результат в браузере,
CLI и API (harness фазы 7 расширяется третьим рантаймом).
## 2. Фаза 10 — PWA (после S1)
- [ ] **[P1]** Манифест: имя (бренд после R1 из plan-seo §6), иконки 192/512 +
maskable, theme-color из дизайн-токенов, `display: standalone`.
- [ ] **[P2]** Service worker (SvelteKit `src/service-worker.ts`): precache
app-shell; immutable-ассеты (build-hash) — cache-first; навигация по
инструментам — stale-while-revalidate; offline-fallback страница. Спайком
оценить вес полного precache: страницы маленькие, тяжёлое тащить по
требованию.
- [ ] **[P3]** Оффлайн-UX: индикатор «offline — работаете локально», своя кнопка
установки (на `beforeinstallprompt`), стратегия обновления версии (prompt
vs skipWaiting — решить на спайке).
- [ ] **[P4]** SEO-безопасность: SW не перехватывает запросы ботов и не меняет
HTML-ответы; canonical/prerendered-страницы не затрагиваются. Проверка:
Lighthouse PWA + SEO зелёные после внедрения.
- Гейт: каталог и топ-инструменты полностью работают в airplane-mode; повторный
визит без сети открывается мгновенно.
## 3. Порядок и связи
- WASM-ядро, эталоны, CLI — `docs/roadmap.md` фазы 2–8; пайплайн как продуктовая
фича — фаза 11 того же документа.
- PWA — после S1 `plan-seo.md`; API — после фаз 5–7.
- Синергия с дистрибуцией: «works offline, installable» и публичный API —
аргументы для Product Hunt / alternative.to / GitHub (`docs/plan-seo.md`, S4c)
и для GEO-текстов (`llms.txt`, описания категорий).
## 4. Шрифты: лицензирование и DOM-free рендер текста
Текст — один из canvas-зависимых классов, которые roadmap (фаза 8) держит в TS
(перечень — там же); для текста выбран курс на реимплементацию, остальные классы
не переносятся. Для CLI/API текст всё равно придётся рендерить в Rust, поэтому
решение: **перевести text-инструменты в ядро** (в браузере canvas-рендер
остаётся TS-фолбэком для A/B). Два вопроса — легальность шрифтов в дистрибутиве
и рендер без DOM.
**Когда начинать:** блока F1–F4 нет — они не привязаны к фазе roadmap, но F5/F6
без них бессмысленны (нечего рендерить). Реализуется всё это в фазе 8, поэтому
лицензирование стоит закрыть до неё, а не после.
### Лицензирование (решить до выбора шрифтов, backlog «Font Style»)
- [ ] **[F1]** В набор — только лицензии, допускающие **коммерческое
использование и встраивание в продукт** (сайт + PWA + CLI + API): база —
SIL OFL 1.1 (можно редистрибутировать и использовать коммерчески; нельзя
продавать сами шрифты; требование — сохранять лицензию и имена при
редистрибуции) и Apache 2.0 (Roboto, Open Sans, часть Noto).
- [ ] **[F2]** Запрещённые категории: «free for personal use», shareware, шрифты
без явной лицензии, MS core fonts (лицензия привязана к поставке с
Windows-ПО). Отдельно проверять: бесплатность «для веба на одном домене» ≠
право класть TTF в дистрибутив CLI/API — у OFL/Apache таких ограничений
нет, у прочих «free»-лицензий бывают.
- [ ] **[F3]** Паспорт шрифта в `core/fonts/<name>/` (или эквивалент): файл(ы),
LICENSE, источник, версия, хеш; **новый гейт** «у каждого файла шрифта
есть паспорт» — его ещё нет, проектируется здесь и добавляется в
`pnpm verify` по правилам `docs/quality-gates.md` (решение о fail-режиме —
`docs/decisions.md`). Браузер грузит шрифты из бандла, не из ОС и не с
`fonts.googleapis.com`-CDN (сторонний запрос = утечка IP пользователя,
лишняя зависимость от чужой инфраструктуры), — одинаковый результат у всех
пользователей и во всех рантаймах.
- [ ] **[F4]** Кириллица: приоритет семействам с полным покрытием — Liberation
Sans, PT Sans/PT Serif, Noto Sans, Inter, Roboto (OFL/Apache), DejaVu Sans
(пермиссивная лицензия Bitstream Vera: редистрибуция и коммерческое
использование разрешены) — закрывает и backlog-вопрос про «только с
кириллицей».
- **Стартовый набор** (все OFL/Apache, с кириллицей; состав не догма —
расширяется по запросам, каждый проходит паспорт F3). Сейчас в бандле сайта
лежат `@fontsource/ibm-plex-sans` и `@fontsource/ibm-plex-mono` (тоже OFL) — в
наборе ниже их нет, и при введении паспорта F3 их судьбу нужно решить явно:
оставить или заменить.
- базовый sans: **Inter** (дефолт add-text), **PT Sans** / **Golos Text**
(профессиональная русская типографика), **Noto Sans** (фолбэк покрытия);
- serif: **PT Serif**, **Lora**, **Playfair Display** (display);
- mono: **JetBrains Mono** (дефолт), **Roboto Mono**;
- display/акцент: **Oswald** (узкий), **Rubik** (округлый), **Comfortaa**,
**Caveat** (рукописный), **Amatic SC** (маркерный);
- кириллицу проверить по файлу при добавлении: Bebas Neue, Pacifico
(добавлялась поздними версиями). Источник файлов — только Google Fonts /
Fontsource (версионированные пакеты под паспорт F3); случайные «free
font»-сайты — нет. Вес: woff2-сабсеты latin+cyr лениво в браузере, TTF в CLI
(~2–4 МБ суммарно), в wasm не зашивать — грузить из статики по требованию.
### Рендер без DOM (спайк — фаза 5–6, реализация — фаза 8)
- [ ] **[F5]** Спайк стека: растеризация глифов (`ab_glyph`/`rusttype`) +
шейпинг (`swash`/`harfrust`; для латиницы/кириллицы сложный шейпинг не
критичен, сложные письменности — осознанно не в первой версии) → отрисовка
в наш `Image`. Эталон — canvas-рендер TS (perceptual-класс по roadmap §3):
совпадать обязаны позиция/выравнивание/переносы, пиксели будут отличаться
хинтингом/антиалиасингом.
- [ ] **[F6]** Единый текстовый API для TS-core и Rust-core: параметры текста —
из JSON-схемы инструмента, шрифт — **id из реестра шрифтов** (не системное
имя), метрики базовой линии считать в ядре, а не через canvas
`measureText` (иначе TS и Rust разъедутся).
- Гейт: один и тот же текстовый шаг пайплайна даёт в браузере (wasm), CLI и API
визуально идентичный результат; в дистрибутиве нет ни одного файла шрифта без
паспорта лицензии.
+438
View File
@@ -0,0 +1,438 @@
# План: SEO + GEO — индексация и конкуренция в image tools
> **Статус: active.** Адреса страниц инструментов — `/tools/<slug>` (`crop-png`
> и подобные), каталог — `/list-tools`; реестр двухслойный: страница `slug`
> ссылается на инструмент по `id` (`docs/architecture.md`, раздел 3).
>
> **`noindex, nofollow` выставлен на каждой странице инструмента**
> (`web/src/routes/tools/[slug]/+page.svelte`) — осознанно, до появления
> уникального контента (S2). Пока он стоит, сайт не индексируется вообще, и
> **S1b–S1e (sitemap, `robots.txt` с `Sitemap:`, canonical, OG) не дают
> эффекта**: их имеет смысл делать вместе со снятием `noindex`, а не вместо
> него. Условие снятия и порядок — §4, S1.
>
> **Цель:** страница каждого инструмента — индексируемый лендинг под конкретный
> интент («crop png», «make png transparent», «jpg to png»…) плюс попадание в
> ответы генеративных систем (ChatGPT, Perplexity, AI Overviews) — GEO.
>
> **Позиционирование:** не «PNG-инструменты», а **image tools с PNG-ядром** —
> любой инструмент принимает PNG/JPG/WebP/GIF/BMP и отдаёт результат в
> PNG/JPG/WebP/BMP через селектор в кнопке Download. Дифференциатор —
> прозрачность/альфа/фон и no-upload-приватность. Референс-конкурент по ядру —
> onlinepngtools.com (§2). Долгосрочно ядро переезжает на wasm и обрастает API +
> PWA (`docs/plan-platform.md`); для SEO это аргументы дистрибуции и GEO-факты,
> схему сайта они не меняют.
>
> **Слаги в этом документе:** `jpg-to-png`, `crop-jpeg` и подобное — интенты и
> URL конкурента, а не наши страницы. Наши страницы — в `docs/tools-map.md`
> (карта перечисляет `slug`, не `id`); в тексте ниже названия инструментов
> приводятся по этим слагам, потому что для SEO важны адреса.
## 0. Решения
- **Бренд: переименовать до индексации** (новое имя и домен сознательно не
записаны в этом репозитории — чек-лист в локальном файле
`plan-domain.local.md`, см. §6, он вне git: выбранное имя не должно утекать в
историю коммитов до покупки домена). Легенда честная: «started as PNG tools,
now all formats» — это контент-плюс, а не баг. Адреса страниц
(`/tools/crop-png`) и localStorage-ключи не переименовывать (см. решение про
`slug` ниже). Точка истины по бренду: константа в i18n + README; хардкод в
компонентах недопустим.
- **Форматы: формат = выбор в UI, не отдельные страницы.** Download-кнопка
каждого инструмента — с селектором PNG/JPG/WebP/BMP (+quality для lossy). Райз
«выбор формата/качества в Download» вытащен из backlog №7 в S1f: механика уже
есть (`Tool.output`), нужна как продуктовая основа «image
tools»-позиционирования. В направлении «из формата в PNG» отдельных
инструментов нет (загрузка принимает любой формат) — см. §5.
- **Адреса страниц `-png` сохраняются, внутренние имена — без.** Инструмент
работает с любым форматом, поэтому его `id` не должен врать (`crop`, а не
`crop-png`); png-интент держат адрес страницы и имя скачиваемого файла.
**Реализовано:** реестр разделён на инструменты (`registry/tools/`, уникальный
`id`) и страницы (`registry/pages/`, уникальный `slug`), роут —
`tools/[slug]/`; модель — `docs/architecture.md`, раздел 3. **Следствие для
SEO: редиректы не понадобились, canonical и sitemap не меняются, адреса те
же** — поэтому это была безопасная правка до индексации, а не после. Побочная
выгода: широкие интенты («crop image online») переносятся теми же страницами
контентом — H1 «Crop image», текст «works with PNG, JPG, WebP»; URL-алиасы без
`-png` по-прежнему не создаются (канонический беспорядок). Побочный эффект
модели: страница может ссылаться на один инструмент, а инструмент —
обслуживать много страниц, поэтому следующие шаги (свести convert-страницы на
один `convert`, селектор формата в Download — S1f) не требуют новой миграции
реестра. Задача на консолидацию — `docs/backlog.md`.
- **Индексируемая локаль — EN** (базовая; конкурент англоязычный). RU-версия —
отдельно и позже: префикс `/ru/`, prerender обеих локалей, hreflang
(сознательно отложено). Пока язык переключается на клиенте (localStorage) —
для SEO не учитываем.
- **Прод-домен без `BASE_PATH`.** Сборка с базой (GitHub Pages `/repo/`, см.
`BASE_PATH` в `web/vite.config.ts` и `deploy.yml`) ломает абсолютные URL,
canonical и «корневость» страниц. Свой домен + корень. Текущий деплой — GitHub
Pages (`.github/workflows/deploy.yml`); перенос на свой домен выполняется
сменой origin в CI и `PUBLIC_SITE_ORIGIN`.
- **URL-схема инструментов:** короткие плоские адреса — `/tools/<slug>` для
страницы, `/list-tools` для каталога, где `<slug>` берётся из записи
`registry/pages/`. Схему не менять без отдельного SEO-плана: после индексации
переименование URL означает 301 и потерю позиций. Разделение инструмента и
страницы (решение выше) адресов не меняет.
- **Аналитика — GA4, в режиме «только агрегаты».** GA4 уже стоит в проде
(`web/src/app.html`, prerender-оболочка, значит на каждой странице). Решение —
не выпиливать, а ограничить: cookieless-конфигурация, без рекламных audiences,
signals и ремаркетинга. Граница приватности проходит по данным изображения: GA
не видит ни файла, ни его содержимого, ни результата инструмента — только факт
открытия страницы. **Рекламу и ремаркетинг не ставим** — они противоречат
позиционированию, в отличие от счётчика посещений. Формулировку «без трекинга»
на публичных страницах не использовать: она теперь неточна, см. §1 и S4b.
- **Честные методы.** Никакого клонированного текста, дорвеев, PBN. Контент — по
каждому инструменту, с ручной вычиткой.
## 1. Аудит (2026-09-09)
Фундамент уже есть:
- SvelteKit + `adapter-static`, `prerender = true` на весь сайт
(`web/src/routes/+layout.ts`) — весь контент в статичном HTML, ботам не нужен
JS. Это главный технический козырь.
- Страницы в `web/src/lib/registry/pages` со «SEO-шными» slug (`crop-png`,
`add-border-png`, …) — URL уже повторяют паттерн конкурента, при этом
внутренние имена инструментов png-интента не держат. Актуальный состав:
`docs/tools-map.md`.
- На корневых страницах инструментов есть базовые `<title>` и `robots`; полный
SEO-слой (canonical, OG, content и JSON-LD) остаётся S1/S2.
- Каталог со ссылками на все инструменты (внутренняя перелинковка) и карта
инструментов против конкурента: `docs/tools-map.md`.
- EN-тексты в реестре + i18n-словари ru/en.
Чего нет (пробелы закрываются этапами ниже):
| Пробел | Где сейчас | Этап |
| -------------------------------------------------- | ----------------------------------- | ---- |
| `noindex` на страницах инструментов | стоит (`+page.svelte`) | S1 |
| `sitemap.xml` | нет | S1 |
| `robots.txt` без `Sitemap:` | allow-all (`web/static/robots.txt`) | S1 |
| `rel=canonical` | нет | S1 |
| OG/Twitter-теги, OG-картинки | нет | S1 |
| Канонический домен в абсолютных URL | нет, деплой на GitHub Pages с базой | S1 |
| Контент страницы (how-to, FAQ, related) | title, robots и отрендеренный H1 | S2 |
| JSON-LD (SoftwareApplication, FAQPage, Breadcrumb) | нет | S3 |
| `llms.txt`, хабы категорий с текстом | нет | S3 |
| GSC + доводка GA до cookieless-конфигурации | GA4 стоит, GSC не подключён | S4 |
Строка `noindex` — первая не потому, что её сложнее всего, а потому, что она
блокирует всё остальное: пока тег стоит, остальные строки таблицы не дают
трафика. Подробности и условие снятия — §4, S1.
## 2. Конкурент (onlinepngtools.com)
Снято с живого сайта (2026-09-09):
- ~311 URL в sitemap, все плоские (`/crop-png`, `/create-transparent-png`);
title вида «Crop a PNG – Online PNG Maker».
- Страница инструмента: h1 + 2–3 абзаца («World's simplest…», import/crop
примеры), примеры «before/after», how-to-блок, огромные футерные перелинковки
(серии Logo*/Icon*/Stamp*), страницы-хабы, price-wall.
- Сила: возраст домена, ссылочная масса, бренд, охват, серии-клоны под длинные
хвосты.
- Слабость (наш контраргумент): реклама, ежедневные лимиты free-плана, платное
коммерческое использование, апселл-модалки, страницы по ~450 КБ.
- Наш ответ в контенте каждой страницы: **no upload (всё локально в браузере),
no signup, no limits, бесплатно в т.ч. для коммерческого использования, open
source**.
## 3. Стратегия
1. **Не лобовая, а ядро интентов.** 80% объёма поиска приносят ~30–50 запросов.
Покрытие инструментов у нас почти полное (список — `docs/tools-map.md`);
приоритет — контент и качество страниц ядра, затем хвосты.
2. **Страница = лендинг, а не «пустая коробка».** Шаблон контента: H1 → 1–2
абзаца «что делает» → как использовать (шаги) → параметры → FAQ (3–5 вопросов
уровня People-Also-Ask) → related tools по категории.
3. **CWV как измеримое преимущество.** Лёгкие быстрые страницы против ~450-КБ
страниц конкурента; после редизайна не потерять скорость.
4. **GEO.** LLM-системы цитируют страницы с прямыми определениями, списками,
таблицами и уникальными фактами. Всё это уже есть в данных реестра — отдавать
в HTML-структуре, без воды.
5. **Информационный контур — потом, не вместо.** Гайды по форматам (PNG/JPEG/
WebP: история, модификации, lossy/lossless, прозрачность, «png vs jpeg») дают
E-E-A-T, перехватывают сравнительные запросы и служат хабами внутренних
ссылок на инструменты. Но это этап S3e и только после того, как написано ядро
S2: транзакционная страница приносит конверсию, информационная — только
трафик. Тонкие FAQ-страницы «про всё» не заводить — несколько сильных
материалов с ссылками на несколько связанных инструментов.
6. **Дистрибуция.** GitHub-репо (звёзды/форки = сигналы), alternative.to,
Product Hunt, Reddit по интенту — реальный путь набрать первые бэклинки без
бюджетов.
## 4. Этапы
### S1. Технический минимум (закрывает блокер индексации)
Разбито на две группы: **domain-free** (делаем параллельно с покупкой домена) и
**domain-required** (после покупки и DNS-настройки).
- [ ] **[S1h]** Снять `noindex, nofollow` со страниц инструментов
(`web/src/routes/tools/[slug]/+page.svelte`) — заменить на
`<meta name="robots" content="index, follow" />`. **Это первым
обязательным шагом S1:** пока тег стоит, остальные задачи S1 готовят
инфраструктуру, но не приводят трафик. Порядок: S1h ставится после
S1b/S1c/S1d, иначе Google проиндексирует сайт без sitemap и canonical.
Снимать `noindex` до готовности S2-контента осознанно не стоит: в индекс
попадут тонкие страницы с одним title/description.
#### S1-free: без домена
- [ ] **[S1e]** OG-картинки per-tool: генерация PNG-карточек скриптом на
существующем ядре (мы сами умеем рисовать PNG) — имя инструмента + бренд,
файл `static/og/[slug].png`, ссылка из `Seo.svelte`. Путь к картинке
строить через `resolve()` из `$app/paths` (как favicon в
`+layout.svelte`), а не литералом `/og/…`: под `BASE_PATH` корневой путь
ломается. Домен для этого не нужен; при деплое на собственном домене
OG-картинки будут абсолютными и корректными.
- [ ] **[S1f]** Download-кнопка с селектором формата PNG/JPG/WebP/BMP (+quality
для lossy) для всех инструментов — механика `Tool.output` уже есть
(`docs/backlog.md`, «Идеи» №6, UX-райз «Download»). Продуктовая основа
«image tools»-позиционирования: без неё сайт остаётся PNG-сайтом. Вести
параллельно с текущими quality-задачами. Побочно снимает `quality` из схем
**конвертеров** — после этого convert-страницы сводятся на один инструмент
(`docs/backlog.md`). Не из всех схем: у `jpeg-artifacts` `quality` —
функциональное поле (сила артефактов), а `output` у инструмента нет, так
что селектор формата его не покрывает.
- [ ] **[S1g]** Поведение конвертеров после S1f: не предлагать в меню форматов
формат исходника (disable) — чтобы `convert-png-to-jpg` не мог сохранить в
png. Существующие страницы не трогать: `convert-png-to-jpg`,
`convert-png-to-webp`, `png-to-bmp` остаются самостоятельными лендингами
со своим контентом (§5).
#### S1-domain: с доменом (после покупки + DNS)
- [ ] **[S1a]** Перевести прод на собственный домен: убрать `BASE_PATH` из
`deploy.yml`, задать `PUBLIC_SITE_ORIGIN`, перенаправить
`https://<юзер>/<repo>/…` на корневые URL (301). Делается одновременно с
S1d (canonical), чтобы Google проиндексировал сразу с правильным доменом.
- [ ] **[S1b]** Скрипт `web/scripts/gen-sitemap.mjs`: генерирует `sitemap.xml` в
`build/` из `PAGES` реестра (`web/src/lib/registry`) + статических
страниц. Взять адреса у страниц, а не у инструментов: страниц может быть
несколько на один инструмент, у инструмента адреса нет. Вызов из
`pnpm build` после `vite build` (adapter-static пишет в `build/`,
дописываем файл там же). Origin — из env (`PUBLIC_SITE_ORIGIN`), учёт
опционального `BASE_PATH`.
- [ ] **[S1c]** `robots.txt`: `Sitemap:` + закрыть служебный `/kit`; после
cleanup — вернуть минимальный allow-all.
- [ ] **[S1d]** Компонент `Seo.svelte` (kit): canonical (абсолютный URL по
актуальному маршруту), OG-теги (title/description/type/url/image),
twitter:card. Подключить в root-layout. **Требует `PUBLIC_SITE_ORIGIN`**
для абсолютных URL в canonical и OG:url.
> **Порядок S1:** S1e/S1f/S1g → покупка домена → S1a+S1b+S1c+S1d → **S1h**
> (снятие `noindex`) → S2-контент. Гейты: `pnpm --dir web build` зелёный; в
> `build/` лежит корректный `sitemap.xml` со всеми страницами инструментов;
> canonical/OG присутствуют на всех страницах; `noindex` отсутствует в собранном
> HTML (`pnpm --dir web build` и проверка `build/tools/*/index.html`);
> `pnpm --dir web test:e2e` — по правилам `docs/quality-gates.md`, в CI job
> `e2e` non-blocking, поэтому его статус читается по отдельному job
> (`docs/decisions.md`, раздел 10).
### S2. Контентный слой (параллельно quality-плану, волнами по категориям)
- [ ] **[S2a]** Схема данных: `seo`-блок per-tool (`longDescription`,
`howTo: string[]`, `faq: {q, a}[]`) — в i18n-словарях (ru/en), не в коде
реестра. Черновики — генерацией из схемы параметров инструмента, вычитка
вручную пачками по категориям (самый трудоёмкий этап: объём растёт вместе
с числом инструментов × 2 локали; RU — отложить до решения о `/ru/`).
- [ ] **[S2b]** Компонент контента страницы инструмента (kit): H1, lead, how-to
(`ol`), описание параметров (из схемы), FAQ, related tools (перелинковка
внутри категории + 2–3 «соседних» интента).
- [ ] **[S2c]** Хлебные крошки (Каталог → Категория → Инструмент) в UI.
- [ ] **[S2d]** Первая волна контента — топ приоритетных инструментов ядра
(приоритет по вордстату: transparent png, jpg→png, compress, resize, crop,
rotate, circle crop, add text, pixelate, grayscale…); отбор — §5.
- Гейты: на каждой странице инструмента — уникальный текст ≥ 300 слов, H1
совпадает с интентом, FAQ ≥ 3, related ≥ 3; текст в статике (prerender), не в
рантайме.
### S3. Структурированные данные + GEO
- [ ] **[S3a]** JSON-LD: `SoftwareApplication` (+ `offers` price 0) и `FAQPage`
на страницах инструментов; `WebSite` + `Organization` на главной;
`BreadcrumbList` на всех внутренних. Учесть: Google ограничил rich results
по FAQ/HowTo для мелких сайтов — разметка нужна прежде всего для
LLM-парсинга, она безвредна.
- [ ] **[S3b]** `llms.txt` в статике: короткий манифест сайта + список
инструментов с одним предложением на каждый (генерится из реестра тем же
скриптом, что sitemap).
- [ ] **[S3c]** Хабы категорий с текстом: 2–3 абзаца на категорию (что за
группа, когда какие инструменты применять), не голые списки карточек.
- [ ] **[S3d]** GEO-редактура: каждое описание начинается прямым определением
(«Crop PNG is a free tool that cuts a rectangular area from a PNG image
right in your browser»), списки/таблицы для фактов, без вводных «в
современном мире…». Проверка: страница отвечает на запрос одним абзацем
«без скролла».
- [ ] **[S3e]** _(опционально)_ Информационные гайды по форматам: 3–4 сильных
материала (`/guides/png-format`, `/guides/jpeg-vs-png`, …): история и
модификации формата, lossy/lossless, прозрачность, когда какой формат
выбирать. Каждый — с ссылками на 3–5 связанных инструментов (хабы
перелинковки), FAQ из гайдов продублирован как FAQPage-разметка. Приоритет
ниже S2/S3a–d: только когда ядро написано и есть ресурс.
- Гейты: валидация JSON-LD (Rich Results Test без ошибок); `llms.txt` актуален
при добавлении инструментов (генерация, а не ручное редактирование).
### S4. Измерение и запуск
- [ ] **[S4a]** Google Search Console: собственность на домен, отправка sitemap,
мониторинг индексации и запросов. Бесплатно и обязательно.
- [ ] **[S4b]** Довести GA4 до решения §0: cookieless-конфигурация, отключённые
рекламные audiences и signals, `anonymize_ip`, запрет на передачу
параметров с данными картинки. Мусорные попадания уже отсечены: тег
подключается только при `PUBLIC_GA_ENABLED=true` (выставлен в
deploy-workflow, `+layout.svelte`). GA/ЯМ как замена счётчику не
рассматриваем.
- [ ] **[S4c]** Дистрибуция: закрепить GitHub-репо (README со скриншотами),
подача на alternative.to (страница против конкурента), Product Hunt,
тематические Reddit/HN-треды. Бэклинки — главный дефицит для конкуренции.
- [ ] **[S4d]** _(опционально, позже)_ Сравнительные посадочные «vs …» (без
негатива: «no limits, no signup, no upload») — по мере роста доверия
домена.
- [ ] **[S4e]** Контроль CWV: Lighthouse на страницы ядра после каждого крупного
этапа редизайна; цель — зелёные метрики на мобильных.
## 5. Приоритизация инструментов (первая волна контента)
Источник — `docs/tools-map.md` (наш состав и замер по конкуренту — §2). Слаги
ниже — **фактические `slug` страниц** из `docs/tools-map.md` §1; первая волна —
топ-интенты, где конкуренты ранжируются, а у нас уже есть инструмент и он не
хуже:
- прозрачность/фон: create-empty-png, remove-background-png, change-png-opacity,
set-alpha-channel-png, round-corners-png
- конвертация: convert-png-to-jpg, convert-png-to-webp, png-to-bmp, svg-to-png,
png-to-base64
- геометрия: crop-png, resize-png, rotate-png, flip-png, change-aspect-ratio-png
- цвет/фильтры: grayscale-png, invert-colors-png, pixelate-png
- текст: add-text-png, watermark-tile-png
- генерация: linear-gradient-png
> Группы здесь — по категории страницы в реестре (`registry/pages/`), а не по
> смыслу: `round-corners-png` лежит в `alpha`, `add-text-png` и
> `watermark-tile-png` — в `text`.
> **Открытый вопрос по интентам «из формата в PNG».** У конкурента есть
> `jpg-to-png`, `webp-to-png`, `gif-to-png` как отдельные страницы; у нас таких
> инструментов нет и создавать их не хочется — загрузка файла уже принимает
> любой формат, а конвертер «во что-то» закрывает обратное направление. Это
> high-volume интент без посадочной страницы. Решение (страница-обёртка на
> существующий инструмент / приём в H1 и тексте / ничего) требует вордстата и
> должно быть принято до старта S2, иначе первая волна контента закроет не ядро
> запросов.
Новая волна форматов (после S1f): jpg↔webp и другие конвертации «формат →
формат» — только по подтверждённому вордстату; базовые кейсы закрывает селектор
формата в Download (S1f), страницы заводим лишь там, где интент стабильно
ищется.
- **crop-png: H1 «Crop image online — free, no upload»**, текст «works with PNG,
JPG, WebP» — широкий интент обслуживается контентом страницы с png-слагом,
слаг при этом остаётся якорем дешёвого png-интента.
### Форматные варианты страниц (`crop-jpeg`, `crop-webp`, …)
Три возможных механики — важно не перепутать:
1. **301-редирект на существующую страницу** (`/crop-jpg` → `/crop-jpeg`, или
`/crop-png` → новый слаг после переименования) — законно **только** для
опечаток, альтернативных написаний и переименований **уже опубликованной**
страницы: URL, которого никогда не было в индексе, редиректом не закрывается.
Для самостоятельного запроса редирект — это отказ от страницы: Google
перестаёт показывать редирект, ранжировать будет только цель. Как посадочная
под «crop jpeg» не работает вообще.
2. **Зеркало с canonical на `/crop-png`** — технически легально, но бесполезно:
canonical-дубль в индекс не попадает, видимости не добавляет, а массовое
производство таких зеркал выглядит как накрутка площади и при ошибке в
canonical превращается в дорвейный кластер. Не делаем.
3. **Самостоятельная страница = самостоятельный контент.** Заводить `/crop-jpeg`
можно только если: (а) интент подтверждён вордстатом, (б) на странице
собственный текст — H1 «Crop JPEG», FAQ про качество/артефакты/ когда
выбирать JPEG вместо PNG, отличия от png-кейса, (в) текст написан или выверен
человеком, а не получен подстановкой формата в шаблон. Реестром
автоматизировать **данные** (тот же tool, другой дефолтный output) — можно;
автоматизировать **текст** — нельзя: массовая подстановка {tool}×{format} в
шаблон — doorway-паттерн, риск deindex всего сайта.
Как правильно получать видимость под «jpeg»/«webp» без клонов: широкие
формулировки закрывает контент существующей страницы (H1 «Crop image», «works
with PNG, JPG, WebP» в тексте + селектор формата в Download, S1f). Отдельные
форматные страницы — точечные исключения по вордстату, не серия.
### Convert-инструменты: страницы и «универсальный конвертер»
Разводить два разных интента:
- **«convert png to jpg»** — интент «страница-конвертер»: пользователь ищет
конкретную пару форматов (лучшее качество конверсии, меньше ошибок), это
готовые лендинги. Существующие (`convert-png-to-jpg`, `convert-png-to-webp`,
`png-to-bmp`, `svg-to-png`, `base64-to-png`, …) **оставить и наполнить
контентом как все**: свой H1 («Convert PNG to JPG — free, no upload»), FAQ про
качество/прозрачность/фон, related. Селектор формата (S1f) их **не заменяет**
— он про «уже открыл инструмент и хочу другой формат на выходе».
- **«convert image» / «image converter»** — интент «швейцарский нож». Под него —
**один будущий инструмент `/convert-image`**: сначала пустой **core-tool** в
`core/` — функция ре-энкода («копия с перекодированием»: декодирует любой
поддержанный исходник в ImageData и кодирует в выбранный формат; поверх неё
S1f строит селектор формата). Инструмент и страницу добавлять **только вместе
с собственным текстом** («Convert any image to PNG, JPG or WebP in your
browser») — core в коде не обязывает к странице. Этот же шаблон потом
закрывает форматные волны (jpg→webp и т.п.) без новых страниц.
Не делать: серию `convert-jpeg`/`convert-webp`-клонов с подстановкой формата
(doorway, см. выше) и «универсальный конвертер» как единственную страницу вместо
форматных лендингов — теряется ядро «convert png to jpg»-запросов.
Правило добавления новых инструментов: брать из хвоста конкурента только те
интенты, которые подтверждены вордстатом, и сразу с контентом S2 (страница без
текста — потраченный URL).
## 6. Переименование бренда и домен (сделать до S1-domain, дёшево сейчас)
Выбранное имя и домен **в этом репозитории не записаны**: детальный чек-лист
лежит в локальном файле `plan-domain.local.md` рядом с этим планом, он исключён
из git через `.gitignore`, чтобы имя не попало в историю коммитов до покупки
домена. Агент, работающий из клона, этого файла не увидит — при необходимости
попросить чек-лист у пользователя.
- [ ] **[R1]** Механический переезд бренда на новое имя. Список файлов и строк с
хардкодом — в локальном плане (раздел «Где живёт бренд в коде»); здесь
только SEO-ограничения. **НЕ трогать:** адреса страниц инструментов
(`/tools/crop-png`) и localStorage-ключи — `theme` и `locale` лежат без
префикса бренда, поэтому переименование с ними не столкнётся (проверено в
`theme.svelte.ts` и `i18n/locale.svelte.ts`); историю `docs/archive`.
Бренд должен стать i18n-константой, а не тремя независимыми хардкодами.
- [ ] **[R2]** Купить выбранный домен (кандидаты и пробы — в локальном плане) и
перевести прод на него без `BASE_PATH` (§0). Домен нужен до S1-domain:
canonical и sitemap генерятся от `PUBLIC_SITE_ORIGIN`.
- **Порядок:** R1+R2 можно делать параллельно с текущими quality-задачами (не
блокируют). R2 (домен) — блокер для S1-domain (canonical + sitemap), а
S1-domain — блокер для S1h (снятие `noindex`). После индексации переименование
= переезд с 301, потеря позиций и ссылок — не делать.
## 7. Что осознанно НЕ делаем
- Клонирование всего каталога конкурента (низкокачественные дубли + нет ресурсов
на контент) — вместо этого ядро + обоснованные хвосты.
- RU-локаль в индексе до решения о структуре `/ru/` + hreflang.
- Реклама, ремаркетинг, pixels сторонних сетей — противоречит позиционированию.
Счётчик посещений GA4 — не противоречит, условия ограничений — §0 и S4b.
- Дорвеи, программные зеркала, покупные ссылки.
- Массово-сгенерированные форматные клоны (`crop-jpeg`/`crop-webp` как копии
`crop-png` с подстановкой формата в шаблонный текст) — doorway-паттерн, риск
deindex всего сайта. Форматная страница — только уникальный контент + вордстат
(§5); редиректы — только для опечаток/переименований.
- URL-алиасы без `-png` для существующих инструментов и зеркальные
страницы-конвертеры «на каждый формат» без вордстата.
## 8. Связанные документы
- `docs/tools-map.md` — карта «наш инструмент ↔ интент конкурента».
- `docs/backlog.md` — пункты SEO/GEO в общем бэклоге.
- `docs/roadmap.md` — фазы 2–8 (ядро TS→Rust/wasm, CLI), фаза 11 (пайплайн);
фазы 9–10 — API и PWA.
- `docs/plan-platform.md` — планы API и PWA (общее ядро, оффлайн-режим).
- `plan-domain.local.md` — локальный чек-лист переименования и покупки домена;
**в git не входит** (`.gitignore`), в клоне отсутствует.
+123
View File
@@ -0,0 +1,123 @@
# Quality gates
> Статус: **active**. Каноническое описание быстрых проверок: команды, coverage,
> warnings, CI. Почему гейт такой, а не иначе, — `docs/decisions.md`.
Команды выполняются из корня репозитория.
## Каноническая команда
```text
pnpm verify
```
`verify` выполняет последовательно:
1. `pnpm --dir web check` — SvelteKit sync и `svelte-check`.
2. `pnpm --dir web test:coverage` — полный Vitest-набор вместе с
coverage-гейтом.
3. `pnpm --dir web test:rules` — отдельный быстрый прогон тестов ESLint-правил.
4. `pnpm --dir web lint:all` — ESLint, Stylelint и token audit.
5. `pnpm --dir web format:check` — форматирование кода `web/`.
6. `pnpm check:docs` — форматирование Markdown (`AGENTS.md`, `README.md`,
`docs/`).
7. `pnpm --dir web build` — production build.
Полный `test` уже включает тесты lint-правил; отдельный `test:rules` в `verify`
оставлен как явный быстрый диагностический этап.
## Coverage
`pnpm --dir web test:coverage` считает покрытие только по `src/lib` и падает
ниже порога. Пороги и список исключений из scope — в `web/vitest.config.ts`
(`coverage.thresholds`, `coverage.exclude`); здесь они не дублируются, чтобы
числа не устарели молча (`AGENTS.md`).
Порог — нижняя граница, а не оценка качества: он блокирует регрессию и не
требует сначала закрывать весь долг. Актуальные проценты даёт
`pnpm --dir web test:coverage`. Локальный замер занимает единицы секунд, поэтому
отдельного прогона в `verify` нет — `test:coverage` заменяет `test`.
Исключения из scope и их причины:
| Исключение | Причина |
| ------------------------------------------- | ------------------------------------------- |
| `**/*.test.ts`, `**/test-helpers.ts` | сами тесты |
| `**/*.svelte.ts` | runes, не работают в node |
| `executor/executor.worker.ts` | entry worker, исполняется только в браузере |
| `components/define.ts` | type-only, нет исполняемого кода |
| `core/domText.ts`, `core/io.ts` | canvas, `createImageBitmap`, нужен браузер |
| `i18n/en.ts`, `i18n/ru.ts`, `tool-icons.ts` | данные, а не логика |
`registry/*` из scope НЕ исключены: низкое покрытие там означает непокрытую
логику, а не ограничение окружения. Включение браузерных модулей в scope —
отдельное решение, а не молчаливое расширение списка.
## E2E
E2E не входит в `verify`, потому что Playwright поднимает build и статический
сервер:
```text
pnpm --dir web test:e2e
```
Запускать его обязательно для изменений маршрутов, загрузки файла, результата,
download, темы или локализации. Изменения runtime fixtures/загрузки файла
проверяются отдельным `web/e2e/png-fixtures.spec.ts`; ручные бинарники из
`tests/fixtures/` не входят в `verify`.
Локально прогон идёт с `workers: 1` и заметно медленнее. Правила нагрузки на
машину пользователя и диагностика флаков — `docs/agent-workflow.md`, раздел 3;
матрица проектов и ожидаемое поведение декодеров — `docs/testing-strategy.md`,
раздел 7; статус e2e в CI и порядок чтения прогона — `docs/decisions.md`,
разделы 9–10.
## Быстрые подмножества
Матрица «изменение → минимальный набор проверок»:
| Изменение | Команда |
| ---------------------------------------------------- | ------------------------------------------------------- |
| Только Markdown | `pnpm check:docs` |
| TypeScript/Svelte | `pnpm --dir web check` и `pnpm --dir web test` |
| Lint-правило или lint-конфиг | `pnpm --dir web test:rules` и `pnpm --dir web lint:all` |
| Маршрут, загрузка, результат, download, тема, локаль | `pnpm --dir web test:e2e` |
| Перед ревью или merge | `pnpm verify` |
## Errors и warnings
- `errors` завершают соответствующий инструмент ненулевым кодом.
- ESLint запускается с `--max-warnings=0`: новые ESLint warnings блокируют gate.
- Token audit оставляет unused-token сообщение неблокирующим: новый токен
появляется вместе с потребителем, но стиль не блокирует деплой (решение —
`docs/decisions.md`, раздел 6).
- Резолв `var()` в `app.css` — ошибка: несуществующий токен молча роняет
объявление, поэтому этот check failing, в отличие от unused.
- `dict-consistency` использует base-locale fallback для registry metadata и
pattern-based policy для временно неиспользуемых `tools.*.params`; это не
молчаливый `allowPaths`-список.
`lint:all` может завершиться успешно, напечатав unused-token сообщения: это
единственный неблокирующий сигнал в нём. ESLint warnings, наоборот, роняют gate,
поэтому «прошёл» ≠ «нечего чинить». Агент показывает число errors, warnings и
token warnings раздельно.
## CI
`.github/workflows/quality.yml`:
- `verify` блокирует PR при typecheck, unit, rule-test, lint, format, docs или
build ошибке;
- `e2e` запускается отдельным job после verify и пока не блокирует PR.
`.github/workflows/deploy.yml` перед production build запускает тот же
`pnpm verify`, поэтому deploy не должен проходить при ошибке быстрых проверок.
| Workflow | Триггер | Jobs |
| ------------------------ | --------------------------------------- | --------------------------- |
| `Deploy to GitHub Pages` | push в `main`, dispatch | `verify`, `build`, `deploy` |
| `Quality` | push в `main`, `pull_request`, dispatch | `verify`, `e2e` |
Результат после пуша читается **по отдельным job** — процедура в
`docs/decisions.md`, раздел 10.
+187
View File
@@ -0,0 +1,187 @@
# План развития: easy-png-tools
> **Статус: active.** Фаза 1 выполнена — сайт живёт на типизированном реестре,
> production UI — корневые маршруты. Текущий фокус: SEO S1-free
> (`docs/plan-seo.md`) + открытые задачи (`docs/backlog.md`). Домен нужен для
> SEO (S1-domain). Фазы 2–8 — будущие; 9–10 спланированы в
> `docs/plan-platform.md`; 11 — пайплайн.
>
> **Идентификаторы.** Ссылаться на фазу этого документа. Пространства
> идентификаторов задач: `FA-*` — правки полей и раскладки
> (`docs/plan-fields-audit-fixes.md`); `S*` — SEO/GEO (`docs/plan-seo.md`); `A*`
> — API, `P*` — PWA, `F*` — шрифты (`docs/plan-platform.md`); `R*` — бренд и
> домен (`docs/plan-seo.md`, §6). Буквы `Q*`/`C*` в архивных документах
> относятся к волнам редизайна и как идентификаторы задач не используются.
## 0. Решения
Продуктовые и стратегические — здесь; устройство кода (реестр, исполнитель,
слои) — `docs/architecture.md`, разделы 2–3.
- **Набор утилит для изображений, всё в браузере** (SvelteKit +
`adapter-static`): файл картинки не покидает машину. Счётчик посещений GA4
этому не противоречит — границы `docs/plan-seo.md`, §0.
- **Первый и главный приоритет — полностью рабочий сайт**: максимум простых
инструментов, UX/UI, пайплайны (несколько инструментов подряд), описания
инструментов.
- **Никакого wasm/Rust на этом этапе.** TS-реализация на canvas + `ImageData` —
эталон алгоритмов, из которого позже генерируются эталонные файлы для проверки
Rust-порта. Rust-ядро, WASM и CLI — отдельные фазы позже, когда сайт живёт.
- **Пайплайн = JSON-список шагов** `{id, params}` — формат шага совпадает с
полем `Page.steps`. Формат зафиксирован этим решением и станет общим для
браузера, wasm и CLI; реализуется в фазе 11, не в фазе 1.
- Валидация будущего Rust-порта — эталонные файлы, сгенерированные из
TS-реализации. Сравнение: **бит-в-бит** для чистых пиксельных операций,
**perceptual diff** для canvas-зависимых (правила сравнения — раздел 3).
## 1. Где что лежит
Актуальная карта модулей, поток данных и рецепт добавления инструмента —
`docs/architecture.md`. Список реализованных инструментов — `docs/tools-map.md`,
открытые продуктовые задачи — `docs/backlog.md`.
## 2. Фазы
### Фаза 1 — Полноценный TS-сайт (выполнена)
Поставлено: каркас SvelteKit + static, дизайн-система и общий лейаут; ядро над
`ImageData` (загрузка/декодирование, кодирование, операции по категориям) без
DOM-зависимостей; типизированный реестр с универсальным рендерером страницы и
формы; массовая реализация EASY-инструментов; i18n, темы, состояния
загрузки/ошибок. Checkpoint достигнут: загрузил PNG → применил инструмент →
скачал результат, без wasm.
**В фазе 1 пайплайна нет.** Workspace цепочек с сохранением в localStorage и
экспортом в JSON — фаза 11, не поставленная часть фазы 1. Страница инструмента
работает ровно с одной записью реестра; что именно зарезервировано под пайплайн
(компонент `StepCard`, ключи `chain.*`), перечислено в `docs/architecture.md`,
раздел 1 «Чего в коде нет».
Незакрытый долг по фазе — в `docs/backlog.md`; он не входит в фазы 2–8 и 11.
### Фаза 2 — Тестовые PNG и генератор эталонов
- Фикстурные входы: градиенты/паттерны/шум + пара «настоящих» PNG.
- Правило: сравниваем **распакованные пиксели**, не байты файла.
- **Checkpoint:** в `tests/reference/<op>/` лежат `input-*.png` +
`expected-*.png`.
### Фаза 3 — Эталонные файлы и UI-регресс
> UI-регресс на Playwright уже существует и закрывает фазу 3 частично
> (`web/e2e/`, browser matrix). Недостающая часть фазы — именно эталоны.
- Скрипт генерации эталонов: грузит страницу, гоняет TS-реализацию, сохраняет
`expected-*.png`.
- **Checkpoint:** регресс-тесты падают при изменении выхода эталона.
### Фаза 4 — Rust-ядро
- Порт 1:1 чистых операций (первым делом те, что покрыты эталонными файлами) с
`PixelImage` ↔ TS `ImageData`.
- `clamp_to_u8` с семантикой `ToUint8Clamp` (round-half-to-even, ≥255 → 255) +
юнит-тесты на краях.
- `cargo test` читает эталонные файлы напрямую, **без браузера**.
- **Checkpoint:** `cargo test` зелёный на всех эталонных файлах.
### Фаза 5 — WASM на сайте
- Спайк WASM + Vite (проверить интеграцию) → wasm-bindgen-биндинги.
- Свитч A/B: TS-реализация заменяется на wasm-вызов.
- Playwright-проверка: wasm-результат == эталон.
- **Checkpoint:** сайт работает через WASM; A/B TS vs WASM идентично.
### Фаза 6 — Rust-CLI
- CLI: обход папки, `--pipeline pipeline.json`, запись результатов.
- Воспроизведение эталонов из консоли.
- **Checkpoint:** CLI выдаёт те же пиксели, что сохранены в эталонных файлах.
### Фаза 7 — Сквозная проверка идентичности
- Матрица: **эталонные файлы (из TS) ↔ Rust-native ↔ Rust-wasm**, одна команда
`harness compare`.
- **Checkpoint:** сходимость по всем покрытым операциям во всех трёх рантаймах.
### Фаза 8 — Масштабирование и продвинутое
- Порт оставшихся EASY → MEDIUM (свёртки, морфология, квантование; кодеки через
крейт `image`).
- Canvas-зависимые операции (текст, градиенты, штампы, `ctx.filter`): остаются в
TS с perceptual-классом либо реимплементация в Rust. Для текста выбран курс на
реимплементацию в Rust (рендер без DOM + шрифты OFL с лицензионными
паспортами) — `docs/plan-platform.md`, §4.
- batch-страница: дроп папки → wasm по всем файлам → zip. localStorage-пресеты
(тот же JSON, что у CLI).
- HARD: compress/optimize через oxipng/zopfli (в Rust — нативно).
### Фаза 9 — API (монетизация) — см. `docs/plan-platform.md`
Серверная обёртка над тем же ядром (нативный Rust или wasm на edge — решает
спайк), ключи и free tier, платные лимиты, индексируемая документация `/api`.
Чек-лист и решения — `docs/plan-platform.md`, §1.
### Фаза 10 — PWA — см. `docs/plan-platform.md`
Manifest + service worker, оффлайн-режим («работает без сети — файлы не покидают
устройство»), установка как приложение. Wasm-фаз не требует, идёт после S1 из
`plan-seo.md`. Чек-лист — `docs/plan-platform.md`, §2.
### Фаза 11 — Пайплайн (цепочки шагов)
Продуктовая фича, **не зависит от wasm-фаз**: работает на существующем
исполнителе и реестре, поэтому может идти параллельно с SEO. Номер 11 выбран
чтобы не сдвигать уже используемые ссылки на фазы 2–10.
- **Формат шага** — `{id, params}`, список шагов применяется последовательно,
результат предыдущего идёт в следующий. Формат зафиксирован в §0 как общий для
браузера, wasm и CLI, поэтому версионируется с первого дня. Он уже совпадает с
полем `Page.steps` (`{ id, params? }`), так что страница может быть готовой
цепочкой без новой модели данных; исполняется пока первый шаг.
- **Первый шаг фазы:** модуль состояния цепочки + сохранение в localStorage +
экспорт/импорт JSON (ошибки разбора уже описаны ключами
`errors.badPipelineShape` / `errors.pipelineVersion` / `errors.noSteps`).
- **Затем:** редактирование шагов (drag-n-drop, удаление, добавление), переход
«один инструмент → цепочка» без перезагрузки, сохранённые именованные цепочки,
отображение маски результата.
- **Checkpoint:** собрал цепочку из N шагов → сохранил → вернулся позже → скачал
результат последнего шага, всё в браузере.
Ограничения и открытые вопросы (1 → many не терминален, many → 1, частичные
ошибки, имена частей) — `docs/backlog.md`, раздел «Идеи». Пайплайн из архивного
UI-v1 удалён; что осталось в коде под эту фазу — `docs/architecture.md`,
раздел 1.
## 3. Правила сравнения (для фаз 4–7)
| Класс операции | Критерий |
| ------------------------------------------------------------------------------ | --------------------------------------------------------------------------- |
| Чистые пиксельные: цвет, альфа, порог, свёртки, морфология, квантование | **бит-в-бит** |
| Canvas-растеризация: текст, градиенты, антиалиасинг, `drawImage`, `ctx.filter` | **perceptual diff** (допуск: % изменившихся пикселей / цветовое расстояние) |
| PNG-декод | бит-в-бит (lossless) |
| JPEG-декод | perceptual (разные декодеры дают лёгкий дрейф) |
| Кодирование | сравниваем декодированные пиксели, не байты файла |
| compress/optimize | своя метрика: размер + визуальная дельта |
## 4. Риски и контрмеры
- **Реестр разрастается, страницы дублируются** — один универсальный рендерер
страниц/форм из записей реестра; инструмент = данные + `run`.
- **Семантика пайплайна** — зафиксирована: каждый шаг применяется к результату
предыдущего; единый тип `PixelImage` на всём пути; схема параметров на шаге.
- **Canvas-операции не бит-в-бит** — класс perceptual закладывается на Фазе 2–3,
а не в конце.
- **WASM + Vite** — спайк на Фазе 5 до массовой интеграции (риск отложен
сознательно: сайт уже живёт на TS).
- **Расползание реестра при будущем Rust-порте** — схема параметров в JSON, её
можно парсить и из Rust.
- **Разные декодеры PNG** — фикстурные PNG генерировать самим, чтобы не тащить
чужие артефакты.
## 5. Команды верификации
Команды текущего этапа и матрица «изменение → проверки» —
`docs/quality-gates.md` (`pnpm verify` — быстрый gate). Команды фаз 2–8
(`cargo test`, `harness compare`) **в проекте не существуют** и появятся вместе
с соответствующими фазами: до этапа 2 проверять нечем, кроме `pnpm verify` и
e2e.
+145
View File
@@ -0,0 +1,145 @@
# Стратегия e2e-тестирования
> Статус: **active**. Канонические правила для `web/e2e/`; продуктовые решения и
> известные баги остаются в `docs/backlog.md` и
> `docs/checklist-manual-testing.md`.
## 1. Что проверяем
E2E проверяет пользовательский контракт production UI: открытие маршрута,
доступность элемента, загрузку или ввод данных, появление результата, ошибку,
скачивание и переключение локали. Внутренняя реализация, порядок карточек,
CSS-классы и конкретный формат временного URL не являются контрактом.
Точные переводы проверяются в unit-тестах i18n. В e2e проверяется поведение и
отдельный RU/EN smoke, а не полный локализованный текст каждого verdict.
## 2. Приоритет locator-ов
1. `getByRole()` с доступным именем и `getByLabel()`.
2. Стабильные `href` или `id`, если семантического API нет.
3. Разрешённые `data-testid` только для визуальных границ, которые нельзя
надёжно назвать через accessibility API:
- `source-image`;
- `result-image`;
- `result-verdict`;
- `empty-state`.
4. `input[type="file"]` и download-button допускаются только внутри
`web/e2e/helpers/page.ts`.
Нельзя добавлять `data-testid` для счётчиков, групп, metadata, verdict текста,
кнопок темы или переключателя языка без отдельного обоснования.
## 3. Запрещённые зависимости
В e2e не фиксируются:
- абсолютные `TOTAL`, `GROUPS` и другие snapshot-числа каталога;
- точное число fuzzy-search результатов или порядок карточек;
- внутренние CSS-классы и HTML-структура, если есть role/label/href;
- `alt` как способ найти результат или источник;
- полные локализованные предложения и длинные тире в ожиданиях;
- локализованные имена контролов в общих сценариях без явной установки `en`.
Поведенческий тест может проверять, что известный инструмент видим или скрыт,
ошибка имеет текст и не является сырым i18n-ключом, результат доступен для
скачивания. Это не требует повторять словарь в e2e.
## 4. Локаль и fixtures
Обычные сценарии запускаются с `en` через `useEnglish()` или `openTool()`. RU/EN
проверяется отдельным `i18n.spec.ts`; после смены локали locator не должен
зависеть от старого accessible name.
Изображения создаются воспроизводимыми helpers в `web/e2e/helpers/fixtures.ts`.
Тесты не добавляют большие бинарные файлы и не зависят от локальных
пользовательских файлов. Runtime-fixtures для malformed/special PNG проверяют
browser-контракт: critical CRC и truncated отклоняются движком, ancillary CRC и
хвост после `IEND` допускаются, palette/16-bit декодируются. Отказ декодера —
свойство движка, поэтому ожидание задаётся per-engine в
`web/e2e/png-fixtures.spec.ts`, а не через skip. Общее требование для всех
движков: битый файл не роняет страницу, отказ показывается alert-ом, а успешный
декод даёт результат без ошибки. `toPass()` допускается только для ограниченного
ожидания hydration или асинхронного результата; он не должен превращать проверку
продукта в бесконечный retry.
Чистая логика схемы, layout и preview-модели (defaults, sanitization,
`resolveLayoutGroups`, `buildSchemaPreviewModel`) проверяется в
`src/**/*.test.ts`; DOM, hydration и пользовательские потоки — в Playwright.
Component-тесты не дублируют e2e-контракт.
Состав и причины исключений из coverage-гейта, а также пороги — в
`docs/quality-gates.md`; здесь они не дублируются.
## 5. Helpers и `test.fixme`
`web/e2e/helpers/page.ts` владеет общими операциями `openTool`, `uploadImage`,
`renderText`, `resultImage`, `sourceImage`, `verdictStatus`, `textResult`,
`errorAlert`, `emptyState`, `rangeInput`, `downloadResult`, `downloadResultFile`
и `downloadResultBytes`. Специфика должна вызывать эти helpers, а не повторять
raw-селекторы. Harness выбирается по контракту: image — upload + `resultImage`,
text — `renderText` + результат, generator — auto-run без upload, verdict —
`verdictStatus`, files — отдельный ZIP-сценарий.
`test.fixme` допускается только для зарегистрированной проблемы в backlog или
checklist. В комментарии указывается ссылка на неё. После исправления тест и
ссылка обновляются вместе.
Если тест сравнивает два состояния одного элемента, он ждёт изменения значения
(`not.toHaveText`, `not.toHaveAttribute`), а не его видимости. Пересчёт идёт
через debounce, поэтому `toBeVisible()` после второго ввода проходит на старом
значении. Такой тест флакает по таймингу и обычно ловится на CI, а не локально.
## 6. Проверки
Матрица «изменение → минимальный набор проверок» и состав `pnpm verify` — в
`docs/quality-gates.md`. Для изменений e2e дополнительно уместны
`pnpm --dir web check` и `pnpm --dir web format:check`.
`verify` не включает Playwright: E2E остаётся отдельным тяжёлым gate. В CI
non-blocking E2E не считается заменой локальному smoke; перед ревью фиксируются
число passed, skipped и известные fixme. Статус e2e в CI — `docs/decisions.md`,
раздел 9.
## 7. Browser matrix
`web/playwright.config.ts` запускает четыре проекта:
| Проект | Движок | Спецификации |
| ----------------- | -------- | ------------------------------------------------------------------------ |
| `chromium` | Chromium | все e2e |
| `firefox` | Firefox | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `webkit` | WebKit | `tool-flows`, `png-fixtures`, `text-and-verdicts` |
| `mobile-chromium` | Chromium | `navigation`, `catalog`, `generators`, `tool-flows`, 390×844, `hasTouch` |
- Название `tool-flows.spec.ts` описывает содержание: сквозной поток одного
инструмента (загрузка → правка параметра → результат → download) на наборе
представительных страниц. Пайплайна в коде нет (`docs/architecture.md`, раздел
1), поэтому спеку нельзя называть `pipeline` — при её переименовании поправить
`browserContractTests` и `mobileTests` в `web/playwright.config.ts` и
`allowDefaultProject` в `web/eslint.config.js`.
- Рядом живёт `tools-smoke.spec.ts` — дешёвый проход по выборке страниц («выдал
результат без ошибок»): по одной-двум на категорию, а не по каждому
инструменту. Остальные страницы закрыты точечными спеками (`generators`,
`text-and-verdicts`, `tool-flows`). `tool-flows` отвечает за более глубокие
проверки, `tools-smoke` — за дешёвый прогон подряд; дублировать их цели не
стоит.
- Новые engine-специфичные фичи сначала добавляются в `chromium`, потом
решается, попадает ли spec в `browserContractTests`.
- Расхождения декодеров не ужесточаются и не скрываются skip-ом: они описаны в
тесте как ожидаемое поведение движка.
| Fixture | Отклоняют |
| ------------------------------------ | ------------------------- |
| `crc-bad-idat.png` | Chromium, Firefox |
| `truncated-header.png` | Chromium, Firefox, WebKit |
| `truncated-no-iend.png` | Chromium |
| `idat-cut.png` | Chromium |
| ancillary CRC, tail, palette, 16-bit | ни один движок |
- `workers` — 1 локально и 2 в CI: Playwright не умеет per-project лимит
воркеров, а browser matrix ограничена памятью, а не CPU. Правила нагрузки на
машину и порядок диагностики флаков — `docs/agent-workflow.md`, раздел 3;
обоснование отсутствия per-project таймаутов — `docs/decisions.md`, раздел 7.
- Visual snapshots и per-project таймауты сознательно не используются —
`docs/decisions.md`, разделы 5 и 7.
+191
View File
@@ -0,0 +1,191 @@
# Аудит использования - UX\UI
> Статус: **active**. **Дата прохода: 2026-09-13** (инструменты и поля). Здесь
> только «что плохо» — наблюдения, без порядка работ и без критериев приёмки.
> Новый проход — новая дата сверху; закрытая находка не удаляется, а помечается
> ссылкой на задачу в `docs/backlog.md` или на уже сделанное в коде.
>
> **Слаги сверены с реестром** на дату прохода (`docs/tools-map.md` §1).
> Отдельно: `webp-to-png` и `color-wheel-generator` в коде отсутствуют (реальная
> страница — `color-wheel-png`), а `png-info` — ещё не заведённая страница
> (`docs/backlog.md`, «Идеи»). Такие пункты переименовываются по `PAGES`, а не
> resurrect'ятся. Перед работой по любому пункту — найти соответствующую
> страницу в `web/src/lib/registry/pages/` или `docs/tools-map.md`; ищи по
> смыслу: категория в заголовке раздела + ключевое слово в пункте.
>
> **Заголовки разделов ниже — это id категорий из `web/src/lib/categories.ts`**,
> по ним и ищи страницу в `registry/pages/<group>.ts`. Раздела `convert` в
> аудите нет: конвертация проверялась вместе с остальными инструментами,
> отдельного прохода по ней не было.
>
> Как исправлять — `docs/backlog.md` (задачи) и
> `docs/plan-fields-audit-fixes.md` (порядок правок полей). Находка в аудите и
> задача в бэклоге — не дубль: пока задачи нет, находка просто висит.
## везде
- возможность переключать пиксели\проценты
- инструменты без параметров выглядят странно, пересмотреть, может добавить
какую-то надпись "нет параметров"
- удаление\маски и т.д. - надо добавить галочку сглаживание
- производительность: почему flip-png заметно тормозит, но нет индикации работы
инструмента (также sharpen-png)
- производительность: исследовать, есть ли смысл на инструментах геометрии
выводить рамку результата до полноценной работы (например на skew \ rotate
будет удобнее)
## аудит инструментов
### alpha
(в UI раздел называется «Transparency» / «Прозрачность»)
- add-stroke-png: добавить галочку "расширить на толщину линии"
- find-contour-png: если толщина линии более 1px - круг боле с искажениями, надо
расширять на толщину линии
- circle-mask-png: размеры заданы в процентах, надо задавать в пикселях
- square-mask-png: размеры заданы в процентах, надо задавать в пикселях
- star-mask-png: размеры заданы в процентах, надо задавать в пикселях, проверить
форму звезды - сейчас выпуклые края, радиус задан до 100%, а более 50 - смысле
нет
- wavy-mask-png:размеры заданы в процентах, надо задавать в пикселях,
пересмотреть форму и параметры. как будто неудобно конфигурировать
- remove-alpha-channel-png: выбор цвета фона, как будет работать с
полупрозрачной картинкой?
- extract-alpha-mask-png: галочка инвертировать маску
- round-corners-png: размеры заданы в пикселях, должна быть возможность задать в
процентах, нету сглаживания по вырезанным пикселям
- invert-alpha-png: края со сглаживанием странно обрабатываются, может
галочка\слайдер обрезать сглаживание?
- remove-background-png: возможность выбора точки, от которой удалять (или
отдельный инструмент?)
- make-thicker-png: расширять картинку на толщину линии
- make-thinner-png: уменьшать от краев, если картинка начинается от края, то
остается выступ
- feather-edges-png: расширять картинку на толщину линии, (выбор цвета возможен?
другой инструмент?)
- clean-edges-png: непонятный инструмент (слишком маленький максимум?). найти
тест кейс для него
- despeckle-alpha-png: найти более заметный тест-кейс
- close-holes-png: проверить, как должен работать, картинки с большой
полупрозрачной областью - закрывает всё, по описанию должен закрывать только
небольшие дырки
### color
- two-colors-png: в range slider добавить описание что это luminance threshold
- gamma-png: проверить правильность работы - яркое изображение выглядит не
контрастным, проверить максимум и минимум
- temperature-png: проверить правильность работы - не слишком ли отдает
желтым\синим?
- quantize-png: максимум 64 - не слишком мало? сделать кнопки с пресетами
- custom-palette-png: копирует two-colors-png, только ввод через текстовое поле
и нет выбора порога luminance
- dithering-png: проверить алгоритм Floyd–Steinberg на малом количестве цветов
- sepia-png: не нужна настройка силы эффекта?
- extract-channel-png: мульти вывод сразу в 3 канала?
- swap-channels-png: обдумать описание подобных инструментов через flow более
простых?
- black-and-white-png: аналог two-colors-png, только без выбора цветов
- posterize-png: не нужен вариант настройки для каждого канала отдельно?
- auto-contrast-png: найти тестовый кейс
- decrease-color-count-png: сделать кнопки вместо селекта
- png-to-hsl: проверить, что `space as rgb` должна выдавать одинаковые
результаты, вне зависимости от выбора h s l. может выбрать отдельные кнопки?
- png-to-hsv: тоже самое ^
- png-to-hsi: тоже самое ^, нужно ли разделять на отдельные инструменты?
- png-to-cmyk: тоже самое ^
- png-to-lab: тоже самое ^
### geometry
- add-border-png: прозрачность цвета (аудит полей)
- fit-on-background-png: цвет
- change-canvas-size-png: использовать position9 вместо селекта
- resize-png: по умолчанию размеры 0х0 — сделано, дефолт берётся из source
(`defaultFromSource`), до загрузки 1×1. Выбор метода масштабирования — поля в
схеме нет вовсе, это не правка селекта, а новое поле
(`docs/plan-fields-audit-fixes.md` §7.1)
- crop-png: offset - слишком большие значения
- rotate-png: кнопки вместо select
- flip-png: кнопки вместо select
- add-padding-png: чем отличается от add-border-png?
- center-by-alpha-png: найти тест кейс
- zoom-png: выбор точки центра
- trim-empty-space-png: выбор от 0 до 254 (надо везде привести к единому стилю -
переключение 0-100 проценты и 0-255)
- change-aspect-ratio-png: не видно что меняется, добавить рамку вокруг
результата, а не вокруг div
- swap-orientation-png: кнопки вместо select
- symmetric-copy-png: кнопки вместо select, два выбора в селекте `keep side` не
работают (в зависимости от выбора `axis`)
- shift-png: цвет (прозрачность)
- split-into-parts-png: результат с несколькими файлами, обдумать процесс
### filters
- vignette-png: выбор точки центра, цвет, кроме силы нужен еще параметр прироста
силы (как называется?)
- pixelate-png: разные алгоритмы? выглядит не очень. тест кейс?
- randomize-pixels-png: настройка размеров другим вариантом - на сколько кусков
делить. seed - поменять
- add-noise-png: кнопки вместо select, seed - поменять
- sharpen-png: добавить тест кейс
- silhouette-png: аналог extract-alpha-mask-png?
### text
- add-text-png: plate снизу большой отступ, сделать галочку для этого (некоторые
буквы будут обрезаться - y, g)
- watermark-tile-png: заполняет не полностью
### analyze
- extract-color-from-png: надо описать или дать выбор на основе какого параметра
работает threshold
- show-transparent-png: кнопки вместо select
- show-grayscale-pixels-png: кнопки вместо select
- show-color-pixels-png: кнопки вместо select
- light-pixel-mask-png: кнопки вместо select
- verify-is-png: название неверное, в описании указано изображение, а фактически
проверяется текст
- png-is-grayscale: результат плохо виден, нужно ли копирование на полный
вердикт? может отдельные поля - 0\1, yes\no?
### generate
(в UI раздел называется «Generate» / «Генерация»)
- color-spectrum-png: кнопки вместо select
- draw-grid-png: выбор цвета фона
- placeholder-png: с галочкой "показать текст" нет заполнения фона
- blend-two-png: высота не задается
- step-colors-png: кнопки вместо select, высота не задается, не слишком ли
маленький max steps?
- emoji-to-png: выбор эмодзи
- color-wheel-png: сглаживание краев
- complementary-png: кнопки вместо select(?), не меняется, всего 2 цвета, высота
не задается
- triadic-png: кнопки вместо select, высота не задается
- tetradic-png: кнопки вместо select, высота не задается
- analogous-png: кнопки вместо select, высота не задается
- monochromatic-png: кнопки вместо select, высота не задается
- shades-png: кнопки вместо select, высота не задается
- mix-colors-png: высота не задается
- sort-colors-png: кнопки вместо select, высота не задается
## аудит полей
- у полей plate, position9 и font-style вторая подпись дублирует первую
- position offset: сделано — отдельные числовые поля X и Y в
`fields/schema/OffsetControl.svelte` (не range slider); задача на px/% вместо
них открыта, `docs/plan-fields-audit-fixes.md` §7.1
- range slider: поле ввода, кнопки + и -, кнопка сброса на умолчание
- range slider with presets: слайдер, как выше, добавочные кнопки с пресетами
- color picker: пикер с картинки (как исходника, так и результата), прозрачность
- ввод высоты и ширины с keep aspect ratio должен быть единым компонентом
- поле seed (random) - range slider не нужен, поле для ввода и "randomize"
кнопка
- поле текста: нормальная высота по умолчанию (не одна строка), для svg —
форматирование или подсветка синтаксиса, для bin\hex — колонки, для
hex\bytes\rgba — пример в placeholder
+260
View File
@@ -0,0 +1,260 @@
# Карта инструментов: реализовано · добавить · идеи
> Статус: **active**. Навигационный список: что уже есть по адресам
> `/tools/<slug>`, что можно добавить, что отложено. Источник правды — реестр
> (`web/src/lib/registry/`), модель страницы и инструмента —
> `docs/architecture.md`, раздел 3; сверяться с реестром перед использованием.
> Количество инструментов здесь не фиксируется — оно выводится из раздела 1 и из
> `PAGES` (`AGENTS.md`).
>
> **Как читать строку.** `slug` — адрес страницы и имя скачиваемого файла;
> страница вызывает инструмент через `steps[0].id`, и у `id` нет png-интента
> (`crop-png` → `id: "crop"`, `png-to-hsl` → `id: "to-hsl"`). Имена полей — это
> id полей из `schema.fields` инструмента; составные поля (`size`, `pair`,
> `offset`, `style`, `plate`, `gradient`) в скобках раскрыты, но не выдуманы как
> отдельные поля. Сравнение с каталогом источника (объём, нишевые клоны серий
> Logo\*/Icon\*/Stamp\*) — замер в `docs/plan-seo.md` §2; здесь он не
> дублируется.
>
> **Формат строки (проверяется `web/src/lib/registry/tools-map-doc.test.ts`):**
> всё **до первого тире** `—` — это slug страниц через `/` или `,`; всё после
> тире — свободное описание параметров и не разбирается. Второй slug нельзя
> оставлять после тире, иначе он не попадёт в разбор и страница будет считаться
> неописанной.
---
## 1. Реализовано
Slugs приведены к фактическим в `web/src/lib/registry/pages/`. Разделы идут по
файлу реестра; поля — по схеме инструмента, который страница вызывает.
### Конвертация (`convert`)
Отдельных «из формата в PNG» инструментов **нет**: входной формат выбирается
загрузкой файла, любой инструмент принимает PNG/JPG/WebP/GIF/BMP. Все конвертеры
исходят из PNG.
- `convert-png-to-jpg` — background, quality
- `convert-png-to-webp` — quality
- `png-to-bmp` — без параметров (24-бит, фон вместо альфы)
- `svg-to-png` — width (ширина результата; вход — SVG-код)
- `png-to-base64`, `base64-to-png` — без параметров (строка)
- `png-to-data-uri`, `data-uri-to-png` — без параметров (строка)
- `png-to-hex`, `hex-to-png` — вход `width`; rrggbbaa по строкам / tokens
- `png-to-bytes`, `bytes-to-png` — вход `width`; десятичные RGBA-байты по
строкам / tokens
- `png-to-rgb-values`, `rgb-values-to-png` — вход `width`; rgba(r,g,b,a) по
пикселям / числа
### Прозрачность (`alpha`)
- `add-stroke-png` — color, thickness
- `find-contour-png` — color, thickness
- `remove-color-from-png` — targetColor, tolerance
- `remove-background-png` — color, tolerance, outerOnly, smooth
- `make-thicker-png`, `make-thinner-png` — radius
- `harden-alpha-png` — threshold
- `feather-edges-png` — radius (размытие только альфы)
- `clean-edges-png` — radius (defringe: RGB от ближайшего непрозрачного)
- `despeckle-alpha-png`, `close-holes-png` — radius
- `round-corners-png` — radius (% от половины меньшей стороны)
- `invert-alpha-png` — без параметров
- `extract-alpha-mask-png` — без параметров
- `set-alpha-channel-png` — percent
- `remove-alpha-channel-png` — без параметров (фон `#ffffff` захардкожен,
`docs/plan-fields-audit-fixes.md` FA-D1)
- `circle-mask-png` — size (диаметр, % меньшей стороны), offset (x/y)
- `square-mask-png` — widthPct, heightPct, offset (x/y)
- `star-mask-png` — points, innerRadius, size, rotation, offset (x/y)
- `wavy-mask-png` — size, amplitude, waves, phase, offset (x/y)
### Цвет (`color`)
- `grayscale-png`, `invert-colors-png`, `sepia-png`, `auto-contrast-png` — без
параметров
- `adjust-brightness-contrast-png` — brightness, contrast
- `change-png-opacity` — percent
- `change-png-hue` — degrees
- `temperature-png` — percent
- `gamma-png` — value
- `tint-png` — color, strength
- `two-colors-png` — pair (светлый/тёмный), threshold
- `black-and-white-png` — threshold
- `posterize-png` — levels
- `quantize-png` — colors (k, median-cut)
- `decrease-color-count-png` — maxColors (2…256)
- `custom-palette-png` — colors (hex через запятую, ближайший цвет)
- `dithering-png` — colors (k), pattern (Floyd–Steinberg / Bayer 4×4)
- `extract-channel-png` — channel (r/g/b)
- `swap-channels-png` — pair (r-g/r-b/g-b)
### Разложение каналов (`color`)
- `png-to-hsl`, `png-to-hsv`, `png-to-hsi`, `png-to-cmyk`, `png-to-ycbcr`,
`png-to-lab` — component (h/s/l и т.п.), display (`gray` | `space-as-rgb`)
### Геометрия (`geometry`)
- `resize-png` — size (по умолчанию размер source, 0 по одной стороне = авто),
keepAspect
- `crop-png` — x, y, size (по умолчанию вся область source)
- `rotate-png` — angle (90/180/270)
- `rotate-free-png` — angle
- `flip-png` — axis (h/v)
- `skew-png` — degX, degY
- `zoom-png` — scale
- `shift-png` — offsetX, offsetY, color
- `add-padding-png` — padding, transparent, color
- `add-border-png` — thickness, color
- `fit-on-background-png` — size, transparent, color
- `tile-png` — columns, rows
- `split-into-parts-png` — columns, rows (мультифайловый вывод, zip)
- `trim-empty-space-png` — threshold (по альфе)
- `change-canvas-size-png` — size, anchor (3×3)
- `change-aspect-ratio-png` — ratio (пресеты), mode (crop/pad)
- `swap-orientation-png` — target (portrait/landscape)
- `symmetric-copy-png` — axis, keepSide
- `center-by-alpha-png` — без параметров
### Фильтры (`filters`)
- `blur-png` — radius
- `sharpen-png` — strength
- `vignette-png` — strength
- `jpeg-artifacts-png` — quality (имитация пережатия jpg/webp; поле
функциональное, у инструмента нет `output` — `docs/plan-seo.md` S1f)
- `pixelate-png` — blockSize
- `randomize-pixels-png` — blockSize, seed
- `add-noise-png` — amount, mode (mono/color), seed
- `silhouette-png` — color, threshold
### Анализ и вердикты (`analyze`)
- `png-file-size` — без параметров (DOM-инструмент: кодирует в PNG и отдаёт
размер)
- `png-is-transparent`, `png-is-grayscale`, `png-orientation` — без параметров,
текстовый вердикт
- `verify-is-png` — без параметров (вход — текст: base64/data-uri), вердикт по
сигнатуре
### Маски по свойствам пикселей (`analyze`)
- `show-transparent-png` — mode, color, opacity
- `show-grayscale-pixels-png` — mode, tolerance, color, opacity
- `show-color-pixels-png` — mode, tolerance, color, opacity
- `light-pixel-mask-png` — mode, threshold, color, opacity
- `dark-pixel-mask-png` — mode, threshold, color, opacity
- `unique-color-mask-png` — mode, rarity (макс. повторов), color, opacity
- `extract-color-from-png` — color, tolerance
### Генерация (`generate`)
- `create-empty-png` — size, transparent, color
- `single-color-png` — size, color
- `random-noise-png` — size, seed
- `linear-gradient-png` — size, gradient (от/к, направление)
- `color-spectrum-png` — size, direction, saturation, lightness
- `random-colors-png` — size, blockSize, seed
- `draw-grid-png` — size, cols, rows, lineWidth, color, transparentBg
- `placeholder-png` — size, backgroundColor, color, showText
- `blend-two-png` — pair (от/к), width
- `step-colors-png` — pair (от/к), steps, width, layout (grid/strip)
- `color-wheel-png` — size, lightness
- `mix-colors-png` — colors, width
- `sort-colors-png` — colors, order (hue/brightness/saturation), width, layout
- `emoji-to-png` — emoji, size
- `text-to-png` — text, style (шрифт/размер/цвет/жирность), transparentBg,
backgroundColor, padding
- `complementary-png`, `triadic-png`, `tetradic-png`, `analogous-png`,
`monochromatic-png`, `shades-png` — baseColor, width, layout; у `analogous`
ещё spread и count, у `monochromatic` — count и range, у `shades` — count и
depth
### Текст (`text`)
- `add-text-png` — text, style (шрифт/размер/цвет/жирность), position (3×3),
margin, plate (плашка, её цвет и прозрачность)
- `date-stamp-png` — format, style, position, margin, plate
- `watermark-tile-png` — text, style, opacity, angle, stepX, stepY
---
## 2. Можно добавить — из onlinepngtools
Всё из этого списка **в реестре отсутствует** — это план, а не факт. Список
проверяется `web/src/lib/registry/tools-map-doc.test.ts` в обратную сторону:
реализованный slug, оставшийся здесь, роняет тест.
### Разложение каналов — остаток
- separate-colors — minShare слоя (MEDIUM, мультифайловый вывод → см. идеи в
`docs/backlog.md`)
### Края и силуэт — остаток
- glow — radius, color, intensity
- shadow — offsetX, offsetY, blur, color, alpha
### Эффекты — остаток
- censor-region / erase-region — область (MEDIUM: нужен UI выделения → см. идеи)
- whirl — угол, центр, радиус (MEDIUM)
### Сортировка/блоки пикселей
- sort-pixels — blockSize, ключ (яркость/канал), направление (MEDIUM)
- slow-reveal / fade-in / fade-out / disappear — анимационные (→ идеи)
### Генераторы — остаток
- multi-color-gradient — список стопов (нужен новый тип параметра → MEDIUM-UI)
### Конвертеры — остаток
- png-to-gif — MEDIUM (однокадровый GIF-энкодер руками)
- gif-to-frames — MEDIUM (мультифайловый вывод → идеи)
- change-bit-depth — MEDIUM (пересборка PNG)
### Сжатие и качество
- compress, reduce-to-size, pick-a-color, extract-barcode — **не реализованы**;
заведены в `docs/backlog.md`. Сжатие до целевого размера — прежде всего
селектор формата в Download (S1f, `docs/plan-seo.md` §4): для lossy это
quality-ручка, а для PNG — квантование палитры. Нужен ли отдельный инструмент
под этот интент, решается по вордстату, имя страницы пока не выбрано. Пипетка
для выбора цвета уже есть в превью, отдельная страница не планируется.
Извлечение штрихкодов — HARD, вне планов.
---
## 3. Идеи на рассмотрение (нужна архитектура или спорная ценность)
Развёрнутые описания этих идей живут в `docs/backlog.md`, раздел «Идеи»; здесь
только список.
Буллиты этого раздела, как и раздела 2, перечисляют **несуществующие** страницы:
уже реализованные в них не называются, иначе тест решит, что slug пора
переносить в раздел 1. Реализованные примеры пишутся прозой между буллитами, как
ниже.
- **Region-инструменты** — нужен selection-компонент на превью: censor-region,
erase-region, pixelate-area, blur-area, sharpen-area, reverse-colors-area.
Один раз делаем selection — получаем шесть инструментов.
- **Мультифайловый выход** — остальные потребители механизма «результат = набор
файлов»: gif-to-frames, separate-colors, multiply-grid-as-files — подключаются
по мере нужды.
Механизм уже реализован на `split-into-parts-png` (`docs/architecture.md`,
раздел 7), он в разделе 1.
- **Анимационные эффекты** — slow-reveal, fade-in/out, disappearing, scrolling:
это видео/GIF на выходе, а не PNG. Отдельное решение о формате результата.
- **HARD-хвост** — glitch-art, extract-signature, handwritten→digital,
extract-barcode.
- **Нишевые серии** (logo/icon/stamp/signature — замер в шапке документа) —
сознательно не копируем: это обычные операции над конкретным контентом, у нас
они доступны через базовые инструменты + цепочки.
- **Входные «из формата в PNG»** (jpg-to-png, webp-to-png) — решение и его
SEO-последствия описаны в `docs/plan-seo.md` §5; отдельные страницы под эти
интенты — предмет решения там же.
-60
View File
@@ -1,60 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Redirecting...</title>
<script>
const NEW_DOMAIN = "https://easy-image-tools.com";
const OLD_REPO_NAME = "easy-png-tools";
</script>
<style>
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
background-color: #ddd;
color: #222;
}
@media (prefers-color-scheme: dark) {
html,
body {
background-color: #222;
color: #ddd;
}
}
</style>
<script>
(function () {
let currentPath = window.location.pathname;
if (currentPath.startsWith("/" + OLD_REPO_NAME)) {
currentPath = currentPath.substring(OLD_REPO_NAME.length + 1);
}
const newUrl =
NEW_DOMAIN +
currentPath +
window.location.search +
window.location.hash;
window.location.replace(newUrl);
})();
</script>
<noscript>
<meta
http-equiv="refresh"
content="0; url=https://easy-image-tools.com"
/>
<link rel="canonical" href="https://easy-image-tools.com" />
</noscript>
</head>
<body>
<p>
"Easy png tools" now named "Easy image tools" and moved to a new domain.
Please follow this link: <a href="https://easy-image-tools.com"></a>.
</p>
</body>
</html>
+30
View File
@@ -0,0 +1,30 @@
{
"name": "easy-png-tools",
"version": "0.2.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "pnpm --dir web test",
"verify": "pnpm --dir web check && pnpm --dir web test:coverage && pnpm --dir web test:rules && pnpm --dir web lint:all && pnpm --dir web format:check && pnpm check:docs && pnpm --dir web build",
"dev": "pnpm --dir web dev",
"build": "pnpm --dir web build",
"format": "pnpm format:docs && pnpm --dir web format",
"format:docs": "prettier --write AGENTS.md README.md docs --log-level warn --cache",
"check:docs": "prettier --check AGENTS.md README.md docs --cache",
"generate:fixtures": "node tests/fixtures/manual-edge-cases/generate.mjs"
},
"keywords": [],
"author": "Ku6epXBOCTuK",
"license": "MIT",
"devEngines": {
"packageManager": {
"name": "pnpm",
"version": "^11.20.0",
"onFail": "download"
}
},
"type": "module",
"devDependencies": {
"prettier": "^3.9.6"
}
}
+223
View File
@@ -0,0 +1,223 @@
---
lockfileVersion: '9.0'
importers:
.:
configDependencies: {}
packageManagerDependencies:
'@pnpm/exe':
specifier: ^11.20.0
version: 11.25.0
pnpm:
specifier: ^11.20.0
version: 11.25.0
packages:
'@pnpm/exe@11.25.0':
resolution: {integrity: sha512-X19R2uC+VAJ4UJQE9c/PCdOXbDaWnZtad7WUrTHBFQuMVaK9MAHbyO/WgahQCuRtTmJkLbxcWKKCk+JeTYFm/g==}
hasBin: true
'@pnpm/linux-arm64@11.25.0':
resolution: {integrity: sha512-ra8akqhzsbcOhKSJ9fFV8H+Oc9uGQAcp/XmIBEdT+8hPPoZCit3+RNCHmg8bLoNvpSLtRvZE0WFCMj7WyzxeBA==}
cpu: [arm64]
os: [linux]
'@pnpm/linux-x64@11.25.0':
resolution: {integrity: sha512-pQl/L10diKQCbF73viRrtVU8qVWMTudUCSG1uZ+EWBNKtU9Sbxe6Q378diXDb1uTwSgUVMQoypxlC1MTzh7qvQ==}
cpu: [x64]
os: [linux]
'@pnpm/linuxstatic-arm64@11.25.0':
resolution: {integrity: sha512-cYcrbB/xN1N6/5VUlFuHblb1gNyJgZv1CF5Pk1T0sHJxQMY4DFJV3OqHVAgahxyUfSTaQcVLvH1H2JFvd/+sgw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@pnpm/linuxstatic-x64@11.25.0':
resolution: {integrity: sha512-HXDtaeQod16DDMUUcVLIMxvEc1jKn7IYsNPwbwAPs/Je/d3umRPJi3Ejjdn6e9qpXN6Oon+yvSsJr7B9oDt6KA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@pnpm/macos-arm64@11.25.0':
resolution: {integrity: sha512-m/eAgEqKhiSexGxWPHNXNnSRI0hudymg6K7LbrU2EoDs9IgJ28OKEz9mGxMzhkYBweEquR4k5teMNes/aToy9w==}
cpu: [arm64]
os: [darwin]
'@pnpm/win-arm64@11.25.0':
resolution: {integrity: sha512-oAeECbtZ+eJziaBmPUkwJM8Dx7KVQ5FO367AXTjD2HGfB5ob1Bcu5hoUF5RBTgDBiP9fRQ1u13uAEpqar/6NLA==}
cpu: [arm64]
os: [win32]
'@pnpm/win-x64@11.25.0':
resolution: {integrity: sha512-8/n+wCc0a8TRYTMFqti93Vh0cscdJ25xfMyYSDdXrLUh2ccxSFuS+SE7cNPjd/nOXoFAJvdW0kwfbyRPLa16/w==}
cpu: [x64]
os: [win32]
'@reflink/reflink-darwin-arm64@0.1.19':
resolution: {integrity: sha512-ruy44Lpepdk1FqDz38vExBY/PVUsjxZA+chd9wozjUH9JjuDT/HEaQYA6wYN9mf041l0yLVar6BCZuWABJvHSA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [darwin]
'@reflink/reflink-darwin-x64@0.1.19':
resolution: {integrity: sha512-By85MSWrMZa+c26TcnAy8SDk0sTUkYlNnwknSchkhHpGXOtjNDUOxJE9oByBnGbeuIE1PiQsxDG3Ud+IVV9yuA==}
engines: {node: '>= 10'}
cpu: [x64]
os: [darwin]
'@reflink/reflink-linux-arm64-gnu@0.1.19':
resolution: {integrity: sha512-7P+er8+rP9iNeN+bfmccM4hTAaLP6PQJPKWSA4iSk2bNvo6KU6RyPgYeHxXmzNKzPVRcypZQTpFgstHam6maVg==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-arm64-musl@0.1.19':
resolution: {integrity: sha512-37iO/Dp6m5DDaC2sf3zPtx/hl9FV3Xze4xoYidrxxS9bgP3S8ALroxRK6xBG/1TtfXKTvolvp+IjrUU6ujIGmA==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@reflink/reflink-linux-x64-gnu@0.1.19':
resolution: {integrity: sha512-jbI8jvuYCaA3MVUdu8vLoLAFqC+iNMpiSuLbxlAgg7x3K5bsS8nOpTRnkLF7vISJ+rVR8W+7ThXlXlUQ93ulkw==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@reflink/reflink-linux-x64-musl@0.1.19':
resolution: {integrity: sha512-e9FBWDe+lv7QKAwtKOt6A2W/fyy/aEEfr0g6j/hWzvQcrzHCsz07BNQYlNOjTfeytrtLU7k449H1PI95jA4OjQ==}
engines: {node: '>= 10'}
cpu: [x64]
os: [linux]
libc: [musl]
'@reflink/reflink-win32-arm64-msvc@0.1.19':
resolution: {integrity: sha512-09PxnVIQcd+UOn4WAW73WU6PXL7DwGS6wPlkMhMg2zlHHG65F3vHepOw06HFCq+N42qkaNAc8AKIabWvtk6cIQ==}
engines: {node: '>= 10'}
cpu: [arm64]
os: [win32]
'@reflink/reflink-win32-x64-msvc@0.1.19':
resolution: {integrity: sha512-E//yT4ni2SyhwP8JRjVGWr3cbnhWDiPLgnQ66qqaanjjnMiu3O/2tjCPQXlcGc/DEYofpDc9fvhv6tALQsMV9w==}
engines: {node: '>= 10'}
cpu: [x64]
os: [win32]
'@reflink/reflink@0.1.19':
resolution: {integrity: sha512-DmCG8GzysnCZ15bres3N5AHCmwBwYgp0As6xjhQ47rAUTUXxJiK+lLUxaGsX3hd/30qUpVElh05PbGuxRPgJwA==}
engines: {node: '>= 10'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
pnpm@11.25.0:
resolution: {integrity: sha512-XN6SW08HX3Jetx+64YpC/+eEUkeJ8ZthxzHLhyHsKKruFg4BqNWvT+2ypCzb8wDv4j2zVrDUoXtNY+EfirfJVg==}
engines: {node: '>=22.13'}
hasBin: true
snapshots:
'@pnpm/exe@11.25.0':
dependencies:
'@reflink/reflink': 0.1.19
detect-libc: 2.1.2
optionalDependencies:
'@pnpm/linux-arm64': 11.25.0
'@pnpm/linux-x64': 11.25.0
'@pnpm/linuxstatic-arm64': 11.25.0
'@pnpm/linuxstatic-x64': 11.25.0
'@pnpm/macos-arm64': 11.25.0
'@pnpm/win-arm64': 11.25.0
'@pnpm/win-x64': 11.25.0
'@pnpm/linux-arm64@11.25.0':
optional: true
'@pnpm/linux-x64@11.25.0':
optional: true
'@pnpm/linuxstatic-arm64@11.25.0':
optional: true
'@pnpm/linuxstatic-x64@11.25.0':
optional: true
'@pnpm/macos-arm64@11.25.0':
optional: true
'@pnpm/win-arm64@11.25.0':
optional: true
'@pnpm/win-x64@11.25.0':
optional: true
'@reflink/reflink-darwin-arm64@0.1.19':
optional: true
'@reflink/reflink-darwin-x64@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-arm64-musl@0.1.19':
optional: true
'@reflink/reflink-linux-x64-gnu@0.1.19':
optional: true
'@reflink/reflink-linux-x64-musl@0.1.19':
optional: true
'@reflink/reflink-win32-arm64-msvc@0.1.19':
optional: true
'@reflink/reflink-win32-x64-msvc@0.1.19':
optional: true
'@reflink/reflink@0.1.19':
optionalDependencies:
'@reflink/reflink-darwin-arm64': 0.1.19
'@reflink/reflink-darwin-x64': 0.1.19
'@reflink/reflink-linux-arm64-gnu': 0.1.19
'@reflink/reflink-linux-arm64-musl': 0.1.19
'@reflink/reflink-linux-x64-gnu': 0.1.19
'@reflink/reflink-linux-x64-musl': 0.1.19
'@reflink/reflink-win32-arm64-msvc': 0.1.19
'@reflink/reflink-win32-x64-msvc': 0.1.19
detect-libc@2.1.2: {}
pnpm@11.25.0: {}
---
lockfileVersion: '9.0'
settings:
autoInstallPeers: true
excludeLinksFromLockfile: false
importers:
.:
devDependencies:
prettier:
specifier: ^3.9.6
version: 3.9.6
packages:
prettier@3.9.6:
resolution: {integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==}
engines: {node: '>=14'}
hasBin: true
snapshots:
prettier@3.9.6: {}
+16
View File
@@ -0,0 +1,16 @@
import { readFileSync, writeFileSync } from "node:fs";
const version = process.argv[2];
if (!version) {
console.error("usage: node scripts/sync-version.mjs <version>");
process.exit(1);
}
for (const file of ["package.json", "web/package.json"]) {
const raw = readFileSync(file, "utf8");
const manifest = JSON.parse(raw);
manifest.version = version;
const indent = raw.match(/(?<=\n)([^\S\n]+)"/)?.[1] ?? 2;
const eol = raw.endsWith("\n") ? "\n" : "";
writeFileSync(file, JSON.stringify(manifest, null, indent) + eol);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 162 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 225 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 203 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 290 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 B

+1
View File
@@ -0,0 +1 @@
this is definitely not a png file
+329
View File
@@ -0,0 +1,329 @@
import { deflateSync } from "node:zlib";
import { writeFileSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { fileURLToPath } from "node:url";
const __dirname = dirname(fileURLToPath(import.meta.url));
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(data) {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++)
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
function makePng(width, height, pixelAt) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const stride = width * 4 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0; // filter: none
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 4;
raw[p] = r;
raw[p + 1] = g;
raw[p + 2] = b;
raw[p + 3] = a;
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makePalettePng(width, height, palette, pixelAt) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 3; // color type: indexed
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
// PLTE: palette entries (R,G,B × count), padded to multiple of 3
const plteData = Buffer.alloc(256 * 3);
for (let i = 0; i < palette.length && i < 256; i++) {
plteData[i * 3] = palette[i][0];
plteData[i * 3 + 1] = palette[i][1];
plteData[i * 3 + 2] = palette[i][2];
}
// Raw image data: 1 byte per pixel (index)
const stride = width + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0;
for (let x = 0; x < width; x++) {
raw[y * stride + 1 + x] = pixelAt(x, y);
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("PLTE", plteData),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function make16bitPng(width, height, pixelAt) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 16; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = width * 8 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
raw[y * stride] = 0;
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 8;
raw.writeUInt16BE(r, p);
raw.writeUInt16BE(g, p + 2);
raw.writeUInt16BE(b, p + 4);
raw.writeUInt16BE(a, p + 6);
}
}
return Buffer.concat([
sig,
chunk("IHDR", ihdr),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
function makeCorruptPng() {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(4, 0);
ihdr.writeUInt32BE(4, 4);
ihdr[8] = 8;
ihdr[9] = 2; // RGB
ihdr[10] = 0;
ihdr[11] = 0;
ihdr[12] = 0;
const stride = 4 * 3 + 1;
const raw = Buffer.alloc(4 * stride);
for (let y = 0; y < 4; y++) {
raw[y * stride] = 0;
for (let x = 0; x < 4; x++) {
const p = y * stride + 1 + x * 3;
raw[p] = 255;
raw[p + 1] = 0;
raw[p + 2] = 0;
}
}
const idat = chunk("IDAT", deflateSync(raw));
// Corrupt CRC: flip a byte in the CRC field
idat[idat.length - 1] ^= 0xff;
return Buffer.concat([sig, chunk("IHDR", ihdr), idat, chunk("IEND", Buffer.alloc(0))]);
}
function makeTruncatedPng() {
return Buffer.from([137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82]);
}
const outDir = join(__dirname);
// 1. PNG 1×1
writeFileSync(join(outDir, "1x1.png"), makePng(1, 1, () => [255, 0, 0, 255]));
// 2. PNG 1×1 fully transparent
writeFileSync(
join(outDir, "1x1-transparent.png"),
makePng(1, 1, () => [0, 0, 0, 0]),
);
// 3. Large 1024×1024 checkerboard (≈1 megapixel RGBA)
writeFileSync(
join(outDir, "1024x1024-checker.png"),
makePng(1024, 1024, (x, y) =>
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255],
),
);
// 4. 1920×1080 gradient (≈2 megapixels, tests larger displays)
writeFileSync(
join(outDir, "1920x1080-gradient.png"),
makePng(1920, 1080, (x, y) => [
Math.floor((x / 1920) * 255),
Math.floor((y / 1080) * 255),
128,
255,
]),
);
// 5. Transparency checkerboard (black/white with alpha pattern)
writeFileSync(
join(outDir, "alpha-checkerboard.png"),
makePng(64, 64, (x, y) => {
const alpha = (x + y) % 3 === 0 ? 0 : 255;
const base = (x % 2 === y % 2) ? 0 : 255;
return [base, base, base, alpha];
}),
);
// 6. Palette / indexed-color PNG (4 colors: red, green, blue, white)
const palette = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255, 255, 255],
];
writeFileSync(
join(outDir, "palette-4colors.png"),
makePalettePng(32, 32, palette, (x, y) => {
const idx = (Math.floor(x / 8) + Math.floor(y / 8)) % palette.length;
return idx;
}),
);
// 7. Palette with many colors (256-color full palette)
const fullPalette = Array.from({ length: 256 }, (_, i) => [
i,
(i * 3) % 256,
(i * 7) % 256,
]);
writeFileSync(
join(outDir, "palette-256.png"),
makePalettePng(64, 64, fullPalette, (x, y) => (x + y * 64) % 256),
);
// 8. 16-bit RGBA PNG (subtle gradient to test high bit depth)
writeFileSync(
join(outDir, "16bit-subtle.png"),
make16bitPng(32, 32, (x, y) => [
Math.floor((x / 32) * 65535),
Math.floor((y / 32) * 65535),
32768,
65535,
]),
);
// 9. Corrupt PNG (bad CRC in IDAT)
writeFileSync(join(outDir, "corrupt-bad-crc.png"), makeCorruptPng());
// 10. Truncated PNG (only header, no data)
writeFileSync(join(outDir, "truncated.png"), makeTruncatedPng());
// 11. Non-PNG file disguised as PNG
writeFileSync(
join(outDir, "fake-png.txt"),
Buffer.from("this is definitely not a png file"),
);
// 12. All-black fully opaque
writeFileSync(
join(outDir, "all-black-opaque.png"),
makePng(64, 64, () => [0, 0, 0, 255]),
);
// 13. All-white fully opaque
writeFileSync(
join(outDir, "all-white-opaque.png"),
makePng(64, 64, () => [255, 255, 255, 255]),
);
// 14. All-transparent
writeFileSync(
join(outDir, "all-transparent.png"),
makePng(64, 64, () => [0, 0, 0, 0]),
);
// 15. Semi-transparent gradient (alpha 0→255 across width)
writeFileSync(
join(outDir, "alpha-gradient.png"),
makePng(128, 64, (x, y) => [
0,
100,
255,
Math.floor((x / 128) * 255),
]),
);
// 16. Noise pattern (random-ish pixels)
writeFileSync(
join(outDir, "noise-pattern.png"),
makePng(100, 100, (x, y) => {
const v = ((x * 7919 + y * 104729) % 256);
return [v, (v * 3) % 256, (v * 7) % 256, 255];
}),
);
// 17. Minimal size: 2×2 with extreme colors
writeFileSync(
join(outDir, "2x2-extreme.png"),
makePng(2, 2, (x, y) => {
if (x === 0 && y === 0) return [0, 0, 0, 255];
if (x === 1 && y === 0) return [255, 255, 255, 255];
if (x === 0 && y === 1) return [255, 0, 0, 128];
return [0, 0, 255, 0];
}),
);
// 18. Long strip (1×4000) — tests scroll / vertical tools
writeFileSync(
join(outDir, "1x4000-strip.png"),
makePng(1, 4000, (x, y) => [y % 256, (y * 2) % 256, (y * 3) % 256, 255]),
);
// 19. Wide strip (4000×1) — tests horizontal handling
writeFileSync(
join(outDir, "4000x1-strip.png"),
makePng(4000, 1, (x, y) => [x % 256, (x * 2) % 256, (x * 3) % 256, 255]),
);
// 20. PNG with very high alpha variance (sparse transparency)
writeFileSync(
join(outDir, "sparse-alpha.png"),
makePng(100, 100, (x, y) => {
const sparse = ((x * 31 + y * 17) % 100) < 10;
return [sparse ? 0 : 100, sparse ? 0 : 200, 255, sparse ? 0 : 255];
}),
);
console.log("Generated 20 edge case PNG files in:", outDir);
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 879 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 722 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 B

+28
View File
@@ -0,0 +1,28 @@
node_modules
# Output
.output
.vercel
.netlify
.wrangler
/.svelte-kit
/build
# OS
.DS_Store
Thumbs.db
# Env
.env
.env.*
!.env.example
!.env.test
# Vite
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
# Playwright
/test-results
/playwright-report
/e2e-results
+1
View File
@@ -0,0 +1 @@
engine-strict=true
+9
View File
@@ -0,0 +1,9 @@
node_modules
build
.svelte-kit
dist
pnpm-lock.yaml
static
*.log
.DS_Store
audit
+23
View File
@@ -0,0 +1,23 @@
{
"useTabs": true,
"singleQuote": false,
"trailingComma": "all",
"printWidth": 80,
"proseWrap": "always",
"endOfLine": "lf",
"plugins": ["prettier-plugin-svelte"],
"overrides": [
{
"files": "*.md",
"options": {
"useTabs": false
}
},
{
"files": "*.svelte",
"options": {
"parser": "svelte"
}
}
]
}
+3
View File
@@ -0,0 +1,3 @@
{
"recommendations": ["svelte.svelte-vscode"]
}
+50
View File
@@ -0,0 +1,50 @@
import { expect, test } from "playwright/test";
import {
expectNoErrors,
toolLink,
trackErrors,
useEnglish,
} from "./helpers/page";
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
test("catalog exposes a total and category headings", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/list-tools");
const total = page.getByRole("status", { name: /tools available/i });
await expect(total).toBeVisible();
await expect(total).toHaveAttribute("aria-label", /^\d+ /);
await expect(page.getByRole("heading", { level: 2 }).first()).toBeVisible();
expectNoErrors(sink);
});
test("catalog search narrows results", async ({ page }) => {
await page.goto("/list-tools");
await page.getByRole("textbox", { name: "Search tools" }).fill("resize");
await expect(toolLink(page, "resize-png")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
});
test("category filter narrows the catalog and resets", async ({ page }) => {
await page.goto("/list-tools");
const convert = page.getByRole("button", { name: /convert/i, exact: true });
const all = page.getByRole("button", { name: /^all$/i });
await convert.click();
await expect(convert).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "convert-png-to-jpg")).toBeVisible();
await expect(toolLink(page, "flip-png")).toBeHidden();
await all.click();
await expect(all).toHaveAttribute("aria-pressed", "true");
await expect(toolLink(page, "flip-png")).toBeVisible();
});
test("workspace search matches a tool by title", async ({ page }) => {
await page.goto("/");
await page.getByRole("textbox", { name: "Search tools" }).fill("flip");
await expect(toolLink(page, "flip-png")).toBeVisible();
await expect(toolLink(page, "resize-png")).toBeHidden();
});
+62
View File
@@ -0,0 +1,62 @@
import { expect, test } from "playwright/test";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
} from "./helpers/page";
const GENERATOR_IDS = [
"create-empty-png",
"random-noise-png",
"linear-gradient-png",
"color-spectrum-png",
"random-colors-png",
"draw-grid-png",
"placeholder-png",
"blend-two-png",
"step-colors-png",
"emoji-to-png",
"color-wheel-png",
"complementary-png",
"triadic-png",
"tetradic-png",
"analogous-png",
"monochromatic-png",
"shades-png",
"mix-colors-png",
"sort-colors-png",
"text-to-png",
] as const;
test.describe("generators", () => {
test("single-color-png auto-runs and reacts to parameters", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "single-color-png");
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(0);
await page.getByRole("spinbutton", { name: "Width" }).fill("32");
await page.getByRole("spinbutton", { name: "Height" }).fill("24");
await expect(resultImage(page)).toHaveJSProperty("naturalWidth", 32);
await expect(resultImage(page)).toHaveJSProperty("naturalHeight", 24);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
for (const id of GENERATOR_IDS) {
test(`tool ${id} produces a result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(resultImage(page)).toBeVisible();
await expect(page.getByRole("button", { name: "Generate" })).toHaveCount(
0,
);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
});
+288
View File
@@ -0,0 +1,288 @@
import { deflateSync } from "node:zlib";
export interface SourceFile {
name: string;
mimeType: string;
buffer: Buffer;
}
const CRC_TABLE = (() => {
const table = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c >>> 0;
}
return table;
})();
function crc32(data: Uint8Array): number {
let c = 0xffffffff;
for (let i = 0; i < data.length; i++)
c = CRC_TABLE[(c ^ data[i]) & 0xff] ^ (c >>> 8);
return (c ^ 0xffffffff) >>> 0;
}
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
function makeIhdr(
width: number,
height: number,
bitDepth: number,
colorType: number,
): Buffer {
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = bitDepth;
ihdr[9] = colorType;
return ihdr;
}
function chunk(type: string, data: Buffer): Buffer {
const out = Buffer.alloc(8 + data.length + 4);
out.writeUInt32BE(data.length, 0);
out.write(type, 4, "ascii");
data.copy(out, 8);
out.writeUInt32BE(crc32(out.subarray(4, 8 + data.length)), 8 + data.length);
return out;
}
function findChunk(png: Buffer, type: string): number {
let offset = PNG_SIGNATURE.length;
while (offset + 12 <= png.length) {
const length = png.readUInt32BE(offset);
const chunkType = png.subarray(offset + 4, offset + 8).toString("ascii");
if (chunkType === type) return offset;
offset += 12 + length;
}
throw new Error(`PNG chunk ${type} not found`);
}
/** Строит валидный RGBA PNG в рантайме (8-bit, фильтр type 0, deflate/zlib). */
export function makePng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const stride = width * 4 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 4;
raw[p] = r;
raw[p + 1] = g;
raw[p + 2] = b;
raw[p + 3] = a;
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function makeIndexedPng(
width: number,
height: number,
palette: [number, number, number][],
pixelAt: (x: number, y: number) => number,
): Buffer {
const plte = Buffer.alloc(palette.length * 3);
for (const [index, color] of palette.entries()) {
plte[index * 3] = color[0];
plte[index * 3 + 1] = color[1];
plte[index * 3 + 2] = color[2];
}
const stride = width + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
raw[y * stride + 1 + x] = pixelAt(x, y);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 8, 3)),
chunk("PLTE", plte),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function make16BitPng(
width: number,
height: number,
pixelAt: (x: number, y: number) => [number, number, number, number],
): Buffer {
const stride = width * 8 + 1;
const raw = Buffer.alloc(height * stride);
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const [r, g, b, a] = pixelAt(x, y);
const p = y * stride + 1 + x * 8;
raw.writeUInt16BE(r, p);
raw.writeUInt16BE(g, p + 2);
raw.writeUInt16BE(b, p + 4);
raw.writeUInt16BE(a, p + 6);
}
}
return Buffer.concat([
PNG_SIGNATURE,
chunk("IHDR", makeIhdr(width, height, 16, 6)),
chunk("IDAT", deflateSync(raw)),
chunk("IEND", Buffer.alloc(0)),
]);
}
export function withPngChunk(png: Buffer, type: string, data: Buffer): Buffer {
const iendOffset = png.length - 12;
return Buffer.concat([
png.subarray(0, iendOffset),
chunk(type, data),
png.subarray(iendOffset),
]);
}
export function withBadChunkCrc(png: Buffer, type: string): Buffer {
const copy = Buffer.from(png);
const offset = findChunk(copy, type);
const length = copy.readUInt32BE(offset);
copy[offset + 8 + length + 3] ^= 0xff;
return copy;
}
export function truncatePng(png: Buffer, length: number): Buffer {
return Buffer.from(png.subarray(0, length));
}
export function truncateChunkData(
png: Buffer,
type: string,
length: number,
): Buffer {
const offset = findChunk(png, type);
return truncatePng(png, offset + 8 + length);
}
export function withTrailingBytes(png: Buffer, tail: Buffer): Buffer {
return Buffer.concat([png, Buffer.from(tail)]);
}
const checker = (x: number, y: number): [number, number, number, number] =>
x % 2 === y % 2 ? [255, 0, 0, 255] : [255, 255, 255, 255];
const alphaGrid = (x: number, y: number): [number, number, number, number] =>
(x + y) % 3 === 0 ? [0, 0, 0, 0] : [0, 128, 255, 255];
const solidRed = (): [number, number, number, number] => [255, 0, 0, 255];
export function asSourceFile(buffer: Buffer, name: string): SourceFile {
return { name, mimeType: "image/png", buffer };
}
export const opaquePng: SourceFile = asSourceFile(
makePng(64, 48, checker),
"opaque.png",
);
export const transparentPng: SourceFile = asSourceFile(
makePng(64, 64, alphaGrid),
"transparent.png",
);
export const onePixelPng: SourceFile = asSourceFile(
makePng(1, 1, solidRed),
"1px.png",
);
export const landscapePng: SourceFile = asSourceFile(
makePng(64, 16, checker),
"landscape.png",
);
export const largePng: SourceFile = asSourceFile(
makePng(256, 256, checker),
"large.png",
);
export const corruptPng: SourceFile = {
name: "corrupt.png",
mimeType: "image/png",
buffer: Buffer.from("this is definitely not a png file", "utf8"),
};
const specialPng = makePng(8, 8, checker);
const specialWithText = withPngChunk(
specialPng,
"tEXt",
Buffer.from("Comment\0q6d", "ascii"),
);
const palette4 = [
[255, 0, 0],
[0, 255, 0],
[0, 0, 255],
[255, 255, 255],
] as [number, number, number][];
const palette256 = Array.from({ length: 256 }, (_, index) => [
index,
(index * 3) % 256,
(index * 7) % 256,
]) as [number, number, number][];
export const crcBadIdatPng = asSourceFile(
withBadChunkCrc(specialPng, "IDAT"),
"crc-bad-idat.png",
);
export const crcBadAncillaryPng = asSourceFile(
withBadChunkCrc(specialWithText, "tEXt"),
"crc-bad-ancillary.png",
);
export const truncatedHeaderPng = asSourceFile(
truncatePng(specialPng, PNG_SIGNATURE.length + 12 + 13),
"truncated-header.png",
);
export const truncatedNoIendPng = asSourceFile(
truncatePng(specialPng, specialPng.length - 12),
"truncated-no-iend.png",
);
export const idatCutPng = asSourceFile(
truncateChunkData(specialPng, "IDAT", 4),
"idat-cut.png",
);
export const garbageTailPng = asSourceFile(
withTrailingBytes(specialPng, Buffer.from("trailing-garbage", "ascii")),
"garbage-tail.png",
);
export const palette4Png = asSourceFile(
makeIndexedPng(
8,
8,
palette4,
(x, y) => (Math.floor(x / 2) + Math.floor(y / 2)) % palette4.length,
),
"palette-4.png",
);
export const palette256Png = asSourceFile(
makeIndexedPng(16, 16, palette256, (x, y) => (x + y * 16) % 256),
"palette-256.png",
);
export const sixteenBitPng = asSourceFile(
make16BitPng(8, 8, (x, y) => [x * 8000, y * 8000, 32768, 65535]),
"16bit.png",
);
export const tinyBase64 = makePng(8, 8, solidRed).toString("base64");
export const svgMarkup =
'<svg xmlns="http://www.w3.org/2000/svg" width="10" height="6">' +
'<rect width="10" height="6" fill="#ff0000"/></svg>';
const PIXEL_BYTES = (r: number, g: number, b: number, a: number): string =>
`${r} ${g} ${b} ${a}`;
/** Строка для bytes-to-png / rgb-values-to-png, ровно 32 пикселя в ширину по умолчанию. */
export function pixelRow(
count: number,
rgba: [number, number, number, number],
): string {
return Array.from({ length: count }, () => PIXEL_BYTES(...rgba)).join(" ");
}
+160
View File
@@ -0,0 +1,160 @@
import { readFile } from "node:fs/promises";
import {
expect,
type Download,
type Locator,
type Page,
} from "playwright/test";
import type { SourceFile } from "./fixtures";
export const TEST_IDS = {
source: "source-image",
result: "result-image",
verdict: "result-verdict",
empty: "empty-state",
} as const;
export type ErrorSink = { errors: string[] };
export function trackErrors(page: Page): ErrorSink {
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(`pageerror: ${e.message}`));
page.on("console", (m) => {
if (m.type() === "error") errors.push(`console: ${m.text()}`);
});
return { errors };
}
export async function useEnglish(page: Page): Promise<void> {
await page.addInitScript(() => {
window.localStorage.setItem("locale", "en");
});
}
export async function openTool(page: Page, slug: string): Promise<void> {
await useEnglish(page);
await page.goto(`/tools/${slug}`);
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
}
export function toolLink(page: Page, slug: string): Locator {
return page.locator(`a[href="/tools/${slug}"]`);
}
export function resultImage(page: Page): Locator {
return page.getByTestId(TEST_IDS.result);
}
export function sourceImage(page: Page): Locator {
return page.getByTestId(TEST_IDS.source);
}
export function verdictStatus(page: Page): Locator {
return page.getByTestId(TEST_IDS.verdict);
}
export function textResult(page: Page): Locator {
return page.getByLabel("Text result");
}
export function errorAlert(page: Page): Locator {
return page.getByRole("alert");
}
export function emptyState(page: Page): Locator {
return page.getByTestId(TEST_IDS.empty);
}
export function rangeInput(page: Page, name: RegExp | string): Locator {
return page.getByRole("slider", { name });
}
export async function renderText(page: Page, value: string): Promise<void> {
const input = page.getByRole("textbox", { name: "Text data" });
await input.fill(value);
await page.getByRole("button", { name: "Render text" }).click();
}
export async function uploadImage(page: Page, file: SourceFile): Promise<void> {
// На странице может быть два input[type=file]: дропзона + кнопка Open image;
// канонический — первый (кнопка в шапке панели).
const input = page.locator('input[type="file"]').first();
await expect(input).toBeAttached();
await input.setInputFiles({
name: file.name,
mimeType: file.mimeType,
buffer: file.buffer,
});
}
export async function uploadViaDrop(
page: Page,
file: SourceFile,
): Promise<void> {
const dataTransfer = await page.evaluateHandle(
({
bytes,
name,
mimeType,
}: {
bytes: number[];
name: string;
mimeType: string;
}) => {
const dt = new DataTransfer();
dt.items.add(new File([new Uint8Array(bytes)], name, { type: mimeType }));
return dt;
},
{
bytes: Array.from(file.buffer),
name: file.name,
mimeType: file.mimeType,
},
);
await page
.getByRole("button", { name: /drop a png/i })
.dispatchEvent("drop", { dataTransfer });
}
export async function downloadResultFile(page: Page): Promise<Download> {
// Split-кнопка: основная часть "Download" (image) или "Download ZIP" (files).
const button = page.getByRole("button", { name: /^Download( ZIP)?$/ });
await expect(button).toBeVisible();
const [download] = await Promise.all([
page.waitForEvent("download"),
button.click(),
]);
return download;
}
export async function chooseDownloadFormat(
page: Page,
format: string,
): Promise<void> {
await page.getByRole("button", { name: "Choose download format" }).click();
await page.getByRole("menuitemradio", { name: format, exact: true }).click();
}
export async function downloadResultBytes(
page: Page,
): Promise<{ name: string; bytes: Uint8Array }> {
const download = await downloadResultFile(page);
const path = await download.path();
if (!path) throw new Error("download path is unavailable");
return {
name: download.suggestedFilename(),
bytes: new Uint8Array(await readFile(path)),
};
}
export async function downloadResult(page: Page): Promise<string> {
return (await downloadResultFile(page)).suggestedFilename();
}
export async function expectNoErrorAlert(page: Page): Promise<void> {
await expect(errorAlert(page)).toHaveCount(0);
}
export async function expectNoErrors(sink: ErrorSink): Promise<void> {
expect(sink.errors, "нет console/page errors").toEqual([]);
}
+51
View File
@@ -0,0 +1,51 @@
import { expect, test } from "playwright/test";
import { pixelRow } from "./helpers/fixtures";
import { openTool, toolLink, useEnglish } from "./helpers/page";
test("поиск каталога находит по названию из другой локали", async ({
page,
}) => {
await useEnglish(page);
await page.goto("/list-tools");
const input = page.getByRole("textbox");
const flipCard = toolLink(page, "flip-png");
await input.fill("отразить");
await expect(flipCard).toContainText("Flip PNG");
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(flipCard).toContainText("Отразить PNG");
await input.fill("flip");
await expect(flipCard).toContainText("Отразить PNG");
});
test("RU/EN: переключение переводит заголовок инструмента", async ({
page,
}) => {
await openTool(page, "resize-png");
const ruBtn = page.getByRole("button", { name: "RU", exact: true });
const enBtn = page.getByRole("button", { name: "EN", exact: true });
const heading = page.getByRole("heading", { level: 1 });
await ruBtn.click();
await expect(heading).toHaveText("Изменить размер PNG");
await expect(ruBtn).toHaveAttribute("aria-pressed", "true");
await enBtn.click();
await expect(heading).toHaveText("Resize PNG");
});
test("ошибка run'а локализуется и переводится при смене языка", async ({
page,
}) => {
await openTool(page, "bytes-to-png");
await page
.getByRole("textbox", { name: "Text data" })
.fill(pixelRow(33, [255, 0, 0, 255]));
await page.getByRole("button", { name: "Render text" }).click();
const alert = page.getByRole("alert");
await expect(alert).toBeVisible();
await expect(alert).toHaveText(/\S/);
await expect(alert).not.toHaveText(/^errors\./);
const english = await alert.textContent();
await page.getByRole("button", { name: "RU", exact: true }).click();
await expect(alert).not.toHaveText(english ?? "");
await expect(alert).not.toHaveText(/^errors\./);
});
+54
View File
@@ -0,0 +1,54 @@
import { expect, test } from "playwright/test";
import { expectNoErrors, trackErrors, useEnglish } from "./helpers/page";
const ROUTES = ["/", "/list-tools", "/kit", "/tools/flip-png"];
test.beforeEach(async ({ page }) => {
await useEnglish(page);
});
for (const route of ROUTES) {
test(`page ${route} loads without errors`, async ({ page }) => {
const sink = trackErrors(page);
const resp = await page.goto(route);
expect(resp?.status()).toBe(200);
await expect(page.getByRole("main")).toBeVisible();
expectNoErrors(sink);
});
}
test("unknown tool route responds 404 on static build", async ({ page }) => {
const resp = await page.goto("/tools/definitely-not-a-tool");
expect(resp?.status()).toBe(404);
});
test("theme toggle persists the selected theme", async ({ page }) => {
const sink = trackErrors(page);
await page.goto("/");
const main = page.getByRole("main");
const before = await main.getAttribute("data-theme");
expect(["light", "dark"]).toContain(before);
await page.getByRole("button", { name: "Toggle theme" }).click();
await expect(main).not.toHaveAttribute("data-theme", before ?? "");
const after = await main.getAttribute("data-theme");
const stored = await page.evaluate(() => localStorage.getItem("theme"));
expect(["light", "dark"]).toContain(after);
expect(stored).toBe(after);
await page.reload();
await expect(main).toHaveAttribute("data-theme", after ?? "");
expectNoErrors(sink);
});
test("language toggle marks the active button", async ({ page }) => {
await page.goto("/");
const group = page.getByRole("group", { name: "Language" });
const en = group.getByRole("button", { name: "EN" });
await en.click();
await expect(en).toHaveAttribute("aria-pressed", "true");
await expect(group.getByRole("button", { name: "RU" })).toHaveAttribute(
"aria-pressed",
"false",
);
});
+72
View File
@@ -0,0 +1,72 @@
import { expect, test, type Page } from "playwright/test";
import {
crcBadAncillaryPng,
crcBadIdatPng,
garbageTailPng,
idatCutPng,
palette4Png,
palette256Png,
sixteenBitPng,
truncatedHeaderPng,
truncatedNoIendPng,
type SourceFile,
} from "./helpers/fixtures";
import {
errorAlert,
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
uploadImage,
} from "./helpers/page";
async function expectRejected(page: Page, file: SourceFile): Promise<void> {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, file);
await expect(errorAlert(page)).toBeVisible();
await expect(resultImage(page)).toHaveCount(0);
expectNoErrors(sink);
}
async function expectAccepted(page: Page, file: SourceFile): Promise<void> {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, file);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
}
type DecodeContract = {
file: SourceFile;
rejectedIn: readonly string[];
};
const decodeContracts: readonly DecodeContract[] = [
{ file: crcBadIdatPng, rejectedIn: ["chromium", "firefox"] },
{ file: truncatedHeaderPng, rejectedIn: ["chromium", "firefox", "webkit"] },
{ file: truncatedNoIendPng, rejectedIn: ["chromium"] },
{ file: idatCutPng, rejectedIn: ["chromium"] },
{ file: crcBadAncillaryPng, rejectedIn: [] },
{ file: garbageTailPng, rejectedIn: [] },
{ file: palette4Png, rejectedIn: [] },
{ file: palette256Png, rejectedIn: [] },
{ file: sixteenBitPng, rejectedIn: [] },
];
test.describe("PNG special fixtures", () => {
for (const { file, rejectedIn } of decodeContracts) {
test(`${file.name}: matches the engine decode contract`, async ({
browserName,
page,
}) => {
if (rejectedIn.includes(browserName)) {
await expectRejected(page, file);
} else {
await expectAccepted(page, file);
}
});
}
});
+104
View File
@@ -0,0 +1,104 @@
import { expect, test } from "playwright/test";
import {
opaquePng,
pixelRow,
svgMarkup,
tinyBase64,
transparentPng,
} from "./helpers/fixtures";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
renderText,
resultImage,
trackErrors,
uploadImage,
verdictStatus,
} from "./helpers/page";
async function expectVerdict(page: Parameters<typeof verdictStatus>[0]) {
const verdict = verdictStatus(page);
await expect(verdict).toBeVisible();
await expect(verdict).toHaveText(/\S/);
}
for (const [id, input] of [
["base64-to-png", tinyBase64],
["hex-to-png", "ff0000ff"],
["bytes-to-png", pixelRow(32, [255, 0, 0, 255])],
[
"rgb-values-to-png",
Array.from({ length: 32 }, () => "rgba(255,0,0,255)").join(" "),
],
["data-uri-to-png", `data:image/png;base64,${tinyBase64}`],
["svg-to-png", svgMarkup],
] as const) {
test(`text input → ${id} produces result image`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, id);
await expect(async () => {
await renderText(page, input);
await expect(resultImage(page)).toBeVisible();
}).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
test("png-to-base64 shows decoded text result", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-base64");
await uploadImage(page, opaquePng);
const output = page.getByLabel("Text result");
await expect(output).toBeVisible();
await expect(output).toHaveText(/\S/);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("verify-is-png renders different verdicts for valid and invalid input", async ({
page,
}) => {
await openTool(page, "verify-is-png");
const verdict = verdictStatus(page);
await renderText(page, tinyBase64);
await expect(verdict).toBeVisible();
const validText = await verdict.textContent();
await renderText(page, "aGVsbG8=");
await expect(verdict).not.toHaveText(validText ?? "");
});
test("png-is-transparent: opaque image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-is-transparent: transparent image renders a verdict", async ({
page,
}) => {
await openTool(page, "png-is-transparent");
await uploadImage(page, transparentPng);
await expectVerdict(page);
});
test("png-is-grayscale: colored image renders a verdict", async ({ page }) => {
await openTool(page, "png-is-grayscale");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-orientation: landscape image renders a verdict", async ({ page }) => {
await openTool(page, "png-orientation");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
test("png-file-size: renders a verdict", async ({ page }) => {
await openTool(page, "png-file-size");
await uploadImage(page, opaquePng);
await expectVerdict(page);
});
+320
View File
@@ -0,0 +1,320 @@
import { unzipSync } from "fflate";
import { expect, test } from "playwright/test";
import {
corruptPng,
landscapePng,
largePng,
onePixelPng,
opaquePng,
transparentPng,
} from "./helpers/fixtures";
import {
chooseDownloadFormat,
downloadResultBytes,
errorAlert,
expectNoErrorAlert,
expectNoErrors,
openTool,
rangeInput,
resultImage,
sourceImage,
trackErrors,
uploadImage,
uploadViaDrop,
useEnglish,
} from "./helpers/page";
test("resize-png uses the source dimensions by default", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("resize-png keeps user-set size when another image is uploaded", async ({
page,
}) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
const width = page.getByRole("spinbutton", { name: "Width" });
await width.fill("25");
// keepAspect включён по умолчанию: высота пересчитана из 64×48 → 19
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"19",
);
await uploadImage(page, landscapePng);
await expect(width).toHaveValue("25");
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"19",
);
});
test("resize-png applies size preset chips", async ({ page }) => {
await openTool(page, "resize-png");
await uploadImage(page, opaquePng);
await page.getByRole("button", { name: "32×32" }).click();
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"32",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"32",
);
});
test("crop-png starts with the full source area", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "crop-png");
await uploadImage(page, opaquePng);
await expect(page.getByRole("spinbutton", { name: "Width" })).toHaveValue(
"64",
);
await expect(page.getByRole("spinbutton", { name: "Height" })).toHaveValue(
"48",
);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("flip-png: upload via drag-and-drop on the dropzone", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadViaDrop(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("flip-png: full flow - upload, result, download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.png");
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("convert-png-to-jpg: produces JPEG and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-jpg");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-jpg.jpg");
expect(Array.from(low.bytes.subarray(0, 3))).toEqual([255, 216, 255]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("convert-png-to-webp: produces WebP and quality affects size", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "convert-png-to-webp");
await uploadImage(page, largePng);
await expect(resultImage(page)).toBeVisible();
const quality = rangeInput(page, "Quality");
await quality.fill("10");
const low = await downloadResultBytes(page);
expect(low.name).toBe("convert-png-to-webp.webp");
expect(Array.from(low.bytes.subarray(0, 4))).toEqual([82, 73, 70, 70]);
expect(Array.from(low.bytes.subarray(8, 12))).toEqual([87, 69, 66, 80]);
await quality.fill("90");
const high = await downloadResultBytes(page);
expect(high.bytes.length).toBeGreaterThan(low.bytes.length);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("download format selector: JPG warns about alpha and produces JPEG", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, transparentPng);
await expect(resultImage(page)).toBeVisible();
await chooseDownloadFormat(page, "JPG");
await expect(
page.getByRole("status").filter({ hasText: /transparency/ }),
).toBeVisible();
// у JPG в dropdown есть настройка качества
await rangeInput(page, "Quality").fill("10");
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("flip-png.jpg");
expect(Array.from(bytes.subarray(0, 3))).toEqual([0xff, 0xd8, 0xff]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("png-to-bmp: produces a BMP download", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "png-to-bmp");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("png-to-bmp.bmp");
expect(Array.from(bytes.subarray(0, 2))).toEqual([66, 77]);
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("reset restores defaults and re-runs with the source", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const radius = rangeInput(page, /radius/i);
await radius.fill("20");
await expect(radius).toHaveValue("20");
await page.getByRole("button", { name: "Reset", exact: true }).click();
await expect(radius).toHaveValue("4");
await expect(sourceImage(page)).toBeVisible();
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("split-into-parts-png downloads a zip with named PNG parts", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "split-into-parts-png");
await uploadImage(page, landscapePng);
await rangeInput(page, "Columns").fill("3");
await rangeInput(page, "Rows").fill("2");
await expect(page.getByText("part-1-3.png")).toBeVisible();
const { name, bytes } = await downloadResultBytes(page);
expect(name).toBe("split-into-parts-png.zip");
const entries = unzipSync(bytes);
expect(Object.keys(entries)).toEqual([
"part-1-1.png",
"part-1-2.png",
"part-1-3.png",
"part-2-1.png",
"part-2-2.png",
"part-2-3.png",
]);
for (const bytes of Object.values(entries)) {
expect(Array.from(bytes.subarray(0, 8))).toEqual([
137, 80, 78, 71, 13, 10, 26, 10,
]);
}
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("blur: changing slider updates result image", async ({ page }) => {
await openTool(page, "blur-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
const firstSource = await resultImage(page).getAttribute("src");
await rangeInput(page, /radius/i).fill("20");
await expect(resultImage(page)).not.toHaveAttribute("src", firstSource ?? "");
});
test("runs without Web Worker (fallback)", async ({ browser }, testInfo) => {
// newContext() не наследует use.baseURL из конфига — пробрасываем явно,
// иначе относительный goto() в helpers не резолвится.
const context = await browser.newContext({
baseURL: testInfo.project.use.baseURL,
});
try {
await context.addInitScript(() => {
Object.defineProperty(window, "Worker", {
value: undefined,
configurable: true,
});
});
const page = await context.newPage();
await useEnglish(page);
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, landscapePng);
await expect(resultImage(page)).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
} finally {
await context.close();
}
});
test("chain: add step extends pipeline without reload and persists", async ({
page,
}) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, opaquePng);
await expect(resultImage(page)).toBeVisible();
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
await expect(resultImage(page)).toBeVisible();
// цепочка переживает перезагрузку (localStorage)
await page.reload();
await expect(stepTwo).toBeVisible();
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
test("chain: remove step returns to a single tool", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await page.getByRole("button", { name: "Add step" }).last().click();
await page.getByRole("menuitem", { name: "Grayscale PNG" }).click();
const stepTwo = page.getByRole("heading", {
name: "Grayscale PNG",
exact: true,
});
await expect(stepTwo).toBeVisible();
await page.getByRole("button", { name: "Remove step" }).last().click();
await expect(stepTwo).toHaveCount(0);
expectNoErrors(sink);
});
test("invalid file upload shows error, no crash", async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, corruptPng);
await expect(errorAlert(page)).toBeVisible();
await expect(resultImage(page)).toHaveCount(0);
expectNoErrors(sink);
});
for (const fixture of [onePixelPng, largePng]) {
test(`handles ${fixture.name} correctly`, async ({ page }) => {
const sink = trackErrors(page);
await openTool(page, "flip-png");
await uploadImage(page, fixture);
await expect(resultImage(page)).toBeVisible();
expectNoErrors(sink);
});
}
+147
View File
@@ -0,0 +1,147 @@
import { expect, test } from "playwright/test";
import type { SourceFile } from "./helpers/fixtures";
import { opaquePng, transparentPng } from "./helpers/fixtures";
import {
expectNoErrorAlert,
expectNoErrors,
openTool,
resultImage,
trackErrors,
textResult,
uploadImage,
verdictStatus,
} from "./helpers/page";
type Kind = "image" | "text-out" | "verdict";
const CASES: { id: string; kind: Kind; file?: SourceFile }[] = [
// ── convert ────────────────────────────────────────────────
{ id: "convert-png-to-jpg", kind: "image" },
{ id: "convert-png-to-webp", kind: "image" },
{ id: "png-to-bmp", kind: "image" },
{ id: "png-to-base64", kind: "text-out" },
{ id: "png-to-data-uri", kind: "text-out" },
{ id: "png-to-hex", kind: "text-out" },
{ id: "png-to-bytes", kind: "text-out" },
{ id: "png-to-rgb-values", kind: "text-out" },
// ── alpha ──────────────────────────────────────────────────
{ id: "remove-background-png", kind: "image", file: transparentPng },
{ id: "remove-color-from-png", kind: "image", file: transparentPng },
{ id: "round-corners-png", kind: "image", file: transparentPng },
{ id: "add-stroke-png", kind: "image", file: transparentPng },
{ id: "circle-mask-png", kind: "image", file: transparentPng },
{ id: "wavy-mask-png", kind: "image", file: transparentPng },
{ id: "find-contour-png", kind: "image", file: transparentPng },
{ id: "feather-edges-png", kind: "image", file: transparentPng },
{ id: "clean-edges-png", kind: "image", file: transparentPng },
{ id: "make-thicker-png", kind: "image", file: transparentPng },
{ id: "make-thinner-png", kind: "image", file: transparentPng },
{ id: "despeckle-alpha-png", kind: "image", file: transparentPng },
{ id: "close-holes-png", kind: "image", file: transparentPng },
{ id: "harden-alpha-png", kind: "image", file: transparentPng },
{ id: "invert-alpha-png", kind: "image", file: transparentPng },
{ id: "set-alpha-channel-png", kind: "image", file: transparentPng },
{ id: "extract-alpha-mask-png", kind: "image", file: transparentPng },
{ id: "remove-alpha-channel-png", kind: "image", file: transparentPng },
{ id: "square-mask-png", kind: "image", file: transparentPng },
{ id: "star-mask-png", kind: "image", file: transparentPng },
// ── color ──────────────────────────────────────────────────
{ id: "grayscale-png", kind: "image" },
{ id: "invert-colors-png", kind: "image" },
{ id: "sepia-png", kind: "image" },
{ id: "quantize-png", kind: "image" },
{ id: "dithering-png", kind: "image" },
{ id: "decrease-color-count-png", kind: "image" },
{ id: "two-colors-png", kind: "image" },
{ id: "tint-png", kind: "image" },
{ id: "auto-contrast-png", kind: "image" },
{ id: "png-to-hsl", kind: "image" },
{ id: "png-to-cmyk", kind: "image" },
{ id: "gamma-png", kind: "image" },
{ id: "temperature-png", kind: "image" },
{ id: "custom-palette-png", kind: "image" },
{ id: "adjust-brightness-contrast-png", kind: "image" },
{ id: "change-png-opacity", kind: "image" },
{ id: "change-png-hue", kind: "image" },
{ id: "swap-channels-png", kind: "image" },
{ id: "black-and-white-png", kind: "image" },
{ id: "posterize-png", kind: "image" },
{ id: "png-to-hsv", kind: "image" },
{ id: "png-to-hsi", kind: "image" },
{ id: "png-to-ycbcr", kind: "image" },
{ id: "png-to-lab", kind: "image" },
// ── geometry ───────────────────────────────────────────────
{ id: "flip-png", kind: "image" },
{ id: "rotate-png", kind: "image" },
{ id: "rotate-free-png", kind: "image" },
{ id: "fit-on-background-png", kind: "image" },
{ id: "add-padding-png", kind: "image" },
{ id: "add-border-png", kind: "image" },
{ id: "tile-png", kind: "image" },
{ id: "change-canvas-size-png", kind: "image" },
{ id: "trim-empty-space-png", kind: "image" },
{ id: "swap-orientation-png", kind: "image" },
{ id: "skew-png", kind: "image" },
{ id: "zoom-png", kind: "image" },
{ id: "center-by-alpha-png", kind: "image" },
{ id: "change-aspect-ratio-png", kind: "image" },
{ id: "symmetric-copy-png", kind: "image" },
{ id: "shift-png", kind: "image" },
// ── filters ────────────────────────────────────────────────
{ id: "blur-png", kind: "image" },
{ id: "sharpen-png", kind: "image" },
{ id: "pixelate-png", kind: "image" },
{ id: "add-noise-png", kind: "image" },
{ id: "vignette-png", kind: "image" },
{ id: "silhouette-png", kind: "image" },
{ id: "randomize-pixels-png", kind: "image" },
{ id: "jpeg-artifacts-png", kind: "image" },
// ── text ───────────────────────────────────────────────────
{ id: "add-text-png", kind: "image" },
{ id: "date-stamp-png", kind: "image" },
{ id: "watermark-tile-png", kind: "image" },
// ── analyze (image output) ─────────────────────────────────
{ id: "extract-color-from-png", kind: "image", file: transparentPng },
{ id: "show-transparent-png", kind: "image", file: transparentPng },
{ id: "light-pixel-mask-png", kind: "image", file: transparentPng },
{ id: "extract-channel-png", kind: "image", file: transparentPng },
{ id: "unique-color-mask-png", kind: "image", file: transparentPng },
{ id: "show-grayscale-pixels-png", kind: "image", file: transparentPng },
{ id: "show-color-pixels-png", kind: "image", file: transparentPng },
{ id: "dark-pixel-mask-png", kind: "image", file: transparentPng },
// ── analyze (verdict output) ───────────────────────────────
{ id: "png-is-transparent", kind: "verdict" },
{ id: "png-is-grayscale", kind: "verdict" },
{ id: "png-orientation", kind: "verdict" },
{ id: "png-file-size", kind: "verdict" },
];
test.describe("smoke: tools produce output without errors", () => {
for (const { id, kind, file: fixture } of CASES) {
test(`tool ${id} (${kind})`, async ({ page }) => {
const sink = trackErrors(page);
const outputLocator = () => {
switch (kind) {
case "image":
return resultImage(page);
case "text-out":
return textResult(page);
case "verdict":
return verdictStatus(page);
}
};
// toPass: устойчивость к hydration-рейсу (ввод до гидрации SvelteKit
// может не обработаться с первого раза).
await expect(async () => {
await openTool(page, id);
await uploadImage(page, fixture ?? opaquePng);
await expect(outputLocator()).toBeVisible({ timeout: 8_000 });
}).toPass({ timeout: 25_000 });
await expectNoErrorAlert(page);
expectNoErrors(sink);
});
}
});
+7
View File
@@ -0,0 +1,7 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"types": ["node"]
},
"include": ["./**/*.ts"]
}
+201
View File
@@ -0,0 +1,201 @@
# ESLint-плагины и дизайн-токены: детали
> Статус: **active**. Область применения — `web/eslint-plugins/**`,
> `web/scripts/**`, конфиги линтинга. Остальная рутина разработки её не требует;
> детали нужны только при правке самих линт-правил или конфигов. Команды и
> пороги — `docs/quality-gates.md`.
## Структура
- `web/eslint-plugins/` — локальные ESLint-плагины.
- `design-tokens/` — правила для `<style>`-блоков svelte-компонентов.
- `conventions/` — конвенции кода (интерфейс пропсов, запрет union-алиасов).
- `i18n/` — кросс-языковой линтер словарей (`dict-consistency`).
- `__tests__/` — юнит-тесты (Vitest + `RuleTester`/`Linter`).
- `__fixtures__/` — фикстуры для тестов: миниатюрный `src/app.css` (словарь
токенов) и мини-словари `lib/i18n/`.
- `web/scripts/`:
- `lint-all.mjs` — оркестратор `lint:all`.
- `check-tokens.mjs` + `token-audit/` — токен-аудит по `app.css`.
- `postcss-hct.mjs` — постcss-плагин эммита `hct()` в sRGB-hex.
- `__tests__/` — тесты аудита и HCT-плагина; подхватываются обычным
`vitest run` через `scripts/**/*.test.mjs`, поэтому входят в `verify`.
- `web/stylelint.config.js`, `web/postcss.config.js` — конфиги stylelint/css.
## Конфигурация ESLint (`web/eslint.config.js`)
Устроена инкрементально:
- На **весь код** — парсинг TS/Svelte + правило
`@typescript-eslint/consistent-type-imports` (запрет инлайн-тип-импортов,
`prefer: 'type-imports'`).
- Полные `recommended`-наборы (`eslint` + `typescript-eslint` +
`eslint-plugin-svelte`) навешаны на UI-код (`src/lib/components/**`,
`src/routes/**`). Scoped-пути двигаются вместе с папками: не оставлять
устаревшие пути в `eslint.config.js`.
- Базовый `js.configs.recommended` + `globals.node` навешаны на инфраструктуру
линтинга: `eslint-plugins/**/*.js` и `scripts/**/*.mjs`.
TS/svelte-рекомендации туда не подключаются.
- Для `*.svelte` выключен `prefer-const` (пропсы в Svelte 5 пишутся через
`let`).
- `allowDefaultProject` перечисляет test-файлы и фикстуры точечно (glob'ы с `**`
там запрещены tseslint) — при добавлении файлов в `__tests__/` /
`__fixtures__/` дописать их туда же.
- design-tokens правила применяются к тем же scoped-путям
(`src/lib/components/**`, `src/routes/**`).
- `i18n/no-hardcoded-user-text` применяется к обоим scoped-путям
(`src/lib/components/**` и `src/routes/**`): хардкод в `+page.svelte` и
`+layout.svelte` — такой же production-UI. Исключение одно — `/kit`, витрина с
намеренно латинскими подписями; выключается отдельным объектом конфига с
`files: ["**/src/routes/kit/**/*.svelte"]`, потому что негативные паттерны в
`files` не сужают область действия правила. Опция `allowWords` — brand не
хардкодится в правиле.
## Правила плагина design-tokens
- `design-tokens/no-hardcoded-in-svelte` — запрет прямых цветов/размеров/
длительностей/z-index и `color-mix()` (результат токенизировать в CSS).
Исключения размеров: `0`, `0px`, `1px`, проценты и unitless (`line-height`).
- `design-tokens/no-category-mismatch` — size-свойство не может использовать
color-токен (`--color-*`/`--brand-*`) и наоборот.
- `design-tokens/no-token-definition-in-svelte` — определение `--x:` в
компоненте не может содержать примитив (hex/rgb/oklch/px/rem); допустимы
производные от токенов (`var()`, `calc()`, unitless-числа).
- `design-tokens/no-undefined-in-svelte` — `var(--x)` в `<style>` должен быть
определён в `src/app.css` (словарь токенов) или локально в компоненте. Файл
читается один раз и кэшируется (не `glob`-зависим).
(ESLint лезет в `<style>`-блоки через постпрефес postcss AST от
`svelte-eslint-parser`; stylelint прогоняется по всем CSS-файлам.)
## Плагин i18n (`i18n/`)
### `i18n/dict-consistency`
Кросс-языковой линтер словарей `lib/i18n/` (историческая модель и завершённые
фазы: `docs/archive/plan-preview-i18n.md`). Сравнивает каждый словарь со
**всеми** остальными локалями (не только с BASE). Список локалей берётся из
`LOCALES` в `lib/i18n/dict.ts` — новый словарь подхватывается без правки
правила.
- **warn-only, никогда `error`** — пропущенный перевод не валит сборку.
- Диагностика живёт там, где фикс: каждый файл репортит **только свои**
расхождения:
- **паритет ключей**: правило собирает union всех ключей из словарей-соседей
(с диска) и для текущего файла репортит те, которых в нём нет — ровно одно
предупреждение на разрыв, независимо от числа локалей, где ключ есть;
пропавшая целая секция репортится один раз (не по каждому потомку);
- **пустые значения**: `""` и строка из одних пробелов;
- **паритет плейсхолдеров**: у одинакового ключа набор `{name}` сравнивается с
каноническим (мажоритарный по локалям, при равенстве — `BASE_LOCALE`, затем
`LOCALES`) — ловит потерянную при переводе переменную ровно один раз, даже
когда отклоняется одна-единственная локаль.
- Словари-соседи читаются с диска и кэшируются на процесс линта (как
`no-undefined-in-svelte`); каждый парсится `@typescript-eslint/parser`
(синтакс, без типов).
- Опция `allowPaths: string[]` — dot-path ключи, исключаемые из всех проверок
(осознанные отклонения до достижения zero-warn).
- `baseLocaleFallback: string[]` — паттерны ключей, которые допустимо
отсутствовать только в базовой локали; в текущей конфигурации это тексты
страниц `pages.*.title` и `pages.*.description` (EN-строка берётся из
реестра). Пустые значения и placeholders для присутствующих ключей всё равно
проверяются.
- `ignoreMissingPatterns: string[]` — паттерны ключей, которые временно не
проверяются на missing; в текущей конфигурации это неиспользуемые
`tools.*.params`. Эта опция не отключает empty/placeholder checks.
- Тесты: `__tests__/dict-consistency.test.ts`, фикстуры-словари в
`__fixtures__/src/lib/i18n/` (`en`, `ru`, `de` — консистентные, zero-warn) — в
тестах подаются модифицированные варианты `en.ts`/`ru.ts` против эталонных на
диске.
### `i18n/no-hardcoded-user-text`
Запрещает пользовательский текст, зашитый в шаблон: литерал в Svelte-тексте или
в строковом значении пользовательского атрибута молча уедет в RU-локаль. Пилот
report-only (`warn`), baseline — 0 срабатываний.
- Поверхность: узлы `SvelteText` (включая текстовые части смешанного контента) и
строковые литералы атрибутов из `i18n/lists.js` (`label`, `title`,
`placeholder`, `aria-label`, `alt`, `description`, `emptyText`, `hint`,
`eyebrow`, `suffix`, `unit`, `error`). Технические атрибуты (`variant`,
`tone`, `type`, `icon`, `size`, `href`, `class`) не проверяются, значения в
скобках (`{expr}`) тоже — они считаются в другом месте.
- Исключения списком, а не «на глаз»: форматы и единицы (`PNG`, `px`, `×`),
имена файлов и версии (`result.png`, `v0.1.0`, `v1`), размеры (`512×512`),
registry-строки и прочие ALL CAPS идентификаторы (`BACKGROUND`), а также
`/kit` — витрина компонентов с намеренно латинскими подписями (исключение
живёт в `eslint.config.js`, а не в правиле).
- Опция `allowWords: string[]` — brand и прочие собственные имена. Название
продукта передаётся конфигом, а не зашито в правило.
- Содержимое `<style>` и `<script>` пропускается: там код, а не копирайт.
- Тесты: `__tests__/no-hardcoded-user-text.test.ts` — `RuleTester` на
svelte-eslint-parser плюс прямые кейсы `hasUserWords`.
## Токены-префиксы (целевой словарь дизайна)
- Цвета: `--color-*`, бренд `--brand-main` / `--brand-alt` — единственные две
переменные, которым разрешено быть hex/rgb, остальные цвета — только `hct()` и
только в app.css.
- Размеры: `--space-*`, `--text-*` (font-size), `--radius-*`, `--size-*`.
- Брейкпоинты: `@custom-media --bp-mobile (max-width: 640px)` /
`--bp-tablet (800px)` / `--bp-desktop (1100px)` — объявляются в app.css,
используются как `@media (--bp-*)`. CSS-переменные в `@media` не работают,
поэтому отдельных `--bp-*` токенов нет; раскрытие делает postcss-плагин
`postcss-custom-media` (конфиг `web/postcss.config.js`, определения
подтягиваются через `@csstools/postcss-global-data`).
- z-index: `--z-*`; длительности/анимации: `--duration-*`, `--ease-*`.
Нюансы stylelint:
- Правило `custom-property-pattern` тестирует паттерн **без** `--` (`--x` →
`x`), а `declaration-property-value-disallowed-list` — целиком с `--`.
- `custom-property-empty-line-before` перенастроен (`after-custom-property` в
`ignore`, не в `except`): пустые строки между подряд идущими токенами
**свободны** и `--fix` их не удаляет — можно группировать цветовые и размерные
токены в app.css отдельными блоками.
## Токен-аудит (`check-tokens.mjs`)
Лёгкий оркестратор поверх `web/scripts/token-audit/*`. Проверяет:
- **Parity**: каждый цветовой токен из `:root` обязан иметь пару в
`[data-theme="dark"]` и наоборот. Производные токены (значение содержит
`var()`, напр. `hct(from var(--...))`) из пары исключены — они наследуют тему
автоматически.
- **hct-only для цветов**: ЛЮБОЕ цветовое значение в app.css обязано быть
`hct(...)` — и литерал, и производное `hct(from var(...) h c t)`. Исключения:
только `--brand-main` / `--brand-alt` (seed-токены, любая форма) и
`color-mix(...)` (единственный легальный способ смешать два токена).
`oklch()/rgb()/#hex` в `--color-*` запрещены.
- **Резолв `var()`**: каждая ссылка `var(--x)` в app.css должна указывать на
токен, определённый в этом же файле. `var(--x, fallback)` не считается ошибкой
— fallback легален без определения. Провал (exit 1), потому что несуществующий
токен молча роняет объявление.
- **Неиспользуемые токены**: выводится **варнинг** (определены, но нигде не
используются) — выход он не меняет. Проверка видит только ссылки `var(--x)`,
поэтому чтение токена из JS (`getPropertyValue`) она не учтёт; решение
оставить проверку неблокирующей — `docs/decisions.md`, раздел 6 (как оно
выглядит в гейте — `docs/quality-gates.md`). Провалами (exit 1) считаются
parity, hct-авторство и резолв `var()`.
## Тесты кастомных линт-правил
Линт-правила покрыты юнит-тестами (Vitest, `test:rules`):
- `__tests__/design-tokens.test.ts` — три чистых правила
(`no-hardcoded-in-svelte`, `no-category-mismatch`,
`no-token-definition-in-svelte`) через `RuleTester` со строковыми кейсами;
`no-undefined-in-svelte` — через `Linter` API, т.к. читает словарь токенов из
`__fixtures__/src/app.css` (не из реального `src/app.css`).
- `__tests__/interface-props.test.ts` и
`__tests__/no-string-union-alias.test.ts` — конвенции плагина `conventions`
(интерфейс пропсов, запрет union-алиасов).
- Хелпер `__tests__/helpers.ts` собирает `Linter` с `cwd = __fixtures__` —
`process.cwd()` не трогается, реальные `src/` не читаются.
Запуск: `pnpm --dir web test:rules` (`vitest run eslint-plugins/__tests__`).
Полный `pnpm --dir web test` тоже их гоняет.
Фикстуры живут в `__fixtures__/` и сами прогоняются линтом (`eslint .`), поэтому
добавление/правка стабов — тоже работа с валидным кодом.
@@ -0,0 +1,11 @@
:root {
--color-fg: #101010;
--space-1: calc(var(--size-1) / 2);
--z-nav: 100;
--duration-fast: 120ms;
--text-md: 0.875rem;
}
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1,28 @@
import type { Dict } from "./dict";
export const de: Dict = {
header: {
workspace: "Arbeitsbereich",
catalog: "Katalog",
},
home: {
hero: "Hallo {name}!",
restoreLast: "{title} wiederherstellen",
},
errors: {
sourceRequired: "Quelle erforderlich",
},
pages: {
addBorder: {
title: "Rahmen hinzufügen",
description: "Zeichnet einen {kind}-Rahmen.",
},
},
tools: {
addBorder: {
results: {
done: "Rahmen {what}",
},
},
},
};
@@ -0,0 +1,20 @@
export const LOCALES = ["en", "ru", "de"] as const;
export const BASE_LOCALE: "en" = "en";
export type Dict = {
header: Record<string, string>;
home: Record<string, string>;
errors: Record<string, string>;
pages?: Record<string, PageStrings>;
tools: Record<string, ToolStrings>;
};
export type PageStrings = {
title?: string;
description?: string;
};
export type ToolStrings = {
results?: Record<string, string>;
};
@@ -0,0 +1,24 @@
import type { Dict } from "./dict";
export const en: Dict = {
header: {
workspace: "Workspace",
catalog: "Catalog",
},
home: {
hero: "Hello {name}!",
restoreLast: "Restore {title}",
},
errors: {
sourceRequired: "Source image required",
},
// Базовая локаль: тексты страниц берутся из EN-строк реестра.
pages: {},
tools: {
addBorder: {
results: {
done: "Border {what}",
},
},
},
};
@@ -0,0 +1,28 @@
import type { Dict } from "./dict";
export const ru: Dict = {
header: {
workspace: "Рабочая область",
catalog: "Каталог",
},
home: {
hero: "Привет, {name}!",
restoreLast: "Вернуть {title}",
},
errors: {
sourceRequired: "Нужен исходник",
},
pages: {
addBorder: {
title: "Добавить рамку",
description: "Рисует рамку {kind}.",
},
},
tools: {
addBorder: {
results: {
done: "Рамка {what}",
},
},
},
};
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
// Fixture stub for lint-rule tests: content is ignored, must exist for import resolution.
@@ -0,0 +1 @@
<!-- Fixture stub for lint-rule tests: content is ignored, must exist for import resolution. -->
@@ -0,0 +1,205 @@
// Tests for the design-tokens plugin rules (web/eslint-plugins/design-tokens).
//
// Strategy:
// - no-hardcoded-in-svelte, no-category-mismatch, no-token-definition-in-svelte
// are pure AST checks -> standard RuleTester with string cases.
// - no-undefined-in-svelte reads the token dictionary from <cwd>/src/app.css,
// so it runs through the Linter API with cwd pinned to the fixtures dir
// (see __tests__/helpers.ts). The fixture dictionary has exactly five
// tokens; anything else must be reported even if it exists in the REAL
// web/src/app.css.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import noCategoryMismatch from "../design-tokens/no-category-mismatch.js";
import noHardcoded from "../design-tokens/no-hardcoded-in-svelte.js";
import noTokenDefinition from "../design-tokens/no-token-definition-in-svelte.js";
import noUndefined from "../design-tokens/no-undefined-in-svelte.js";
import { asRuleModule, verifyInFixtures, type FlatConfig } from "./helpers.js";
const parserOptions = {
parser: tseslint.parser,
};
// Vitest doesn't expose describe/it as globals (no `globals: true` in the
// config), so RuleTester would fall back to its synchronous default handler.
// Register the real ones so each case becomes a proper vitest test.
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions,
},
});
/** Wrap CSS from a <style> block into a minimal Svelte component. */
const component = (style: string): string =>
`<div class="box">x</div>\n\n<style>\n${style}\n</style>\n`;
const frame = (style: string) => ({
code: component(style),
filename: "Component.svelte",
});
describe("design-tokens/no-hardcoded-in-svelte", () => {
ruleTester.run("no-hardcoded-in-svelte", asRuleModule(noHardcoded), {
valid: [
frame(".box { color: var(--color-fg); }"),
frame(".box { padding: var(--space-3); }"),
frame(".box { background-color: transparent; }"),
frame(".box { width: 100%; }"),
frame(".box { line-height: 1.5; }"),
frame(".box { transition: var(--duration-fast); }"),
frame(".box { z-index: var(--z-nav); }"),
frame(".box { font-size: var(--text-md); }"),
frame("<div>no style block at all</div>"),
],
invalid: [
{
...frame(".box { color: #ff0000; }"),
errors: [{ messageId: "hardcodedColor" }],
},
{
...frame(".box { padding: 16px; }"),
errors: [{ messageId: "hardcodedSize" }],
},
{
...frame(".box { transition: 200ms; }"),
errors: [{ messageId: "hardcodedDuration" }],
},
{
...frame(".box { z-index: 100; }"),
errors: [{ messageId: "hardcodedZIndex" }],
},
{
...frame(
"@media (max-width: 640px) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "hardcodedBreakpoint" }],
},
{
...frame(
"@media (min-width: var(--bp-m)) { .box { width: var(--text-md); } }",
),
errors: [{ messageId: "varInMedia" }],
},
{
...frame(
".box { color: color-mix(in srgb, var(--color-a), var(--color-b)); }",
),
errors: [{ messageId: "colorMix" }],
},
],
});
});
describe("design-tokens/no-category-mismatch", () => {
ruleTester.run("no-category-mismatch", asRuleModule(noCategoryMismatch), {
valid: [
frame(".box { padding: var(--space-1); }"),
frame(".box { color: var(--color-fg); }"),
frame(".box { border: 1px solid var(--color-border); }"),
],
invalid: [
{
...frame(".box { padding: var(--color-fg); }"),
errors: [{ messageId: "categoryMismatch" }],
},
{
...frame(".box { color: var(--space-1); }"),
errors: [{ messageId: "categoryMismatch" }],
},
],
});
});
describe("design-tokens/no-token-definition-in-svelte", () => {
ruleTester.run(
"no-token-definition-in-svelte",
asRuleModule(noTokenDefinition),
{
valid: [
frame(".box { --local: var(--color-fg); }"),
frame(".box { --local: calc(var(--space-1) * 2); }"),
frame(".box { --ratio: 1.5; }"),
],
invalid: [
{
...frame(".box { --local: #ff0000; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: 12px; }"),
errors: [{ messageId: "tokenPrimitive" }],
},
{
...frame(".box { --local: oklch(0.5 0.1 240); }"),
errors: [{ messageId: "tokenPrimitive" }],
},
],
},
);
});
describe("design-tokens/no-undefined-in-svelte", () => {
const undefinedConfig: FlatConfig = [
{
files: ["**/*.svelte"],
plugins: {
"design-tokens": {
rules: { "no-undefined-in-svelte": asRuleModule(noUndefined) },
},
},
rules: { "design-tokens/no-undefined-in-svelte": "error" },
languageOptions: {
parser: svelteParser,
parserOptions,
},
},
];
it("accepts tokens defined in the fixture app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-fg); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("accepts component-local overrides", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { --local-x: var(--color-fg); color: var(--local-x); }"),
"lib/components/Foo.svelte",
);
expect(messages).toEqual([]);
});
it("flags an unknown token even if it exists in the real web/src/app.css", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(".box { color: var(--color-love); }"),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--color-love");
});
it("flags unknown tokens referenced inside @media params", () => {
const messages = verifyInFixtures(
undefinedConfig,
component(
"@media (width >= var(--bp-xs)) { .box { width: var(--text-md); } }",
),
"lib/components/Foo.svelte",
);
expect(messages).toHaveLength(1);
expect(messages[0].messageId).toBe("undefinedToken");
expect(messages[0].message).toContain("--bp-xs");
});
});
@@ -0,0 +1,216 @@
// Tests for i18n/dict-consistency (web/eslint-plugins/i18n/dict-consistency.js).
//
// The rule reads its own dict dir from disk (LOCALES in dict.ts + sibling
// locale files), so it runs through the Linter API with cwd pinned to the
// fixture tree (see __tests__/helpers.ts). The committed fixtures at
// __fixtures__/src/lib/i18n/ are mutually consistent (zero-warn) — tests feed
// MODIFIED variants of en.ts / ru.ts as the linted code so each check is
// exercised against the on-disk references.
//
// Reporting contract: each file reports only its OWN gaps. A key missing in a
// file produces exactly ONE `missingKey` warning, emitted on the incomplete
// file itself (the union of all keys comes from the on-disk dicts), regardless
// of how many other locales have the key. Placeholder parity is compared
// against the canonical set — the one shared by the most locales (ties by
// LOCALES order) — so a deviation is reported exactly once.
import fs from "node:fs";
import path from "node:path";
import { describe, expect, it } from "vitest";
import tseslint from "typescript-eslint";
import dictConsistency from "../i18n/dict-consistency.js";
import {
asRuleModule,
FIXTURES_SRC,
verifyInFixtures,
type FlatConfig,
} from "./helpers.js";
const I18N_DIR = path.join(FIXTURES_SRC, "lib", "i18n");
const fixture = (name: string) =>
fs.readFileSync(path.join(I18N_DIR, name), "utf8");
function lint(code: string, relFile: string, options?: unknown) {
const rule = options ? ["warn", options] : "warn";
const config: FlatConfig = [
{ files: ["**/*.ts"], languageOptions: { parser: tseslint.parser } },
{
files: ["**/*.ts"],
plugins: {
i18n: {
rules: { "dict-consistency": asRuleModule(dictConsistency) },
},
},
rules: { "i18n/dict-consistency": rule as "warn" },
},
];
return verifyInFixtures(config, code, relFile);
}
function messageIds(messages: { messageId?: string }[]) {
return messages.map((m) => m.messageId);
}
const registryPolicy = {
baseLocaleFallback: ["pages.*.title", "pages.*.description"],
ignoreMissingPatterns: ["tools.*.params"],
};
describe("i18n/dict-consistency", () => {
it("stays clean on the consistent fixture dicts", () => {
expect(
messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts", registryPolicy)),
).toEqual([]);
expect(
messageIds(lint(fixture("ru.ts"), "lib/i18n/ru.ts", registryPolicy)),
).toEqual([]);
expect(
messageIds(lint(fixture("de.ts"), "lib/i18n/de.ts", registryPolicy)),
).toEqual([]);
});
it("allows page texts only in the base locale", () => {
// EN не переводит заголовки страниц: их берёт EN-строка реестра.
expect(
messageIds(lint(fixture("en.ts"), "lib/i18n/en.ts", registryPolicy)),
).toEqual([]);
const ruMissing = fixture("ru.ts").replace(
'\t\t\ttitle: "Добавить рамку",\n',
"",
);
expect(
messageIds(lint(ruMissing, "lib/i18n/ru.ts", registryPolicy)),
).toEqual(["missingKey"]);
});
it("does not warn for a tool object with nothing but ignored results", () => {
const enMissingTool = fixture("en.ts").replace(
/\t\taddBorder: \{[\s\S]*?\n\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingTool, "lib/i18n/en.ts", {
...registryPolicy,
ignoreMissingPatterns: [
...registryPolicy.ignoreMissingPatterns,
"tools.*.results",
],
}),
),
).toEqual([]);
});
it("does not warn for configured legacy missing paths", () => {
const enMissingResults = fixture("en.ts").replace(
/\t\t\tresults: \{[\s\S]*?\n\t\t\t\},\n/,
"",
);
expect(
messageIds(
lint(enMissingResults, "lib/i18n/en.ts", {
...registryPolicy,
ignoreMissingPatterns: ["tools.*.results"],
}),
),
).toEqual([]);
});
it("still reports empty page text", () => {
const ruEmpty = fixture("ru.ts").replace(
'title: "Добавить рамку",',
'title: "",',
);
expect(messageIds(lint(ruEmpty, "lib/i18n/ru.ts", registryPolicy))).toEqual(
["emptyValue"],
);
});
it("flags a leaf key missing in the current file", () => {
const enMissing = fixture("en.ts").replace(
'\t\trestoreLast: "Restore {title}",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"home.restoreLast"');
expect(messages[0].message).toContain(" in en");
});
it("reports a missing whole section once, not per child key", () => {
const enNoHeader = fixture("en.ts").replace(/\theader: \{[\s\S]*?\},/, "");
const messages = lint(enNoHeader, "lib/i18n/en.ts", registryPolicy);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"header"');
expect(messages[0].message).not.toContain("header.workspace");
});
it("emits ONE warning per gap even when several locales own the key", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", registryPolicy);
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["missingKey"]);
expect(messages[0].message).toContain('"errors.sourceRequired"');
expect(messages[0].message).toContain(" in en");
});
it("flags empty and whitespace-only values", () => {
const enEmpty = fixture("en.ts")
.replace('workspace: "Workspace",', 'workspace: "",')
.replace('catalog: "Catalog",', 'catalog: " ",');
const messages = lint(enEmpty, "lib/i18n/en.ts", registryPolicy);
expect(messageIds(messages)).toEqual(["emptyValue", "emptyValue"]);
expect(messages[0].message).toContain('"header.workspace"');
expect(messages[0].message).toContain(" in en");
expect(messages[1].message).toContain('"header.catalog"');
});
it("flags a placeholder lost in translation (vs the canonical set)", () => {
const ruLostPlaceholder = fixture("ru.ts").replace(
'hero: "Привет, {name}!",',
'hero: "Привет!",',
);
const messages = lint(ruLostPlaceholder, "lib/i18n/ru.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"home.hero"');
expect(messages[0].message).toContain("{} vs expected {name}");
});
it("flags an extra placeholder introduced in one locale", () => {
const ruExtraPlaceholder = fixture("ru.ts").replace(
'description: "Рисует рамку {kind}.",',
'description: "Рисует рамку {kind} от {author}.",',
);
const messages = lint(ruExtraPlaceholder, "lib/i18n/ru.ts");
expect(messages).toHaveLength(1);
expect(messageIds(messages)).toEqual(["placeholderMismatch"]);
expect(messages[0].message).toContain('"pages.addBorder.description"');
expect(messages[0].message).toContain("{author, kind} vs expected {kind}");
});
it("honours allowPaths for conscious deviations", () => {
const enMissing = fixture("en.ts").replace(
'\t\tsourceRequired: "Source image required",',
"",
);
const messages = lint(enMissing, "lib/i18n/en.ts", {
...registryPolicy,
allowPaths: ["errors.sourceRequired"],
});
expect(messageIds(messages)).toEqual([]);
});
it("ignores files that are not locale dicts", () => {
const notALocale = `
export const helper = { a: "b" };
`;
expect(messageIds(lint(notALocale, "lib/i18n/t.ts"))).toEqual([]);
expect(messageIds(lint(notALocale, "lib/i18n/misc.ts"))).toEqual([]);
});
});
+49
View File
@@ -0,0 +1,49 @@
// Shared harness for testing the custom ESLint rules in eslint-plugins/.
//
// Two strategies, picked per rule in the test files:
// - RuleTester (eslint) — pure string cases, no filesystem. Best fit for the
// design-tokens rules that only inspect the postcss AST of Svelte `<style>`.
// - Linter API + fixtures — needed when a rule reads real files, such as
// no-undefined-in-svelte, which reads <cwd>/src/app.css for the token
// dictionary. `verifyInFixtures` runs a rule with cwd fixed to
// `__fixtures__/`, without touching process.cwd() or the real src/ tree.
import { Linter } from "eslint";
import type { Rule } from "eslint";
import path from "node:path";
import { fileURLToPath } from "node:url";
const HERE = path.dirname(fileURLToPath(import.meta.url));
export const FIXTURES_DIR = path.resolve(HERE, "../__fixtures__");
export const FIXTURES_SRC = path.join(FIXTURES_DIR, "src");
/** Flat-config shape the Linter.verify accepts. */
export type FlatConfig = Parameters<Linter["verify"]>[1];
/**
* Run a rule (or a config) against `code` as if it were the fixture file
* `relFile` under `__fixtures__/src`. Returns the lint messages.
*/
export function verifyInFixtures(
config: FlatConfig,
code: string,
relFile: string,
) {
const linter = new Linter({ configType: "flat", cwd: FIXTURES_DIR });
return linter.verify(code, config, path.join(FIXTURES_SRC, relFile));
}
/** Wrap a script body into a minimal Svelte component (runs in runes mode). */
export function svelteComponent(script: string): string {
return `<main>Hello</main>\n\n<script lang="ts">\n${script}\n</script>\n`;
}
/**
* Narrow an inferred JS rule to the typed `Rule.RuleModule`. The plugin rule
* files are plain JS, so TypeScript infers a widened shape (`meta.type: string`)
* that `RuleTester.run` rejects; the shape is structurally correct at runtime,
* which is why existing rules keep their JS inference and only the tests cast.
*/
export function asRuleModule(rule: unknown): Rule.RuleModule {
return rule as Rule.RuleModule;
}
@@ -0,0 +1,99 @@
// Tests for the conventions/interface-props rule
// (web/eslint-plugins/conventions/interface-props.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester with svelte-eslint-parser + the TS sub-parser. No fixtures needed.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import interfaceProps from "../conventions/interface-props.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
ruleTester.run("interface-props", asRuleModule(interfaceProps), {
valid: [
{ code: "<main>Hello</main>", filename: "Component.svelte" },
frame("let x = 1;"),
// $state / $derived calls are unrelated, not props.
frame("const s = $state(0);"),
frame("const doubled = $derived(s * 2);"),
frame(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
frame(
"interface Props { label: string }\nlet { label = 'x' }: Props = $props();",
),
frame(
"interface Props { value: string }\nlet { value = $bindable() }: Props = $props();",
),
// Interface declared after the destructuring is still fine.
frame(
"let { label }: Props = $props();\ninterface Props { label: string }",
),
frame(
"interface Props { id: string; rest?: unknown }\nlet { id, ...rest }: Props = $props();",
),
frame(
"import type { Snippet } from 'svelte';\n" +
"interface Props { children?: Snippet }\n" +
"let { children }: Props = $props();",
),
],
invalid: [
{
// Untyped destructuring with a local interface -> autofixable.
...frame("interface Props { label: string }\nlet { label } = $props();"),
output: svelteComponent(
"interface Props { label: string }\nlet { label }: Props = $props();",
),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Untyped destructuring without an interface -> report, no fix.
...frame("let { label } = $props();"),
errors: [{ messageId: "untypedDestructure" }],
},
{
// Binding the whole props object instead of destructuring.
...frame("let props = $props();"),
errors: [{ messageId: "noDestructure" }],
},
{
// Inline generic is banned outright.
...frame("const props = $props<{ a: string }>();"),
errors: [{ messageId: "inlineGeneric" }],
},
{
// A different type name than the local interface Props.
...frame(
"interface Props { a: string }\nlet { a }: StageProps = $props();",
),
errors: [{ messageId: "notNamedProps" }],
},
{
// Inline object props type instead of interface Props.
...frame("let { a }: { a: string } = $props();"),
errors: [{ messageId: "inlineObjectType" }],
},
{
// Reference to Props without any local declaration.
...frame("let { a }: Props = $props();"),
errors: [{ messageId: "missingInterface" }],
},
],
});
@@ -0,0 +1,167 @@
// Tests for the i18n/no-hardcoded-user-text rule
// (web/eslint-plugins/i18n/no-hardcoded-user-text.js).
//
// The rule is a pure AST check over the Svelte template, so it runs through the
// standard RuleTester with svelte-eslint-parser + the TS sub-parser. The
// exclusion list (brand, formats, units, registry source strings) is the whole
// point of the pilot, so every exclusion has a case here.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, expect, it } from "vitest";
import rule, { hasUserWords } from "../i18n/no-hardcoded-user-text.js";
import { asRuleModule } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const ruleTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const OPTIONS = [{ allowWords: ["easy-png-tools"] }];
ruleTester.run("no-hardcoded-user-text", asRuleModule(rule), {
valid: [
// Translated text comes from t(), never from a literal.
{ code: '<p>{t("ui.reset")}</p>', filename: "Component.svelte" },
{
code: '<Button label={t("actions.download")} />',
filename: "Component.svelte",
},
// Formats, units and file names are not copy.
{ code: "<p>PNG</p>", filename: "Component.svelte" },
{ code: "<p>result.png</p>", filename: "Component.svelte" },
{ code: "<p>512×512</p>", filename: "Component.svelte" },
{ code: '<span class="u">px</span>', filename: "Component.svelte" },
{ code: "<p>1.0 s</p>", filename: "Component.svelte" },
{ code: "<p>v0.1.0</p>", filename: "Component.svelte" },
// Registry source strings and other shouted identifiers.
{ code: "<p>BACKGROUND</p>", filename: "Component.svelte" },
{ code: '<StepCard type="TRANSFORM" />', filename: "Component.svelte" },
// Technical attributes are out of scope.
{
code: '<Button variant="outline" tone="danger" size="lg" />',
filename: "Component.svelte",
},
{
code: '<a href="/tools/resize-png">{t("nav.home")}</a>',
filename: "x.svelte",
},
// Values written as expressions are computed elsewhere.
{
code: "<p>{value}</p>",
filename: "Component.svelte",
},
{
code: "<EmptyState title={copy} description={hint} />",
filename: "Component.svelte",
},
// Brand configured as an allowed word.
{
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
options: OPTIONS,
},
{
code: "<footer><span>easy-png-tools</span><span>v0.1.0</span></footer>",
filename: "Component.svelte",
options: OPTIONS,
},
// <style> and <script> bodies are code, not copy.
{
code: '<p>{t("ui.reset")}</p>\n<style>.a { color: red; }</style>',
filename: "Component.svelte",
options: OPTIONS,
},
{
code: '<script>const hint = "internal message";</script>\n<p>{t("ui.reset")}</p>',
filename: "Component.svelte",
options: OPTIONS,
},
],
invalid: [
{
code: "<p>Visible text</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<span class="axis-label">From</span>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="Download result" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<Button label="outline" variant="outline" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
code: '<ImageCard alt="Preview of the processed image" />',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Mixed content: the text part carries copy, the unit part does not.
code: "<p>Result {value} px</p>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Navigation copy is user text too.
code: '<a href="/tools/resize-png">Home</a>',
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Nested elements are walked too.
code: "<div><span><b>Delete file</b></span></div>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
{
// Without the option the brand word is reported: the exclusion is
// configuration, not a hardcoded constant inside the rule.
code: "<span>easy-png-tools</span>",
filename: "Component.svelte",
errors: [{ messageId: "hardcodedText" }],
},
],
});
describe("hasUserWords", () => {
it("accepts technical fragments", () => {
for (const value of [
"",
" ",
"px",
"512×512",
"result.png",
"1.0 s",
"v0.1.0",
"× ·",
"BACKGROUND",
"·",
]) {
expect(hasUserWords(value), value).toBe(false);
}
});
it("accepts configured brand words", () => {
expect(hasUserWords("easy-png-tools", ["easy-png-tools"])).toBe(false);
expect(hasUserWords("easy-png-tools")).toBe(true);
});
it("accepts formats and units next to real copy", () => {
expect(hasUserWords("PNG 512×512")).toBe(false);
expect(hasUserWords("Result PNG")).toBe(true);
});
});
@@ -0,0 +1,78 @@
// Tests for the conventions/no-string-union-alias rule
// (web/eslint-plugins/conventions/no-string-union-alias.js).
//
// The rule is a pure AST check (no filesystem), so it runs through the standard
// RuleTester: plain .ts via the tseslint parser, and (because the rule is also
// enabled on new .svelte scripts) .svelte via the svelte tester. No fixtures.
import { RuleTester } from "eslint";
import svelteParser from "svelte-eslint-parser";
import tseslint from "typescript-eslint";
import { describe, it } from "vitest";
import noStringUnionAlias from "../conventions/no-string-union-alias.js";
import { asRuleModule, svelteComponent } from "./helpers.js";
RuleTester.describe = describe;
RuleTester.it = it;
const tsTester = new RuleTester({
languageOptions: {
parser: tseslint.parser,
},
});
const svelteTester = new RuleTester({
languageOptions: {
parser: svelteParser,
parserOptions: { parser: tseslint.parser },
},
});
const frame = (script: string) => ({
code: svelteComponent(script),
filename: "Component.svelte",
});
tsTester.run("no-string-union-alias", asRuleModule(noStringUnionAlias), {
valid: [
{ code: "type X = 1 | 2;", filename: "types.ts" },
{ code: "type X = 'a' | number;", filename: "types.ts" },
{ code: "type X = string;", filename: "types.ts" },
{ code: "const KIND = { a: 1, b: 2 } as const;", filename: "types.ts" },
{ code: "function f(x: 'a' | 'b') {}", filename: "types.ts" },
{ code: "const x: 'a' | 'b' = 'a';", filename: "types.ts" },
{ code: "type X = 'a' | TemplateStrings;", filename: "types.ts" },
],
invalid: [
{
code: "type Kind = 'a' | 'b' | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
// Nested/parenthesized unions still resolve to string literals.
code: "type Kind = ('a' | 'b') | 'c';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
{
code: "type Lang =\n\t'en'\n\t| 'ru';",
filename: "types.ts",
errors: [{ messageId: "stringUnion" }],
},
],
});
// The rule also runs on new .svelte files (their scripts are TS).
svelteTester.run(
"no-string-union-alias (svelte script)",
asRuleModule(noStringUnionAlias),
{
valid: [frame("type X = 1 | 2;"), frame("const M = { a: 1 } as const;")],
invalid: [
{
...frame("type Kind = 'a' | 'b' | 'c';"),
errors: [{ messageId: "stringUnion" }],
},
],
},
);
+28
View File
@@ -0,0 +1,28 @@
/**
* Local ESLint plugin "conventions".
*
* Cross-cutting code conventions that the recommended rule sets don't enforce
* (and plain @typescript-eslint rules cannot express in one shot):
*
* - interface-props: Svelte 5 props always go through a local `interface
* Props` + `let { ... }: Props = $props()` (no inline generics, no inline
* type imports, no untyped destructuring);
* - no-string-union-alias: string-literal union aliases (`type Kind = 'a' |
* 'b'`) are banned in favor of a single `as const` object + indexed access,
* so literal sets live in exactly one place.
*
* Rules are AST-only (no filesystem), see the tests in eslint-plugins/__tests__.
*/
import interfaceProps from "./interface-props.js";
import noStringUnionAlias from "./no-string-union-alias.js";
export default {
meta: {
name: "conventions",
version: "0.1.0",
},
rules: {
"interface-props": interfaceProps,
"no-string-union-alias": noStringUnionAlias,
},
};
@@ -0,0 +1,135 @@
// Rule: SVELTE 5 PROPS MUST USE A LOCAL `interface Props`.
//
// Convention (AGENTS.md): every typed props of a component is described by a
// local `interface Props`, and the props are destructured with the annotation:
//
// interface Props {
// label: string;
// accent?: boolean;
// children?: Snippet;
// }
// let { label, accent = false, children }: Props = $props();
//
// Banned instead:
// - the inline generic `$props<{ ... }>()` — hard to read and splits the
// type away from the file structure;
// - untyped destructuring `let { ... } = $props()`;
// - binding the whole props object (`const props = $props()`);
// - any props type name other than the local `interface Props`.
//
// Inline `import('...')` type queries are NOT checked here: they are already
// banned by @typescript-eslint/consistent-type-imports.
//
// The check is purely syntactic (AST-level). Svelte's runes model guarantees
// $props() only exists in instance <script> blocks, so no scope/type info is
// needed. Interfaces are collected from every <script> (module + instance).
import { isInsideScriptElement } from "./utils.js";
export default {
meta: {
type: "suggestion",
fixable: "code",
docs: {
description:
"Require a local `interface Props` and `let { ... }: Props = $props()` for Svelte 5 props; ban inline generics, untyped destructuring and non-local props type names.",
category: "Svelte conventions",
recommended: true,
},
schema: [],
messages: {
inlineGeneric:
"Avoid the inline generic `$props<T>()`; declare a local `interface Props` and destructure it: `let { ... }: Props = $props()`.",
untypedDestructure:
"Annotate the props destructuring with a local `interface Props`: `let { ... }: Props = $props()`.",
noDestructure:
"Destructure props with `let { ... }: Props = $props()` instead of binding the whole `$props()` object.",
notNamedProps:
"The props type must be the local `interface Props` (found '{{name}}').",
inlineObjectType:
"Declare a local `interface Props` instead of an inline object props type.",
missingInterface:
"No local `interface Props` is declared in this component; add one and use it as the props type.",
},
},
create(context) {
const interfaces = new Set();
return {
Program(node) {
for (const child of node.body) {
if (child.type !== "SvelteScriptElement") continue;
for (const stmt of child.body ?? []) {
if (stmt.type !== "TSInterfaceDeclaration") continue;
interfaces.add(stmt.id.name);
}
}
},
CallExpression(node) {
if (
node.callee.type !== "Identifier" ||
node.callee.name !== "$props"
) {
return;
}
if (!isInsideScriptElement(node)) return;
// Inline generic $props<{ ... }>() is banned outright.
if (node.typeArguments?.params?.length) {
context.report({ node: node.callee, messageId: "inlineGeneric" });
return;
}
let declarator = node.parent;
while (declarator && declarator.type !== "VariableDeclarator") {
declarator = declarator.parent;
}
if (!declarator) return;
const id = declarator.id;
if (id.type === "ObjectPattern") {
const annotation = id.typeAnnotation?.typeAnnotation ?? null;
if (annotation) {
if (annotation.type === "TSTypeReference") {
const typeName = annotation.typeName;
if (!typeName || typeName.type !== "Identifier") return;
if (typeName.name !== "Props") {
context.report({
node: typeName,
messageId: "notNamedProps",
data: { name: typeName.name },
});
} else if (!interfaces.has("Props")) {
context.report({
node: typeName,
messageId: "missingInterface",
});
}
} else if (annotation.type !== "TSImportType") {
context.report({
node: annotation,
messageId: "inlineObjectType",
});
}
} else {
context.report({
node: id,
messageId: "untypedDestructure",
...(interfaces.has("Props")
? {
fix: (fixer) => fixer.insertTextAfter(id, ": Props"),
}
: {}),
});
}
return;
}
if (id.type === "Identifier") {
context.report({ node: id, messageId: "noDestructure" });
}
},
};
},
};
@@ -0,0 +1,64 @@
// Rule: NO STRING-LITERAL UNION TYPE ALIASES.
//
// A type alias whose members are all string literals (`type Kind = 'a' | 'b'`)
// duplicates the literal set: the same set is re-typed in many places, and new
// "derived" aliases (`type Kind2 = 'a' | 'c'`) appear that drift from the
// source. Prefer a single `as const` object as the source of truth and derive
// the type from it with indexed access.
//
// const KIND = { a: ..., b: ..., c: ... } as const;
// type Kind = (typeof KIND)[keyof typeof KIND];
//
// Only TSTypeAliasDeclaration is checked — inline unions in parameter or
// property types (one-off uses) are left alone.
/** True when the union and all its (possibly nested) members are string literals. */
function allStringLiterals(/** @type {any} */ union) {
for (const member of union.types) {
if (member.type === "TSUnionType") {
if (!allStringLiterals(member)) return false;
} else if (member.type === "TSLiteralType") {
const literal = member.literal;
if (!(literal.type === "Literal" && typeof literal.value === "string")) {
return false;
}
} else {
return false;
}
}
return true;
}
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban string-literal union type aliases in favor of an `as const` object plus `(typeof X)[keyof typeof X]`.",
category: "TypeScript conventions",
recommended: true,
},
schema: [],
messages: {
stringUnion:
"Prefer one `as const` object over the string-literal union alias '{{name}}' — literal sets duplicated across types or files drift apart. Derive the type instead: `const {{constName}} = {...} as const; type {{name}} = (typeof {{constName}})[keyof typeof {{constName}}]`.",
},
},
create(context) {
return {
TSTypeAliasDeclaration(node) {
const annotation = node.typeAnnotation;
if (!annotation || annotation.type !== "TSUnionType") return;
if (!allStringLiterals(annotation)) return;
context.report({
node: node.id,
messageId: "stringUnion",
data: {
name: node.id.name,
constName: node.id.name.toUpperCase(),
},
});
},
};
},
};
+15
View File
@@ -0,0 +1,15 @@
// Shared helpers for the conventions plugin (web/eslint-plugins/conventions).
/**
* True when the node sits somewhere under a Svelte <script> block
* (SvelteScriptElement). Used to limit $props() checks to script code.
* @param {any} node
*/
export function isInsideScriptElement(node) {
let cursor = node.parent;
while (cursor && cursor.type !== "Program") {
if (cursor.type === "SvelteScriptElement") return true;
cursor = cursor.parent;
}
return false;
}
+64
View File
@@ -0,0 +1,64 @@
// Shared constants for the design-tokens ESLint plugin.
// Single source for property lists and regexes so each rule cooks from the
// same vocabulary.
// =====================================================================
// Properties that accept a COLOR.
// =====================================================================
export const COLOR_PROPS =
/^(color|background|background-color|background-image|border|border-color|border-top|border-right|border-bottom|border-left|outline|outline-color|box-shadow|text-shadow|fill|stroke|fill-color|stroke-color|stop-color|flood-color|lighting-color|column-rule|column-rule-color|text-decoration|text-decoration-color|caret-color|accent-color|border-top-color|border-right-color|border-bottom-color|border-left-color)$/;
// =====================================================================
// Properties that accept a SIZE (px/rem/em).
// z-index is handled separately (it's an integer, not a length).
// =====================================================================
export const SIZE_PROPS =
/^(width|height|min-width|max-width|min-height|max-height|padding|padding-top|padding-right|padding-bottom|padding-left|margin|margin-top|margin-right|margin-bottom|margin-left|gap|column-gap|row-gap|top|right|bottom|left|inset|font|font-size|letter-spacing|word-spacing|line-height|border-radius|border-top-left-radius|border-top-right-radius|border-bottom-left-radius|border-bottom-right-radius|border-width|border-top-width|border-right-width|border-bottom-width|border-left-width|flex-basis|background-size|background-position|border-spacing|grid-template-columns|grid-template-rows)$/;
// =====================================================================
// SHORTHAND properties that accept BOTH a color and a size. The browser
// assigns their sub-properties by value type at runtime (length -> width,
// color -> ...-color), so they cannot be category-checked positionally.
// border -> border-width + border-color
// outline -> outline-width + outline-color
// text-decoration -> text-decoration-line/-color/...
// column-rule -> column-rule-width + column-rule-color
// The longhands they expand to (border-width, border-color, ...) are already
// covered individually by SIZE_PROPS / COLOR_PROPS.
// =====================================================================
export const MIXED_PROPS =
/^(border|border-top|border-right|border-bottom|border-left|outline|text-decoration|column-rule)$/;
// =====================================================================
// Properties that carry a DURATION (ms/s) — transitions/animations.
// =====================================================================
export const DURATION_PROPS =
/^(transition|transition-duration|transition-delay|animation|animation-duration|animation-delay)$/;
// =====================================================================
// A hardcoded COLOR literal: hex / color functions / named colors.
// Regex legend:
// - hex: #[0-9a-fA-F]{3,8}\b — #fff / #112233 / #1234
// - funcs: (rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)(
// - names: common CSS color names on a word boundary
// =====================================================================
export const COLOR_LITERAL =
/#[0-9a-fA-F]{3,8}\b|\b(?:rgb|rgba|hsl|hsla|hwb|lab|lch|oklch|oklab)\s*\(|(?:^|\s|,|\()(?:white|black|red|green|blue|yellow|orange|purple|pink|gray|grey|silver|lime|teal|cyan|navy|maroon|olive|aqua|fuchsia|gold|indigo|violet|magenta|grey)\b/gi;
// =====================================================================
// A hardcoded SIZE literal (px/rem/em), fractional allowed: 0.5rem.
// =====================================================================
export const FORBIDDEN_SIZE_TOKEN = /\d+(?:\.\d+)?(?:px|rem|em)\b/g;
// =====================================================================
// A hardcoded DURATION literal (ms/s): 200ms, 0.3s.
// =====================================================================
export const FORBIDDEN_DURATION_TOKEN = /\d+(?:\.\d+)?(?:ms|s)\b/g;
// =====================================================================
// Token PREFIXES by category (tests run against the var() name, e.g.
// --color-bg, --brand-main, --space-1, --z-header).
// =====================================================================
export const COLOR_TOKEN = /^--(?:color|brand)-/;
export const SIZE_TOKEN = /^--(?:space|size|text|radius|bp|z)-/;
export const DURATION_TOKEN = /^--(?:duration|ease|motion)-/;
@@ -0,0 +1,95 @@
// Rule: NO TOKEN-CATEGORY MISMATCH IN COMPONENTS.
// A size property (padding, gap, font-size ...) must use a SIZE token
// (--space-*, --size-*, --text-*, --radius-*, --bp-*, --z-*). A color property
// (color, background, border-color ...) must use a COLOR token
// (--color-*, --brand-*). Crossing categories (e.g. padding: var(--color-x))
// is a sign the wrong token is being reused.
import {
COLOR_PROPS,
COLOR_TOKEN,
MIXED_PROPS,
SIZE_PROPS,
SIZE_TOKEN,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Collect the token names referenced by var() in a value.
const collectVars = (value) =>
Array.from(value.matchAll(/var\(\s*(--[\w-]+)/g), (m) => m[1]);
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban using a color token in size properties and a size token in color properties; each property must use tokens of its own category.",
category: "Design tokens",
recommended: true,
},
messages: {
categoryMismatch:
"Token '{{token}}' in '{{prop}}' belongs to the {{category}} category; expected a {{expected}} token.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Mixed shorthands (border, outline, text-decoration,
// column-rule) legitimately take BOTH a size and a color.
// The browser assigns sub-properties by value type at runtime,
// not by position, so skip category checks for them.
// Their longhands (border-width, border-color, ...) are still
// covered individually.
if (MIXED_PROPS.test(prop)) return;
const isSizeProp = SIZE_PROPS.test(prop);
const isColorProp = COLOR_PROPS.test(prop);
if (!isSizeProp && !isColorProp) return;
for (const token of collectVars(value)) {
// A size property that holds a COLOR token.
if (isSizeProp && COLOR_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "color",
expected: "size",
});
}
// A color property that holds a SIZE token (z-radius/space/text...).
if (isColorProp && SIZE_TOKEN.test(token)) {
report(decl, "categoryMismatch", {
token,
prop,
category: "size",
expected: "color",
});
}
}
});
},
};
},
};
@@ -0,0 +1,171 @@
// Rule: NO HARDCODED COLORS / SIZES / DURATIONS / Z-INDEX IN COMPONENTS.
// Everything visual in a Svelte <style> block must come from design tokens
// (var(--...)); direct literals are banned. color-mix() is banned too — its
// result must be tokenized in the design CSS file.
import {
COLOR_LITERAL,
COLOR_PROPS,
DURATION_PROPS,
FORBIDDEN_DURATION_TOKEN,
FORBIDDEN_SIZE_TOKEN,
MIXED_PROPS,
SIZE_PROPS,
} from "./lists.js";
import { getStyleNodeLoc, getStyleRoot } from "./style-context.js";
// Sub-rule: stripe var(...) bodies out of a value, so tokens inside are never
// mistaken for literals.
const stripVars = (value) => value.replace(/var\([^)]*\)/g, "");
export default {
meta: {
type: "suggestion",
docs: {
description:
"Ban hardcoded colors, sizes, durations and z-index in Svelte <style> blocks; use CSS variables (design tokens).",
category: "Design tokens",
recommended: true,
},
messages: {
hardcodedColor:
"Hardcoded color '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedSize:
"Hardcoded size '{{value}}' in '{{prop}}'. Use a var(--...) design token.",
hardcodedDuration:
"Hardcoded duration '{{value}}' in '{{prop}}'. Use a var(--duration-...) token.",
hardcodedZIndex:
"Hardcoded z-index '{{value}}'. Use a var(--z-...) token.",
hardcodedBreakpoint:
"Hardcoded breakpoint '{{value}}' in @media. Use the @custom-media name instead: declare `@custom-media --bp-* (...)` in app.css and write `@media (--bp-*)`.",
varInMedia:
"var() inside @media '{{value}}'. Custom properties do not resolve in media queries — declare `@custom-media --bp-* (...)` in app.css and use `@media (--bp-*)`.",
colorMix:
"color-mix() in component ({{value}}). Tokenize the result in the design CSS file.",
},
schema: [],
},
create(context) {
const root = getStyleRoot(context);
if (!root) return {};
const styleNodeLoc = getStyleNodeLoc(context);
return {
"Program:exit"(programNode) {
const report = (node, ruleId, data) => {
if (!styleNodeLoc) return;
const loc = styleNodeLoc(node);
context.report({
node: programNode,
loc,
ruleId,
messageId: ruleId,
data,
});
};
// =====================================================================
// 3) NO HARDCODED BREAKPOINTS IN @media
// @media conditions must use --bp-* tokens, not raw lengths.
// Regex: (max|min)-width followed by a NON-var(() number.
// =====================================================================
const BREAKPOINT_RE =
/(?:(?:max|min)-width)\s*:\s*(?!var\()(\d+(?:\.\d+)?(?:px|rem|em))/gi;
// Custom properties do not resolve inside @media conditions, so any
// var(--bp-*) inside @media is a latent bug — the block never applies.
const VAR_IN_MEDIA_RE = /var\(\s*(--[a-zA-Z0-9-]+)/g;
// Sub-rule 6: color-mix() is banned anywhere in a component style.
const COLOR_MIX_RE = /color-mix\s*\(/gi;
// --- @media breakpoints --- //
root.walkAtRules("media", (atRule) => {
const params = atRule.params ?? "";
let m;
BREAKPOINT_RE.lastIndex = 0;
while ((m = BREAKPOINT_RE.exec(params)) !== null) {
report(atRule, "hardcodedBreakpoint", { value: m[1] });
}
VAR_IN_MEDIA_RE.lastIndex = 0;
while ((m = VAR_IN_MEDIA_RE.exec(params)) !== null) {
report(atRule, "varInMedia", { value: m[1] });
}
});
// --- declarations --- //
root.walkDecls((decl) => {
const prop = decl.prop ?? "";
const value = decl.value ?? "";
// Sub-rule 6: color-mix in any property.
if (COLOR_MIX_RE.test(value)) {
report(decl, "colorMix", { value: value.trim() });
}
// Sub-rule 4: durations in transition/animation props.
if (DURATION_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const m = withoutVars.match(FORBIDDEN_DURATION_TOKEN);
if (m) {
report(decl, "hardcodedDuration", { prop, value: m[0] });
}
}
// Sub-rule 7: z-index must come from a var(--z-...).
if (prop === "z-index") {
const withoutVars = stripVars(value).trim();
if (/^-?\d+$/.test(withoutVars)) {
report(decl, "hardcodedZIndex", { value: withoutVars });
}
}
// Only inspect known layout properties from here on.
if (!COLOR_PROPS.test(prop) && !SIZE_PROPS.test(prop)) return;
// Sub-rule 2: sizes.
// Fire only on a real remaining px/rem/em token — percentages
// (width: 80%) and unitless values (line-height: 1.5) stay legal.
// The only legal absolute length is a 0px reset line.
// MIXED_PROPS (border/outline shorthands) are checked for their
// SIZE half too — a border width must come from var(--size-*).
if (SIZE_PROPS.test(prop) || MIXED_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const reported = new Set();
for (const match of withoutVars.matchAll(FORBIDDEN_SIZE_TOKEN)) {
const shown = match[0];
if (shown !== "0px" && !reported.has(shown)) {
reported.add(shown);
report(decl, "hardcodedSize", { prop, value: shown });
}
}
}
// Sub-rule 1: colors.
// Remaining value must be only allowed keywords
// (currentColor/transparent/inherit/none/0); otherwise a color
// literal is reported.
if (COLOR_PROPS.test(prop)) {
const withoutVars = stripVars(value);
const withoutKeywords = withoutVars
.replace(
/\b(?:currentcolor|transparent|inherit|none|initial|unset|revert)\b/gi,
"",
)
.trim();
if (withoutKeywords !== "" && withoutKeywords !== "0") {
const reported = new Set();
for (const match of value.matchAll(COLOR_LITERAL)) {
const shown = match[0].trim();
if (!reported.has(shown)) {
reported.add(shown);
report(decl, "hardcodedColor", { prop, value: shown });
}
}
}
}
});
},
};
},
};

Some files were not shown because too many files have changed in this diff Show More