docs: restructure docs/ into guides/ reference/ working/ research/
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
# 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 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)` 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)
|
||||
Reference in New Issue
Block a user