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

307 lines
10 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```html
<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
```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.
```html
<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`):
```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`):
```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)