10 KiB
Inline Journal Feed Design Spec
2026-06-20
Goal
Replace click-through journal entry cards with fully inline posts across the trip page, dailies page, and home page. Each journal entry renders its full content in the feed — title, meta, photo strip, and body text — without requiring navigation to the detail page.
Scope
In scope:
- Journal entry display in
trip.html.twig,dailies.html.twig,home.html.twig - New
.journal-postCSS component and photo strip styles - Dot-sync JS for the photo strip (one shared block in
base.html.twig) - Map flash animation extended to
.journal-post.is-highlighted - Test updates for T1, T2
Out of scope:
- Story cards in the feed — remain as click-through
<a class="entry-card entry-card--story">, unchanged - The journal entry detail page (
entry.html.twig) — kept as-is; just not linked from the feed - The post form — photos are already uploaded correctly
- Lightbox on the feed — only on the detail page
Layout
Each journal entry in the feed renders as:
Title (DM Serif Display, ~xl)
DATE · 📍 City, Country · ☀️ Weather ← meta row; DATE is the permalink to detail page
┌──────────────────────────────────────┐
│ │
│ Photo (full-width, 3:2 ratio) │ ← swipe left/right for 2–4 photos
│ │
└──────────────────────────────────────┘
● ○ ○ ← dots; hidden when only 1 photo
Body text paragraph(s)
──────────────────────────────────────── ← border-bottom separator
- Title sits above the photo, using
var(--font-display)atvar(--text-xl) - Meta row (date, location, weather) sits between title and photo; the date is a small
<a>permalink to the detail page, styled invar(--color-ink-muted). Location and weather are plain text spans - Photo strip: CSS scroll-snap, no JS library required for swipe
- Dots: visible only when the entry has 2+ images; update via scroll listener
- Body: full entry body text — not truncated, not excerpted
- Separator:
border-bottom: 1px solid var(--color-border)on the post root, matching the current entry card separator
HTML Structure
<article class="journal-post" id="entry-{{ entry.slug }}"
data-type="journal" data-lat="{{ entry.header.lat }}" data-lng="{{ entry.header.lng }}">
<header class="journal-post-header">
<h2 class="journal-post-title">{{ entry.title }}</h2>
<p class="journal-post-meta">
<a class="journal-post-permalink" href="{{ entry.url }}">
<time datetime="{{ entry.date|date('Y-m-d') }}">{{ entry.date|date('d M Y')|upper }}</time>
</a>
{% if entry.header.location_city or entry.header.location_country %}
<span class="journal-post-location">
· 📍
{%- set _loc = [] -%}
{%- if entry.header.location_city -%}{%- set _loc = _loc|merge([entry.header.location_city]) -%}{%- endif -%}
{%- if entry.header.location_country -%}{%- set _loc = _loc|merge([entry.header.location_country]) -%}{%- endif -%}
{{ _loc|join(', ') }}
</span>
{% endif %}
{% if entry.header.weather_desc %}
<span class="journal-post-weather">· {{ weather_icons[entry.header.weather_desc] ?? '' }} {{ entry.header.weather_desc }}</span>
{% endif %}
</p>
</header>
{% set images = entry.media.images %}
{% if images|length > 0 %}
<div class="journal-photo-strip" data-slides="{{ images|length }}">
{% for img in images %}
<div class="journal-photo-slide">
<img src="{{ img.cropResize(900, 600).url }}" alt="{{ entry.title }}" loading="lazy">
</div>
{% endfor %}
</div>
{% if images|length > 1 %}
<div class="journal-photo-dots" aria-hidden="true">
{% for img in images %}
<span class="journal-photo-dot{% if loop.first %} is-active{% endif %}"></span>
{% endfor %}
</div>
{% endif %}
{% endif %}
<div class="journal-post-body">{{ entry.content|raw }}</div>
</article>
Key attribute notes:
id="entry-{{ entry.slug }}"— required for map marker scroll targeting (document.getElementById)data-type="journal"— required for the trip page filter bar (querySelectorAll('[data-type]'))data-lat/data-lng— required for map marker rendering- The
<article>root replaces the old<a class="entry-card">— the entry is no longer a clickable card
The weather_icons map (currently defined inline in entry.html.twig) must also be defined at the top of trip.html.twig, dailies.html.twig, and home.html.twig so the meta row can use it.
Photo Strip: CSS
/* ── Journal post ──────────────────────────────────────────── */
.journal-post {
border-bottom: 1px solid var(--color-border);
padding-bottom: var(--space-12);
margin-bottom: var(--space-12);
}
.journal-post-header {
margin-bottom: var(--space-4);
}
.journal-post-title {
font-family: var(--font-display);
font-size: var(--text-xl);
font-weight: 400;
line-height: var(--leading-snug);
color: var(--color-ink);
margin-bottom: var(--space-2);
}
.journal-post-meta {
font-size: var(--text-xs);
color: var(--color-ink-muted);
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--space-2);
}
.journal-post-permalink {
color: var(--color-ink-muted);
text-decoration: none;
font-weight: 700;
letter-spacing: 0.07em;
}
.journal-post-permalink:hover { color: var(--color-accent); }
.journal-post-location,
.journal-post-weather {
color: var(--color-ink-muted);
}
/* Photo strip */
.journal-photo-strip {
display: flex;
overflow-x: scroll;
scroll-snap-type: x mandatory;
scrollbar-width: none;
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.journal-photo-strip::-webkit-scrollbar { display: none; }
.journal-photo-slide {
flex: 0 0 100%;
scroll-snap-align: start;
aspect-ratio: 3 / 2;
overflow: hidden;
}
.journal-photo-slide img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
/* Dot indicators */
.journal-photo-dots {
display: flex;
justify-content: center;
gap: var(--space-2);
margin-bottom: var(--space-4);
}
.journal-photo-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--color-border);
transition: background 0.2s;
}
.journal-photo-dot.is-active {
background: var(--color-ink-muted);
}
/* Body */
.journal-post-body {
font-size: var(--text-base);
line-height: var(--leading-normal);
color: var(--color-ink-2);
}
.journal-post-body p { margin-bottom: var(--space-4); }
.journal-post-body p:last-child { margin-bottom: 0; }
/* Map flash — extends existing keyframe */
.journal-post.is-highlighted {
animation: card-highlight 0.7s ease-out forwards;
}
Photo Strip: JS
One shared script block added to base.html.twig, just before </body>. It is a no-op on pages with no strips.
<script>
(function () {
document.querySelectorAll('.journal-photo-strip').forEach(function (strip) {
var dots = strip.nextElementSibling;
if (!dots || !dots.classList.contains('journal-photo-dots')) return;
var dotEls = Array.from(dots.querySelectorAll('.journal-photo-dot'));
strip.addEventListener('scroll', function () {
var idx = Math.round(strip.scrollLeft / strip.offsetWidth);
dotEls.forEach(function (d, i) { d.classList.toggle('is-active', i === idx); });
}, { passive: true });
});
})();
</script>
CSS Cleanup
The following selectors are used exclusively by the old journal entry card and can be removed from style.css once the new .journal-post component is in place. Story cards in the feed (entry-card--story) do not use them:
.entry-card-textmetaand children (.entry-date-plain,.entry-location-plain).entry-card-photo-overlayand children (.entry-date-overlay,.entry-location-overlay).entry-excerpt.entry-read-more.entry-card .entry-title— the title rule scoped to.entry-card; replace with.journal-post-title.entry-card:hover .entry-card-photo img— photo zoom on hover; journal posts have no hover interaction.entry-card:hover .entry-title— title tint on hover; same reason.entry-card.is-highlighted— replaced by.journal-post.is-highlighted
Keep the following — they are still used by story cards (entry-card--story) or elsewhere:
.entry-cardbase styles — story cards still use this class.entry-card-photoand.entry-card-photo img— story cards use.entry-card-photo--story.entry-card:hoverbackground lift (in the shared three-card selector) — story cards still hover- All single-entry-page styles (
.entry-hero,.entry-header,.entry-body, etc.)
Test Updates
T1 (tests/ui/dailies.spec.js):
// OLD
await expect(page.locator('.entry-card').first()).toBeVisible();
// NEW
await expect(page.locator('.journal-post').first()).toBeVisible();
T2 (tests/ui/dailies.spec.js):
// OLD — used href on the <a> root
const newerCard = page.locator(`.entry-card[href*="${NEWER_SLUG}"]`);
const olderCard = page.locator(`.entry-card[href*="${OLDER_SLUG}"]`);
// ...
findIndex(c => c === el)
// NEW — use id attribute (journal posts are <article>, not <a>)
const newerCard = page.locator(`#entry-${NEWER_SLUG}`);
const olderCard = page.locator(`#entry-${OLDER_SLUG}`);
// ...
findIndex(c => c.id === el.id)
Out of scope
- Swipe velocity / momentum — native browser scroll-snap handles this
- Lightbox on the feed photo strip — photos are not tappable in the feed; the detail page retains the lightbox
- Lazy-load placeholder shimmer
- Image ordering UI — photos appear in filesystem order (same as the detail page gallery)