From d90f3820edf121e230ed55d68aa8c69a802e63bd Mon Sep 17 00:00:00 2001 From: Igor Kochman Date: Fri, 1 May 2026 09:04:50 +0200 Subject: [PATCH] feat(design): adopt editorial 'Letter' layout for editorial style MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adapts the Trailpath / Claude Design exploration into the editorial style. Black ink on cream paper (OKLCH tokens for paper / ink / rule / hairline, dark-mode swap via prefers-color-scheme). System serif stack (Charter / Iowan Old Style / Apple Garamond / Hoefler fallbacks) and system mono — deliberately no Google Fonts CDN so the page stays readable offline (ADR-001). The Newsreader serif the design used will land later as a base64-embedded subset; the system stack is the right interim because Charter ships on every Apple device, where the bulk of recipients will read. Markup changes: - Masthead: mono-uppercase eyebrow (uses narrative.milestone), large editorial title, balanced dek (uses narrative.subtitle), delicate byline-and-date meta line. - First paragraph carries a serif drop cap; siblings in inactive languages are display:none, so ::first-letter automatically selects the active language's first character — no per-language dropcap variants needed. - Pull quote uses a single left-rule italic treatment, not a tinted box. Hero photo crops 16:9 mobile / 2.35:1 desktop with a saturate(0.92) contrast(1.02) print-magazine filter. - Photos 2 and 3 float as inline asymmetric figures (right then left) inside the prose at desktop widths; on mobile they fall into the flow at full bleed. Any photo not claimed by hero or inline placement falls through to an asymmetric scrapbook grid. Photo-count contract unchanged: every selected photo embeds exactly once. - Sticky marginalia sidebar on >= 900px holds a mono-uppercase facts table (Where/When/Distance/Ascent/Time/Summit) and the elevation sparkline. On mobile the marginalia collapses to a closing reference under the prose. - Three discrete EN / RU / DE pills replace the cycling single button. Each is click-targetable, aria-pressed reflects state, and the class-swap mechanism (ADR-005) is unchanged. The pull-quote class, drop-cap class, and marginalia sidebar are new contract markers — codified in test_styles.py so future refactors can't silently drift the polished default. log and encyclopedia styles are intentionally untouched. Co-Authored-By: Claude Opus 4.7 --- CHANGELOG.md | 28 + templates/styles/editorial.html.j2 | 756 ++++++++++++++++++------ tests/golden/test-render-editorial.html | 737 +++++++++++++++++------ tests/test_styles.py | 25 +- 4 files changed, 1154 insertions(+), 392 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af87f0b..a86fa1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,34 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed +- **Editorial style is now a magazine "Letter" layout** adapted from the + Trailpath / Claude Design exploration. Black ink on cream paper + (OKLCH tokens for paper / ink / rule / hairline, with a + `prefers-color-scheme: dark` swap). System serif stack (Charter, + Iowan Old Style, Apple Garamond, Hoefler Text fallbacks) plus + system mono — no Google Fonts CDN, the whole page stays readable + offline (ADR-001). The masthead now opens with a mono uppercase + eyebrow (the milestone), a large editorial title, a balanced dek, + and a delicate byline-and-date meta line. The first paragraph + carries a serif drop cap. The pull quote takes a single + left-rule italic treatment instead of a tinted box. Photos pick + up a `saturate(0.92) contrast(1.02)` filter for a print-magazine + print feel; the first photo becomes a 16:9 (2.35:1 on desktop) + hero, photos 2 and 3 float as inline asymmetric figures inside + the prose, and the rest land in an asymmetric scrapbook grid + below. A sticky marginalia sidebar on desktop (`≥ 900px`) holds + a mono-uppercase facts table (Where / When / Distance / Ascent + / Time / Summit) and the elevation sparkline; on mobile the + layout collapses to single column and the marginalia continues + as a closing reference. Three discrete EN / RU / DE language + pills replace the previous single cycling button — each pill is + click-targetable (no hidden state), `aria-pressed` reflects the + active language, and the underlying class-swap mechanism + (ADR-005) is unchanged so RU and DE bodies stay zero-cost. + `log` and `encyclopedia` styles are intentionally untouched in + this PR. + ### Added - **Per-IP rate limit on `POST /generate`** (10 requests / hour / client IP, sliding window). Caps abuse cost at the diff --git a/templates/styles/editorial.html.j2 b/templates/styles/editorial.html.j2 index e91c3e7..8517b36 100644 --- a/templates/styles/editorial.html.j2 +++ b/templates/styles/editorial.html.j2 @@ -5,257 +5,630 @@ {{ narrative.title.en }} -
- + +
+ + +
-
- + +
+ {{ narrative.milestone.en }} {{ narrative.milestone.ru }} {{ narrative.milestone.de }} -

+

{{ narrative.title.en }} {{ narrative.title.ru }} {{ narrative.title.de }}

-

+

{{ narrative.subtitle.en }} {{ narrative.subtitle.ru }} {{ narrative.subtitle.de }}

- {% if meta.location or meta.date %} -
+
- {% endif %} + {% endif %} +

-
-
{{ stats.distance_km }}
km
-
{{ stats.elevation_gain_m | int }}
m gain
-
{{ stats.duration_min }}
min
-
{{ stats.summit_elev_m | int }}
m summit
-
- - - -
-
- {% for p in narrative.paragraphs.en %}

{{ p }}

{% endfor %} -
-
- {% for p in narrative.paragraphs.ru %}

{{ p }}

{% endfor %} -
-
- {% for p in narrative.paragraphs.de %}

{{ p }}

{% endfor %} -
- -
- {{ narrative.pull_quote.en }} - {{ narrative.pull_quote.ru }} - {{ narrative.pull_quote.de }} -
-
- -
- {% for photo in photos %} - - {% endfor %} + {% if photos|length > 0 %} +
+ +
+ {% endif %} + +
+ +
+ {# + Each paragraph is rendered ONCE — the three language variants + live as inline siblings inside the same

. CSS + visibility on body.lang-* drives which one shows. Inline + photos and the pull quote sit OUTSIDE the language spans so + every photo embeds exactly once (data:URI economy + ADR-001 + bundle-size discipline). + + Photo placement rules: photos[1] becomes the inline-right + figure after paragraph 0 if there are ≥ 2 paragraphs; photos[2] + becomes inline-left after paragraph 1 if ≥ 3 paragraphs. Any + photo that doesn't find an inline home falls through to the + scrapbook below — no photo is dropped. + #} + {% set para_count = narrative.paragraphs.en | length %} + {% set has_inline_right = photos | length > 1 and para_count >= 2 %} + {% set has_inline_left = photos | length > 2 and para_count >= 3 %} + {% for i in range(para_count) %} + {% if i == 1 and has_inline_right %} +

+ +
+ {% endif %} + +

+ {{ narrative.paragraphs.en[i] }} + {{ narrative.paragraphs.ru[i] }} + {{ narrative.paragraphs.de[i] }} +

+ + {% if i == 1 %} +
+ {{ narrative.pull_quote.en }} + {{ narrative.pull_quote.ru }} + {{ narrative.pull_quote.de }} +
+ {% endif %} + + {% if i == 2 and has_inline_left %} +
+ +
+ {% endif %} + {% endfor %} +
+ + + +
+ + {# + Scrapbook: every photo that wasn't claimed by hero (photos[0]) or + the inline placements above. photos[1] and photos[2] fall through + here when the narrative is short enough to leave them homeless; + photos[3:] always falls through. + #} + {% set has_orphan_right = photos | length > 1 and not has_inline_right %} + {% set has_orphan_left = photos | length > 2 and not has_inline_left %} + {% set has_tail = photos | length > 3 %} + {% if has_orphan_right or has_orphan_left or has_tail %} +
+ {% if has_orphan_right %} +
+ +
+ {% endif %} + {% if has_orphan_left %} +
+ +
+ {% endif %} + {% if has_tail %} + {% for photo in photos[3:] %} +
+ +
+ {% endfor %} + {% endif %}
+ {% endif %} -
{{ meta.slug }}
+
{{ meta.slug }}
- -