mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-21 14:36:31 +00:00
feat(notices): add the 4.0.0 release modal
The thank-you modal was one column of markdown for every notice. A release needs more room than that: what shipped, and why it took what it took. Adds a `release` block to the notice contract. A notice carrying it renders through ReleaseNoticeModal instead of the generic body — the release on the left, the note from the maintainer on the right, both halves driven entirely by translation keys so the next release only edits the registry entry. Sizing lives on one CSS variable (--rn-s), so the panel scales as a piece and the design's proportions hold at every breakpoint. The thank-you notice gets maxVersion 4.0.0: from there on the release modal carries the same thank-you and the same two support links, and installs still on 3.x keep the old one. All 23 locales.
This commit is contained in:
@@ -0,0 +1,160 @@
|
||||
// FE-RN-001 to FE-RN-011
|
||||
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. */
|
||||
function releaseNotice(overrides: Partial<SystemNoticeDTO> = {}): SystemNoticeDTO {
|
||||
return {
|
||||
id: 'release-4-0-0',
|
||||
display: 'modal',
|
||||
severity: 'info',
|
||||
titleKey: 'rel.headline',
|
||||
bodyKey: 'rel.intro',
|
||||
dismissible: true,
|
||||
desktopOnly: true,
|
||||
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',
|
||||
eyebrowKey: 'rel.eyebrow',
|
||||
tagKey: 'rel.tag',
|
||||
headlineKey: 'rel.headline',
|
||||
introKey: 'rel.intro',
|
||||
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' },
|
||||
],
|
||||
note: {
|
||||
eyebrowKey: 'rel.note.eyebrow',
|
||||
titleKey: 'rel.note.title',
|
||||
bodyKey: 'rel.note.body',
|
||||
promiseLabelKey: 'rel.promise.label',
|
||||
promiseTextKey: 'rel.promise.text',
|
||||
bodyAfterKey: 'rel.note.after',
|
||||
closingKey: 'rel.note.closing',
|
||||
signatureKey: 'rel.note.signature',
|
||||
},
|
||||
supportTextKey: 'rel.support',
|
||||
},
|
||||
...overrides,
|
||||
} as SystemNoticeDTO
|
||||
}
|
||||
|
||||
function renderModal(notice = releaseNotice(), handlers: Partial<{
|
||||
onDismiss: () => void; onCTA: () => void; onSecondaryCTA: () => void
|
||||
}> = {}) {
|
||||
const onDismiss = handlers.onDismiss ?? vi.fn()
|
||||
const onCTA = handlers.onCTA ?? vi.fn()
|
||||
const onSecondaryCTA = handlers.onSecondaryCTA ?? vi.fn()
|
||||
const view = render(
|
||||
<ReleaseNoticeModal
|
||||
notice={notice}
|
||||
visible
|
||||
onDismiss={onDismiss}
|
||||
onCTA={onCTA}
|
||||
onSecondaryCTA={onSecondaryCTA}
|
||||
/>
|
||||
)
|
||||
return { ...view, onDismiss, onCTA, onSecondaryCTA }
|
||||
}
|
||||
|
||||
describe('ReleaseNoticeModal', () => {
|
||||
it('FE-RN-001: renders nothing for a notice without a release block', () => {
|
||||
const { container } = renderModal(releaseNotice({ release: undefined }))
|
||||
expect(container.querySelector('.rn-overlay')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-002: shows the version as a plain figure, not a translation key', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('4.0.0')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RN-003: renders one row per feature, with the badge only where declared', () => {
|
||||
renderModal()
|
||||
expect(screen.getByText('rel.f1.title')).toBeInTheDocument()
|
||||
expect(screen.getByText('rel.f2.badge')).toBeInTheDocument()
|
||||
expect(document.querySelectorAll('.rn-feature')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('FE-RN-004: splits the note body on the blank line into separate paragraphs', () => {
|
||||
const n = releaseNotice()
|
||||
// The translation stub echoes the key, so drive the split through a real value.
|
||||
n.release!.note.bodyKey = 'first paragraph\n\nsecond paragraph'
|
||||
renderModal(n)
|
||||
expect(screen.getByText('first paragraph')).toBeInTheDocument()
|
||||
expect(screen.getByText('second paragraph')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('FE-RN-005: omits the stats row when the release carries no stats', () => {
|
||||
renderModal()
|
||||
expect(document.querySelector('.rn-release-foot')).toBeNull()
|
||||
})
|
||||
|
||||
it('FE-RN-006: shows the stats row and the notes link when both are present', () => {
|
||||
const n = releaseNotice()
|
||||
n.release!.stats = [{ value: '~150', labelKey: 'rel.stat.bugs' }]
|
||||
n.release!.notes = { labelKey: 'rel.notes', href: 'https://example.test/notes' }
|
||||
renderModal(n)
|
||||
expect(screen.getByText('~150')).toBeInTheDocument()
|
||||
const link = screen.getByRole('link', { name: /rel\.notes/ })
|
||||
expect(link).toHaveAttribute('href', 'https://example.test/notes')
|
||||
expect(link).toHaveAttribute('target', '_blank')
|
||||
expect(link).toHaveAttribute('rel', 'noopener noreferrer')
|
||||
})
|
||||
|
||||
it('FE-RN-007: dismisses from the close button and from the backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice(), { onDismiss })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Close' }))
|
||||
expect(onDismiss).toHaveBeenCalledTimes(1)
|
||||
|
||||
await user.click(document.querySelector('.rn-overlay')!)
|
||||
expect(onDismiss).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('FE-RN-008: a click inside the panel does not dismiss', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice(), { onDismiss })
|
||||
await user.click(screen.getByText('rel.note.title'))
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-RN-009: a non-dismissible notice has no close button and an inert backdrop', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onDismiss = vi.fn()
|
||||
renderModal(releaseNotice({ dismissible: false }), { onDismiss })
|
||||
expect(screen.queryByRole('button', { name: 'Close' })).toBeNull()
|
||||
await user.click(document.querySelector('.rn-overlay')!)
|
||||
expect(onDismiss).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('FE-RN-010: wires each support button to its own handler', async () => {
|
||||
const user = userEvent.setup()
|
||||
const onCTA = vi.fn()
|
||||
const onSecondaryCTA = vi.fn()
|
||||
renderModal(releaseNotice(), { onCTA, onSecondaryCTA })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /rel\.bmc/ }))
|
||||
expect(onCTA).toHaveBeenCalledTimes(1)
|
||||
expect(onSecondaryCTA).not.toHaveBeenCalled()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /rel\.kofi/ }))
|
||||
expect(onSecondaryCTA).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('FE-RN-011: labels the dialog with the headline and describes it with the intro', () => {
|
||||
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')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
import React from 'react';
|
||||
import * as LucideIcons from 'lucide-react';
|
||||
import { ArrowRight, Coffee, Heart, Infinity as InfinityIcon, Sparkles, X } from 'lucide-react';
|
||||
import { useTranslation } from '../../i18n/TranslationContext.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import './releaseNotice.css';
|
||||
|
||||
interface Props {
|
||||
notice: SystemNoticeDTO;
|
||||
visible: boolean;
|
||||
onDismiss: () => void;
|
||||
onCTA: () => void;
|
||||
onSecondaryCTA: () => void;
|
||||
}
|
||||
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Desktop only (the notices carrying it set `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.
|
||||
*/
|
||||
export function ReleaseNoticeModal({ notice, visible, onDismiss, onCTA, onSecondaryCTA }: Props) {
|
||||
const { t } = useTranslation();
|
||||
const release = notice.release;
|
||||
if (!release) return null;
|
||||
|
||||
const titleId = `notice-title-${notice.id}`;
|
||||
const bodyId = `notice-body-${notice.id}`;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="rn-overlay"
|
||||
role="presentation"
|
||||
style={{ opacity: visible ? 1 : 0, transition: 'opacity 260ms ease' }}
|
||||
onClick={notice.dismissible ? onDismiss : undefined}
|
||||
>
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
aria-describedby={bodyId}
|
||||
className="rn-panel"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{/* ── Left: the release ─────────────────────────────────────────── */}
|
||||
<div className="rn-release">
|
||||
<div className="rn-release-grain" aria-hidden="true" />
|
||||
|
||||
<div className="rn-release-inner">
|
||||
<div className="rn-eyebrow-row">
|
||||
<span className="rn-mark">
|
||||
<img src="/icons/icon-white.svg" alt="" aria-hidden="true" />
|
||||
</span>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
{((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>
|
||||
)}
|
||||
|
||||
{release.notes && (
|
||||
<a
|
||||
className="rn-notes"
|
||||
href={release.notes.href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t(release.notes.labelKey)}
|
||||
<span className="rn-notes-arrow">
|
||||
<ArrowRight size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
</span>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{release.footnoteKey && (
|
||||
<div className="rn-footnote">{t(release.footnoteKey)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Right: the note ───────────────────────────────────────────── */}
|
||||
<div className="rn-note">
|
||||
{notice.dismissible && (
|
||||
<button className="rn-close" onClick={onDismiss} aria-label={t('common.close')}>
|
||||
<X size={18} strokeWidth={2} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<div className="rn-note-body">
|
||||
<div className="rn-note-eyebrow">{t(release.note.eyebrowKey)}</div>
|
||||
<h3 className="rn-note-title">{t(release.note.titleKey)}</h3>
|
||||
|
||||
{paragraphs(t(release.note.bodyKey)).map((p, i) => <p key={i}>{p}</p>)}
|
||||
|
||||
<div className="rn-promise">
|
||||
<div className="rn-promise-label">
|
||||
<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>
|
||||
|
||||
{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>
|
||||
|
||||
<div className="rn-support">
|
||||
<div className="rn-support-text">{t(release.supportTextKey)}</div>
|
||||
<div className="rn-support-buttons">
|
||||
{notice.cta && (
|
||||
<button
|
||||
id={`notice-cta-${notice.id}`}
|
||||
className="rn-support-btn rn-support-bmc"
|
||||
onClick={onCTA}
|
||||
>
|
||||
<Coffee size={18} strokeWidth={2.1} aria-hidden="true" />
|
||||
{t(notice.cta.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
{notice.secondaryCta && (
|
||||
<button
|
||||
id={`notice-cta2-${notice.id}`}
|
||||
className="rn-support-btn rn-support-kofi"
|
||||
onClick={onSecondaryCTA}
|
||||
>
|
||||
<Heart size={18} strokeWidth={2.1} aria-hidden="true" />
|
||||
{t(notice.secondaryCta.labelKey)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useSystemNoticeStore } from '../../store/systemNoticeStore.js';
|
||||
import type { SystemNoticeDTO } from '../../store/systemNoticeStore.js';
|
||||
import { useTranslation, isRtlLanguage } from '../../i18n/index.js';
|
||||
import { runNoticeAction } from './noticeActions.js';
|
||||
import { ReleaseNoticeModal } from './ReleaseNoticeModal.js';
|
||||
import { lockBodyScroll } from '../../utils/bodyScrollLock.js';
|
||||
|
||||
const ReactMarkdown = React.lazy(() =>
|
||||
@@ -864,5 +865,18 @@ export function ModalRenderer({ notices }: Props) {
|
||||
const S = useSystemNoticeModal(notices);
|
||||
// No notice to show
|
||||
if (!S.notice) return null;
|
||||
// A notice carrying `release` draws its own two-column panel, but keeps this
|
||||
// hook's ESC handling, scroll lock and dismissal.
|
||||
if (S.notice.release && !S.isMobile) {
|
||||
return (
|
||||
<ReleaseNoticeModal
|
||||
notice={S.notice}
|
||||
visible={S.visible}
|
||||
onDismiss={S.handleDismissAll}
|
||||
onCTA={S.handleCTA}
|
||||
onSecondaryCTA={S.handleSecondaryCTA}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return S.isMobile ? <MobileNoticeSheet {...S} /> : <DesktopNoticeModal {...S} />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,498 @@
|
||||
/*
|
||||
* The release notice modal — a two-column panel: the release on the left,
|
||||
* 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 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.
|
||||
*/
|
||||
|
||||
.rn-overlay {
|
||||
/* Design is drawn at 1420px wide; anything below full size is this factor. */
|
||||
--rn-s: 0.72;
|
||||
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28px;
|
||||
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; }
|
||||
}
|
||||
|
||||
@keyframes rn-rise {
|
||||
from { opacity: 0; transform: translateY(16px) scale(0.985); }
|
||||
to { opacity: 1; transform: none; }
|
||||
}
|
||||
|
||||
.rn-panel {
|
||||
width: calc(1420px * var(--rn-s));
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
display: grid;
|
||||
grid-template-columns: 1.3fr 1fr;
|
||||
border-radius: calc(34px * var(--rn-s));
|
||||
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);
|
||||
}
|
||||
|
||||
/* Below the two-column breakpoint the halves stack and the panel scrolls. */
|
||||
@media (max-width: 1080px) {
|
||||
.rn-panel { grid-template-columns: 1fr; overflow-y: auto; }
|
||||
}
|
||||
|
||||
[data-reduce-motion='true'] .rn-overlay,
|
||||
[data-reduce-motion='true'] .rn-panel {
|
||||
animation-duration: 0.01ms;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.rn-overlay, .rn-panel { animation-duration: 0.01ms; }
|
||||
}
|
||||
|
||||
/* ── Left: the release ─────────────────────────────────────────────────── */
|
||||
|
||||
.rn-release {
|
||||
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));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
/* The dot field, faded out towards the lower right. */
|
||||
.rn-release-grain {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background-image: radial-gradient(circle, oklch(1 0 0 / 0.1) 1px, transparent 1.3px);
|
||||
background-size: 24px 24px;
|
||||
-webkit-mask-image: radial-gradient(90% 70% at 25% 12%, #000 0%, transparent 78%);
|
||||
mask-image: radial-gradient(90% 70% at 25% 12%, #000 0%, transparent 78%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.rn-release-inner {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rn-eyebrow-row { display: flex; align-items: center; gap: calc(11px * var(--rn-s)); }
|
||||
|
||||
.rn-mark {
|
||||
width: calc(32px * var(--rn-s));
|
||||
height: calc(32px * var(--rn-s));
|
||||
border-radius: calc(10px * var(--rn-s));
|
||||
background: oklch(1 0 0 / 0.1);
|
||||
border: 1px solid oklch(1 0 0 / 0.16);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.rn-mark img {
|
||||
width: calc(19px * var(--rn-s));
|
||||
height: calc(19px * var(--rn-s));
|
||||
display: block;
|
||||
}
|
||||
|
||||
.rn-eyebrow {
|
||||
font-family: var(--font-subtext);
|
||||
font-size: calc(11.5px * var(--rn-s));
|
||||
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 {
|
||||
font-family: var(--font-subtext);
|
||||
font-size: calc(112px * var(--rn-s));
|
||||
font-weight: 600;
|
||||
line-height: 0.82;
|
||||
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 {
|
||||
word-break: auto-phrase;
|
||||
margin: calc(24px * var(--rn-s)) 0 0;
|
||||
font-size: calc(30px * var(--rn-s));
|
||||
font-weight: 600;
|
||||
line-height: 1.22;
|
||||
letter-spacing: -0.03em;
|
||||
max-width: calc(600px * var(--rn-s));
|
||||
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;
|
||||
color: oklch(1 0 0 / 0.62);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.rn-features {
|
||||
margin-top: calc(30px * var(--rn-s));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.rn-feature {
|
||||
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);
|
||||
}
|
||||
|
||||
.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-text { min-width: 0; }
|
||||
|
||||
.rn-feature-title {
|
||||
font-size: calc(16.5px * var(--rn-s));
|
||||
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;
|
||||
}
|
||||
|
||||
.rn-feature-body {
|
||||
margin-top: calc(5px * var(--rn-s));
|
||||
font-size: calc(14.5px * var(--rn-s));
|
||||
line-height: 1.55;
|
||||
color: oklch(1 0 0 / 0.58);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.rn-release-foot {
|
||||
margin-top: auto;
|
||||
padding-top: calc(28px * var(--rn-s));
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: calc(24px * var(--rn-s));
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.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-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));
|
||||
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-weight: 600;
|
||||
white-space: nowrap;
|
||||
box-shadow: inset 0 1px 0 oklch(1 0 0 / 0.1);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.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));
|
||||
border-radius: 50%;
|
||||
background: oklch(0.9 0.16 88);
|
||||
color: #101013;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
* 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);
|
||||
}
|
||||
|
||||
/* ── Right: the note ───────────────────────────────────────────────────── */
|
||||
|
||||
.rn-note {
|
||||
position: relative;
|
||||
background: var(--bg-card);
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.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));
|
||||
border-radius: 50%;
|
||||
background: var(--bg-hover);
|
||||
border: 1px solid var(--border-faint);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-secondary);
|
||||
z-index: 2;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
.rn-close:hover { background: var(--bg-selected); color: var(--text-primary); }
|
||||
|
||||
.rn-note-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: calc(50px * var(--rn-s)) calc(52px * var(--rn-s)) calc(30px * var(--rn-s));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.rn-note-eyebrow {
|
||||
font-family: var(--font-subtext);
|
||||
font-size: calc(11.5px * var(--rn-s));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.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));
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
letter-spacing: -0.03em;
|
||||
max-width: calc(420px * var(--rn-s));
|
||||
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;
|
||||
color: var(--text-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.rn-note p + p { margin-top: calc(15px * var(--rn-s)); }
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.rn-promise-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: calc(9px * var(--rn-s));
|
||||
font-family: var(--font-subtext);
|
||||
font-size: calc(11px * var(--rn-s));
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
color: oklch(0.55 0.09 190);
|
||||
}
|
||||
|
||||
.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;
|
||||
letter-spacing: -0.01em;
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.rn-signoff { margin-top: auto; padding-top: calc(26px * var(--rn-s)); }
|
||||
|
||||
.rn-closing {
|
||||
font-size: calc(18px * var(--rn-s));
|
||||
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));
|
||||
border-top: 1px solid var(--border-primary);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.rn-support-text {
|
||||
font-size: calc(15px * var(--rn-s));
|
||||
line-height: 1.68;
|
||||
color: var(--text-secondary);
|
||||
text-wrap: pretty;
|
||||
}
|
||||
|
||||
.rn-support-buttons {
|
||||
margin-top: calc(16px * var(--rn-s));
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: calc(10px * var(--rn-s));
|
||||
}
|
||||
|
||||
.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));
|
||||
font-weight: 600;
|
||||
box-shadow: 0 2px 6px oklch(0 0 0 / 0.1);
|
||||
cursor: pointer;
|
||||
border: 0;
|
||||
transition: filter 0.15s ease;
|
||||
}
|
||||
|
||||
.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; }
|
||||
|
||||
/* 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)); }
|
||||
Reference in New Issue
Block a user