Skip to content
Merged

Dev #60

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
b9ef53e
feature: rpa packer + color picker fixes (wip)
somepoi Oct 9, 2026
75e7d8e
feature: warning if eyedropper isn't available in user's browser
somepoi Oct 9, 2026
c5a9339
feature: rpa packer + color picker fixes
somepoi Oct 9, 2026
582127b
feature: browser added for rpa packer
somepoi Oct 9, 2026
281f8ee
feature: added ability to remove folders while packing rpa
somepoi Oct 9, 2026
8a5c3a3
feature: staged disclosure workflow added for tools
somepoi Oct 10, 2026
36308ba
feature: character creator added
somepoi Oct 10, 2026
47a5cf4
chope: .gitignore updated
somepoi Oct 10, 2026
b435e82
feature: universal converter added
somepoi Oct 10, 2026
db9c943
fix: confine static asset requests to their directories
somepoi Oct 10, 2026
f34f34d
fix: refresh resource metadata for original game file changes
somepoi Oct 10, 2026
ef74b29
fix: invalidate composed sprites when their layers change
somepoi Oct 10, 2026
a9b0194
fix: validate tinted caches against all rendering inputs
somepoi Oct 10, 2026
0319497
fix: normalize and validate sitemap origins
somepoi Oct 10, 2026
31e1e71
fix: preserve literal fences inside Markdown containers
somepoi Oct 10, 2026
d355592
fix: respect standalone banner boundaries in nested containers
somepoi Oct 10, 2026
51a5eb4
fix: keep article and code rendering off the event loop
somepoi Oct 10, 2026
3ebfe09
fix: bound archive preview reads before allocating assets
somepoi Oct 10, 2026
d24f027
fix: discard pending search results when queries change or close
somepoi Oct 10, 2026
3f79203
fix: honor the latest navigation during overlapping page loads
somepoi Oct 10, 2026
cbefcca
fix: keep audio playback available when browser storage is denied
somepoi Oct 10, 2026
6e19fed
fix: dispose article listeners and observers during navigation
somepoi Oct 10, 2026
986e139
fix: preserve selected history URLs when page scripts finish late
somepoi Oct 10, 2026
f9fe4e5
test: verify application regressions in native browsers
somepoi Oct 10, 2026
37023f5
Merge pull request #59 from sovue/feat/global-fixes
somepoi Oct 10, 2026
815b858
chore: new tools added
somepoi Oct 10, 2026
cafa44c
fix: bunch 'o fixes for character creator tool
somepoi Oct 10, 2026
6d2f6ef
fix: added new error explanation into tools section
somepoi Oct 11, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -33,5 +33,6 @@ content/*
!content/main

.impeccable/critique/
.impeccable/review/
.agents/
.codex/
.codex/
8 changes: 7 additions & 1 deletion .impeccable/design.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"generatedAt": "2026-10-06T21:19:06Z",
"generatedAt": "2026-10-10T10:40:00Z",
"title": "Design System: ES Doc",
"extensions": {
"colorMeta": {
Expand Down Expand Up @@ -168,6 +168,12 @@
{ "name": "narrow", "value": "400px" }
],
"surfaceGuidance": {
"tools-characters": {
"layout": "Scoped 78rem settings-left / scene-and-code-right workbench, .9 / 1.15 columns with a 40px gap. Section navigation sticks at 64px and result at 132px; heading/field scroll margins are 140px. At 960px columns become equal, gap 24px and parameter groups one column. At 720px settings precede the static result with a 32px gap, common fields return to two columns, and section buttons become a fixed safe-area bottom bar. Reserve 88px below the page and use 80px top / 88px bottom scroll margins on mobile. Jumps focus destinations; Settings returns to the last connected edited field. Keep 44px controls and body-sized mobile inputs.",
"behavior": "Variable/name and five common fields lead; name semantics, inheritance, fonts, typography, behavior, screen and engine settings start in native disclosures. Each field discloses its key, type, explicit-value checkbox and reset. HEX preserves text alpha; the native chooser displays RGB without mutating text and an explicit selection sets an opaque color. Added fields have visible removal even when empty, returning focus to adding a property. Removing a configured value is undoable. Catalog search matches keys, Russian help/labels, inflections and task synonyms; a scope-switch recovery names and focuses matching results elsewhere. Existing-property selection reveals and focuses its input. Preserve omission versus explicit empty/False/None/zero and typed export; Python is unexecuted text with structural checks only. Per-field errors have accessible descriptions, block copy/.rpy and offer first-error focus. The approximate 1920 × 1080 scene starts enlarged 2× desktop / 3× mobile; full-scene view remains available. Reflect supported literal styling and converted padding; list unsupported configured properties. Explicit replay safely preserves supported text-tag spans and respects motion/speed/interruption settings. Local draft status names readiness, restored state, successful writes or storage failure. A 64-step history includes reset/removal/import; shortcuts act outside text editors. JSON settings and definition-only .rpy are distinct; collapsed usage shows the current valid variable. Failed import retains state and failed copy selects code. Preview files stay local/outside JSON. Navigation flushes edits, releases listeners/timers/URLs/font faces and rejects stale loads.",
"dataColors": "Name #e2c778, dialogue #f5ecd7, scene #253846 and textbox #18232ee8 are functional preview data, independent of site theme. User colors, local backgrounds and specimen fonts add no global accents or shipping assets. Controls, focus, errors and code stay on incumbent theme tokens.",
"typography": "PT Serif page title, Inter controls and help, monospace parameter keys/code. Scene-pixel, game-font-path and outline-tuple help uses the existing --fs-ui step. Mobile inputs use body size; preview text scales with scene width. No new global typography or color tokens."
},
"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.",
Expand Down
12 changes: 11 additions & 1 deletion DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,8 @@ Standard header spacing is 32px above / 28px below, 40/32 for the homepage, and

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.

The character workbench uses a scoped 78rem maximum: settings left, approximate scene above code right, with .9 / 1.15 columns and a 40px gap. Section navigation sticks at 64px and the result at 132px. At 960px and below the columns become equal with a 24px gap and single-column parameter groups. At 720px and below the result becomes static after settings, separated by 32px; common fields return to two columns. Section navigation becomes a fixed bottom bar padded for the safe area, with 88px reserved below the page. Heading and field scroll margins are 140px on desktop and 80px above / 88px below on mobile. Section jumps focus their destination, and Settings returns to the last connected edited field. Controls retain 44px targets and mobile inputs use body-sized text.

### 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).
Expand Down Expand Up @@ -540,7 +542,7 @@ Tools warnings use the site's shared attention circle with an exclamation mark,

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` is a lightweight hub with flat linked rows for `/tools/unpack`, `/tools/colors` and `/tools/characters`, 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.

Expand All @@ -560,6 +562,14 @@ Image selection accepts PNG, JPEG, WebP, GIF and AVIF locally, up to 40 MiB. Sam

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.

### Character creator

`/tools/characters` is the simple character creator. Only name, dynamic, kind and the who_/what_ color, prefix, suffix (labelled postfix), font, bold, italic, strikethrough and underline properties are editable. Empty names export as None. Daytime game bases come from game/media.rpy; custom kind accepts a character variable and preserves all entered style overrides. Old drafts and imported JSON retain only these properties. The native color chooser and editable HEX support explicit colors; the owner-requested link to /tools/colors opens in a new tab to preserve the draft.

The preview uses the original daytime dialogue_box.png through the game resource route and locally installed Calibri, the original main_font from screens.rpy. Calibri falls back to sans-serif when unavailable; game font binaries are not included in the assets. Other font paths cannot be previewed. The local specimen font alias ES Dialogue loads Calibri. The game's name color #e1dd7d, dialogue color #ffdd7d, black drop shadow, window and font are specimen data rather than UI tokens. Dynamic scrambles the preview name every 100ms; reduced motion shows a single scrambled value. A visible warning explains that name must identify a variable defined in the game, and that scrambling only represents a dynamic value. The original 1572 × 151 dialogue artwork keeps its aspect ratio. A crop of the camp entrance background and four decorative game control images reproduce the original small-font say screen. Name and dialogue positions follow screens.rpy (20/15 and 20/48 relative to the artwork), with 28px text scaled uniformly. Long dialogue scrolls inside the fixed window rather than stretching its border. A zoom toggle enlarges the specimen to at least 960px and allows horizontal scrolling on narrow viewports.

Local draft persistence, undo/redo, JSON import/export, Python quoting, field errors, manual clipboard fallback and .rpy export remain. Soft navigation aborts listeners, clears animation timers and releases download URLs. Arbitrary Python modes, engine/window properties, replay and local background/font uploads are removed from this simple surface.

## Do's and Don'ts

### Do:
Expand Down
3 changes: 2 additions & 1 deletion app/routes/docs.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import re
from urllib.parse import quote

Expand Down Expand Up @@ -89,7 +90,7 @@ async def page(doc, request: Request):
if any(i in request.query_params for i in ['s', 'search']):
return PlainTextResponse(re.sub(r'^(#+ )|(- )|(> )|(```\w*)|(:::\w*)|(---)$', '', text, flags=re.MULTILINE).replace('\n\n', '\n'))

title, nav, body = render(text)
title, nav, body = await asyncio.to_thread(render, text)

if any(i in request.query_params for i in ['r', 'raw']):
return PlainTextResponse(body)
Expand Down
4 changes: 2 additions & 2 deletions app/routes/res.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,11 +89,11 @@ async def _ensure_tinted(kind, name):
if not source:
raise HTTPException(404, f'Исходный файл для "{kind} {name}" не существует.')

if not is_tinted(kind, name, source):
if not is_tinted(kind, name, source, item['tint']):
async with compose_lock:
# Re-check: the request holding the lock before us may have
# composed this very image.
if not is_tinted(kind, name, source):
if not is_tinted(kind, name, source, item['tint']):
try:
await asyncio.to_thread(compose_tint, kind, name, source, item['tint'])
except Exception:
Expand Down
3 changes: 2 additions & 1 deletion app/routes/resources.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import asyncio
import html
import os
from pathlib import Path
Expand Down Expand Up @@ -218,7 +219,7 @@ async def browser(request: Request, path=''):
if target.is_file():
size = target.stat().st_size
kind = FILE_KINDS.get(target.suffix.lower(), 'other')
view = _file_view(target)
view = await asyncio.to_thread(_file_view, target)
sub = [KIND_LABELS[kind], _human_size(size)]
if view['mode'] == 'code':
sub.append(view['lines'])
Expand Down
33 changes: 28 additions & 5 deletions app/routes/search_discovery.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
"""Crawler entry points for the public pages of the site."""

from urllib.parse import quote
from urllib.parse import quote, urlsplit, urlunsplit
from xml.etree import ElementTree

from fastapi import Response
from fastapi import HTTPException, Response
from fastapi.responses import PlainTextResponse

from ..utils.config import CONFIG
Expand All @@ -15,17 +15,40 @@

STATIC_PAGES = (
'/', '/docs/', '/authors', '/specialists', '/materials', '/news-resources',
'/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers',
'/support', '/resources/', '/resources/community', '/tools', '/tools/unpack', '/tools/colors', '/tools/warpers', '/tools/characters',
)


def _site_origin():
value = CONFIG.site_url
if not isinstance(value, str) or not value.strip():
return None
origin = value.strip().rstrip('/')
if any(char.isspace() for char in origin):
return None
try:
parsed = urlsplit(origin)
if (parsed.scheme not in ('http', 'https') or not parsed.hostname
or parsed.username is not None or parsed.password is not None
or parsed.path or parsed.query or parsed.fragment):
return None
parsed.port # Validate the optional port before publishing the URL.
return urlunsplit((parsed.scheme, parsed.netloc, '', '', ''))
except ValueError:
return None


@main_router.get('/robots.txt', response_class=PlainTextResponse)
async def robots_txt():
return f'User-agent: *\nAllow: /\n\nSitemap: {CONFIG.site_url}/sitemap.xml\n'
origin = _site_origin()
return 'User-agent: *\nAllow: /\n' + (f'\nSitemap: {origin}/sitemap.xml\n' if origin else '')


@main_router.get('/sitemap.xml')
async def sitemap_xml():
origin = _site_origin()
if not origin:
raise HTTPException(404, 'Адрес сайта не настроен.')
paths = list(STATIC_PAGES)
paths.extend(f'/docs/{quote(doc["slug"], safe="")}' for doc in CONFIG.search_index)
paths.extend(
Expand All @@ -39,7 +62,7 @@ async def sitemap_xml():
root = ElementTree.Element(f'{{{SITEMAP_NS}}}urlset')
for path in dict.fromkeys(paths):
entry = ElementTree.SubElement(root, f'{{{SITEMAP_NS}}}url')
ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = CONFIG.site_url + path
ElementTree.SubElement(entry, f'{{{SITEMAP_NS}}}loc').text = origin + path

return Response(
content=ElementTree.tostring(root, encoding='utf-8', xml_declaration=True),
Expand Down
17 changes: 16 additions & 1 deletion app/routes/static.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,19 @@
_VERSIONED_CACHE = 'public, max-age=31536000, immutable'


def _asset_path(folder: str, name: str):
if not name or any(char in name for char in ('/', '\\', ':', '\x00')):
raise HTTPException(404, 'Файл не существует.')
root = (ROOT / 'static' / folder).resolve()
try:
path = (root / name).resolve()
if path.is_relative_to(root) and path.is_file():
return path
except (OSError, ValueError):
pass
raise HTTPException(404, 'Файл не существует.')


def _text_asset(request: Request, rel: str, media_type: str) -> Response:
try:
body, digest = load_asset(rel)
Expand All @@ -44,10 +57,12 @@ async def favicon():

@router.get('/static/css/{name}')
async def css(name, request: Request):
_asset_path('css', name)
return _text_asset(request, f'static/css/{name}', 'text/css; charset=utf-8')

@router.get('/static/js/{name}')
async def js(name, request: Request):
_asset_path('js', name)
return _text_asset(request, f'static/js/{name}', 'application/javascript; charset=utf-8')


Expand All @@ -62,7 +77,7 @@ async def font(name):
# Binary asset: FileResponse, not read_text (which decodes as UTF-8).
# Immutable, content-hashed by weight/subset, so cache hard.
return FileResponse(
str(ROOT / 'static' / 'fonts' / name),
str(_asset_path('fonts', name)),
media_type='font/woff2',
headers={
'Cache-Control': 'public, max-age=31536000, immutable',
Expand Down
8 changes: 7 additions & 1 deletion app/routes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
'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',
'manifest.json': 'application/json', 'NOTICE.txt': 'text/plain', 'rpa.py': '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',
Expand Down Expand Up @@ -50,11 +50,17 @@ async def colors_page(request: Request):
return templates.TemplateResponse(request, 'tools_colors.html', {})


@main_router.get('/tools/characters')
async def characters_page(request: Request):
return templates.TemplateResponse(request, 'tools_characters.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'),
'packer': f'/static/tools/rpa.py?v={digest("rpa.py")}',
'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")}',
Expand Down
Loading