Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vgmzx8VTTTmCskSpQtsLTr
314 lines
13 KiB
Markdown
314 lines
13 KiB
Markdown
# Frontend Polish Design Spec
|
||
|
||
> **For agentic workers:** REQUIRED SUB-SKILL: Use `superpowers:subagent-driven-development` (recommended) or `superpowers:executing-plans` to implement this plan task-by-task.
|
||
|
||
**Goal:** Elevate the visual identity and consistency of the five primary page templates — home, trip, trips listing, individual entry, and story — without touching the map page or dailies index. Improvements fall into three categories: visual identity (header, stats, pills), typographic consistency (emoji removal), and content pages (trip cards, story transitions).
|
||
|
||
**Architecture:** Mostly CSS changes in `style.css`. Two Twig templates need small additions (`trips.html.twig`, `story.html.twig`). One blueprint gets a new field (`blueprints/trip.yaml`). One partial gets emoji removed (`partials/entry-journal.html.twig`). No new JS libraries.
|
||
|
||
**Already completed as part of this session:**
|
||
- `entry.html.twig` unified with `partials/entry-journal.html.twig` — hero image removed, custom lightbox replaced with PhotoSwipe, dead CSS stripped
|
||
- See git log for the entry template rewrite commit
|
||
|
||
---
|
||
|
||
## Global Constraints
|
||
|
||
- All changes in `user/` — commit with `git -C user`
|
||
- All new CSS must use token variables — never hardcode hex values
|
||
- No new JS libraries or CDN dependencies
|
||
- Changes must degrade gracefully if optional data (cover image, location) is absent
|
||
- `prefers-reduced-motion` must be respected for any new animations
|
||
|
||
---
|
||
|
||
## A — Trip Cards: Cover Image
|
||
|
||
### Problem
|
||
The trips listing (`/trips`) renders a vertical stack of text-only cards: title, date range, entry count. For a travel blog, the archive is the viewer's first encounter with trips they haven't visited — showing no visual context is a significant missed opportunity.
|
||
|
||
### Design decision
|
||
Each `.trip-card` gets a full-width banner image above the existing text. Aspect ratio 3:1 — wide enough to suggest landscape/geography without dominating a card in a list.
|
||
|
||
```
|
||
┌─────────────────────────────────────────┐
|
||
│ [cover image — 3:1 aspect ratio] │
|
||
├─────────────────────────────────────────┤
|
||
│ Japan & South Korea │
|
||
│ Apr 2026 — Jun 2026 · 24 entries │
|
||
└─────────────────────────────────────────┘
|
||
```
|
||
|
||
### Image resolution
|
||
The card is constrained to `--content-width` (720px). Use `cropResize(720, 240)` for the 3:1 crop.
|
||
|
||
### Image source priority
|
||
1. `trip.header.cover_image` — a filename from the trip page's own media (explicit, curated)
|
||
2. First image from the first published journal entry in the trip (automatic fallback)
|
||
3. No image — card degrades to text-only (existing layout, unchanged)
|
||
|
||
### Blueprint change
|
||
Add a `cover_image` field to `user/themes/intotheeast/blueprints/trip.yaml`:
|
||
|
||
```yaml
|
||
cover_image:
|
||
type: filepicker
|
||
label: Cover Image
|
||
preview_images: true
|
||
folder: '@self'
|
||
accept:
|
||
- image/*
|
||
```
|
||
|
||
### New CSS
|
||
|
||
```css
|
||
.trip-card-cover {
|
||
aspect-ratio: 3 / 1;
|
||
overflow: hidden;
|
||
border-radius: var(--radius-md) var(--radius-md) 0 0;
|
||
background: var(--color-border);
|
||
margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) var(--space-5);
|
||
}
|
||
|
||
.trip-card-cover img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform 0.45s ease;
|
||
}
|
||
|
||
.trip-card:hover .trip-card-cover img { transform: scale(1.04); }
|
||
```
|
||
|
||
The negative margin pulls the image flush to the card edges while the card keeps its existing padding for the text below.
|
||
|
||
---
|
||
|
||
## B — Replace Emoji Icons
|
||
|
||
### Problem
|
||
`partials/entry-journal.html.twig` (which now also powers `entry.html.twig`) uses `📍` for location and emoji for weather conditions (☀️, 🌧️, etc.). These are OS-rendered, variable in size, and break the typographic consistency of the warm-dark palette.
|
||
|
||
### Design decision
|
||
- **Location:** Replace `📍` with a minimal inline SVG mappin. 16×16, `currentColor`, single path.
|
||
- **Weather:** Drop the emoji prefix entirely. The text description ("Sunny", "Rain", "Partly cloudy") is the information — the emoji is decoration. Text-only is cleaner and the muted color already signals it as secondary metadata.
|
||
|
||
### SVG mappin (inline, replaces `📍`)
|
||
|
||
```html
|
||
<svg width="12" height="14" viewBox="0 0 12 14" fill="currentColor" aria-hidden="true" style="flex-shrink:0;margin-top:1px">
|
||
<path d="M6 0C3.24 0 1 2.24 1 5c0 3.75 5 9 5 9s5-5.25 5-9c0-2.76-2.24-5-5-5zm0 6.75A1.75 1.75 0 1 1 6 3.25a1.75 1.75 0 0 1 0 3.5z"/>
|
||
</svg>
|
||
```
|
||
|
||
---
|
||
|
||
## C — Header Identity
|
||
|
||
### Problem
|
||
The site header reads like a product app: text logo left, two nav links right, 60px tall, 3px teal stripe on top. The brand "into the east" at `--text-lg` with `-0.01em` tracking is timid. The content pages are atmospheric and cinematic; the header is functional and forgettable.
|
||
|
||
### Design decision
|
||
Two targeted CSS-only changes:
|
||
|
||
1. **Site title tracking:** Increase from `--text-lg` to `--text-xl`, set `letter-spacing: 0.06em`. Wider tracking on a dark background is a deliberate typographic mark — it reads as a designed wordmark rather than placeholder text.
|
||
|
||
2. **Accent stripe:** Increase from `3px` to `4px`. Apply a two-stop gradient along the 90deg axis: `linear-gradient(90deg, var(--color-accent), var(--color-accent-hover))`. This gives the stripe direction (reads left-to-right like a journey) and signals it was chosen, not defaulted.
|
||
|
||
No layout change, no template change, no height change.
|
||
|
||
---
|
||
|
||
## D — Story Opening Transition
|
||
|
||
### Problem
|
||
After the Ken Burns hero and the 40vh spacer, `story.html.twig` begins the body content immediately with prose. There is no visual breath between the cinematic full-screen image and the reading experience. The reader has no bearing — no confirmation of where they are or when.
|
||
|
||
### Design decision
|
||
Add a `.story-opener` block at the top of `.story-body`, before `{{ page.content|raw }}`. It displays the location and formatted date string centered, separated from the prose below by a thin ruled line.
|
||
|
||
```
|
||
Sorano, Italy · 14–16 June 2026
|
||
────────────────────────────────
|
||
[prose begins here]
|
||
```
|
||
|
||
Data comes from `location` and `date_str`, already computed at the top of `story.html.twig`. If both are empty the opener renders nothing (zero markup visible).
|
||
|
||
The opener fades in using the existing `storyReveal` keyframe (`filter: blur → 0`, `opacity: 0 → 1`, `translateY(22px → 0)`) with a 0.8s delay so it appears after the hero title animation completes.
|
||
|
||
### New CSS
|
||
|
||
```css
|
||
.story-opener {
|
||
text-align: center;
|
||
padding-bottom: var(--space-12);
|
||
margin-bottom: var(--space-12);
|
||
border-bottom: 1px solid var(--color-border);
|
||
opacity: 0;
|
||
animation: storyReveal 0.9s cubic-bezier(.16,1,.3,1) 0.8s both;
|
||
}
|
||
|
||
.story-opener__text {
|
||
font-family: var(--font-ui);
|
||
font-size: var(--text-sm);
|
||
color: var(--color-ink-muted);
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.story-opener { opacity: 1; animation: none; }
|
||
}
|
||
```
|
||
|
||
### Template addition (in `story.html.twig`, inside `.story-body`, before `page.content`)
|
||
|
||
```twig
|
||
{% if location or date_str %}
|
||
<div class="story-opener">
|
||
<span class="story-opener__text">
|
||
{{- date_str -}}
|
||
{%- if location and date_str %} · {% endif -%}
|
||
{{- location -}}
|
||
</span>
|
||
</div>
|
||
{% endif %}
|
||
```
|
||
|
||
---
|
||
|
||
## E — Reading Progress Bar
|
||
|
||
### Problem
|
||
Story pages are long-form — the longest may scroll for several minutes of reading. There is no visual feedback about progress through the piece. This is a small but meaningful quality signal on an immersive reading experience.
|
||
|
||
### Design decision
|
||
A 2px teal bar fixed to the bottom edge of the site header (`top: var(--site-header-height)`), filling left-to-right as the reader scrolls through `.story-body`. Progress is calculated relative to the story body element (not the full page including the hero), so the bar reads 0% when the hero exits and 100% when the last line of content reaches the viewport bottom.
|
||
|
||
The bar is invisible before the story body enters view. It does not render at all if `prefers-reduced-motion` is set — there should be no static `width: 0` bar for reduced-motion users.
|
||
|
||
### New CSS
|
||
|
||
```css
|
||
.story-progress {
|
||
position: fixed;
|
||
top: var(--site-header-height);
|
||
left: 0;
|
||
height: 2px;
|
||
width: 0%;
|
||
background: var(--color-accent);
|
||
z-index: 200;
|
||
pointer-events: none;
|
||
will-change: width;
|
||
}
|
||
```
|
||
|
||
### JS logic (no transition — rAF-driven for smoothness)
|
||
|
||
```javascript
|
||
(function () {
|
||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return;
|
||
var bar = document.getElementById('story-progress');
|
||
var body = document.querySelector('.story-body');
|
||
if (!bar || !body) return;
|
||
|
||
function update() {
|
||
var rect = body.getBoundingClientRect();
|
||
var total = body.offsetHeight - window.innerHeight;
|
||
var scrolled = -rect.top;
|
||
var pct = total > 0 ? Math.min(100, Math.max(0, (scrolled / total) * 100)) : 0;
|
||
bar.style.width = pct.toFixed(1) + '%';
|
||
}
|
||
|
||
window.addEventListener('scroll', update, { passive: true });
|
||
update();
|
||
})();
|
||
```
|
||
|
||
The element `<div class="story-progress" id="story-progress"></div>` is added to `story.html.twig` immediately after the opening `{% block content %}`.
|
||
|
||
---
|
||
|
||
## F — Pill Shape Differentiation
|
||
|
||
### Problem
|
||
All interactive pills use `border-radius: 9999px` regardless of their role. Back-navigation pills, filter buttons, panel toggles, sort toggles — they all look identical, which collapses the visual grammar. A reader cannot tell at a glance whether tapping a pill will navigate them away or toggle a filter.
|
||
|
||
### Design decision
|
||
Establish a two-shape grammar:
|
||
|
||
| Role | Shape | Classes |
|
||
|---|---|---|
|
||
| Navigation (go somewhere, leave the page) | Full pill `9999px` | `.back-pill`, `.story-escape`, `.story-totop` |
|
||
| Controls (toggle, filter, sort in place) | Rounded rect `var(--radius-sm)` = 4px | `.trip-filter-btn`, `.trip-stats-btn` |
|
||
| Panel toggles (secondary, in-place) | Full pill (keep — less prominent than controls) | `.trip-panel-toggle` |
|
||
|
||
CSS-only change. `.back-pill`, `.story-escape`, `.story-totop` are unchanged. Only `.trip-filter-btn` and `.trip-stats-btn` change from `border-radius: var(--radius-full)` to `border-radius: var(--radius-sm)`.
|
||
|
||
---
|
||
|
||
## G — Stats: Field Notes Treatment
|
||
|
||
### Problem
|
||
`.stat-block` renders as a bordered card with a `background: var(--color-canvas)` surface, box shadow, and teal accent numbers. This reads as a metrics dashboard — every SaaS product uses this pattern. For a travel journal, numbers like "1,847 km" and "3 countries" should feel earned and written, not computed and charted.
|
||
|
||
### Design decision
|
||
Two changes:
|
||
|
||
1. **Remove the box.** Drop `background`, `border`, and `box-shadow` from `.stat-block`. Replace with a `border-left: 2px solid var(--color-accent)` and `padding-left: var(--space-4)`. Text left-aligns. Numbers feel like notes in a margin, not cells in a table.
|
||
|
||
2. **Change number color.** `stat-value` moves from `var(--color-accent)` to `var(--color-ink)`. Teal numbers on dark are a SaaS color decision. Cream numbers on dark with a teal accent stripe are a traveler's notation.
|
||
|
||
The teal accent is now only the left rule — restrained, singular.
|
||
|
||
### CSS change
|
||
|
||
```css
|
||
/* Before */
|
||
.stat-block {
|
||
background: var(--color-canvas);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: var(--radius-md);
|
||
padding: var(--space-6) var(--space-5);
|
||
text-align: center;
|
||
box-shadow: var(--shadow-sm);
|
||
}
|
||
|
||
.stat-value {
|
||
color: var(--color-accent);
|
||
...
|
||
}
|
||
|
||
/* After */
|
||
.stat-block {
|
||
border-left: 2px solid var(--color-accent);
|
||
padding: var(--space-2) 0 var(--space-2) var(--space-4);
|
||
text-align: left;
|
||
}
|
||
|
||
.stat-value {
|
||
color: var(--color-ink);
|
||
...
|
||
}
|
||
```
|
||
|
||
The `.trip-stats-grid` and `.stats-grid` gap/column settings are unchanged.
|
||
|
||
---
|
||
|
||
## Verification Checklist
|
||
|
||
After full implementation, check each page:
|
||
|
||
| Page | Check |
|
||
|---|---|
|
||
| `/trips` | Trip cards show cover image (or degrade to text-only gracefully); hover scales image |
|
||
| `/trips/<any-trip>` | Stats panel shows left-rule style, cream numbers; filter buttons are rounded-rect |
|
||
| `/trips/<any-trip>/dailies/<any-entry>` (standalone) | Photo strip renders via PhotoSwipe, no broken lightbox; no hero image at top |
|
||
| `/trips/<any-trip>/<any-story>` | Opener block shows location + date; progress bar fills while scrolling; no bar if reduced-motion |
|
||
| Any page | Header title has wider tracking; accent stripe is slightly thicker with gradient |
|
||
| Any page with journal entries | Location shows SVG pin; weather shows text only, no emoji |
|