feat(notices): bring the release notes back on every update

The release modal takes the 4.3.0 design: the release and its headline cards
on the left, the note and the support links on the right, at the design's own
size. The cards carry small drawings of the features instead of an icon.

The 4.0.0 entry is retired. In its place sits one entry, release-notes, that
every big release rewrites and keeps. It recurs per version with no upper
bound, so every user sees it once on each update, whatever they did with it
before, and a reload of the same version keeps it closed. An integration test
holds that promise through the real entry and the real dismiss route.

All 23 locales carry the new copy and drop the 4.0.0 strings.
This commit is contained in:
Maurice
2026-09-15 23:50:15 +02:00
parent 99783cc8da
commit 05ee3d50e4
34 changed files with 1562 additions and 1206 deletions
@@ -1,14 +1,14 @@
// FE-RN-001 to FE-RN-011
// FE-RN-001 to FE-RN-014
import { describe, it, expect, vi } from 'vitest'
import { render, screen } from '../../../tests/helpers/render'
import userEvent from '@testing-library/user-event'
import { ReleaseNoticeModal } from './ReleaseNoticeModal'
import type { SystemNoticeDTO } from '../../store/systemNoticeStore'
/** A notice shaped like the 4.0.0 registry entry, with the pieces a test needs to vary. */
/** A notice shaped like the release-notes registry entry, with the pieces a test needs to vary. */
function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
return {
id: 'release-4-0-0',
id: 'release-notes',
display: 'modal',
severity: 'info',
titleKey: 'rel.headline',
@@ -18,25 +18,27 @@ function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDT
cta: { kind: 'link', labelKey: 'rel.bmc', href: 'https://buymeacoffee.com/mauriceboe' },
secondaryCta: { kind: 'link', labelKey: 'rel.kofi', href: 'https://ko-fi.com/mauriceboe' },
release: {
version: '4.0.0',
version: '4.3.0',
eyebrowKey: 'rel.eyebrow',
tagKey: 'rel.tag',
headlineKey: 'rel.headline',
introKey: 'rel.intro',
featuresLabelKey: 'rel.features.label',
features: [
{ iconName: 'Smartphone', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
{ iconName: 'BookOpen', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body', badgeKey: 'rel.f2.badge' },
{ iconName: 'Database', visual: 'places-api', titleKey: 'rel.f1.title', bodyKey: 'rel.f1.body' },
{ iconName: 'Route', visual: 'roadtrip', titleKey: 'rel.f2.title', bodyKey: 'rel.f2.body' },
{ iconName: 'MapPin', visual: 'dawarich', titleKey: 'rel.f3.title', bodyKey: 'rel.f3.body' },
],
note: {
eyebrowKey: 'rel.note.eyebrow',
titleKey: 'rel.note.title',
bodyKey: 'rel.note.body',
promiseLabelKey: 'rel.promise.label',
promiseLeadKey: 'rel.promise.lead',
promiseTextKey: 'rel.promise.text',
bodyAfterKey: 'rel.note.after',
closingKey: 'rel.note.closing',
signatureKey: 'rel.note.signature',
},
supportLeadKey: 'rel.support.lead',
supportTextKey: 'rel.support',
},
...overrides,
@@ -69,14 +71,16 @@ describe('ReleaseNoticeModal', () => {
it('FE-RN-002: shows the version as a plain figure, not a translation key', () => {
renderModal()
expect(screen.getByText('4.0.0')).toBeInTheDocument()
expect(screen.getByText('4.3.0')).toBeInTheDocument()
})
it('FE-RN-003: renders one row per feature, with the badge only where declared', () => {
it('FE-RN-003: renders one card per feature, each with its drawing', () => {
renderModal()
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
expect(screen.getByText('rel.f2.badge')).toBeInTheDocument()
expect(document.querySelectorAll('.rn-feature')).toHaveLength(2)
expect(document.querySelectorAll('.rn-feature')).toHaveLength(3)
expect(document.querySelector('.rn-vis-places')).not.toBeNull()
expect(document.querySelector('.rn-vis-road')).not.toBeNull()
expect(document.querySelector('.rn-vis-trail')).not.toBeNull()
})
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
@@ -88,17 +92,17 @@ describe('ReleaseNoticeModal', () => {
expect(screen.getByText('second paragraph')).toBeInTheDocument()
})
it('FE-RN-005: omits the stats row when the release carries no stats', () => {
it('FE-RN-005: omits the foot when the release has neither a footnote nor notes', () => {
renderModal()
expect(document.querySelector('.rn-release-foot')).toBeNull()
})
it('FE-RN-006: shows the stats row and the notes link when both are present', () => {
it('FE-RN-006: shows the footnote and the notes link when both are present', () => {
const n = releaseNotice()
n.release!.stats = [{ value: '~150', labelKey: 'rel.stat.bugs' }]
n.release!.footnoteKey = 'rel.footnote'
n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' }
renderModal(n)
expect(screen.getByText('~150')).toBeInTheDocument()
expect(screen.getByText('rel.footnote')).toBeInTheDocument()
const link = screen.getByRole('link', { name: /rel\.notes/ })
expect(link).toHaveAttribute('href', 'https://example.test/notes')
expect(link).toHaveAttribute('target', '_blank')
@@ -152,9 +156,37 @@ describe('ReleaseNoticeModal', () => {
renderModal()
const dialog = screen.getByRole('dialog')
expect(dialog).toHaveAttribute('aria-modal', 'true')
expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-4-0-0')
expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-4-0-0')
expect(document.getElementById('notice-title-release-4-0-0')).toHaveTextContent('rel.headline')
expect(document.getElementById('notice-body-release-4-0-0')).toHaveTextContent('rel.intro')
expect(dialog).toHaveAttribute('aria-labelledby', 'notice-title-release-notes')
expect(dialog).toHaveAttribute('aria-describedby', 'notice-body-release-notes')
expect(document.getElementById('notice-title-release-notes')).toHaveTextContent('rel.headline')
expect(document.getElementById('notice-body-release-notes')).toHaveTextContent('rel.intro')
})
it('FE-RN-012: sets the promise and the support text with a bold lead', () => {
renderModal()
expect(screen.getByText('rel.promise.lead').tagName).toBe('B')
expect(screen.getByText('rel.support.lead').tagName).toBe('B')
expect(screen.getByText('rel.promise.text')).toBeInTheDocument()
})
it('FE-RN-013: the features aside is optional', () => {
renderModal()
expect(document.querySelector('.rn-features-aside')).toBeNull()
const n = releaseNotice()
n.release!.featuresAsideKey = 'rel.features.aside'
renderModal(n)
expect(screen.getByText('rel.features.aside')).toBeInTheDocument()
})
it('FE-RN-014: a card whose drawing this client does not know falls back to its icon', () => {
const n = releaseNotice()
n.release!.features = [
{ iconName: 'Route', visual: 'from-a-newer-server', titleKey: 'rel.x.title', bodyKey: 'rel.x.body' },
{ iconName: 'NoSuchIcon', titleKey: 'rel.y.title', bodyKey: 'rel.y.body' },
]
renderModal(n)
expect(document.querySelectorAll('.rn-vis-icon')).toHaveLength(2)
expect(document.querySelectorAll('.rn-vis-icon svg')).toHaveLength(2)
})
})
@@ -1,8 +1,8 @@
import React from 'react';
import * as LucideIcons from 'lucide-react';
import { ArrowRight, Coffee, Heart, Infinity as InfinityIcon, Sparkles, X } from 'lucide-react';
import { ArrowRight, Heart, Infinity as InfinityIcon, X } from 'lucide-react';
import { useTranslation } from '../../i18n/TranslationContext.js';
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
import { ReleaseFeatureVisual } from './ReleaseNoticeVisuals.js';
import './releaseNotice.css';
interface Props {
@@ -13,6 +13,20 @@ interface Props {
onSecondaryCTA: () => void;
}
/** Lucide's coffee cup, drawn here so its three steam strokes can drift up in waves. */
function SteamingCoffee() {
return (
<svg className="rn-coffee" width={18} height={18} viewBox="0 0 24 24" fill="none" stroke="currentColor"
strokeWidth={2.1} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M17 8h1a4 4 0 1 1 0 8h-1" />
<path d="M3 8h14v9a4 4 0 0 1-4 4H7a4 4 0 0 1-4-4Z" />
<path className="rn-steam" d="M6 5.5c-1.2-.9 1.2-1.7 0-2.6s1.2-1.7 0-2.6 1.2-1.7 0-2.6" />
<path className="rn-steam" d="M10 5.5c-1.2-.9 1.2-1.7 0-2.6s1.2-1.7 0-2.6 1.2-1.7 0-2.6" />
<path className="rn-steam" d="M14 5.5c-1.2-.9 1.2-1.7 0-2.6s1.2-1.7 0-2.6 1.2-1.7 0-2.6" />
</svg>
);
}
/** Splits a translated block into paragraphs the way the notice bodies are written. */
function paragraphs(text: string): string[] {
return text.split('\n\n').map(p => p.trim()).filter(Boolean);
@@ -21,10 +35,10 @@ function paragraphs(text: string): string[] {
/**
* The release modal: what shipped on the left, a note from the maintainer on
* the right. Rendered instead of the generic notice body whenever a notice
* carries `release` — every string comes from that block, so a later release
* only edits the registry entry.
* carries `release`. Every string comes from that block, so a later release
* only edits the registry entry and its keys.
*
* Desktop only (the notices carrying it set `desktopOnly`), and it keeps the
* Desktop only (the notice carrying it sets `desktopOnly`), and it keeps the
* generic modal's behaviour: the host hook still owns ESC, the scroll lock and
* the dismissal, this component only draws.
*/
@@ -62,48 +76,37 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
<span className="rn-eyebrow">{t(release.eyebrowKey)}</span>
</div>
<div className="rn-version-row">
<div className="rn-version">{release.version}</div>
<div className="rn-tag">{t(release.tagKey)}</div>
</div>
<div className="rn-version">{release.version}</div>
<h2 id={titleId} className="rn-headline">{t(release.headlineKey)}</h2>
<p id={bodyId} className="rn-intro">{t(release.introKey)}</p>
<div className="rn-features">
{release.features.map(f => {
const Icon: React.ElementType =
((LucideIcons as Record<string, unknown>)[f.iconName] as React.ElementType) ?? Sparkles;
return (
<div key={f.titleKey} className="rn-feature">
<span className="rn-feature-icon">
<Icon size={18} strokeWidth={1.9} aria-hidden="true" />
</span>
<div className="rn-feature-text">
<div className="rn-feature-title">
{t(f.titleKey)}
{f.badgeKey && <span className="rn-badge">{t(f.badgeKey)}</span>}
</div>
<div className="rn-feature-body">{t(f.bodyKey)}</div>
</div>
</div>
);
})}
<div className="rn-features-head">
<span className="rn-features-label">{t(release.featuresLabelKey)}</span>
<span className="rn-features-rule" aria-hidden="true" />
{release.featuresAsideKey && (
<span className="rn-features-aside">{t(release.featuresAsideKey)}</span>
)}
</div>
{((release.stats?.length ?? 0) > 0 || release.notes) && (
<div className="rn-release-foot">
{release.stats && release.stats.length > 0 && (
<div className="rn-stats">
{release.stats.map(s => (
<div key={s.labelKey}>
<div className="rn-stat-value">{s.value}</div>
<div className="rn-stat-label">{t(s.labelKey)}</div>
</div>
))}
<div className="rn-features">
{release.features.map(f => (
<div key={f.titleKey} className="rn-feature">
<div className="rn-feature-shine" aria-hidden="true" />
<div className="rn-feature-visual" aria-hidden="true">
<ReleaseFeatureVisual visual={f.visual} iconName={f.iconName} />
</div>
)}
<div>
<div className="rn-feature-title">{t(f.titleKey)}</div>
<div className="rn-feature-body">{t(f.bodyKey)}</div>
</div>
</div>
))}
</div>
{(release.footnoteKey || release.notes) && (
<div className="rn-release-foot">
<div className="rn-footnote">{release.footnoteKey ? t(release.footnoteKey) : null}</div>
{release.notes && (
<a
className="rn-notes"
@@ -119,10 +122,6 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
)}
</div>
)}
{release.footnoteKey && (
<div className="rn-footnote">{t(release.footnoteKey)}</div>
)}
</div>
</div>
@@ -130,7 +129,7 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
<div className="rn-note">
{notice.dismissible && (
<button type="button" className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
<X size={18} strokeWidth={2} />
<X size={17} strokeWidth={2} />
</button>
)}
@@ -145,19 +144,21 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
<InfinityIcon size={14} strokeWidth={2.1} aria-hidden="true" />
{t(release.note.promiseLabelKey)}
</div>
<div className="rn-promise-text">{t(release.note.promiseTextKey)}</div>
<div className="rn-promise-text">
<b>{t(release.note.promiseLeadKey)}</b>{' '}
<span>{t(release.note.promiseTextKey)}</span>
</div>
</div>
{paragraphs(t(release.note.bodyAfterKey)).map((p, i) => <p key={i}>{p}</p>)}
<div className="rn-signoff">
<div className="rn-closing">{t(release.note.closingKey)}</div>
<div className="rn-signature">{t(release.note.signatureKey)}</div>
</div>
<div className="rn-closing">{t(release.note.closingKey)}</div>
</div>
<div className="rn-support">
<div className="rn-support-text">{t(release.supportTextKey)}</div>
<div className="rn-support-text">
<b>{t(release.supportLeadKey)}</b> {t(release.supportTextKey)}
</div>
<div className="rn-support-buttons">
{notice.cta && (
<button type="button"
@@ -165,7 +166,7 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
className="rn-support-btn rn-support-bmc"
onClick={onCTA}
>
<Coffee size={18} strokeWidth={2.1} aria-hidden="true" />
<SteamingCoffee />
{t(notice.cta.labelKey)}
</button>
)}
@@ -175,7 +176,7 @@ export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecond
className="rn-support-btn rn-support-kofi"
onClick={onSecondaryCTA}
>
<Heart size={18} strokeWidth={2.1} aria-hidden="true" />
<Heart className="rn-heart" size={18} strokeWidth={2.1} aria-hidden="true" />
{t(notice.secondaryCta.labelKey)}
</button>
)}
@@ -0,0 +1,85 @@
import React from 'react';
import * as LucideIcons from 'lucide-react';
import { Car, Landmark, Search, Sparkles } from 'lucide-react';
import DawarichIcon from '../shared/DawarichIcon';
import TrekMark from '../shared/TrekMark';
/*
* The small pictures on the release cards: pieces of TREK itself, drawn at card
* size rather than screenshotted, because a screenshot this small is a blur. They
* carry no words, only place names and figures that read the same in every
* language, so nothing here needs translating.
*/
function PlacesApiVisual() {
return (
<div className="rn-vis rn-vis-places">
<span className="rn-vis-trekbadge"><TrekMark className="rn-vis-trekmark" /></span>
<div className="rn-vis-search">
<Search size={11} strokeWidth={2.4} />
<span className="rn-vis-typed">Fushimi Inari</span>
<span className="rn-vis-caret" />
</div>
<div className="rn-vis-result">
<span className="rn-vis-cat" style={{ background: '#ef4444' }}><Landmark size={10} strokeWidth={2.2} /></span>
<span className="rn-vis-place">
<span>Fushimi Inari Taisha</span>
<small>Kyoto</small>
</span>
</div>
</div>
);
}
// The route and its stops, in the planner's own route blue and marker style.
const ROUTE = 'M26 84 C 58 82, 60 44, 96 46 S 136 74, 160 52 S 176 30, 182 28';
function RoadtripVisual() {
return (
<div className="rn-vis rn-vis-road">
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<path className="rn-vis-road-casing" d={ROUTE} />
<path className="rn-vis-road-line" d={ROUTE} />
</svg>
<span className="rn-vis-marker" style={{ left: '13%', top: '75%', background: '#6366f1' }}>1</span>
<span className="rn-vis-marker" style={{ left: '48%', top: '41%', background: '#f59e0b' }}>2</span>
<span className="rn-vis-marker" style={{ left: '91%', top: '25%', background: '#ec4899' }}>3</span>
<span className="rn-vis-distance"><Car size={10} strokeWidth={2.2} />386 km</span>
</div>
);
}
// A recorded day, with the two places the traveller stayed.
const TRAIL = '84,94 96,88 106,80 118,76 130,74 142,66 152,58 162,62 172,66 180,56 186,46';
const STAYS: Array<[number, number]> = [[130, 74], [186, 46]];
function DawarichVisual() {
return (
<div className="rn-vis rn-vis-trail">
<svg viewBox="0 0 200 112" preserveAspectRatio="xMidYMid slice" aria-hidden="true">
<polyline className="rn-vis-trail-line" points={TRAIL} />
{STAYS.map(([x, y]) => <circle key={x} className="rn-vis-trail-stay" cx={x} cy={y} r="3.4" />)}
</svg>
<span className="rn-vis-brand"><DawarichIcon size={58} /></span>
</div>
);
}
const VISUALS: Record<string, () => React.ReactElement> = {
'places-api': PlacesApiVisual,
roadtrip: RoadtripVisual,
dawarich: DawarichVisual,
};
/** The card's picture, or its icon when the release names no drawing this client knows. */
export function ReleaseFeatureVisual({ visual, iconName }: { visual?: string; iconName: string }) {
const Drawing = visual ? VISUALS[visual] : undefined;
if (Drawing) return <Drawing />;
const Icon: React.ElementType =
((LucideIcons as Record<string, unknown>)[iconName] as React.ElementType) ?? Sparkles;
return (
<div className="rn-vis rn-vis-icon">
<Icon size={30} strokeWidth={1.6} aria-hidden="true" />
</div>
);
}
@@ -1,22 +1,18 @@
/*
* The release notice modal — a two-column panel: the release on the left,
* a note from the maintainer on the right.
* The release notice modal: a two-column panel, the release on the left and a
* note from the maintainer on the right.
*
* The measurements here are the design's own, kept in one stylesheet rather
* than spread across inline styles (the same arrangement mobile.css uses for
* the mobile shell). Every one of them is multiplied by --rn-s, so the whole
* panel scales as one piece and the proportions never drift: change that one
* number to resize the modal.
* The measurements are the design's own, at full size, kept in one stylesheet
* rather than spread across inline styles (the same arrangement mobile.css uses
* for the mobile shell). The panel is 1420px wide wherever the screen has room
* and gives up width, never size, where it has not.
*
* The left column is deliberately dark in both themes: it is a brand panel,
* not a surface, so it does not read the surface tokens. Everything on the
* right does.
* The left column is deliberately dark in both themes: it is a brand panel, not
* a surface, so it does not read the surface tokens. Everything on the right
* does. theme-lint-disable: the brand panel's colours are fixed by design.
*/
.rn-overlay {
/* Design is drawn at 1420px wide; anything below full size is this factor. */
--rn-s: 0.72;
position: fixed;
inset: 0;
/* Above the navbar, the page-bound sticky bars and the page-owned
@@ -25,22 +21,13 @@
display: flex;
align-items: center;
justify-content: center;
padding: 28px;
padding: 34px;
background: oklch(0.12 0.01 60 / 0.52);
backdrop-filter: blur(14px) saturate(1.1);
-webkit-backdrop-filter: blur(14px) saturate(1.1);
animation: rn-fade 0.35s ease both;
}
/* Roomy screens can carry a little more of the design's own scale. */
@media (min-width: 1900px) and (min-height: 1150px) {
.rn-overlay { --rn-s: 0.86; }
}
@media (max-width: 1280px), (max-height: 760px) {
.rn-overlay { --rn-s: 0.64; padding: 16px; }
}
@keyframes rn-fade {
from { opacity: 0; }
to { opacity: 1; }
@@ -51,18 +38,30 @@
to { opacity: 1; transform: none; }
}
/*
* The design draws these boxes with their border outside the stated size, the
* browser default. TREK sets border-box globally, which would take 2px off each.
*/
.rn-panel,
.rn-mark,
.rn-feature,
.rn-feature-visual,
.rn-notes,
.rn-close { box-sizing: content-box; }
.rn-panel {
width: calc(1420px * var(--rn-s));
max-width: 100%;
width: min(1420px, calc(100% - 2px));
max-height: 100%;
display: grid;
grid-template-columns: 1.3fr 1fr;
border-radius: calc(34px * var(--rn-s));
grid-template-columns: 1.2fr 1fr;
border-radius: 30px;
overflow: hidden;
box-shadow: 0 4px 12px oklch(0 0 0 / 0.2), 0 60px 130px -40px oklch(0 0 0 / 0.7);
border: 1px solid oklch(1 0 0 / 0.1);
animation: rn-rise 0.5s cubic-bezier(0.22, 1, 0.36, 1) both;
font-family: var(--font-system);
/* The design sets no base line height; the app's 1.5 would add 3px to every label. */
line-height: normal;
}
/* Below the two-column breakpoint the halves stack and the panel scrolls. */
@@ -85,7 +84,7 @@
position: relative;
background: radial-gradient(120% 95% at 18% 0%, #1b1b22 0%, #0e0e12 52%, #08080a 100%);
color: #f5f5f7;
padding: calc(50px * var(--rn-s)) calc(54px * var(--rn-s)) calc(38px * var(--rn-s));
padding: 44px 46px 34px;
display: flex;
flex-direction: column;
overflow: hidden;
@@ -110,14 +109,17 @@
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
.rn-eyebrow-row { display: flex; align-items: center; gap: calc(11px * var(--rn-s)); }
.rn-release-inner::-webkit-scrollbar { display: none; }
.rn-eyebrow-row { display: flex; align-items: center; gap: 11px; }
.rn-mark {
width: calc(32px * var(--rn-s));
height: calc(32px * var(--rn-s));
border-radius: calc(10px * var(--rn-s));
width: 30px;
height: 30px;
border-radius: 10px;
background: oklch(1 0 0 / 0.1);
border: 1px solid oklch(1 0 0 / 0.16);
display: flex;
@@ -126,167 +128,156 @@
flex: none;
}
.rn-mark img {
width: calc(19px * var(--rn-s));
height: calc(19px * var(--rn-s));
display: block;
}
.rn-mark img { width: 18px; height: 18px; display: block; }
.rn-eyebrow {
.rn-eyebrow,
.rn-features-label {
font-family: var(--font-subtext);
font-size: calc(11.5px * var(--rn-s));
font-size: 11px;
line-height: 14px;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
color: oklch(1 0 0 / 0.5);
}
.rn-version-row {
margin-top: calc(30px * var(--rn-s));
display: flex;
align-items: flex-end;
gap: calc(20px * var(--rn-s));
}
.rn-version {
margin-top: 22px;
font-family: var(--font-subtext);
font-size: calc(112px * var(--rn-s));
font-size: 74px;
font-weight: 600;
line-height: 0.82;
line-height: 0.84;
letter-spacing: -0.055em;
color: #fbfbfd;
font-variant-numeric: tabular-nums;
}
.rn-tag {
padding-bottom: calc(11px * var(--rn-s));
font-size: calc(12.5px * var(--rn-s));
font-weight: 500;
letter-spacing: 0.14em;
text-transform: uppercase;
color: oklch(0.84 0.18 85);
}
.rn-headline {
/* CJK breaks anywhere by default; break at phrase boundaries where supported. */
word-break: auto-phrase;
margin: calc(24px * var(--rn-s)) 0 0;
font-size: calc(30px * var(--rn-s));
margin: 20px 0 0;
font-size: 26px;
font-weight: 600;
line-height: 1.22;
line-height: 1.24;
letter-spacing: -0.03em;
max-width: calc(600px * var(--rn-s));
max-width: 560px;
text-wrap: pretty;
}
.rn-intro {
margin: calc(13px * var(--rn-s)) 0 0;
max-width: calc(620px * var(--rn-s));
font-size: calc(15.5px * var(--rn-s));
line-height: 1.62;
margin: 11px 0 0;
max-width: 560px;
font-size: 16.5px;
line-height: 1.6;
color: oklch(1 0 0 / 0.62);
text-wrap: pretty;
}
.rn-features {
margin-top: calc(30px * var(--rn-s));
.rn-features-head {
margin-top: 28px;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
}
.rn-features-label { white-space: nowrap; }
.rn-features-rule { flex: 1; height: 1px; background: oklch(1 0 0 / 0.12); }
.rn-features-aside {
font-size: 13px;
font-weight: 500;
color: oklch(1 0 0 / 0.4);
white-space: nowrap;
}
.rn-features {
margin-top: 14px;
/* Keeps the foot clear of the cards when the column has no height to spare. */
margin-bottom: 26px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
.rn-feature {
position: relative;
display: flex;
gap: calc(18px * var(--rn-s));
padding: calc(16px * var(--rn-s)) 0;
border-top: 1px solid oklch(1 0 0 / 0.09);
flex-direction: column;
gap: 18px;
min-height: 0;
padding: 14px 14px 18px;
border-radius: 20px;
background: linear-gradient(160deg, oklch(1 0 0 / 0.1) 0%, oklch(1 0 0 / 0.045) 62%);
border: 1px solid oklch(1 0 0 / 0.13);
backdrop-filter: blur(16px) saturate(1.2);
-webkit-backdrop-filter: blur(16px) saturate(1.2);
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.16), 0 12px 28px -16px oklch(0 0 0 / 0.7);
overflow: hidden;
}
.rn-feature:last-child { border-bottom: 1px solid oklch(1 0 0 / 0.09); }
.rn-feature-icon {
width: calc(38px * var(--rn-s));
height: calc(38px * var(--rn-s));
flex: none;
border-radius: calc(12px * var(--rn-s));
background: oklch(1 0 0 / 0.07);
border: 1px solid oklch(1 0 0 / 0.1);
display: flex;
align-items: center;
justify-content: center;
color: oklch(0.9 0.16 88);
.rn-feature-shine {
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.4), transparent);
}
.rn-feature-text { min-width: 0; }
.rn-feature-visual {
position: relative;
height: 112px;
border-radius: 14px;
overflow: hidden;
background: oklch(1 0 0 / 0.06);
border: 1px solid oklch(1 0 0 / 0.12);
}
.rn-feature-title {
font-size: calc(16.5px * var(--rn-s));
font-size: 17px;
font-weight: 600;
letter-spacing: -0.01em;
}
.rn-badge {
display: inline-flex;
align-items: center;
margin-left: calc(4px * var(--rn-s));
padding: calc(3px * var(--rn-s)) calc(8px * var(--rn-s));
border-radius: calc(6px * var(--rn-s));
background: oklch(0.84 0.18 85 / 0.16);
border: 1px solid oklch(0.84 0.18 85 / 0.4);
font-family: var(--font-subtext);
font-size: calc(9.5px * var(--rn-s));
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
color: oklch(0.9 0.16 88);
vertical-align: 2px;
line-height: 1.25;
letter-spacing: -0.015em;
text-wrap: pretty;
}
.rn-feature-body {
margin-top: calc(5px * var(--rn-s));
font-size: calc(14.5px * var(--rn-s));
margin-top: 7px;
font-size: 13.5px;
line-height: 1.55;
color: oklch(1 0 0 / 0.58);
color: oklch(1 0 0 / 0.6);
text-wrap: pretty;
}
.rn-release-foot {
margin-top: auto;
padding-top: calc(28px * var(--rn-s));
padding-top: 26px;
display: flex;
align-items: flex-end;
align-items: center;
justify-content: space-between;
gap: calc(24px * var(--rn-s));
flex-wrap: wrap;
gap: 20px;
border-top: 1px solid oklch(1 0 0 / 0.09);
}
.rn-stats { display: flex; gap: calc(34px * var(--rn-s)); }
.rn-stat-value {
font-family: var(--font-subtext);
font-size: calc(20px * var(--rn-s));
font-weight: 600;
letter-spacing: -0.02em;
font-variant-numeric: tabular-nums;
}
.rn-stat-label {
margin-top: calc(3px * var(--rn-s));
font-size: calc(11px * var(--rn-s));
letter-spacing: 0.1em;
text-transform: uppercase;
color: oklch(1 0 0 / 0.42);
.rn-footnote {
font-size: 14.5px;
line-height: 1.55;
color: oklch(1 0 0 / 0.46);
max-width: 360px;
text-wrap: pretty;
}
.rn-notes {
display: inline-flex;
align-items: center;
gap: calc(12px * var(--rn-s));
height: calc(46px * var(--rn-s));
padding: 0 calc(8px * var(--rn-s)) 0 calc(20px * var(--rn-s));
gap: 11px;
height: 44px;
padding: 0 7px 0 18px;
border-radius: 999px;
background: oklch(1 0 0 / 0.07);
border: 1px solid oklch(1 0 0 / 0.14);
color: #f5f5f7;
font-size: calc(14px * var(--rn-s));
font-size: 15px;
font-weight: 600;
white-space: nowrap;
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.1);
@@ -298,10 +289,10 @@
.rn-notes:hover { background: oklch(1 0 0 / 0.12); color: #f5f5f7; }
.rn-notes-arrow {
width: calc(30px * var(--rn-s));
height: calc(30px * var(--rn-s));
width: 30px;
height: 30px;
border-radius: 50%;
background: oklch(0.9 0.16 88);
background: #f5f5f7;
color: #101013;
display: flex;
align-items: center;
@@ -309,24 +300,192 @@
flex: none;
}
.rn-footnote {
margin-top: calc(22px * var(--rn-s));
padding-top: calc(18px * var(--rn-s));
font-size: calc(14px * var(--rn-s));
line-height: 1.6;
color: oklch(1 0 0 / 0.5);
text-wrap: pretty;
/* ── The card pictures ─────────────────────────────────────────────────── */
.rn-vis {
position: absolute;
inset: 0;
color: #f5f5f7;
font-family: var(--font-system);
}
/*
* Its own rule so the footnote only draws a divider when something sits between
* it and the feature list. Without the stats row the list's closing border is
* already right above it, and two lines land on top of each other.
*/
.rn-release-foot + .rn-footnote {
border-top: 1px solid oklch(1 0 0 / 0.09);
.rn-vis-road > svg,
.rn-vis-trail > svg { position: absolute; inset: 0; width: 100%; height: 100%; }
.rn-vis-icon {
display: flex;
align-items: center;
justify-content: center;
color: oklch(1 0 0 / 0.55);
}
/* Places API: the index answering a search while it is typed. */
.rn-vis-places {
padding: 9px 12px;
display: flex;
flex-direction: column;
gap: 6px;
}
/* The same wordmark the API keys card leads with, centred; its pill text inverts against the tile. */
.rn-vis-trekbadge { align-self: center; display: flex; align-items: center; height: 24px; }
.rn-vis-trekmark { display: block; height: 20px; width: auto; color: #f5f5f7; }
.rn-vis-places .trek-mark .pill-fg { fill: #101013; }
.rn-vis-search {
display: flex;
align-items: center;
gap: 6px;
height: 22px;
padding: 0 9px;
border-radius: 8px;
background: oklch(1 0 0 / 0.08);
border: 1px solid oklch(1 0 0 / 0.16);
font-size: 10px;
font-weight: 500;
white-space: nowrap;
}
.rn-vis-search svg { width: 11px; height: 11px; flex: none; color: oklch(1 0 0 / 0.55); }
/* The query types itself out once, then the answer arrives under it. */
.rn-vis-typed {
display: inline-block;
max-width: 90px;
overflow: hidden;
white-space: nowrap;
animation: rn-type 1.3s steps(13, end) 0.7s both;
}
@keyframes rn-type { from { max-width: 0; } to { max-width: 90px; } }
.rn-vis-caret {
width: 1px;
height: 12px;
margin-left: -4px;
background: #f5f5f7;
animation: rn-caret 1s steps(1) infinite;
}
@keyframes rn-caret { 50% { opacity: 0; } }
.rn-vis-result {
display: flex;
align-items: center;
gap: 7px;
padding: 5px 8px;
border-radius: 8px;
background: oklch(1 0 0 / 0.07);
animation: rn-answer 0.3s ease 2.1s both;
}
@keyframes rn-answer { from { opacity: 0; transform: translateY(-3px); } to { opacity: 1; transform: none; } }
.rn-vis-cat {
width: 18px;
height: 18px;
border-radius: 50%;
border: 1.5px solid #ffffff;
color: #ffffff;
display: flex;
align-items: center;
justify-content: center;
flex: none;
box-sizing: border-box;
}
.rn-vis-place { display: flex; flex-direction: column; min-width: 0; line-height: 1.2; }
.rn-vis-place span {
font-size: 10px;
font-weight: 600;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rn-vis-place small { font-size: 8.5px; color: oklch(1 0 0 / 0.5); }
@media (prefers-reduced-motion: reduce) {
.rn-vis-typed, .rn-vis-caret, .rn-vis-result { animation: none; }
}
[data-reduce-motion='true'] .rn-vis-typed,
[data-reduce-motion='true'] .rn-vis-caret,
[data-reduce-motion='true'] .rn-vis-result { animation: none; }
/* Road trip: the drive on the map, in the planner's route blue and marker style. */
.rn-vis-road-casing { fill: none; stroke: #0a5cc2; stroke-width: 5; stroke-linecap: round; stroke-linejoin: round; }
.rn-vis-road-line { fill: none; stroke: #0a84ff; stroke-width: 3; stroke-linecap: round; stroke-linejoin: round; }
.rn-vis-marker {
position: absolute;
width: 18px;
height: 18px;
margin: -9px 0 0 -9px;
border-radius: 50%;
border: 2px solid #ffffff;
box-sizing: border-box;
box-shadow: 0 1px 4px oklch(0 0 0 / 0.35);
color: #ffffff;
font-size: 8.5px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
font-variant-numeric: tabular-nums;
}
.rn-vis-distance {
position: absolute;
right: 9px;
bottom: 8px;
display: flex;
align-items: center;
gap: 4px;
padding: 2px 7px;
border-radius: 99px;
background: #f5f5f7;
color: #101013;
font-size: 9px;
font-weight: 600;
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
/* Dawarich: a recorded day, in the trail colour the map draws it with. */
.rn-vis-trail-line { fill: none; stroke: #2563eb; stroke-width: 2.2; stroke-linecap: round; stroke-linejoin: round; }
.rn-vis-trail-stay { fill: #ffffff; stroke: #2563eb; stroke-width: 2; }
/* The mark itself, unframed, with its corners rounded like an app icon. */
.rn-vis-brand {
position: absolute;
top: 12px;
left: 12px;
width: 58px;
height: 58px;
border-radius: 15px;
overflow: hidden;
box-shadow: 0 4px 14px oklch(0 0 0 / 0.45), 0 0 0 1px oklch(1 0 0 / 0.14);
}
/* A glass sheen over the mark: a soft light from the top and a bright inner edge. */
.rn-vis-brand::after {
content: '';
position: absolute;
inset: 0;
border-radius: inherit;
background: linear-gradient(160deg, oklch(1 0 0 / 0.42) 0%, oklch(1 0 0 / 0.08) 38%, transparent 55%, oklch(0 0 0 / 0.12) 100%);
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.55), inset 0 0 0 1px oklch(1 0 0 / 0.22);
pointer-events: none;
}
.rn-vis-brand img { display: block; width: 100%; height: 100%; }
/* ── Right: the note ───────────────────────────────────────────────────── */
.rn-note {
@@ -340,10 +499,10 @@
.rn-close {
position: absolute;
top: calc(24px * var(--rn-s));
right: calc(24px * var(--rn-s));
width: calc(38px * var(--rn-s));
height: calc(38px * var(--rn-s));
top: 22px;
right: 22px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--bg-hover);
border: 1px solid var(--border-faint);
@@ -361,15 +520,19 @@
.rn-note-body {
flex: 1;
min-height: 0;
padding: calc(50px * var(--rn-s)) calc(52px * var(--rn-s)) calc(30px * var(--rn-s));
padding: 44px 44px 26px;
display: flex;
flex-direction: column;
overflow-y: auto;
scrollbar-width: none;
}
.rn-note-body::-webkit-scrollbar { display: none; }
.rn-note-eyebrow {
font-family: var(--font-subtext);
font-size: calc(11.5px * var(--rn-s));
font-size: 11px;
line-height: 14px;
font-weight: 600;
letter-spacing: 0.2em;
text-transform: uppercase;
@@ -377,103 +540,139 @@
}
.rn-note-title {
/* CJK breaks anywhere by default; break at phrase boundaries where supported. */
word-break: auto-phrase;
margin: calc(16px * var(--rn-s)) 0 0;
font-size: calc(29px * var(--rn-s));
margin: 14px 0 0;
font-size: 29px;
font-weight: 600;
line-height: 1.2;
letter-spacing: -0.03em;
max-width: calc(420px * var(--rn-s));
max-width: 420px;
text-wrap: pretty;
}
.rn-note p {
margin: calc(20px * var(--rn-s)) 0 0;
font-size: calc(15px * var(--rn-s));
line-height: 1.68;
margin: 20px 0 0;
font-size: 16px;
line-height: 1.66;
color: var(--text-secondary);
text-wrap: pretty;
}
.rn-note p + p { margin-top: calc(15px * var(--rn-s)); }
.rn-note p + p { margin-top: 14px; }
/*
* The promise is a pane of glass in the inverse of the note: dark on a light note,
* light on a dark one, with the same sheen and edge light as the release cards.
*/
.rn-promise {
margin-top: calc(24px * var(--rn-s));
padding: calc(20px * var(--rn-s)) calc(22px * var(--rn-s));
border-radius: calc(20px * var(--rn-s));
background: oklch(0.72 0.11 190 / 0.1);
border: 1px solid oklch(0.72 0.11 190 / 0.28);
position: relative;
isolation: isolate;
overflow: hidden;
margin-top: 24px;
padding: 18px 20px;
border-radius: 18px;
background: linear-gradient(
160deg,
color-mix(in oklch, var(--text-primary) 94%, transparent) 0%,
color-mix(in oklch, var(--text-primary) 80%, transparent) 100%
);
border: 1px solid color-mix(in oklch, var(--text-primary) 55%, transparent);
backdrop-filter: blur(16px) saturate(1.3);
-webkit-backdrop-filter: blur(16px) saturate(1.3);
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.2), 0 16px 34px -20px oklch(0 0 0 / 0.6);
color: var(--bg-primary);
}
/* The edge light along the top, as on the cards. */
.rn-promise::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent, oklch(1 0 0 / 0.5), transparent);
z-index: 1;
}
/* A soft light falling in from the top left, and a faint glow where it leaves. */
.rn-promise::after {
content: '';
position: absolute;
inset: 0;
background:
radial-gradient(120% 110% at 0% 0%, oklch(1 0 0 / 0.16) 0%, transparent 55%),
radial-gradient(80% 90% at 100% 100%, oklch(0.84 0.18 85 / 0.12) 0%, transparent 60%);
pointer-events: none;
z-index: -1;
}
.rn-promise-label {
display: flex;
align-items: center;
gap: calc(9px * var(--rn-s));
gap: 9px;
font-family: var(--font-subtext);
font-size: calc(11px * var(--rn-s));
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.16em;
text-transform: uppercase;
color: oklch(0.55 0.09 190);
color: color-mix(in oklch, var(--bg-primary) 55%, transparent);
}
.dark .rn-promise-label { color: oklch(0.78 0.1 190); }
.rn-promise-text {
margin-top: calc(10px * var(--rn-s));
font-size: calc(15.5px * var(--rn-s));
font-weight: 600;
line-height: 1.55;
margin-top: 9px;
font-size: 16.5px;
line-height: 1.5;
letter-spacing: -0.01em;
text-wrap: pretty;
}
.rn-signoff { margin-top: auto; padding-top: calc(26px * var(--rn-s)); }
.rn-promise-text b { font-weight: 600; }
.rn-promise-text span { font-weight: 400; color: color-mix(in oklch, var(--bg-primary) 68%, transparent); }
.rn-closing {
font-size: calc(18px * var(--rn-s));
margin-top: auto;
padding-top: 24px;
font-size: 16.5px;
font-weight: 600;
letter-spacing: -0.02em;
color: var(--text-primary);
}
.rn-signature {
margin-top: calc(8px * var(--rn-s));
font-size: calc(15px * var(--rn-s));
font-weight: 500;
color: var(--text-secondary);
}
.rn-support {
padding: calc(26px * var(--rn-s)) calc(52px * var(--rn-s)) calc(34px * var(--rn-s));
padding: 22px 44px 26px;
border-top: 1px solid var(--border-primary);
background: var(--bg-secondary);
background: var(--bg-hover);
}
.rn-support-text {
font-size: calc(15px * var(--rn-s));
line-height: 1.68;
font-size: 15.5px;
line-height: 1.62;
color: var(--text-secondary);
text-wrap: pretty;
}
.rn-support-text b { font-weight: 600; color: var(--text-primary); }
.rn-support-buttons {
margin-top: calc(16px * var(--rn-s));
margin-top: 14px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: calc(10px * var(--rn-s));
gap: 10px;
}
.rn-support-btn {
display: flex;
align-items: center;
justify-content: center;
gap: calc(9px * var(--rn-s));
height: calc(50px * var(--rn-s));
border-radius: calc(16px * var(--rn-s));
font-size: calc(14.5px * var(--rn-s));
gap: 9px;
height: 50px;
border-radius: 15px;
font-size: 15.5px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
box-shadow: 0 2px 6px oklch(0 0 0 / 0.1);
cursor: pointer;
border: 0;
@@ -482,19 +681,41 @@
.rn-support-btn:hover { filter: brightness(0.96); }
/* Brand colours — theme-lint-disable: Buy Me a Coffee and Ko-fi own these. */
.rn-support-bmc { background: #ffdd00; color: #101013; }
.rn-support-kofi { background: #ff5e5b; color: #ffffff; }
/* The steam lifts off the cup in turn, and the heart beats. */
.rn-coffee { overflow: visible; }
.rn-steam { stroke-width: 1.8; animation: rn-steam 3s ease-in-out infinite; }
.rn-steam:nth-of-type(4) { animation-delay: 0.5s; }
.rn-steam:nth-of-type(5) { animation-delay: 1s; }
@keyframes rn-steam {
0% { opacity: 0; transform: translate(0, 3px); }
25% { opacity: 1; transform: translate(-0.7px, -1px); }
55% { opacity: 0.85; transform: translate(0.7px, -5px); }
100% { opacity: 0; transform: translate(-0.4px, -10px); }
}
.rn-heart { transform-origin: 50% 55%; animation: rn-beat 1.3s ease-in-out infinite; }
@keyframes rn-beat {
0%, 60%, 100% { transform: scale(1); }
15% { transform: scale(1.2); }
30% { transform: scale(1); }
45% { transform: scale(1.12); }
}
@media (prefers-reduced-motion: reduce) {
.rn-steam, .rn-heart { animation: none; }
}
[data-reduce-motion='true'] .rn-steam,
[data-reduce-motion='true'] .rn-heart { animation: none; }
/* Brand colours: Buy Me a Coffee and Ko-fi own these. */
.rn-support-bmc { background: #ffdd00; color: #101013; } /* theme-lint-disable */
.rn-support-kofi { background: #ff5e5b; color: #ffffff; } /* theme-lint-disable */
/* Narrow viewports: keep the proportions, drop the outer padding. */
@media (max-width: 900px) {
.rn-overlay { padding: 0; }
.rn-panel { border-radius: 0; border: 0; max-height: 100%; }
}
/* Icons carry fixed size attributes from lucide; scale them with the panel. */
.rn-feature-icon svg { width: calc(18px * var(--rn-s)); height: calc(18px * var(--rn-s)); }
.rn-notes-arrow svg { width: calc(16px * var(--rn-s)); height: calc(16px * var(--rn-s)); }
.rn-promise-label svg { width: calc(14px * var(--rn-s)); height: calc(14px * var(--rn-s)); flex: none; }
.rn-support-btn svg { width: calc(18px * var(--rn-s)); height: calc(18px * var(--rn-s)); flex: none; }
.rn-close svg { width: calc(18px * var(--rn-s)); height: calc(18px * var(--rn-s)); }