Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
61 changes: 61 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,67 @@ Versioning follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]

### Changed
- **Editorial photo layout: consistent aspect ratio + interleaved
through paragraphs.** `templates/styles/editorial.html.j2` now locks
every `.figure img` to a `3 / 2` aspect ratio with
`object-fit: cover`, so portrait and landscape originals render as
the same rectangle instead of a ragged mixed-height stack. Photos
past the hero are interleaved one-per-paragraph through the body
(full-column variants `v-b` / `v-c` / `v-d`) instead of the previous
"one in the middle, all the rest stacked after the pull quote"
pattern; any overflow falls after the quote with the original
rotation. Pilot users on 6–8-photo hikes were reading the old layout
as a top half of prose followed by a bottom half of photo dump;
interleaving keeps the visual rhythm matched to the prose rhythm.
- **Per-sentence provenance UI hidden by default; `Notes` audit toggle
added (editorial style).** The ADR-014 INFERRED tint and the
per-sentence `title=` tooltip used to be on for every reader; pilot
users read the amber background as random highlighting and the
resulting `cursor: help` as a broken affordance (the native browser
tooltip is slow, low-contrast, and absent on touch). The audit UI
now lives behind a `Notes` toggle in the editorial template's top
bar, which flips `body.audit` and reveals a richer treatment than
before: per-source colour cues (INFERRED amber background; PHOTO /
SEED / GPX underline tints) plus a custom `::after` tooltip reading
`data-tip` so it actually renders quickly on hover with readable
contrast. The `<span class="sent">` carries `data-prov` + `data-tip`
on every sentence regardless, so the data is still available; the
default rendering just stays clean for the page's actual audience
(a family member, not the author). Author preference is persisted
in `localStorage` under `trailstory.notes`. Log and encyclopedia
styles already used the flat fallback and are unaffected.
- **Writer prompt rebalanced for warmth + faithfulness.**
`SYSTEM_NARRATIVE` and `USER_NARRATIVE_TEMPLATE` in
`trailstory/llm/prompts.py` no longer ask for an "intimate, literary"
voice / "Bourdain on a quiet afternoon" framing — pilot output
drifted into ornate atmospheric prose detached from the hiker's
seed text. The prompt now frames the task as "a letter home to
people who love them — warm, intimate, direct", with explicit
instructions to name the people from the ledger when they appear
in a beat, surface the sensory specifics (light, sound, smell,
texture) and emotions the ledger records, and avoid the magazine
essay register. Grounded-sentence aim raised from ≥ 60% to ≥ 70%
of `seed` / `photo` / `gpx` provenance. New hard rule: do not
quote GPX numbers (distance, elevation, duration, summit height)
verbatim in the prose — those live in the stats block, qualitative
reference only. Milestone JSON skeleton now states the
"under 30 characters in every language" rubric ceiling explicitly
so the model stops blowing the cap on RU/DE. Paragraph count
remains 3–5 (a first iteration shortening it to 2–3 was rejected
by the eval). Previous prompt versions preserved as dated comments
for revertability. CLI narrative cache is not invalidated by
prompt-only changes — clear `~/.cache/trailstory/narratives/` to
regenerate old hikes with the new register; the web builder
streaming path bypasses cache and is unaffected.

**Eval status (paid LLM-as-judge, run on PR branch before merge,
threshold 1.00):** all 4 cases pass judge regression after two
prompt iterations. Cases 01/02/03/04 warmth Δ = -0.5, +0.5, 0.0,
-0.5; narrative_arc Δ = -0.5, +0.5, 0.0, -0.5; russian_fidelity
Δ = -0.5, 0.0, 0.0, 0.0; faithfulness Δ = +0.12, -0.24, +0.38,
+0.90 (faithfulness improved in three cases, dipped marginally on
case 02 within threshold). Goldens refreshed via
`make eval-update-golden` and committed alongside the prompt.
- **Two-pass narrative pipeline with a structured `FactLedger` (Phase 2 of
the narrative-faithfulness initiative;
[ADR-009](docs/adr/009-two-pass-narrative-with-fact-ledger.md)).** Narrative
Expand Down
165 changes: 141 additions & 24 deletions templates/styles/editorial.html.j2
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,14 @@
}
.figure img {
display: block; width: 100%; height: auto;
/* Lock every figure to the same landscape rectangle (3:2 — standard
editorial photo ratio). Portrait and landscape originals end up the
same height; CSS crops the overflow with object-fit so a tall
portrait does not stretch a column into a wall, and a wide
landscape does not letterbox. Mixed-aspect photo sets render as a
consistent grid instead of a ragged stack. */
aspect-ratio: 3 / 2;
object-fit: cover;
opacity: 0; transition: opacity 240ms ease;
filter: saturate(0.92) contrast(1.02);
}
Expand Down Expand Up @@ -425,23 +433,78 @@
}

/* ADR-014 / Phase 4: sentence-level provenance.
INFERRED sentences get a subtle background tint so the reader sees at
a glance which prose is the writer's literary reconstruction vs which
is grounded in seed / photos / GPX. Hover surfaces the full provenance
via the native title attribute (no JS needed for v0). */
Default state is clean — the recipient of a memory page is a family
member, not the author auditing their own draft, so no tints, no
cursor changes, no tooltips. The provenance data still lives on
every <span class="sent"> via the data-prov / data-tip attributes,
and the Notes toggle (see `.notes` below) flips ``body.audit`` to
reveal the audit affordances.

Previous default-on treatment (2026-05): every INFERRED sentence
was tinted amber and ``cursor: help`` plus the native ``title``
tooltip fired on hover. Pilot users read the amber as random
highlighting and the help cursor as a broken affordance because the
native tooltip is slow, low-contrast, and absent on touch. Hiding
the UI by default fixes both — the data is still there for the
author to inspect, but the page reads as prose. */
.sent { transition: background-color 120ms ease; }
.sent[data-prov="inferred"] {
body.audit .sent { position: relative; }
body.audit .sent[data-prov="inferred"] {
background: oklch(0.92 0.04 80);
border-radius: 2px;
padding: 0 2px;
}
.sent:hover {
body.audit .sent[data-prov="photo"] { box-shadow: inset 0 -2px 0 oklch(0.78 0.10 220 / 0.55); }
body.audit .sent[data-prov="seed"] { box-shadow: inset 0 -2px 0 oklch(0.78 0.10 150 / 0.55); }
body.audit .sent[data-prov="gpx"] { box-shadow: inset 0 -2px 0 oklch(0.78 0.06 60 / 0.55); }
body.audit .sent:hover {
background: oklch(0.86 0.06 80);
cursor: help;
}
/* Custom tooltip: the native ``title`` attribute is slow, low-contrast,
and missing on touch. We render our own via ``::after`` reading
``data-tip``. Only visible when ``body.audit`` is on. */
body.audit .sent:hover::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 4px); left: 0;
background: var(--ink); color: var(--paper);
padding: 6px 8px;
font-family: var(--mono); font-size: 11px;
letter-spacing: 0.02em; line-height: 1.35;
max-width: 280px; white-space: normal;
border-radius: 2px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
z-index: 30;
pointer-events: none;
}
@media print {
.sent[data-prov="inferred"] { background: transparent; }
body.audit .sent[data-prov="inferred"] { background: transparent; }
body.audit .sent[data-prov="photo"],
body.audit .sent[data-prov="seed"],
body.audit .sent[data-prov="gpx"] { box-shadow: none; }
}

/* ── notes (audit) toggle ──────────────────────────────────────── */
/* Sits to the left of the language switcher with the same visual
idiom. Clicking it flips ``body.audit`` and reveals the per-
sentence provenance tints + tooltips. Off by default. */
.notes {
position: absolute; top: 18px; right: 220px; z-index: 20;
border: 1px solid var(--ink); background: var(--paper);
font-family: var(--mono);
}
@media (max-width: 1023px) { .notes { right: 188px; } }
@media (max-width: 767px) { .notes { right: 156px; top: 12px; } }
.notes button {
background: var(--paper); color: var(--ink);
border: 0; padding: 6px 10px; margin: 0;
font: inherit; font-size: 10px;
letter-spacing: 0.14em; text-transform: uppercase;
cursor: pointer;
}
.notes button[aria-pressed="true"] { background: var(--ink); color: var(--paper); }
@media (max-width: 767px) { .notes button { padding: 4px 8px; font-size: 9px; } }
</style>
</head>
<body class="lang-en style-editorial">
Expand All @@ -452,6 +515,16 @@

<article class="article" id="article">

<div class="notes" role="group" aria-label="Show writing notes">
<button
type="button"
id="notesBtn"
aria-pressed="false"
aria-controls="article"
aria-label="Show or hide per-sentence writing notes"
>Notes</button>
</div>

<div class="lang" role="group" aria-label="Language">
<button type="button" data-lang="en" aria-pressed="true">EN</button>
<button type="button" data-lang="ru" aria-pressed="false">RU</button>
Expand Down Expand Up @@ -512,34 +585,52 @@
<figure class="figure v-a"><img src="{{ photos[0].data_uri }}" alt="" loading="eager"></figure>
{% endif %}

{# Mid-body photo: between paragraph blocks. ADR-014 (Phase 4):
paragraphs is a list of Paragraph, each a list of Sentence with
tri-lingual text + a provenance tag. Each sentence becomes a
<span data-prov> so the rendered HTML carries the grounding
info into the reader's hover/click. Sentences are joined with a
space within each <p>. INFERRED sentences get a subtle tint
via a CSS rule keyed off the data-prov attribute. #}
{% set n = narrative.paragraphs | length %}
{% set mid = (n // 2) if n >= 3 else n %}
{# Photo distribution. ADR-014 (Phase 4): paragraphs is a list
of Paragraph, each a list of Sentence with tri-lingual text
and a provenance tag. Each sentence becomes a <span data-prov>
so the rendered HTML carries the grounding info; the Notes
toggle (`.notes` above) flips ``body.audit`` to reveal it.

Layout: photos[0] is the hero float (emitted above). The
remaining photos are interleaved one-per-paragraph through
the body so the photo rhythm matches the prose rhythm; any
overflow falls after the pull quote.

Previous layout (2026-05) put a single mid-body photo at
index ``n//2 - 1`` and stacked photos[2:] in a tail after
the quote. With 6–8 selected photos that produced one photo
in the top half and five-to-seven crowded after the bottom
quote — pilot users read it as a photo dump. Interleaving
keeps the page balanced. #}
{% set extras = photos[1:] if photos|length > 1 else [] %}
{% set inter = extras[:narrative.paragraphs | length] %}
{% set tail = extras[narrative.paragraphs | length:] %}
<div class="body swap">
{% for paragraph in narrative.paragraphs %}
<p class="en">
{% for sentence in paragraph -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" title="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.en }}</span>{% if not loop.last %} {% endif -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" data-tip="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.en }}</span>{% if not loop.last %} {% endif -%}
{% endfor %}
</p>
<p class="ru">
{% for sentence in paragraph -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" title="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.ru }}</span>{% if not loop.last %} {% endif -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" data-tip="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.ru }}</span>{% if not loop.last %} {% endif -%}
{% endfor %}
</p>
<p class="de">
{% for sentence in paragraph -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" title="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.de }}</span>{% if not loop.last %} {% endif -%}
<span class="sent" data-prov="{{ sentence.provenance.source }}" data-tip="{{ sentence.provenance.source }}: {{ sentence.provenance.reference }}">{{ sentence.text.de }}</span>{% if not loop.last %} {% endif -%}
{% endfor %}
</p>
{%- if loop.index0 == mid - 1 and photos|length > 1 %}
<figure class="figure v-c"><img src="{{ photos[1].data_uri }}" alt="" loading="lazy"></figure>
{%- if loop.index0 < inter|length %}
{# Use full-column variants for inline interleavers — float
variants (v-a, v-e) interact poorly with mid-body
paragraph wrapping. The float idioms still anchor the
hero at the top and the tail at the bottom. #}
{% set v = ['v-b', 'v-c', 'v-d', 'v-b'][loop.index0 % 4] %}
<figure class="figure {{ v }}">
<img src="{{ inter[loop.index0].data_uri }}" alt="" loading="lazy">
</figure>
{% endif %}
{% endfor %}
</div>
Expand All @@ -550,9 +641,12 @@
<span class="de">{{ narrative.pull_quote.de }}</span>
</blockquote>

{# Remaining photos in two layout variants, rotating, after the quote. #}
{% if photos|length > 2 %}
{% for ph in photos[2:] %}
{# Overflow tail: photos that did not fit in the interleaved
slots above. Rotates through the original float / block
variants so the bottom of the page still has visual rhythm
when there are more photos than paragraphs. #}
{% if tail|length > 0 %}
{% for ph in tail %}
{% set v = ['v-e', 'v-b', 'v-d', 'v-b'][loop.index0 % 4] %}
<figure class="figure {{ v }}">
<img src="{{ ph.data_uri }}" alt="" loading="lazy">
Expand Down Expand Up @@ -775,6 +869,29 @@
else applyLang('en');
}

// ── notes (audit) toggle ──────────────────────────────────────
// The Notes button flips ``body.audit``, revealing the per-
// sentence provenance tints + custom tooltips that are otherwise
// hidden from the reader. Default is off — the recipient of a
// memory page wants prose, not author audit UI. Author preference
// is persisted in localStorage so a creator who wants notes on
// doesn't have to re-enable on every page reload.
var NOTES_KEY = 'trailstory.notes';
var notesBtn = document.getElementById('notesBtn');
function setNotes(on) {
document.body.classList.toggle('audit', !!on);
if (notesBtn) notesBtn.setAttribute('aria-pressed', on ? 'true' : 'false');
try { localStorage.setItem(NOTES_KEY, on ? '1' : '0'); } catch (e) {}
}
if (notesBtn) {
var notesStored = null;
try { notesStored = localStorage.getItem(NOTES_KEY); } catch (e) {}
if (notesStored === '1') setNotes(true);
notesBtn.addEventListener('click', function () {
setNotes(!document.body.classList.contains('audit'));
});
}

// ── reading progress bar ──────────────────────────────────────
var article = document.getElementById('article');
var progress = document.getElementById('progress');
Expand Down
Loading
Loading