Files
intotheeast-com/docs/superpowers/specs/2026-06-20-inline-journal-feed-design.md
T

10 KiB
Raw Blame History

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-post CSS 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 24 photos
│                                      │
└──────────────────────────────────────┘
           ●  ○  ○                        ← dots; hidden when only 1 photo
Body text paragraph(s)
──────────────────────────────────────── ← border-bottom separator
  • Title sits above the photo, using var(--font-display) at var(--text-xl)
  • Meta row (date, location, weather) sits between title and photo; the date is a small <a> permalink to the detail page, styled in var(--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-textmeta and children (.entry-date-plain, .entry-location-plain)
  • .entry-card-photo-overlay and 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-card base styles — story cards still use this class
  • .entry-card-photo and .entry-card-photo img — story cards use .entry-card-photo--story
  • .entry-card:hover background 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)