diff --git a/.gitignore b/.gitignore index 1b3de54..3bc2aaa 100644 --- a/.gitignore +++ b/.gitignore @@ -31,3 +31,7 @@ config.yaml .deploy.lock content/* !content/main + +.impeccable/critique/ +.agents/ +.codex/ \ No newline at end of file diff --git a/.impeccable/critique/2026-09-05T09-03-13Z__site-homepage-docs-resources.md b/.impeccable/critique/2026-09-05T09-03-13Z__site-homepage-docs-resources.md deleted file mode 100644 index 11904fa..0000000 --- a/.impeccable/critique/2026-09-05T09-03-13Z__site-homepage-docs-resources.md +++ /dev/null @@ -1,72 +0,0 @@ ---- -target: site (homepage + docs + resources) -total_score: 27 -max_score: 40 -na_heuristics: -p0_count: 1 -p1_count: 2 -target_identity: "file:D:\\GITHUB\\es-doc\\site (homepage + docs + resources)" -timestamp: 2026-09-05T09-03-13Z -slug: site-homepage-docs-resources -closed: true ---- -⚠️ DEGRADED: single-context (Assessment B subagent failed: rate limit hit mid-run — HTTP 429, resets 3:30am Europe/Moscow). Assessment A ran as a fully isolated subagent; Assessment B's remaining scope (detector run, false-positive triage, browser measurements) was completed personally and cross-checked against A's claims. - -# Design Health Score — 27/40 - -| # | Heuristic | Score | Key Issue | -|---|---|---|---| -| 1 | Visibility of System Status | 3 | Orphan article (outside tree.yaml) gets no sidebar highlight, no prev/next | -| 2 | Match System / Real World | 4 | Terminology solid; tab row mixes source (original/community) with a tool (browser) | -| 3 | User Control and Freedom | 3 | Theme toggle is 2-state (verified aria-label), no path back to system preference | -| 4 | Consistency and Standards | 2 | home.html hardcodes a link to an article absent from tree.yaml (verified in both files) | -| 5 | Error Prevention | 3 | _tidy() prevents orphan dividers but nothing prevents home/tree drift | -| 6 | Recognition Rather Than Recall | 2 | 13/20 code panels overflow on /docs/narration, worst 2.43x (measured) | -| 7 | Flexibility and Efficiency | 2 | build_items() indexes only titles + h2/h3 (verified in app/utils/docs.py); imagebutton unsearchable | -| 8 | Aesthetic and Minimalist Design | 3 | Palette/type strong; /authors duplicates home.html's contributor block verbatim (verified) | -| 9 | Error Recovery | 3 | Empty-search recovery link points to /docs/, which hides most of the corpus | -| 10 | Help and Documentation | 2 | No callout legend, no inline-code-is-clickable hint beyond mouse-only title attr | -| **Total** | | **27/40** | | - -# Design Specificity Verdict - -Grounded in the product: day/night character colour chips, the warper playground built on 146 real game backgrounds, home hero crossfading real camp locations. The one thin spot is /docs/ itself — generic hero + bare link list. - -Deterministic scan (templates, run personally after Assessment B's failure): 21 findings — 19 design-system-color (all false positives: detector cannot resolve `{{ href }}`, defaults unresolved cascade to rgb(0,0,0)), 2 broken-image (both false positives: empty `` shells in lightbox/warper-preview templates, filled by JS at runtime — confirmed against source). - -# Overall Impression - -Strong, specific visual world undermined by one structural problem: the docs hub (tree.yaml) shows 6 of 23 real articles, home.html links directly into the hidden set, and search only indexes titles/headings so the terms modders actually search for return nothing. Fix the corpus/search gap and this becomes a genuinely strong Read-mode site. - -# What's Working - -1. One `--focus-ring` token consumed by ~23 rules sitewide, including a rebind for code.css's own palette — a system, not a convention. -2. Resource surfaces built for copying, not reading: every atom (hex, code, curve formula) has a correct accessible name and live-region confirmation. -3. Reduced motion treated as first-class: opt-in (`no-preference`) rather than opt-out across 8 CSS files; the warper curve stays meaningful with motion off (static canvas, only the marker moves). - -# Priority Issues - -- **[P0] /docs/ hides 17 of 23 articles; home.html links directly into the hidden set.** Verified: tree.yaml declares 6 nodes with its own comment admitting the omission; home.html:61 links to an article not in the tree. Fix: generate home's doc links from build_tree() output; give tree.yaml a status group for the remaining 17 instead of omitting them. → `$impeccable harden` -- **[P1] Search indexes only titles and h2/h3, missing the terms users actually search.** Verified in app/utils/docs.py build_items(); imagebutton exists only as a lexer keyword, unsearchable despite appearing as a chip 4x on /docs/screens. Fix: add a code-token/identifier row class to the search corpus. → direct implementation -- **[P1] Code panels overflow horizontally on the flagship article.** Measured: 13/20 panels on /docs/narration overflow, worst 2.43x. Fix: soft-wrap as default with hanging indent, toggle for true pre. → `$impeccable adapt` -- **[P2] 55 inline code chips are tab stops whose aria-label overwrites their own text.** Measured: 55/72 focusable elements in
on /docs/screens; aria-label replaces (not supplements) the token text. Fix: drop tabindex/aria-label from chips, keep the existing block-level copy button. → direct implementation -- **[P2] Heading level skip (H1→H3, no H2) and mobile TOC overflow.** Measured: heading sequence H1>H3>H3>H3>H3>H3 on /docs/screens; anchor `#` glyph has no aria-hidden; mobile TOC scrollWidth 897 vs clientWidth 343. Fix: normalize heading levels, hide anchor glyph from AT, collapse mobile TOC into
. → `$impeccable adapt` - -# Persona Red Flags - -**Impatient power user (search-driven):** imagebutton search returns nothing despite appearing as a copyable chip on the very page she needs it from; falls back to Ctrl+F; the code example she wants is clipped 2.43x. - -**Confused first-timer (mobile):** Reaches an article with no highlighted position in the sidebar and no next-step; nothing in the section list says "start here". - -**Keyboard/screen-reader user:** Skip link and focus ring work well (verified, better than average) — then hits an H1→H3 heading skip, an unhidden anchor glyph read aloud as "#Предисловие", and 55 uninterrupted tab stops before reaching the next heading. - -# Minor Observations - -- /authors duplicates home.html's bottom 60% verbatim (authors_core + GitHub contributors + thanks_section) — confirmed in both template files. -- Theme toggle is 2-state with no path back to system preference (verified via aria-label). -- Self-caught false alarm: an initial contrast reading of 1.96:1 on `.nav-links a` in light theme was a measurement artifact (missing forced reflow after setting data-theme); re-measured correctly at 5.65:1. No real contrast failures found on measured elements in either theme. - -# Questions to Consider - -- If /docs/ were deleted tomorrow, what would actually break? Search and the article sidebar already cover more than the index does. -- tree.yaml currently encodes editorial status (rewritten vs. not) as visibility. What changes if status becomes a visible facet instead of an omission? diff --git a/.impeccable/design.json b/.impeccable/design.json index c470802..8042374 100644 --- a/.impeccable/design.json +++ b/.impeccable/design.json @@ -1,6 +1,6 @@ { "schemaVersion": 2, - "generatedAt": "2026-10-01T22:31:39Z", + "generatedAt": "2026-10-06T21:19:06Z", "title": "Design System: ES Doc", "extensions": { "colorMeta": { @@ -19,7 +19,7 @@ }, "leaf-deep": { "role": "primary", - "displayName": "Leaf Deep (hover · pine · tip)", + "displayName": "Leaf Deep (hover / pine / tip)", "canonical": "#206220", "dark": "#AED5FF (hover, follows Lake Blue) / #8FB07A (pine, tip, success — stays green)" }, @@ -166,7 +166,15 @@ { "name": "tablet", "value": "768px" }, { "name": "mobile", "value": "640px" }, { "name": "narrow", "value": "400px" } - ] + ], + "surfaceGuidance": { + "tools-colors": { + "layout": "72rem workbench: code formats left, visual editor right, contrast below both. DOM and focus order match visual order. At 640px and below, formats/editor/contrast stack in the same order, quick section buttons scroll and focus without navigation or state reset, and four supporting tabs wrap. Keep this measure scoped to the color tool.", + "behavior": "One sRGB selection synchronizes field/wheel, RGB/HSV/HSL/alpha, six always-visible editable format rows, harmonies, OKLab shades and composited contrast. Preserve active typing and valid color on errors. Arrow keys step the focusable area and numeric channels; picker-wide undo restores the preview pair and role. The role selector edits the existing text/background color, swap exchanges them, and Lucide reset restores defaults. Native EyeDropper is feature-detected. Clipboard failure selects the affected row or a manual palette-copy field. Local image metadata shows dimensions without filenames; loading/errors remain explicit. Each invalid field owns its visible error and accessible description. Saved collections join undo/redo; clearing also exposes immediate recovery preserving later color edits. Recent colors remain separate. The named-color palette preserves all 1012 supplied name/HEX pairs, searches names and full/short HEX case-insensitively with е/ё equivalence, shows names beside HEX and renders another 100 matches only on request. Delegate palette clicks and avoid repainting unchanged samples. Keyboard help sits beside the editor. Alpha controls use Russian transparency labels.", + "dataColors": "Spectrum colors, checkerboards, arbitrary samples and black/white cursor inks are functional data visualization. They do not add UI accents or alter the leaf day / lake night palette.", + "typography": "HEX uses 18px monospace; other editable formats use the UI size, and swatch labels use 12px. The 24px preview title demonstrates large text. OKLCH wraps in an editable textarea without reducing precision. Mobile inputs use the body size. These roles are scoped to the tool." + } + } }, "components": [ { @@ -248,6 +256,13 @@ "description": "The ladybug: the one red on a day page outside the logo. Same shape as info; its triangle is what tells it apart from danger's octagon.", "html": "
Внимание. Это предупреждение.
", "css": ".ds-warn { display: flex; align-items: flex-start; gap: 12px; padding: 12px 16px; color: var(--warning); border-top: 1px solid var(--warning); border-bottom: 1px solid var(--warning); background: linear-gradient(0deg, var(--warning-bg) 5%, var(--warning-bg1) 25%, var(--warning-bg1) 75%, var(--warning-bg) 95%); } .ds-warn-icon { width: 24px; height: 24px; flex-shrink: 0; margin-top: 0.1rem; } .ds-warn-body { min-width: 0; color: var(--text); line-height: var(--lh-prose); } .ds-warn-body strong { color: var(--warning); }" + }, + { + "name": "Color Picker Code Output", + "kind": "custom", + "description": "Always-visible editable formats with per-row Lucide copying; the live page synchronizes all fields and validates errors.", + "html": "

Для кода

", + "css": ".ds-picker-output { max-width: 480px; color: var(--text); background: var(--bg); font-family: var(--font-sans); } .ds-picker-output h3 { font-size: var(--fs-body); margin: 0 0 14px; } .ds-picker-row { display: grid; grid-template-columns: 96px minmax(0,1fr) 44px; align-items: center; gap: 8px; margin: 12px 0; } .ds-picker-row label { color: var(--text-soft); font-size: 12px; } .ds-picker-row :is(input, textarea) { width: 100%; box-sizing: border-box; min-height: 44px; padding: 8px 12px; color: var(--text); background: var(--input-bg); border: 1px solid var(--border); border-radius: var(--radius-sm); font: var(--fs-ui) var(--font-mono); caret-color: var(--accent); } .ds-picker-row button { min-height: 44px; border: 0; color: var(--text-soft); background: transparent; cursor: pointer; } .ds-picker-row :focus-visible { outline: var(--focus-ring); outline-offset: 3px; } .ds-picker-row textarea { resize: vertical; white-space: pre-wrap; overflow-wrap: anywhere; }" } ], "narrative": { @@ -269,7 +284,7 @@ { "name": "The Two-Palette Code Rule", "body": "Code panels follow the theme like every other surface. That means two syntax palettes, not one: the ink weights on `#F4F9F1` by day, the lifted film palette on `#12161E` at night, each verified ≥4.5:1 against *its own* panel (and, by day, ≥4.7 on a line lit by `--target-bg`). Adding a `--code-*` token to one theme without the other is the same bug as any other half-defined token.", "section": "colors" }, { "name": "The Opaque-Band Rule", "body": "By day a callout band is a plain opaque tint, each hue mixed toward white until its ink holds ≥5.3:1 on the strongest band and a body link holds AA through the middle one — never a saturated hue laid on at 8–16%. That was learned on the old sand page, where `#F4EAD6`, a yellow at chroma 11, dragged every thin film toward itself (sky-blue info composited to a grey-green at hue 130°) and the bands had to be pre-mixed and laid on at 92% to keep their hue. White drags nothing, so the recipe simplified with the sand gone; the rule against films did not. Night keeps the thin-film treatment: a dark page is cool and neutral enough that a translucent hue stays the hue it started as.", "section": "colors" }, { "name": "The One White Rule", "body": "White is the page and the things on it; it is the brand's own colour, not a missing tint. Every other neutral is pulled toward the leaf by day — ink, ink soft, hairline, shade, paper, chip and table head all sit between hue 127° and 150° — or toward lake-blue by night. Never `#000`, and never an untinted gray. Prefer the token (`var(--text)`, `var(--border)`) over any literal hex that has one.", "section": "colors" }, - { "name": "The Cyrillic-Leading Rule", "body": "Headings led at 1.2 until it was measured: PT Serif 700 at 38.4px has a 51px font box, and a 1.2 line box is 46px, so two-line headings overlapped their own em boxes. Russian exposes what Latin hides — Ё and Й carry marks above cap height while у, р, ц, щ and д descend, which left 2.5px between one line’s descenders and the next line’s diaereses. Leading on this site is budgeted for Cyrillic, not for a ratio borrowed from Latin setting.", "section": "typography" }, + { "name": "The Cyrillic-Leading Rule", "body": "Headings led at 1.2 until it was measured: PT Serif 700 at 38.4px has a 51px font box, and a 1.2 line box is 46px, so two-line headings overlapped their own em boxes. Russian exposes what Latin hides — Ё and Й carry marks above cap height while у, р, ц, щ and д descend, which left 2.5px between one line's descenders and the next line's diaereses. Leading on this site is budgeted for Cyrillic, not for a ratio borrowed from Latin setting.", "section": "typography" }, { "name": "The Hyphenation Rule", "body": "Prose sets `hyphens: auto`, and `lang=\"ru\"` on the document is what makes it work. At 375px a Russian paragraph runs about 30 characters a line, far under the 45 the eye wants, and the body size cannot come down to meet it. Hyphenation is the only lever left.", "section": "typography" }, { "name": "The Serif-Carries-Warmth Rule", "body": "Headings are ink-colored, not accent-colored. PT Serif carries the warmth so the leaf stays a spotlight (see the One-Leaf Rule) — and on this site green means \"you can press this\", so a green heading reads as a link. A page full of green headings is drift.", "section": "typography" }, { "name": "The Three-Weight Rule", "body": "Only 400, 600, and 700 exist. Weight 500 was removed from the scale; do not reintroduce it.", "section": "typography" }, @@ -290,6 +305,7 @@ "**Do** pair every callout/icon with `class=\"icon\"` and `currentColor` so it inherits the semantic color and the shared 24px size.", "**Do** keep the site's own links in the **same tab** — the nav, section rows, footer icons, and outbound rows like `/news/sources` and «Прочие ссылки»: no `target=\"_blank\"`, so readers keep their back button and choose a new tab themselves (WCAG G200; NN/g; GOV.UK). The one exception is a link *inside an article's prose* that leaves the site: the markdown renderer opens it in a new tab with `rel=\"noopener noreferrer\"`, because articles are reference material read while doing something else, and following the Ren'Py manual mid-example shouldn't cost the reader their place in it. Authors write both kinds the same way; the renderer decides. The other exception is hand-written, at the owner's request: the resource lightbox's «Открыть файл» opens the original beside the list, so the lightbox and the reader's place in a long listing survive the look.", "**Do** use тире (`—`, U+2014) as the Russian title separator (`'Авторы — ES Doc'`) and nested-list marker. This deliberately overrides the English \"no em dashes\" default: the audience reads Russian, where тире is correct.", + "**Do** separate inline metadata with a slash (`/`) surrounded by spaces, as in `Telegram / 8 июля 2026`.", "**Do** respect `prefers-reduced-motion`: disable both transitions and keyframe animations, merged into one block per stylesheet.", "**Do** underline links inside running text at rest — a faint `--link-underline` (half the link's own colour), full on hover and focus. Colour alone can't mark them: accent on ink is 2.78:1 by day and 1.49:1 at night, under WCAG 1.4.1's 3:1. Navigation, section rows and buttons read as links by place and shape and stay bare; footnote markers and code-labelled links carry their own cue.", "**Do** keep every state visible in Windows' high-contrast mode (`forced-colors: active`), which drops shadows and repaints backgrounds: focus goes through `outline` (a transparent one where the design draws its ring with a shadow), the current page gets a `text-decoration` underline, the active search option the system `Highlight`.", @@ -303,6 +319,7 @@ "**Don't** add drop shadows to cards, rows, or callouts. Only the search popover, the image lightbox, the player bar and the back-to-top button leave the page plane.", "**Don't** color headings green; ink + PT Serif carries them, and on this site a green heading reads as a link.", "**Don't** reintroduce font-weight 500, or a display font in UI labels/data.", + "**Don't** use the middle dot (U+00B7) anywhere in authored interface or design-system copy, including labels, tooltips, status messages, accessible names and component examples; use a slash (`/`) for separators. `tests/test_ui_copy.py` safeguards templates, JavaScript, CSS, `DESIGN.md` and `.impeccable/design.json`.", "**Don't** replace тире (`—`) in Russian copy with a colon, pipe, or middle dot.", "**Don't** hand-write `target=\"_blank\"` anywhere else — the renderer owns the prose case, and the lightbox's «Открыть файл» is the one written by hand — and don't add external-link/new-tab icons (NN/g and GOV.UK found they aren't reliably understood; GOV.UK removed theirs).", "**Don't** add a pause or stop control to the home hero's crossfade. It was removed deliberately at the owner's request and is to stay out; `prefers-reduced-motion` is its off switch (see the hero under Components)." diff --git a/.impeccable/review/dark-desktop.jpg b/.impeccable/review/dark-desktop.jpg deleted file mode 100644 index dc31ab1..0000000 Binary files a/.impeccable/review/dark-desktop.jpg and /dev/null differ diff --git a/.impeccable/review/dark-mobile.jpg b/.impeccable/review/dark-mobile.jpg deleted file mode 100644 index a729ee9..0000000 Binary files a/.impeccable/review/dark-mobile.jpg and /dev/null differ diff --git a/.impeccable/review/desktop-viewport.jpg b/.impeccable/review/desktop-viewport.jpg deleted file mode 100644 index b289614..0000000 Binary files a/.impeccable/review/desktop-viewport.jpg and /dev/null differ diff --git a/.impeccable/review/desktop.jpg b/.impeccable/review/desktop.jpg deleted file mode 100644 index ea9858c..0000000 Binary files a/.impeccable/review/desktop.jpg and /dev/null differ diff --git a/.impeccable/review/empty-mobile.jpg b/.impeccable/review/empty-mobile.jpg deleted file mode 100644 index 27167ae..0000000 Binary files a/.impeccable/review/empty-mobile.jpg and /dev/null differ diff --git a/.impeccable/review/mobile.jpg b/.impeccable/review/mobile.jpg deleted file mode 100644 index 4327245..0000000 Binary files a/.impeccable/review/mobile.jpg and /dev/null differ diff --git a/.impeccable/review/no-art-dark-desktop.jpg b/.impeccable/review/no-art-dark-desktop.jpg deleted file mode 100644 index b91f74f..0000000 Binary files a/.impeccable/review/no-art-dark-desktop.jpg and /dev/null differ diff --git a/.impeccable/review/no-art-dark-mobile.jpg b/.impeccable/review/no-art-dark-mobile.jpg deleted file mode 100644 index 93613f7..0000000 Binary files a/.impeccable/review/no-art-dark-mobile.jpg and /dev/null differ diff --git a/.impeccable/review/no-art-light-desktop.jpg b/.impeccable/review/no-art-light-desktop.jpg deleted file mode 100644 index 8ead188..0000000 Binary files a/.impeccable/review/no-art-light-desktop.jpg and /dev/null differ diff --git a/.impeccable/review/no-art-light-mobile.jpg b/.impeccable/review/no-art-light-mobile.jpg deleted file mode 100644 index d75f9d5..0000000 Binary files a/.impeccable/review/no-art-light-mobile.jpg and /dev/null differ diff --git a/.impeccable/review/user-530.jpg b/.impeccable/review/user-530.jpg deleted file mode 100644 index deaa3ff..0000000 Binary files a/.impeccable/review/user-530.jpg and /dev/null differ diff --git a/.impeccable/surfaces/templates-specialists-html.md b/.impeccable/surfaces/templates-specialists-html.md index 29e5712..a3298bb 100644 --- a/.impeccable/surfaces/templates-specialists-html.md +++ b/.impeccable/surfaces/templates-specialists-html.md @@ -6,51 +6,18 @@ related_targets: ["static/css/specialists.css","static/js/specialists.js"] --- ## Mode +Operate / extend the incumbent collaborator directory while preserving records and ES Doc's visual system. -Operate. Scoped extension of the incumbent artist directory. Preserve the existing visual system and artist records; prepare the four additional categories empty. +## Direction and navigation +Find a collaborator by discipline, availability and work. Retain the five wrapping category links with counts, gallery/board/table views, search and status controls. Category changes preserve view, search, status and sort. Empty categories explain the relevant work and provide the existing contact link. Default sorting places open commissions first, then names; explicit alphabetical sorting survives URL synchronization. -## Direction contract +## Profiles and portfolios +Category lists in specialists.yaml hold independent profiles, including separate portfolios for the same person in different disciplines. Preserve contacts, commission statuses and examples. Preview is explicit and never derives from or populates works. Relative work links use the confined /specialists/res route; images and tracks use the shared lightbox and player. -THESIS: Find a collaborator by discipline, availability and actual work. +## Gallery and identity +Gallery cards retain equal heights within each row, a 3:2 preview frame and corner status. Missing or failed artwork shows a decorative PT Serif initial. Failed previews remove their lightbox link and loading skeleton. Missing descriptions remain absent; previews remain independent from work examples. -OWN-WORLD: Inherit ES Doc's white/leaf day and blue lake night, PT Serif headings, Inter body, flat bordered surfaces. +Each identity row has a round decorative initial: 30px in gallery/board and 26px in the table. An optional logo covers it; a missing or failed logo exposes it. Transparent logos retain the existing background so the initial cannot show through. The fallback is hidden from assistive technology because the adjacent name supplies the identity. -STORY: Choose one of five disciplines, inspect work, check commission status, follow a contact. - -FIRST VIEWPORT: Existing page hero, wrapping category navigation with counts, familiar view/search/status controls, artwork-led cards. Empty categories explain what examples belong there and offer the existing contact link. - -FORM: Extend the existing directory directly; no concept seed for this scoped extension. Keep gallery, board and table. - -FINISH: unreviewed and undocumented is unfinished; this build ends with the finish review, the verdict, DESIGN.md, and every shipping raster carrying its provenance. - -## Finish result - -Disposition: **ship**. The finish reviewer opened all seven valid captures in `.impeccable/review/`: `desktop.jpg`, `mobile.jpg`, `dark-desktop.jpg`, `dark-mobile.jpg`, `empty-mobile.jpg`, `user-530.jpg` and `desktop-viewport.jpg`. No material rendering or behavior defect remained in the scoped extension. Fresh verification passed 35 Python tests, nine relevant JavaScript tests and Ruff. - -The built directory retains all 22 artists, contacts and commission statuses. Category links preserve view/search/status/sort, including empty categories; artwork and tracks use the existing lightbox and player. DESIGN.md and its existing sidecar now record Specialists navigation, category affordances and the implemented portfolio schema. No new shipping raster assets were created. - -Not canonized: the inherited artist status-badge clamp reaching 0.625rem, reported as one detector advisory. It remains an incumbent exception rather than a new typography rule; this scoped extension does not change the global type system. - -## No-art gallery follow-up - -Disposition: **ship** after scoped review of `no-art-dark-desktop.jpg`, `no-art-dark-mobile.jpg`, `no-art-light-desktop.jpg` and `no-art-light-mobile.jpg` in `.impeccable/review/`. Every gallery card keeps its 3:2 preview frame and corner status; missing or failed artwork shows a decorative PT Serif first initial, and the Inter name row reserves the optional avatar space to maintain mobile alignment. Fresh verification passed 13 targeted Python tests and one JavaScript state test. No material fixes remained, no design tokens changed and no new shipping raster assets were created. - -## Independent profile follow-up - -The user's 2026-10-02 request supersedes the no-art framing above. Missing previews now render no frame or initial, and a failed preview removes its entire surface. Status stays in the body; optional descriptions occupy space only when supplied. Gallery items use their natural height without a reserved avatar row or a portfolio pushed down to fill the card. - -Separate category lists in `specialists.yaml` now hold independent profiles. Migration preserved all contacts, statuses and work examples: 24 people become 25 category profiles, including separate coding and music portfolios for the same author. Preview is explicit and never derives from or populates works. Relative work links use the confined `/specialists/res/*` route; `teromioset/preview.jpg` loaded successfully in the shared lightbox. Default sorting puts open commissions first, then names within each status; explicit alphabetical sorting survives URL synchronization. - -Disposition: **ship** after direct browser inspection at 1280px and 390px. Verified gallery/table navigation preserves categories, missing `puhhhel` preview removes its frame, and Progress plays through the shared player. All 46 Python tests, five specialist JavaScript tests and Ruff passed. Independent code review found no actionable defect. The full JavaScript suite also exposes an unrelated pre-existing `warpers_reload` test-stub failure; the changed UI has only the inherited 0.625rem status-font detector advisory. No new shipping raster assets were created. - -## Restored initial fallback - -The user's subsequent clarification explicitly restores the large preview-area placeholder. Gallery cards now retain their 3:2 frame and corner status, show a PT Serif initial when preview is absent or fails, and stretch to equal heights within each row. Missing descriptions remain absent, and previews remain independent from work examples. Failed previews remove their lightbox link and loading skeleton. - -The original `puhhhel` files were present under the misspelled assets directory `puhhhtel`; that directory is now `puhhhel`, matching the existing YAML. Direct HTTP verification confirmed the logo, cached preview and raw work image all return 200, and every configured local profile image exists. Browser measurements at 1280px and 390px confirmed equal card and preview heights for the two coder profiles. The inherited status-font advisory is unchanged. - -## Round avatar fallback - -Every identity row now has a decorative initial in the same round avatar frame: 30px in gallery/board, 26px in the table. An optional logo covers the initial; a missing or failed logo leaves it visible, with failed-image loading skeletons cleared. Transparent logos retain the existing background colour so the initial cannot show through them. The fallback uses the existing serif face, size and colour tokens and is hidden from assistive technology because the adjacent name provides the identity. - -Verified at 1280px and 390px: the real logo and `poi` initial both occupy 30px square frames, and the established equal gallery-card height is preserved. All 47 Python tests and six specialist JavaScript tests passed; Ruff and whitespace checks passed. The detector only reports the inherited status-font advisory. +## Quality bar +Inherit leaf day / lake night, PT Serif headings, Inter body and flat bordered surfaces. Preserve directory state, portfolio playback, initial fallbacks and equal gallery heights on desktop and mobile. The inherited status-badge clamp reaching 0.625rem remains an existing exception, not a new typography rule. No shipping raster assets or additional design tokens are required. diff --git a/.impeccable/surfaces/templates-tools-colors-html.md b/.impeccable/surfaces/templates-tools-colors-html.md new file mode 100644 index 0000000..da8d151 --- /dev/null +++ b/.impeccable/surfaces/templates-tools-colors-html.md @@ -0,0 +1,28 @@ +--- +version: 1 +slug: "templates-tools-colors-html" +primary_target: "templates/tools_colors.html" +related_targets: ["static/css/colors.css","static/js/colors.js","static/js/colors-core.js","static/js/colors-named.js"] +--- + +## Mode +Operate / practical color workbench for Russian-speaking Ren'Py modders. + +## Direction +Preserve ES Doc's leaf day / lake night, Inter, PT Serif and flat Lucide controls. The workflow is pick or paste / refine / copy / compare a foreground-background pair / explore palettes or a local image. Spectrum gradients and checkerboards represent color data. + +## Layout +Six editable formats occupy the left desktop column, the visual editor the right, and contrast follows both. DOM and visual order agree. Mobile keeps formats first and offers section buttons that focus and scroll without navigating or resetting selection. Tabs wrap; keyboard help stays beside the editor. Mobile inputs use the body size and controls retain 44px targets. + +## Behavior +One sRGB selection synchronizes HEX, Python RGB/RGBA, CSS RGB/HSL/OKLCH and precise channels. HEX shortens only losslessly and includes alpha automatically; Python RGB excludes alpha and edits preserve existing alpha. OKLCH wraps without hiding precision. Invalid fields retain the last valid color and show individual errors with accessible descriptions. Clipboard failures expose a selectable value. + +The edit-role selector chooses an existing preview color; swap exchanges the pair and reset restores the original green/white pair. Contrast composites transparency over white, checks unrounded ratios and rounds its display down. A 64-step undo history includes color, preview pair/role and saved collections. Recent colors remain separate, limited to twelve. Clearing saved colors has both undo and an explicit recovery action that retains later color edits. Duplicate saves create no history step; unavailable storage reports session-only persistence. + +Field, wheel and image sampling work by keyboard. Local images retain sampled alpha, have bounded canvas dimensions and explicit loading failures. Soft navigation releases listeners, decoded bitmaps, timers and canvas memory, and rejects stale asynchronous work. The native screen pipette is feature-detected. + +## Named palette +«Именованные цвета» preserves all 1012 name/HEX pairs and their source order from charColorCodes.rpy. Search accepts names, full or shortened HEX, ignores case and treats е/ё alike. Initially render 100 colors; an explicit action reveals the next 100. Every sample shows its name and HEX. Delegate sample clicks and avoid repainting unchanged samples during color editing. + +## Quality bar +Keep all six formats visible and editable, independent field errors, recovery, copy feedback and keyboard focus. Verify desktop and mobile in both themes without document overflow. Never use U+00B7 in authored UI or design-system copy; use slash separators. DESIGN.md and this contract hold current rules rather than historical review logs. diff --git a/DESIGN.md b/DESIGN.md index 609255d..6fb31b8 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -262,7 +262,7 @@ doing four jobs, and 1.5, 1.55 and 1.6 sat inside one pixel of each other. Serif 700 at 38.4px has a 51px font box, and a 1.2 line box is 46px, so two-line headings overlapped their own em boxes. Russian exposes what Latin hides — Ё and Й carry marks above cap height while у, р, ц, щ and д descend, which left 2.5px -between one line’s descenders and the next line’s diaereses. Leading on this +between one line's descenders and the next line's diaereses. Leading on this site is budgeted for Cyrillic, not for a ratio borrowed from Latin setting. **The Hyphenation Rule.** Prose sets `hyphens: auto`, and `lang="ru"` on the @@ -313,6 +313,8 @@ The homepage presents its main destinations as individual paper notices on a sha Standard header spacing is 32px above / 28px below, 40/32 for the homepage, and 24px for nested resource headers. The directory body starts 32px below its preceding region. Keep the existing 4px spacing scale; use smaller gaps within groups and larger gaps between tasks. +The color workbench uses a 72rem maximum and two desktop columns, with the editor beside the code result. At 640px and below, code and copy come first, the editor follows, then contrast; supporting tabs wrap rather than hiding destinations behind horizontal scrolling. This is a scoped tool layout, not a replacement for article or directory measures. + ### Responsive and accessibility contract - Breakpoints use em and reflect content fit: 1280px (TOC), 1140px (header links), 992px (article tree), 800px (gutters), 768px (directory groups), 640px (search sheet). @@ -352,10 +354,18 @@ Mostly square, softened only where something is held or looked at. Structure is **The Picture-Gets-The-Curve Rule.** 12px belongs to things that are pictures of something — code, screenshots, artwork. A frame around text stops at 6px, and a list gets no frame at all. +## Icons — Lucide + +**Use ONLY Lucide icons when the catalog has a suitable icon.** This includes navigation, players, tools, files, Markdown controls, callouts, disclosures and confirmations. If Lucide has no suitable icon, another source is allowed; this includes authentic brand logos. Keep exceptions narrow and documented; do not replace available Lucide icons with custom geometry or Unicode glyphs. The site identity mark and content images remain separate from UI icons. + +Keep Lucide's original `0 0 24 24` geometry, `fill="none"`, `stroke="currentColor"`, stroke width 2 and round caps/joins. Default UI size is 18px, compact controls 14–16px, file types 20px, callouts/trash 24px and upload 32px. Decorative icons are hidden from assistive technology; controls supply accessible labels. Preserve existing state selectors and protect CSS masks in forced colors. + +SVGs live directly in `static/icons/`, with their license and source revision in `manifest.json`. Reuse `lucide_icon()` in Jinja/Markdown and `ESDocIcons.svg()` in JavaScript; run `python scripts/sync_lucide_icons.py` after updating the catalog. Large lists use masks from these same SVGs. Lucide excludes brand logos: footer links retain the original GitHub, VK and Telegram SVG marks as explicit exceptions, with platform labels. See the [Lucide conventions](https://lucide.dev/guide/design/icon-design-guide) and [brand-logo policy](https://lucide.dev/brand-logo-statement). + ## Components ### Navigation -Fixed 52px header carrying the five sections — Документация, Ресурсы, Полезные материалы, Специалисты, Новостные ресурсы — declared once in `header.html` and rendered in two places. It sits on `--bg-chrome`: Leaf Shade by day, so the white page under it is the brighter plane, and the page's own night-blue at night. Links use `align-items: stretch` so each is a full-height tap target. Ink-soft at rest, warming to ink on hover. The active page carries `aria-current="page"`, drawn as an inset 2px leaf-green underline via `box-shadow` (no layout shift), and every page of a section sets it, sub-pages included. Авторы and «Поддержать проект» are about the project rather than sections of it, and live in the footer. +Fixed 52px header carrying the six sections — Документация, Ресурсы, Инструменты, Материалы, Специалисты, Площадки — declared once in `header.html` and rendered in two places. It sits on `--bg-chrome`: Leaf Shade by day, so the white page under it is the brighter plane, and the page's own night-blue at night. Links use `align-items: stretch` so each is a full-height tap target. Ink-soft at rest, warming to ink on hover. The active page carries `aria-current="page"`, drawn as an inset 2px leaf-green underline via `box-shadow` (no layout shift), and every page of a section sets it, sub-pages included. Авторы and «Поддержать проект» are about the project rather than sections of it, and live in the footer. **News resources.** `/news-resources` is a directory of places to publish or announce mods, separate from a news feed. Each flat row names the platform, gives one concise note on how to submit or publish, and links directly to the resource or contact. It follows the directory's hairline-separated rows and keeps the light/dark theme, keyboard focus, and narrow-screen actions legible. @@ -374,7 +384,7 @@ Gallery, status board and table remain three views of the same profiles. Gallery ### Site Footer One 20px row, deliberately: this is a docs site, and the footer is wayfinding, not a second homepage. Two text links (Авторы, «Нашли ошибку?»), then the platform icons, then the support control. The row wraps, since it no longer fits below ~580px, but nothing else about it grows. -**Platform icons.** GitHub, VK and Telegram as 18px `currentColor` marks with no labels, ink-soft at rest and accent on hover like every other footer link. They sit in a `.footer-socials` span with no gap of its own, so each icon's 6px of padding leaves 12px between glyphs against the row's 20px: the set reads as one answer to "where else are you" rather than three more list items. VK's viewBox is tightened to `1 1 22 22` because it's a squircle where the other two are circles, and on a plain box its glyph renders at 15px against their 18, reading a size smaller; 16.4px is where a square carries an 18px circle's optical weight. +**Platform icons.** GitHub, VK and Telegram retain their original 18px brand marks because Lucide has no brand logos. Keep their original geometry (including VK's optically adjusted viewBox), accessible labels and platform tooltips. They use ink-soft at rest and accent on hover. The `.footer-socials` group has no gap of its own; each link's 6px padding keeps the logos together within the footer's 20px row gap. **Support control.** A bordered pill on `--bg-surface` with a `currentColor` heart, ink text at rest, accent text and border on hover, surface unchanged. Not a green fill and not green at rest, because the footer sits under *every* page, and a leaf-green button here would spend the One-Leaf Rule's one spotlight on all of them at once. Hover leaves the background alone, the way `.to-top` does: a control that only recolours reads as the same control. (Holding the surface began as an AA fix for the old pioneer red, 4.12:1 on `--bg-elevated` at night; the accent — leaf by day, lake blue by night — holds 6+ there either way, so it is consistency now, not contrast.) It carries no caption — the label is the whole message. It points at `/support` rather than straight at a payment page: the platforms are config-driven and may be several, and an outbound jump from a footer link is a jarring way to leave a docs site. @@ -387,7 +397,7 @@ The header's centerpiece: a bordered input group that rings itself in accent on The native form submits to `/docs/?q=…`, which renders results on the server with the same ranked corpus as autocomplete. It preserves the query, links to matching headings/resources, escapes all labels and offers «Все статьи» plus a useful empty-state message. On phones, only successfully initialized JavaScript collapses the search form behind its button; without it the form stays visible. ### Heading Anchors -Every heading below h1 carries a hover-revealed `#` anchor and a slug built from the **path of headings containing it**: an h2 is `{h2}`, an h3 under it `{h2}/{h3}`, an h4 under that `{h2}/{h3}/{h4}`. Two sections called «Плюсы» under different parents are therefore two different anchors, with no disambiguation needed; genuine collisions under one parent get `-2`, `-3` in document order. +Every heading below h1 carries a hover-revealed Lucide `hash` anchor and a slug built from the **path of headings containing it**: an h2 is `{h2}`, an h3 under it `{h2}/{h3}`, an h4 under that `{h2}/{h3}/{h4}`. Two sections called «Плюсы» under different parents are therefore two different anchors, with no disambiguation needed; genuine collisions under one parent get `-2`, `-3` in document order. This replaced a flat `{текст}-{номера строк}` scheme whose uniqueness came from the heading's line numbers, which meant every anchor on a page moved the moment anyone inserted a paragraph above it. A path survives edits elsewhere in the document, and reads as a location rather than an accident. Slashes are legal in both `id` attributes and URL fragments; the TOC scroll-spy matches ids as strings, never as CSS selectors, so nothing needs escaping. @@ -399,7 +409,7 @@ The primary grouping affordance in section directories is a flat list of links. Neither is a link, and neither is furniture the reader has to step over: the rule is `role="presentation"` and `aria-hidden`, and a heading's items are named to a screen reader through the `aria-label` of the list it heads. The group itself is a real list item — its label plus its own list — not a presentational one: an outer `role="list"` that owns something other than list items has its count wrong in a screen reader (axe's `aria-required-children`), which is what the grouped tree shipped with until the September 2026 audit. A heading's children keep the indent of the rows around them rather than nesting — the indent guide means "these belong to the article above", and above a group there is no article. A rule with nothing left to separate (leading, trailing, or following another) is dropped rather than drawn, so a group whose articles are all missing or renamed doesn't leave a hairline hanging against the top of the list. ### Callouts (Info / Warning / Danger / Attention / Tip) -Full-width note blocks with a top+bottom hairline in the semantic color and a faint vertical gradient band behind. **Info** is deep sky-blue and calm; **Warning** is the ladybug red, the one red on a day page outside the logo; **Danger** is the rung above warning, for a hard constraint rather than a caution; **Attention** is ochre-yellow, the middle setting; **Tip** is the leaf's shade (`--pine`), a friendly aside in the brand's own voice. All five lead with a `currentColor` icon (`class="icon"`) so the types read at a glance and match in size. No side-stripe; the gradient band and paired borders carry the emphasis. The `*-content` body stays ink (`--text`) so the note reads as ordinary prose; only the border, icon, and `` take the semantic colour. (A callout whose whole paragraph is coloured is the wrapper missing its `color: var(--text)` reset.) +Full-width note blocks with a top+bottom hairline in the semantic color and a faint vertical gradient band behind. **Info** is deep sky-blue and calm; **Warning** is the ladybug red, the one red on a day page outside the logo; **Danger** is the rung above warning, for a hard constraint rather than a caution; **Attention** is ochre-yellow, the middle setting; **Tip** is the leaf's shade (`--pine`), a friendly aside in the brand's own voice. All five lead with a shared Lucide `currentColor` icon (`class` includes `icon`) so the types read at a glance and match in size. No side-stripe; the gradient band and paired borders carry the emphasis. The `*-content` body stays ink (`--text`) so the note reads as ordinary prose; only the border, icon, and `` take the semantic colour. (A callout whose whole paragraph is coloured is the wrapper missing its `color: var(--text)` reset.) The icon is **24px, sized to one line of body text** (1.7 line-height on 1rem), nudged down by half the difference so it parks on that first line's optical centre. It used to be a fixed 32px, which set the height of the box: a one-sentence callout — which is most of them here — came out 64px tall, two thirds of it empty. With the icon out of the way the sentence sets the height and the same callout is 53px. Vertical padding is 12px for the same reason. Alignment stays `flex-start`, so on a long callout the icon still sits with the first line rather than drifting to the middle of the paragraph. @@ -414,7 +424,7 @@ The washes themselves follow the Opaque-Band Rule (Colors → Named Rules): plai ### Collapsible Section (`:::details`) `:::details Заголовок` … `:::` renders a native `
`/``, closed by default. Native, not scripted: it needs no JavaScript, works on a no-JS page, and the browser's own find-in-page opens it when the match is inside — which a hand-rolled toggle would swallow. For material that would bury the page if it were always open: a long reference implementation, an aside two readers in ten will follow. -It is deliberately **not** a callout. A callout says "this passage matters"; a disclosure says "there is more here if you want it", so it carries no semantic colour and takes blockquote's treatment instead — a 1px `--border` frame on Leaf Paper (`--bg-light`), `--radius-code`. The summary is PT Serif bold in plain ink (it is the heading of the thing it hides) and warms to accent on hover. The UA's disclosure triangle is suppressed in favour of a CSS-drawn chevron that turns from pointing right to pointing down, drawn as a pseudo-element so it never lands in the summary's text content. The body's top rule only ever appears while the box is open, so it doubles as the seam between the summary and what it revealed. +It is deliberately **not** a callout. A callout says "this passage matters"; a disclosure says "there is more here if you want it", so it carries no semantic colour and takes blockquote's treatment instead — a 1px `--border` frame on Leaf Paper (`--bg-light`), `--radius-code`. The summary is PT Serif bold in plain ink (it is the heading of the thing it hides) and warms to accent on hover. The UA's disclosure triangle is suppressed in favour of Lucide `chevron-right`, masked in a CSS pseudo-element and rotated down when open. The body's top rule only ever appears while the box is open, so it doubles as the seam between the summary and what it revealed. The opener's text is parsed as **inline markdown**, not escaped flat, because titles in these docs routinely name a function in `code`. With no title the summary falls back to «Подробнее» — a `` must never be empty, or it collapses to a bare marker with no hit area. A missing `:::` closer fails the block outright, as with the callouts; here the stakes are higher, since text swallowed into a disclosure would be *hidden* by default and the mistake that much easier to miss. @@ -486,6 +496,12 @@ Not extracted, deliberately: the uppercase Consolas section label appears in six ### Home Hero Slideshow The homepage hero carries the camp itself: four game backgrounds crossfading on a slow 32s cycle (~2.5s fades), day shots in light theme and their night variants in dark. The photos are set dressing, never content — a wash of `--bg` keeps the title column on near-solid page colour, and the image dissolves into the page below rather than ending on a hard edge. By day that wash is white, so the camp reads as noon glare over the beach; at night it is the lake's blue. Reduced motion collapses it to one quiet still. **There is no pause control, on purpose, and it is to stay that way.** A pause button over the photo was built and then removed at the project owner's request: the crossfade is set dressing behind a paper wash, slow (8s a slide, ~2.5s fades), carries no information, and `prefers-reduced-motion` already turns it into a still for anyone who asked for less motion. An audit that flags WCAG 2.2.2 here is reporting this decision, not a defect to fix. Only the first photo loads with the page — it is the largest paint — and the other three wait until the page has loaded (under reduced motion, forever); until they arrive, and without JS, the hero is that one still. Images are served as 1600px WebP downscales (`/resource/hero/`), composed lazily like thumbs; screens up to 480px get a 0.95:1 center crop of the same downscale (`?crop=narrow`) — every pixel a portrait hero box ever showed, at about half the bytes. +### Animation Gallery + +The original resources' animation gallery preserves every declared duration, pause and cycle at original speed, including `black_long`'s 2-second dissolve and 50-second fade. Its sepia still uses the game's palette map, hue rotation and sepia matrix rather than a CSS approximation. Small lazy WebP posters keep browsing light; original frames decode only for requested playback or timeline inspection. Snow uses the original particle speeds and depth scaling, with gradual arrival rather than an instantly filled scene. + +A single name/description search includes variant identifiers. Cards show the selected exact name, “Скопировать” controls for that name and its contextual usage example, original-source links and an accessible timeline. Play/stop buttons use icons with contextual accessible labels; duration/loop captions, playback status text and the separate manual-motion mode are omitted. Playback is explicitly requested and only one session owns loading and painting; stopping preserves the current image and replay starts from the beginning at original speed. Timeline values are available to assistive technology without announcing every playback tick. Selectors, playback and disclosure targets are at least 44px high. The existing leaf/lake palette, type scale and gallery composition are preserved. + ### Character Swatch On the Персонажи resources page, a 36px bordered square carrying the character's in-game name color — the game's *day* value in light theme, the *night* value in dark (same two-way theme plumbing as the sprite group colors). Characters with no name color (narrator, `th`) get an empty dashed frame, never a fake gray fill. The "цвет имени: день / ночь" line under the name repeats both values as text, each led by a small color chip, so both can be compared without toggling the site theme. Name and code stay plain ink: this is a reference page for 40+ characters, and their raw in-game colors are largely undimmed neon (`#FFFF00`, `#4EFF00`, `#FF3200`, …) tuned for a dark dialogue box — recoloring the running text in all of them read as a rainbow list, at odds with the one-accent-color discipline everywhere else on the site. The swatch and chips carry the color; the text doesn't. @@ -504,6 +520,10 @@ Picking «своя формула» in the sandbox opens a formula field beside ### Font Specimen A font file in the resource browser previews itself: the viewer declares `@font-face { font-family: 'fb-specimen' }` over the raw file and sets four lines of Cyrillic, Latin and figures in it. That face is deliberately outside the type system — it is the artefact being inspected, not a voice the site speaks in, and it is the one place a font-family here names something DESIGN.md does not. +The local tools browser uses `tools-specimen` for the same purpose, registering the selected font file with `FontFace` and releasing it when the preview changes. Both specimen faces are inspected user assets, not additional interface typography. + +Tools errors use one short Russian live announcement and static diagnostics with a collapsed technical original. Failed or cancelled previews replace the pending content and release busy state; interrupted requests cannot repaint a later preview. Sources and completed ZIP links remain available. Mode captions name both input and output formats, and «Подробнее» explains lazy file preview, bulk export, and script recovery inside RPA. File and item counters use Russian plural forms; archive breadcrumbs have 44px targets. The right-click picker remains available without a redundant instruction on the initial screen. Warning disclosures expand below their title without relocating the title or count button. Code previews reuse code.css and the documentation's copy button inside the code panel, without a separate toolbar. The pre owns vertical scrolling for both code and line numbers; the code column only scrolls sideways. Preview cancellation sits directly below its loading message. Ready previews, including highlighted markup and warnings, are memoized in tab memory for up to 32 entries and 64 MiB, with least recently used eviction and option-specific keys. Source-list changes clear this cache. The added-files heading groups its count and size with an accessible trash icon for clearing the current mode. + ### Community Links («Прочие ссылки») Under the category list on «Ресурсы сообщества», a second list for everything the community shares that this site can't scan into a listing of its own: an archive, a pack, a tool hosted elsewhere. Same `.sections-list` rows as the categories above it — an outbound link is a row like any other here, and same tab, per the external-links rule below — so the only thing the block adds is a serif title marking where the site's own holdings end and the rest of the internet begins. @@ -514,6 +534,32 @@ It sits *outside* the hub's «здесь пока пусто» check: a collecti In the «Варперы» section of «Ресурсов» (the engine's own set under the original collection, community-contributed ones under the other), a small plot of one Ren'Py easing curve: a 1px `--border` frame over Leaf Paper, a solid hairline at the start value, a dashed one at the target (the only way to see that back/elastic/bounce overshoot), and the curve itself in `--ink-soft`. The box is square so time and value get equal length: the time scale runs along the bottom (quarter ticks, `0`/`1` labelled in the mono face), and the value track runs down the right edge, sharing the plot's vertical mapping so its ticks land exactly on the two guide levels. Both the plot and the row's formula chip are copy controls (promoted from plain picture and plain `` by warpers.js, so a no-JS page grows no dead buttons): the plot hands over that exact variant's formula, the chip the one it displays. What they copy is always the machine-readable form — `t ** 2`, not the typeset `t²` — because the point of taking it is pasting it into the generator, into warpers.yaml or into a mod. Confirmation is the same green the copy buttons use elsewhere, one step more specific than the hover rule so it still reads under the cursor. The whole preview stays ink-quiet at rest, with hollow markers parked at the start of both scales; the leaf appears only on the curve currently hovered or focused, as the traced portion plus the three markers (curve, value, time — one moment shown three ways), so a page of 33 previews still spends its green once (the One-Leaf Rule). Colours are read from the CSS custom properties at runtime and re-read on theme change, since canvas can't inherit them. The reference is a matrix, ten families × three prefixes, so the previews repeat deliberately: they carry no card chrome, only the plot and the name, and the row/column labels do the explaining. Reduced motion parks the marker at the target instead of animating it there. +### Tools + +Tools warnings use the site's shared attention circle with an exclamation mark, numeric count and disclosure chevron, beside the export summary or preview filename. Yellow uses `--attention` and its theme-aware tint; errors stay red (`--danger`) with the shared danger octagon in status messages and failed source rows, so shape and color distinguish them. Native details show a Russian title and explanation for each warning, then an independently collapsed original technical message. Templates cover older Ren'Py versions, unusual RPYC structure, custom displayables, preserved existing sources and expanded recovery; unknown warnings receive a cautious generic explanation without inventing their cause. Successful recovery is distinguished from a failed operation. The diagnostic list is bounded and keyboard-focusable. The count has a descriptive accessible name and show/hide tooltip; a clean result hides it, and each new result starts collapsed. + +Adding files or a folder displays every supported source together without decompiling or extracting them. Loose scripts are listed immediately without loading Python. RPA files load their validated indexes only. Opening a file extracts or decompiles that entry on demand; the current preview is cached, and changing decompilation options updates only the open file. The primary download below the browser processes all sources and automatically downloads the resulting ZIPs. Completed ZIP links remain available for retrying a blocked download. The retry button is for reading the catalog after cancellation or a loading error, never a required processing step. + +`/tools` is a lightweight hub with flat linked rows for `/tools/unpack` and `/tools/colors`, followed by community tools. Individual tool pages link back to the hub and keep the Tools navigation active. Page-specific code loads only where needed. + +`/tools/unpack` is the local Ren'Py workbench in the existing page frame: PT Serif headings, Inter controls, and a 66rem column. One queue accepts .rpa, .rpyc and .rpymc; there are no processing modes to select. Archive extraction also decompiles contained scripts. The source selector groups standalone «Сценарии» and individual archives without changing processing scope. «Скачать всё в ZIP» always exports the whole queue. Clearing or completing a download clears every source; cancellation keeps sources. The queue renders at most 200 rows and the browser at most 500 entries as DOM optimizations; all sources are still processed. The dashed drop area accepts files and recursive folders, turns solid with `--accent-muted` during drag, and offers file/folder selection through its button, context menu and keyboard. Controls keep 44px targets and the shared focus ring. Settings and «Подробнее» remain flat native disclosures; the filename search, breadcrumbs, file-type icons and current-file state support scanning. The list and preview sit side by side on desktop and stack on mobile. Download controls stay below the browser, with per-file downloads below each preview. + +`/tools/colors` is a practical paste / refine / copy workbench in the existing theme. A saturation/value field or hue/saturation wheel, hue/brightness/alpha sliders and precise RGB/HSV/HSL channels control one sRGB color. The source field accepts HEX, CSS RGB/HSL/OKLCH and Python RGB/RGBA tuples. Synchronized inputs preserve active typing; invalid input retains the last valid color with an inline explanation. Field and wheel support pointer, touch and keyboard, with exact channels as an equivalent route. The native screen pipette appears only when EyeDropper exists in a secure context; cancellation and unsupported browsers leave field and image selection available. + +Six always-visible editable monospace rows each have a copy action: HEX, Python RGB without alpha, Python RGBA with normalized alpha, CSS RGB, CSS HSL and CSS OKLCH. HEX includes alpha automatically when the selected color is transparent. Its alpha is rounded to a byte, so decimal alpha may change when pasted back; repeated hexadecimal pairs shorten only losslessly (`#aabbcc` → `#abc`, `#aabbccdd` → `#abcd`). Other formats retain decimal alpha. Copy uses shared copy/check feedback and live status; a clipboard failure selects the affected editable row (or a dedicated manual-copy field for a palette) and explains manual copying. The focusable field/wheel, channel arrow stepping and Ctrl+Z / Ctrl+Shift+Z work throughout the picker, including active text inputs. Completed changes to the selected color, preview partner and edit role enter a 64-step undo history and a 12-color recent list, without recording every pointer move. Explicitly saved colors and recent colors stay in browser-local storage; unavailable storage reports that they remain available for the session. + +The code formats occupy the left desktop column, the visual editor the right, and contrast follows both in a separate row. DOM, visual and keyboard order agree at every breakpoint; mobile keeps formats first and offers quick section buttons that scroll and focus the target without adding a hash navigation or resetting the selection. OKLCH uses a wrapping editable textarea so decimal alpha remains visible without reducing precision. Mobile text inputs use the body size to avoid focus zoom. Invalid fields show individual explanations directly beside their inputs with `aria-describedby`; a visually hidden live region announces errors without moving a distant banner. Keyboard help sits beside the editor and preserves its task-focused paragraphs. Alpha controls are labelled «Прозрачность». + +Saving and clearing colors also enter undo history; duplicate saves do not create a history step. Clearing offers a visible «Восстановить» action and Ctrl+Z. Explicit recovery restores the collection while retaining a color edited since clearing. Recent colors remain separate from the undo snapshot. Recovery writes the collection back to browser storage and reports storage failures. + +Four supporting tabs contain five color harmonies and preset palettes, eleven shades mixed in OKLab, saved/recent colors, and local image sampling. Color buttons carry accessible labels and a selected outline. The role selector chooses which existing preview color to edit without changing the rendered pair; swap exchanges the two colors, and the Lucide reset restores the original pair. Both are undoable. The foreground/background preview composites alpha over white before checking WCAG thresholds (4.5:1 ordinary AA, 3:1 large-text AA, 7:1 ordinary AAA); pass/fail uses the unrounded ratio and its display rounds down to avoid a false pass. The preview title is deliberately 24px large text, the HEX row 18px and other format fields the UI size, and compact swatch labels 12px. These are scoped tool roles, not new global typography steps. Spectrum gradients, checkerboards and contrasting cursor inks express color data rather than add an accent palette. Controls retain 44px targets and visible focus in both themes. + +The «Именованные цвета» palette preserves all 1012 name/HEX pairs from `charColorCodes.rpy` in source order. Search matches Russian names and full or shortened HEX, ignores case and treats е/ё alike. Samples display both name and HEX. Render the first 100 matches, then reveal another 100 on request; delegate sample clicks and leave unchanged samples untouched while editing a color. + +Image selection accepts PNG, JPEG, WebP, GIF and AVIF locally, up to 40 MiB. Sampling uses a processed canvas representation bounded by 4096px per dimension and approximately 12 megapixels, so a reduced image's sampled pixel can differ from the original. It retains pixel alpha and offers up to eight dominant opaque colors. Keyboard movement and Enter select a pixel; loading failures have explicit recovery. Soft navigation aborts page listeners and pending pipette work, rejects stale image loads, closes decoded bitmaps, clears copy-feedback timers and releases the canvas. No raster assets or new design tokens are required. + +Processing runs in a lazily loaded local module Worker using Pyodide and full upstream unrpyc/unrpa. Files stay on the device. There are no application quotas on queue size, script size, archive-index size, entry count or ZIP output; device memory determines capacity. Integrity validation remains: restricted pickle globals, safe paths, valid segment ranges and complete compressed streams. Shared archive segments are validated once and reused as immutable tuples. Each RPA produces its own ZIP named from the archive basename (`data.rpa` → `data.zip`), preserving exact internal paths. Extraction retains compiled originals, adds recovered code and preserves existing source files; ZIPs contain no generated report. Standalone scripts share `unrpyc.zip`. Previewing a compiled archive entry also preserves an existing source, matching export behavior. Local Pygments uses the same Ren'Py lexer and `syntax.css` as the resource browser, with whitespace dots, line numbers excluded from selection, and the shared clipboard control. Image, audio (including Opus and FLAC), video and font previews stay local too. Text above 2 MiB remains downloadable without rendering hundreds of thousands of lines; this is only a preview limit, not a processing or export quota. Warning counts expand to show actual diagnostics. Source links and format help remain visible without JavaScript. Soft navigation terminates the worker and releases listeners, font registrations and download URLs. No new design tokens or raster assets are introduced. + ## Do's and Don'ts ### Do: @@ -524,14 +570,14 @@ In the «Варперы» section of «Ресурсов» (the engine's own set - **Do** keep red for the ladybug — the logo star and the warning → danger ladder — in both themes. - **Do** give code panels a palette per theme, and verify each one against its own panel. - **Do** use PT Serif for headings/quotes, Inter for body/UI, Consolas for meta labels and code, at weights 400/600/700 only. -- **Do** pair every callout/icon with `class="icon"` and `currentColor` so it inherits the semantic color and the shared 24px size. +- **Do** give every callout icon the shared `icon` class and `currentColor` so it inherits the semantic color and the shared 24px size. - **Do** keep the site's own links in the **same tab** — the nav, section rows, footer icons, and outbound rows like «Прочие ссылки»: no `target="_blank"`, so readers keep their back button and choose a new tab themselves (WCAG G200; NN/g; GOV.UK). The one exception is a link *inside an article's prose* that leaves the site: the markdown renderer opens it in a new tab with `rel="noopener noreferrer"`, because articles are reference material read while doing something else, and following the Ren'Py manual mid-example shouldn't cost the reader their place in it. Authors write both kinds the same way; the renderer decides. The other exception is hand-written, at the owner's request: the resource lightbox's «Открыть файл» opens the original beside the list, so the lightbox and the reader's place in a long listing survive the look. - **Do** use тире (`—`, U+2014) as the Russian title separator (`'Авторы — ES Doc'`) and nested-list marker. This deliberately overrides the English "no em dashes" default: the audience reads Russian, where тире is correct. - **Do** separate inline metadata with a slash (`/`) surrounded by spaces, as in `Telegram / 8 июля 2026`. - **Do** respect `prefers-reduced-motion`: disable both transitions and keyframe animations, merged into one block per stylesheet. - **Do** underline links inside running text at rest — a faint `--link-underline` (half the link's own colour), full on hover and focus. Colour alone can't mark them: accent on ink is 2.78:1 by day and 1.49:1 at night, under WCAG 1.4.1's 3:1. Navigation, section rows and buttons read as links by place and shape and stay bare; footnote markers and code-labelled links carry their own cue. - **Do** keep every state visible in Windows' high-contrast mode (`forced-colors: active`), which drops shadows and repaints backgrounds: focus goes through `outline` (a transparent one where the design draws its ring with a shadow), the current page gets a `text-decoration` underline, the active search option the system `Highlight`. -- **Do** draw icons that repeat down a long list with CSS (`.res-icon`: a data-URI mask filled with `currentColor`), not inline SVG — on the 1 162-row sprite listing, three SVGs a row were half the HTML and some 9 300 DOM nodes. +- **Do** use shared local Lucide SVGs as CSS masks for icons repeated down a long list (`.res-icon`, filled with `currentColor`) — on the 1 162-row sprite listing, three inline SVGs a row were half the HTML and some 9 300 DOM nodes. ### Don't: - **Don't** ship the sterile autogenerated **ReadTheDocs / Sphinx** look, the corporate gray of a **Confluence wiki**, or try-hard **neon/cyberpunk gaming** aesthetics (PRODUCT.md anti-references). @@ -541,7 +587,8 @@ In the «Варперы» section of «Ресурсов» (the engine's own set - **Don't** add drop shadows to cards, rows, or callouts. Only the search popover, the image lightbox, the player bar and the back-to-top button leave the page plane. - **Don't** color headings green; ink + PT Serif carries them, and on this site a green heading reads as a link. - **Don't** reintroduce font-weight 500, or a display font in UI labels/data. -- **Don't** use the middle dot (`·`, U+00B7) as a separator in interface copy; use a slash (`/`). +- **Don't** use the middle dot (U+00B7) anywhere in authored interface or design-system copy, including labels, tooltips, status messages, accessible names and component examples; use a slash (`/`) for separators. `tests/test_ui_copy.py` safeguards templates, JavaScript, CSS, `DESIGN.md` and `.impeccable/design.json`. +- **Don't** use "’" symbol anywhere in anywhere in authored interface or design-system copy, including labels, tooltips, status messages, accessible names and component examples, text too. Use a "'" instead or similar. - **Don't** replace тире (`—`) in Russian copy with a colon, pipe, or middle dot. - **Don't** hand-write `target="_blank"` anywhere else — the renderer owns the prose case, and the lightbox's «Открыть файл» is the one written by hand — and don't add external-link/new-tab icons (NN/g and GOV.UK found they aren't reliably understood; GOV.UK removed theirs). - **Don't** add a pause or stop control to the home hero's crossfade. It was removed deliberately at the owner's request and is to stay out; `prefers-reduced-motion` is its off switch (see the hero under Components). diff --git a/app/routes/docs.py b/app/routes/docs.py index 29fb092..0d2fb7b 100644 --- a/app/routes/docs.py +++ b/app/routes/docs.py @@ -1,15 +1,17 @@ -from fastapi import APIRouter, HTTPException, Request -from fastapi.responses import FileResponse, PlainTextResponse import re from urllib.parse import quote -from . import main_router +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import FileResponse, PlainTextResponse + from ..utils.config import CONFIG from ..utils.docs import flatten_tree, search -from ..utils.modified import format_ru -from ..utils.file import templates, read_text -from ..utils.md import render +from ..utils.file import read_text, templates from ..utils.http import cache_headers, is_precompressed +from ..utils.materials import material_file +from ..utils.md import render +from ..utils.modified import format_ru +from . import main_router router = APIRouter(prefix='/docs') @@ -53,6 +55,18 @@ async def image(file, request: Request): return FileResponse(str(path), headers=cache_headers(precompressed=is_precompressed(path))) +@router.get('/download/{file:path}') +async def download_attachment(file: str): + # Articles publish their attachments directly, independently of the + # curated materials catalog. Keep this namespace inside materials/articles. + root = (CONFIG.docs_path.parent / 'materials' / 'articles').resolve() + path = material_file('articles/' + file) + if not path or not path.is_relative_to(root): + raise HTTPException(404, 'Вложение не найдено.') + return FileResponse(path, filename=path.name, content_disposition_type='attachment', + headers={'X-Content-Type-Options': 'nosniff'}) + + @router.get('/{doc}') async def page(doc, request: Request): diff --git a/app/routes/res.py b/app/routes/res.py index a959696..9c4b86e 100644 --- a/app/routes/res.py +++ b/app/routes/res.py @@ -5,6 +5,7 @@ from ..utils.config import CONFIG from ..utils.http import cache_headers, is_precompressed +from ..utils.lifespan.animation_images import make_image from ..utils.lifespan.artist_img_cache import ( cache_file as artist_img_file, ) @@ -175,6 +176,24 @@ def _confined_file(base, resource): return path + +@router.get('/poster/{resource:path}') +async def animation_poster(resource: str): + source = _confined_file(CONFIG.res_path, resource) + if source.suffix.lower() not in ('.png', '.jpg', '.jpeg', '.webp'): + raise HTTPException(404, 'Превью изображения не найдено.') + async with compose_lock: + target = await asyncio.to_thread(make_image, source, poster=True) + return FileResponse(str(target), media_type='image/webp', headers=cache_headers(precompressed=True)) + + +@router.get('/animation/black-long') +async def black_long_image(poster: bool = False): + source = _confined_file(CONFIG.res_path, 'images/bg/ext_camp_entrance_day.jpg') + async with compose_lock: + target = await asyncio.to_thread(make_image, source, poster=poster, sepia=True) + return FileResponse(str(target), media_type='image/webp', headers=cache_headers(precompressed=True)) + @router.get('/raw/{resource:path}') async def raw_page(resource, request: Request): diff --git a/app/routes/resources.py b/app/routes/resources.py index 24520ee..1a475c3 100644 --- a/app/routes/resources.py +++ b/app/routes/resources.py @@ -3,15 +3,16 @@ from pathlib import Path from urllib.parse import quote -from fastapi import APIRouter, Request, HTTPException +from fastapi import APIRouter, HTTPException, Request +from fastapi.responses import RedirectResponse -from . import main_router +from ..utils.animations import available_previews from ..utils.config import CONFIG from ..utils.file import templates from ..utils.lifespan.resources_cache import BG_TIME_LABELS, CATEGORY_TITLES -from ..utils.md import highlight_code, CODE_COPY_BUTTON +from ..utils.md import highlight_code from ..utils.md.lines import numbered_view -from ..utils import warpers as warpers_util +from . import main_router router = APIRouter(prefix='/resources') @@ -54,15 +55,6 @@ 'usage': 'show anim blink_down', 'media': 'image', }, - # The one category that isn't scanned out of game/: warpers belong to the - # engine, so the original collection's list is static (utils/warpers.py) - # and the community's is data-driven (CONFIG.warpers). See STATIC below. - 'warpers': { - 'title': 'Варперы', - 'desc': 'Кривые сглаживания для анимаций ATL', - 'usage': 'easeout_cubic 1.5 xalign 1.0', - 'media': 'warpers', - }, 'sfx': { 'desc': 'Одиночные звуки для канала sound', 'usage': 'play sound sfx_dinner_horn_processed', @@ -85,31 +77,18 @@ meta['title'] = CATEGORY_TITLES[slug] -# Categories whose items don't come from the assets scan, per collection. -# The original's warpers are the engine's own set; the community's are -# whatever warpers.yaml holds — an empty section until it does. -def _static(collection): - if collection == 'original': - return {'warpers': warpers_util.NAMES} - return {'warpers': CONFIG.warpers} - - def _count(category, items): # Hub and switcher counts cover declared resources only; undeclared files # sit behind their toggle and are not part of the headline numbers. if category == 'sprites' and items and 'sprites' in items[0]: return sum(len(group['sprites']) for group in items) - if category == 'warpers': - return len(items) return sum(1 for i in items if i['declared']) def _collection_or_404(collection): if collection not in COLLECTIONS or collection not in CONFIG.resources: raise HTTPException(404, f'Коллекция "{collection}" не существует.') - # Static categories sit alongside the scanned ones, so everything past - # this point can treat one collection as a single {category: items} map. - return {**CONFIG.resources[collection], **_static(collection)} + return CONFIG.resources[collection] def _hub(request, collection): @@ -140,6 +119,11 @@ async def community(request: Request): return _hub(request, 'community') +@router.get('/animations', include_in_schema=False) +async def animations_redirect(): + return RedirectResponse('/resources/original/anim#animation-previews', status_code=301) + + # ── «Браузер ресурсов»: a read-only explorer over the game folder ────── # What a file is, by extension — drives the row icon, the type label and @@ -245,6 +229,7 @@ async def browser(request: Request, path=''): 'raw': f'/resource/raw/{quote(rel)}', 'sub': ' / '.join(sub), 'view': view, + 'show_animation_link': rel in ('media.rpy', 'images/anim/media.rpy'), }) dirs, files = [], [] @@ -286,11 +271,17 @@ async def browser(request: Request, path=''): 'total': len(dirs) + len(files), 'stats': stats, 'folder': crumbs[-1][0], + 'show_animation_link': rel == 'images/anim', }) @router.get('/{collection}/{category}') async def listing(collection, category, request: Request): + # This generic route is registered before the dedicated tool routes. + if category == 'warpers' and collection in COLLECTIONS: + from .warpers import warpers_redirect + return await warpers_redirect() + data = _collection_or_404(collection) if category not in CATEGORIES or category not in data: @@ -298,47 +289,12 @@ async def listing(collection, category, request: Request): items = data[category] - # The switcher is shared by every category page, warpers included. + # The switcher is shared by every resource category page. switcher = [ {'slug': slug, 'count': _count(slug, data[slug]), **CATEGORIES[slug]} for slug in CATEGORIES if slug in data ] - if category == 'warpers': - # The sandbox at the top of the page animates a real background, so it - # borrows the original collection's bg list — declared, non-NSFW and - # actually present on disk. Both collections' warpers get to drive it. - # `preview` is what the sandbox actually loads: the hero downscale - # (1600px WebP, built for the home slideshow) rather than the raw game - # JPG, which runs 700 KB for a frame shown at a third of that. Tinted - # backgrounds have no hero — /resource/hero only downscales plain - # declared files — so those keep the raw path. - backgrounds = [ - {'name': i['name'], 'code': i['code'], 'raw': i['raw'], - 'preview': i['raw'] if i.get('tint') else f"/resource/hero/{quote(i['name'])}"} - for i in CONFIG.resources.get('original', {}).get('bg', []) - if i['declared'] and i['raw'] and not i['nsfw'] - ] - - return templates.TemplateResponse(request, 'resources_warpers.html', { - 'collection': collection, - 'collection_meta': COLLECTIONS[collection], - 'category': category, - 'category_meta': CATEGORIES[category], - 'categories': switcher, - 'count': len(items), - # The engine's own set is a fixed matrix of families; the - # community's is a flat list of contributed curves. - 'builtin': collection == 'original', - 'columns': warpers_util.COLUMNS, - 'special': warpers_util.SPECIAL, - 'families': warpers_util.families(), - 'samples': warpers_util.samples(), - 'community': items if collection != 'original' else [], - 'backgrounds': backgrounds, - 'code_copy_button': CODE_COPY_BUTTON, - }) - # The other collection's tab keeps the category when it exists there, # falling back to that collection's hub. other = 'community' if collection == 'original' else 'original' @@ -367,6 +323,8 @@ async def listing(collection, category, request: Request): 'other_href': other_href, 'bg_locations': bg_locations, 'bg_times': bg_times, + 'animations': available_previews(CONFIG.res_path) + if collection == 'original' and category == 'anim' else None, }) diff --git a/app/routes/search_discovery.py b/app/routes/search_discovery.py index 643bf40..b32a9f1 100644 --- a/app/routes/search_discovery.py +++ b/app/routes/search_discovery.py @@ -15,7 +15,7 @@ STATIC_PAGES = ( '/', '/docs/', '/authors', '/specialists', '/materials', '/news-resources', - '/support', '/resources/', '/resources/community', + '/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers', ) @@ -33,7 +33,7 @@ async def sitemap_xml(): for collection, data in CONFIG.resources.items() if collection in ('original', 'community') for category in CATEGORIES - if category in data or category == 'warpers' + if category in data ) root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset') diff --git a/app/routes/static.py b/app/routes/static.py index 58b93de..438af54 100644 --- a/app/routes/static.py +++ b/app/routes/static.py @@ -4,6 +4,7 @@ from . import main_router from ..utils.assets import load as load_asset from ..utils.file import ROOT +from ..utils.icons import ICON_NAMES router = main_router @@ -49,6 +50,13 @@ async def css(name, request: Request): async def js(name, request: Request): return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8') + +@router.get('/static/icons/{name}.svg') +async def icon(name: str, request: Request): + if name not in ICON_NAMES: + raise HTTPException(404, 'Иконка не существует.') + return _text_asset(request, f'static/icons/{name}.svg', 'image/svg+xml') + @router.get('/static/fonts/{name}') async def font(name): # Binary asset: FileResponse, not read_text (which decodes as UTF-8). @@ -62,4 +70,4 @@ async def font(name): # GZipMiddleware so we don't waste CPU re-compressing for ~0 gain. 'Content-Encoding': 'identity', }, - ) \ No newline at end of file + ) diff --git a/app/routes/tools.py b/app/routes/tools.py new file mode 100644 index 0000000..f6a9e5f --- /dev/null +++ b/app/routes/tools.py @@ -0,0 +1,79 @@ +import hashlib +import json + +from fastapi import HTTPException, Request +from fastapi.responses import FileResponse + +from ..utils.assets import asset_url +from ..utils.file import ROOT, templates +from . import main_router + +TOOLS = ROOT / 'static/tools' +MANIFEST = json.loads((TOOLS / 'manifest.json').read_text('utf-8')) +RUNTIME_VERSION = MANIFEST['pyodide'] +FILES = { + 'engine.py': 'text/plain', 'recovery.py': 'text/plain', 'vendor.zip': 'application/zip', + 'pyc_decompiler.py': 'text/plain', 'bytecode.zip': 'application/zip', + 'syntax.zip': 'application/zip', 'renpy_lexer.py': 'text/plain', + 'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', + **{f'pyodide/{RUNTIME_VERSION}/{name}': media for name, media in { + 'pyodide.mjs': 'application/javascript', 'pyodide.asm.js': 'application/javascript', + 'pyodide.asm.wasm': 'application/wasm', 'python_stdlib.zip': 'application/zip', + 'pyodide-lock.json': 'application/json', 'LICENSE': 'text/plain', + }.items()}, +} +_digests = {} + + +def physical_file(name): + if name == 'renpy_lexer.py': + return ROOT / 'app/utils/renpy_lexer.py' + return TOOLS / name.replace(f'pyodide/{RUNTIME_VERSION}/', 'pyodide/') + + +def digest(name): + path = physical_file(name) + stat = path.stat() + stamp = (stat.st_mtime_ns, stat.st_size) + if name not in _digests or _digests[name][0] != stamp: + _digests[name] = (stamp, hashlib.sha256(path.read_bytes()).hexdigest()[:16]) + return _digests[name][1] + + +@main_router.get('/tools') +async def tools_page(request: Request): + return templates.TemplateResponse(request, 'tools.html', {}) + + +@main_router.get('/tools/colors') +async def colors_page(request: Request): + return templates.TemplateResponse(request, 'tools_colors.html', {}) + + +@main_router.get('/tools/unpack') +async def unpack_page(request: Request): + return templates.TemplateResponse(request, 'tools_unpack.html', { + 'tools_config': { + 'worker': asset_url('/static/js/tools-worker.js'), + 'engine': f'/static/tools/engine.py?v={digest("engine.py")}', + 'recovery': f'/static/tools/recovery.py?v={digest("recovery.py")}', + 'pyc': f'/static/tools/pyc_decompiler.py?v={digest("pyc_decompiler.py")}', + 'bytecode': f'/static/tools/bytecode.zip?v={digest("bytecode.zip")}', + 'vendor': f'/static/tools/vendor.zip?v={digest("vendor.zip")}', + 'syntax': f'/static/tools/syntax.zip?v={digest("syntax.zip")}', + 'lexer': f'/static/tools/renpy_lexer.py?v={digest("renpy_lexer.py")}', + 'runtime': f'/static/tools/pyodide/{RUNTIME_VERSION}/', + }, + }) + + +@main_router.get('/static/tools/{name:path}') +async def tools_asset(name: str, request: Request): + if name not in FILES or not physical_file(name).is_file(): + raise HTTPException(404) + versioned = name.startswith('pyodide/') or request.query_params.get('v') == digest(name) + return FileResponse(physical_file(name), media_type=FILES[name], headers={ + 'X-Content-Type-Options': 'nosniff', + 'Cache-Control': 'public, max-age=31536000, immutable' if versioned else 'public, max-age=300', + **({'Content-Encoding': 'identity'} if name.endswith(('.zip', '.wasm')) else {}), + }) diff --git a/app/routes/warpers.py b/app/routes/warpers.py index 7fa4cae..1460fef 100644 --- a/app/routes/warpers.py +++ b/app/routes/warpers.py @@ -1,12 +1,40 @@ +from urllib.parse import quote + +from fastapi import Request from fastapi.responses import RedirectResponse +from ..utils.config import CONFIG +from ..utils.file import templates +from ..utils.md import CODE_COPY_BUTTON +from ..utils import warpers as warpers_util + from . import main_router router = main_router -# The warper reference used to live at its own top-level URL before it became -# a section of «Ресурсов оригинала». Kept as a permanent redirect so links -# shared while it lived here don't rot. +# Keep old shared links (including their fragments) working after the move. @router.get('/warpers', include_in_schema=False) +@router.get('/resources/original/warpers', include_in_schema=False) +@router.get('/resources/community/warpers', include_in_schema=False) async def warpers_redirect(): - return RedirectResponse('/resources/original/warpers', status_code=308) + return RedirectResponse('/tools/warpers', status_code=308) + + +@router.get('/tools/warpers') +async def warpers_page(request: Request): + backgrounds = [ + {'name': item['name'], + 'location': item.get('loc') or 'Прочее', + 'description': item.get('desc') or item['name'], + 'preview': item['raw'] if item.get('tint') else f"/resource/hero/{quote(item['name'])}"} + for item in CONFIG.resources.get('original', {}).get('bg', []) + if item['declared'] and item['raw'] and not item['nsfw'] + ] + return templates.TemplateResponse(request, 'tools_warpers.html', { + 'columns': warpers_util.COLUMNS, + 'special': warpers_util.SPECIAL, + 'families': warpers_util.families(), + 'community': CONFIG.warpers, + 'background': next((bg for bg in backgrounds if bg['name'] == 'ext_beach_day'), backgrounds[0] if backgrounds else None), + 'code_copy_button': CODE_COPY_BUTTON, + }) diff --git a/app/utils/animations.py b/app/utils/animations.py new file mode 100644 index 0000000..e938456 --- /dev/null +++ b/app/utils/animations.py @@ -0,0 +1,312 @@ +"""Curated, playable previews of the original game's Ren'Py animations.""" + +import re +from pathlib import Path +from urllib.parse import quote + +from .config import CONFIG + + +def _sequence(title, name, source, frames, *, description='', loop=True, fit='cover'): + return { + 'title': title, + 'name': name, + 'source': source, + 'kind': 'sequence', + 'frames': frames, + 'description': description, + 'loop': loop, + 'fit': fit, + } + + +def _frame(path, hold, fade=0, filter=None, transition="dissolve"): + return {'path': path, 'hold': hold, 'fade': fade, 'filter': filter, + 'transition': transition} + + +ANIMATIONS = [ + { + 'title': 'Снег', 'name': 'snow', 'source': ('globals.rpy', 190), + 'kind': 'snow', 'path': 'images/anim/snow.png', 'particles': 50, + 'description': 'Частицы падают с ветром. Можно переключить плотность.', + 'variants': [ + {'label': 'Обычный', 'particles': 50, 'source_name': 'snow'}, + {'label': 'Сильный', 'particles': 500, 'source_name': 'heavy_snow'}, + ], + }, + _sequence( + 'Звёздное небо', 'stars', ('media.rpy', 93), + [_frame('images/anim/stars_1.jpg', 1.5, 1.5), + _frame('images/anim/stars_3.jpg', 1.5, 1.5)], + description='Два ночных кадра плавно сменяют друг друга.', + ), + _sequence( + 'Свеча', 'candle', ('media.rpy', 100), + [_frame('images/anim/candle_1.png', 2, 2), + _frame('images/anim/candle_2.png', 2, 2)], + description='Медленное мерцание на двух кадрах.', + ), + _sequence( + 'Шум', 'prologue_dream', ('media.rpy', 107), + [_frame(f'images/anim/prologue_{i}.png', hold) + for i, hold in [(1, 0.1), (2, 0.1), (3, 0.1), (2, 0)]], + description='Быстрый цикл кадров шума.', + ), + { + 'title': 'Закрыть глаза', 'name': 'blink', 'source': ('media.rpy', 127), + 'kind': 'lids', 'path': 'images/anim/blink_up.png', + 'second_path': 'images/anim/blink_down.png', 'background': '@bg:ext_square_night', + 'motion': 'close', + 'duration': 1.5, 'description': 'Анимация закрытия глаз.', + }, + { + 'title': 'Открыть глаза', 'name': 'unblink', 'source': ('media.rpy', 117), + 'kind': 'lids', 'path': 'images/anim/blink_up.png', + 'second_path': 'images/anim/blink_down.png', 'background': '@bg:ext_square_night', + 'motion': 'open', + 'duration': 1.5, 'description': 'Анимация открытия глаз.', + }, + { + 'title': 'Моргание', 'name': 'blinking', 'source': ('media.rpy', 138), + 'kind': 'lids', 'path': 'images/anim/blink_up.png', + 'second_path': 'images/anim/blink_down.png', 'background': '@bg:ext_square_night', + 'motion': 'blink', + 'duration': 1.5, 'hold': 0.5, + 'description': 'Глаза закрываются, пауза, затем открываются.', + }, + _sequence( + 'Индикатор реплики (ctc)', 'ctc_animation', ('media.rpy', 77), + [_frame(f'images/misc/ctc{i:02d}.png', 0.15) for i in range(1, 9)], + description='Индикатор конца реплики: предлагает нажать для продолжения диалога.', + fit='indicator', + ), + _sequence( + 'Ушки Юли', 'op_uv', ('media.rpy', 81), + [_frame(f'images/misc/op/uv{i}.png', 0.5) for i in (1, 2, 3, 2, 1)], + description='Юля двигает ушами, из заставки в автобусе.', + fit='contain', loop=False, + ), + _sequence( + 'Клавиатура', 'anim 1_prologue', ('media.rpy', 157), + [_frame(f'images/anim/prologue_keyboard_{i}.jpg', hold) + for i, hold in [(1, 6), (2, 0.1), (3, 0.1), (4, 3), (3, 0.1), (2, 0.1), (1, 0)]], + description='Долгие остановки на спокойных кадрах, быстрые смены при печати.', + loop=False, + ), + _sequence( + 'Клавиатура и монитор', 'anim 2_prologue', ('media.rpy', 172), + [_frame(f'images/anim/prologue_keyboard_monitor_{i}.jpg', hold) + for i, hold in [(1, 6), (2, 0.1), (3, 0.1), (4, 3), (3, 0.1), (2, 0.1), (1, 0)]], + description='Та же ритмика, но в кадре также монитор.', + loop=False, + ), + _sequence( + 'Монитор', 'anim 3_prologue', ('media.rpy', 187), + [_frame(f'images/anim/prologue_monitor_{i}.jpg', hold) + for i, hold in [(1, 6), (2, 0.1), (3, 0.1), (4, 0)]], + description='Первые 6 секунд монитор неподвижен, затем изображение меняется.', + loop=False, + ), + _sequence( + 'Кадры пролога', 'anim 4_prologue', ('media.rpy', 196), + [_frame('images/anim/prolog_15.jpg', 6), + _frame('images/anim/prolog_3.jpg', 3, 3, transition='fade'), + _frame('images/anim/prolog_4.jpg', 0, 3, transition='fade')], + description='Первый кадр держится 6 секунд. Затем сцены сменяются через затемнение.', + loop=False, + ), + _sequence( + 'Сова', 'owl', ('media.rpy', 203), + [_frame('images/anim/owl_1.png', 5), _frame('images/anim/owl_2.png', 0.5)], + description='Светящиеся глаза спустя долгую паузу.', + ), + { + 'title': 'Вспышка на площади', 'name': 'bg ext_square_night_flash', + 'source': ('media.rpy', 210), 'kind': 'flash', + 'path': '@bg:ext_square_night', 'duration': 1, 'peak_hold': 0.5, 'hold': 3, 'cycles': 2, + 'description': 'Белая вспышка повторяется с паузой.', + }, + _sequence( + 'Плохая концовка Лены', 'un_ending_bad', ('media.rpy', 217), + [_frame('@cg:epilogue_un_bad', 2, 2), + _frame('@cg:epilogue_un_bad_red', 2, 2)], + description='Исходная иллюстрация сменяется красным оттенком.', + ), + { + 'title': 'Затемнение сцены', 'name': 'black_long', 'source': ('media.rpy', 239), + 'kind': 'blackout', 'path': '@bg:ext_camp_entrance_day', + 'description': 'Появление сепии за 2 секунды, затем затемнение за 50 секунд — как в оригинале.', + 'intro': 2, 'duration': 50, + }, + _sequence( + 'Заставка с монитором', 'backdrop_new', ('script.rpy', 119), + [_frame(f'images/anim/backdrop/{i}.png', hold) for i, hold in ((1, 0.1), (2, 0.1), (3, 0.1), (2, 0))], + description='Используется как заставка для отображения текущего дня (и рута) между днями.', + fit='contain', + ) | {'background': 'images/anim/backdrop/back.jpg', 'delay': 0.1}, + { + 'title': 'Дрожание сцены', 'name': 'zhenya_anim0–6', + 'source': ('scenario/zhenya.rpy', 79), 'kind': 'shake', + 'overlay': 'zhenya/images/blink.png', + 'description': 'Сдвиги сцены по 0,2 секунды под неподвижным слоем век.', + 'variants': [ + {'label': label, 'path': f'@bg:{name}', 'source_name': f'zhenya_anim{index}'} + for index, (label, name) in enumerate([ + ('Медпункт', 'ext_aidpost_night'), ('Площадь', 'ext_square_night'), + ('Клубы', 'ext_clubs_night'), ('Вход в лагерь', 'ext_camp_entrance_night'), + ('Тропа', 'ext_path_night'), ('Лесная тропа', 'ext_path2_night'), + ('Поляна', 'ext_polyana_night'), + ]) + ], + }, +] + + +def _image_file(name, resources): + if name.startswith('@bg:'): + category, key = 'bg', name[4:] + elif name.startswith('@cg:'): + category, key = 'cg', name[4:] + else: + return name + + return next((item.get('file') for item in resources.get('original', {}).get(category, []) + if item['name'] == key and item.get('file')), None) + + +def _public_path(path): + return '/resource/raw/' + quote(path, safe='/') + + +def _poster_path(path, resources): + return '/resource/poster/' + quote(_image_file(path, resources) or path, safe='/') + + +def playback_duration(item): + if item['kind'] == 'sequence': + return round(item.get('delay', 0) + sum(frame['hold'] + frame['fade'] for frame in item['frames']), 3) + if item['kind'] == 'lids': + return item['duration'] * 2 + item['hold'] if item['motion'] == 'blink' else item['duration'] + if item['kind'] == 'flash': + return (item['duration'] * 2 + item['peak_hold'] + item['hold']) * item.get('cycles', 1) + if item['kind'] == 'blackout': + return item.get('intro', 0) + item['duration'] + return 0.8 if item['kind'] == 'shake' else None + + +def _usage(name): + if name == 'ctc_animation': + return 'define narrator = Character(None, ctc="ctc_animation", ctc_position="fixed")' + if name == 'backdrop_new': + return 'scene backdrop_back\nshow backdrop_new' + return ('scene ' if name.startswith(('bg ', 'zhenya_anim')) or name in ('stars', 'candle', 'prologue_dream', 'un_ending_bad', 'black_long') else 'show ') + name + + +def _source_url(root, filename, name, fallback, source_lines): + source = root / filename + line = fallback + if filename not in source_lines: + source_lines[filename] = source.read_text('utf-8', errors='replace').splitlines() if source.is_file() else [] + for number, text in enumerate(source_lines[filename], 1): + if re.match(r'\s*image\s+' + re.escape(name) + r'\s*[:=]', text): + line = number + break + return f'/resources/browser/{quote(filename, safe="/")}#L{line}' + + +def available_previews(root: Path): + ready = [] + resources = CONFIG.resources + source_lines = {} + + for definition in ANIMATIONS: + item = {**definition} + paths = [item.get('path'), item.get('second_path'), item.get('overlay'), item.get('background')] + paths += [frame['path'] for frame in item.get('frames', [])] + paths += [variant.get('path') for variant in item.get('variants', [])] + resolved = {} + for path in filter(None, paths): + filename = _image_file(path, resources) + if filename is None and path.startswith('@'): + continue + filename = filename or path + if (root / filename).is_file(): + resolved[path] = _public_path(filename) + if path.startswith(('@bg:', '@cg:')): + category, key = path[1:].split(':', 1) + resource = next((entry for entry in resources.get('original', {}).get(category, []) + if entry['name'] == key), {}) + if resource.get('tint') and resource.get('raw'): + resolved[path] = resource['raw'] + + required = [item.get('path'), item.get('second_path'), item.get('overlay'), item.get('background')] + required += [frame['path'] for frame in item.get('frames', [])] + if any(path and path not in resolved for path in required): + continue + + if item.get('variants') and item['kind'] == 'shake': + item['variants'] = [ + {**variant, 'src': resolved[variant['path']]} + for variant in item['variants'] if variant['path'] in resolved + ] + if not item['variants']: + continue + item['path'] = item['variants'][0]['path'] + + if item.get('path'): + item['src'] = resolved.get(item['path']) + if item.get('second_path'): + item['second_src'] = resolved[item['second_path']] + if item.get('overlay'): + item['overlay_src'] = resolved[item['overlay']] + if item.get('background'): + item['background_src'] = resolved[item['background']] + if item.get('frames'): + item['frames'] = [ + {**frame, 'src': resolved[frame['path']]} + for frame in item['frames'] + ] + source_path, line = item['source'] + source_name = item.get('variants', [{}])[0].get('source_name', item['name']) + item['source_url'] = _source_url(root, source_path, source_name, line, source_lines) + item['variants'] = [ + {**variant, 'name': variant.get('source_name', item['name']), + 'usage': _usage(variant.get('source_name', item['name'])), + 'source_url': _source_url(root, source_path, variant.get('source_name', item['name']), line, source_lines)} + for variant in item.get('variants', []) + ] + item['display_name'] = source_name + item['usage'] = _usage(source_name) + item['total_duration'] = playback_duration(item) + item['loop'] = item.get('loop', item['kind'] in ('snow', 'flash', 'shake')) + # Posters are small, lazy WebP stills. Original images are decoded only + # after playback or manual inspection is requested. + for key in ('path', 'second_path', 'overlay', 'background'): + if item.get(key): + item[{'path': 'poster', 'second_path': 'second_poster', 'overlay': 'overlay_poster', 'background': 'background_poster'}[key]] = _poster_path(item[key], resources) + if item.get('frames'): + item['poster'] = _poster_path(item['frames'][0]['path'], resources) + for variant in item['variants']: + if variant.get('path'): + variant['poster'] = _poster_path(variant['path'], resources) + if item['kind'] == 'blackout': + item['src'] = '/resource/animation/black-long' + item['poster'] = item['src'] + '?poster=true' + # Link to declared resources in their category, or to the source file + # when it has no declaration (e.g. the dialogue indicator frames). + item['related'] = [] + for path in dict.fromkeys(filter(None, paths)): + if path not in resolved: + continue + filename = _image_file(path, resources) + category = path[1:3] if path.startswith(('@bg:', '@cg:')) else 'anim' + resource = next((entry for entry in resources.get('original', {}).get(category, []) + if entry.get('declared') and entry.get('file') == filename + and (not path.startswith('@') or entry['name'] == path[4:])), None) + href = (('#' if category == 'anim' else f'/resources/original/{category}#') + resource['rid'] + if resource else '/resources/browser/' + quote(filename, safe='/')) + item['related'].append({'name': resource['code'] if resource else filename, 'href': href}) + ready.append(item) + + return ready diff --git a/app/utils/file.py b/app/utils/file.py index 4c8192b..6c0d789 100644 --- a/app/utils/file.py +++ b/app/utils/file.py @@ -1,10 +1,12 @@ from pathlib import Path +from .icons import lucide_icon from fastapi import HTTPException from fastapi.templating import Jinja2Templates ROOT = Path(__file__).parents[2] templates = Jinja2Templates(ROOT / 'templates') +templates.env.globals['lucide_icon'] = lucide_icon def resolve(path): path = Path(path) diff --git a/app/utils/icons.py b/app/utils/icons.py new file mode 100644 index 0000000..85e6223 --- /dev/null +++ b/app/utils/icons.py @@ -0,0 +1,27 @@ +"""Render the locally vendored Lucide set for templates and Markdown.""" +import json +from pathlib import Path + +from markupsafe import Markup, escape + +_DIR = Path(__file__).resolve().parents[2] / 'static' / 'icons' +_MANIFEST = json.loads((_DIR / 'manifest.json').read_text('utf-8')) +ICON_NAMES = frozenset(_MANIFEST['icons']) +_BODIES = { + name: (_DIR / f'{name}.svg').read_text('utf-8').split('>', 1)[1].rsplit('', 1)[0].strip() + for name in _MANIFEST['icons'] +} + + +def lucide_icon(name: str, size: int = 18, class_name: str = '') -> Markup: + """Decorative icon; the containing control supplies its accessible label.""" + body = _BODIES[name] + if type(size) is not int or not 1 <= size <= 128: + raise ValueError('Icon size must be an integer from 1 to 128') + classes = escape(f'lucide lucide-{name}' + (f' {class_name}' if class_name else '')) + return Markup( + f'' + ) diff --git a/app/utils/lifespan/animation_images.py b/app/utils/lifespan/animation_images.py new file mode 100644 index 0000000..3926074 --- /dev/null +++ b/app/utils/lifespan/animation_images.py @@ -0,0 +1,43 @@ +"""Small posters and the game's black_long color pipeline, cached on demand.""" + +import hashlib +import os + +from PIL import Image + +from ..config import CONFIG + + +def image_file(source, *, poster=False, sepia=False): + key = hashlib.sha256(f'v1:{source.resolve()}:{poster}:{sepia}'.encode()).hexdigest() + return CONFIG.cache_path / 'animation-images' / f'{key}.webp' + + +def original_sepia(image): + # media.rpy: im.Map(m,m,m) -> im.matrix.hue(180) -> im.Sepia. + # The legacy im.matrix composition applies grayscale before the tint. + # Reference: https://github.com/renpy/renpy/blob/master/renpy/display/im.py + palette = [0] * 85 + [128] * 86 + [255] * 85 + mapped = image.convert('RGB').point(palette * 3) + rotated = mapped.convert('RGB', (-0.574, 1.43, 0.144, 0, + 0.426, 0.43, 0.144, 0, + 0.426, 1.43, -0.856, 0)) + desat = (0.2126, 0.7152, 0.0722) + matrix = tuple(value for tint in (1, 0.94, 0.76) + for value in (*(weight * tint for weight in desat), 0)) + return rotated.convert('RGB', matrix) + + +def make_image(source, *, poster=False, sepia=False): + target = image_file(source, poster=poster, sepia=sepia) + if target.is_file() and target.stat().st_mtime >= source.stat().st_mtime: + return target + with Image.open(source) as base: + image = original_sepia(base) if sepia else base.convert('RGBA') + if poster: + image.thumbnail((640, 640), Image.Resampling.LANCZOS) + target.parent.mkdir(parents=True, exist_ok=True) + temporary = target.with_suffix('.webp.tmp') + image.save(temporary, 'WEBP', quality=80, lossless=not poster) + os.replace(temporary, target) + return target diff --git a/app/utils/lifespan/search_cache.py b/app/utils/lifespan/search_cache.py index c14dab4..47ecbd5 100644 --- a/app/utils/lifespan/search_cache.py +++ b/app/utils/lifespan/search_cache.py @@ -16,6 +16,10 @@ ('Браузер файлов', '/resources/browser'), ('Специалисты', '/specialists'), ('Материалы', '/materials'), + ('Инструменты', '/tools'), + ('Распаковщик RPA/RPYC/PYC RenPy / Python / UnRpyc / UnRPA / uncompyle6', '/tools/unpack'), + ('Палитра цветов / Цвет / HEX / RGB / Палитры', '/tools/colors'), + ('Варперы / Сглаживание ATL / Тестер формул', '/tools/warpers'), ('Площадки для публикации', '/news-resources'), ('Авторы', '/authors'), ('Поддержка', '/support'), @@ -28,8 +32,8 @@ def _section_items(): for label, url in SECTIONS ] for collection in ('original', 'community'): - for category, title in (CATEGORY_TITLES | {'warpers': 'Варперы'}).items(): - if category not in CONFIG.resources.get(collection, {}) and category != 'warpers': + for category, title in CATEGORY_TITLES.items(): + if category not in CONFIG.resources.get(collection, {}): continue items.append({ 'label': title, 'url': f'/resources/{collection}/{category}', @@ -75,8 +79,8 @@ def rebuild_search(): warpers = [{ 'label': item['name'], 'desc': item.get('desc') or '', 'keywords': item.get('author') or '', - 'context': 'Ресурсы сообщества / Варперы', 'kind': 'res', - 'url': '/resources/community/warpers#' + quote(item['name'], safe=''), + 'context': 'Инструменты / Варперы', 'kind': 'section', + 'url': '/tools/warpers#' + quote(item['name'], safe=''), } for item in CONFIG.warpers] CONFIG.search_items = ( _section_items() + build_items(CONFIG.search_index) diff --git a/app/utils/md/__init__.py b/app/utils/md/__init__.py index 2f35e6d..d24ce70 100644 --- a/app/utils/md/__init__.py +++ b/app/utils/md/__init__.py @@ -11,6 +11,7 @@ from ..renpy_lexer import RenPyLexer from ..svg import SVG +from ..icons import lucide_icon from .audio import audio, render_audio from .banner import banner, render_banner_close, render_banner_open from .details import ( @@ -32,7 +33,6 @@ render_refs_open, ) from .slugs import heading_shift, heading_slugs, render_heading_open -from .table import table_block from .template import template dummy_rule = lambda s: lambda self, tokens, idx, options, env: s @@ -209,14 +209,8 @@ def highlight_code(code, lang, attrs): # copy buttons (res_macros.html). CODE_COPY_BUTTON = ( '' + + str(lucide_icon('copy', 14)) + str(lucide_icon('check', 14)) + + '' ) def render_fence(self, tokens, idx, options, env): @@ -339,14 +333,19 @@ def render_link_open(self, tokens, idx, options, env): return self.renderToken(tokens, idx, options, env) -MD = MarkdownIt('commonmark', {'highlight': highlight_code}) +def render_table_open(self, tokens, idx, options, env): + tokens[idx].attrJoin('class', 'table') + return self.renderToken(tokens, idx, options, env) + + +# Use the built-in GFM pipe-table rule; the rest of the syntax stays CommonMark. +MD = MarkdownIt('commonmark', {'highlight': highlight_code}).enable('table') MD.add_render_rule('fence', render_fence) MD.add_render_rule('code_inline', render_code_inline) MD.add_render_rule('image', render_image) MD.add_render_rule('link_open', render_link_open) MD.add_render_rule('audio', render_audio) -MD.block.ruler.before('fence', 'table', table_block) MD.block.ruler.before('fence', 'audio', audio) MD.block.ruler.before('fence', 'info', template('info')) MD.block.ruler.before('fence', 'warning', template('warning')) @@ -366,8 +365,7 @@ def render_link_open(self, tokens, idx, options, env): MD.add_render_rule('heading_open', render_heading_open) -MD.add_render_rule('table_open', dummy_rule('') ) -MD.add_render_rule('table_close', dummy_rule('
')) +MD.add_render_rule('table_open', render_table_open) MD.add_render_rule('info_open', dummy_rule(f'
{SVG["info"]}
')) MD.add_render_rule('info_close', dummy_rule('
')) diff --git a/app/utils/md/audio.py b/app/utils/md/audio.py index 40c2fb1..e4956dc 100644 --- a/app/utils/md/audio.py +++ b/app/utils/md/audio.py @@ -1,3 +1,4 @@ +from ..icons import lucide_icon import re from urllib.parse import unquote, urlparse @@ -52,13 +53,8 @@ def render_audio(self, tokens, idx, options, env): f'' f'{name}' f'' diff --git a/app/utils/md/slugs.py b/app/utils/md/slugs.py index 17b43bf..7759dfd 100644 --- a/app/utils/md/slugs.py +++ b/app/utils/md/slugs.py @@ -1,3 +1,4 @@ +from ..icons import lucide_icon import re def slugify(text): @@ -116,5 +117,5 @@ def render_heading_open(self, tokens, idx, options, env): # heading's own id, the way "copy link to heading" works everywhere # else it's implemented this way (GitHub, MDN). f'' + f'class="anchor" aria-hidden="true" tabindex="-1">{lucide_icon("hash", 18)}' ) diff --git a/app/utils/md/table.py b/app/utils/md/table.py deleted file mode 100644 index 284a180..0000000 --- a/app/utils/md/table.py +++ /dev/null @@ -1,70 +0,0 @@ -from markdown_it.rules_block import StateBlock - -from . import containers - -# Registered so an enclosing callout counts this fence and doesn't mistake the -# table's closer for its own. The table's own scan below stays first-match: -# its body is read as raw `;`-separated rows, never block-tokenized, so no -# container can open inside it. -containers.register('table') - - -def table_block(state: StateBlock, startLine: int, endLine: int, silent: bool): - pos = state.bMarks[startLine] + state.tShift[startLine] - maximum = state.eMarks[startLine] - - line = state.src[pos:maximum] - - if line != ':::table': - return False - - nextLine = startLine + 1 - - while nextLine < endLine: - pos = state.bMarks[nextLine] + state.tShift[nextLine] - maximum = state.eMarks[nextLine] - - if state.src[pos:maximum] == ':::': - break - - nextLine += 1 - - if nextLine >= endLine: - return False - - token = state.push('table_open', 'table', 1) - token.attrs = {'class': 'table'} - - # First row is the header. It always was meant to be — `th` has had its own - # background in doc.css from the start — but the promotion was written as - # two bare `content.replace(...)` calls whose results were dropped on the - # floor, so every table shipped as an unbroken slab of `` and the `th` - # styling had nothing to style. Building the two sections directly is both - # the fix and one less thing to keep in sync. - rows = [row for row in state.getLines(startLine + 1, nextLine, 0, False).split('\n') if row.strip()] - - def cells(row, tag): - # Cell text stays unescaped on purpose: it is parsed as inline markdown - # below, the same as any other prose in the document, so `code` and - # **bold** work in a cell. - return ''.join(f'\n<{tag}>{col}' for col in row.split(';')) - - content = '' - - if rows: - content += f'\n{cells(rows[0], "th")}\n\n' - - if len(rows) > 1: - content += '\n' - for row in rows[1:]: - content += f'\n{cells(row, "td")}\n' - content += '\n' - - token = state.push('inline', '', 0) - token.content = content - token.children = [] - - state.push('table_close', 'table', -1) - - state.line = nextLine + 1 - return True \ No newline at end of file diff --git a/app/utils/svg.py b/app/utils/svg.py index 00fb0bd..1024def 100644 --- a/app/utils/svg.py +++ b/app/utils/svg.py @@ -1,20 +1,11 @@ -from .file import ROOT +from .icons import lucide_icon -_IMG = ROOT / 'static' / 'img' - -# Callout icons (`:::info`, `:::warning`, …) and the stub-banner icons, inlined -# into the rendered HTML so they inherit `currentColor` from the box they sit in -# and cost no extra request. Banner icons are selected by config.yaml. +# Stable Markdown/config names mapped to our shared Lucide icon catalog. SVG = { - 'info': (_IMG / 'info.svg').read_text('utf-8'), - 'warning': (_IMG / 'warning.svg').read_text('utf-8'), - 'tip': (_IMG / 'tip.svg').read_text('utf-8'), - 'attention': (_IMG / 'attention.svg').read_text('utf-8'), - # An octagon, not another triangle: danger and warning necessarily share a - # near-identical background wash (see vars.css), so the silhouette is what - # tells them apart at a glance. - 'danger': (_IMG / 'danger.svg').read_text('utf-8'), - - 'wip': (_IMG / 'wip.svg').read_text('utf-8'), - 'outdated': (_IMG / 'outdated.svg').read_text('utf-8'), + key: str(lucide_icon(name, 24, 'icon')) + for key, name in { + 'info': 'info', 'warning': 'triangle-alert', 'tip': 'lightbulb', + 'attention': 'circle-alert', 'danger': 'octagon-alert', + 'wip': 'construction', 'outdated': 'clock', + }.items() } diff --git a/app/utils/warpers.py b/app/utils/warpers.py index ec662e1..425c97a 100644 --- a/app/utils/warpers.py +++ b/app/utils/warpers.py @@ -1,7 +1,7 @@ -"""The Ren'Py warper set, as the «Варперы» section of «Ресурсов» renders it. +"""The Ren'Py warper set, for the «Варперы» tool. -Unlike every other resource category, this one isn't scanned out of the -game folder: warpers are part of the engine (renpy/common/000atl.rpy), so +Warper definitions aren't scanned out of the game folder: they are part +of the engine (renpy/common/000atl.rpy), so the list lives here in code. Community-made warpers are a separate, data- driven list — see utils/lifespan/warpers_cache.py. @@ -16,7 +16,7 @@ COLUMNS = [ ('easeout_', 'разгон', 'медленный старт, быстрый финиш'), ('easein_', 'торможение', 'быстрый старт, мягкий финиш'), - ('ease_', 'с обеих сторон', 'медленно по краям, быстро в середине'), + ('ease_', 'разгон и торможение', 'медленно по краям, быстро в середине'), ] PREFIXES = ('easeout', 'easein', 'ease') @@ -24,11 +24,11 @@ # Not easings — these three describe *when* the value changes, so each one # carries its own line instead of leaning on a family note. SPECIAL = [ - ('pause', 'Держит начальное значение всю длительность и переключается на конечное в последний момент.', + ('pause', 'Сохраняет начальное значение до конца интервала, затем устанавливает конечное.', '0.0 if t < 1.0 else 1.0'), - ('instant', 'Ставит конечное значение сразу, оставшаяся длительность работает как пауза после него.', + ('instant', 'Сразу устанавливает конечное значение и ждёт до конца указанного времени.', '1.0'), - ('linear', 'Равномерно, без сглаживания. Хорош для механики: лифт, полоса загрузки, счётчик.', + ('linear', 'Меняет значение с постоянной скоростью.', 't'), ] @@ -43,37 +43,37 @@ # above is typeset for reading, this one pastes into the generator, into # warpers.yaml and into a mod. FAMILIES = [ - ('Sine', '', '1 − cos(t · π/2)', - 'Мягкое сглаживание на косинусе. Единственная тройка без суффикса и разумный выбор по умолчанию.', + ('Стандартные', '', '1 − cos(t * π/2)', + 'Стандартные варперы Ren\'Py с синусоидальным изменением скорости. Имена этих варперов не имеют суффикса _sine.', 'easeout', '1 - cos({t} * pi / 2)'), ('Quad', '_quad', 't²', - 'Самая сдержанная из степенных: заметно живее линейного, но без драмы.', + 'Постоянное ускорение при разгоне и постоянное замедление при торможении.', 'easeout', '{t} ** 2'), ('Cubic', '_cubic', 't³', - 'Рабочая лошадка: разгон уже читается, рывка ещё нет.', + 'При разгоне ускорение постепенно растёт. Начало движения медленнее, чем у Quad.', 'easeout', '{t} ** 3'), ('Quart', '_quart', 't⁴', - 'Больше половины пути проезжается в последней трети времени.', + 'Разгон по четвёртой степени. К середине интервала easeout_quart достигает значения 1/16.', 'easeout', '{t} ** 4'), ('Quint', '_quint', 't⁵', - 'Та же кривая, но жёстче. На длинной анимации читается уже как рывок.', + 'Разгон по пятой степени. К середине интервала easeout_quint достигает значения 1/32.', 'easeout', '{t} ** 5'), - ('Expo', '_expo', '2^(10 · (t − 1))', - 'Самый крутой из плавных: начало движения почти не видно.', + ('Expo', '_expo', '2^(10 * (t − 1))', + 'Экспоненциальный разгон или торможение. Основное изменение значения происходит у одного края интервала.', 'easeout', '2 ** (10 * ({t} - 1))'), ('Circ', '_circ', '1 − √(1 − t²)', - 'Дуга окружности: спокойное начало и обрыв в конце, резче, чем ожидаешь по названию.', + 'Кривая на основе окружности. У easeout_circ скорость резко возрастает в конце интервала.', 'easeout', '1 - sqrt(1 - {t} * {t})'), - ('Back', '_back', 't² · (2.7015 · t − 1.7015)', - 'Замах: значение сначала уходит в обратную сторону и только потом идёт к цели.', + ('Back', '_back', 't² * (2.7015 * t − 1.7015)', + 'Выход за диапазон 0–1: ниже 0 при разгоне, выше 1 при торможении или оба эффекта в варианте ease.', 'easeout', '{t} * {t} * (2.7015 * {t} - 1.7015)'), # Elastic and bounce are the two families the engine writes as easein_ and # derives easeout_ from — see the note in warpers.js. ('Elastic', '_elastic', None, - 'Пружина: цель проскакивается, и значение затухающе колеблется вокруг неё.', + 'Затухающие колебания у начала, конца или обоих краёв интервала. Значение выходит за диапазон 0–1.', 'easein', '1 + 2 ** (-10 * {t}) * sin(({t} - 0.075) * (2 * pi) / 0.3)'), ('Bounce', '_bounce', None, - 'Отскоки, как у мяча об пол. Хорошо ложится на падение и приземление.', + 'Несколько отскоков у начала, конца или обоих краёв интервала. Значение остаётся в диапазоне 0–1.', 'easein', '7.5625 * {t} ** 2 if {t} < 1 / 2.75' ' else 1 + 7.5625 * (({t} - 1.5 / 2.75) ** 2 - (0.5 / 2.75) ** 2) if {t} < 2 / 2.75' @@ -90,13 +90,19 @@ easeout_cubic 1.5 xalign 1.0 """, 'warp': """ -transform proezd(w="easein_quad"): +transform proezd(w=_warper.easein_quad): xpos 0 warp w 2.0 xpos 520 """, 'transition': """ $ medlenno = Dissolve(1.0, time_warp=_warper.easein_quad) """, + 'catalog': ''' +warpers: + - name: soft_back + desc: Отклонение ниже 0 перед разгоном + expr: t * t * (2.4 * t - 1.4) +''', 'custom': """ python early hide: @@ -139,6 +145,40 @@ def out(argument): return {'easeout': easeout, 'easein': easein, 'ease': ease} +def _description(suffix, prefix): + if suffix in ('_back', '_elastic', '_bounce'): + return { + '_back': { + 'easeout': 'В начале значение становится меньше 0, затем растёт до 1. При перемещении объект сначала отклоняется назад.', + 'easein': 'Перед завершением анимации значение превышает 1, затем возвращается к 1. При перемещении объект проходит конечную точку и возвращается к ней.', + 'ease': 'В начале значение становится меньше 0, в конце превышает 1. При перемещении объект отклоняется назад, проходит конечную точку и возвращается к ней.', + }, + '_elastic': { + 'easeout': 'Колебания около начального значения, затем движение к конечному. Значение может становиться меньше 0.', + 'easein': 'Колебания около конечного значения с уменьшением амплитуды. Значение несколько раз превышает 1 и возвращается к нему.', + 'ease': 'Колебания в начале и конце интервала. Значение выходит ниже 0 и выше 1.', + }, + '_bounce': { + 'easeout': 'Отскоки в начале, затем движение к конечному значению. Значение остаётся в диапазоне 0–1.', + 'easein': 'В конце анимации — несколько отскоков от конечного значения. Значение остаётся в диапазоне 0–1.', + 'ease': 'Отскоки в начале и конце анимации. Значение остаётся в диапазоне 0–1.', + }, + }[suffix][prefix] + + kind = { + '': 'Стандартная синусоидальная кривая', '_quad': 'Квадратичная кривая', + '_cubic': 'Кубическая кривая', '_quart': 'Кривая четвёртой степени', + '_quint': 'Кривая пятой степени', '_expo': 'Экспоненциальная кривая', + '_circ': 'Кривая на основе окружности', + }[suffix] + direction = { + 'easeout': 'значение сначала меняется медленно, затем быстрее', + 'easein': 'значение сначала меняется быстро, затем медленнее', + 'ease': 'в начале изменение ускоряется, в конце замедляется', + }[prefix] + return f'{kind}: {direction}.' + + def families(): result = [] @@ -152,7 +192,8 @@ def families(): 'source': variants[base], 'note': note, 'cells': [ - {'name': prefix + suffix, 'source': variants[prefix]} + {'name': prefix + suffix, 'source': variants[prefix], + 'description': _description(suffix, prefix)} for prefix in PREFIXES ], }) @@ -161,4 +202,4 @@ def families(): def samples(): - return {key: MD.render(f'```renpy\n{src.strip()}\n```') for key, src in SAMPLES.items()} + return {key: MD.render(f'```{"yaml" if key == "catalog" else "renpy"}\n{src.strip()}\n```') for key, src in SAMPLES.items()} diff --git a/scripts/prepare_tools.py b/scripts/prepare_tools.py new file mode 100644 index 0000000..4d70d41 --- /dev/null +++ b/scripts/prepare_tools.py @@ -0,0 +1,116 @@ +"""Rebuild pinned browser dependencies explicitly, never at server startup.""" +import argparse +import hashlib +import io +import json +import urllib.request +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DEST = ROOT / 'static/tools' +PYODIDE = '0.29.3' +SOURCES = { + 'unrpyc': ('CensoredUsername/unrpyc', '3ae8334ed71a05535927dcc559663d3aca51215b'), + 'unrpa': ('Lattyware/unrpa', '005b10abec590db374f23fd8d4b111963792a15a'), +} +RUNTIME = ('pyodide.mjs', 'pyodide.asm.js', 'pyodide.asm.wasm', 'python_stdlib.zip', 'pyodide-lock.json') +PYGMENTS = '2.20.0' +BYTECODE_PACKAGES = { + 'uncompyle6': '3.9.3', 'xdis': '6.1.8', 'spark-parser': '1.9.0', + 'click': '8.5.0', 'six': '1.17.0', +} + + +def prepare_bytecode(): + """Bundle verified pure-Python wheels with their original licenses.""" + sources = {} + files = {} + for name, version in BYTECODE_PACKAGES.items(): + release = json.loads(fetch(f'https://pypi.org/pypi/{name}/{version}/json')) + wheel = next(file for file in release['urls'] if file['filename'].endswith('none-any.whl')) + data = fetch(wheel['url']) + checksum = hashlib.sha256(data).hexdigest() + if checksum != wheel['digests']['sha256']: + raise ValueError(f'{name} wheel checksum mismatch') + sources[name] = {'version': version, 'url': wheel['url'], 'sha256': checksum} + with zipfile.ZipFile(io.BytesIO(data)) as bundled: + for path in bundled.namelist(): + if path.endswith('/'): + continue + if path in files: + raise ValueError(f'Duplicate wheel path: {path}') + files[path] = bundled.read(path) + with zipfile.ZipFile(DEST / 'bytecode.zip', 'w') as output: + for path, data in sorted(files.items()): + info = zipfile.ZipInfo(path, (2026, 10, 7, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + output.writestr(info, data) + manifest_path = DEST / 'manifest.json' + manifest = json.loads(manifest_path.read_text('utf-8')) + manifest['sources']['bytecode'] = sources + manifest['sha256']['bytecode.zip'] = hashlib.sha256((DEST / 'bytecode.zip').read_bytes()).hexdigest() + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + + +def prepare_syntax(): + release = json.loads(fetch(f'https://pypi.org/pypi/Pygments/{PYGMENTS}/json')) + wheel = next(file for file in release['urls'] if file['filename'].endswith('py3-none-any.whl')) + data = fetch(wheel['url']) + if hashlib.sha256(data).hexdigest() != wheel['digests']['sha256']: + raise ValueError('Pygments wheel checksum mismatch') + (DEST / 'syntax.zip').write_bytes(data) + manifest_path = DEST / 'manifest.json' + manifest = json.loads(manifest_path.read_text('utf-8')) + manifest['sources']['pygments'] = {'version': PYGMENTS, 'repository': 'https://github.com/pygments/pygments'} + manifest['sha256']['syntax.zip'] = hashlib.sha256(data).hexdigest() + manifest_path.write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + + +def fetch(url): + with urllib.request.urlopen(url, timeout=120) as response: + return response.read() + + +def main(): + DEST.mkdir(parents=True, exist_ok=True) + manifest = {'pyodide': PYODIDE, 'sources': {}, 'sha256': {}} + with zipfile.ZipFile(DEST / 'vendor.zip', 'w', zipfile.ZIP_DEFLATED) as output: + for name, (repo, revision) in SOURCES.items(): + manifest['sources'][name] = {'repository': f'https://github.com/{repo}', 'revision': revision} + source = zipfile.ZipFile(io.BytesIO(fetch(f'https://codeload.github.com/{repo}/zip/{revision}'))) + for entry in sorted(source.namelist()): + relative = entry.partition('/')[2] + if not relative or entry.endswith('/'): + continue + include = (relative.startswith('decompiler/') and relative.endswith('.py') + or relative in ('unrpyc.py', 'deobfuscate.py')) if name == 'unrpyc' else ( + relative.startswith('unrpa/') and relative.endswith('.py')) + if include or relative in ('LICENSE', 'COPYING', 'README.md'): + target = relative if include else f'licenses/{name}/{relative}' + info = zipfile.ZipInfo(target, (2026, 10, 6, 0, 0, 0)) + info.compress_type = zipfile.ZIP_DEFLATED + output.writestr(info, source.read(entry)) + runtime = DEST / 'pyodide' + runtime.mkdir(exist_ok=True) + for name in RUNTIME: + print(f'Downloading Pyodide {PYODIDE}: {name}', flush=True) + (runtime / name).write_bytes(fetch(f'https://cdn.jsdelivr.net/pyodide/v{PYODIDE}/full/{name}')) + (runtime / 'LICENSE').write_bytes(fetch(f'https://raw.githubusercontent.com/pyodide/pyodide/{PYODIDE}/LICENSE')) + for file in [DEST / 'vendor.zip', *sorted(runtime.iterdir())]: + manifest['sha256'][file.relative_to(DEST).as_posix()] = hashlib.sha256(file.read_bytes()).hexdigest() + (DEST / 'manifest.json').write_text(json.dumps(manifest, indent=2) + '\n', encoding='utf-8') + + +if __name__ == '__main__': + parser = argparse.ArgumentParser() + parser.add_argument('--syntax-only', action='store_true') + parser.add_argument('--bytecode-only', action='store_true') + args = parser.parse_args() + if args.bytecode_only: + prepare_bytecode() + else: + if not args.syntax_only: + main() + prepare_syntax() + prepare_bytecode() diff --git a/scripts/sync_lucide_icons.py b/scripts/sync_lucide_icons.py new file mode 100644 index 0000000..d9c7c0e --- /dev/null +++ b/scripts/sync_lucide_icons.py @@ -0,0 +1,55 @@ +"""Rebuild browser icons from the checked-in canonical Lucide SVGs (no network).""" +import json +import re +import hashlib +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +ICON_DIR = ROOT / 'static' / 'icons' + + +def browser_source(): + manifest = json.loads((ICON_DIR / 'manifest.json').read_text('utf-8')) + bodies = { + name: (ICON_DIR / f'{name}.svg').read_text('utf-8').split('>', 1)[1].rsplit('', 1)[0].strip() + for name in manifest['icons'] + } + return '''/* Generated by scripts/sync_lucide_icons.py from the vendored Lucide SVGs. + License and pinned upstream revision: static/icons/. */ +(function () { + 'use strict'; + const bodies = ''' + json.dumps(bodies, ensure_ascii=False, indent=4) + '''; + const escape = value => String(value).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[c]); + window.ESDocIcons = Object.freeze({ + svg(name, size = 18, className = '') { + if (!Object.hasOwn(bodies, name)) throw new Error('Unknown Lucide icon: ' + name); + if (!Number.isInteger(size) || size < 1 || size > 128) throw new Error('Invalid icon size'); + const classes = 'lucide lucide-' + name + (className ? ' ' + className : ''); + return ''; + }, + }); +})(); +''' + + +if __name__ == '__main__': + (ROOT / 'static' / 'js' / 'icons.js').write_text(browser_source(), encoding='utf-8') + def mask_url(match): + name = match[1] + source = (ICON_DIR / f'{name}.svg').read_text('utf-8') + digest = hashlib.sha1(source.encode('utf-8')).hexdigest()[:12] + return f'/static/icons/{name}.svg?v={digest}' + for filename in ('tools.css', 'resources.css', 'doc.css', 'warpers.css'): + path = ROOT / 'static' / 'css' / filename + source = path.read_text('utf-8') + source = re.sub( + r'/static/icons/([a-z0-9-]+)\.svg(?:\?v=[a-f0-9]+)?', + mask_url, source, + ) + path.write_text(source, encoding='utf-8') diff --git a/static/css/animations.css b/static/css/animations.css new file mode 100644 index 0000000..e1114b2 --- /dev/null +++ b/static/css/animations.css @@ -0,0 +1,248 @@ +.animation-gallery { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 28px 24px; + margin: 0; + padding: 0; + list-style: none; +} + +.animation-card { + min-width: 0; + overflow: hidden; + border: 1px solid var(--border); + border-radius: var(--radius-block); + background: var(--bg); +} + +.animation-card[hidden] { display: none; } + +.animation-stage { + position: relative; + overflow: hidden; + aspect-ratio: 16 / 9; + background: #11151d; + isolation: isolate; +} + +.animation-frame, +.animation-lid, +.animation-scene, +.animation-shake-base, +.animation-shake-echo, +.animation-shake-overlay { + position: absolute; + inset: 0; + display: block; + width: 100%; + height: 100%; + object-fit: cover; +} + +.animation-stage--contain .animation-frame { + object-fit: contain; +} + +.animation-stage--indicator .animation-frame { + inset: auto; + right: 9.5%; + bottom: 2%; + width: 28px; + height: 28px; + object-fit: contain; +} + +.animation-scene { + z-index: 0; +} + +.animation-frame { + opacity: 0; + transition: opacity 0s; +} + +.animation-frame.is-visible { + opacity: 1; +} + +.animation-lid { + z-index: 1; +} + +.animation-lid--upper { + transform: translateY(-100%); +} + +.animation-lid--lower { + transform: translateY(100%); +} + +.animation-stage[data-lids="closed"] .animation-lid { + transform: translateY(0); +} + +.animation-stage--snow { + background: linear-gradient(#101c2d, #28465d); +} + +.animation-snow { + display: block; + width: 100%; + height: 100%; +} + +.animation-stage--shake .animation-shake-echo { + clip-path: inset(0); + opacity: 1; +} + +.animation-shake-echo:not([src]) { visibility: hidden; } + +.animation-stage--shake .animation-shake-overlay { + z-index: 1; +} + +.animation-stage--sequence .animation-effect-overlay, +.animation-stage--flash .animation-effect-overlay, +.animation-stage--blackout .animation-effect-overlay { + position: absolute; + z-index: 2; + inset: 0; + background: #fff; + opacity: 0; + pointer-events: none; +} + +.animation-stage--sequence .animation-effect-overlay, +.animation-stage--blackout .animation-effect-overlay { + background: #000; +} + +.animation-card-body { + padding: 15px 17px 17px; +} + +.animation-card-body h3 { + margin: 0; + font-family: var(--font-serif); + font-size: 1.25rem; + line-height: 1.35; +} + +.animation-card-body p { + min-height: 3em; + margin: 7px 0 14px; + color: var(--text-soft); + font-size: var(--fs-ui); + line-height: 1.5; +} + +.animation-variant-label { + display: flex; + align-items: center; + gap: 10px; + margin: 0 0 12px; + color: var(--text-soft); + font-size: var(--fs-ui); +} + +.animation-variant-label select { + min-width: 0; + max-width: 100%; + min-height: 44px; + padding: 5px 28px 5px 9px; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + background: var(--bg); + color: var(--text); + font: inherit; +} + +.animation-variant-label select:focus-visible, +.animation-play:focus-visible, +.animation-card-actions a:focus-visible { + outline: var(--focus-ring); + outline-offset: 3px; +} + +.animation-card-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 10px 16px; +} + +.animation-play { + display: inline-flex; + align-items: center; + justify-content: center; + width: 44px; + height: 44px; + padding: 0; + border: 1px solid var(--accent); + border-radius: var(--radius-sm); + background: var(--accent); + color: var(--bg); + font: inherit; + font-weight: 600; + cursor: pointer; +} + +.animation-play span { display: inline-flex; } +.animation-play span[hidden] { display: none; } + +.animation-play:hover { + border-color: var(--accent-hover); + background: var(--accent-hover); +} + +.animation-play[aria-pressed="true"] { + background: transparent; + color: var(--text); +} + +.animation-card-actions a { + color: var(--link); + font-size: var(--fs-ui); +} + +@media (max-width: 48em) { + .animation-gallery { + grid-template-columns: minmax(0, 1fr); + gap: 20px; + } + + .animation-card-body p { + min-height: 0; + } +} + +.animation-section { margin-bottom: 36px; } +.animation-name { display: block; margin-top: 5px; color: var(--text-soft); overflow-wrap: anywhere; } +.animation-related { margin-top: 12px; font-size: var(--fs-ui); } +.animation-related summary { cursor: pointer; color: var(--link); } +.animation-related ul { margin: 8px 0 0; padding-left: 18px; overflow-wrap: anywhere; } +.animation-related a { color: var(--link); } +.animation-card-body .animation-error { min-height: 0; color: var(--text); } +.animation-play[hidden], .animation-error[hidden], +.animation-tools[hidden], .animation-timeline[hidden], .animation-empty[hidden] { display: none; } + +.animation-tools { display: flex; flex-wrap: wrap; align-items: end; gap: 16px; margin-bottom: 24px; } +.animation-search { flex: 1 1 320px; display: grid; gap: 6px; } +.animation-search input { width: 100%; min-height: 44px; box-sizing: border-box; padding: 9px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; } +.animation-search input::placeholder { color: var(--text-soft); } +.animation-search input:focus-visible, .animation-timeline input:focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.animation-count { padding-bottom: 12px; color: var(--text-soft); font-size: var(--fs-ui); font-variant-numeric: tabular-nums; } +.animation-timeline { display: flex; align-items: center; gap: 12px; margin-block: 6px 12px; } +.animation-timeline label { flex: 1; min-width: 0; } +.animation-timeline input { display: block; width: 100%; min-height: 44px; margin: 0; cursor: pointer; } +.animation-timeline output { min-width: 13ch; color: var(--text-soft); font-size: var(--fs-ui); font-variant-numeric: tabular-nums; text-align: end; } +.animation-usage { margin-top: 12px; font-size: var(--fs-ui); } +.animation-usage summary, .animation-related summary { display: list-item; box-sizing: border-box; min-height: 44px; padding-block: 10px; cursor: pointer; color: var(--link); } +.animation-usage pre { margin: 8px 0; padding: 12px; border: 1px solid var(--border); border-radius: var(--radius-code); background: var(--code-bg); overflow-wrap: anywhere; white-space: pre-wrap; } +.animation-card-actions a { display: inline-flex; align-items: center; min-height: 44px; } +.animation-variant-label select { font-size: var(--fs-body); } + +@media (prefers-reduced-motion: reduce) { + .animation-card *, .animation-card *::before, .animation-card *::after { transition: none; animation: none; } +} diff --git a/static/css/colors.css b/static/css/colors.css new file mode 100644 index 0000000..48b8dfc --- /dev/null +++ b/static/css/colors.css @@ -0,0 +1,171 @@ +.colors-page .page-main { max-width: 72rem; } +.colors-page [hidden] { display: none !important; } +.colors-page :is(input, textarea, select, button) { font-family: var(--font-sans); } +.colors-page :is(input[type="text"], input[type="search"], textarea, select) { min-height: 44px; padding: 8px 12px; min-width: 0; box-sizing: border-box; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--input-bg); color: var(--text); font-size: var(--fs-ui); caret-color: var(--accent); } +.colors-page :is(button, input, textarea, select, summary, canvas, h2):focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.colors-page :is(input, textarea)[aria-invalid="true"] { border-color: var(--danger); } +.colors-page :is(button, select):disabled { opacity: .5; cursor: default; } +.picker-toolbar, .picker-tools, .picker-section-head, .picker-channel-head, .picker-format-row, .picker-palette-head, .picker-background-row, .picker-main-output { display: flex; align-items: center; gap: 12px; } +.picker-toolbar { justify-content: space-between; align-items: flex-start; } +.picker-source-group { flex: 1; max-width: 640px; min-width: 0; } +.picker-source { display: flex; align-items: center; gap: 12px; min-width: 0; } +.picker-source input { flex: 1; width: 100%; font-family: var(--font-mono) !important; font-size: var(--fs-body) !important; } +.picker-checker { background: conic-gradient(#c4c4c4 25%, #fff 0 50%, #c4c4c4 0 75%, #fff 0) 0 0 / 12px 12px; } +.picker-current { width: 44px; height: 44px; border: 1px solid var(--border); border-radius: var(--radius-sm); overflow: hidden; flex: none; } +.picker-current span { display: block; width: 100%; height: 100%; } +.picker-button, .picker-icon { display: inline-flex; align-items: center; justify-content: center; gap: 8px; min-height: 44px; flex: none; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; padding: 8px 12px; } +.picker-icon { width: 44px; border-color: transparent; padding: 10px; color: var(--text-soft); } +.picker-button:hover:enabled, .picker-icon:hover:enabled { color: var(--accent); background: var(--bg-light); } +.picker-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); } +.picker-primary:hover:enabled { background: var(--accent-hover); border-color: var(--accent-hover); color: var(--bg); } +.picker-field-error { color: var(--danger); font-size: 12px; line-height: 1.5; margin: 6px 0 0; overflow-wrap: anywhere; } +.picker-format > .picker-field-error { grid-column: 2; margin-top: -6px; } +.picker-jumps { display: none; } +.picker-workbench { margin-top: 24px; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); gap: 36px; align-items: start; } +.picker-editor, .picker-result { min-width: 0; } +.picker-section-head { justify-content: space-between; margin-bottom: 14px; flex-wrap: wrap; } +.picker-section-head h2, .picker-palette-head h2, .picker-recent-heading { margin: 0; font-size: var(--fs-body); font-weight: 600; } +.picker-segments { display: flex; border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 0; margin: 0; } +.picker-segments label { display: flex; gap: 6px; align-items: center; cursor: pointer; padding: 8px 12px; min-height: 44px; box-sizing: border-box; font-size: var(--fs-ui); } +.picker-segments label + label { border-left: 1px solid var(--border); } +.picker-segments label:has(:checked) { background: var(--bg-light); color: var(--accent); } +.picker-segments input { margin: 0; accent-color: var(--accent); } +.picker-area { position: relative; height: 280px; border: 1px solid var(--border); border-radius: var(--radius-block); touch-action: none; cursor: crosshair; background: linear-gradient(to top, #000, transparent), linear-gradient(to right, #fff, transparent), var(--hue-color, #f00); } +.picker-area:focus-within { outline: var(--focus-ring); outline-offset: 3px; } +.picker-area.is-wheel { width: 280px; max-width: 100%; aspect-ratio: 1; height: auto; margin-inline: auto; border-radius: 50%; background: linear-gradient(rgb(0 0 0 / var(--wheel-shade, 0)), rgb(0 0 0 / var(--wheel-shade, 0))), radial-gradient(circle closest-side, #fff, transparent), conic-gradient(from 90deg, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); } +.picker-cursor { position: absolute; width: 18px; height: 18px; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 1px 3px #000a; outline: 1px solid #000; transform: translate(-50%, -50%); pointer-events: none; } +.picker-area-meta { display: flex; justify-content: space-between; gap: 12px; color: var(--text-soft); font-size: 12px; margin-top: 8px; } +.picker-area-meta output, .picker-slider output { font-variant-numeric: tabular-nums; } +.picker-slider { display: grid; grid-template-columns: 110px minmax(0, 1fr) 62px; align-items: center; gap: 12px; min-height: 44px; font-size: var(--fs-ui); } +.picker-slider:first-of-type { margin-top: 10px; } +.picker-slider output { color: var(--text-soft); text-align: right; } +.picker-range { display: block; height: 44px; width: 100%; margin: 0; appearance: none; background: transparent; cursor: pointer; } +.picker-range::-webkit-slider-runnable-track { height: 12px; border-radius: 6px; background: var(--range-bg); } +.picker-range::-moz-range-track { height: 12px; border-radius: 6px; background: var(--range-bg); } +.picker-range::-webkit-slider-thumb { appearance: none; width: 20px; height: 20px; margin-top: -4px; border: 2px solid var(--bg); border-radius: 50%; background: var(--text); outline: 1px solid var(--text-soft); } +.picker-range::-moz-range-thumb { width: 16px; height: 16px; border: 2px solid var(--bg); border-radius: 50%; background: var(--text); outline: 1px solid var(--text-soft); } +.picker-hue { --range-bg: linear-gradient(to right, #f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00); } +.picker-alpha-track { height: 12px; border-radius: 6px; } +.picker-alpha-track input { margin-top: -16px; } +.picker-channel-head { justify-content: space-between; margin-top: 16px; margin-bottom: 10px; font-size: var(--fs-ui); } +.picker-channel-head select { min-width: 88px; } +.picker-fields { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); align-items: start; gap: 12px; margin: 0; padding: 0; border: 0; min-width: 0; } +.picker-channel { min-width: 0; } +.picker-fields label { display: grid; gap: 6px; min-width: 0; font-size: 12px; color: var(--text-soft); } +.picker-fields input { width: 100%; font-variant-numeric: tabular-nums; } +.picker-alpha-field { display: flex; align-items: center; gap: 12px; margin-top: 12px; font-size: 12px; color: var(--text-soft); } +.picker-alpha-field input { width: 88px; } +.picker-format { display: grid; grid-template-columns: 96px minmax(0, 1fr); gap: 12px; align-items: center; padding: 6px 0; } +.picker-format label { color: var(--text-soft); font-size: 12px; } +.picker-format-value { display: flex; align-items: flex-start; gap: 6px; min-width: 0; } +.picker-format :is(input, textarea) { width: 100%; flex: 1; font: var(--fs-ui)/1.5 var(--font-mono) !important; } +.colors-page .picker-format textarea { resize: vertical; overflow-wrap: anywhere; white-space: pre-wrap; min-height: 68px; } +.picker-format:first-child input { font-size: 18px !important; background: var(--bg-light); } +.picker-format:first-child .picker-copy { color: var(--accent); } +.picker-help { font-size: 12px; color: var(--text-soft); margin: 8px 0 0; } +.picker-status { min-height: 1.5em; margin: 8px 0; font-size: var(--fs-ui); color: var(--text-soft); } +.picker-copy .lucide-check, .picker-copy.copied .lucide-copy { display: none; } +.picker-copy.copied .lucide-check { display: inline-block; } +.picker-shortcuts-trigger { margin-bottom: 16px; } +.picker-shortcuts { width: min(560px, calc(100% - 32px)); max-height: calc(100dvh - 48px); padding: 24px; box-sizing: border-box; border: 0; border-radius: var(--radius-block); background: var(--bg); color: var(--text); font-size: var(--fs-ui); box-shadow: 0 12px 48px #0004; overflow-y: auto; } +.picker-shortcuts::backdrop { background: rgb(0 0 0 / .6); } +.picker-shortcuts p { color: var(--text-soft); margin: 12px 0; } +.picker-shortcuts .picker-section-head { margin-bottom: 20px; } +.picker-eyedropper-overlay { position: fixed; inset: 0; z-index: 10000; background: rgb(0 0 0 / .6); cursor: crosshair; } +.picker-eyedropper-help { position: absolute; bottom: 24px; left: 50%; transform: translateX(-50%); width: min(360px, calc(100% - 32px)); box-sizing: border-box; padding: 20px 24px; border-radius: var(--radius-block); background: var(--bg); color: var(--text); box-shadow: 0 8px 32px #0004; } +.picker-eyedropper-help h2 { display: flex; align-items: center; gap: 8px; margin: 0 0 14px; font-size: var(--fs-body); } +.picker-eyedropper-help p { display: flex; align-items: center; gap: 16px; margin: 8px 0; font-size: var(--fs-ui); } +.picker-eyedropper-help kbd { min-width: 3em; padding: 4px 6px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-light); text-align: center; font: var(--fs-ui)/1.5 var(--font-mono); } +.picker-contrast { grid-column: 1 / -1; display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr); grid-template-areas: "head head" "sample control" "sample result" "sample note"; gap: 0 36px; min-width: 0; } +.picker-contrast > .picker-section-head { grid-area: head; } +.picker-contrast > .picker-sample { grid-area: sample; } +.picker-background-control { grid-area: control; min-width: 0; } +.picker-contrast-result { grid-area: result; } +.picker-contrast > .picker-help { grid-area: note; } +.picker-contrast .picker-section-head { gap: 8px; } +.picker-contrast select { max-width: 180px; } +.picker-sample { padding: 22px 24px; border: 1px solid var(--border); border-radius: var(--radius-block); min-height: 112px; box-sizing: border-box; } +.picker-sample p { margin: 0; } +#colors-sample-title { font-size: 24px; font-weight: 600; line-height: 1.4; margin-bottom: 8px; } +#colors-sample-text { font-size: var(--fs-ui); } +.picker-background-row { font-size: var(--fs-ui); } +.picker-background-row input { width: 100%; flex: 1; font-family: var(--font-mono); } +.picker-contrast-result { display: flex; align-items: baseline; gap: 12px; margin-top: 12px; } +.picker-contrast-result output { font-size: 22px; font-weight: 600; font-variant-numeric: tabular-nums; } +.picker-contrast-result span { font-size: var(--fs-ui); } +.picker-library { margin-top: 36px; } +.picker-tabs { display: flex; gap: 24px; border-bottom: 1px solid var(--border); overflow-x: auto; } +.picker-tabs button { flex: none; border: 0; border-bottom: 2px solid transparent; min-height: 48px; padding: 10px 0; font: 600 var(--fs-ui)/1.5 var(--font-sans); color: var(--text-soft); background: none; cursor: pointer; } +.picker-tabs button[aria-selected="true"] { color: var(--accent); border-bottom-color: var(--accent); } +.picker-tabs button:hover { color: var(--accent); } +.picker-tab-panel { padding: 20px 0; } +.picker-palette-head { flex-wrap: wrap; margin-bottom: 12px; } +.picker-palette-head > :first-child { margin-right: auto; font-size: var(--fs-ui); } +.picker-palette-head p { margin-block: 0; color: var(--text-soft); } +.picker-palette-head select { max-width: 100%; } +.picker-palette-head:not(:first-child) { margin-top: 24px; } +.picker-harmonies, .picker-tones, .picker-swatches { display: grid; gap: 12px; } +.picker-harmonies { grid-template-columns: repeat(var(--swatch-count, 5), minmax(0, 1fr)); } +.picker-tones { grid-template-columns: repeat(11, minmax(0, 1fr)); } +.picker-swatches { grid-template-columns: repeat(10, minmax(0, 1fr)); } +#colors-swatches { display: block; } +.picker-palette-row { display: grid; grid-template-columns: repeat(3, 32px) minmax(0, 1fr) repeat(3, 32px); } +.picker-palette-row button { min-width: 0; min-height: 44px; border: 0; border-radius: 0; cursor: pointer; } +.picker-palette-color { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 10px 12px; text-align: left; font-size: var(--fs-ui); } +.picker-palette-color span { overflow-wrap: anywhere; } +.picker-palette-color code { flex: none; font: 12px/1.5 var(--font-mono); color: inherit; background: none; padding: 0; } +.picker-palette-row button:hover { box-shadow: inset 0 0 0 2px currentColor; } +.picker-palette-row button[aria-pressed="true"] { box-shadow: inset 0 0 0 2px currentColor; } +.colors-page .picker-palette-row button:focus-visible { position: relative; z-index: 1; outline-offset: -3px; } +.picker-swatch-name { font: 12px/1.5 var(--font-sans); overflow-wrap: anywhere; } +.picker-palette-search { display: block; margin-bottom: 16px; } +.picker-palette-search input { width: 100%; } +.picker-palette-footer { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 12px; } +.picker-palette-footer:has(:not([hidden])) { margin-top: 16px; } +.picker-swatch { display: flex; flex-direction: column; gap: 8px; padding: 0 0 4px; align-items: center; border: 0; background: none; color: var(--text-soft); font: 12px/1.4 var(--font-mono); cursor: pointer; min-width: 0; } +.picker-swatch > span { display: block; width: 100%; height: 48px; border: 1px solid var(--border); border-radius: var(--radius-sm); box-sizing: border-box; } +.picker-harmonies .picker-swatch > span { height: 80px; } +.picker-swatch[aria-pressed="true"] > span { outline: 2px solid var(--accent); outline-offset: 3px; } +.picker-swatch:hover { color: var(--accent); } +.picker-empty { grid-column: 1 / -1; margin: 8px 0; color: var(--text-soft); font-size: var(--fs-ui); } +.picker-recent-heading { margin: 24px 0 14px; } +.picker-recovery { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 16px; margin-bottom: 16px; } +.picker-recovery p { margin: 0; color: var(--text-soft); font-size: var(--fs-ui); } +.picker-image-meta { font: 12px/1.5 var(--font-mono); color: var(--text-soft); margin: -8px 0 16px; } +.picker-image-stage { position: relative; width: fit-content; max-width: 100%; margin: 16px 0; } +.picker-image-stage canvas { display: block; max-width: 100%; max-height: 400px; width: auto; height: auto; cursor: crosshair; touch-action: none; background: conic-gradient(#c4c4c4 25%, #fff 0 50%, #c4c4c4 0 75%, #fff 0) 0 0 / 12px 12px; } +#colors-image-cursor { position: absolute; width: 14px; height: 14px; border: 2px solid #fff; outline: 1px solid #000; border-radius: 50%; transform: translate(-50%, -50%); pointer-events: none; } +@media (max-width: 820px) { + .picker-workbench, .picker-contrast { gap: 24px; } + .picker-format { grid-template-columns: 1fr; gap: 4px; } + .picker-format > .picker-field-error { grid-column: 1; margin-top: 2px; } +} +@media (max-width: 640px) { + .colors-page :is(input[type="text"], input[type="search"], textarea, select) { font-size: var(--fs-body) !important; } + .picker-workbench { grid-template-columns: minmax(0, 1fr); gap: 24px; } + .picker-contrast { display: block; } + .picker-background-row { margin-top: 12px; } + .picker-jumps { display: flex; flex-wrap: wrap; gap: 0 16px; margin-top: 12px; border-bottom: 1px solid var(--border); } + .picker-jumps button { display: inline-flex; align-items: center; min-height: 44px; padding: 0; border: 0; background: none; color: var(--accent); font-size: var(--fs-ui); cursor: pointer; } + .picker-jumps button:hover { color: var(--accent-hover); } + .colors-page h2[id] { scroll-margin-block-start: 140px; } + .picker-tabs { flex-wrap: wrap; gap: 4px 20px; overflow: visible; } + .picker-toolbar { flex-wrap: wrap; gap: 8px; } + .picker-source-group { flex-basis: 100%; } + .picker-tools { margin-left: auto; } + .picker-area { height: 240px; } + .picker-slider { grid-template-columns: 110px minmax(0, 1fr) 52px; gap: 8px; } + .picker-swatches, .picker-tones { grid-template-columns: repeat(5, minmax(0, 1fr)); } + .picker-palette-row { grid-template-columns: repeat(3, 24px) minmax(0, 1fr) repeat(3, 24px); } + .picker-palette-color { flex-wrap: wrap; gap: 2px 8px; padding: 8px; } + .picker-palette-color code { font-size: 11px; } + .picker-shortcuts { padding: 20px; } + .picker-harmonies { gap: 8px; } + .picker-harmonies .picker-swatch { font-size: 12px; } + .picker-harmonies .picker-swatch > span { height: 64px; } + .picker-contrast-result { flex-wrap: wrap; } +} +@media (forced-colors: active) { + .picker-area, .picker-current, .picker-range, .picker-swatch > span, .picker-sample, .picker-alpha-track { forced-color-adjust: none; } + .picker-segments label:has(:checked) { outline: 1px solid Highlight; outline-offset: -1px; } +} diff --git a/static/css/doc.css b/static/css/doc.css index 3158c7f..9c0fdb6 100644 --- a/static/css/doc.css +++ b/static/css/doc.css @@ -30,7 +30,7 @@ h2 { padding-bottom: 0.2em; } -/* h3–h6 had no authored size at all and fell through to the browser’s own +/* h3–h6 had no authored size at all and fell through to the browser's own stylesheet, which sizes them off the parent: h3 landed at 18.7px, h4 at exactly the paragraph size, h5 at 13.3px and h6 at 10.7px — below the Label step. Two of the longest guides in the assets repo carry 23 h3s and @@ -227,16 +227,17 @@ img { .sidebar-all-summary::-webkit-details-marker, .sidebar-contents-summary::-webkit-details-marker { display: none; } .sidebar-all-summary::after, .sidebar-contents-summary::after { content: ''; - width: 6px; - height: 6px; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(-45deg); + width: 18px; + height: 18px; + background: currentColor; + mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; + -webkit-mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; + transform: rotate(0); margin-left: auto; margin-right: 4px; flex: none; } -details[open] > .sidebar-all-summary::after, details[open] > .sidebar-contents-summary::after { transform: rotate(45deg); } +details[open] > .sidebar-all-summary::after, details[open] > .sidebar-contents-summary::after { transform: rotate(90deg); } .sidebar-toc .h3 { margin-left: 12px; } .sidebar-toc .h4 { margin-left: 24px; } .sidebar-toc .h5, .sidebar-toc .h6 { margin-left: 32px; } @@ -541,7 +542,7 @@ details[open] > .sidebar-all-summary::after, details[open] > .sidebar-contents-s } } -/* ── Callouts (info · warning · danger · attention · tip) ── +/* ── Callouts (info / warning / danger / attention / tip) ── Five boxes that differ only in hue, so the shape is written once and each name just declares its three colours. Another callout is one more line here, one per selector list below, plus a `template()` registration in @@ -679,24 +680,25 @@ details[open] > .sidebar-all-summary::after, details[open] > .sidebar-contents-s display: none; } -/* Chevron drawn in CSS rather than shipped as a glyph, so it never lands in +/* Lucide chevron masked in CSS, so it never lands in the summary's text content, and turns to point down when the box opens. */ .details-summary::before { content: ''; flex-shrink: 0; - width: 0.45em; - height: 0.45em; - - border-right: 2px solid var(--accent); - border-bottom: 2px solid var(--accent); - transform: rotate(-45deg); + width: 18px; + height: 18px; + color: var(--accent); + background: currentColor; + mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; + -webkit-mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; + transform: rotate(0); transition: transform 0.15s var(--ease-out); } .details[open] > .details-summary::before { - transform: rotate(45deg); + transform: rotate(90deg); } .details-summary:hover { @@ -719,7 +721,7 @@ details[open] > .sidebar-all-summary::after, details[open] > .sidebar-contents-s .details-body > :first-child { margin-top: 0; } .details-body > :last-child { margin-bottom: 0; } -/* ── Article-status banners (stub · wip · outdated) ─────── +/* ── Article-status banners (stub / wip / outdated) ─────── Page-level, not inline, but built from the same family as the callouts above: a top+bottom hairline in the status colour over the same faint gradient wash, reusing each status's existing -bg/-bg1 tint rather than diff --git a/static/css/icons.css b/static/css/icons.css new file mode 100644 index 0000000..785e162 --- /dev/null +++ b/static/css/icons.css @@ -0,0 +1,16 @@ +/* All authored UI glyphs use the locally vendored Lucide geometry. */ +.lucide { display: inline-block; flex: none; vertical-align: middle; } + +/* Mask backgrounds otherwise become Canvas in Windows high-contrast mode. */ +@media (forced-colors: active) { + :root .sidebar-all-summary::after, + :root .sidebar-contents-summary::after, + :root .details-summary::before, + :root .res-sprite-trigger::after, + :root .fb-crumbs-list li:not(:first-child)::before, + :root .wp-name--copy.copied::after, + :root .tools-warning-details > summary::before { + background: CanvasText; + forced-color-adjust: none; + } +} diff --git a/static/css/main.css b/static/css/main.css index 9aca1dc..cc662ae 100644 --- a/static/css/main.css +++ b/static/css/main.css @@ -123,6 +123,22 @@ html[data-doc-width="wide"] body.scene-page::after { display: none; } +/* The preference only opens the 96rem page cap when the viewport is wider. + Below that, keep the standard scene and hide a control with no effect. */ +@media (max-width: 96rem) { + html[data-doc-width="wide"] .page { + max-width: var(--site-max); + } + + html[data-doc-width="wide"] body.scene-page { + background: transparent; + } + + .site-width-toggle { + display: none; + } +} + @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) body.scene-page { --scene-backdrop: url('/resource/hero/ext_beach_night'); diff --git a/static/css/resources.css b/static/css/resources.css index 3e3aeda..a84c709 100644 --- a/static/css/resources.css +++ b/static/css/resources.css @@ -358,17 +358,16 @@ a.res-thumb:focus-visible { content: ''; display: block; flex: none; - width: 6px; - height: 6px; - margin: -3px 1px 0 0; - border-right: 1.5px solid currentColor; - border-bottom: 1.5px solid currentColor; - transform: rotate(45deg); + width: 14px; + height: 14px; + background: currentColor; + mask: url("/static/icons/chevron-down.svg?v=30636a2a3ae3") center / contain no-repeat; + -webkit-mask: url("/static/icons/chevron-down.svg?v=30636a2a3ae3") center / contain no-repeat; + transform: rotate(0); } .res-sprite-trigger[aria-expanded="true"]::after { - margin-top: 3px; - transform: rotate(225deg); + transform: rotate(180deg); } .res-row--sprite:target:not(.is-stale-target) .res-sprite-trigger { @@ -604,7 +603,7 @@ a.res-file:focus-visible { border-color: var(--accent); } -/* The icons are CSS, not inline SVG: a mask cut from a data-URI SVG and +/* The icons are CSS, not inline SVG: a mask from the shared local Lucide SVG and filled with the button's own colour, so hover, copied and playing recolour it exactly as they recoloured the SVG's currentColor. Three inline SVGs a row were 1.09 MB of the 1 162-row sprite listing's 2.3 MB of HTML and @@ -622,11 +621,11 @@ a.res-file:focus-visible { forced-color-adjust: none; } -.res-copy.res-icon { --res-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='%23000' stroke-width='1.3'%3E%3Crect x='4.5' y='4.5' width='8' height='8' rx='1.5'/%3E%3Cpath d='M9.5 3V2.5A1.5 1.5 0 0 0 8 1H2.5A1.5 1.5 0 0 0 1 2.5V8a1.5 1.5 0 0 0 1.5 1.5H3'/%3E%3C/svg%3E"); } -.res-copy.res-icon.copied { --res-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='%23000' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M2 7.5L5.5 11L12 3.5'/%3E%3C/svg%3E"); } -.res-dl.res-icon { --res-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14' fill='none' stroke='%23000' stroke-width='1.4' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M7 1.5v6.8M4.2 5.6 7 8.4l2.8-2.8'/%3E%3Cpath d='M1.8 10.2v.8a1.5 1.5 0 0 0 1.5 1.5h7.4a1.5 1.5 0 0 0 1.5-1.5v-.8'/%3E%3C/svg%3E"); } -.res-play.res-icon { --res-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Cpath d='M3.5 2.2a.6.6 0 0 1 .9-.52l7.2 4.3a.6.6 0 0 1 0 1.04l-7.2 4.3a.6.6 0 0 1-.9-.52z'/%3E%3C/svg%3E"); } -.res-play.res-icon[aria-pressed="true"] { --res-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 14 14'%3E%3Crect x='2.8' y='2' width='3.2' height='10' rx='0.8'/%3E%3Crect x='8' y='2' width='3.2' height='10' rx='0.8'/%3E%3C/svg%3E"); } +.res-copy.res-icon { --res-icon: url("/static/icons/copy.svg?v=c7a383a5e346"); } +.res-copy.res-icon.copied { --res-icon: url("/static/icons/check.svg?v=b931ad1ca897"); } +.res-dl.res-icon { --res-icon: url("/static/icons/download.svg?v=dc340b341cca"); } +.res-play.res-icon { --res-icon: url("/static/icons/play.svg?v=e2998c79f62c"); } +.res-play.res-icon[aria-pressed="true"] { --res-icon: url("/static/icons/pause.svg?v=3691b33f27ff"); } /* ── Characters (speaking cast) ──────────────────────────── */ @@ -819,9 +818,13 @@ a.res-file:focus-visible { } .fb-crumbs-list li:not(:first-child)::before { - /* Empty alt text (the '/ ""') keeps the glyph visual-only, so screen - readers announce the list structure without voicing "›" each step. */ - content: '›' / ''; + content: ''; + width: 14px; + height: 14px; + align-self: center; + background: currentColor; + mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; + -webkit-mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; margin: 0 8px; color: var(--text-soft); font-size: var(--fs-ui); @@ -1243,3 +1246,38 @@ video.fb-media { .res-tabs, .res-cats ul { padding-inline: max(var(--page-gutter), calc((100% - var(--directory-max)) / 2 + var(--page-gutter))); } + +.fb-animation-link { + position: relative; + display: flex; + flex-direction: column; + gap: 3px; + margin-bottom: 24px; + padding: 14px 52px 14px 16px; + border: 1px solid var(--border); + border-radius: var(--radius-block); + background: var(--bg-light); + color: var(--text-soft); + text-decoration: none; +} + +.fb-animation-link:hover .fb-animation-link-title { + color: var(--link-hover); +} + +.fb-animation-link-title { + color: var(--text); + font-family: var(--font-serif); + font-size: 1.1rem; + font-weight: 700; +} + +.fb-animation-link-arrow { + position: absolute; + top: 50%; + right: 16px; + display: grid; + place-items: center; + color: var(--link); + transform: translateY(-50%); +} diff --git a/static/css/tools.css b/static/css/tools.css new file mode 100644 index 0000000..db93d21 --- /dev/null +++ b/static/css/tools.css @@ -0,0 +1,181 @@ +.tools-page .page-main { max-width: 66rem; } +.tools-heading h2, .tools-links h2, #site-tools-heading { font-family: var(--font-serif); font-size: var(--fs-subtitle); font-weight: 700; margin: 0 0 8px; } +.tools-heading p { color: var(--text-soft); margin: 0 0 24px; } +.tools-drop { border: 1px dashed var(--border); padding: 30px 20px 22px; text-align: center; background: var(--bg-light); border-radius: var(--radius-sm); } +.tools-drop.is-dragging { border-color: var(--accent); border-style: solid; background: var(--accent-muted); } +.tools-drop-icon { color: var(--text-soft); margin-bottom: 10px; } +.tools-drop-title { margin: 0 0 6px; font-size: var(--fs-body); font-weight: 600; } +.tools-drop p:not(.tools-drop-title) { color: var(--text-soft); font-size: var(--fs-ui); } +.tools-button { display: inline-flex; justify-content: center; align-items: center; gap: 8px; min-height: 44px; padding: 10px 18px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; font-size: var(--fs-ui); font-weight: 600; cursor: pointer; text-decoration: none; } +.tools-button:hover { border-color: var(--accent); color: var(--accent); text-decoration: none; } +.tools-button-primary { background: var(--accent); border-color: var(--accent); color: var(--bg); } +.tools-button-primary:hover { background: var(--accent-hover); color: var(--bg); } +.tools-button:disabled { background: var(--bg-light); color: var(--text-soft); border-color: var(--border); cursor: default; } +.tools-page :is(button, a, input, summary, .tools-drop):focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.tools-add-menu { position: fixed; z-index: 110; min-width: 210px; padding: 6px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.tools-add-menu button { display: block; width: 100%; min-height: 44px; padding: 10px 14px; background: none; border: 0; text-align: start; color: var(--text); font: inherit; font-size: var(--fs-ui); cursor: pointer; } +.tools-add-menu button:hover { background: var(--bg-light); } +.tools-options { margin-block: 18px 24px; font-size: var(--fs-ui); } +.tools-options label { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-height: 36px; } +.tools-options span { color: var(--text-soft); } +.tools-advanced, .tools-help { border-block: 1px solid var(--border); } +.tools-advanced summary, .tools-help summary { display: flex; align-items: center; gap: 10px; cursor: pointer; min-height: 48px; padding: 10px 0; list-style: none; color: var(--text); font-weight: 600; } +.tools-advanced summary::-webkit-details-marker, .tools-help summary::-webkit-details-marker { display: none; } +.tools-advanced summary:hover, .tools-help summary:hover { color: var(--accent); } +.tools-advanced summary .tools-icon-chevron, .tools-help summary .tools-icon-chevron { margin-left: auto; } +.tools-advanced[open] .tools-icon-chevron, .tools-help[open] .tools-icon-chevron { transform: rotate(180deg); } +.tools-disclosure-body { padding: 4px 0 16px; } +.tools-options label { min-height: 44px; font-weight: 600; } +.tools-advanced p { margin: 0 0 8px 24px; color: var(--text-soft); } +.tools-advanced .tools-disclosure-body > p:first-child { margin-inline-start: 0; } +.tools-queue-heading { display: flex; justify-content: space-between; gap: 12px; align-items: center; } +.tools-queue-summary { display: flex; align-items: baseline; flex-wrap: wrap; gap: 4px 16px; min-width: 0; } +.tools-queue-summary .tools-meta { margin: 0; } +.tools-clear { display: inline-flex; align-items: center; justify-content: center; flex: none; width: 44px; height: 44px; padding: 0; border: 0; border-radius: var(--radius-sm); background: none; color: var(--text-soft); cursor: pointer; } +.tools-clear:hover:enabled { color: var(--danger); background: var(--bg-light); } +.tools-clear:disabled { opacity: 0.5; cursor: default; } +.tools-queue h3 { font-size: var(--fs-body); margin: 0; } +.tools-text-button { font: inherit; font-size: var(--fs-ui); border: 0; background: none; color: var(--accent); padding: 10px 4px; min-height: 44px; cursor: pointer; } +.tools-meta { font-size: var(--fs-ui); color: var(--text-soft); margin: 4px 0; font-variant-numeric: tabular-nums; } +.tools-file-list { list-style: none; padding: 0; margin: 12px 0 20px; border-block: 1px solid var(--border); max-height: 320px; overflow-y: auto; scrollbar-color: var(--border) var(--bg); } +.tools-file-list li { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 12px; min-height: 52px; padding: 6px 4px; } +.tools-file-list li + li { border-top: 1px solid var(--border); } +.tools-file-name { overflow-wrap: anywhere; font-size: var(--fs-ui); } +.tools-file-size { font-size: var(--fs-ui); color: var(--text-soft); font-variant-numeric: tabular-nums; } +.tools-file-state { display: block; color: var(--text-soft); font-size: var(--fs-ui); } +.tools-file-state.is-error { color: var(--danger); } +.tools-remove { border: 0; background: none; color: var(--text-soft); padding: 8px; width: 44px; height: 44px; cursor: pointer; } +.tools-remove:hover { color: var(--accent); } +.tools-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 12px 16px; margin-top: 20px; } +.tools-actions:not(:has(button:not([hidden]))) { display: none; } +.tools-progress { margin-top: 20px; } +.tools-progress progress { width: 100%; height: 8px; accent-color: var(--accent); } +.tools-current, #tools-current { overflow-wrap: anywhere; } +.tools-status { margin: 14px 0 0; font-size: var(--fs-ui); white-space: pre-line; overflow-wrap: anywhere; } +.tools-status.is-error { color: var(--danger); } +.tools-errors { margin-top: 12px; } +.tools-errors .tools-diagnostic + .tools-diagnostic { margin-top: 20px; } +.tools-diagnostic { min-width: 0; overflow-wrap: anywhere; } +.tools-diagnostic > p { color: var(--text-soft); font-size: var(--fs-ui); } +.tools-diagnostic > .tools-button { margin-top: 12px; } +.tools-status.is-error::before, .tools-file-state.is-error::before { content: ''; display: inline-block; width: 18px; height: 18px; margin-inline-end: 8px; vertical-align: -3px; background: currentColor; mask: var(--tools-error-icon) center / contain no-repeat; -webkit-mask: var(--tools-error-icon) center / contain no-repeat; } +.tools-status, .tools-file-state { --tools-error-icon: url("/static/icons/octagon-alert.svg?v=e30c13735bff"); } +.tools-result { padding: 0 0 20px; margin-top: 20px; overflow-anchor: none; } +.tools-result > p:empty { display: none; } +.tools-result p { color: var(--text-soft); font-size: var(--fs-ui); margin-top: 12px; } +.tools-downloads { display: flex; flex-wrap: wrap; gap: 12px; } +.tools-downloads a { overflow-wrap: anywhere; min-width: 0; } +.tools-export { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 20px; padding-top: 20px; border-top: 1px solid var(--border); } +.tools-warning-host { position: relative; min-width: 0; } +.tools-warning-host > :is(p, h4) { min-width: 0; margin: 0; } +.tools-warning-host:has(> .tools-warnings:not([hidden])) > :is(p, h4) { display: flex; align-items: center; min-height: 44px; padding-inline-end: 100px; } +.tools-warning-host > p:empty { display: none; } +.tools-preview-heading { margin-bottom: 14px; } +.tools-warnings { min-width: 0; font-size: var(--fs-ui); } +.tools-warnings > summary { position: absolute; inset-block-start: 0; inset-inline-end: 0; display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); color: var(--attention); font-weight: 600; cursor: pointer; list-style: none; } +.tools-warnings > summary::-webkit-details-marker { display: none; } +.tools-warnings > summary:hover, .tools-warnings[open] > summary { background: var(--attention-bg1); } +.tools-warning-count { min-width: 2ch; text-align: center; font-variant-numeric: tabular-nums; } +.tools-warnings > summary .tools-icon-chevron { width: 14px; height: 14px; color: var(--text-soft); } +.tools-warnings[open] > summary .tools-icon-chevron { transform: rotate(180deg); } +.tools-warnings ul { max-height: 320px; overflow-y: auto; margin: 10px 0 0; padding: 12px 8px 12px 24px; border-block: 1px solid var(--border); color: var(--text); overflow-wrap: anywhere; } +.tools-warnings li + li { margin-top: 16px; } +.tools-diagnostic-path { display: block; margin-bottom: 4px; color: var(--text-soft); font-family: var(--font-mono); } +.tools-diagnostic > strong { display: block; font-weight: 600; } +.tools-diagnostic .tools-diagnostic-explanation { margin: 4px 0 0; color: var(--text); } +.tools-warning-details { margin-top: 4px; } +.tools-warning-details > summary { display: flex; align-items: center; gap: 4px; min-height: 44px; padding: 10px 0; color: var(--text-soft); cursor: pointer; list-style: none; } +.tools-warning-details > summary::-webkit-details-marker { display: none; } +.tools-warning-details > summary::before { content: ""; flex: none; width: 14px; height: 14px; background: currentColor; mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; -webkit-mask: url("/static/icons/chevron-right.svg?v=5fab34b14d1b") center / contain no-repeat; } +.tools-warning-details[open] > summary::before { transform: rotate(90deg); } +.tools-warning-details > summary:hover { color: var(--accent); } +.tools-warning-details .tools-warning-original { margin: 0; color: var(--text-soft); font-family: var(--font-mono); white-space: pre-wrap; max-height: 240px; overflow: auto; } +.tools-warnings-more { color: var(--text-soft); list-style: none; } +.tools-warnings ul:focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.tools-browse-actions { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-top: 18px; } +.tools-browse-actions label { display: grid; gap: 4px; font-size: var(--fs-ui); } +.tools-browse-actions select, .tools-browser-search { min-height: 44px; padding: 8px 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg); color: var(--text); font: inherit; } +.tools-browser { margin-top: 22px; border-top: 1px solid var(--border); padding-top: 20px; } +.tools-browser-head { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; gap: 12px; } +.tools-browser-head h3 { margin: 0; font-size: var(--fs-body); overflow-wrap: anywhere; } +.tools-browser-search { width: min(100%, 280px); min-width: 0; font-size: var(--fs-ui); caret-color: var(--accent); } +.tools-browser-search::placeholder { color: var(--text-soft); opacity: 1; } +.tools-browser-crumbs { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 14px; } +.tools-browser-crumbs button { border: 0; background: none; color: var(--accent); font: inherit; cursor: pointer; min-width: 44px; min-height: 44px; padding: 8px; overflow-wrap: anywhere; max-width: 100%; } +.tools-browser-crumbs button + button::before { content: '/'; margin-right: 10px; color: var(--text-soft); } +.tools-browser-crumbs button:disabled { color: var(--text-soft); cursor: default; } +.tools-browser-layout { display: grid; grid-template-columns: minmax(190px, 0.7fr) minmax(0, 1.7fr); gap: 20px; min-height: 320px; } +.tools-browser-list { list-style: none; margin: 0; padding: 0; max-height: 460px; overflow: auto; border-block: 1px solid var(--border); } +.tools-browser-list li { display: flex; align-items: center; justify-content: space-between; gap: 10px; border-bottom: 1px solid var(--border); min-height: 44px; } +.tools-browser-list li button { display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0; min-height: 44px; border: 0; background: none; color: var(--text); text-align: left; overflow-wrap: anywhere; padding: 10px 4px; font: inherit; font-size: var(--fs-ui); cursor: pointer; } +.tools-browser-list li button:hover { color: var(--accent); } +.tools-browser-list li:has([aria-current]) { background: var(--accent-muted); } +.tools-browser-list li button[aria-current] { font-weight: 600; } +.tools-browser-list li button:disabled { cursor: default; } +.tools-browser-list li > span { flex: none; color: var(--text-soft); font-size: var(--fs-ui); padding-right: 4px; font-variant-numeric: tabular-nums; } +.tools-browser-list li button span { min-width: 0; } +.tools-browser-list li .tools-remove-source { flex: none; width: 44px; padding: 0; justify-content: center; } +.tools-browser-list .tools-list-empty { padding: 12px 4px; color: var(--text-soft); font-size: var(--fs-ui); } +.tools-icon-folder { color: var(--code-inline); } +.tools-icon-code { color: var(--info); } +.tools-icon-image { color: var(--pine); } +.tools-browser-preview { min-width: 0; border-left: 1px solid var(--border); padding-left: 20px; overflow: auto; } +.tools-browser-preview h4 { margin: 0 0 14px; font-size: var(--fs-ui); overflow-wrap: anywhere; } +.tools-preview-heading h4 { margin: 0; } +.tools-browser-preview img, .tools-browser-preview video { display: block; max-width: 100%; max-height: 380px; object-fit: contain; margin-bottom: 14px; } +.tools-browser-preview audio { display: block; width: 100%; margin-bottom: 14px; } +.tools-code-frame { margin-bottom: 16px; } +.tools-code-frame pre { max-height: 440px; overflow-y: auto; margin: 0; font-size: var(--fs-ui); line-height: var(--lh-ui); align-items: flex-start; } +.tools-code-frame pre:focus-visible { outline: var(--focus-ring); outline-offset: -2px; } +/* Keep both columns at their content height. Stretching the code column to + the capped pre creates another vertical scrollport and detaches its rows + from the gutter. The pre owns vertical scroll; code-scroll owns horizontal. */ +.tools-preview-pending { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px; min-height: 260px; } +.tools-browser-preview .tools-preview-pending > p { margin: 0; color: var(--text-soft); } +.tools-browser-preview .tools-preview-empty { display: flex; justify-content: center; align-items: center; min-height: 260px; margin: 0; color: var(--text-soft); } +.tools-browser-preview p { margin: 0 0 14px; } +.tools-font-specimen { padding: 16px 0; overflow-wrap: anywhere; } +.tools-font-specimen p:first-child { font-size: var(--fs-subtitle); } +.tools-help { margin-top: 24px; font-size: var(--fs-ui); color: var(--text-soft); } +.tools-ui-icon { display: inline-block; flex: none; width: 18px; height: 18px; background: currentColor; mask: var(--tools-icon) center / contain no-repeat; -webkit-mask: var(--tools-icon) center / contain no-repeat; } +.tools-icon-folder { --tools-icon: url("/static/icons/folder.svg?v=6899b648f6bb"); } +.tools-icon-file { --tools-icon: url("/static/icons/file.svg?v=98ad89e2f7bd"); } +.tools-icon-code { --tools-icon: url("/static/icons/code-xml.svg?v=cac022efd0b2"); } +.tools-icon-image { --tools-icon: url("/static/icons/image.svg?v=5c89fd0f56ec"); } +.tools-icon-media { --tools-icon: url("/static/icons/circle-play.svg?v=98d556c0a7b9"); } +.tools-icon-chevron { --tools-icon: url("/static/icons/chevron-down.svg?v=30636a2a3ae3"); } +.tools-icon-settings { --tools-icon: url("/static/icons/sliders-horizontal.svg?v=290eb14a87d9"); } +.tools-icon-info { --tools-icon: url("/static/icons/info.svg?v=74879420748e"); } +.tools-icon-warning { --tools-icon: url("/static/icons/circle-alert.svg?v=0ee8ee11f858"); } +.tools-icon-download { --tools-icon: url("/static/icons/download.svg?v=dc340b341cca"); } +.tools-icon-close { --tools-icon: url("/static/icons/x.svg?v=686a1544c0a3"); } +.tools-help p { max-width: 75ch; } +.tools-help a { text-decoration: underline; text-underline-offset: 3px; } +.tools-links { margin-top: 48px; } +.tools-link-list { list-style: none; padding: 0; margin: 18px 0 0; } +.tools-link-list li { border-bottom: 1px solid var(--border); } +.tools-link-list a { display: grid; grid-template-columns: minmax(180px, 1fr) minmax(0, 1.4fr) auto; gap: 24px; align-items: center; padding: 18px 0; text-decoration: none; color: var(--text); } +.tools-link-list strong { font-weight: 600; font-size: var(--fs-body); } +.tools-link-list span { font-size: var(--fs-ui); color: var(--text-soft); } +.tools-link-list a:hover strong { color: var(--accent); } +.tools-page [hidden] { display: none !important; } +@media (max-width: 640px) { + .tools-drop { padding: 24px 14px 20px; } + .tools-link-list a { grid-template-columns: 1fr auto; gap: 4px 12px; } + .tools-link-list a span:nth-child(2) { grid-column: 1; } + .tools-link-list a span:last-child { grid-column: 2; grid-row: 1 / 3; } + .tools-actions > .tools-meta { flex-basis: 100%; } + .tools-file-list li { gap: 8px; } + .tools-browser-layout { grid-template-columns: 1fr; } + .tools-browser-preview { border-left: 0; border-top: 1px solid var(--border); padding: 16px 0 0; } + .tools-browser-list { max-height: 240px; } + .tools-browser-search { width: 100%; } + .tools-export > button { width: 100%; } +} +@media (forced-colors: active) { + .tools-button-primary { border: 1px solid ButtonText; } + .tools-drop.is-dragging { outline: 2px solid Highlight; } + .tools-browser-list li:has([aria-current]) { outline: 1px solid Highlight; outline-offset: -1px; } + .tools-ui-icon { background: ButtonText; forced-color-adjust: none; } + .tools-status.is-error::before, .tools-file-state.is-error::before { background: CanvasText; forced-color-adjust: none; } +} diff --git a/static/css/vars.css b/static/css/vars.css index 9a238a9..da0a966 100644 --- a/static/css/vars.css +++ b/static/css/vars.css @@ -31,7 +31,7 @@ --rail-width: 14rem; color-scheme: light dark; - /* ── Surfaces (Light · a white shirt, the leaf's shade) ── */ + /* ── Surfaces (Light / a white shirt, the leaf's shade) ── */ /* White is the brand's own colour here, not a missing tint: the logo is drawn on it. It is the page and the things that sit on the page — cards, controls, popovers — and the only pure neutral in the file. */ @@ -79,7 +79,7 @@ /* ── Lines ────────────────────────────────────────────── */ --border: #D1DDCD; - /* ── Code (Light · a leaf-paper sheet on a white desk) ─── */ + /* ── Code (Light / a leaf-paper sheet on a white desk) ─── */ /* The fenced panel takes the leaf paper and leans on its 1px border, the way blockquotes do; inline code needs to read as a chip inside a line of prose, so it takes a greyer, deeper chip of its own. */ @@ -192,7 +192,7 @@ heading lines overlapped their own em boxes. Cyrillic makes that visible where Latin hides it — Ё and Й carry marks above cap height while у, р, ц, щ and д descend, so a two-line heading left 2.5px - between one line’s descenders and the next line’s diaereses. */ + between one line's descenders and the next line's diaereses. */ --lh-flat: 1; /* glyphs and icons, where the box is the glyph */ --lh-heading: 1.35; /* every heading and title-shaped thing */ --lh-ui: 1.5; /* dense chrome, table cells, short descriptions */ diff --git a/static/css/warpers.css b/static/css/warpers.css index 9458f49..8dccca5 100644 --- a/static/css/warpers.css +++ b/static/css/warpers.css @@ -1,533 +1,135 @@ -/* ===================================================================== - Warper reference (/warpers) — the naming legend and the preview matrix. - - The page is a lookup table: ten families × three prefixes, plus three - special cases. The previews repeat because the data repeats, so they - carry no card chrome of their own — a 1px frame around the plot, the - name under it, nothing else. Column and row labels do the explaining. - ===================================================================== */ - -/* Wider than the site's prose column: the matrix is four columns of plots. - Tighter on top for the same reason .page-main--res is — tabs and the - category strip already sit above it. */ -.wp-main { - width: 100%; - margin-inline: auto; - padding: 28px var(--page-gutter) 48px; - max-width: var(--directory-max); -} - -.wp-section + .wp-section { - margin-top: 64px; -} - -/* Running text stays inside a comfortable measure even though the matrix - below it spans the full 1100px column. */ -.wp-prose { - max-width: 68ch; -} - -.wp-prose p { - margin: 1em 0; - line-height: var(--lh-prose); - color: var(--text); -} - -.wp-prose p:first-of-type { - margin-top: 0; -} - -.wp-prose .code-block { - margin: 22px 0; -} - -.wp-section-note { - margin: -4px 0 28px; - font-size: var(--fs-ui); - line-height: var(--lh-prose); - color: var(--text-soft); -} - -/* How to drive the previews — a quiet aside under the usage samples, not a - callout box: it explains the page's controls, not the subject. */ -.wp-hint { - font-size: var(--fs-ui); - color: var(--text-soft); -} - -/* Attribution under a community warper. The name above it stays the copy - button; this line is the only place the contributor is credited. */ -.wp-cell-by { - font-size: var(--fs-label); - color: var(--text-soft); -} - -.wp-cell-by a { - color: var(--accent); - text-decoration: none; - transition: color 0.15s var(--ease-out); -} - -.wp-cell-by a:hover { - color: var(--accent-hover); - text-decoration: underline; -} - -.wp-cell-by a:focus-visible { - outline: var(--focus-ring); - outline-offset: 2px; - border-radius: var(--radius-xs); -} - -/* ── Sandbox ─────────────────────────────────────────────── */ - -/* The stage is a window onto a game background, so it takes the game's own - 16:9 and clips: the image inside is overscanned and moves under it. */ -.wp-lab-stage { - overflow: hidden; - aspect-ratio: 16 / 9; - max-width: 760px; - background: var(--bg-light); - border: 1px solid var(--border); - border-radius: var(--radius-sm); -} - -.wp-lab-img { - display: block; - width: 100%; - height: 100%; - object-fit: cover; - /* Every frame is written by warpers.js; no CSS transition may smooth - over it, or the curve on screen stops being the curve you picked. */ - transition: none; -} - -.wp-lab-controls { - display: flex; - flex-wrap: wrap; - align-items: flex-end; - gap: 12px 16px; - max-width: 760px; - margin-top: 16px; -} - -.wp-lab-field { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - flex: 1 1 180px; -} - -.wp-lab-field--time { - flex: 0 1 110px; -} - -/* components.css gives .filter-input `flex: 1 1 200px` for the horizontal - filter bars it was built for. Here the label sits above the control, so - the field is a column — and that 200px basis becomes a 200px *height*, - overriding the input's own. Hand the height back. */ -.wp-lab-field .filter-input { - flex: none; -} - -.wp-lab-label { - font-family: var(--font-mono); - font-size: var(--fs-label); - font-weight: 600; - letter-spacing: 0.1em; - text-transform: uppercase; - color: var(--text-soft); -} - -/* The page's one call to action, so it carries the accent at rest — the - previews only spend the leaf under the cursor, leaving the spotlight free. */ -.wp-lab-play { - display: inline-flex; - align-items: center; - gap: 8px; - height: 36px; - padding: 0 16px; - font-family: var(--font-sans); - font-size: var(--fs-ui); - font-weight: 600; - color: var(--accent); - background: none; - border: 1px solid var(--accent); - border-radius: var(--radius-sm); - cursor: pointer; - transition: color 0.15s var(--ease-out), background-color 0.15s var(--ease-out); -} - -.wp-lab-play:hover { - color: var(--bg); - background: var(--accent); -} - -.wp-lab-play:focus-visible { - outline: var(--focus-ring); - outline-offset: 2px; -} - -/* ── Generator ───────────────────────────────────────────── */ - -.wp-lab-formula { - display: flex; - align-items: flex-start; - gap: 16px; - max-width: 760px; - margin-top: 16px; - padding-top: 16px; - border-top: 1px solid var(--border); -} - -.wp-lab-formula-fields { - display: flex; - flex-wrap: wrap; - align-items: flex-start; - gap: 12px 16px; - flex: 1 1 auto; - min-width: 0; -} - -.wp-lab-formula-fields .wp-lab-field { - flex: 1 1 220px; -} - -.wp-lab-field--name { - flex: 0 1 160px; -} - -/* Formulas are code: they read in the mono face, like every other literal - on the site. */ -#wp-lab-expr, -#wp-lab-name { - font-family: var(--font-mono); -} - -#wp-lab-expr[aria-invalid], -#wp-lab-name[aria-invalid] { - border-color: var(--warning); -} - -/* One line that's the hint most of the time and the parser's complaint when - there is one — same slot either way, so nothing jumps as you type. */ -.wp-lab-note { - flex-basis: 100%; - margin: 0; - font-size: var(--fs-label); - line-height: var(--lh-ui); - color: var(--text-soft); -} - -.wp-lab-note.is-error { - color: var(--warning); -} - -.wp-lab-curve { - display: block; - flex: 0 0 auto; - width: 148px; - aspect-ratio: 1 / 1; - background: var(--bg-light); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - transition: border-color 0.15s var(--ease-out); -} - -.wp-lab-curve:hover { - border-color: var(--accent); -} - -.wp-lab-code { - max-width: 760px; - margin-top: 16px; -} - -/* The generated line is short and fixed-height; no scrollbar should flicker - in and out as the warper name changes length. */ -.wp-lab-code pre { - overflow-x: auto; -} - -/* ── Naming legend ───────────────────────────────────────── */ - -.wp-legend { - margin: 0; - border-top: 1px solid var(--border); -} - -.wp-legend-row { - display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 4px 14px; - padding: 12px 0; - border-bottom: 1px solid var(--border); -} - -.wp-legend dt { - flex-shrink: 0; -} - -.wp-legend dd { - margin: 0; - color: var(--text-soft); - line-height: var(--lh-prose); -} - -.wp-legend b { - color: var(--text); - font-weight: 600; -} - -/* ── Matrix ──────────────────────────────────────────────── */ - -.wp-matrix { - display: grid; - grid-template-columns: 210px repeat(3, minmax(0, 1fr)); - gap: 44px 28px; - align-items: start; -} - -.wp-specials { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 32px 28px; -} - -/* Column heading sits on the baseline of the first row of plots. */ -.wp-col { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 8px; - align-self: end; - padding-bottom: 10px; - border-bottom: 1px solid var(--border); -} - -.wp-col-name { - font-size: var(--fs-label); -} - -.wp-col-desc { - font-size: var(--fs-label); - line-height: var(--lh-ui); - color: var(--text-soft); -} - -.wp-row-label { - display: flex; - flex-wrap: wrap; - align-items: baseline; - column-gap: 12px; -} - -.wp-family { - margin: 0; - font-family: var(--font-serif); - font-size: var(--fs-lead); - font-weight: 700; - line-height: var(--lh-heading); - color: var(--text); -} - -.wp-formula { - font-size: var(--fs-label); -} - -/* warpers.js promotes the chip to a copy control; the ring is inset so - nothing shifts when it appears. What it hands over is the code form - (`t ** 2`), not the typeset one it displays. */ -.wp-formula[role="button"] { - cursor: pointer; - transition: box-shadow 0.15s var(--ease-out), color 0.15s var(--ease-out); -} - -.wp-formula[role="button"]:hover { - box-shadow: inset 0 0 0 1px var(--code-inline); -} - -.wp-formula[role="button"]:focus-visible { - outline: var(--focus-ring); - outline-offset: 2px; -} - -.wp-formula.copied { - color: var(--code-success); - box-shadow: inset 0 0 0 1px var(--code-success); -} - -.wp-family-note { - flex-basis: 100%; - max-width: 62ch; - margin: 10px 0 0; - font-size: var(--fs-ui); - line-height: var(--lh-ui); - color: var(--text-soft); -} - -/* ── Preview cell ────────────────────────────────────────── */ - -.wp-cell { - display: flex; - flex-direction: column; - align-items: flex-start; - gap: 10px; - min-width: 0; -} - -/* Square on purpose: time along the bottom and value up the right side get - the same length, so the curve isn't stretched into looking gentler or - sharper than it is. */ -.wp-plot { - display: block; - width: 100%; - aspect-ratio: 1 / 1; - background: var(--bg-light); - border: 1px solid var(--border); - border-radius: var(--radius-sm); - transition: border-color 0.15s var(--ease-out); -} - -/* The frame warms with the curve you're pointing at — the marker inside is - drawn in the same accent, and only ever on one preview at a time. */ -.wp-cell:hover .wp-plot, -.wp-cell:focus-within .wp-plot, -.wp-cell:target .wp-plot { - border-color: var(--accent); -} - -/* The plot copies its registered easing name when warpers.js promotes it; - without JS it stays a plain picture. */ -.wp-plot[role="button"] { - cursor: pointer; -} - -.wp-plot:focus-visible { - outline: var(--focus-ring); - outline-offset: 2px; -} - -/* After the hover rule above, and one step more specific, so the confirmation - still reads while the cursor is on the plot that was just copied. */ -.wp-cell:hover .wp-plot.copied, -.wp-cell .wp-plot.copied { - border-color: var(--pine); -} - -/* The name is what people come here to take away, so it reads at body size - rather than as a caption under the plot. */ -.wp-name { - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-family: var(--font-mono); - font-size: var(--fs-body); - color: var(--text); -} - -/* warpers.js upgrades the plain label into this button when there's a - clipboard to copy into; without JS the name stays plain text. */ -.wp-name--copy { - padding: 4px 8px; - margin-left: -8px; - background: none; - border: 1px solid transparent; - border-radius: var(--radius-xs); - cursor: pointer; - transition: color 0.15s var(--ease-out), border-color 0.15s var(--ease-out); -} - -.wp-name--copy:hover { - color: var(--accent); - border-color: var(--border); -} - -.wp-name--copy:focus-visible { - outline: var(--focus-ring); - outline-offset: 2px; -} - -.wp-name--copy.copied { - color: var(--pine); - border-color: var(--pine); -} - -.wp-name--copy.copied::after { - content: " ✓"; -} - -.wp-cell-desc { - font-size: var(--fs-ui); - line-height: var(--lh-ui); - color: var(--text-soft); -} - -/* ── Responsive ──────────────────────────────────────────── */ - -/* Below the full matrix width the label column would squeeze the plots, so - the family label becomes a heading over its own row of three. The prefix - headings go with it — the legend above already carries what they mean. */ -@media (max-width: 62.5em) { - .wp-matrix { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 20px 24px; - } - - .wp-corner, - .wp-col { - display: none; - } - - .wp-row-label { - grid-column: 1 / -1; - margin-top: 22px; - } -} - -@media (max-width: 43.75em) { - .wp-matrix, - .wp-specials { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } -} - -@media (max-width: 40em) { - .wp-main { - padding: 24px 20px 40px; - } - - /* Match the 44px touch height components.css gives the filter controls - this button stands next to. */ - .wp-lab-play { - height: 44px; - } - - /* The curve drops under its own fields rather than squeezing them. */ - .wp-lab-formula { - flex-wrap: wrap; - } -} - -@media (max-width: 28.75em) { - .wp-matrix, - .wp-specials { - grid-template-columns: minmax(0, 1fr); - } - - .wp-cell { - max-width: 320px; - } -} - -@media (prefers-reduced-motion: reduce) { - .wp-plot, - .wp-name--copy, - .wp-cell-by a, - .wp-lab-play { - transition: none; - } -} +/* Curve catalogue and ATL workbench share the site's paper, ink and leaf. */ +.wp-main { width: 100%; min-width: 0; max-width: var(--directory-max); margin-inline: auto; padding: 16px var(--page-gutter) 48px; } +.wp-section + .wp-section { margin-top: 48px; } +.wp-prose { max-width: 68ch; } +.wp-prose p { line-height: var(--lh-prose); } +.wp-section-note, .wp-hint, .wp-catalog-hint { color: var(--text-soft); font-size: var(--fs-ui); line-height: var(--lh-prose); } +.wp-section-note { margin: 0 0 20px; } +.wp-catalog-hint { margin: 0 0 24px; } +.wp-catalog-hint svg { vertical-align: middle; } +.wp-jumps { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 20px; } +.wp-jumps a { display: inline-flex; align-items: center; gap: 6px; min-height: 44px; padding: 8px 12px; border: 1px solid var(--border); border-radius: var(--radius-sm); font-size: var(--fs-ui); color: var(--text); text-decoration: none; } +.wp-jumps a:hover { background: var(--bg-light); border-color: var(--accent); } +.wp-jumps a:last-child { font-weight: 600; color: var(--accent); border-color: var(--accent); } +.wp-jumps a:focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.wp-family[id] { scroll-margin-top: 132px; } +.wp-section h2[id], .wp-cell[id] { scroll-margin-top: 88px; } +.wp-specials { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 24px; max-width: 660px; } +.wp-matrix-head, .wp-family-row { display: grid; grid-template-columns: minmax(160px, .9fr) repeat(3, minmax(0, 1fr)); gap: 24px; } +.wp-matrix-head { padding-block: 12px; position: sticky; top: 64px; background: var(--bg); border-bottom: 1px solid var(--border); z-index: 2; font-size: var(--fs-ui); } +.wp-matrix-head > div { display: flex; flex-direction: column; gap: 4px; } +.wp-matrix-head span { color: var(--text-soft); } +.wp-family-row { padding-block: 22px; border-bottom: 1px solid var(--border); } +.wp-family { margin: 0 0 6px; font-size: var(--fs-lead); } +.wp-formula { color: var(--text-soft); font-size: var(--fs-ui); } +.wp-family-note { font-size: var(--fs-ui); color: var(--text-soft); line-height: var(--lh-ui); margin: 10px 0 0; } +.wp-cell { --wp-plot-height: 138px; position: relative; min-width: 0; } +.wp-select { display: block; text-decoration: none; color: var(--text); } +.wp-plot { display: block; width: 100%; height: var(--wp-plot-height); background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.wp-select:hover .wp-plot, .wp-select[aria-current="true"] .wp-plot { border-color: var(--accent); } +.wp-cell-meta { display: flex; align-items: center; justify-content: space-between; gap: 4px; } +.wp-cell-desc, .wp-cell-by { font-size: var(--fs-label); color: var(--text-soft); line-height: var(--lh-ui); } +.wp-cell .res-copy { width: 100%; min-height: 44px; height: auto; justify-content: space-between; margin-block: 8px; padding: 8px; background: var(--code-bg); } +.wp-cell .res-copy::before { order: 1; } +.wp-cell .res-copy span { white-space: normal; overflow-wrap: anywhere; text-align: left; } +.wp-cell .res-copy.copied { color: var(--code-success); border-color: var(--code-success); } +.wp-cell .res-copy:disabled { cursor: default; } +.wp-cell .res-copy:disabled::before { display: none; } +.wp-lab { padding-top: 16px; border-top: 1px solid var(--border); } +.wp-lab > .wp-section-note { max-width: none; margin-bottom: 12px; } +.wp-lab-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(320px, .54fr); grid-template-areas: 'editor scene' 'editor export'; gap: 28px; align-items: start; } +.wp-lab-editor { grid-area: editor; min-width: 0; } +.wp-export { grid-area: export; min-width: 0; padding: 16px; background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius-sm); } +.wp-lab .wp-export h3 { margin: 0 0 8px; } +.wp-export .wp-hint { margin: 8px 0; } +.wp-export .wp-lab-code pre { max-height: 60vh; overflow: auto; padding: 48px 14px 14px; font-size: var(--fs-label); } +.wp-export .code-copy { width: 36px; height: 36px; } +.wp-scene-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 12px; margin-top: 12px; } +.wp-scene-controls .wp-hint { flex: 1 1 240px; margin: 0; } +.wp-lab .wp-scene { grid-area: scene; min-width: 0; margin-top: 0; } +.wp-inputs { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; align-items: start; } +.wp-lab h2 { font-family: var(--font-serif); font-size: var(--fs-title); margin: 0 0 8px; } +.wp-lab h3 { font-size: var(--fs-body); margin-block: 24px 8px; } +.wp-lab-controls { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; margin-bottom: 6px; } +.wp-lab-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; flex: 1 1 160px; } +.wp-lab-field--time { flex: 0 0 140px; } +.wp-lab-field .filter-input { flex: none; min-height: 44px; width: 100%; } +.wp-lab-field .filter-select { min-height: 44px; width: 100%; max-width: none; } +.wp-lab-label { font-size: var(--fs-ui); font-weight: 600; } +.wp-expression { margin-bottom: 6px; } +#wp-lab-expr, #wp-lab-name { font-family: var(--font-mono); } +[id^="wp-lab-"][aria-invalid="true"] { border-color: var(--warning); } +.wp-lab-note { color: var(--text-soft); font-size: var(--fs-label); line-height: var(--lh-ui); margin: 6px 0; } +.wp-lab-note:empty { display: none; } +.wp-lab-note.is-error { color: var(--warning); } +.wp-comparison-key { display: flex; flex-wrap: wrap; gap: 20px; font-size: var(--fs-label); color: var(--text-soft); margin: 16px 0 10px; } +.wp-comparison-key span { display: inline-flex; align-items: center; gap: 7px; } +.wp-key-selected::before { content: ''; width: 10px; height: 10px; background: var(--accent); border-radius: 50%; } +.wp-key-linear::before { content: ''; width: 10px; height: 10px; background: var(--danger); border-radius: 50%; } +.wp-workbench { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 20px; } +.wp-graph { min-width: 0; } +.wp-selection-note { margin: 10px 0; font-size: var(--fs-ui); line-height: var(--lh-ui); color: var(--text); } +.wp-lab-curve { display: block; width: 100%; height: 224px; background: var(--bg-light); border: 1px solid var(--border); border-radius: var(--radius-sm); } +#wp-lab-values, #wp-lab-progress { font-family: var(--font-mono); font-size: var(--fs-label); } +.wp-demos { display: grid; gap: 8px; } +.wp-demo { margin: 0; } +.wp-demo figcaption { display: flex; justify-content: space-between; gap: 8px; font-size: var(--fs-ui); margin-bottom: 5px; } +.wp-demo figcaption code { font-size: var(--fs-label); color: var(--text-soft); } +.wp-demo-stage { overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-light); } +.wp-demo-lane { height: 26px; position: relative; margin-inline: 14%; } +.wp-demo-lane--linear { border-top: 2px dashed var(--danger); } +.wp-demo-object { position: absolute; width: 20px; height: 20px; top: 3px; left: 50%; border-radius: 50%; background: var(--accent); transform: translateX(-50%); } +.wp-demo-object--linear { background: var(--danger); } +.wp-demo-stage--xalign .wp-demo-object { left: 0; transform: translateX(calc(var(--travel, 0) * 1px)); } +.wp-transport { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; margin-top: 12px; } +.wp-transport button, .wp-lab-play, .wp-formula-examples button { min-height: 44px; padding: 8px 16px; background: none; color: var(--text); border: 1px solid var(--border); border-radius: var(--radius-sm); font: inherit; font-size: var(--fs-ui); cursor: pointer; } +.wp-transport .wp-lab-play, .wp-scene-controls .wp-lab-play { color: var(--accent); border-color: var(--accent); min-width: 120px; font-weight: 600; } +.wp-transport button:hover, .wp-lab-play:hover, .wp-formula-examples button:hover { background: var(--bg-light); } +.wp-transport button:disabled, .wp-lab-play:disabled { opacity: .5; cursor: not-allowed; } +.wp-repeat { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; font-size: var(--fs-ui); } +.wp-repeat input { accent-color: var(--accent); width: 18px; height: 18px; } +#wp-lab-progress { display: block; text-align: right; margin-top: 4px; } +.wp-scrub { display: flex; align-items: center; gap: 16px; font-size: var(--fs-label); color: var(--text-soft); } +.wp-scrub input { flex: 1; min-width: 0; min-height: 44px; accent-color: var(--accent); } +.wp-scene { border-top: 1px solid var(--border); padding-top: 12px; margin-top: 24px; } +.wp-formula-examples { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 12px; } +.wp-lab-field--name { max-width: 440px; margin-top: 12px; } +.wp-lab-code { margin-top: 16px; } +.wp-lab-code pre { margin: 0; padding: 18px; font-size: var(--fs-ui); overflow-x: auto; } +.wp-lab-stage { margin-top: 16px; max-width: 760px; overflow: hidden; aspect-ratio: 16 / 9; border: 1px solid var(--border); } +.wp-lab-img { display: block; width: 100%; height: 100%; object-fit: cover; transition: none; } +.wp-main a:focus-visible, .wp-main button:focus-visible, .wp-main summary:focus-visible, .wp-main input:focus-visible { outline: var(--focus-ring); outline-offset: 3px; } +.wp-main [hidden] { display: none !important; } +@media (max-width: 1100px) { + .wp-lab-layout { grid-template-columns: minmax(0, 1fr); grid-template-areas: 'editor' 'scene' 'export'; } + .wp-export { position: static; } +} +@media (max-width: 800px) { + .wp-inputs { display: block; } + .wp-matrix-head { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; top: 52px; } + .wp-matrix-head > span { display: none; } + .wp-family-row { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; } + .wp-row-label { grid-column: 1 / -1; } + .wp-family-note { margin-top: 6px; } + .wp-family { display: inline; margin-right: 12px; } + .wp-workbench { grid-template-columns: minmax(0, 1fr); gap: 16px; } + .wp-lab-curve { height: 240px; } + .wp-transport { position: sticky; top: 52px; z-index: 3; gap: 8px; padding: 8px 0; background: var(--bg); border-block: 1px solid var(--border); } + .wp-transport button { padding-inline: 12px; } + .wp-transport .wp-lab-play { min-width: 0; } + .wp-export .code-copy { width: 44px; height: 44px; } + .wp-export .wp-lab-code pre { padding-top: 58px; } +} +@media (max-width: 480px) { + .wp-specials, .wp-family-row { gap: 8px; } + .wp-cell { --wp-plot-height: 102px; } + .wp-cell-meta { align-items: start; flex-direction: column; } + .wp-cell .res-copy { flex-direction: column; align-items: start; gap: 6px; } + .wp-lab-controls > .wp-lab-field:first-child { flex-basis: 100%; } + .wp-lab-field--time { flex-basis: 140px; } + .wp-scrub { gap: 8px; } + .wp-scrub > span { max-width: 90px; } +} + +.wp-demo-stage--zoom .wp-demo-lane, .wp-demo-stage--alpha .wp-demo-lane { margin-inline: 0; } +.wp-demo-stage--zoom .wp-demo-object, .wp-demo-stage--alpha .wp-demo-object { width: 100%; height: 100%; top: 0; border-radius: 0; } diff --git a/static/icons/LICENSE b/static/icons/LICENSE new file mode 100644 index 0000000..718bb3f --- /dev/null +++ b/static/icons/LICENSE @@ -0,0 +1,43 @@ +ISC License + +Copyright (c) 2026 Lucide Icons and Contributors + +Permission to use, copy, modify, and/or distribute this software for any +purpose with or without fee is hereby granted, provided that the above +copyright notice and this permission notice appear in all copies. + +THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES +WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF +MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR +ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES +WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN +ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF +OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE. + +--- + +The following Lucide icons are derived from the Feather project: + +airplay, alert-circle, alert-octagon, alert-triangle, aperture, arrow-down-circle, arrow-down-left, arrow-down-right, arrow-down, arrow-left-circle, arrow-left, arrow-right-circle, arrow-right, arrow-up-circle, arrow-up-left, arrow-up-right, arrow-up, at-sign, calendar, cast, check, chevron-down, chevron-left, chevron-right, chevron-up, chevrons-down, chevrons-left, chevrons-right, chevrons-up, circle, clipboard, clock, code, columns, command, compass, corner-down-left, corner-down-right, corner-left-down, corner-left-up, corner-right-down, corner-right-up, corner-up-left, corner-up-right, crosshair, database, divide-circle, divide-square, dollar-sign, download, external-link, feather, frown, hash, headphones, help-circle, info, italic, key, layout, life-buoy, link-2, link, loader, lock, log-in, log-out, maximize, meh, minimize, minimize-2, minus-circle, minus-square, minus, monitor, moon, more-horizontal, more-vertical, move, music, navigation-2, navigation, octagon, pause-circle, percent, plus-circle, plus-square, plus, power, radio, rss, search, server, share, shopping-bag, sidebar, smartphone, smile, square, table-2, tablet, target, terminal, trash-2, trash, triangle, tv, type, upload, x-circle, x-octagon, x-square, x, zoom-in, zoom-out + +The MIT License (MIT) (for the icons listed above) + +Copyright (c) 2013-present Cole Bemis + +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. diff --git a/static/icons/arrow-left.svg b/static/icons/arrow-left.svg new file mode 100644 index 0000000..b40bff8 --- /dev/null +++ b/static/icons/arrow-left.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/arrow-right.svg b/static/icons/arrow-right.svg new file mode 100644 index 0000000..433ded3 --- /dev/null +++ b/static/icons/arrow-right.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/arrow-up.svg b/static/icons/arrow-up.svg new file mode 100644 index 0000000..50a81cb --- /dev/null +++ b/static/icons/arrow-up.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/check.svg b/static/icons/check.svg new file mode 100644 index 0000000..5abe152 --- /dev/null +++ b/static/icons/check.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/chevron-down.svg b/static/icons/chevron-down.svg new file mode 100644 index 0000000..e70d30b --- /dev/null +++ b/static/icons/chevron-down.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/chevron-right.svg b/static/icons/chevron-right.svg new file mode 100644 index 0000000..db62a0c --- /dev/null +++ b/static/icons/chevron-right.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/circle-alert.svg b/static/icons/circle-alert.svg new file mode 100644 index 0000000..578d25c --- /dev/null +++ b/static/icons/circle-alert.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/circle-play.svg b/static/icons/circle-play.svg new file mode 100644 index 0000000..4290be9 --- /dev/null +++ b/static/icons/circle-play.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/clock.svg b/static/icons/clock.svg new file mode 100644 index 0000000..b6ae283 --- /dev/null +++ b/static/icons/clock.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/code-xml.svg b/static/icons/code-xml.svg new file mode 100644 index 0000000..74f2299 --- /dev/null +++ b/static/icons/code-xml.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/columns-3.svg b/static/icons/columns-3.svg new file mode 100644 index 0000000..1ad5c61 --- /dev/null +++ b/static/icons/columns-3.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/construction.svg b/static/icons/construction.svg new file mode 100644 index 0000000..a39818a --- /dev/null +++ b/static/icons/construction.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/static/icons/copy.svg b/static/icons/copy.svg new file mode 100644 index 0000000..f62ce99 --- /dev/null +++ b/static/icons/copy.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/download.svg b/static/icons/download.svg new file mode 100644 index 0000000..5c75527 --- /dev/null +++ b/static/icons/download.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/file.svg b/static/icons/file.svg new file mode 100644 index 0000000..161be7a --- /dev/null +++ b/static/icons/file.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/film.svg b/static/icons/film.svg new file mode 100644 index 0000000..fae7568 --- /dev/null +++ b/static/icons/film.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/static/icons/fold-horizontal.svg b/static/icons/fold-horizontal.svg new file mode 100644 index 0000000..0f967cf --- /dev/null +++ b/static/icons/fold-horizontal.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/static/icons/folder-up.svg b/static/icons/folder-up.svg new file mode 100644 index 0000000..b050925 --- /dev/null +++ b/static/icons/folder-up.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/folder.svg b/static/icons/folder.svg new file mode 100644 index 0000000..ef69b34 --- /dev/null +++ b/static/icons/folder.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/hash.svg b/static/icons/hash.svg new file mode 100644 index 0000000..29bb6d3 --- /dev/null +++ b/static/icons/hash.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/heart.svg b/static/icons/heart.svg new file mode 100644 index 0000000..9e0ba06 --- /dev/null +++ b/static/icons/heart.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/image-up.svg b/static/icons/image-up.svg new file mode 100644 index 0000000..01b11ba --- /dev/null +++ b/static/icons/image-up.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/image.svg b/static/icons/image.svg new file mode 100644 index 0000000..3241134 --- /dev/null +++ b/static/icons/image.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/info.svg b/static/icons/info.svg new file mode 100644 index 0000000..2a46eac --- /dev/null +++ b/static/icons/info.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/layout-grid.svg b/static/icons/layout-grid.svg new file mode 100644 index 0000000..f8eca3a --- /dev/null +++ b/static/icons/layout-grid.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/lightbulb.svg b/static/icons/lightbulb.svg new file mode 100644 index 0000000..3f4e409 --- /dev/null +++ b/static/icons/lightbulb.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/list.svg b/static/icons/list.svg new file mode 100644 index 0000000..0d88fb1 --- /dev/null +++ b/static/icons/list.svg @@ -0,0 +1,18 @@ + + + + + + + + diff --git a/static/icons/manifest.json b/static/icons/manifest.json new file mode 100644 index 0000000..8bc0f94 --- /dev/null +++ b/static/icons/manifest.json @@ -0,0 +1,56 @@ +{ + "source": "https://github.com/lucide-icons/lucide", + "revision": "1fae58d0a9c661a338036838caf3399b5507bab6", + "icons": [ + "arrow-left", + "arrow-right", + "arrow-up", + "check", + "chevron-down", + "chevron-right", + "circle-alert", + "circle-play", + "clock", + "code-xml", + "columns-3", + "construction", + "copy", + "download", + "file", + "film", + "fold-horizontal", + "folder", + "folder-up", + "hash", + "heart", + "image", + "image-up", + "info", + "layout-grid", + "lightbulb", + "list", + "moon", + "music", + "octagon-alert", + "palette", + "pause", + "pipette", + "play", + "redo-2", + "repeat", + "rotate-ccw", + "search", + "send", + "sliders-horizontal", + "square", + "sun", + "trash", + "triangle-alert", + "type", + "undo-2", + "unfold-horizontal", + "users", + "volume-2", + "x" + ] +} diff --git a/static/icons/moon.svg b/static/icons/moon.svg new file mode 100644 index 0000000..3c4bc31 --- /dev/null +++ b/static/icons/moon.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/music.svg b/static/icons/music.svg new file mode 100644 index 0000000..dd9faac --- /dev/null +++ b/static/icons/music.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/octagon-alert.svg b/static/icons/octagon-alert.svg new file mode 100644 index 0000000..e466f50 --- /dev/null +++ b/static/icons/octagon-alert.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/palette.svg b/static/icons/palette.svg new file mode 100644 index 0000000..25fb773 --- /dev/null +++ b/static/icons/palette.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/static/icons/pause.svg b/static/icons/pause.svg new file mode 100644 index 0000000..ea5762f --- /dev/null +++ b/static/icons/pause.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/pipette.svg b/static/icons/pipette.svg new file mode 100644 index 0000000..2a5e136 --- /dev/null +++ b/static/icons/pipette.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/play.svg b/static/icons/play.svg new file mode 100644 index 0000000..199743e --- /dev/null +++ b/static/icons/play.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/redo-2.svg b/static/icons/redo-2.svg new file mode 100644 index 0000000..72371c8 --- /dev/null +++ b/static/icons/redo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/repeat.svg b/static/icons/repeat.svg new file mode 100644 index 0000000..b8dccd7 --- /dev/null +++ b/static/icons/repeat.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/rotate-ccw.svg b/static/icons/rotate-ccw.svg new file mode 100644 index 0000000..eb0fe34 --- /dev/null +++ b/static/icons/rotate-ccw.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/search.svg b/static/icons/search.svg new file mode 100644 index 0000000..03e442a --- /dev/null +++ b/static/icons/search.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/send.svg b/static/icons/send.svg new file mode 100644 index 0000000..001843b --- /dev/null +++ b/static/icons/send.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/sliders-horizontal.svg b/static/icons/sliders-horizontal.svg new file mode 100644 index 0000000..819f80c --- /dev/null +++ b/static/icons/sliders-horizontal.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/static/icons/square.svg b/static/icons/square.svg new file mode 100644 index 0000000..7862733 --- /dev/null +++ b/static/icons/square.svg @@ -0,0 +1,13 @@ + + + diff --git a/static/icons/sun.svg b/static/icons/sun.svg new file mode 100644 index 0000000..3ecadd6 --- /dev/null +++ b/static/icons/sun.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + diff --git a/static/icons/trash.svg b/static/icons/trash.svg new file mode 100644 index 0000000..e8cb0ed --- /dev/null +++ b/static/icons/trash.svg @@ -0,0 +1,17 @@ + + + + + + + diff --git a/static/icons/triangle-alert.svg b/static/icons/triangle-alert.svg new file mode 100644 index 0000000..3232fb0 --- /dev/null +++ b/static/icons/triangle-alert.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/type.svg b/static/icons/type.svg new file mode 100644 index 0000000..8f269e7 --- /dev/null +++ b/static/icons/type.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/undo-2.svg b/static/icons/undo-2.svg new file mode 100644 index 0000000..3e93594 --- /dev/null +++ b/static/icons/undo-2.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/icons/unfold-horizontal.svg b/static/icons/unfold-horizontal.svg new file mode 100644 index 0000000..e54e20e --- /dev/null +++ b/static/icons/unfold-horizontal.svg @@ -0,0 +1,20 @@ + + + + + + + + + + diff --git a/static/icons/users.svg b/static/icons/users.svg new file mode 100644 index 0000000..e31958f --- /dev/null +++ b/static/icons/users.svg @@ -0,0 +1,16 @@ + + + + + + diff --git a/static/icons/volume-2.svg b/static/icons/volume-2.svg new file mode 100644 index 0000000..519e6cb --- /dev/null +++ b/static/icons/volume-2.svg @@ -0,0 +1,15 @@ + + + + + diff --git a/static/icons/x.svg b/static/icons/x.svg new file mode 100644 index 0000000..e317943 --- /dev/null +++ b/static/icons/x.svg @@ -0,0 +1,14 @@ + + + + diff --git a/static/img/attention.svg b/static/img/attention.svg deleted file mode 100644 index 41012ce..0000000 --- a/static/img/attention.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/danger.svg b/static/img/danger.svg deleted file mode 100644 index 3b1211b..0000000 --- a/static/img/danger.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/info.svg b/static/img/info.svg deleted file mode 100644 index f25ea41..0000000 --- a/static/img/info.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/static/img/outdated.svg b/static/img/outdated.svg deleted file mode 100644 index d0d659c..0000000 --- a/static/img/outdated.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/img/tip.svg b/static/img/tip.svg deleted file mode 100644 index fd34abd..0000000 --- a/static/img/tip.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/static/img/warning.svg b/static/img/warning.svg deleted file mode 100644 index 9dac30d..0000000 --- a/static/img/warning.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/static/img/wip.svg b/static/img/wip.svg deleted file mode 100644 index f480fdb..0000000 --- a/static/img/wip.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/static/js/animations.js b/static/js/animations.js new file mode 100644 index 0000000..55c2b03 --- /dev/null +++ b/static/js/animations.js @@ -0,0 +1,400 @@ +/* User-triggered previews. One cancellable session owns loading and painting. */ +(function () { + window.__esdocAnimationsCleanup?.(); + const cards = [...document.querySelectorAll('[data-animation-card]')]; + if (!cards.length) return; + const images = new Map(); + let active = null; + let disposed = false; + const tools = document.querySelector('[data-animation-tools]'); + const stageFor = card => card.querySelector('[data-stage]'); + const definitionFor = card => JSON.parse(card.querySelector('.animation-definition').textContent); + const durationFor = definition => definition.total_duration ?? ( + definition.kind === 'sequence' ? (definition.delay || 0) + definition.frames.reduce((sum, frame) => sum + frame.hold + frame.fade, 0) + : definition.kind === 'lids' ? definition.duration * (definition.motion === 'blink' ? 2 : 1) + (definition.motion === 'blink' ? definition.hold : 0) + : definition.kind === 'blackout' ? (definition.intro || 0) + definition.duration + : definition.kind === 'flash' ? (definition.duration * 2 + (definition.peak_hold || 0) + definition.hold) * (definition.cycles || 1) + : definition.kind === 'shake' ? 0.8 : 30); + const seconds = value => `${value.toFixed(1).replace('.', ',')} с`; + const progress = (card, definition, elapsed) => { + card._position = elapsed; + const total = durationFor(definition); + const position = definition.loop && definition.kind !== 'snow' ? elapsed % total : Math.min(total, elapsed); + const slider = card.querySelector('[data-position]'); + if (slider) { + slider.value = String(position); + slider.setAttribute('aria-valuetext', `${seconds(position)} из ${seconds(total)}`); + } + const output = card.querySelector('[data-time]'); + const text = definition.kind === 'snow' ? seconds(elapsed) : `${seconds(position)} / ${seconds(total)}`; + if (output && output.textContent !== text) output.textContent = text; + }; + const current = (card, state) => !disposed && card._animationState === state; + const setButton = (card, label, pressed) => { + const button = card.querySelector('[data-play]'); + button.setAttribute('aria-pressed', String(pressed)); + const title = definitionFor(card).title || definitionFor(card).name || 'анимация'; + const action = label === 'Загрузка…' ? 'Отменить загрузку' : label; + button.setAttribute('aria-label', `${action}: ${title}`); + button.setAttribute('title', `${action}: ${title}`); + button.setAttribute('aria-busy', String(label === 'Загрузка…')); + const playIcon = button.querySelector('[data-play-icon]'); + const stopIcon = button.querySelector('[data-stop-icon]'); + if (playIcon) playIcon.hidden = pressed; + if (stopIcon) stopIcon.hidden = !pressed; + }; + const stop = (card, label = 'Показать') => { + const state = card._animationState; + if (state?.raf != null) cancelAnimationFrame(state.raf); + if (state?.flakes) card._snowStill = {image: state.image, flakes: state.flakes}; + card._animationState = null; + stageFor(card).classList.remove('is-playing'); + setButton(card, label, false); + if (active === card) active = null; + // Preserve the painted frame, including the end of a one-shot effect. + }; + const loadImage = src => { + if (!images.has(src)) { + const promise = new Promise((resolve, reject) => { + const image = new Image(); + image.onload = async () => { + try { + if (image.decode) await image.decode(); + resolve(image); + } catch (error) { reject(error); } + }; + image.onerror = () => reject(new Error('Image unavailable')); + image.src = src; + }); + images.set(src, promise); + promise.catch(() => { if (images.get(src) === promise) images.delete(src); }); + } + return images.get(src); + }; + const selected = (card, definition) => { + const index = Number(card.querySelector('[data-variant]')?.value || 0); + return definition.variants?.[index] || {}; + }; + const sources = (definition, variant) => [...new Set([ + definition.src, definition.second_src, definition.overlay_src, definition.background_src, + variant.src, ...(definition.frames || []).map(frame => frame.src), + ].filter(Boolean))]; + const paintImage = (layer, frame, opacity = 1) => { + if (layer.getAttribute('src') !== frame.src) layer.src = frame.src; + layer.style.filter = frame.filter || ''; + layer.style.opacity = String(opacity); + }; + const ease = value => (1 - Math.cos(Math.PI * Math.min(1, Math.max(0, value)))) / 2; + const paintSequence = (stage, definition, elapsed) => { + const frames = definition.frames; + const delay = definition.delay || 0; + const total = delay + frames.reduce((sum, frame) => sum + frame.hold + frame.fade, 0); + const complete = !definition.loop && elapsed >= total; + let time = definition.loop && total > 0 ? elapsed % total : elapsed; + if (time < delay && !complete) { + const [back, front] = stage.querySelectorAll('.animation-frame'); + if (elapsed < total) back.style.opacity = '0'; + else paintImage(back, frames[frames.length - 1]); + front.style.opacity = '0'; + return false; + } + time -= delay; + let index = 0; + if (complete) index = frames.length - 1; + else { + while (index < frames.length - 1 && time >= frames[index].hold + frames[index].fade) { + time -= frames[index].hold + frames[index].fade; + index += 1; + } + } + const frame = frames[index]; + const previous = frames[index ? index - 1 : (elapsed >= total ? frames.length - 1 : 0)]; + const [back, front] = stage.querySelectorAll('.animation-frame'); + const overlay = stage.querySelector('.animation-effect-overlay'); + const progress = complete || !frame.fade ? 1 : Math.min(1, time / frame.fade); + overlay.style.opacity = '0'; + if (frame.transition === 'fade' && progress < 1) { + paintImage(back, progress < 0.5 ? previous : frame); + front.style.opacity = '0'; + overlay.style.opacity = String(progress < 0.5 ? progress * 2 : (1 - progress) * 2); + } else { + // The back layer stays opaque, so a dissolve never exposes an empty stage. + paintImage(back, progress < 1 ? previous : frame); + paintImage(front, frame, progress < 1 ? progress : 0); + } + return complete; + }; + const paintLids = (stage, definition, elapsed) => { + const duration = definition.duration; + const blink = definition.motion === 'blink'; + const total = blink ? duration * 2 + definition.hold : duration; + let closed = ease(elapsed / duration); + if (definition.motion === 'open') closed = 1 - closed; + else if (blink && elapsed > duration + definition.hold) { + closed = 1 - ease((elapsed - duration - definition.hold) / duration); + } + stage.querySelector('.animation-lid--upper').style.transform = `translateY(${(closed - 1) * 100}%)`; + stage.querySelector('.animation-lid--lower').style.transform = `translateY(${(1 - closed) * 100}%)`; + return elapsed >= total; + }; + const paintFlash = (stage, definition, elapsed) => { + const duration = definition.duration; + const peak = definition.peak_hold || 0; + const time = elapsed % (duration * 2 + peak + definition.hold); + const opacity = time < duration ? time / duration + : time < duration + peak ? 1 + : time < duration * 2 + peak ? 1 - (time - duration - peak) / duration : 0; + stage.querySelector('.animation-effect-overlay').style.opacity = String(opacity); + return false; + }; + const paintShake = (stage, elapsed) => { + const time = (elapsed % 0.8) / 0.2; + const points = [[-50, 50], [0, 0], [50, 50], [0, 0], [-50, 50]]; + const step = Math.floor(time); + const progress = ease(time - step); + const from = points[step]; + const to = points[step + 1]; + // The very first ease starts at the unshifted scene. + const initial = elapsed < 0.2; + const x = initial ? 0 : from[0] + (to[0] - from[0]) * progress; + const y = initial ? 0 : from[1] + (to[1] - from[1]) * progress; + stage.querySelector('.animation-shake-echo').style.transform = `translate(${x / 1920 * 100}%, ${y / 1080 * 100}%)`; + return false; + }; + const makeSnow = (definition, variant) => Array.from({length: variant.particles || definition.particles}, (_, index) => { + const depth = 1 + Math.floor(Math.random() * 10); + const scale = Math.min(1, 1.1 - (depth - 1) / 10); + const speed = 1.5 - depth / 10; + const border = Math.random() * 100; + const x = Math.random() * (1920 + border * 2) - border; + const y = -(50 + Math.random() * 350); + return {x, y, born: index / 60, + size: scale, alpha: scale, wind: (Math.random() * 200 - 100) * speed, speed: 150 * speed}; + }); + const paintSnow = (canvas, image, flakes, elapsed = 0, poster = false) => { + const bounds = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + const width = Math.max(1, Math.round(bounds.width * ratio)); + const height = Math.max(1, Math.round(bounds.height * ratio)); + if (canvas.width !== width) canvas.width = width; + if (canvas.height !== height) canvas.height = height; + const context = canvas.getContext('2d'); + context.clearRect(0, 0, width, height); + for (const flake of flakes) { + if (!poster && elapsed < flake.born) continue; + const verticalLife = (1080 - flake.y) / flake.speed; + const horizontalLife = flake.wind < 0 ? Math.max(0, flake.x / -flake.wind) + : flake.wind > 0 ? Math.max(0, (1920 - flake.x) / flake.wind) : Infinity; + const age = poster ? 0 : (elapsed - flake.born) % Math.max(0.1, Math.min(verticalLife, horizontalLife)); + const x = flake.x + flake.wind * age; + const y = poster ? (flake.born * 617) % 1080 : flake.y + flake.speed * age; + context.globalAlpha = flake.alpha; + context.drawImage(image, x / 1920 * width, y / 1080 * height, + Math.max(1, image.naturalWidth * flake.size / 1920 * width), + Math.max(1, image.naturalHeight * flake.size / 1080 * height)); + } + context.globalAlpha = 1; + }; + const prepare = (card, definition, variant, originals = true) => { + const stage = stageFor(card); + const overlay = stage.querySelector('.animation-effect-overlay'); + if (overlay) overlay.style.opacity = '0'; + if (definition.kind === 'shake') { + stage.querySelectorAll('.animation-shake-base, .animation-shake-echo').forEach(layer => { + layer.src = originals ? variant.src : variant.poster || variant.src; + layer.style.transform = ''; + }); + } + if (originals) { + const scene = stage.querySelector('.animation-scene'); + if (scene) scene.src = definition.background_src; + if (definition.kind === 'lids') { + stage.querySelector('.animation-lid--upper').src = definition.src; + stage.querySelector('.animation-lid--lower').src = definition.second_src; + } + if (definition.kind === 'shake') stage.querySelector('.animation-shake-overlay')?.setAttribute('src', definition.overlay_src); + if (definition.kind === 'blackout' || definition.kind === 'flash') stage.querySelector('.animation-frame').src = definition.src; + } + if (definition.kind === 'lids') paintLids(stage, definition, 0); + if (definition.kind === 'sequence' && originals) paintSequence(stage, definition, 0); + if (definition.kind === 'blackout') stage.querySelector('.animation-frame').style.filter = definition.filter || ''; + }; + const paint = (card, definition, state, elapsed) => { + const stage = stageFor(card); + let complete = false; + if (definition.kind === 'sequence') complete = paintSequence(stage, definition, elapsed); + else if (definition.kind === 'lids') complete = paintLids(stage, definition, elapsed); + else if (definition.kind === 'flash') paintFlash(stage, definition, elapsed); + else if (definition.kind === 'shake') paintShake(stage, elapsed); + else if (definition.kind === 'blackout') { + const intro = definition.intro || 0; + const opacity = intro && elapsed < intro ? 1 - elapsed / intro : Math.min(1, (elapsed - intro) / definition.duration); + stage.querySelector('.animation-effect-overlay').style.opacity = String(opacity); + complete = elapsed >= intro + definition.duration; + } else if (definition.kind === 'snow') paintSnow(stage.querySelector('canvas'), state.image, state.flakes, elapsed); + progress(card, definition, elapsed); + return complete; + }; + const start = async card => { + if (active) stop(active); + const definition = definitionFor(card); + const variant = selected(card, definition); + const stage = stageFor(card); + const state = {raf: null, started: null, last: null}; + card._animationState = state; + active = card; + card.querySelector('[data-error]').hidden = true; + setButton(card, 'Загрузка…', true); + try { + const loaded = await Promise.all(sources(definition, variant).map(loadImage)); + if (!current(card, state)) return; + prepare(card, definition, variant); + stage.classList.add('is-playing'); + setButton(card, 'Остановить', true); + if (definition.kind === 'snow') { + state.image = loaded[0]; + state.flakes = makeSnow(definition, variant); + } + const tick = time => { + if (!current(card, state)) return; + if (state.started === null) state.started = time; + const elapsed = (time - state.started) / 1000; + state.last = time; + const complete = paint(card, definition, state, elapsed); + if (complete) stop(card, 'Повторить'); + else state.raf = requestAnimationFrame(tick); + }; + state.raf = requestAnimationFrame(tick); + } catch (error) { + if (!current(card, state)) return; + stop(card, 'Повторить'); + const message = card.querySelector('[data-error]'); + message.textContent = 'Не удалось загрузить кадры. Попробуйте ещё раз.'; + message.hidden = false; + } + }; + const showSnow = async card => { + const definition = definitionFor(card); + const variant = selected(card, definition); + const token = {}; + card._snowPoster = token; + try { + const image = await loadImage(definition.src); + if (disposed || card._snowPoster !== token || card._animationState) return; + const flakes = makeSnow(definition, variant); + card._snowStill = {image, flakes}; + paintSnow(stageFor(card).querySelector('canvas'), image, flakes, 0, true); + } catch { /* Playback exposes a retryable error on demand. */ } + }; + const inspect = async (card, elapsed) => { + if (active) stop(active); + const definition = definitionFor(card); + const variant = selected(card, definition); + const state = {raf: null}; + card._animationState = state; + active = card; + card.querySelector('[data-error]').hidden = true; + try { + const loaded = await Promise.all(sources(definition, variant).map(loadImage)); + if (!current(card, state)) return; + prepare(card, definition, variant); + if (definition.kind === 'snow') { + state.image = loaded[0]; + state.flakes = card._snowStill?.flakes || makeSnow(definition, variant); + } + paint(card, definition, state, elapsed); + stop(card); + setButton(card, 'Показать с начала', false); + } catch { + if (!current(card, state)) return; + stop(card, 'Повторить'); + const message = card.querySelector('[data-error]'); + message.textContent = 'Не удалось загрузить кадры. Попробуйте ещё раз.'; + message.hidden = false; + } + }; + const posters = typeof IntersectionObserver === 'function' ? new IntersectionObserver(entries => { + entries.forEach(entry => { + if (entry.isIntersecting) { + showSnow(entry.target); + posters.unobserve(entry.target); + } + }); + }) : null; + cards.forEach(card => { + const definition = definitionFor(card); + const button = card.querySelector('[data-play]'); + button.hidden = false; + button.addEventListener('click', () => active === card ? stop(card) : start(card)); + card.querySelector('[data-timeline]')?.removeAttribute('hidden'); + card.querySelector('[data-position]')?.addEventListener('input', event => inspect(card, Number(event.target.value))); + progress(card, definition, 0); + card.querySelector('[data-variant]')?.addEventListener('change', () => { + const wasPlaying = active === card; + if (wasPlaying) stop(card); + const definition = definitionFor(card); + const variant = selected(card, definition); + prepare(card, definition, variant, false); + card.querySelector('[data-source]').href = variant.source_url || definition.source_url; + const name = variant.name || variant.source_name || definition.display_name || definition.name; + const nameLabel = card.querySelector('[data-animation-name]'); + if (nameLabel) nameLabel.textContent = name; + const copy = card.querySelector('.animation-card-actions .res-copy'); + if (copy) { + copy.dataset.copy = name; + copy.setAttribute('aria-label', `Скопировать: ${name}`); + } + const usage = card.querySelector('[data-usage]'); + if (usage) usage.textContent = variant.usage || definition.usage; + const usageCopy = card.querySelector('.animation-usage .res-copy'); + if (usageCopy) usageCopy.dataset.copy = variant.usage || definition.usage; + progress(card, definition, 0); + setButton(card, 'Показать', false); + if (wasPlaying) start(card); + else if (definition.kind === 'snow') showSnow(card); + }); + if (card.dataset.kind === 'lids') prepare(card, definition, {}, false); + if (card.dataset.kind === 'snow') { + if (posters) posters.observe(card); + else showSnow(card); + } + }); + if (tools) tools.hidden = false; + const search = document.querySelector('[data-animation-search]'); + const normalize = value => value.toLowerCase().replaceAll('ё', 'е'); + search?.addEventListener('input', () => { + const query = normalize(search.value.trim()); + let visible = 0; + cards.forEach(card => { + const definition = definitionFor(card); + const text = [definition.title, definition.name, definition.description, + ...(definition.variants || []).flatMap(variant => [variant.name, variant.label])].join(' '); + card.hidden = !normalize(text).includes(query); + if (card.hidden && active === card) stop(card); + if (!card.hidden) visible++; + }); + const count = document.querySelector('[data-animation-count]'); + if (count) count.textContent = `${visible} из ${cards.length}`; + const empty = document.querySelector('[data-animation-empty]'); + if (empty) empty.hidden = visible > 0; + }); + const resize = () => cards.forEach(card => { + if (card._snowStill && !card._animationState) { + paintSnow(stageFor(card).querySelector('canvas'), card._snowStill.image, card._snowStill.flakes, card._position || 0, card._position == null || card._position === 0); + } + }); + const hide = () => { if (active) stop(active); }; + const visibility = () => { if (document.hidden) hide(); }; + window.addEventListener('resize', resize); + window.addEventListener('pagehide', hide); + document.addEventListener('visibilitychange', visibility); + window.__esdocAnimationsCleanup = () => { + hide(); + disposed = true; + images.clear(); + posters?.disconnect(); + window.removeEventListener('resize', resize); + window.removeEventListener('pagehide', hide); + document.removeEventListener('visibilitychange', visibility); + }; +})(); diff --git a/static/js/code.js b/static/js/code.js index 018bcf6..89bb208 100644 --- a/static/js/code.js +++ b/static/js/code.js @@ -35,7 +35,9 @@ window.copyControl = (element, getValue, { message, status, reset = 1600 } = {}) if (status) status.textContent = typeof message === 'function' ? message() : message; clearTimeout(timer); timer = setTimeout(() => element.classList.remove('copied'), reset); - }).catch(() => {}); + }).catch(() => { + if (status) status.textContent = 'Не удалось скопировать. Выделите текст и скопируйте его вручную.'; + }); }; /* ── Placeholder values (`<Название лейбла>`) ── @@ -103,8 +105,8 @@ document.querySelectorAll('span.cs').forEach(el => { const value = chip.textContent; if (!value.trim()) return; - const copy = window.copyControl(chip, () => value, { - message: 'Скопировано: ' + value, status, + const copy = window.copyControl(chip, () => chip.textContent, { + message: () => 'Скопировано: ' + chip.textContent, status, }); chip.classList.add('code-copyable'); diff --git a/static/js/colors-core.js b/static/js/colors-core.js new file mode 100644 index 0000000..780487f --- /dev/null +++ b/static/js/colors-core.js @@ -0,0 +1,195 @@ +/* Color conversions are independent of the page, clipboard and theme. */ +(function () { + 'use strict'; + const clamp = (value, min, max) => Math.min(max, Math.max(min, value)); + const decimal = (value, precision = 6) => String(Number(value.toFixed(precision))); + const byte = value => Math.round(clamp(value, 0, 255)); + + const parseHex = value => { + if (typeof value !== 'string') return null; + let text = value.trim().replace(/^#/, ''); + if (!/^(?:[a-f\d]{3}|[a-f\d]{4}|[a-f\d]{6}|[a-f\d]{8})$/i.test(text)) return null; + if (text.length <= 4) text = [...text].map(letter => letter + letter).join(''); + return { + r: parseInt(text.slice(0, 2), 16), + g: parseInt(text.slice(2, 4), 16), + b: parseInt(text.slice(4, 6), 16), + a: text.length === 8 ? parseInt(text.slice(6, 8), 16) / 255 : 1, + }; + }; + const shortenHex = hex => { + const text = hex.toLowerCase(); + if (/^#(?:[a-f\d]{6}|[a-f\d]{8})$/.test(text)) { + const pairs = text.slice(1).match(/../g); + if (pairs.every(pair => pair[0] === pair[1])) return '#' + pairs.map(pair => pair[0]).join(''); + } + return text; + }; + const toHex = (color, includeAlpha = color.a < 1, shorten = true) => { + const values = [color.r, color.g, color.b]; + if (includeAlpha) values.push(color.a * 255); + const hex = '#' + values.map(value => byte(value).toString(16).padStart(2, '0')).join(''); + return shorten ? shortenHex(hex) : hex; + }; + const parseNumber = (value, min, max, integer = false) => { + const text = String(value).trim(); + if (!/^(?:\d+(?:\.\d*)?|\.\d+)$/.test(text)) return null; + const number = Number(text); + return Number.isFinite(number) && number >= min && number <= max + && (!integer || Number.isInteger(number)) ? number : null; + }; + const hsvToRgb = ({ h, s, v, a = 1 }) => { + const hue = ((h % 360) + 360) % 360 / 60; + const saturation = clamp(s, 0, 100) / 100; + const value = clamp(v, 0, 100) / 100; + const chroma = value * saturation; + const x = chroma * (1 - Math.abs(hue % 2 - 1)); + const m = value - chroma; + const sectors = [[chroma, x, 0], [x, chroma, 0], [0, chroma, x], [0, x, chroma], [x, 0, chroma], [chroma, 0, x]]; + const [r, g, b] = sectors[Math.floor(hue)].map(channel => byte((channel + m) * 255)); + return { r, g, b, a }; + }; + const rgbToHsv = ({ r, g, b, a = 1 }, fallbackHue = 0) => { + const red = r / 255, green = g / 255, blue = b / 255; + const max = Math.max(red, green, blue), min = Math.min(red, green, blue), delta = max - min; + let h = fallbackHue; + if (delta) { + if (max === red) h = 60 * ((green - blue) / delta % 6); + else if (max === green) h = 60 * ((blue - red) / delta + 2); + else h = 60 * ((red - green) / delta + 4); + if (h < 0) h += 360; + } + return { h, s: max ? delta / max * 100 : 0, v: max * 100, a }; + }; + const rgbToHsl = color => { + const hsv = rgbToHsv(color); + const v = hsv.v / 100, s = hsv.s / 100; + const l = v * (1 - s / 2); + return { h: hsv.h, s: l && l !== 1 ? (v - l) / Math.min(l, 1 - l) * 100 : 0, l: l * 100, a: color.a }; + }; + const pythonRgb = color => `(${color.r}, ${color.g}, ${color.b})`; + const pythonRgba = color => `(${color.r}, ${color.g}, ${color.b}, ${decimal(color.a)})`; + const cssHsl = color => { + const hsl = rgbToHsl(color); + return `hsl(${decimal(hsl.h, 2)} ${decimal(hsl.s, 2)}% ${decimal(hsl.l, 2)}%${color.a < 1 ? ' / ' + decimal(color.a) : ''})`; + }; + const hslToRgb = ({ h, s, l, a = 1 }) => { + const light = l / 100, saturation = s / 100; + const v = light + saturation * Math.min(light, 1 - light); + return hsvToRgb({h, s: v ? 200 * (1 - light / v) : 0, v: v * 100, a}); + }; + const parseColor = value => { + if (typeof value !== 'string') return null; + const hex = parseHex(value); + if (hex) return hex; + const text = value.trim(); + const renpy = text.match(/^renpy\.Color\(\s*(["'])(#[a-f\d]{3,8})\1\s*(?:,\s*alpha\s*=\s*([\d.]+)\s*)?\)$/i); + if (renpy) { + const result = parseHex(renpy[2]); + if (!result) return null; + if (renpy[3] !== undefined) { + const opacity = parseNumber(renpy[3], 0, 1); + // Ren'Py ignores the alpha argument if the color already contains it. + if (opacity === null || [4, 8].includes(renpy[2].length - 1)) return null; + result.a = opacity; + } + return result; + } + const alpha = text => text === undefined ? 1 : text.endsWith('%') + ? parseNumber(text.slice(0, -1), 0, 100) === null ? null : Number(text.slice(0, -1)) / 100 + : parseNumber(text, 0, 1); + const perceptual = text.match(/^oklch\(\s*([^()]+)\)$/i); + if (perceptual) { + const parts = perceptual[1].trim().split(/\s*\/\s*|\s+/); + if (parts.length < 3 || parts.length > 4) return null; + const channel = (part, scale, max) => part === 'none' ? 0 : part.endsWith('%') + ? parseNumber(part.slice(0, -1), 0, max / scale * 100) === null ? null : Number(part.slice(0, -1)) / 100 * scale + : parseNumber(part, 0, max); + const l = channel(parts[0], 1, 1), chroma = channel(parts[1], .4, 1000); + const angle = parts[2] === 'none' ? 0 : /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg|rad|grad|turn)?$/i.test(parts[2]) ? parseFloat(parts[2]) : NaN; + const unit = parts[2].match(/(deg|rad|grad|turn)$/i)?.[1]?.toLowerCase(); + const h = angle * (unit === 'rad' ? 180 / Math.PI : unit === 'grad' ? .9 : unit === 'turn' ? 360 : 1); + const a = parts[3] === 'none' ? 0 : alpha(parts[3]); + if (l === null || chroma === null || !Number.isFinite(h) || a === null) return null; + return oklabToRgb({l, x: chroma * Math.cos(h * Math.PI / 180), y: chroma * Math.sin(h * Math.PI / 180), a}); + } + const tuple = text.match(/^\(\s*([\d.]+)\s*,\s*([\d.]+)\s*,\s*([\d.]+)(?:\s*,\s*([\d.]+))?\s*\)$/); + const css = text.match(/^(rgb|rgba|hsl|hsla)\(\s*([^()]+)\s*\)$/i); + let parts, space = 'rgb'; + if (tuple) parts = tuple.slice(1); + else if (css) { + space = css[1].toLowerCase().startsWith('hsl') ? 'hsl' : 'rgb'; + parts = css[2].trim().split(/\s*[,/]\s*|\s+/); + if (parts.length < 3 || parts.length > 4) return null; + } else return null; + const a = alpha(parts[3]); + if (a === null) return null; + if (space === 'hsl') { + const h = Number(parts[0].replace(/deg$/i, '')); + const percentages = parts.slice(1, 3).map(part => part.endsWith('%') ? parseNumber(part.slice(0, -1), 0, 100) : null); + if (!/^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:deg)?$/i.test(parts[0]) || !Number.isFinite(h) || percentages.includes(null)) return null; + return hslToRgb({h, s: percentages[0], l: percentages[1], a}); + } + const channels = parts.slice(0, 3).map(part => part.endsWith('%') ? parseNumber(part.slice(0, -1), 0, 100) : parseNumber(part, 0, 255, !!tuple)); + if (channels.includes(null)) return null; + return {r: byte(channels[0] * (parts[0].endsWith('%') ? 2.55 : 1)), g: byte(channels[1] * (parts[1].endsWith('%') ? 2.55 : 1)), b: byte(channels[2] * (parts[2].endsWith('%') ? 2.55 : 1)), a}; + }; + const linear = channel => channel <= .04045 ? channel / 12.92 : ((channel + .055) / 1.055) ** 2.4; + const encoded = channel => channel <= .0031308 ? channel * 12.92 : 1.055 * channel ** (1 / 2.4) - .055; + const luminance = color => .2126 * linear(color.r / 255) + .7152 * linear(color.g / 255) + .0722 * linear(color.b / 255); + const composite = (foreground, background) => ({ + r: foreground.r * foreground.a + background.r * (1 - foreground.a), + g: foreground.g * foreground.a + background.g * (1 - foreground.a), + b: foreground.b * foreground.a + background.b * (1 - foreground.a), a: 1, + }); + const contrast = (foreground, background) => { + const bg = composite(background, {r: 255, g: 255, b: 255, a: 1}); + const fg = composite(foreground, bg), first = luminance(fg), second = luminance(bg); + return (Math.max(first, second) + .05) / (Math.min(first, second) + .05); + }; + // D65 OKLab matrices from Björn Ottosson's published reference. + const rgbToOklab = color => { + const r = linear(color.r / 255), g = linear(color.g / 255), b = linear(color.b / 255); + const l = Math.cbrt(.4122214708 * r + .5363325363 * g + .0514459929 * b); + const m = Math.cbrt(.2119034982 * r + .6806995451 * g + .1073969566 * b); + const s = Math.cbrt(.0883024619 * r + .2817188376 * g + .6299787005 * b); + return {l: .2104542553 * l + .793617785 * m - .0040720468 * s, + x: 1.9779984951 * l - 2.428592205 * m + .4505937099 * s, + y: .0259040371 * l + .7827717662 * m - .808675766 * s, a: color.a}; + }; + const oklabToRgb = color => { + const l = (color.l + .3963377774 * color.x + .2158037573 * color.y) ** 3; + const m = (color.l - .1055613458 * color.x - .0638541728 * color.y) ** 3; + const s = (color.l - .0894841775 * color.x - 1.291485548 * color.y) ** 3; + return {r: byte(encoded(4.0767416621 * l - 3.3077115913 * m + .2309699292 * s) * 255), + g: byte(encoded(-1.2684380046 * l + 2.6097574011 * m - .3413193965 * s) * 255), + b: byte(encoded(-.0041960863 * l - .7034186147 * m + 1.707614701 * s) * 255), a: color.a ?? 1}; + }; + const cssOklch = color => { + const lab = rgbToOklab(color), chroma = Math.hypot(lab.x, lab.y); + const hue = chroma < .000001 ? 0 : (Math.atan2(lab.y, lab.x) * 180 / Math.PI + 360) % 360; + return `oklch(${decimal(lab.l * 100, 3)}% ${decimal(chroma, 5)} ${decimal(hue, 2)}${color.a < 1 ? ' / ' + decimal(color.a) : ''})`; + }; + const cssRgb = color => `rgb(${color.r} ${color.g} ${color.b}${color.a < 1 ? ' / ' + decimal(color.a) : ''})`; + const renpyColor = color => `renpy.Color("${toHex(color, false, false)}"${color.a < 1 ? ', alpha=' + decimal(color.a) : ''})`; + const harmony = (color, kind) => { + const hsl = rgbToHsl(color); + const offsets = {analogous: [-30, -15, 0, 15, 30], complementary: [0, 180], split: [0, 150, 210], triadic: [0, 120, 240], tetradic: [0, 90, 180, 270]}; + return (offsets[kind] || offsets.analogous).map(offset => hslToRgb({...hsl, h: hsl.h + offset})); + }; + const shades = color => { + const base = rgbToOklab(color); + if (base.l < .06 || base.l > .94) return [.98, .92, .85, .76, .67, .58, .49, .4, .3, .2, .1].map(l => oklabToRgb({l, x: 0, y: 0, a: color.a})); + return [.95, .85, .7, .5, .25, 0, -.2, -.4, -.6, -.75, -.88].map(amount => { + const target = amount >= 0 ? 1 : 0, mix = Math.abs(amount); + return oklabToRgb({l: base.l * (1 - mix) + target * mix, x: base.x * (1 - mix), y: base.y * (1 - mix), a: color.a}); + }); + }; + + window.ESDocColors = Object.freeze({ + clamp, decimal, parseHex, shortenHex, toHex, parseNumber, + hsvToRgb, rgbToHsv, rgbToHsl, pythonRgb, pythonRgba, cssHsl, + hslToRgb, parseColor, cssRgb, renpyColor, rgbToOklab, oklabToRgb, cssOklch, + luminance, composite, contrast, harmony, shades, + }); +})(); diff --git a/static/js/colors-named.js b/static/js/colors-named.js new file mode 100644 index 0000000..e80983d --- /dev/null +++ b/static/js/colors-named.js @@ -0,0 +1,1015 @@ +// Named colors supplied in charColorCodes.rpy. Source order and names are preserved. +window.ESDocNamedColors = Object.freeze([ + Object.freeze(["Белый", "#ffffff"]), + Object.freeze(["Дымчато-белый", "#f5f5f5"]), + Object.freeze(["Синяя Элис", "#f0f8ff"]), + Object.freeze(["Призрачно-белый", "#f8f8ff"]), + Object.freeze(["Магнолия", "#f8f4ff"]), + Object.freeze(["Лаванда", "#e6e6fa"]), + Object.freeze(["Барвинок Крайола", "#c5d0e6"]), + Object.freeze(["Светлый стальной синий", "#b0c4de"]), + Object.freeze(["Очень светлый синий", "#a6bdd7"]), + Object.freeze(["Ниагара", "#9db1cc"]), + Object.freeze(["Дикий синий Крайола", "#a2add0"]), + Object.freeze(["Колокольчик Крайола", "#a2a2d0"]), + Object.freeze(["Очень светлый пурпурно-синий", "#baacc7"]), + Object.freeze(["Кадетский синий Крайола", "#b0b7c6"]), + Object.freeze(["Серебряный", "#c0c0c0"]), + Object.freeze(["Светло-серый", "#bbbbbb"]), + Object.freeze(["Агатовый серый", "#b5b8b1"]), + Object.freeze(["Светло-серебристый", "#c9c0bb"]), + Object.freeze(["Серебряный Крайола", "#cdc5c2"]), + Object.freeze(["Лесной волк", "#dbd7d2"]), + Object.freeze(["Гейнсборо", "#dcdcdc"]), + Object.freeze(["Светлый серый", "#d7d7d7"]), + Object.freeze(["Бороды Абдель-Керима", "#d5d5d5"]), + Object.freeze(["Светлый телегрей", "#d0d0d0"]), + Object.freeze(["Гридеперлевый", "#c7d0cc"]), + Object.freeze(["Очень бледный синий", "#c1caca"]), + Object.freeze(["Очень светлый зеленовато-синий", "#a3c6c0"]), + Object.freeze(["Светлый серо-синий", "#84c3be"]), + Object.freeze(["Пастельно-бирюзовый", "#7fb5b5"]), + Object.freeze(["Кадетский синий", "#5f9ea0"]), + Object.freeze(["Пастельно-синий", "#5d9b9b"]), + Object.freeze(["Светлый зеленовато-синий", "#649a9e"]), + Object.freeze(["Бирюзово-синий", "#3f888f"]), + Object.freeze(["Мятно-бирюзовый", "#497e76"]), + Object.freeze(["Ядовито-зелёный", "#40826d"]), + Object.freeze(["Светлый синевато-зелёный", "#669e85"]), + Object.freeze(["Лиственный зелёный Крайола", "#6dae81"]), + Object.freeze(["Папоротник Крайола", "#71bc78"]), + Object.freeze(["Блестящий зелёный", "#47a76a"]), + Object.freeze(["Умеренно-зелёное море", "#3cb371"]), + Object.freeze(["Нефритовый", "#00a86b"]), + Object.freeze(["Зелёный Крайола", "#1cac78"]), + Object.freeze(["Зелёный трилистник", "#009a63"]), + Object.freeze(["Зелёное море", "#2e8b57"]), + Object.freeze(["Транспортный зелёный", "#308446"]), + Object.freeze(["Сигнальный зелёный", "#317f43"]), + Object.freeze(["Изумрудно-зелёный", "#287233"]), + Object.freeze(["Травяной зелёный", "#35682d"]), + Object.freeze(["Папоротниково-зелёный", "#3d642d"]), + Object.freeze(["Зелёный папоротник", "#4f7942"]), + Object.freeze(["Умеренный желтовато-зелёный", "#657f4b"]), + Object.freeze(["Резедово-зелёный", "#587246"]), + Object.freeze(["Тёмно-оливковый", "#556832"]), + Object.freeze(["Глубокий жёлтый зелёный", "#425e17"]), + Object.freeze(["Умеренный оливково-зелёный", "#434b1b"]), + Object.freeze(["Тёмный желтовато-зелёный", "#304b26"]), + Object.freeze(["Миртовый", "#21421e"]), + Object.freeze(["Глубокий зелёный", "#004524"]), + Object.freeze(["Очень глубокий жёлто-зелёный", "#002800"]), + Object.freeze(["Глубокий оливково-зелёный", "#142300"]), + Object.freeze(["Очень тёмный жёлто-зелёный", "#132712"]), + Object.freeze(["Тёмный оливково-зелёный", "#232c16"]), + Object.freeze(["Хромовый зелёный", "#2e3a23"]), + Object.freeze(["Тёмный зелёный", "#203a27"]), + Object.freeze(["Фталоцианиновый зелёный", "#123524"]), + Object.freeze(["Тёмно-зелёный", "#013220"]), + Object.freeze(["Глубокий синевато-зелёный", "#00382b"]), + Object.freeze(["Тёмный синевато-зелёный", "#013a33"]), + Object.freeze(["Цвет Красного моря", "#1f4037"]), + Object.freeze(["Зелёный мох", "#2f4538"]), + Object.freeze(["Аспидно-серый", "#2f4f4f"]), + Object.freeze(["Умеренный зеленовато-синий", "#30626b"]), + Object.freeze(["Тёмно-бирюзовый", "#116062"]), + Object.freeze(["Мурена", "#1c6b72"]), + Object.freeze(["Водная синь", "#256d7b"]), + Object.freeze(["Насыщенный зеленовато-синий", "#00677e"]), + Object.freeze(["Перламутровый горечавково-синий", "#2a6478"]), + Object.freeze(["Лазурно-синий", "#025669"]), + Object.freeze(["Зелёный орел", "#004953"]), + Object.freeze(["Тёмный зеленовато-синий", "#003841"]), + Object.freeze(["Перламутровый опаловый", "#193737"]), + Object.freeze(["Сине-зелёный", "#1f3a3d"]), + Object.freeze(["Зелёно-синий", "#1f3438"]), + Object.freeze(["Антрацитово-серый", "#293133"]), + Object.freeze(["Тёмный серо-синий", "#2c3337"]), + Object.freeze(["Гранитовый серый", "#2f353b"]), + Object.freeze(["Чёрно-зелёный", "#343e40"]), + Object.freeze(["Космос", "#414a4c"]), + Object.freeze(["Железно-серый", "#434b4d"]), + Object.freeze(["Синий серый", "#474b4e"]), + Object.freeze(["Тёмный синевато-чёрный", "#464544"]), + Object.freeze(["Ливерный", "#534b4f"]), + Object.freeze(["Цвет мокрого асфальта", "#505050"]), + Object.freeze(["Зеленоватый мокрый асфальт", "#4e5452"]), + Object.freeze(["Базальтово-серый", "#4e5754"]), + Object.freeze(["Брезентово-серый", "#4c514a"]), + Object.freeze(["Фельдграу", "#4d5d53"]), + Object.freeze(["Серовато-зелёный", "#575e4e"]), + Object.freeze(["Зелёно-серый", "#4d5645"]), + Object.freeze(["Серая спаржа", "#465945"]), + Object.freeze(["Винтовочный зелёный", "#414833"]), + Object.freeze(["Оливково-зелёный", "#424632"]), + Object.freeze(["Бутылочно-зелёный", "#343b29"]), + Object.freeze(["Пихтовый зелёный", "#31372b"]), + Object.freeze(["Тёмный зеленовато-жёлто-зелёный", "#313830"]), + Object.freeze(["Чёрно-оливковый", "#3b3c36"]), + Object.freeze(["Серый оливковый", "#3e3b32"]), + Object.freeze(["Коричнево-зелёный", "#39352a"]), + Object.freeze(["Серая умбра", "#332f2c"]), + Object.freeze(["Серый коричневый", "#403a3a"]), + Object.freeze(["Тёмно-серый", "#49423d"]), + Object.freeze(["Тёмный зеленовато-серый", "#45433b"]), + Object.freeze(["Тёмно-серо-коричневый", "#483c32"]), + Object.freeze(["Оливковый серый", "#4d4234"]), + Object.freeze(["Жёлто-оливковый", "#47402e"]), + Object.freeze(["Коричневый серый", "#464531"]), + Object.freeze(["Сероватый оливково-зелёный", "#48442d"]), + Object.freeze(["Серовато-оливковый", "#52442c"]), + Object.freeze(["Очень тёмный хаки", "#4c3c18"]), + Object.freeze(["Очень тёмный оливковый", "#362c12"]), + Object.freeze(["Тёмный желтовато-коричневый", "#3f2512"]), + Object.freeze(["Тёмный коричневый", "#35170c"]), + Object.freeze(["Ивово-коричневый", "#321414"]), + Object.freeze(["Тёмный красно-коричневый", "#321011"]), + Object.freeze(["Бурый", "#45161c"]), + Object.freeze(["Очень тёмный красный", "#320a18"]), + Object.freeze(["Очень тёмный пурпурно-красный", "#28071a"]), + Object.freeze(["Очень тёмный красно-пурпурный", "#270a1f"]), + Object.freeze(["Очень тёмно-пурпурный", "#230d21"]), + Object.freeze(["Тёмный пурпурно-синий", "#1a162a"]), + Object.freeze(["Сапфирово-синий", "#1d1e33"]), + Object.freeze(["Кобальтово-синий", "#1e213d"]), + Object.freeze(["Ночной синий", "#252850"]), + Object.freeze(["Умеренный пурпурно-синий", "#423c63"]), + Object.freeze(["Умеренный фиолетовый", "#543964"]), + Object.freeze(["Сине-сиреневый", "#6c4675"]), + Object.freeze(["Серобуромалиновый", "#735184"]), + Object.freeze(["Насыщенный фиолетовый", "#53377a"]), + Object.freeze(["Насыщенный пурпурно-синий", "#474389"]), + Object.freeze(["Тёмный аспидно-синий", "#483d8b"]), + Object.freeze(["Глубокий фиолетовый", "#423189"]), + Object.freeze(["Синяя пыль", "#003399"]), + Object.freeze(["Синий экран смерти", "#122faa"]), + Object.freeze(["Кобальт синий", "#0047ab"]), + Object.freeze(["Фиолетово-синий Крайола", "#324ab2"]), + Object.freeze(["Cиняя лазурь", "#2a52be"]), + Object.freeze(["Королевский синий", "#4169e1"]), + Object.freeze(["Синий Крайола", "#1f75fe"]), + Object.freeze(["Синий Градуса", "#007dff"]), + Object.freeze(["Лазурный", "#007fff"]), + Object.freeze(["Защитно-синий", "#1e90ff"]), + Object.freeze(["Электрик", "#2c75ff"]), + Object.freeze(["Аква", "#008cf0"]), + Object.freeze(["Тёмно-синий Крайола", "#1974d2"]), + Object.freeze(["Светлый джинсовый", "#2b6cc4"]), + Object.freeze(["Джинсовый синий", "#1560bd"]), + Object.freeze(["Зелёно-синий Крайола", "#1164b4"]), + Object.freeze(["Синий Клейна", "#3a75c4"]), + Object.freeze(["Небесно-синий", "#2271b3"]), + Object.freeze(["Средний персидский синий", "#0067a5"]), + Object.freeze(["Насыщенный синий", "#00538a"]), + Object.freeze(["Капри синий", "#1b5583"]), + Object.freeze(["Полуночный синий Крайола", "#1a4876"]), + Object.freeze(["Мертвенный индиго", "#00416a"]), + Object.freeze(["Фиолетово-синий", "#354d73"]), + Object.freeze(["Умеренный синий", "#395778"]), + Object.freeze(["Бриллиантово-синий", "#3e5f8a"]), + Object.freeze(["Отдалённо-синий", "#49678d"]), + Object.freeze(["ВКонтакте", "#4d7198"]), + Object.freeze(["Голубино-синий", "#606e8c"]), + Object.freeze(["Светлый пурпурно-синий", "#837da2"]), + Object.freeze(["Перламутрово-фиолетовый", "#8673a1"]), + Object.freeze(["Светло-фиолетовый", "#876c99"]), + Object.freeze(["Пурпурное горное величие", "#9d81ba"]), + Object.freeze(["Фиолетовый Крайола", "#926eae"]), + Object.freeze(["Блестящий фиолетовый", "#755d9a"]), + Object.freeze(["Блестящий пурпурно-синий", "#62639b"]), + Object.freeze(["Фейсбука", "#3b5998"]), + Object.freeze(["Цвет Чёрного моря", "#1a4780"]), + Object.freeze(["Тёмно-лазурный", "#08457e"]), + Object.freeze(["Транспортный синий", "#063971"]), + Object.freeze(["Полуночно-синий", "#003366"]), + Object.freeze(["Перламутровый ночной", "#102c54"]), + Object.freeze(["Горечавково-синий", "#0e294b"]), + Object.freeze(["Глубокий синий", "#002f55"]), + Object.freeze(["Берлинская лазурь", "#003153"]), + Object.freeze(["Океанская синь", "#1d334a"]), + Object.freeze(["Тёмно-синий", "#002137"]), + Object.freeze(["Очень тёмный зеленовато-синий", "#022027"]), + Object.freeze(["Очень тёмный синевато-зелёный", "#001d18"]), + Object.freeze(["Очень тёмный зелёный", "#16251c"]), + Object.freeze(["Оливково-чёрный", "#121910"]), + Object.freeze(["Черновато-зелёный", "#141613"]), + Object.freeze(["Почти чёрный", "#131313"]), + Object.freeze(["Зеленовато-чёрный", "#181513"]), + Object.freeze(["Коричневато-чёрный", "#140f0b"]), + Object.freeze(["Чёрный", "#000000"]), + Object.freeze(["Чёрный янтарь", "#0a0a0a"]), + Object.freeze(["Синевато-чёрный", "#151719"]), + Object.freeze(["Черновато-синий", "#161a1e"]), + Object.freeze(["Чёрно-синий", "#18171c"]), + Object.freeze(["Пурпурно-чёрный", "#1b1116"]), + Object.freeze(["Красновато-чёрный", "#1e1112"]), + Object.freeze(["Черновато-красный", "#1f0e11"]), + Object.freeze(["Черновато-пурпурный", "#1d1018"]), + Object.freeze(["Стальной синий", "#231a24"]), + Object.freeze(["Серый синий", "#26252d"]), + Object.freeze(["Чёрно-серый", "#23282b"]), + Object.freeze(["Сигнальный чёрный", "#282828"]), + Object.freeze(["Чёрно-коричневый", "#212121"]), + Object.freeze(["Транспортный чёрный", "#1e1e1e"]), + Object.freeze(["Графитно-чёрный", "#1c1c1c"]), + Object.freeze(["Коричнево-оливковый", "#25221b"]), + Object.freeze(["Тёмный серо-оливково-зелёный", "#27261a"]), + Object.freeze(["Тёмный серо-оливковый", "#2b2517"]), + Object.freeze(["Тёмный оливково-коричневый", "#302112"]), + Object.freeze(["Сепия коричневый", "#382c1e"]), + Object.freeze(["Бистр", "#3d2b1f"]), + Object.freeze(["Тёмный серо-коричневый", "#32221a"]), + Object.freeze(["Тёмный серо-красно-коричневый", "#371f1c"]), + Object.freeze(["Чёрно-красный", "#412227"]), + Object.freeze(["Тёмный черновато-пурпурный", "#452d35"]), + Object.freeze(["Тёмный серо-красный", "#482a2a"]), + Object.freeze(["Махагон коричневый", "#4c2f27"]), + Object.freeze(["Кофейный", "#442d25"]), + Object.freeze(["Шоколадно-коричневый", "#45322e"]), + Object.freeze(["Тёмный терракотовый", "#4e3b31"]), + Object.freeze(["Коричневато-серый", "#503d33"]), + Object.freeze(["Тёмный красно-серый", "#523c36"]), + Object.freeze(["Тёмный пурпурно-серый", "#564042"]), + Object.freeze(["Розово-эбонитовый", "#674846"]), + Object.freeze(["Умеренный серо-коричневый", "#674c47"]), + Object.freeze(["Блошиный", "#755a57"]), + Object.freeze(["Серовато-пурпурный", "#72525c"]), + Object.freeze(["Баклажановый Крайола", "#6e5160"]), + Object.freeze(["Баклажановый", "#614051"]), + Object.freeze(["Тёмная Византия", "#5d3954"]), + Object.freeze(["Красно-сиреневый", "#6d3f5b"]), + Object.freeze(["Умеренный пурпурный", "#7f4870"]), + Object.freeze(["Сигнальный фиолетовый", "#924e7d"]), + Object.freeze(["Умеренный красно-пурпурный", "#8c4566"]), + Object.freeze(["Серовато-красно-пурпурный", "#7d4d5d"]), + Object.freeze(["Темно-серая мальва", "#915f6d"]), + Object.freeze(["Розово-серо-коричневый", "#905d5d"]), + Object.freeze(["Медно-розовый", "#996666"]), + Object.freeze(["Тёмно-каштановый", "#986960"]), + Object.freeze(["Светлый серо-красно-коричневый", "#966a57"]), + Object.freeze(["Светлый серо-коричневый", "#946b54"]), + Object.freeze(["Светло-коричневый", "#987654"]), + Object.freeze(["Коричнево-бежевый", "#8a6642"]), + Object.freeze(["Светлый серо-оливковый", "#8b734b"]), + Object.freeze(["Тёмный жёлто-коричневый", "#918151"]), + Object.freeze(["Серовато-жёлто-зелёный", "#90845b"]), + Object.freeze(["Жёлто-серый", "#8f8b66"]), + Object.freeze(["Серо-бежевый", "#9e9764"]), + Object.freeze(["Оливково-жёлтый", "#999950"]), + Object.freeze(["Умеренный жёлто-зелёный", "#8b8940"]), + Object.freeze(["Спаржа", "#7ba05b"]), + Object.freeze(["Спаржа Крайола", "#87a96b"]), + Object.freeze(["Бледно-зелёный", "#89ac76"]), + Object.freeze(["Тёмное зелёное море", "#8fbc8f"]), + Object.freeze(["Очень светлый зелёный", "#98c793"]), + Object.freeze(["Тёмный зелёный чай", "#badbad"]), + Object.freeze(["Зелёный чай", "#d0f0c0"]), + Object.freeze(["Бело-зелёный", "#bdecb6"]), + Object.freeze(["Зелёный лишайник", "#addfad"]), + Object.freeze(["Селадон", "#ace1af"]), + Object.freeze(["Бабушкины яблоки", "#a8e4a0"]), + Object.freeze(["Очень светлый жёлто-зелёный", "#c6df90"]), + Object.freeze(["Обычный весенний бутон", "#c9dc87"]), + Object.freeze(["Жёлто-зелёный Крайола", "#c5e384"]), + Object.freeze(["Вердепешевый", "#dad871"]), + Object.freeze(["Светлый жёлто-зелёный", "#dcd36a"]), + Object.freeze(["Жёлтая слоновая кость", "#e1cc4f"]), + Object.freeze(["Старое золото", "#cfb53b"]), + Object.freeze(["Насыщенный зеленовато-жёлтый", "#cca817"]), + Object.freeze(["Жёлтый ракитник", "#d6ae01"]), + Object.freeze(["Тёмно-грушевый", "#d8a903"]), + Object.freeze(["Золотисто-березовый", "#daa520"]), + Object.freeze(["Нарциссово-жёлтый", "#dc9d00"]), + Object.freeze(["Кукурузно-жёлтый", "#e4a010"]), + Object.freeze(["Насыщенный жёлтый", "#e59e1f"]), + Object.freeze(["Гуммигут", "#e49b0f"]), + Object.freeze(["Почти чистый оранжевый", "#e28b00"]), + Object.freeze(["Светло-морковный", "#ed9121"]), + Object.freeze(["Умеренный оранжево-жёлтый", "#f7943c"]), + Object.freeze(["Неоново-морковный", "#ffa343"]), + Object.freeze(["Жёлто-оранжевый Крайола", "#ffae42"]), + Object.freeze(["Блестящий оранжевый", "#ffb841"]), + Object.freeze(["Бриллиантовый оранжево-жёлтый", "#ffb02e"]), + Object.freeze(["Солнечно-жёлтый", "#f39f18"]), + Object.freeze(["Люминесцентный ярко-оранжевый", "#ffa420"]), + Object.freeze(["Сигнальный оранжевый цвет", "#ff9900"]), + Object.freeze(["Кожура апельсина", "#ffa000"]), + Object.freeze(["Оранжевый", "#ffa500"]), + Object.freeze(["Тёмно-мандариновый", "#ffa812"]), + Object.freeze(["Георгиново-жёлтый", "#f3a505"]), + Object.freeze(["Дынно-жёлтый", "#f4a900"]), + Object.freeze(["Ярко-жёлтый", "#ffb300"]), + Object.freeze(["Отборный жёлтый", "#ffba00"]), + Object.freeze(["Янтарный", "#ffbf00"]), + Object.freeze(["Цвет Яндекса", "#ffcc00"]), + Object.freeze(["Яркий зеленовато-жёлтый", "#f4c800"]), + Object.freeze(["Сигнальный жёлтый", "#e5be01"]), + Object.freeze(["Шафрановый", "#f4c430"]), + Object.freeze(["Блестящий жёлтый", "#ffcf40"]), + Object.freeze(["Восход солнца", "#ffcf48"]), + Object.freeze(["Шафраново-жёлтый", "#f5d033"]), + Object.freeze(["Грушевый", "#efd334"]), + Object.freeze(["Блестящий зеленовато-жёлтый", "#ffdc33"]), + Object.freeze(["Транспортно-жёлтый", "#fad201"]), + Object.freeze(["Золотой", "#ffd700"]), + Object.freeze(["Жёлтого школьного автобуса", "#ffd800"]), + Object.freeze(["Рапсово-жёлтый", "#f3da0b"]), + Object.freeze(["Лимонный", "#fde910"]), + Object.freeze(["Цинково-жёлтый", "#f8f32b"]), + Object.freeze(["Цвет детской неожиданности", "#f7f21a"]), + Object.freeze(["Жёлтый", "#ffff00"]), + Object.freeze(["Лазерный лимон", "#fefe22"]), + Object.freeze(["Жёлтая сера", "#edff21"]), + Object.freeze(["Электрик лайм Крайола", "#ceff1d"]), + Object.freeze(["Электрик лайм", "#ccff00"]), + Object.freeze(["Зелёно-лаймовый", "#bfff00"]), + Object.freeze(["Зелёно-жёлтый", "#adff2f"]), + Object.freeze(["Весенний бутон", "#a7fc00"]), + Object.freeze(["Шартрёз", "#7fff00"]), + Object.freeze(["Зелёная лужайка", "#7cfc00"]), + Object.freeze(["Ярко-зелёный", "#66ff00"]), + Object.freeze(["Лайм", "#00ff00"]), + Object.freeze(["Лаймово-зелёный", "#32cd32"]), + Object.freeze(["Вердепомовый", "#34c924"]), + Object.freeze(["Ирландский зелёный", "#4cbb17"]), + Object.freeze(["Тёмный пастельно-зелёный", "#03c03c"]), + Object.freeze(["Малахитовый", "#0bda51"]), + Object.freeze(["Кричащий зелёный", "#76ff7a"]), + Object.freeze(["Весенне-зелёный", "#00ff7f"]), + Object.freeze(["Умеренный весенний зелёный", "#00fa9a"]), + Object.freeze(["Морской зелёный", "#54ff9f"]), + Object.freeze(["Салатовый", "#99ff99"]), + Object.freeze(["Зелёной мяты", "#98ff98"]), + Object.freeze(["Бледный зелёный", "#98fb98"]), + Object.freeze(["Светло-зелёный", "#90ee90"]), + Object.freeze(["Пастельно-зелёный", "#77dd77"]), + Object.freeze(["Изумрудный", "#50c878"]), + Object.freeze(["Пигментный зелёный", "#00a550"]), + Object.freeze(["Лесной зелёный", "#228b22"]), + Object.freeze(["Зелёный", "#008000"]), + Object.freeze(["Индийский зелёный", "#138808"]), + Object.freeze(["Мусульманский зелёный", "#009900"]), + Object.freeze(["Влюблённой жабы", "#3caa3c"]), + Object.freeze(["Тёмный жёлто-зелёный", "#57a639"]), + Object.freeze(["Травяной", "#5da130"]), + Object.freeze(["Блестящий желтовато-зелёный", "#8ccb5e"]), + Object.freeze(["Фисташковый", "#bef574"]), + Object.freeze(["Гусеница", "#b2ec5d"]), + Object.freeze(["Июньский бутон", "#bdda57"]), + Object.freeze(["Жёлто-зелёный", "#9acd32"]), + Object.freeze(["Яркий жёлто-зелёный", "#93aa00"]), + Object.freeze(["Блестящий жёлто-зелёный", "#ced23a"]), + Object.freeze(["Грушево-зелёный", "#d1e231"]), + Object.freeze(["Незрелый жёлтый", "#ffff66"]), + Object.freeze(["Лимонно-жёлтый Крайола", "#fff44f"]), + Object.freeze(["Кукурузный", "#fbec5d"]), + Object.freeze(["Светлый зеленовато-жёлтый", "#ffde5a"]), + Object.freeze(["Горчичный", "#ffdb58"]), + Object.freeze(["Светлый глубокий жёлтый", "#ffd35f"]), + Object.freeze(["Оранжево-жёлтый Крайола", "#f8d568"]), + Object.freeze(["Одуванчиковый", "#fddb6d"]), + Object.freeze(["Золотарник Крайола", "#fcd975"]), + Object.freeze(["Песочный", "#fcdd76"]), + Object.freeze(["Жёлтый Крайола", "#fce883"]), + Object.freeze(["Светло-золотистый", "#ffec8b"]), + Object.freeze(["Канареечный, ярко-жёлтый", "#ffff99"]), + Object.freeze(["Зелёно-жёлтый Крайола", "#f0e891"]), + Object.freeze(["Светлый хаки", "#f0e68c"]), + Object.freeze(["Старый лен", "#eedc82"]), + Object.freeze(["Кожа буйвола, палевый", "#f0dc82"]), + Object.freeze(["Бледный зеленовато-жёлтый", "#ffdf84"]), + Object.freeze(["Бледно-жёлтый", "#ffdb8b"]), + Object.freeze(["Светлая слоновая кость", "#e6d690"]), + Object.freeze(["Бледный жёлто-зелёный", "#f0d698"]), + Object.freeze(["Светло-песочный", "#fdeaa8"]), + Object.freeze(["Пергидролевой блондинки", "#eee6a3"]), + Object.freeze(["Бледно-золотистый", "#eee8aa"]), + Object.freeze(["Бананомания", "#fae7b5"]), + Object.freeze(["Пшеничный", "#f5deb3"]), + Object.freeze(["Желтовато-белый", "#ffe2b7"]), + Object.freeze(["Мокасиновый", "#ffe4b5"]), + Object.freeze(["Персиковый", "#ffe5b4"]), + Object.freeze(["Жёлто-розовый", "#ffe4b2"]), + Object.freeze(["Медовый", "#fee5ac"]), + Object.freeze(["Белый навахо", "#ffdead"]), + Object.freeze(["Жёлто-персиковый", "#fadfad"]), + Object.freeze(["Маисовый", "#edd19c"]), + Object.freeze(["Золотой Крайола", "#e7c697"]), + Object.freeze(["Оранжево-персиковый", "#ffcc99"]), + Object.freeze(["Бледный оранжево-жёлтый", "#ffca86"]), + Object.freeze(["Макароны и сыр", "#ffbd88"]), + Object.freeze(["Светлый жёлто-розовый", "#ffb28b"]), + Object.freeze(["Сомон", "#efaf8c"]), + Object.freeze(["Перекати-поле", "#deaa88"]), + Object.freeze(["Желтовато-серый", "#caa885"]), + Object.freeze(["Цвет загара", "#d2b48c"]), + Object.freeze(["Экрю", "#cdb891"]), + Object.freeze(["Кремовый хаки", "#c3b091"]), + Object.freeze(["Светлый зеленовато-белый", "#baaf96"]), + Object.freeze(["Галечный серый", "#b8b799"]), + Object.freeze(["Болотный", "#acb78e"]), + Object.freeze(["Зелёно-бежевый", "#bebd7f"]), + Object.freeze(["Оливково-зелёный Крайола", "#bab86c"]), + Object.freeze(["Тёмный хаки", "#bdb76b"]), + Object.freeze(["Жёлтая охра", "#aea04b"]), + Object.freeze(["Латунный", "#b5a642"]), + Object.freeze(["Лимонно-жёлтый", "#c7b446"]), + Object.freeze(["Умеренный зеленовато-жёлтый", "#c4a43d"]), + Object.freeze(["Жёлто-золотой", "#cda434"]), + Object.freeze(["Умеренный жёлтый", "#d79d41"]), + Object.freeze(["Пастельно-жёлтый", "#efa94a"]), + Object.freeze(["Насыщенный красно-оранжевый", "#ffb961"]), + Object.freeze(["Красный песок", "#f4a460"]), + Object.freeze(["Цвет загара Крайола", "#faa76c"]), + Object.freeze(["Светлый оранжевый", "#ffa161"]), + Object.freeze(["Оранжево-розовый", "#ff9966"]), + Object.freeze(["Цвет маленького мандарина", "#ffa474"]), + Object.freeze(["Сырая охра", "#d68a59"]), + Object.freeze(["Медный Крайола", "#dd9475"]), + Object.freeze(["Тёмно-лососёвый", "#e9967a"]), + Object.freeze(["Умеренный жёлто-розовый", "#ee9374"]), + Object.freeze(["Ярко-мандариновый", "#ffa089"]), + Object.freeze(["Умеренный розовый", "#ee9086"]), + Object.freeze(["Тёмный жёлто-розовый", "#cc6c5c"]), + Object.freeze(["Серовато-красно-оранжевый", "#b85d43"]), + Object.freeze(["Коричневый Крайола", "#b4674d"]), + Object.freeze(["Светлый коричневый", "#a86540"]), + Object.freeze(["Сепия Крайола", "#a5694f"]), + Object.freeze(["Светлый красно-коричневый", "#aa6651"]), + Object.freeze(["Светлый серо-красный", "#b17267"]), + Object.freeze(["Марсала", "#ad655f"]), + Object.freeze(["Светлый серо-пурпурно-красный", "#b27070"]), + Object.freeze(["Старинный розовый", "#c08081"]), + Object.freeze(["Пюсовый", "#cc8899"]), + Object.freeze(["Оперный розовато-лиловый", "#b784a7"]), + Object.freeze(["Светло-пурпурный", "#ba7fa2"]), + Object.freeze(["Светлый красно-пурпурный", "#bb6c8a"]), + Object.freeze(["Розово-золотой", "#b76e79"]), + Object.freeze(["Турецкий розовый", "#b57281"]), + Object.freeze(["Бледный красно-пурпурный", "#ac7580"]), + Object.freeze(["Тусклый пурпурный", "#ae848b"]), + Object.freeze(["Розово-коричневый", "#bc8f8f"]), + Object.freeze(["Серовато-пурпурно-розовый", "#cc9293"]), + Object.freeze(["Серовато-розовый", "#cf9b8f"]), + Object.freeze(["Серовато-жёлто-розовый", "#d39b85"]), + Object.freeze(["Коричневато-розовый", "#cd9a7b"]), + Object.freeze(["Античная латунь", "#cd9575"]), + Object.freeze(["Бежево-красный", "#c1876b"]), + Object.freeze(["Светлый серо-жёлто-коричневый", "#b48764"]), + Object.freeze(["Цвет увядших листьев", "#c19a6b"]), + Object.freeze(["Песочно-жёлтый", "#c6a664"]), + Object.freeze(["Серовато-зеленовато-жёлтый", "#c4a55f"]), + Object.freeze(["Серовато-жёлтый", "#cea262"]), + Object.freeze(["Светлый жёлто-коричневый", "#bb8b54"]), + Object.freeze(["Тёмный серо-жёлтый", "#a47c45"]), + Object.freeze(["Шамуа", "#a08040"]), + Object.freeze(["Тёмный зеленовато-жёлтый", "#9b8127"]), + Object.freeze(["Песочный серо-коричневый", "#967117"]), + Object.freeze(["Светло-оливковый", "#846a20"]), + Object.freeze(["Хаки", "#806b2a"]), + Object.freeze(["Зелёно-коричневый", "#826c34"]), + Object.freeze(["Серый хаки", "#6a5f31"]), + Object.freeze(["Умеренно-оливковый", "#5e490f"]), + Object.freeze(["В меру оливково-коричневый", "#64400f"]), + Object.freeze(["Натуральная умбра", "#734a12"]), + Object.freeze(["Сепия", "#704214"]), + Object.freeze(["Глиняный коричневый", "#734222"]), + Object.freeze(["Умеренный жёлто-коричневый", "#7d512d"]), + Object.freeze(["Сырая умбра", "#714b23"]), + Object.freeze(["Тёмно-коричневый", "#654321"]), + Object.freeze(["Оливково-коричневый", "#6f4f28"]), + Object.freeze(["Перламутрово-золотой", "#705335"]), + Object.freeze(["Бежево-коричневый", "#79553d"]), + Object.freeze(["Серовато-жёлто-коричневый", "#785840"]), + Object.freeze(["Бледно-коричневый", "#755c48"]), + Object.freeze(["Перламутрово-бежевый", "#6a5d4d"]), + Object.freeze(["Бежево-серый", "#6d6552"]), + Object.freeze(["Серый мох", "#6c7059"]), + Object.freeze(["Тростниково-зелёный", "#6c7156"]), + Object.freeze(["Защитный хаки", "#78866b"]), + Object.freeze(["Лягушка в обмороке", "#7b917b"]), + Object.freeze(["Бледно-зелёно-серый", "#8d917a"]), + Object.freeze(["Каменно-серый", "#8b8c7a"]), + Object.freeze(["Цементно-серый", "#7d8471"]), + Object.freeze(["Зеленовато-серый", "#7a7666"]), + Object.freeze(["Кварцевый серый", "#6c6960"]), + Object.freeze(["Серый бетон", "#686c5e"]), + Object.freeze(["Мышино-серый", "#646b63"]), + Object.freeze(["Тусклый серый", "#696969"]), + Object.freeze(["Перламутрово-ежевичный", "#6c6874"]), + Object.freeze(["Маренго", "#4c5866"]), + Object.freeze(["Серовато-синий", "#4a545c"]), + Object.freeze(["Графитовый серый", "#474a51"]), + Object.freeze(["Сланцево-серый", "#434750"]), + Object.freeze(["Антрацитовый", "#464451"]), + Object.freeze(["Серовато-пурпурно-синий", "#413d51"]), + Object.freeze(["Серовато-фиолетовый", "#46394b"]), + Object.freeze(["Тёмно-пурпурный", "#472a3f"]), + Object.freeze(["Тёмный красно-пурпурный", "#4f273a"]), + Object.freeze(["Пурпурно-фиолетовый", "#4a192c"]), + Object.freeze(["Очень глубокий пурпурно-красный", "#470027"]), + Object.freeze(["Очень глубокий красно-пурпурный", "#470736"]), + Object.freeze(["Очень глубокий пурпурный", "#320b35"]), + Object.freeze(["Глубокий фиолетово-чёрный", "#240935"]), + Object.freeze(["Глубокий пурпурно-синий", "#1a153f"]), + Object.freeze(["Ультрамариново-синий", "#20214f"]), + Object.freeze(["Сигнальный синий", "#1e2460"]), + Object.freeze(["Сапфировый", "#082567"]), + Object.freeze(["Пурпурно-синий", "#20155e"]), + Object.freeze(["Полуночный чёрный", "#191970"]), + Object.freeze(["Тёмный индиго", "#310062"]), + Object.freeze(["Персидский индиго", "#32127a"]), + Object.freeze(["Индиго", "#4b0082"]), + Object.freeze(["Тёмный пурпурно-фиолетовый", "#660099"]), + Object.freeze(["Ультрамариновый", "#120a8f"]), + Object.freeze(["Формы морских офицеров", "#000080"]), + Object.freeze(["Тёмный ультрамариновый", "#00008b"]), + Object.freeze(["Синий", "#0000ff"]), + Object.freeze(["Персидский синий", "#6600ff"]), + Object.freeze(["Фиолетово-сизый", "#8000ff"]), + Object.freeze(["Фиолетовый", "#8b00ff"]), + Object.freeze(["Сине-лиловый", "#8a2be2"]), + Object.freeze(["Тёмно-фиолетовый", "#9400d3"]), + Object.freeze(["Тёмная орхидея", "#9932cc"]), + Object.freeze(["Пурпурное сердце", "#7442c8"]), + Object.freeze(["Умеренный аспидно-синий", "#7b68ee"]), + Object.freeze(["Аспидно-синий", "#6a5acd"]), + Object.freeze(["Средний пурпурный", "#9370d8"]), + Object.freeze(["Аметистовый", "#9966cc"]), + Object.freeze(["Королевский пурпурный Крайола", "#7851a9"]), + Object.freeze(["Сине-фиолетовый Крайола", "#7366bd"]), + Object.freeze(["Индиго Крайола", "#5d76cb"]), + Object.freeze(["Васильковый", "#6495ed"]), + Object.freeze(["Голубой", "#42aaff"]), + Object.freeze(["Твиттера", "#1faee9"]), + Object.freeze(["Морозное небо", "#00bfff"]), + Object.freeze(["Небесный", "#7fc7ff"]), + Object.freeze(["Светло-голубой", "#87cefa"]), + Object.freeze(["Городское небо", "#87ceeb"]), + Object.freeze(["Голубой Крайола", "#80daeb"]), + Object.freeze(["Бирюзово-голубой Крайола", "#77dde7"]), + Object.freeze(["Аквамариновый Крайола", "#78dbe2"]), + Object.freeze(["Циан, цвет морской волны", "#00ffff"]), + Object.freeze(["Ярко-бирюзовый", "#08e8de"]), + Object.freeze(["Светло-бирюзовый", "#40e0d0"]), + Object.freeze(["Бирюзовый", "#30d5c8"]), + Object.freeze(["Умеренно-бирюзовый", "#48d1cc"]), + Object.freeze(["Яиц странствующего дрозда", "#1fcecb"]), + Object.freeze(["Цвет яйца дрозда", "#00cccc"]), + Object.freeze(["Светлое зелёное море", "#20b2aa"]), + Object.freeze(["Персидский зелёный", "#00a693"]), + Object.freeze(["Блестящий синевато-зелёный", "#009b76"]), + Object.freeze(["Зелёные джунгли Крайола 90-го года", "#29ab87"]), + Object.freeze(["Зелёные джунгли Крайола", "#3bb08f"]), + Object.freeze(["Мятный", "#3eb489"]), + Object.freeze(["Горный луг", "#30ba8f"]), + Object.freeze(["Карибский зелёный", "#1cd3a2"]), + Object.freeze(["Трилистник Крайола", "#45cea2"]), + Object.freeze(["Умеренный аквамариновый", "#66cdaa"]), + Object.freeze(["Аквамариновый", "#7fffd4"]), + Object.freeze(["Магическая мята", "#aaf0d1"]), + Object.freeze(["Морской зелёный Крайола", "#9fe2bf"]), + Object.freeze(["Очень светлый синевато-зелёный", "#a0d6b4"]), + Object.freeze(["Умеренно зелёный", "#c0dcc0"]), + Object.freeze(["Серый зелёный чай", "#cadaba"]), + Object.freeze(["Очень бледный зелёный", "#d8deba"]), + Object.freeze(["Весенне-зелёный Крайола", "#eceabe"]), + Object.freeze(["Бледный весенний бутон", "#ecebbd"]), + Object.freeze(["Кремово-жёлтый", "#fffdd0"]), + Object.freeze(["Лимонно-кремовый", "#fffacd"]), + Object.freeze(["Светло-жёлтый золотистый", "#fafad2"]), + Object.freeze(["Светло-жёлтый", "#ffffe0"]), + Object.freeze(["Слоновой кости", "#fffddf"]), + Object.freeze(["Цвет пестиков неспелой кукурузы", "#fff8dc"]), + Object.freeze(["Шампанского", "#fcfcee"]), + Object.freeze(["Бежевый", "#f5f5dc"]), + Object.freeze(["Жемчужно-белый", "#eae6ca"]), + Object.freeze(["Сливочный", "#f2e8c9"]), + Object.freeze(["Зеленовато-белый", "#f5e6cb"]), + Object.freeze(["Тёмно-кремовый", "#f2ddc6"]), + Object.freeze(["Миндаль Крайола", "#efdecd"]), + Object.freeze(["Синевато-белый", "#f9dfcf"]), + Object.freeze(["Пурпурно-белый", "#fadbc8"]), + Object.freeze(["Песок пустыни", "#efcdb8"]), + Object.freeze(["Циннвальдит", "#ebc2af"]), + Object.freeze(["Бледно-песочный", "#dabdab"]), + Object.freeze(["Серый шёлк", "#cac4b0"]), + Object.freeze(["Светлый синевато-серый", "#beada1"]), + Object.freeze(["Светлый пурпурно-серый", "#c8a99e"]), + Object.freeze(["Розовато-серый", "#c8a696"]), + Object.freeze(["Серовато-оранжевый", "#c2a894"]), + Object.freeze(["Бледный серо-коричневый", "#bc987e"]), + Object.freeze(["Бобровый", "#9f8170"]), + Object.freeze(["Полумрак Крайола", "#8a795d"]), + Object.freeze(["Светлый оливковый серый", "#887359"]), + Object.freeze(["Светлый коричневато-серый", "#8b6d5c"]), + Object.freeze(["Красновато-серый", "#8b6c62"]), + Object.freeze(["Пурпурно-серый", "#88706b"]), + Object.freeze(["Средний серый", "#817066"]), + Object.freeze(["Синевато-серый", "#7d746d"]), + Object.freeze(["Перламутровый мышино-серый", "#898176"]), + Object.freeze(["Перламутровый тёмно-серый", "#828282"]), + Object.freeze(["Пыльно-серый", "#7d7f7d"]), + Object.freeze(["Серый", "#808080"]), + Object.freeze(["Платиново-серый", "#7f7679"]), + Object.freeze(["Бледный пурпурно-синий", "#8a7f8e"]), + Object.freeze(["Бледно-фиолетовый", "#957b8d"]), + Object.freeze(["Розовый Маунтбэттена", "#997a8d"]), + Object.freeze(["Пастельно-фиолетовый", "#a18594"]), + Object.freeze(["Розовый кварц", "#aa98a9"]), + Object.freeze(["Ламантин", "#979aaa"]), + Object.freeze(["Серое окно", "#9da1aa"]), + Object.freeze(["Серый нейтральный", "#a0a0a4"]), + Object.freeze(["Бело-алюминиевый", "#a5a5a5"]), + Object.freeze(["Перламутровый светло-серый", "#9c9c9c"]), + Object.freeze(["Бледный синий", "#919192"]), + Object.freeze(["Телегрей", "#909090"]), + Object.freeze(["Серый Крайола", "#95918c"]), + Object.freeze(["Кварцевый", "#99958c"]), + Object.freeze(["Сигнальный серый", "#969992"]), + Object.freeze(["Транспортный серый", "#8d948d"]), + Object.freeze(["Серебристо-серый", "#8a9597"]), + Object.freeze(["Тёмный телегрей", "#82898f"]), + Object.freeze(["Серая белка", "#78858b"]), + Object.freeze(["Серый шифер", "#708090"]), + Object.freeze(["Светлый аспидно-серый", "#778899"]), + Object.freeze(["Светлый синий серый", "#6c92af"]), + Object.freeze(["Сизый", "#79a0c1"]), + Object.freeze(["Цвет Хабрахабра", "#78a2b7"]), + Object.freeze(["Сине-серый Крайола", "#6699cc"]), + Object.freeze(["Блестящий синий", "#4285b4"]), + Object.freeze(["Синяя сталь", "#4682b4"]), + Object.freeze(["Тёмно-голубой", "#3b83bd"]), + Object.freeze(["Ярко-синий", "#007cad"]), + Object.freeze(["Лазурно-серый", "#007ba7"]), + Object.freeze(["Воды пляжа Бонди", "#0095b6"]), + Object.freeze(["Сине-зелёный Крайола", "#0d98ba"]), + Object.freeze(["Лазурный Крайола", "#1dacd6"]), + Object.freeze(["Тихоокеанский синий", "#1ca9c9"]), + Object.freeze(["Синий чирок", "#18a7b5"]), + Object.freeze(["Блестящий зеленовато-синий", "#2a8d9c"]), + Object.freeze(["Тёмный циан", "#008b8b"]), + Object.freeze(["Окраски птицы чирок", "#008080"]), + Object.freeze(["Зелёная сосна Крайола", "#158078"]), + Object.freeze(["Зелёная сосна", "#01796f"]), + Object.freeze(["Мокрый тропический лес", "#17806d"]), + Object.freeze(["Яркий синевато-зелёный", "#00836e"]), + Object.freeze(["Насыщенный синевато-зелёный", "#006d5b"]), + Object.freeze(["Опаловый зелёный", "#015d52"]), + Object.freeze(["Бирюзово-зелёный", "#1e5945"]), + Object.freeze(["Сосновый зелёный", "#2c5545"]), + Object.freeze(["Умеренный синевато-зелёный", "#2f6556"]), + Object.freeze(["Патиново-зелёный", "#316650"]), + Object.freeze(["Умеренный зелёный", "#386646"]), + Object.freeze(["Охотничий зелёный", "#355e3b"]), + Object.freeze(["Лиственно-зелёный", "#2d572c"]), + Object.freeze(["Перламутрово-зелёный", "#1c542d"]), + Object.freeze(["Мятно-зелёный", "#20603d"]), + Object.freeze(["Тёмный весенне-зелёный", "#177245"]), + Object.freeze(["Дартмутский зелёный", "#00693e"]), + Object.freeze(["Насыщенный зелёный", "#006b3c"]), + Object.freeze(["Мичиганского университета", "#006633"]), + Object.freeze(["Глубокий желтовато-зелёный", "#00541f"]), + Object.freeze(["Насыщенный оливково-зелёный", "#0a4500"]), + Object.freeze(["Ёлки", "#2a5c03"]), + Object.freeze(["Очень тёмный лимонный зеленый", "#006400"]), + Object.freeze(["Яркий зелёный", "#007d34"]), + Object.freeze(["Арлекин", "#44944a"]), + Object.freeze(["Майский зелёный", "#4c9141"]), + Object.freeze(["Насыщенный желтовато-зелёный", "#478430"]), + Object.freeze(["Кленовый зелёный", "#507d2a"]), + Object.freeze(["Нежно-оливковый", "#6b8e23"]), + Object.freeze(["Насыщенный жёлто-зелёный", "#7f8f18"]), + Object.freeze(["Оливковый", "#808000"]), + Object.freeze(["Жёлтый карри", "#9d9101"]), + Object.freeze(["Глубокий зеленовато-жёлтый", "#9f8200"]), + Object.freeze(["Медово-жёлтый", "#a98307"]), + Object.freeze(["Тёмный золотарник", "#b8860b"]), + Object.freeze(["Глубокий жёлтый", "#b57900"]), + Object.freeze(["Тёмно-жёлтый", "#b07d2b"]), + Object.freeze(["Медный", "#b87333"]), + Object.freeze(["Перу", "#cd853f"]), + Object.freeze(["Бронзовый", "#cd7f32"]), + Object.freeze(["Тёмный оранжево-жёлтый", "#c37629"]), + Object.freeze(["Охра", "#cc7722"]), + Object.freeze(["Рыжий", "#d77d31"]), + Object.freeze(["Шоколадный", "#d2691e"]), + Object.freeze(["Умеренный оранжевый", "#e8793e"]), + Object.freeze(["Ванильный", "#d5713f"]), + Object.freeze(["Коричневато-оранжевый", "#b15124"]), + Object.freeze(["Перламутрово-оранжевый", "#c35831"]), + Object.freeze(["Тёмно-коралловый", "#cd5b45"]), + Object.freeze(["Умеренный красно-оранжевый", "#d35339"]), + Object.freeze(["Лососёво-красный", "#d95030"]), + Object.freeze(["Лососёво-оранжевый", "#e55137"]), + Object.freeze(["Китайский красный", "#e34234"]), + Object.freeze(["Карминно-розовый", "#eb4c42"]), + Object.freeze(["Глубокий жёлто-розовый", "#f64a46"]), + Object.freeze(["Шапка Санта-Клауса", "#ed4830"]), + Object.freeze(["Красно-оранжевый Крайола", "#ff5349"]), + Object.freeze(["Оранжевая заря", "#fd5e53"]), + Object.freeze(["Томатный", "#ff6347"]), + Object.freeze(["Скандальный оранжевый", "#ff6e4a"]), + Object.freeze(["Насыщенный жёлто-розовый", "#ff7a5c"]), + Object.freeze(["Сиена жжёная", "#e97451"]), + Object.freeze(["Огненная cиена Крайола", "#ea7e5d"]), + Object.freeze(["Лососёвый", "#ff8c69"]), + Object.freeze(["Яркий жёлто-розовый", "#ff845c"]), + Object.freeze(["Коралловый", "#ff7f50"]), + Object.freeze(["Огненный оранжевый", "#ff7f49"]), + Object.freeze(["Манго-танго", "#ff8243"]), + Object.freeze(["Оранжевый Крайола", "#ff7538"]), + Object.freeze(["Морковный", "#f36223"]), + Object.freeze(["Пылкий красно-оранжевый", "#f75e25"]), + Object.freeze(["Гранатовый", "#f34723"]), + Object.freeze(["Транспортный оранжевый", "#f54021"]), + Object.freeze(["Яркий красно-оранжевый", "#f13a13"]), + Object.freeze(["Транспортный красный", "#cc0605"]), + Object.freeze(["Бордо", "#b00000"]), + Object.freeze(["Глубокий красно-оранжевый", "#a91d11"]), + Object.freeze(["Бисмарк-фуриозо", "#a5260a"]), + Object.freeze(["Томатно-красный", "#a12312"]), + Object.freeze(["Сигнальный красный", "#a52019"]), + Object.freeze(["Карминно-красный", "#a2231d"]), + Object.freeze(["Огненно-красный", "#af2b1e"]), + Object.freeze(["Кораллово-красный", "#b32821"]), + Object.freeze(["Ориент красный", "#b32428"]), + Object.freeze(["Насыщенный красный", "#bf2233"]), + Object.freeze(["Кардинал", "#c41e3a"]), + Object.freeze(["Малиново-красный", "#c51d34"]), + Object.freeze(["Персидский красный", "#cc3333"]), + Object.freeze(["Клубнично-красный", "#d53032"]), + Object.freeze(["Цвет пожарной машины", "#ce2029"]), + Object.freeze(["Яркий красный", "#c10020"]), + Object.freeze(["Тёмно-алый", "#cb2821"]), + Object.freeze(["Красно-оранжевый", "#c93c20"]), + Object.freeze(["Шапки Деда Мороза", "#ca3a27"]), + Object.freeze(["Светло-тициановый", "#d84b20"]), + Object.freeze(["Тициановый", "#d53e07"]), + Object.freeze(["Красное дерево", "#c04000"]), + Object.freeze(["Ржаво-коричневый", "#b7410e"]), + Object.freeze(["Глубокий оранжевый", "#c34d0a"]), + Object.freeze(["Жжёный апельсин", "#cc5500"]), + Object.freeze(["Красно-буро-оранжевый", "#cd5700"]), + Object.freeze(["Глубокий оранжево-жёлтый", "#d76e00"]), + Object.freeze(["Насыщенный оранжевый", "#ec7c26"]), + Object.freeze(["Жёлто-оранжевый", "#ed760e"]), + Object.freeze(["Тёмный мандарин", "#ea7500"]), + Object.freeze(["Тусклый мандарин", "#f28500"]), + Object.freeze(["Последний вздох Жако", "#ff9218"]), + Object.freeze(["Насыщенный оранжево-жёлтый", "#ff8e0d"]), + Object.freeze(["Яркий оранжево-жёлтый", "#ff8e00"]), + Object.freeze(["Тёмно-оранжевый", "#ff8c00"]), + Object.freeze(["Мандариновый", "#ff8800"]), + Object.freeze(["Тёмный янтарь", "#ff7e00"]), + Object.freeze(["Тыква", "#ff7518"]), + Object.freeze(["Пастельно-оранжевый", "#ff7514"]), + Object.freeze(["Яркий оранжевый", "#ff6800"]), + Object.freeze(["Сигнальный оранжевый", "#ff4f00"]), + Object.freeze(["Киноварь", "#ff4d00"]), + Object.freeze(["Алый", "#ff2400"]), + Object.freeze(["Красный", "#ff0000"]), + Object.freeze(["Люминесцентный красный", "#f80000"]), + Object.freeze(["Оранжево-красный Крайола", "#ff2b2b"]), + Object.freeze(["Карминово-красный", "#ff0033"]), + Object.freeze(["Американский розовый", "#ff033e"]), + Object.freeze(["Пламенная маджента Крайола", "#f8173e"]), + Object.freeze(["Скарлет", "#fc2847"]), + Object.freeze(["Глубокий коралловый", "#ff4040"]), + Object.freeze(["Глубокий карминно-розовый", "#ef3038"]), + Object.freeze(["Ализариновый красный", "#e32636"]), + Object.freeze(["Светлый малиново-красный", "#e63244"]), + Object.freeze(["Малиновый", "#dc143c"]), + Object.freeze(["Красный Крайола", "#ee204d"]), + Object.freeze(["Амарантовый", "#e52b50"]), + Object.freeze(["Радикальный красный", "#ff496c"]), + Object.freeze(["Крутой розовый Крайола", "#fb607f"]), + Object.freeze(["Дикий арбуз Крайола", "#fc6c85"]), + Object.freeze(["Насыщенный пурпурно-розовый", "#f6768e"]), + Object.freeze(["Пылкий розовый", "#ff7e93"]), + Object.freeze(["Насыщенный розовый", "#fd7b7c"]), + Object.freeze(["Горько-сладкий", "#fd7c6e"]), + Object.freeze(["Светлый карминно-розовый", "#e66761"]), + Object.freeze(["Красное дерево Крайола", "#cd4a4c"]), + Object.freeze(["Кирпично-красный", "#cb4154"]), + Object.freeze(["Терракота", "#cc4e5c"]), + Object.freeze(["Индийский красный", "#cd5c5c"]), + Object.freeze(["Сочный каштановый Крайола", "#b94e48"]), + Object.freeze(["Перламутрово-розовый", "#b44c43"]), + Object.freeze(["Средний карминный", "#af4035"]), + Object.freeze(["Бледно-карминный", "#b03f35"]), + Object.freeze(["Тёмный красно-оранжевый", "#9b2f1f"]), + Object.freeze(["Коричнево-бордовый", "#a52a2a"]), + Object.freeze(["Бордовый", "#9b2d30"]), + Object.freeze(["Умеренный красный", "#ab343a"]), + Object.freeze(["Глубокий карминный", "#a9203e"]), + Object.freeze(["Амарантово-пурпурный", "#ab274f"]), + Object.freeze(["Насыщенный пурпурно-красный", "#b32851"]), + Object.freeze(["Коричнево-малиновый Крайола", "#c8385a"]), + Object.freeze(["Яркий пурпурно-красный", "#d5265b"]), + Object.freeze(["Светлая вишня", "#de3163"]), + Object.freeze(["Цвет суеты", "#e3256b"]), + Object.freeze(["Грузинский розовый", "#d71868"]), + Object.freeze(["Джазовый джем", "#ca3767"]), + Object.freeze(["Телемагента", "#cf3476"]), + Object.freeze(["Вересково-фиолетовый", "#de4c8a"]), + Object.freeze(["Фиолетово-красный Крайола", "#f75394"]), + Object.freeze(["Французский розовый", "#f64a8a"]), + Object.freeze(["Глубокий пурпурно-розовый", "#eb5284"]), + Object.freeze(["Тёмно-розовый", "#e75480"]), + Object.freeze(["Румянец", "#de5d83"]), + Object.freeze(["Бледный фиолетово-красный", "#d87093"]), + Object.freeze(["Лиловый", "#db7093"]), + Object.freeze(["Розовый \"Пощекочи меня\"", "#fc89ac"]), + Object.freeze(["Блестящий пурпурно-розовый", "#ff97bb"]), + Object.freeze(["Розовый щербет", "#f78fa7"]), + Object.freeze(["Светлый розово-лиловый", "#ea899a"]), + Object.freeze(["Умеренный пурпурно-розовый", "#e28090"]), + Object.freeze(["Тёмный пурпурно-розовый", "#c76574"]), + Object.freeze(["Розовый антик", "#d36e70"]), + Object.freeze(["Фузи-вузи", "#cc6666"]), + Object.freeze(["Тёмный розовый", "#c76864"]), + Object.freeze(["Каштановый Крайола", "#bc5d58"]), + Object.freeze(["Розовая долина", "#ab4e52"]), + Object.freeze(["Серовато-пурпурно-красный", "#8c4852"]), + Object.freeze(["Кордованский", "#893f45"]), + Object.freeze(["Серовато-красный", "#8c4743"]), + Object.freeze(["Кирпичный", "#884535"]), + Object.freeze(["Терракотовый", "#904d30"]), + Object.freeze(["Сиена", "#a0522d"]), + Object.freeze(["Оранжево-коричневый", "#a65e2e"]), + Object.freeze(["Камелопардовый", "#a25f2a"]), + Object.freeze(["Охра коричневая", "#955f20"]), + Object.freeze(["Светлый оливково-коричневый", "#945d0b"]), + Object.freeze(["Насыщенный жёлто-коричневый", "#95500c"]), + Object.freeze(["Коричневый", "#964b00"]), + Object.freeze(["Кожаного седла для лошади", "#8b4513"]), + Object.freeze(["Коричный", "#7b3f00"]), + Object.freeze(["Медвежьего ушка", "#834d18"]), + Object.freeze(["Красно-жёлто-коричневый", "#80461b"]), + Object.freeze(["Насыщенный коричневый", "#753313"]), + Object.freeze(["Медно-коричневый", "#8e402a"]), + Object.freeze(["Умбра жжёная", "#8a3324"]), + Object.freeze(["Коричнево-красный", "#781f19"]), + Object.freeze(["Фалунский красный", "#801818"]), + Object.freeze(["Насыщенный красно-коричневый", "#7f180d"]), + Object.freeze(["Коричнево-малиновый", "#800000"]), + Object.freeze(["Тёмно-красный", "#8b0000"]), + Object.freeze(["Сангина", "#92000a"]), + Object.freeze(["Кармин", "#960018"]), + Object.freeze(["Рубиново-красный", "#9b111e"]), + Object.freeze(["Бургундский", "#900020"]), + Object.freeze(["Глубокий красный", "#7b001c"]), + Object.freeze(["Розовый лес", "#65000b"]), + Object.freeze(["Пурпурно-красный", "#75151e"]), + Object.freeze(["Перламутрово-рубиновый", "#721422"]), + Object.freeze(["Тёмный красный", "#681c23"]), + Object.freeze(["Очень тёмный алый", "#560319"]), + Object.freeze(["Очень глубокий красный", "#4f0014"]), + Object.freeze(["Глубокий красно-коричневый", "#490005"]), + Object.freeze(["Болгарский розовый", "#480607"]), + Object.freeze(["Блошиного брюшка", "#4e1609"]), + Object.freeze(["Глубокий коричневый", "#4d220e"]), + Object.freeze(["Глубокий жёлто-коричневый", "#593315"]), + Object.freeze(["Олень коричневый", "#59351f"]), + Object.freeze(["Орехово-коричневый", "#5b3a29"]), + Object.freeze(["Серовато-коричневый", "#5a3d30"]), + Object.freeze(["Серовато-красно-коричневый", "#5e3830"]), + Object.freeze(["Каштаново-коричневый", "#633a34"]), + Object.freeze(["Известковая глина", "#79443b"]), + Object.freeze(["Сигнальный коричневый", "#6c3b2a"]), + Object.freeze(["Умеренный коричневый", "#673923"]), + Object.freeze(["Перламутровый медный", "#763c28"]), + Object.freeze(["Золотисто-каштановый", "#712f26"]), + Object.freeze(["Оксид красный", "#642424"]), + Object.freeze(["Красно-коричневый", "#592321"]), + Object.freeze(["Винно-красный", "#5e2129"]), + Object.freeze(["Тёмный пурпурно-красный", "#5b1e31"]), + Object.freeze(["Бордово-фиолетовый", "#641c34"]), + Object.freeze(["Глубокий пурпурно-красный", "#6f0035"]), + Object.freeze(["Вишнёвый", "#911e42"]), + Object.freeze(["Красно-фиолетовый", "#922b3e"]), + Object.freeze(["Умеренный пурпурно-красный", "#a73853"]), + Object.freeze(["Малиново-розовый", "#b3446c"]), + Object.freeze(["Фанданго", "#b55489"]), + Object.freeze(["Шелковица Крайола", "#c54b8c"]), + Object.freeze(["Красно-фиолетовый Крайола", "#c0448f"]), + Object.freeze(["Транспортный пурпурный", "#a03472"]), + Object.freeze(["Насыщенный красно-пурпурный", "#9a366b"]), + Object.freeze(["Мальва", "#993366"]), + Object.freeze(["Амарантовый глубоко-пурпурный", "#9f2b68"]), + Object.freeze(["Баклажановый", "#990066"]), + Object.freeze(["Яркий красно-пурпурный", "#7e0059"]), + Object.freeze(["Глубокий красно-пурпурный", "#641349"]), + Object.freeze(["Глубокий пурпурный", "#531a50"]), + Object.freeze(["Византия", "#702963"]), + Object.freeze(["Умеренно-тёмный пурпурный", "#803e75"]), + Object.freeze(["Сливовый Крайола", "#8e4585"]), + Object.freeze(["Яркий фиолетовый Крайола", "#8f509d"]), + Object.freeze(["Яркий пурпурный", "#943391"]), + Object.freeze(["Сливовый", "#660066"]), + Object.freeze(["Пурпурный", "#800080"]), + Object.freeze(["Тёмный маджента", "#8b008b"]), + Object.freeze(["Фиолетово-баклажанный", "#991199"]), + Object.freeze(["Византийский", "#bd33a4"]), + Object.freeze(["Королевская фуксия", "#ca2c92"]), + Object.freeze(["Умеренный фиолетово-красный", "#c71585"]), + Object.freeze(["Амарантовый светло-вишнёвый", "#cd2682"]), + Object.freeze(["Светло-вишнёвый Крайола", "#dd4492"]), + Object.freeze(["Маджента Крайола", "#f664af"]), + Object.freeze(["Дикая клубника Крайола", "#ff43a4"]), + Object.freeze(["Глубокий розовый", "#ff1493"]), + Object.freeze(["Персидский розовый", "#fe28a2"]), + Object.freeze(["Мовеин, анилиновый пурпур", "#ef0097"]), + Object.freeze(["Модная фуксия", "#f400a1"]), + Object.freeze(["Звезды в шоке", "#ff47ca"]), + Object.freeze(["Экстравагантный розовый Крайола", "#ff33cc"]), + Object.freeze(["Ярко-розовый", "#fc0fc0"]), + Object.freeze(["Пурпурная пицца", "#ff00cc"]), + Object.freeze(["Маджента", "#ff00ff"]), + Object.freeze(["Ярко-фиолетовый", "#cd00cd"]), + Object.freeze(["Амарантовый маджента", "#ed3cca"]), + Object.freeze(["Фуксия", "#f754e1"]), + Object.freeze(["Розовый фламинго", "#fc74fd"]), + Object.freeze(["Розовая фуксия", "#ff77ff"]), + Object.freeze(["Шокирующий розовый Крайола", "#fb7efd"]), + Object.freeze(["Гелиотроп", "#df73ff"]), + Object.freeze(["Умеренный цвет орхидеи", "#ba55d3"]), + Object.freeze(["Глубокая фуксия Крайола", "#c154c1"]), + Object.freeze(["Фуксия Крайола", "#c364c5"]), + Object.freeze(["Орхидея", "#da70d6"]), + Object.freeze(["Фиалковый", "#ea8df7"]), + Object.freeze(["Розово-фиолетовый", "#ee82ee"]), + Object.freeze(["Светло-розовая фуксия", "#f984ef"]), + Object.freeze(["Бледно-пурпурный", "#f984e5"]), + Object.freeze(["Блестящий пурпурный", "#dd80cc"]), + Object.freeze(["Лавандовый розовый", "#fba0e3"]), + Object.freeze(["Светлая слива", "#dda0dd"]), + Object.freeze(["Ярко-сиреневый", "#e0b0ff"]), + Object.freeze(["Глициния", "#c9a0dc"]), + Object.freeze(["Глициния Крайола", "#cda4de"]), + Object.freeze(["Сиреневый", "#c8a2c8"]), + Object.freeze(["Орхидея Крайола", "#e6a8d7"]), + Object.freeze(["Очень светлый фиолетовый", "#eebef1"]), + Object.freeze(["Светлая мальва", "#dcd0ff"]), + Object.freeze(["Барвинок, перванш", "#ccccff"]), + Object.freeze(["Синий-синий иней", "#afdafc"]), + Object.freeze(["Светло-синий", "#a6caf0"]), + Object.freeze(["Бледно-васильковый", "#abcdef"]), + Object.freeze(["Васильковый Крайола", "#9aceeb"]), + Object.freeze(["Светлый синий", "#add8e6"]), + Object.freeze(["Пыльный голубой", "#b0e0e6"]), + Object.freeze(["Снежно-синий", "#ace5ee"]), + Object.freeze(["Бледно-синий", "#afeeee"]), + Object.freeze(["Панг", "#c7fcec"]), + Object.freeze(["Светлый циан", "#e0ffff"]), + Object.freeze(["Небесная лазурь", "#f0ffff"]), + Object.freeze(["Мятно-кремовый", "#f5fffa"]), + Object.freeze(["Медовая роса", "#f0fff0"]), + Object.freeze(["Слоновая кость", "#fffff0"]), + Object.freeze(["Цветочный белый", "#fffaf0"]), + Object.freeze(["Цвет морской раковины", "#fff5ee"]), + Object.freeze(["Льняной", "#faf0e6"]), + Object.freeze(["Старое кружево", "#fdf5e6"]), + Object.freeze(["Космические сливки", "#fff8e7"]), + Object.freeze(["Кремовый", "#fdf4e3"]), + Object.freeze(["Бедра испуганной нимфы", "#faeedd"]), + Object.freeze(["Белый антик", "#faebd7"]), + Object.freeze(["Побег папайи", "#ffefd5"]), + Object.freeze(["Очищенный миндаль", "#ffebcd"]), + Object.freeze(["Бисквитный", "#ffe4c4"]), + Object.freeze(["Тёмно-персиковый", "#ffdab9"]), + Object.freeze(["Абрикосовый Крайола", "#fdd9b5"]), + Object.freeze(["Крайоловый Абрикос", "#fdd5b1"]), + Object.freeze(["Персиковый Крайола", "#ffcfab"]), + Object.freeze(["Бледный жёлто-розовый", "#ffc8a8"]), + Object.freeze(["Абрикосовый", "#fbceb1"]), + Object.freeze(["Циннвальдитово-розовый", "#ffcbbb"]), + Object.freeze(["Светло-коралловый", "#ffbcad"]), + Object.freeze(["Дыня Крайола", "#fdbcb4"]), + Object.freeze(["Бледный пурпурно-розовый", "#fdbdba"]), + Object.freeze(["Розовый", "#ffc0cb"]), + Object.freeze(["Светло-розовый", "#ffb6c1"]), + Object.freeze(["Светлый пурпурно-розовый", "#ffa8af"]), + Object.freeze(["Лососёвый Крайола", "#ff9baa"]), + Object.freeze(["Розовато-лиловый Крайола", "#ef98aa"]), + Object.freeze(["Амарантово-розовый", "#f19cbb"]), + Object.freeze(["Розовая гвоздика", "#ffaacc"]), + Object.freeze(["Лавандовый Крайола", "#fcb4d5"]), + Object.freeze(["Сладкая вата", "#ffbcd9"]), + Object.freeze(["Очень светло-пурпурный", "#e3a9be"]), + Object.freeze(["Очень бледный фиолетовый", "#d8b1bf"]), + Object.freeze(["Бледно-каштановый", "#ddadaf"]), + Object.freeze(["Очень бледно-пурпурный", "#e6bbc1"]), + Object.freeze(["Тускло-амарантово-розовый", "#ddbec3"]), + Object.freeze(["Очень бледный пурпурно-синий", "#cbbac5"]), + Object.freeze(["Чертополох", "#d8bfd8"]), + Object.freeze(["Чертополох Крайола", "#ebc7df"]), + Object.freeze(["Бледно-розоватый", "#ffcbdb"]), + Object.freeze(["Пастельно-розовый", "#ffd1dc"]), + Object.freeze(["Розовый поросёнок", "#fddde6"]), + Object.freeze(["Бледно-розовый", "#fadadd"]), + Object.freeze(["Тускло-розовый", "#ffe4e1"]), + Object.freeze(["Розово-лавандовый", "#fff0f5"]), + Object.freeze(["Белоснежный", "#fffafa"]) +]); diff --git a/static/js/colors.js b/static/js/colors.js new file mode 100644 index 0000000..6639a95 --- /dev/null +++ b/static/js/colors.js @@ -0,0 +1,530 @@ +(function () { + 'use strict'; + window.__esdocColorsCleanup?.(); + const app = document.getElementById('colors-app'); + if (!app) return; + const c = window.ESDocColors, get = id => document.getElementById('colors-' + id); + const controller = new AbortController(); + const on = (element, event, callback) => element.addEventListener(event, callback, {signal: controller.signal}); + const outputIds = ['hex','rgb','rgba','css','hsl','oklch','renpy']; + const ids = ['hex', 'r', 'g', 'b', 'a', 'h', 's', 'v', 'hh', 'ss', 'll', ...outputIds.map(id => 'output-' + id), 'background']; + let color = c.parseHex('#2f7524'), hsv = c.rgbToHsv(color), other = c.parseHex('#fff'); + let role = 'text', surface = 'field', pointer = null, disposed = false, imageRequest = 0, imagePoint = {x: 0, y: 0}; + const undos = [], redos = [], copyTimers = new Map(); + let eyedropperOverlayTimer, eyedropperController; + const same = (first, second) => JSON.stringify(first) === JSON.stringify(second); + const readList = key => { + try { + const data = JSON.parse(window.localStorage?.getItem('es-colors-' + key) || '[]'); + return Array.isArray(data) ? data.filter(value => value && ['r', 'g', 'b'].every(channel => c.parseNumber(value[channel], 0, 255, true) !== null) && c.parseNumber(value.a, 0, 1) !== null) : []; + } catch (_) { return []; } + }; + let saved = readList('saved'), recent = readList('recent').slice(0, 12); + let clearedSaved = null; + const snapshot = () => ({color: {...color}, hsv: {...hsv}, other: {...other}, role, saved: saved.map(value => ({...value})), clearedSaved: clearedSaved?.map(value => ({...value})) || null}); + let committed = snapshot(); + const persist = () => { + try { + if (!window.localStorage) return; + window.localStorage.setItem('es-colors-saved', JSON.stringify(saved)); + window.localStorage.setItem('es-colors-recent', JSON.stringify(recent)); + } catch (_) { get('status').textContent = 'Не удалось сохранить в браузере. Цвета доступны до закрытия страницы.'; } + }; + const remember = () => { + recent = [({...color}), ...recent.filter(value => !same(value, color))].slice(0, 12); + persist(); + }; + const commit = (record = true) => { + const next = snapshot(); + if (!same(committed, next)) { + undos.push(committed); + if (undos.length > 64) undos.shift(); + committed = next; + redos.length = 0; + if (record) remember(); + } + renderHistory(); + renderUndo(); + }; + const validFields = () => ids.filter(id => get(id).getAttribute('aria-invalid') === 'true'); + const errors = new Map(); + const inputHelp = () => { + for (const id of ids) { + const invalid = get(id).getAttribute('aria-invalid') === 'true'; + if (!invalid) errors.delete(id); + get('error-' + id).textContent = invalid ? errors.get(id) : ''; + get('error-' + id).hidden = !invalid; + } + const text = validFields().map(id => errors.get(id)).join(' '); + get('input-help').textContent = text; get('input-help').hidden = !text; + }; + const fieldError = (id, message) => { get(id).setAttribute('aria-invalid', 'true'); errors.set(id, message); inputHelp(); }; + const formats = () => ({hex: c.toHex(color), rgb: c.pythonRgb(color), rgba: c.pythonRgba(color), css: c.cssRgb(color), hsl: c.cssHsl(color), oklch: c.cssOklch(color), renpy: c.renpyColor(color)}); + const preset = { + basic: [['Чёрный','#000'],['Белый','#fff'],['Красный','#f00'],['Оранжевый','#f80'],['Жёлтый','#ff0'],['Зелёный','#0a0'],['Бирюзовый','#0cc'],['Синий','#00f'],['Фиолетовый','#80f'],['Розовый','#f08']], + pastel: [['Пудровый','#ffd1dc'],['Персиковый','#ffdab9'],['Ванильный','#fff4b8'],['Фисташковый','#d4efbf'],['Мятный','#b5ead7'],['Небесный','#c7e9ff'],['Лавандовый','#d9c7ff'],['Сиреневый','#e6c8f2'],['Песочный','#eadbc8'],['Серый','#d8dfe8']], + characters: [ + ['Диалог','#e2c778'], ['Алиса','#ffaa00'], ['Виола','#a5a5ff'], + ['Мику','#00deff'], ['Ольга Дмитриевна','#00ea32'], ['Семён','#e1dd7d'], + ['Женя','#72a0ff'], ['Пионер','#e60101'], ['Шурик','#fff226'], + ['Славя','#ffd200'], ['Лена','#b956ff'], ['Ульяна','#ff3200'], ['Юля','#4eff00'], + ], + named: window.ESDocNamedColors, + }; + let paletteLimit = 100; + const normalizeSearch = value => value.trim().toLocaleLowerCase('ru').replaceAll('ё', 'е'); + const namedIndex = preset.named.map(([name, hex]) => ({name, hex, search: normalizeSearch(name + ' ' + hex + ' ' + c.toHex(c.parseHex(hex)))})); + const paintSwatches = (id, values, names = [], empty = '') => { + const host = get(id); + const buttons = [...host.querySelectorAll('button')]; + if (buttons.length !== values.length || !values.length) { + host.replaceChildren(); + if (!values.length && empty) { + const note = document.createElement('p'); + note.className = 'picker-empty'; note.textContent = empty; host.appendChild(note); + } + for (const value of values) { + const button = document.createElement('button'); + button.type = 'button'; button.className = 'picker-swatch'; + button.append(document.createElement('span'), document.createElement('small'), document.createElement('small')); + button.children[2].className = 'picker-swatch-name'; + if (id !== 'swatches') on(button, 'click', () => choose(JSON.parse(button.dataset.color))); + host.appendChild(button); + } + } + host.style.setProperty('--swatch-count', values.length); + [...host.querySelectorAll('button')].forEach((button, index) => { + const value = values[index], hex = c.toHex(value), label = names[index] || hex; + const encoded = JSON.stringify(value), pressed = String(c.toHex(color) === hex), title = label + (label === hex ? '' : ' / ' + hex); + if (button.dataset.color !== encoded) { + button.dataset.color = encoded; + button.dataset.hex = c.toHex(value, false, false); + button.children[0].style.background = 'linear-gradient(' + c.cssRgb(value) + ',' + c.cssRgb(value) + '), conic-gradient(#ccc 25%,#fff 0 50%,#ccc 0 75%,#fff 0) 0 0 / 12px 12px'; + button.children[1].textContent = hex; + } + if (button.getAttribute('aria-pressed') !== pressed) button.setAttribute('aria-pressed', pressed); + if (button.title !== title) { button.title = title; button.setAttribute('aria-label', label + (label === hex ? '' : ', ' + hex)); } + const nameHidden = id !== 'swatches' || get('palette').value !== 'named'; + if (button.children[2].hidden !== nameHidden) button.children[2].hidden = nameHidden; + if (button.children[2].textContent !== label) button.children[2].textContent = label; + }); + }; + on(get('swatches'), 'click', event => { + const button = event.target.closest?.('button[data-color]'); + if (button && get('swatches').contains(button)) choose(JSON.parse(button.dataset.color)); + }); + let paletteKey = ''; + const paintPalette = entries => { + const host = get('swatches'), key = JSON.stringify(entries); + if (key !== paletteKey) { + paletteKey = key; host.replaceChildren(); + if (!entries.length) { + const note = document.createElement('p'); + note.className = 'picker-empty'; note.textContent = 'Не найдено. Измените название или HEX.'; host.appendChild(note); + } + for (const [name, hex] of entries) { + const base = c.parseHex(hex), hsl = c.rgbToHsl(base); + const row = document.createElement('div'); row.className = 'picker-palette-row'; row.dataset.hex = c.toHex(base, false, false); + const variants = [.65, .75, .85].map(l => c.hslToRgb({h: hsl.h, s: hsl.s ? 100 : 0, l: l * 100})); + for (const [index, value] of [...variants, base, ...variants.slice().reverse()].entries()) { + const button = document.createElement('button'), valueHex = c.toHex(value, false, false); + button.type = 'button'; button.className = index === 3 ? 'picker-palette-color' : 'picker-palette-variant'; + button.dataset.color = JSON.stringify(value); + button.dataset.hex = valueHex; + button.style.backgroundColor = c.cssRgb(value); + button.style.color = c.contrast({r:23,g:38,b:26,a:1}, value) >= 4.5 ? '#17261a' + : c.contrast({r:0,g:0,b:0,a:1}, value) >= 4.5 ? '#000' : '#fff'; + button.title = name + ' / ' + valueHex; + button.setAttribute('aria-label', (index === 3 ? name : 'Оттенок: ' + name) + ', ' + valueHex); + if (index === 3) { + const label = document.createElement('span'), code = document.createElement('code'); + label.textContent = name; code.textContent = valueHex; button.append(label, code); + } + row.appendChild(button); + } + host.appendChild(row); + } + } + const selectedHex = c.toHex(color, color.a < 1, false); + for (const button of host.querySelectorAll('button')) { + const pressed = String(button.dataset.hex === selectedHex); + if (button.getAttribute('aria-pressed') !== pressed) button.setAttribute('aria-pressed', pressed); + } + }; + const renderPalette = () => { + const named = get('palette').value === 'named'; + get('palette-search-control').hidden = !named; + get('palette-summary').hidden = !named; + const query = normalizeSearch(get('palette-search').value); + const entries = named ? namedIndex.filter(entry => entry.search.includes(query)) : []; + const values = named ? entries.slice(0, paletteLimit).map(({name, hex}) => [name, hex]) : preset[get('palette').value] || preset.basic; + paintPalette(values); + const summary = 'Показано ' + values.length + ' из ' + entries.length; + if (get('palette-summary').textContent !== summary) get('palette-summary').textContent = summary; + get('palette-more').hidden = !named || values.length >= entries.length; + }; + const renderUndo = () => { + get('undo').disabled = !undos.length && same(committed, snapshot()); + get('redo').disabled = !redos.length; + }; + const renderHistory = () => { + paintSwatches('saved', saved, [], 'Сохраните выбранный цвет кнопкой «Сохранить цвет».'); + paintSwatches('recent', recent, [], 'Здесь появятся цвета после выбора или копирования.'); + get('copy-saved').disabled = !saved.length; + get('clear-saved').disabled = !saved.length; + get('saved-recovery').hidden = !clearedSaved?.length || !!saved.length; + }; + const renderContrast = () => { + const asBackground = role === 'background'; + const foreground = asBackground ? other : color, background = asBackground ? color : other; + const bg = c.composite(background, {r: 255, g: 255, b: 255, a: 1}), fg = c.composite(foreground, bg); + get('sample').style.backgroundColor = c.cssRgb(bg); + get('sample').style.color = c.cssRgb(fg); + get('other-label').textContent = asBackground ? 'Текст' : 'Фон'; + const ratio = c.contrast(foreground, background); + get('contrast-value').textContent = (Math.floor(ratio * 100) / 100).toFixed(2) + ':1'; + get('contrast-aa').textContent = ratio >= 7 ? 'AAA / обычный текст' : ratio >= 4.5 ? 'AA / обычный текст' : ratio >= 3 ? 'AA / только крупный текст' : 'Недостаточный контраст'; + }; + const render = (force = false) => { + const hsl = c.rgbToHsl(color); + if (!hsl.s) hsl.h = hsv.h; + const values = {hex: c.toHex(color), r: color.r, g: color.g, b: color.b, a: c.decimal(color.a), + h: c.decimal(hsv.h, 2), s: c.decimal(hsv.s, 2), v: c.decimal(hsv.v, 2), + hh: c.decimal(hsl.h, 2), ss: c.decimal(hsl.s, 2), ll: c.decimal(hsl.l, 2)}; + for (const [id, value] of Object.entries(values)) { + const input = get(id); + if (force === true || document.activeElement !== input && input.getAttribute('aria-invalid') !== 'true') input.value = value; + } + for (const [id, value] of Object.entries(formats())) { + const input = get('output-' + id); + if (force === true || document.activeElement !== input && input.getAttribute('aria-invalid') !== 'true') input.value = value; + get('copy-' + id).disabled = input.getAttribute('aria-invalid') === 'true'; + } + get('preview-role').value = role; + if (force === true || document.activeElement !== get('background') && get('background').getAttribute('aria-invalid') !== 'true') get('background').value = c.toHex(other); + get('current').style.backgroundColor = c.cssRgb(color); + get('hue').value = hsv.h; get('hue-value').textContent = c.decimal(hsv.h, 1) + '°'; + get('alpha').value = color.a * 100; get('alpha-value').textContent = c.decimal(color.a * 100, 1) + '%'; + get('brightness').value = hsv.v; get('brightness-value').textContent = c.decimal(hsv.v, 1) + '%'; + get('s-range').value = hsv.s; get('v-range').value = hsv.v; + for (const id of ['s-range', 'v-range']) get(id).setAttribute('aria-valuetext', 'Насыщенность ' + c.decimal(hsv.s, 1) + '%, яркость ' + c.decimal(hsv.v, 1) + '%'); + get('sv').style.setProperty('--hue-color', 'hsl(' + hsv.h + ' 100% 50%)'); + get('sv').classList.toggle('is-wheel', surface === 'wheel'); + get('sv').style.setProperty('--wheel-shade', 1 - hsv.v / 100); + get('brightness-row').hidden = surface !== 'wheel'; + get('area-help').textContent = surface === 'wheel' ? 'Тон' : 'Насыщенность'; + get('area-second').textContent = surface === 'wheel' ? 'Насыщенность' : 'Яркость'; + get('sv').setAttribute('aria-label', surface === 'wheel' ? 'Тон и насыщенность' : 'Насыщенность и яркость'); + get('area-value').textContent = surface === 'wheel' ? c.decimal(hsv.h, 1) + '°' : c.decimal(hsv.s, 1) + '%'; + get('area-second-value').textContent = c.decimal(surface === 'wheel' ? hsv.s : hsv.v, 1) + '%'; + const angle = hsv.h * Math.PI / 180; + get('cursor').style.left = (surface === 'wheel' ? 50 + Math.cos(angle) * hsv.s / 2 : hsv.s) + '%'; + get('cursor').style.top = (surface === 'wheel' ? 50 + Math.sin(angle) * hsv.s / 2 : 100 - hsv.v) + '%'; + get('cursor').style.backgroundColor = c.cssRgb({...color, a: 1}); + get('alpha').style.setProperty('--range-bg', 'linear-gradient(to right, transparent, ' + c.cssRgb({...color, a: 1}) + ')'); + get('brightness').style.setProperty('--range-bg', 'linear-gradient(to right, #000, ' + c.cssRgb(c.hsvToRgb({...hsv, v: 100, a: 1})) + ')'); + renderPalette(); + paintSwatches('harmonies', c.harmony(color, get('harmony').value)); + paintSwatches('shades', c.shades(color)); + renderHistory(); renderContrast(); renderUndo(); + }; + const update = (next, nextHsv = null, clearInvalid = false) => { + color = {...next}; hsv = nextHsv || c.rgbToHsv(color, hsv.h); + if (clearInvalid) for (const id of ids) get(id).removeAttribute('aria-invalid'); + inputHelp(); get('status').textContent = ''; render(); + }; + const choose = next => { update(next, null, true); render(true); commit(); }; + for (const id of ids.filter(id => !id.startsWith('output-') && id !== 'background')) { + const input = get(id); + on(input, 'input', () => { + const numeric = input.value.replace(',', '.'); + const value = id === 'hex' ? c.parseColor(input.value) : c.parseNumber(numeric, 0, + id === 'a' ? 1 : ['h','hh'].includes(id) ? 360 : ['r','g','b'].includes(id) ? 255 : 100, ['r','g','b'].includes(id)); + if (value === null) { + fieldError(id, id === 'hex' ? 'Не удалось прочитать цвет. Проверьте HEX, CSS или tuple (R, G, B).' + : ['r','g','b'].includes(id) ? 'Каналы RGB — целые числа от 0 до 255.' + : id === 'a' ? 'Прозрачность — число от 0 до 1.' + : ['h','hh'].includes(id) ? 'Тон — число от 0 до 360.' : 'Введите число от 0 до 100.'); + return; + } + input.removeAttribute('aria-invalid'); + if (id === 'hex') update(value, null, true); + else if (['h','s','v'].includes(id)) { const next = {...hsv, [id]: value}; update(c.hsvToRgb(next), next); } + else if (['hh','ss','ll'].includes(id)) { + const next = c.rgbToHsl(color); if (!next.s) next.h = hsv.h; + next[{hh:'h',ss:'s',ll:'l'}[id]] = value; + const rgb = c.hslToRgb(next); update(rgb, c.rgbToHsv(rgb, next.h)); + } + else update({...color, [id]: value}, id === 'a' ? {...hsv, a: value} : null); + }); + on(input, 'change', () => { if (!validFields().length) commit(); }); + on(input, 'blur', () => { if (!validFields().length) commit(); render(); }); + on(input, 'keydown', event => { + if (event.key === 'Enter' && !validFields().length) commit(); + if (id === 'hex' || !['ArrowUp','ArrowDown','Home','End'].includes(event.key) || event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + const maximum = id === 'a' ? 1 : ['h','hh'].includes(id) ? 360 : ['r','g','b'].includes(id) ? 255 : 100; + const current = c.parseNumber(input.value.replace(',', '.'), 0, maximum); + if (current === null) return; + const step = (id === 'a' ? .01 : 1) * (event.shiftKey ? 10 : 1); + input.value = c.decimal(event.key === 'Home' ? 0 : event.key === 'End' ? maximum : c.clamp(current + (event.key === 'ArrowUp' ? step : -step), 0, maximum)); + input.dispatchEvent(new Event('input', {bubbles: true})); commit(); + }); + } + for (const [id, channel, factor] of [['hue','h',1],['brightness','v',1],['alpha','a',.01],['s-range','s',1],['v-range','v',1]]) { + on(get(id), 'input', () => { const next = {...hsv, [channel]: Number(get(id).value) * factor}; update(c.hsvToRgb(next), next, true); }); + on(get(id), 'change', () => commit()); + } + on(get('space'), 'change', () => { for (const space of ['rgb','hsv','hsl']) get('fields-' + space).hidden = get('space').value !== space; }); + for (const radio of app.querySelectorAll('[name="color-surface"]')) on(radio, 'change', () => { surface = radio.value; render(); }); + for (const format of outputIds) { + const input = get('output-' + format); + on(input, 'input', () => { + const text = input.value.trim(); + const matches = format === 'hex' ? !!c.parseHex(text) : format === 'renpy' ? /^renpy\.Color\(/i.test(text) + : format === 'rgb' ? /^\([^,]+,[^,]+,[^,]+\)$/.test(text) + : format === 'rgba' ? /^\([^,]+,[^,]+,[^,]+,[^,]+\)$/.test(text) + : new RegExp('^' + (format === 'css' ? 'rgba?' : format === 'hsl' ? 'hsla?' : 'oklch') + '\\(', 'i').test(text); + const parsed = matches ? c.parseColor(text) : null; + if (!parsed) { + get('copy-' + format).disabled = true; + const examples = {hex: 'HEX: #abc или #aabbcc; с прозрачностью #abcd или #aabbccdd.', + rgb: 'RGB: (R, G, B), целые каналы от 0 до 255.', rgba: 'RGBA: (R, G, B, A), каналы от 0 до 255, прозрачность от 0 до 1.', + renpy: 'Ren’Py: renpy.Color("#aabbcc") или renpy.Color("#aabbcc", alpha=0.5). Прозрачность — от 0 до 1.', + css: 'CSS RGB: rgb(255 0 0 / 50%), каналы от 0 до 255.', hsl: 'HSL: hsl(120 100% 50% / 0.5), насыщенность и светлота от 0 до 100%.', + oklch: 'OKLCH: oklch(62.8% 0.258 29.2 / 0.5), светлота от 0 до 100%, цветность неотрицательная.'}; + fieldError('output-' + format, examples[format]); return; + } + if (format === 'rgb') parsed.a = color.a; + update(parsed, null, true); + }); + const finishEdit = () => { if (input.getAttribute('aria-invalid') !== 'true') { commit(); input.value = formats()[format]; } }; + on(input,'change', finishEdit); on(input,'blur', finishEdit); + on(input,'keydown', event => { if (event.key === 'Enter') finishEdit(); }); + } + const fromPointer = event => { + const rect = get('sv').getBoundingClientRect(), x = (event.clientX - rect.left) / rect.width, y = (event.clientY - rect.top) / rect.height; + const next = surface === 'wheel' ? {...hsv, h: Math.hypot(x - .5, y - .5) < .001 ? hsv.h : (Math.atan2(y - .5, x - .5) * 180 / Math.PI + 360) % 360, s: c.clamp(Math.hypot(x - .5, y - .5) * 200, 0, 100)} + : {...hsv, s: c.clamp(x * 100, 0, 100), v: 100 - c.clamp(y * 100, 0, 100)}; + update(c.hsvToRgb(next), next, true); + }; + on(get('sv'), 'pointerdown', event => { if (event.button !== 0) return; pointer = event.pointerId; get('sv').setPointerCapture(pointer); get('sv').focus({preventScroll:true}); fromPointer(event); }); + on(get('sv'), 'pointermove', event => { if (event.pointerId === pointer) fromPointer(event); }); + for (const type of ['pointerup','pointercancel','lostpointercapture']) on(get('sv'), type, () => { if (pointer !== null) { pointer = null; commit(); } }); + on(get('sv'), 'keydown', event => { + if (!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Home','End','PageUp','PageDown'].includes(event.key) || event.ctrlKey || event.metaKey || event.altKey) return; + event.preventDefault(); + const next = {...hsv}, step = event.shiftKey || event.key.startsWith('Page') ? 10 : 1; + if (surface === 'wheel') { + if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') next.h = (next.h + (event.key === 'ArrowLeft' ? -step : step) + 360) % 360; + else if (event.key === 'ArrowUp' || event.key === 'PageUp') next.s = c.clamp(next.s + step,0,100); + else if (event.key === 'ArrowDown' || event.key === 'PageDown') next.s = c.clamp(next.s - step,0,100); + else next.s = event.key === 'Home' ? 0 : 100; + } + else if (event.key === 'ArrowLeft') next.s = c.clamp(next.s - step, 0, 100); + else if (event.key === 'ArrowRight') next.s = c.clamp(next.s + step, 0, 100); + else if (event.key === 'ArrowUp' || event.key === 'PageUp') next.v = c.clamp(next.v + step, 0, 100); + else if (event.key === 'ArrowDown' || event.key === 'PageDown') next.v = c.clamp(next.v - step, 0, 100); + else next[event.target === get('v-range') ? 'v' : 's'] = event.key === 'Home' ? 0 : 100; + update(c.hsvToRgb(next), next, true); commit(); + }); + const travel = backwards => { + commit(false); + const from = backwards ? undos : redos, to = backwards ? redos : undos; + if (!from.length) return; + to.push(snapshot()); const next = from.pop(); + const collectionChanged = !same(saved, next.saved); + color = {...next.color}; hsv = {...next.hsv}; other = {...next.other}; role = next.role; + saved = next.saved.map(value => ({...value})); clearedSaved = next.clearedSaved?.map(value => ({...value})) || null; committed = snapshot(); + for (const id of ids) get(id).removeAttribute('aria-invalid'); + inputHelp(); render(true); + get('status').textContent = collectionChanged && saved.length ? 'Сохранённые цвета восстановлены.' : backwards ? 'Изменение отменено.' : 'Изменение повторено.'; + persist(); + }; + on(get('undo'), 'click', () => travel(true)); on(get('redo'), 'click', () => travel(false)); + on(document, 'keydown', event => { + if (!app.contains(event.target) && event.target !== document.body) return; + if (!(event.ctrlKey || event.metaKey) || event.altKey || event.key.toLowerCase() !== 'z') return; + event.preventDefault(); travel(!event.shiftKey); + }); + const copy = async (button, value, message, field = null) => { + try { + if (!window.navigator?.clipboard) throw new Error('Clipboard unavailable'); + await window.navigator.clipboard.writeText(value); + if (disposed) return; + commit(); remember(); renderHistory(); + button.classList.add('copied'); get('status').textContent = message || 'Скопировано: ' + value; + clearTimeout(copyTimers.get(button)); + copyTimers.set(button, setTimeout(() => { button.classList.remove('copied'); copyTimers.delete(button); }, 1600)); + } catch (_) { + if (disposed) return; + const input = field || get('copy-fallback'); input.hidden = false; input.value = value; input.focus(); input.select(); + get('status').textContent = 'Не удалось скопировать. Значение выделено: нажмите Ctrl+C или скопируйте вручную.'; + } + }; + for (const format of outputIds) on(get('copy-' + format), 'click', () => { if (get('output-' + format).getAttribute('aria-invalid') !== 'true') copy(get('copy-' + format), formats()[format], '', get('output-' + format)); }); + const copyList = (id, values) => copy(get(id), JSON.stringify(values.map(value => c.toHex(value))), 'Палитра скопирована в HEX.'); + on(get('copy-palette'), 'click', () => copyList('copy-palette', c.harmony(color, get('harmony').value))); + on(get('copy-shades'), 'click', () => copyList('copy-shades', c.shades(color))); + on(get('copy-saved'), 'click', () => copyList('copy-saved', saved)); + on(get('save'), 'click', () => { + commit(); if (!saved.some(value => same(value, color))) saved.push({...color}); + commit(false); get('status').textContent = 'Цвет сохранён в «Мои цвета».'; persist(); + }); + on(get('clear-saved'), 'click', () => { + if (!saved.length) return; + commit(); clearedSaved = saved.map(value => ({...value})); saved = []; commit(false); + get('status').textContent = 'Сохранённые цвета очищены. Их можно восстановить кнопкой или Ctrl+Z.'; persist(); + }); + on(get('restore-saved'), 'click', () => { + if (!clearedSaved?.length) return; + commit(); saved = clearedSaved.map(value => ({...value})); clearedSaved = null; commit(false); + get('status').textContent = 'Сохранённые цвета восстановлены.'; persist(); + }); + on(get('palette'), 'change', () => { paletteLimit = 100; get('palette-search').value = ''; renderPalette(); }); + on(get('palette-search'), 'input', () => { paletteLimit = 100; renderPalette(); }); + on(get('palette-more'), 'click', () => { paletteLimit += 100; renderPalette(); }); + on(get('harmony'), 'change', render); + on(get('preview-role'), 'change', () => { commit(); const nextRole = get('preview-role').value; const before = {...color}; color = {...other}; other = before; role = nextRole; hsv = c.rgbToHsv(color); for (const id of ids) get(id).removeAttribute('aria-invalid'); inputHelp(); render(true); commit(); }); + on(get('background'), 'input', () => { + const parsed = c.parseColor(get('background').value); + if (!parsed) { fieldError('background', 'Не удалось прочитать цвет. Проверьте HEX, CSS или tuple (R, G, B).'); return; } + other = parsed; get('background').removeAttribute('aria-invalid'); inputHelp(); + get('contrast-note').textContent = 'WCAG / обычный текст 4.5:1, крупный 3:1. Прозрачность учитывается на белой подложке.'; + renderContrast(); + }); + for (const type of ['change','blur']) on(get('background'), type, () => { if (get('background').getAttribute('aria-invalid') !== 'true') commit(); }); + on(get('swap'), 'click', () => { commit(); const next = {...other}; other = {...color}; update(next, null, true); render(true); commit(); }); + on(get('reset'), 'click', () => { commit(); other = c.parseHex(role === 'text' ? '#fff' : '#2f7524'); update(c.parseHex(role === 'text' ? '#2f7524' : '#fff'), null, true); render(true); commit(); }); + const tabs = ['palettes','shades','saved','image']; + const showTab = id => { + for (const name of tabs) { + get('tab-' + name).setAttribute('aria-selected', String(id === name)); + get('tab-' + name).tabIndex = id === name ? 0 : -1; + get('panel-' + name).hidden = id !== name; + } + }; + for (const [index, id] of tabs.entries()) { + on(get('tab-' + id), 'click', () => showTab(id)); + on(get('tab-' + id), 'keydown', event => { + if (!['ArrowLeft','ArrowRight','Home','End'].includes(event.key)) return; + event.preventDefault(); + const next = event.key === 'Home' ? 0 : event.key === 'End' ? tabs.length - 1 : (index + (event.key === 'ArrowLeft' ? -1 : 1) + tabs.length) % tabs.length; + showTab(tabs[next]); get('tab-' + tabs[next]).focus(); + }); + } + for (const [section, target] of [['code','code-heading'],['editor','editor-heading'],['contrast','contrast-heading']]) { + on(get('jump-' + section), 'click', () => { get(target).focus({preventScroll: true}); get(target).scrollIntoView({block: 'start', behavior: 'auto'}); }); + } + const shortcuts = get('shortcuts'); + on(get('shortcuts-open'), 'click', () => shortcuts.showModal()); + on(get('shortcuts-close'), 'click', () => shortcuts.close()); + on(shortcuts, 'click', event => { + const rect = shortcuts.getBoundingClientRect(); + if (event.target === shortcuts && (event.clientX < rect.left || event.clientX > rect.left + rect.width || event.clientY < rect.top || event.clientY > rect.top + rect.height)) shortcuts.close(); + }); + on(shortcuts, 'close', () => { if (!disposed) get('shortcuts-open').focus({preventScroll: true}); }); + // Cancel events delivered to the page. Native browser eyedropper UI may + // consume mouse input before it reaches the document. + const cancelEyedropper = event => { + if (!eyedropperController || eyedropperController.signal.aborted) return; + event.preventDefault(); + eyedropperController.abort(); + clearTimeout(eyedropperOverlayTimer); + get('eyedropper-overlay').hidden = true; + get('status').textContent = 'Выбор пипеткой отменён.'; + }; + on(document, 'pointerdown', event => { if (event.button === 2) cancelEyedropper(event); }); + on(document, 'contextmenu', cancelEyedropper); + on(document, 'keydown', event => { if (event.key === 'Escape') cancelEyedropper(event); }); + if (window.EyeDropper && window.isSecureContext) { + get('eyedropper').hidden = false; + on(get('eyedropper'), 'click', async () => { + const pickController = new AbortController(); + eyedropperController = pickController; + const abortPick = () => pickController.abort(); + controller.signal.addEventListener('abort', abortPick, {once: true}); + get('eyedropper').disabled = true; + // Chromium snapshots the screen on open. Delay the dimmer until after + // that capture so the magnifier samples the original page colors. + eyedropperOverlayTimer = setTimeout(() => { if (!disposed) get('eyedropper-overlay').hidden = false; }, 300); + try { const result = await new window.EyeDropper().open({signal: pickController.signal}); if (!disposed && !pickController.signal.aborted) choose(c.parseHex(result.sRGBHex)); } + catch (error) { if (!disposed) get('status').textContent = error.name === 'AbortError' ? 'Выбор пипеткой отменён.' : 'Не удалось открыть пипетку. Используйте поле или изображение.'; } + finally { controller.signal.removeEventListener('abort', abortPick); if (eyedropperController === pickController) eyedropperController = null; clearTimeout(eyedropperOverlayTimer); if (!disposed) { get('eyedropper').disabled = false; get('eyedropper-overlay').hidden = true; get('eyedropper').focus({preventScroll: true}); } } + }); + } + const canvas = get('image-canvas'); + const imageContext = () => canvas.getContext('2d', {willReadFrequently: true}); + const placeImagePoint = () => { + get('image-cursor').style.left = (imagePoint.x + .5) / canvas.width * 100 + '%'; + get('image-cursor').style.top = (imagePoint.y + .5) / canvas.height * 100 + '%'; + }; + const pickImagePoint = () => { + const pixel = imageContext().getImageData(imagePoint.x, imagePoint.y, 1, 1).data; + choose({r: pixel[0], g: pixel[1], b: pixel[2], a: pixel[3] / 255}); placeImagePoint(); + }; + on(get('image-add'), 'click', () => { get('image-file').value = ''; get('image-file').click(); }); + on(get('image-file'), 'change', async () => { + const file = get('image-file').files[0]; + if (!file) return; + const request = ++imageRequest; get('image-add').disabled = false; + if (!['image/png','image/jpeg','image/webp','image/gif','image/avif'].includes(file.type)) { get('image-status').textContent = 'Этот формат не поддерживается. Выберите PNG, JPEG, WebP, GIF или AVIF.'; return; } + if (file.size > 40 * 1024 * 1024) { get('image-status').textContent = 'Изображение больше 40 МиБ. Откройте уменьшенную копию.'; return; } + get('image-add').disabled = true; get('image-status').textContent = 'Читаем изображение…'; + let bitmap; + try { + bitmap = await createImageBitmap(file); + if (disposed || request !== imageRequest) return; + const scale = Math.min(1, 4096 / bitmap.width, 4096 / bitmap.height, Math.sqrt(12000000 / (bitmap.width * bitmap.height))); + canvas.width = Math.max(1, Math.round(bitmap.width * scale)); canvas.height = Math.max(1, Math.round(bitmap.height * scale)); + const context = imageContext(); + context.clearRect(0, 0, canvas.width, canvas.height); context.drawImage(bitmap, 0, 0, canvas.width, canvas.height); + get('image-stage').hidden = false; + imagePoint = {x: Math.floor(canvas.width / 2), y: Math.floor(canvas.height / 2)}; placeImagePoint(); + const data = context.getImageData(0, 0, canvas.width, canvas.height).data, buckets = new Map(), stride = Math.max(1, Math.floor(canvas.width * canvas.height / 65536)); + for (let pixel = 0; pixel < data.length / 4; pixel += stride) { + const i = pixel * 4; if (data[i + 3] < 128) continue; + const key = (data[i] >> 4) + ',' + (data[i + 1] >> 4) + ',' + (data[i + 2] >> 4); + const bucket = buckets.get(key) || {r:0,g:0,b:0,count:0}; + bucket.r += data[i]; bucket.g += data[i + 1]; bucket.b += data[i + 2]; bucket.count++; buckets.set(key,bucket); + } + const dominant = []; + for (const bucket of [...buckets.values()].sort((a,b) => b.count - a.count)) { + const value = {r:Math.round(bucket.r / bucket.count),g:Math.round(bucket.g / bucket.count),b:Math.round(bucket.b / bucket.count),a:1}; + if (dominant.every(existing => Math.hypot(existing.r-value.r,existing.g-value.g,existing.b-value.b) > 40)) dominant.push(value); + if (dominant.length === 8) break; + } + paintSwatches('image-palette', dominant, [], 'Нет достаточно непрозрачных пикселей для палитры. Выберите отдельный пиксель.'); + get('image-status').textContent = ''; + get('image-meta').hidden = false; get('image-meta').textContent = canvas.width + ' × ' + canvas.height + (scale < 1 ? ' / уменьшено для просмотра' : ''); + } catch (_) { if (!disposed && request === imageRequest) get('image-status').textContent = 'Не удалось прочитать изображение. Попробуйте другой файл или сохраните его как PNG.'; } + finally { bitmap?.close(); if (!disposed && request === imageRequest) get('image-add').disabled = false; } + }); + on(canvas, 'pointerdown', event => { + if (event.button !== 0) return; + const rect = canvas.getBoundingClientRect(); + imagePoint = {x: c.clamp(Math.floor((event.clientX-rect.left)/rect.width*canvas.width),0,canvas.width-1), y: c.clamp(Math.floor((event.clientY-rect.top)/rect.height*canvas.height),0,canvas.height-1)}; + pickImagePoint(); + }); + on(canvas, 'keydown', event => { + if (!['ArrowLeft','ArrowRight','ArrowUp','ArrowDown','Enter',' '].includes(event.key)) return; + event.preventDefault(); + if (event.key === 'Enter' || event.key === ' ') { pickImagePoint(); return; } + const step = event.shiftKey ? 10 : 1; + if (event.key === 'ArrowLeft') imagePoint.x -= step; + if (event.key === 'ArrowRight') imagePoint.x += step; + if (event.key === 'ArrowUp') imagePoint.y -= step; + if (event.key === 'ArrowDown') imagePoint.y += step; + imagePoint.x = c.clamp(imagePoint.x,0,canvas.width-1); imagePoint.y = c.clamp(imagePoint.y,0,canvas.height-1); + placeImagePoint(); + }); + window.__esdocColorsCleanup = () => { + disposed = true; imageRequest++; controller.abort(); + clearTimeout(eyedropperOverlayTimer); + shortcuts.close(); get('eyedropper-overlay').hidden = true; + for (const timer of copyTimers.values()) clearTimeout(timer); + canvas.width = canvas.height = 0; + window.__esdocColorsCleanup = null; + }; + app.hidden = false; render(); +})(); diff --git a/static/js/icons.js b/static/js/icons.js new file mode 100644 index 0000000..67ceb7b --- /dev/null +++ b/static/js/icons.js @@ -0,0 +1,72 @@ +/* Generated by scripts/sync_lucide_icons.py from the vendored Lucide SVGs. + License and pinned upstream revision: static/icons/. */ +(function () { + 'use strict'; + const bodies = { + "arrow-left": "\n ", + "arrow-right": "\n ", + "arrow-up": "\n ", + "check": "", + "chevron-down": "", + "chevron-right": "", + "circle-alert": "\n \n ", + "circle-play": "\n ", + "clock": "\n ", + "code-xml": "\n \n ", + "columns-3": "\n \n ", + "construction": "\n \n \n \n \n \n \n ", + "copy": "\n ", + "download": "\n \n ", + "file": "\n ", + "film": "\n \n \n \n \n \n \n ", + "fold-horizontal": "\n \n \n \n \n \n \n ", + "folder": "", + "folder-up": "\n \n ", + "hash": "\n \n \n ", + "heart": "", + "image": "\n \n ", + "image-up": "\n \n \n ", + "info": "\n \n ", + "layout-grid": "\n \n \n ", + "lightbulb": "\n \n ", + "list": "\n \n \n \n \n ", + "moon": "", + "music": "\n \n ", + "octagon-alert": "\n \n ", + "palette": "\n \n \n \n ", + "pause": "\n ", + "pipette": "\n \n ", + "play": "", + "redo-2": "\n ", + "repeat": "\n \n \n ", + "rotate-ccw": "\n ", + "search": "\n ", + "send": "\n ", + "sliders-horizontal": "\n \n \n \n \n \n \n \n ", + "square": "", + "sun": "\n \n \n \n \n \n \n \n ", + "trash": "\n \n \n \n ", + "triangle-alert": "\n \n ", + "type": "\n \n ", + "undo-2": "\n ", + "unfold-horizontal": "\n \n \n \n \n \n \n ", + "users": "\n \n \n ", + "volume-2": "\n \n ", + "x": "\n " +}; + const escape = value => String(value).replace(/[&<>"']/g, c => ({ + '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', + })[c]); + window.ESDocIcons = Object.freeze({ + svg(name, size = 18, className = '') { + if (!Object.hasOwn(bodies, name)) throw new Error('Unknown Lucide icon: ' + name); + if (!Number.isInteger(size) || size < 1 || size > 128) throw new Error('Invalid icon size'); + const classes = 'lucide lucide-' + name + (className ? ' ' + className : ''); + return ''; + }, + }); +})(); diff --git a/static/js/navigation.js b/static/js/navigation.js index 8529570..5c306e7 100644 --- a/static/js/navigation.js +++ b/static/js/navigation.js @@ -19,6 +19,7 @@ if (pathname === '/docs' || pathname.startsWith('/docs/')) return 'docs'; if (pathname === '/resources' || pathname.startsWith('/resources/')) return 'resources'; if (pathname === '/materials') return 'materials'; + if (pathname === '/tools' || pathname.startsWith('/tools/')) return 'tools'; if (pathname === '/specialists' || pathname === '/artists') return 'specialists'; if (pathname === '/news-resources') return 'news-resources'; return null; @@ -108,6 +109,9 @@ if (id !== navigationId) return; window.__esdocWarperCleanup?.(); + window.__esdocToolsCleanup?.(); + window.__esdocColorsCleanup?.(); + window.__esdocAnimationsCleanup?.(); content.innerHTML = nextContent.innerHTML; document.title = nextDocument.title; document.body.className = nextDocument.body.className; diff --git a/static/js/player.js b/static/js/player.js index b42f816..b48988b 100644 --- a/static/js/player.js +++ b/static/js/player.js @@ -47,24 +47,17 @@ bar.setAttribute('aria-label', 'Сейчас играет'); bar.innerHTML = '' + + window.ESDocIcons.svg('pause', 16) + + window.ESDocIcons.svg('play', 16) + '' + '' + + window.ESDocIcons.svg('square', 16) + '' + '
' + '
0:00' + '' + '0:00
' + '' + - '

Документация по моддин data-play-src="{{ track.src }}" data-play-name="{{ track_name }}" data-play-label="Включить тему сайта" aria-label="Включить тему сайта" aria-pressed="false" hidden> - - + {{ lucide_icon('play', 16, 'hero-track-icon-play') }} + {{ lucide_icon('pause', 16, 'hero-track-icon-pause') }}
- На главную + На главную

Полезные материалы

Материалы по программированию и писательскому мастерству. diff --git a/templates/news_resources.html b/templates/news_resources.html index 8459aff..7c3fbc4 100644 --- a/templates/news_resources.html +++ b/templates/news_resources.html @@ -10,7 +10,7 @@

- На главную + На главную

Новостные ресурсы

Различные площадки с новостями и группы по интересам.

Если хотите видеть ваш канал здесь — свяжитесь с нами.

diff --git a/templates/partials/animation_gallery.html b/templates/partials/animation_gallery.html new file mode 100644 index 0000000..f964443 --- /dev/null +++ b/templates/partials/animation_gallery.html @@ -0,0 +1,82 @@ + {% if animations %} + + + + {% else %} +
+

Файлы анимаций не найдены.

+

Проверьте, что подключена папка ресурсов оригинала.

+
+ {% endif %} diff --git a/templates/partials/footer.html b/templates/partials/footer.html index 6c37a87..66a32ec 100644 --- a/templates/partials/footer.html +++ b/templates/partials/footer.html @@ -7,36 +7,25 @@ {# Icon-only links sit closer to each other than to the text links beside them, so they read as one set of "where to find us". #} - - - {# viewBox shifted 0.9 up: the glyph spans y 4.2–13.6, so on a - 0 0 16 16 box it sits below the optical centre of the label - beside it. #} - + {{ lucide_icon('heart', 15) }} Поддержать проект

diff --git a/templates/partials/head.html b/templates/partials/head.html index 1a40def..054d571 100644 --- a/templates/partials/head.html +++ b/templates/partials/head.html @@ -18,6 +18,7 @@ + {# The player is site-wide: its audio state survives normal section navigations through sessionStorage, so the bar must exist on every page. #} @@ -25,6 +26,7 @@ {% for href in (extra_css or []) %} {% endfor %} + @@ -47,7 +49,15 @@ {% if active == "resources" %} {% endif %}{% if active == "specialists" %} {% endif %} +{% if with_animation_js %}{% endif %} {% if active == "resources" or active == "specialists" %} {% endif %} +{% if with_unpacker_js %} + +{% endif %} +{% if with_colors_js %} + + +{% endif %} {% if DEBUG %} {% endif %} diff --git a/templates/partials/header.html b/templates/partials/header.html index 30bd037..9a5a9ff 100644 --- a/templates/partials/header.html +++ b/templates/partials/header.html @@ -7,9 +7,10 @@ {% set sections = [ ('/docs/', 'docs', 'Документация'), ('/resources/', 'resources', 'Ресурсы'), - ('/materials', 'materials', 'Полезные материалы'), + ('/tools', 'tools', 'Инструменты'), + ('/materials', 'materials', 'Материалы'), ('/specialists', 'specialists', 'Специалисты'), - ('/news-resources', 'news-resources', 'Новостные ресурсы'), + ('/news-resources', 'news-resources', 'Площадки'), ] %} {% macro section_links(sections, active) %} @@ -34,20 +35,14 @@
diff --git a/templates/resources_browser.html b/templates/resources_browser.html index c84cdd3..6637afe 100644 --- a/templates/resources_browser.html +++ b/templates/resources_browser.html @@ -11,42 +11,7 @@ {% macro icon(kind) %} {% endmacro %} @@ -57,7 +22,7 @@
- Ресурсы + Ресурсы

Браузер ресурсов

Папка game декомпилированной игры как она есть — @@ -71,6 +36,14 @@

Браузер ресурсов

+ {% if show_animation_link %} + + Эффекты и анимации оригинала + Кадры из этой папки собраны в живые превью + + + {% endif %} +

') + paragraph = html.index('

После таблицы внутри блока.

') + heading = html.index('Инструменты', response.text) + self.assertNotIn('name="tool-mode"', response.text) + self.assertIn('.rpa, .rpyc, .rpymc, .pyc', response.text) + self.assertNotIn('Результат — ZIP', response.text) + self.assertNotIn('Попытаться снять обфускацию', response.text) + self.assertNotIn('tools-report', response.text) + self.assertIn('tools-download-all', response.text) + self.assertIn('id="tools-browser-search" class="tools-browser-search"', response.text) + self.assertIn('/static/css/syntax.css', response.text) + self.assertIn('tools-warning-host tools-result-meta', response.text) + self.assertIn('aria-label="Причины предупреждений"', response.text) + self.assertNotIn('Часть кода могла восстановиться неточно', response.text) + self.assertLess(response.text.index('id="tools-browser-preview"'), response.text.index('id="tools-download-all"')) + + async def test_hub_links_to_tools_without_loading_unpacker(self): + response = await self.get('/tools') + self.assertEqual(response.status_code, 200) + self.assertIn('href="/tools/unpack"', response.text) + self.assertIn('href="/tools/colors"', response.text) + self.assertNotIn('tools-worker.js', response.text) + self.assertNotIn('/static/js/tools.js', response.text) + + async def test_color_page_has_its_own_controls_and_scripts(self): + response = await self.get('/tools/colors') + self.assertEqual(response.status_code, 200) + self.assertIn('colors-core.js', response.text) + self.assertIn('colors-named.js', response.text) + self.assertIn('colors.js', response.text) + self.assertIn('Именованные цвета', response.text) + catalog = await self.get('/static/js/colors-named.js') + self.assertEqual(catalog.status_code, 200) + self.assertIn('Белоснежный', catalog.text) + self.assertIn('href="/tools"', response.text) + self.assertNotIn('tools-worker.js', response.text) + self.assertNotIn('/static/js/tools.js', response.text) + + async def test_engine_and_runtime_are_served_locally(self): + stylesheet = await self.get('/static/css/tools.css') + self.assertEqual(stylesheet.status_code, 200) + self.assertIn('.tools-icon-warning { --tools-icon: url("/static/icons/circle-alert.svg?v=', stylesheet.text) + self.assertIn('color: var(--attention)', stylesheet.text) + self.assertIn('.tools-status.is-error { color: var(--danger); }', stylesheet.text) + self.assertIn('--tools-error-icon:', stylesheet.text) + for path in ('engine.py', 'recovery.py', 'pyc_decompiler.py', 'bytecode.zip', 'vendor.zip', 'syntax.zip', 'renpy_lexer.py', 'pyodide/0.29.3/pyodide.mjs', 'pyodide/0.29.3/pyodide.asm.wasm'): + response = await self.get('/static/tools/' + path) + self.assertEqual(response.status_code, 200, path) + self.assertIn('nosniff', response.headers['x-content-type-options']) + response = await self.get('/static/tools/../../app/app.py') + self.assertEqual(response.status_code, 404) + + async def test_tools_is_in_sitemap_and_global_search(self): + response = await self.get('/sitemap.xml') + for path in ('/tools', '/tools/unpack', '/tools/colors'): + self.assertIn(path + '', response.text) + self.assertIn(path, [item['url'] for item in _section_items()]) diff --git a/tests/test_tools_engine.py b/tests/test_tools_engine.py new file mode 100644 index 0000000..c72b2cd --- /dev/null +++ b/tests/test_tools_engine.py @@ -0,0 +1,498 @@ +import base64 +import importlib.util +import io +import pickle +import struct +import sys +import unittest +import zipfile +import zlib +from pathlib import Path +from tempfile import TemporaryDirectory +from unittest.mock import patch + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / 'static/tools')) +sys.path.insert(0, str(ROOT / 'static/tools/vendor.zip')) +spec = importlib.util.spec_from_file_location('tools_engine', ROOT / 'static/tools/engine.py') + + +def archive(entries, version=3, key=0xDEADBEEF): + header_size = 34 if version == 3 else 25 + body = bytearray(b' ' * header_size) + index = {} + for name, parts in entries.items(): + records = [] + for content, prefix in parts: + offset = len(body) + body.extend(content) + length = len(content) + len(prefix) + records.append((offset ^ key, length ^ key, prefix) if version == 3 + else (offset, length, prefix)) + index[name] = records + offset = len(body) + body.extend(zlib.compress(pickle.dumps(index, protocol=2))) + header = (f'RPA-3.0 {offset:016x} {key:08x}\n' if version == 3 + else f'RPA-2.0 {offset:016x}\n').encode() + body[:len(header)] = header + return bytes(body) + + +def extended_index_archive(records=7, prefix=b''): + key = 0x42424242 + payload = b'body' + index_offset = 34 + len(payload) + offset, length = 34 ^ key, len(payload) ^ key + record = { + 4: (offset, length, prefix, b'end metadata'), + 5: (b'first metadata', offset, length, prefix, b'end metadata'), + 6: (b'first metadata', offset, length, length, prefix, b'end metadata'), + 7: (b'first metadata', offset, length, length, prefix, b'end metadata', b'end2 metadata'), + }[records] + index = {'test.txt': [record]} + return f'xehsoidx{index_offset:016x} {key:08x}\n'.encode() + payload + zlib.compress(pickle.dumps(index, protocol=2)) + + +def protocol2_fixture(value): + from decompiler import magic + class FixturePickler(magic.SafePickler): + def save_global(self, obj, name=None): + if isinstance(obj, magic.FakeClassType): + self.write(pickle.GLOBAL + f'{obj.__module__}\n{obj.__name__}\n'.encode()) + self.memoize(obj) + else: + super().save_global(obj, name) + + out = io.BytesIO() + FixturePickler(out, protocol=2).dump(value) + return out.getvalue() + + +class EngineTests(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.engine = importlib.util.module_from_spec(spec) + spec.loader.exec_module(cls.engine) + + def run_job(self, entries, mode='unrpa', options=None): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + files = [] + for i, (name, data) in enumerate(entries): + physical = Path(directory) / f'input-{i}' + physical.write_bytes(data) + files.append({'path': name, 'source': str(physical)}) + sink = io.BytesIO() + events = [] + result = self.engine.process(files, mode, options or {}, sink, events.append) + with zipfile.ZipFile(io.BytesIO(sink.getvalue())) as output: + contents = {name: output.read(name) for name in output.namelist()} + return result, contents, events + + def test_rpa3_xor_prefix_split_and_unicode_paths(self): + data = archive({'сцены/intro.rpy': [(b'world', b'hello '), (b'!', b'')], + 'images/empty.png': [(b'', b'')]}) + result, contents, events = self.run_job([('game/data.rpa', data)]) + self.assertEqual(contents['сцены/intro.rpy'], b'hello world!') + self.assertEqual(contents['images/empty.png'], b'') + self.assertNotIn('esdoc-report.txt', contents) + self.assertEqual(result['succeeded'], 1) + self.assertTrue(events) + + def test_rpa2_archives_are_processed_as_separate_outputs(self): + data = archive({'script.rpy': [(b'label start:\n pass\n', b'')]}, version=2) + for name in ('a.rpa', 'nested/b.rpa'): + result, contents, _ = self.run_job([(name, data)]) + self.assertEqual(list(contents), ['script.rpy']) + self.assertEqual(result['succeeded'], 1) + + def test_catalog_lists_without_decompiling_then_reads_only_clicked_script(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'archive' + source.write_bytes(archive({'scenario/a.rpyc': [(self.script(), b'')], + 'other.rpyc': [(b'invalid', b'')]})) + with patch.object(self.engine, 'decompile', wraps=self.engine.decompile) as decompile: + catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}]) + self.assertEqual(len(catalog.listing()), 2) + self.assertEqual(decompile.call_count, 0) + path, data, warnings = catalog.read('0:scenario/a.rpyc', {}) + self.assertEqual(path, 'scenario/a.rpy') + self.assertIn(b'label start:', data) + self.assertEqual(decompile.call_count, 1) + self.assertEqual(catalog.read('0:scenario/a.rpyc', {}), (path, data, warnings)) + self.assertEqual(decompile.call_count, 1) + catalog.read('0:scenario/a.rpyc', {'no_init_offset': True}) + self.assertEqual(decompile.call_count, 2) + + def test_lazy_preview_preserves_existing_source_and_split_segments(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'archive' + source.write_bytes(archive({'script.rpyc': [(b'invalid', b'')], + 'script.rpy': [(b'start:', b'label '), (b'\n pass\n', b'')]})) + catalog = self.engine.Catalog([{'path': 'data.rpa', 'source': str(source)}]) + path, data, warnings = catalog.read('0:script.rpyc', {}) + self.assertEqual((path, data), ('script.rpy', b'label start:\n pass\n')) + self.assertEqual(len(warnings), 1) + + def test_catalog_reports_bad_archives_without_hiding_good_sources(self): + with TemporaryDirectory(dir=ROOT / 'temp') as directory: + source = Path(directory) / 'good' + source.write_bytes(self.script()) + bad = Path(directory) / 'bad' + bad.write_bytes(archive({'../unsafe': [(b'x', b'')]})) + catalog = self.engine.Catalog([{'path': 'bad.rpa', 'source': str(bad)}, + {'path': 'good.rpyc', 'source': str(source)}]) + self.assertEqual(len(catalog.errors), 1) + self.assertEqual(catalog.listing()[0]['id'], '1:good.rpyc') + + def test_local_highlighter_uses_shared_renpy_lexer_and_escapes_markup(self): + with patch.dict(sys.modules, {'renpy_lexer': __import__('app.utils.renpy_lexer', fromlist=['RenPyLexer'])}): + html = self.engine.preview_html('script.rpy', b'label start:\n ""\n') + self.assertIn('class="k"', html) + self.assertIn('class="w"', html) + self.assertNotIn(''); + assert.match(unknown.title, /Не удалось обработать/); + assert.equal(unknown.technical, ''); +}); + +test('only changed script structure offers advanced recovery; memory and network have separate advice', () => { + assert.equal(core.explainError('Unable to find the right slot to load from the rpyc file. The file header structure has been changed.').tryHarder, true); + assert.match(core.explainError('MemoryError: Out of memory').explanation, /меньше файлов/); + assert.match(core.explainError('TypeError: Failed to fetch dynamically imported module').explanation, /соединение/); + const archive = core.explainError('game/data.rpa: UnknownArchiveError: unsupported header'); + assert.equal(archive.path, 'game/data.rpa'); + assert.match(archive.title, /формат архива/); +}); + +test('filter extensions by tool, including uppercase and modules', () => { + assert.equal(core.accepts('game/SCRIPT.RPYC', 'unrpyc'), true); + assert.equal(core.accepts('game/file.rpymc', 'combined'), true); + assert.equal(core.accepts('game/data.rpa', 'combined'), true); + assert.equal(core.accepts('game/data.rpa', 'unrpyc'), false); + assert.equal(core.accepts('game/a.png', 'combined'), false); +}); +test('reject unsafe paths before passing files to worker', () => { + for (const path of ['../file', '/absolute', 'C:\\file', 'a/../../b', 'a\0b']) { + assert.throws(() => core.safePath(path)); + } + assert.equal(core.safePath('game/сцены/script.rpyc'), 'game/сцены/script.rpyc'); +}); + +test('read every batch of a dropped folder and preserve nested paths', async () => { + const file = name => ({ isFile: true, name, file: done => done({ name, size: 1 }) }); + const folder = (name, batches) => ({ isDirectory: true, name, createReader: () => ({ + readEntries: done => done(batches.shift() || []), + }) }); + const entry = folder('game', [[file('a.rpyc')], [folder('scenario', [[file('b.rpyc')], []])], []]); + const paths = []; + await core.walkEntry(entry, '', item => paths.push(item.path)); + assert.deepEqual(paths, ['game/a.rpyc', 'game/scenario/b.rpyc']); +}); + +test('permission errors from folder enumeration propagate', async () => { + const entry = { name: 'blocked', isDirectory: true, createReader: () => ({ + readEntries: (done, error) => error(new Error('permission denied')), + }) }; + await assert.rejects(core.walkEntry(entry, '', () => {}), /permission denied/); +}); + +test('legacy RenPy warnings explain successful decompilation and possible inaccuracies', () => { + for (const version of [6, 7]) { + const message = `game/сцены/script.rpyc: Warning: analysis found signs that this .rpyc file was generated by ren'py \n version ${version} or below, while this unrpyc version targets ren'py \n version 8. Decompilation will still be attempted, but errors or incorrect \n decompilation might occur. `; + const warning = core.explainWarning(message); + assert.equal(warning.path, 'game/сцены/script.rpyc'); + assert.match(warning.explanation, /Файл декомпилирован\./); + assert.match(warning.explanation, new RegExp(`Ren'Py ${version} или более ранней`)); + assert.match(warning.explanation, /вероятно/); + assert.match(warning.explanation, /возможны ошибки и неточности/); + assert.equal(warning.technical, message); + } +}); + +test('structural and custom-displayable warnings give distinct explanations', () => { + const structure = core.explainWarning('Warning: Encountered an unexpected slot structure. It is possible the \n file header structure has been changed.'); + assert.equal(structure.title, 'Изменённая структура файла'); + const displayable = core.explainWarning('Warning: Encountered a user-defined displayable of type "Custom".\n For now the style name "custom_style" will be substituted.'); + assert.match(displayable.explanation, /«custom_style»/); + assert.match(displayable.explanation, /renpy.register_sl_displayable/); +}); + +test('existing source is not described as decompiled, and recovery attempts are explained', () => { + const source = core.explainWarning('script.rpymc: Исходный файл script.rpym уже есть в архиве, сохранён без замены.'); + assert.equal(source.path, 'script.rpymc'); + assert.match(source.explanation, /повторная декомпиляция этого файла не выполнялась/); + const recovery = core.explainWarning('Attempting to deobfuscate file:\n strategy first failed\n strategy second success'); + assert.match(recovery.explanation, /Неудачные промежуточные попытки/); + assert.match(recovery.explanation, /Файл восстановлен/); +}); + +test('unknown messages retain original details and do not invent a specific cause', () => { + const message = '\n more details'; + const warning = core.explainWarning(message); + assert.equal(warning.title, 'Результат требует проверки'); + assert.equal(warning.technical, message); + assert.match(warning.explanation, /Файл получен/); + assert.equal(warning.path, ''); +}); + +test('container warnings distinguish extracted blocks and missing Python source', () => { + const partial = core.explainWarning('script.rpyc: Контейнер: Python-блок содержит только байткод. Исходник не восстановлен, в .rpy оставлены комментарий и pass.') + assert.equal(partial.title, 'Python-блок восстановлен частично'); + assert.match(partial.explanation, /pass/); + assert.match(partial.explanation, /не восстановлен/); + const extracted = core.explainWarning('Контейнер: восстановлено блоков: 18. Они добавлены ниже основного скрипта для чтения и адаптации.') + assert.equal(extracted.title, 'Извлечены блоки сценария') + assert.match(extracted.explanation, /загрузчик/i); + const failed = core.explainWarning('Контейнер: блок 0 не восстановлен (ValueError). Исходный контейнер оставлен в .rpy.') + assert.equal(failed.title, 'Не удалось раскрыть контейнер') + assert.match(failed.explanation, /сохранён/); +}); + diff --git a/tests/tools_pyodide.test.mjs b/tests/tools_pyodide.test.mjs new file mode 100644 index 0000000..1c762a1 --- /dev/null +++ b/tests/tools_pyodide.test.mjs @@ -0,0 +1,200 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import vm from 'node:vm'; +import { fileURLToPath } from 'node:url'; +import { test } from 'node:test'; +import { loadPyodide } from '../static/tools/pyodide/pyodide.mjs'; + +test('real Pyodide processes RPYC and RPA, then lists and previews each result', async () => { + const root = fileURLToPath(new URL('../', import.meta.url)); + const pyodide = await loadPyodide({ indexURL: path.join(root, 'static/tools/pyodide/'), + stdout: () => {}, stderr: () => {} }); + pyodide.FS.mkdirTree('/tools'); + pyodide.FS.mkdirTree('/browse'); + pyodide.FS.mkdirTree('/catalog'); + pyodide.FS.writeFile('/tools/vendor.zip', fs.readFileSync(path.join(root, 'static/tools/vendor.zip'))); + pyodide.FS.writeFile('/tools/engine.py', fs.readFileSync(path.join(root, 'static/tools/engine.py'))); + pyodide.FS.writeFile('/tools/recovery.py', fs.readFileSync(path.join(root, 'static/tools/recovery.py'))); + pyodide.FS.writeFile('/tools/pyc_decompiler.py', fs.readFileSync(path.join(root, 'static/tools/pyc_decompiler.py'))) + pyodide.FS.writeFile('/tools/bytecode.zip', fs.readFileSync(path.join(root, 'static/tools/bytecode.zip'))) + pyodide.FS.writeFile('/tools/syntax.zip', fs.readFileSync(path.join(root, 'static/tools/syntax.zip'))); + pyodide.FS.writeFile('/tools/renpy_lexer.py', fs.readFileSync(path.join(root, 'app/utils/renpy_lexer.py'))); + await pyodide.runPythonAsync(String.raw` +import io, json, pickle, struct, sys, zipfile, zlib +sys.path.insert(0, '/tools') +with zipfile.ZipFile('/tools/vendor.zip') as bundled: + bundled.extractall('/tools') +with zipfile.ZipFile('/tools/syntax.zip') as bundled: + bundled.extractall('/tools') +with zipfile.ZipFile('/tools/bytecode.zip') as bundled: + bundled.extractall('/tools') +import engine +pyc_data = bytes.fromhex('330d0d0a0000000000000000e30000000000000000000000000100000000000000730800000064005a00640153002802000000692a0000004e28010000007506000000616e73776572280000000028000000002800000000750a0000006578616d706c652e707975080000003c6d6f64756c653e010000007300000000') +with open('/module.pyc', 'wb') as source: + source.write(pyc_data) +pyc_files = [{'path': 'module.pyc', 'source': '/module.pyc'}] +pyc_catalog = engine.Catalog(pyc_files) +name, text, warnings = pyc_catalog.read('0:module.pyc', {}) +assert name == 'module.py' and b'answer = 42' in text and not warnings +pyc_sink = io.BytesIO() +pyc_result = engine.process(pyc_files, 'combined', {}, pyc_sink) +assert pyc_result['failed'] == 0, pyc_result +with zipfile.ZipFile(io.BytesIO(pyc_sink.getvalue())) as output: + assert output.read('module.pyc') == pyc_data + assert output.read('module.py') == text +import decompiler.renpycompat as compat +label = compat.CLASS_FACTORY('Label', 'renpy.ast')() +say = compat.CLASS_FACTORY('Say', 'renpy.ast')() +label.__dict__.update(name='start', linenumber=1, parameters=None, hide=False, block=[say]) +say.__dict__.update(who=None, what='Hello', linenumber=2, interact=True, + attributes=None, arguments=None, temporary_attributes=None, + identifier=None, explicit_identifier=None) +payload = zlib.compress(compat.pickle_safe_dumps(({}, [label]))) +compiled = b'RENPY RPC2' + struct.pack(' { messages.push(message); waiting.get(message.requestId)?.(message); } }; + // Node has no browser Blob mount. Keep the actual Python ZIP reads and + // conversions, with the completed archive already written to MEMFS. + const runtime = { + runPythonAsync: source => pyodide.runPythonAsync(source), + globals: pyodide.globals, + FS: { mkdirTree() {}, mount() {}, unmount() {}, filesystems: { WORKERFS: {} } }, + }; + const context = vm.createContext({ self, runtime }); + const worker = fs.readFileSync(path.join(root, 'static/js/tools-worker.js'), 'utf8'); + vm.runInContext(worker.replace('let runtimePromise;', 'let runtimePromise = Promise.resolve(runtime);'), context); + globalThis.esdocPreview = self.esdocPreview; + globalThis.esdocSourcePreview = self.esdocSourcePreview; + globalThis.esdocEmit = self.esdocEmit; + globalThis.esdocNotify = self.esdocNotify; + const request = data => new Promise(resolve => { + waiting.set(data.requestId, message => { waiting.delete(data.requestId); resolve(message); }); + self.onmessage({ data }); + }); + try { + for (const [index, mode] of ['unrpyc', 'combined'].entries()) { + pyodide.globals.set('test_mode', mode); + const result = JSON.parse(await pyodide.runPythonAsync(` +source = '/script.rpyc' if test_mode == 'unrpyc' else '/data.rpa' +sink = io.BytesIO() +result = engine.process([{'path': source[1:], 'source': source}], test_mode, {}, sink) +with open('/browse/result.zip', 'wb') as destination: + destination.write(sink.getvalue()) +json.dumps(result) +`)); + assert.equal(result.failed, 0); + const list = await request({ type: 'browse-list', blob: new Blob(), requestId: index * 2 + 1 }); + assert.equal(list.type, 'browse-list', list.error); + const script = list.entries.find(entry => entry.path.endsWith('.rpy')); + assert(script); + const preview = await request({ type: 'browse-read', path: script.path, requestId: index * 2 + 2 }); + assert.equal(preview.type, 'browse-file', preview.error); + assert.match(new TextDecoder().decode(preview.buffer), /label start:/); + pyodide.FS.writeFile('/catalog/input-0', pyodide.FS.readFile(mode === 'unrpyc' ? '/script.rpyc' : '/data.rpa')); + const catalog = await request({ type: 'catalog', files: [{ path: mode === 'unrpyc' ? 'script.rpyc' : 'data.rpa', file: new Blob() }], + requestId: 10 + index * 2 }); + assert.equal(catalog.type, 'catalog', catalog.error); + assert(catalog.entries[0].path.endsWith('.rpyc')); + const opened = await request({ type: 'source-read', id: catalog.entries[0].id, options: {}, requestId: 11 + index * 2 }); + assert.equal(opened.type, 'source-file', opened.error); + assert.match(new TextDecoder().decode(opened.buffer), /label start:/); + assert.match(opened.html, /class="k"/); + assert.match(opened.html, /class="w"/); + } + pyodide.FS.mkdirTree('/input'); + for (const [index, source] of ['/script.rpyc', '/data.rpa', '/script.rpyc'].entries()) { + pyodide.FS.writeFile(`/input/input-${index}`, pyodide.FS.readFile(source)); + } + messages.length = 0; + await vm.runInContext(`run({mode: 'combined', files: [ + {path: 'script.rpyc'}, {path: 'data.rpa'}, {path: 'other.rpyc'}], options: {}})`, context); + const outputs = messages.filter(message => message.type === 'output'); + assert.equal(outputs.length, 2, 'All standalone scripts share one ZIP beside the archive ZIP'); + const scriptOutput = outputs.find(message => message.name === 'unrpyc.zip'); + assert.ok(scriptOutput, 'Standalone scripts must download with a ZIP filename'); + assert.equal(scriptOutput.result.succeeded, 2); + assert.equal(outputs.find(message => message.name === 'data.zip').result.succeeded, 1); + assert.equal(messages.at(-1).type, 'done'); + assert.equal(messages.at(-1).result.succeeded, 3); + let chunks = []; + for (const message of messages) { + if (message.type === 'output-start') chunks = []; + if (message.type === 'chunk') chunks.push(Buffer.from(message.buffer)); + if (message.type === 'output') { + pyodide.FS.writeFile('/verify.zip', Buffer.concat(chunks)); + const paths = JSON.parse(await pyodide.runPythonAsync(` +with zipfile.ZipFile('/verify.zip') as output: + output_paths = sorted(output.namelist()) +json.dumps(output_paths) +`)); + assert.deepEqual(paths, message.name === 'unrpyc.zip' + ? ['other.rpy', 'script.rpy'] : ['scenario/script.rpy', 'scenario/script.rpyc']); + } + } + for (const path of ['script.rpyc', 'data.rpa', 'other.rpyc']) { + const event = messages.find(message => message.type === 'file' && message.path === path); + assert.equal(event.index, ['script.rpyc', 'data.rpa', 'other.rpyc'].indexOf(path), 'Progress refers to the original queue index'); + } + messages.length = 0; + pyodide.FS.writeFile('/input/input-2', pyodide.FS.readFile('/data.rpa')); + await vm.runInContext(`run({mode: 'combined', files: [ + {path: 'script.rpyc'}, {path: 'unrpyc.rpa'}, {path: 'folder/unrpyc.rpa'}], options: {}})`, context); + assert.deepEqual(messages.filter(message => message.type === 'output').map(message => message.name), + ['unrpyc.zip', 'unrpyc-2.zip', 'unrpyc-3.zip'], 'ZIP filenames must be unique across scripts and archives'); + assert.equal(messages.at(-1).result.failed, 0); + } finally { + delete globalThis.esdocPreview; + delete globalThis.esdocSourcePreview; + delete globalThis.esdocEmit; + delete globalThis.esdocNotify; + } +}); diff --git a/tests/tools_ui.test.mjs b/tests/tools_ui.test.mjs new file mode 100644 index 0000000..0d60ec8 --- /dev/null +++ b/tests/tools_ui.test.mjs @@ -0,0 +1,481 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { test } from 'node:test'; + +function page(search = '', withClipboard = false) { + class Element { + listeners = new Map(); children = []; hidden = false; disabled = false; + value = ''; files = []; clicks = 0; style = {}; attributes = {}; + classList = { add() {}, remove() {}, toggle() {} }; + addEventListener(type, callback) { + const handlers = this.listeners.get(type) || []; + handlers.push(callback); this.listeners.set(type, handlers); + } + emit(type, detail = {}) { + for (const callback of this.listeners.get(type) || []) callback({ target: this, ...detail }); + } + click() { this.clicks++; this.emit('click'); } + focus() { document.activeElement = this; } + contains(target) { return target === this || this.children.includes(target); } + getBoundingClientRect() { return { left: 0, bottom: 40, width: 210, height: 100 }; } + setAttribute(key, value) { this.attributes[key] = value; } + removeAttribute(key) { delete this.attributes[key]; } + querySelectorAll() { return []; } + replaceChildren(...children) { + for (const child of this.children) child.parentNode = null; + this.children = []; this.append(...children); + } + get lastElementChild() { return this.children.at(-1); } + appendChild(child) { + if (child.parentNode) child.parentNode.children = child.parentNode.children.filter(item => item !== child); + child.parentNode = this; this.children.push(child); + } + append(...children) { for (const child of children) this.appendChild(child); } + } + const elements = new Map(); + const get = id => { + if (!elements.has(id)) elements.set(id, new Element()); + return elements.get(id); + }; + const document = new Element(); + document.getElementById = get; + document.createElement = tag => Object.assign(new Element(), { tagName: tag.toUpperCase() }); + document.createDocumentFragment = () => new Element(); + const app = get('tools-app'); + app.dataset = { config: '{"worker":"tools-worker.js"}' }; + const archiveRadio = new Element(); archiveRadio.value = 'unrpa'; + const scriptRadio = new Element(); scriptRadio.value = 'unrpyc'; + const option = get('tools-no-init-offset'); + get('tools-operation-actions').append(get('tools-start'), get('tools-cancel')); + app.querySelectorAll = selector => selector === '[name="tool-mode"]' ? [archiveRadio, scriptRadio] + : selector === '.tools-options input' ? [option, get('tools-try-harder')] : []; + const menu = get('tools-add-menu'); menu.hidden = true; + menu.children = [get('tools-pick-files'), get('tools-pick-folder')]; + const messages = [], workers = [], revoked = [], blobs = []; + const window = new Element(); window.location = { search }; + const copied = []; + if (withClipboard) { + window.navigator = { clipboard: {} }; + window.copyControl = (button, value) => () => copied.push(value()); + } + window.Worker = class { + constructor() { workers.push(this); } + postMessage(message) { messages.push(message); } + terminate() { this.terminated = true; } + }; + window.WebAssembly = {}; + const context = vm.createContext({ window, document, Worker: window.Worker, + AbortController, Blob, URLSearchParams, URL: { createObjectURL: blob => { blobs.push(blob); return 'blob:' + Math.random(); }, + revokeObjectURL: url => revoked.push(url) }, TextDecoder, queueMicrotask, innerWidth: 1200, innerHeight: 900 }); + vm.runInContext(fs.readFileSync(new URL('../static/js/icons.js', import.meta.url), 'utf8'), context); + vm.runInContext(fs.readFileSync(new URL('../static/js/tools-core.js', import.meta.url), 'utf8'), context); + window.ESDocTools = context.ESDocTools; + vm.runInContext(fs.readFileSync(new URL('../static/js/tools.js', import.meta.url), 'utf8'), context); + const result = { succeeded: 1, written: 1, failed: 0, warnings: 0, errors: [] }; + const send = data => workers.at(-1).onmessage({ data }); + const catalog = entries => send({ type: 'catalog', requestId: messages.at(-1).requestId, entries, errors: [] }); + const add = async (names, entries) => { + get('tools-files').files = names.map(name => ({ name, size: 1 })); + get('tools-files').emit('change'); await Promise.resolve(); + if (entries && messages.at(-1)?.type === 'catalog') catalog(entries); + }; + const exportDone = (names = ['unrpyc.zip'], total = result) => { + for (const name of names) { + send({ type: 'output-start' }); + send({ type: 'chunk', buffer: new Uint8Array([1, 2]).buffer }); + send({ type: 'output', name, result }); + } + send({ type: 'done', result: total }); + }; + return { get: name => get('tools-' + name), document, archiveRadio, scriptRadio, + messages, workers, revoked, blobs, window, send, catalog, add, exportDone, result, copied }; +} +const script = { id: '0:script.rpyc', path: 'script.rpyc', source: 0, size: 1 }; + +test('PYC accepts uploads and shows Python source preview', async () => { + const ui = page() + const entry = { id: '0:module.pyc', path: 'module.pyc', source: 0, size: 100 } + await ui.add(['module.pyc'], [entry]) + ui.get('browser-list').children[0].children[0].click() + assert.equal(ui.get('browser-preview').children[0].children[0].textContent, 'Декомпилируем файл…') + const pending = ui.messages.at(-1) + assert.equal(pending.type, 'source-read') + ui.send({ type: 'source-file', requestId: pending.requestId, name: 'module.py', + buffer: new TextEncoder().encode('answer = 42\n').buffer, warnings: [], html: null }) + assert.equal(ui.get('browser-preview').children[2].download, 'module.py') + assert.match(ui.get('browser-preview').children[1].children[0].children[1].children[0].textContent, /answer = 42/) +}) + +for (const [name, mime] of [['track.OPUS', 'audio/ogg'], ['track.FLAC', 'audio/flac']]) { + test(`archive previews ${name} as playable audio and retains its download`, async () => { + const ui = page('?mode=unrpa'); + const entry = { id: '0:' + name, path: name, source: 0, size: 3 }; + await ui.add(['music.rpa'], [entry]); + const button = ui.get('browser-list').children[0].children[0]; + button.click(); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name, + buffer: new Uint8Array([1, 2, 3]).buffer }); + assert.equal(ui.blobs.at(-1).type, mime); + const media = ui.get('browser-preview').children[1]; + assert.equal(media.tagName, 'AUDIO'); + assert.equal(media.controls, true); + assert.equal(media.preload, 'metadata'); + assert.match(media.src, /^blob:/); + assert.equal(ui.get('browser-preview').children[2].download, name); + assert.equal(button.children[0].className, 'tools-ui-icon tools-icon-media'); + }); +} + +test('picker menu survives transient focus loss and closes when tabbing out', () => { + const { get, document } = page(); + get('add').click(); document.activeElement = null; + get('add-menu').emit('focusout', { relatedTarget: null }); + assert.equal(get('add-menu').hidden, false); + get('pick-folder').click(); assert.equal(get('folder').clicks, 1); + get('add').click(); + get('add-menu').emit('focusout', { relatedTarget: get('start') }); + assert.equal(get('add-menu').hidden, true); +}); + +test('adding scripts prepares a catalog without decompiling or auto-opening', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + assert.equal(ui.messages.length, 0); + assert.equal(ui.get('browser').hidden, false); + assert.equal(ui.get('browser-preview').children[0].textContent, 'Файл не выбран'); + assert.equal(ui.get('start').hidden, true); assert.equal(ui.get('file-list').hidden, false); +}); + +test('click requests one source and renders highlighted code with a separate gutter', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + const read = ui.messages.at(-1); + assert.equal(read.type, 'source-read'); assert.equal(read.id, script.id); + const html = 'label start:\n'; + ui.send({ type: 'source-file', requestId: read.requestId, name: 'script.rpy', html, + buffer: new TextEncoder().encode('label start:\n').buffer }); + const pre = ui.get('browser-preview').children[1].children[0]; + assert.equal(pre.children[0].textContent, '1'); + assert.equal(pre.children[1].children[0].innerHTML, html); + assert.equal(ui.get('browser-preview').children[2].download, 'script.rpy'); + assert.equal(ui.get('downloads').children.length, 0); +}); + +test('preview uses the shared code panel and copies source text without the gutter', async () => { + const ui = page('', true); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + const text = 'label start:\n pass\n'; + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode(text).buffer }); + const frame = ui.get('browser-preview').children[1]; + assert.equal(frame.className, 'code-block code-block--numbered tools-code-frame'); + assert.equal(frame.children[1].className, 'code-copy'); + const pre = frame.children[0], scroll = pre.children[1]; + assert.equal(pre.tabIndex, 0); + assert.equal(pre.attributes['aria-label'], 'Код файла'); + assert.notEqual(scroll.tabIndex, 0); + scroll.scrollLeft = 0; + let prevented = false; + pre.emit('keydown', { key: 'ArrowRight', preventDefault: () => { prevented = true; } }); + assert.equal(scroll.scrollLeft, 40); + assert.equal(prevented, true); + frame.children[1].click(); + assert.deepEqual(ui.copied, [text]); +}); + +test('options reprocess only the open file', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode('label start:').buffer }); + ui.get('no-init-offset').checked = true; ui.get('no-init-offset').emit('change'); + assert.equal(ui.messages.at(-1).type, 'source-read'); + assert.equal(ui.messages.at(-1).options.no_init_offset, true); + assert.equal(ui.messages.filter(message => message.type === 'run').length, 0); +}); + +test('advanced recovery reindexes archives and passes options to catalog and export', async () => { + const ui = page(); + const entry = { id: '0:script.rpyc', path: 'script.rpyc', source: 0, size: 10 }; + await ui.add(['custom.rpa'], [entry]); + assert.equal(Boolean(ui.messages.at(-1).options.try_harder), false); + ui.get('try-harder').checked = true; + ui.get('try-harder').emit('change'); + const catalog = ui.messages.at(-1); + assert.equal(catalog.type, 'catalog'); + assert.equal(catalog.options.try_harder, true); + ui.send({ type: 'catalog', requestId: catalog.requestId, entries: [entry], errors: [] }); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).type, 'run'); + assert.equal(ui.messages.at(-1).options.try_harder, true); +}); + +test('preview cancellation stays below its loading message and returns after completion', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + const pending = ui.get('browser-preview').children[0]; + assert.equal(pending.children[0].textContent, 'Декомпилируем файл…'); + assert.equal(pending.children[1], ui.get('cancel')); + assert.equal(ui.get('browser-status').textContent, ''); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', buffer: new ArrayBuffer(0) }); + assert.equal(ui.get('cancel').parentNode, ui.get('operation-actions')); + assert.equal(ui.get('cancel').hidden, true); +}); + +test('switching files reuses ready previews and warnings without another worker request', async () => { + const ui = page(); await ui.add(['script.rpyc', 'other.rpyc']); + const open = index => ui.get('browser-list').children[index].children[0].click(); + const respond = name => ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, + name, warnings: ['Warning: analysis found signs that this .rpyc file was generated by renpy version 7 or below'], + buffer: new TextEncoder().encode(name).buffer }); + open(0); respond('script.rpy'); open(1); respond('other.rpy'); + const requests = ui.messages.length; + open(0); + assert.equal(ui.messages.length, requests); + assert.equal(ui.get('browser-preview').children[0].children[0].textContent, 'script.rpy'); + assert.equal(ui.get('browser-preview').children[0].children[1].hidden, false); + assert.equal(ui.get('cancel').hidden, true); + ui.get('no-init-offset').checked = true; ui.get('no-init-offset').emit('change'); + assert.equal(ui.messages.length, requests + 1); + respond('script.rpy'); + ui.get('clear').click(); await ui.add(['script.rpyc']); open(0); + assert.equal(ui.messages.at(-1).type, 'source-read'); + assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'true'); +}); + +test('preview cache evicts the least recently used file after 32 ready previews', async () => { + const ui = page(); await ui.add(Array.from({ length: 33 }, (_, i) => `${i}.rpyc`)); + const open = index => ui.get('browser-list').children[index].children[0].click(); + const respond = () => ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, + name: 'script.rpy', buffer: new ArrayBuffer(10) }); + for (let i = 0; i < 32; i++) { open(i); respond(); } + open(0); open(32); respond(); + const requests = ui.messages.length; + open(0); assert.equal(ui.messages.length, requests); + open(1); assert.equal(ui.messages.length, requests + 1); +}); + +test('download-all processes all sources, downloads automatically, and retains retry links', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).type, 'run'); assert.equal(ui.messages.at(-1).mode, 'combined'); + ui.exportDone(); + assert.equal(ui.get('downloads').children[0].download, 'unrpyc.zip'); + assert.equal(ui.get('downloads').children[0].clicks, 1); + assert.equal(ui.get('queue').hidden, true); + ui.get('download-all').click(); assert.equal(ui.get('downloads').children[0].clicks, 2); + ui.window.__esdocToolsCleanup(); assert.equal(ui.revoked.length, 1); +}); + +test('archives show contents before extraction and each exports as a separate ZIP', async () => { + const ui = page('?mode=unrpa'); + await ui.add(['a.rpa', 'b.rpa'], [script, { ...script, id: '1:other.txt', path: 'other.txt', source: 1 }]); + assert.equal(ui.messages[0].type, 'catalog'); assert.equal(ui.get('browse-actions').hidden, false); + assert.equal(ui.get('browser-list').children.length, 1); + ui.get('archive').value = '1'; ui.get('archive').emit('change'); + assert.equal(ui.get('browser-title').textContent, 'b.rpa'); assert.equal(ui.messages.length, 1); + ui.get('download-all').click(); assert.equal(ui.messages.at(-1).mode, 'combined'); + ui.exportDone(['a.zip', 'b.zip'], { ...ui.result, succeeded: 2, written: 2 }); + assert.deepEqual(ui.get('downloads').children.map(link => link.clicks), [1, 1]); +}); + +test('clearing a mixed queue removes every source', async () => { + const ui = page(); await ui.add(['script.rpyc', 'data.rpa'], [script]); + ui.get('clear').click(); assert.equal(ui.get('queue').hidden, true); + assert.equal(ui.get('download-all').hidden, true); + assert.equal(ui.get('count').textContent.startsWith('0 '), true); +}); + +test('mixed archives and standalone scripts share one export and source browser', async () => { + const ui = page(); + await ui.add(['script.rpyc', 'data.rpa'], [script, + { id: '1:script.rpyc', path: 'script.rpyc', source: 1, size: 9 }]); + assert.equal(ui.get('archive').value, 'scripts'); + assert.equal(ui.get('browser-list').children.length, 1); + assert.equal(ui.get('browse-actions').hidden, false); + ui.get('archive').value = '1'; ui.get('archive').emit('change'); + assert.equal(ui.get('browser-title').textContent, 'data.rpa'); + assert.equal(ui.get('browser-list').children[0].children.length, 2, + 'Archive entries cannot remove a same-named standalone source'); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).mode, 'combined'); + assert.equal(ui.messages.at(-1).files.length, 2); + ui.exportDone(['unrpyc.zip', 'data.zip']); + assert.equal(ui.get('queue').hidden, true); + assert.deepEqual(ui.get('downloads').children.map(link => link.clicks), [1, 1]); +}); + +test('export progress advances by completed sources even when worker groups nonadjacent scripts', async () => { + const ui = page(); await ui.add(['script.rpyc', 'data.rpa', 'other.rpyc'], [script]); + ui.get('download-all').click(); ui.send({ type: 'ready' }); + ui.send({ type: 'file', path: 'script.rpyc', index: 0, state: 'done' }); + assert.equal(ui.get('progress-bar').value, 1); + ui.send({ type: 'file', path: 'other.rpyc', index: 2, state: 'done' }); + assert.equal(ui.get('progress-bar').value, 2); + ui.send({ type: 'entry', path: 'resource.png', index: 1, current: 1, total: 2 }); + assert.equal(ui.get('progress-bar').value, 2.5); + ui.send({ type: 'file', path: 'data.rpa', index: 1, state: 'done' }); + assert.equal(ui.get('progress-bar').value, 3); +}); + +test('cancellation keeps sources and permits reindexing', async () => { + const ui = page('?mode=unrpa'); await ui.add(['data.rpa']); ui.get('cancel').click(); + assert.equal(ui.workers[0].terminated, true); assert.equal(ui.get('start').hidden, false); + ui.get('start').click(); assert.equal(ui.messages.at(-1).type, 'catalog'); + assert.equal(ui.messages.at(-1).files.length, 1); +}); + +test('preview errors are recoverable without starting a full conversion', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + ui.send({ type: 'source-error', requestId: ui.messages.at(-1).requestId, error: 'Invalid RPYC' }); + assert.equal(ui.get('download-all').disabled, false); + assert.match(ui.get('status').textContent, /формат сценария/); + assert.doesNotMatch(ui.get('status').textContent, /Invalid RPYC/); + assert.equal(ui.get('browser-status').textContent, ''); + assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'false'); + const error = ui.get('browser-preview').children[0]; + assert.equal(error.children.at(-2).open, false); + assert.equal(error.children.at(-2).children[1].textContent, 'Invalid RPYC'); + ui.get('browser-list').children[0].children[0].click(); + assert.equal(ui.messages.at(-1).type, 'source-read'); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode('label start:').buffer }); + assert.equal(ui.get('start').hidden, true); +}); + +test('partial export failure retains completed ZIP links', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); ui.get('download-all').click(); + ui.send({ type: 'output', name: 'unrpyc.zip', result: ui.result }); + ui.send({ type: 'fatal', error: 'Out of memory' }); + assert.equal(ui.get('downloads').children.length, 1); assert.equal(ui.revoked.length, 0); + assert.match(ui.get('status').textContent, /памяти/); + assert.doesNotMatch(ui.get('status').textContent, /Out of memory/); + assert.equal(ui.get('errors').hidden, false); +}); + +test('cancelling a pending preview replaces loading and ignores late replies before retry', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + const read = ui.messages.at(-1); + const oldHandler = ui.workers.at(-1).onmessage; + ui.get('cancel').click(); + assert.equal(ui.get('browser-status').textContent, ''); + assert.match(ui.get('browser-preview').children[0].textContent, /отменён/); + assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'false'); + oldHandler({ data: { type: 'source-file', requestId: read.requestId, name: 'script.rpy', buffer: new ArrayBuffer(0) } }); + assert.match(ui.get('browser-preview').children[0].textContent, /отменён/); + ui.get('browser-list').children[0].children[0].click(); + assert.equal(ui.messages.at(-1).type, 'source-read'); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode('label start:').buffer }); + assert.equal(ui.get('start').hidden, true); +}); + +test('fatal errors finish a pending preview and render technical text safely once', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + ui.send({ type: 'fatal', error: 'Traceback\n' }); + assert.equal(ui.get('browser-status').textContent, ''); + assert.doesNotMatch(ui.get('browser-preview').children[0].textContent || '', /Декомпилируем/); + assert.equal(ui.get('browser-preview').attributes['aria-busy'], 'false'); + assert.doesNotMatch(ui.get('status').textContent, /Traceback|'); + assert.equal(technical.innerHTML, undefined); +}); + +test('equal totals are not duplicated and warning causes are shown', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); ui.get('download-all').click(); + ui.exportDone(['unrpyc.zip'], { ...ui.result, warnings: 1, warning_details: ['script.rpyc: partial recovery'] }); + assert.equal(ui.get('result-description').textContent, 'Обработано: 1.'); + assert.equal(ui.get('warnings').hidden, false); + const diagnostic = ui.get('warning-list').children[0]; + assert.equal(diagnostic.children[0].textContent, 'script.rpyc'); + assert.match(diagnostic.children[2].textContent, /Файл получен/); + assert.match(diagnostic.children[3].children[1].textContent, /partial recovery/); + const summary = ui.get('warnings-summary'); + assert.equal(summary.children[0].className, 'tools-ui-icon tools-icon-warning'); + assert.equal(summary.children[1].textContent, '1'); + assert.equal(summary.attributes['aria-label'], 'Предупреждения: 1'); + assert.equal(summary.title, 'Показать предупреждения'); + ui.get('warnings').open = true; ui.get('warnings').ontoggle(); + assert.equal(summary.title, 'Скрыть предупреждения'); +}); + +test('file warnings use the same indicator next to the filename, with safely rendered causes', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode('label start:').buffer, warnings: [''] }); + const heading = ui.get('browser-preview').children[0]; + assert.equal(heading.children[0].textContent, 'script.rpy'); + const warning = heading.children[1]; + assert.equal(warning.open, false); + assert.equal(warning.children[0].children[0].className, 'tools-ui-icon tools-icon-warning'); + assert.equal(warning.children[0].children[1].textContent, '1'); + const diagnostic = warning.children[1].children[0]; + assert.equal(diagnostic.children[2].children[0].textContent, 'Техническое сообщение'); + assert.equal(diagnostic.children[2].children[1].textContent, ''); + assert.equal(warning.children[1].attributes['aria-label'], 'Причины предупреждений файла'); + assert.equal(ui.get('browser-preview').children[2].download, 'script.rpy'); +}); + +test('legacy warnings show the Russian explanation before the original log in the file preview', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-list').children[0].children[0].click(); + const message = "Warning: analysis found signs that this .rpyc file was generated by ren'py\n version 7 or below, while this unrpyc version targets ren'py\n version 8. Decompilation will still be attempted."; + ui.send({ type: 'source-file', requestId: ui.messages.at(-1).requestId, name: 'script.rpy', + buffer: new TextEncoder().encode('label start:').buffer, warnings: [message] }); + const diagnostic = ui.get('browser-preview').children[0].children[1].children[1].children[0]; + assert.match(diagnostic.children[1].textContent, /Файл декомпилирован/); + assert.match(diagnostic.children[1].textContent, /возможны ошибки и неточности/); + assert.equal(diagnostic.children[2].children[1].textContent, message); +}); + +test('warning count is retained when diagnostics are truncated, and the next clean export hides it', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); ui.get('download-all').click(); + ui.exportDone(['unrpyc.zip'], { ...ui.result, warnings: 13, warning_details: ['only first cause'] }); + assert.equal(ui.get('warnings-summary').children[1].textContent, '13'); + assert.equal(ui.get('warning-list').children[1].textContent, 'Показано: 1 из 13.'); + ui.get('warnings').open = true; + await ui.add(['script.rpyc'], [script]); ui.get('download-all').click(); ui.exportDone(); + assert.equal(ui.get('warnings').hidden, true); + assert.equal(ui.get('warnings').open, false); + assert.equal(ui.get('warning-list').children.length, 0); + assert.equal(ui.get('warnings-summary').children.length, 0); +}); + +test('partial conversion keeps sources available for a fresh export', async () => { + const ui = page(); await ui.add(['script.rpyc', 'bad.rpyc'], [script]); + ui.get('download-all').click(); + ui.exportDone(['unrpyc.zip'], { ...ui.result, failed: 1, errors: ['bad.rpyc: invalid'] }); + assert.equal(ui.get('queue').hidden, false); + assert.equal(ui.get('downloads').children[0].clicks, 1); + ui.get('download-all').click(); + assert.equal(ui.messages.at(-1).type, 'run'); + assert.equal(ui.messages.at(-1).files.length, 2); +}); + +test('search returns a visible empty state and removing a source refreshes the browser', async () => { + const ui = page(); await ui.add(['script.rpyc'], [script]); + ui.get('browser-search').value = 'missing'; ui.get('browser-search').emit('input'); + assert.equal(ui.get('browser-list').children[0].textContent, 'Файлы не найдены'); + ui.get('browser-search').value = ''; ui.get('browser-search').emit('input'); + ui.get('browser-list').children[0].children[2].click(); + assert.equal(ui.get('queue').hidden, true); + assert.equal(ui.get('result').hidden, true); + assert.equal(ui.messages.length, 0); +}); + +test('5001 sources are all exported despite limiting rendered rows', async () => { + const ui = page(); await ui.add(Array.from({ length: 5001 }, (_, i) => i + '.rpyc'), []); + ui.get('download-all').click(); assert.equal(ui.messages.at(-1).files.length, 5001); +}); + +test('a script drop keeps the unified accepted formats without processing files', async () => { + const ui = page('?mode=unrpa'); + ui.get('drop').emit('drop', { preventDefault() {}, dataTransfer: { files: [{ name: 'a.rpymc', size: 1 }] } }); + await Promise.resolve(); await Promise.resolve(); + assert.equal(ui.get('drop-help').textContent, '.rpa, .rpyc, .rpymc, .pyc'); + assert.equal(ui.messages.length, 0); +}); diff --git a/tests/tools_worker.test.mjs b/tests/tools_worker.test.mjs new file mode 100644 index 0000000..073d146 --- /dev/null +++ b/tests/tools_worker.test.mjs @@ -0,0 +1,77 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import vm from 'node:vm'; +import { test } from 'node:test'; + +async function run(mode, paths, errors = []) { + const messages = [], jobs = [], limits = []; + let job; + const self = { postMessage: message => messages.push(message) }; + const runtime = { + FS: { mkdirTree() {}, mount() {}, unmount() {}, filesystems: { WORKERFS: {} } }, + globals: { set: (name, value) => { + if (name === 'job_json') job = JSON.parse(value); + else if (name === 'output_limit') limits.push(value); + }, delete() {} }, + runPythonAsync: async source => { + if (source.includes('archive.infolist()')) { + return JSON.stringify([{ path: 'scenario/script.rpy', size: 12 }]); + } + if (source.includes('esdocPreview(item.filename')) { + self.esdocPreview('scenario/script.rpy', { toJs: () => new Uint8Array([65, 66]) }); + return; + } + if (!source.includes('engine.process')) return; + jobs.push(job); + self.esdocNotify({ toJs: () => ({ type: 'file', index: 0, state: 'done', path: job.files[0].path }) }); + self.esdocEmit({ toJs: () => new Uint8Array([1, jobs.length]) }); + return JSON.stringify({ succeeded: job.files.length, failed: errors.length, written: 1, warnings: 0, errors }); + }, + }; + const context = vm.createContext({ self, runtime }); + const source = fs.readFileSync(new URL('../static/js/tools-worker.js', import.meta.url), 'utf8'); + // Replace only lazy runtime bootstrapping; execute the real job controller. + vm.runInContext(source.replace('let runtimePromise;', 'let runtimePromise = Promise.resolve(runtime);'), context); + await self.onmessage({ data: { type: 'run', mode, config: {}, options: {}, + files: paths.map(path => ({ path, file: {} })) } }); + return { messages, jobs, limits, self }; +} + +test('multiple RPAs produce separate named ZIP streams and preserve their input sources', async () => { + const { messages, jobs, limits } = await run('combined', ['game/data.rpa', 'folder/images.RPA']); + assert.deepEqual(jobs.map(job => job.files.map(file => file.source)), [['/input/input-0'], ['/input/input-1']]); + assert.deepEqual(messages.filter(message => message.type === 'output').map(message => message.name), ['data.zip', 'images.zip']); + assert.deepEqual(messages.filter(message => message.type === 'file').map(message => message.index), [0, 1]); + assert.deepEqual(messages.map(message => message.type), ['ready', 'output-start', 'file', 'chunk', 'output', + 'output-start', 'file', 'chunk', 'output', 'done']); + assert.equal(messages.at(-1).result.succeeded, 2); + assert.deepEqual(limits, []); +}); + +test('loose compiled scripts remain in one unrpyc.zip', async () => { + const { messages, jobs } = await run('unrpyc', ['a.rpyc', 'folder/b.rpymc']); + assert.equal(jobs.length, 1); + assert.equal(jobs[0].files.length, 2); + assert.equal(messages.find(message => message.type === 'output').name, 'unrpyc.zip'); +}); + +test('large error lists do not exceed the JavaScript argument limit', async () => { + const errors = Array(200000).fill('script.rpyc: invalid'); + const { messages } = await run('combined', ['data.rpa'], errors); + assert.equal(messages.at(-1).type, 'done'); + assert.equal(messages.at(-1).result.failed, errors.length); + assert.equal(messages.at(-1).result.errors.length, errors.length); +}); + +test('completed output supports listing and reading files in the worker', async () => { + const { messages, self } = await run('unrpyc', ['script.rpyc']); + await self.onmessage({ data: { type: 'browse-list', blob: new Blob(), requestId: 3 } }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(messages.at(-1).entries[0].path, 'scenario/script.rpy'); + assert.equal(messages.at(-1).entries[0].size, 12); + await self.onmessage({ data: { type: 'browse-read', path: 'scenario/script.rpy', requestId: 4 } }); + await new Promise(resolve => setImmediate(resolve)); + assert.equal(messages.at(-1).type, 'browse-file'); + assert.equal(messages.at(-1).requestId, 4); + assert.deepEqual([...new Uint8Array(messages.at(-1).buffer)], [65, 66]); +}); diff --git a/tests/warper_duration.test.mjs b/tests/warper_duration.test.mjs index 0e344b2..f558ff9 100644 --- a/tests/warper_duration.test.mjs +++ b/tests/warper_duration.test.mjs @@ -20,4 +20,8 @@ test('keeps durations above ten seconds in generated code', () => { }`); assert.equal(readDuration('67'), 67); + assert.ok(Number.isNaN(readDuration('-1'))); + assert.ok(Number.isNaN(readDuration(''))); + assert.ok(Number.isNaN(readDuration('0.1'))); + assert.equal(readDuration('0.2'), 0.2); }); diff --git a/tests/warper_export.test.mjs b/tests/warper_export.test.mjs new file mode 100644 index 0000000..d60b7bb --- /dev/null +++ b/tests/warper_export.test.mjs @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../static/js/warpers.js', import.meta.url), 'utf8'); +const writeBody = source.match(/const write = \(\) => \{([\s\S]*?)\r?\n \};\r?\n\r?\n const validate/)?.[1]; + +function output(custom = true, seconds = 1.5, valid = true, property = 'xalign') { + const node = () => ({ + textContent: '', hidden: false, disabled: false, + setAttribute() {}, closest() { return this; }, classList: { toggle() {} }, + append(...parts) { this.textContent += parts.join(''); }, + }); + const snippet = node(); + const context = { + snippet, codeOutput: node(), codeEmpty: node(), codeFile: node(), + nameInput: node(), nameNote: node(), seconds: node(), timeNote: node(), + playButton: node(), playButtons: [node(), node()], scrub: node(), + valid, property: {value: property}, warperName: () => custom ? 'test_curve' : 'easeout_cubic', + isCustom: () => custom, duration: () => seconds, + formulaInput: { value: 't ** 2' }, keywords: new Set(), Warpers: {}, + FORMULA_FUNCS: {}, FORMULA_CONSTS: {}, + token: (_className, text) => text, indent: (times = 1) => ' '.repeat(times), + formulaTokens: text => [text], + }; + assert.ok(writeBody, 'output generator exists'); + vm.runInNewContext(`(() => {${writeBody}\n})()`, context); + return context; +} + +test('one code block includes custom registration and the selected ATL effect', () => { + const result = output(); + assert.match(result.snippet.textContent, /def test_curve\(t\):/); + assert.match(result.snippet.textContent, /test_curve 1\.5 xalign 1\.0/); + assert.match(output(true, 1.5, true, 'zoom').snippet.textContent, /zoom 0\.5/); + assert.match(output(true, 1.5, true, 'alpha').snippet.textContent, /alpha 0\.0/); +}); + +test('invalid duration blocks ATL but does not block a valid custom definition', () => { + const result = output(true, NaN); + assert.equal(result.codeOutput.hidden, false); + assert.match(result.snippet.textContent, /return t \*\* 2/); + assert.doesNotMatch(result.snippet.textContent, /transform warper_/); + assert.ok(result.playButtons.every(button => button.disabled)); +}); + +test('built-in selection generates ATL without registration', () => { + const result = output(false); + assert.equal(result.codeOutput.hidden, false); + assert.match(result.snippet.textContent, /easeout_cubic 1\.5/); + assert.doesNotMatch(result.snippet.textContent, /python early/); +}); diff --git a/tests/warper_formula.test.mjs b/tests/warper_formula.test.mjs new file mode 100644 index 0000000..9391768 --- /dev/null +++ b/tests/warper_formula.test.mjs @@ -0,0 +1,43 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; + +const source = fs.readFileSync(new URL('../static/js/warpers.js', import.meta.url), 'utf8'); +const parser = source.slice(source.indexOf('const FORMULA_FUNCS'), source.indexOf('/* ── Preview canvas')); +const parse = vm.runInNewContext(`${parser}\nparseFormula`, { Math, isFinite }); + +test('evaluates Python powers, conditional expressions and chained comparisons', () => { + assert.equal(parse('-t ** 2')(.5), -.25); + assert.equal(parse('t if 0.2 < t < 0.8 else 1')(.5), .5); + assert.equal(parse('t if 0.2 < t < 0.8 else 1')(.9), 1); + assert.equal(parse('sin(t * pi / 2)')(1), 1); + assert.equal(parse('t < 0.5')(.2), 1); + assert.equal(parse('not t')(0), 1); + assert.equal(parse('.5e-2 * t')(1), .005); +}); + +test('rejects undefined values between the old twenty validation points', () => { + assert.throws(() => parse('1 / (t - 0.125)'), /не определено/); +}); + +test('never accepts pasted code or browser globals', () => { + for (const value of ['window', 'alert(t)', 't.constructor', 't; 1', 'sqrt(-1)', 'log(t)', 'log(1+t, 0)']) { + assert.throws(() => parse(value), value); + } +}); + +test('matches Python rounding at halfway values', () => { + const round = parse('round(t * 5)'); + assert.equal(round(.1), 0); + assert.equal(round(.3), 2); + assert.equal(round(.5), 2); + assert.equal(parse('round(-t * 5)')(.1), 0); + assert.equal(parse('log(1 + t, 2)')(1), 1); +}); + +test('rejects unsupported call signatures instead of silently changing Python meaning', () => { + for (const value of ['sin(t, 2)', 'round(t, 2)', 'pow(t, 2, 3)', 'min(t)', 'sqrt()']) { + assert.throws(() => parse(value), value); + } +}); diff --git a/tests/warper_playback.test.mjs b/tests/warper_playback.test.mjs new file mode 100644 index 0000000..8dee8d3 --- /dev/null +++ b/tests/warper_playback.test.mjs @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import test from 'node:test'; +import vm from 'node:vm'; +const source = fs.readFileSync(new URL('../static/js/warpers.js', import.meta.url), 'utf8'); +const body = source.match(/class WarperPlayback \{[\s\S]*?\r?\n\}\r?\n/)?.[0]; +const create = () => { + assert.ok(body, 'Playback clock exists'); + return new (vm.runInNewContext(`${body}; WarperPlayback`))(); +}; +test('pause and resume retain elapsed progress', () => { + const clock = create(); + clock.resume(100); + assert.equal(clock.tick(600, 1000, false), .5); + clock.pause(); + clock.resume(5000); + assert.equal(clock.tick(5250, 1000, false), .75); +}); +test('repeat holds the endpoint and starts forward again', () => { + const clock = create(); + clock.resume(0); + assert.equal(clock.tick(1200, 1000, true), 1); + assert.equal(clock.tick(1500, 1000, true), .05); + assert.equal(clock.running, true); +}); +test('seek and completion stop playback', () => { + const clock = create(); + clock.seek(.4, 1000); + clock.resume(100); + assert.equal(clock.tick(700, 1000, false), 1); + assert.equal(clock.running, false); + clock.seek(0, 1000); + assert.equal(clock.progress, 0); +}); +test('turning repeat off completes the current cycle without a jump', () => { + const clock = create(); + clock.resume(0); + assert.equal(clock.tick(1600, 1000, true), .15); + clock.rebase(1600, 1000); + assert.equal(clock.tick(1616, 1000, false), .166); + assert.equal(clock.running, true); +}); diff --git a/tests/warpers_reload.test.mjs b/tests/warpers_reload.test.mjs index 41a14ce..cdde9ed 100644 --- a/tests/warpers_reload.test.mjs +++ b/tests/warpers_reload.test.mjs @@ -8,18 +8,24 @@ import { fileURLToPath } from 'node:url'; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const source = fs.readFileSync(path.join(root, 'static/js/warpers.js'), 'utf8'); -test('warpers script can be loaded again after soft navigation', () => { +function createPageContext(copyNames = []) { const mediaQuery = { matches: false, addEventListener() {}, removeEventListener() {}, }; - const context = vm.createContext({ + return vm.createContext({ console: { warn() {} }, document: { documentElement: {}, + body: { append() {} }, + createElement() { + return { style: {}, setAttribute() {}, remove() {} }; + }, querySelector() { return null; }, - querySelectorAll() { return []; }, + querySelectorAll(selector) { + return selector === '.wp-cell .res-copy[data-copy]' ? copyNames : []; + }, }, getComputedStyle() { return { getPropertyValue() { return ''; } }; @@ -39,9 +45,20 @@ test('warpers script can be loaded again after soft navigation', () => { isFinite, Math, }); +} + +test('warpers script can be loaded again after soft navigation', () => { + const context = createPageContext(); assert.doesNotThrow(() => { vm.runInContext(source, context); vm.runInContext(source, context); }); }); + +test('warper names stay visible when the Clipboard API is unavailable', () => { + const nameButton = { hidden: true, disabled: false, dataset: { copy: 'easeout_cubic' } }; + vm.runInContext(source, createPageContext([nameButton])); + assert.equal(nameButton.hidden, false, 'clipboard enhancement must not hide the warper name'); + assert.equal(nameButton.disabled, true, 'unsupported copying must not offer a dead action'); +});