'use client'
/*
* Reduced-motion preference, hydration-safe: the server and the hydrating render both read
* `false`, then React re-renders with the real value, so server HTML and the first client
* render always agree.
*/
const REDUCED_QUERY = '(prefers-reduced-motion: reduce)'
function subscribeReduced(onChange: () => void) {
const mq = window.matchMedia(REDUCED_QUERY)
mq.addEventListener('change', onChange)
return () => mq.removeEventListener('change', onChange)
}
const getReduced = () => window.matchMedia(REDUCED_QUERY).matches
const getServerReduced = () => false
/**
* Changelog — "What's new" that knows what you haven't read.
*
* Releases hang off a rail: a plain 1px line with a node per version
* (`rail="line"`, default), or a measuring tape with versions as the major
* ticks (`rail="tape"`). It remembers each visitor: anything new since their last visit carries an
* unread dot, and a "Since your last visit" line (like the "new messages" line
* in a chat) separates new from old. Linger on a release and its dot fades;
* "Mark all as read" clears the rest in a sweep down the list while the count
* rolls to zero. Tag chips filter the list and the rest fold away.
*
* Functional:
* - per-visitor read state in localStorage: the set of release ids seen, so
* removing or renaming old releases can never make others look unread
* - "Since your last visit" dates from the start of the previous visit
* (stored apart from read updates, fixed for the browser session)
* - releases can be added any time; new ones show up unread automatically
* - tag filters with counts (case-insensitive: "New" and "new" are one
* tag), anchor link per release, locale dates
* - every UI string overridable through `labels`
* - date-only strings ("2026-09-22") are calendar days: formatted in UTC,
* so no time zone ever shows the day before, and server and browser agree
* - releases may arrive async: read state waits until there are releases,
* so `initialSeen` still decides what a first visit sees as unread
* - worst-case safe: long versions truncate (full text in the tooltip), long
* titles, URLs and hashes wrap, `<pre>` scrolls; many tags show the first
* `maxFilters` chips plus "N more"; duplicate-case tags on one release show
* once; invalid dates ("2026-02-29", "not a date") are left out with a dev
* warning; an empty `releases` shows `labels.empty`
* - narrow layout (tighter rail, icon-only "Mark all") on screens up to 560px
* and wherever the component itself is 440px wide or less (container query)
*
* SSR: dates use the visitor's locale by default; pass `locale` when rendering
* on the server so the markup matches on hydration.
*
* Accessible: an ordered list of articles with headings, unread state spoken,
* filters are pressed-state buttons, count updates announced politely.
* Reduced motion: dots and entries simply appear and disappear.
*
* Corners follow --ui-radius-sm, --ui-radius-pill (see Theming → Corners).
* Theme via library tokens (--ui-ink, --ui-surface, --ui-accent) or
* --changelog-*.
*
* Requires: react >= 19, motion >= 11.
*/
import { AnimatePresence, motion } from 'motion/react'
import {
createElement,
useEffect,
useId,
useLayoutEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type CSSProperties,
type ReactNode,
} from 'react'
/* Icons: Iconoir (MIT, © 2021 Luca Burgio, iconoir.com), inlined so the component stays dependency-free. */
const UI_ICONS = {
'view-grid': [['path', { d: 'M14 20.4V14.6C14 14.2686 14.2686 14 14.6 14H20.4C20.7314 14 21 14.2686 21 14.6V20.4C21 20.7314 20.7314 21 20.4 21H14.6C14.2686 21 14 20.7314 14 20.4Z' }], ['path', { d: 'M3 20.4V14.6C3 14.2686 3.26863 14 3.6 14H9.4C9.73137 14 10 14.2686 10 14.6V20.4C10 20.7314 9.73137 21 9.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z' }], ['path', { d: 'M14 9.4V3.6C14 3.26863 14.2686 3 14.6 3H20.4C20.7314 3 21 3.26863 21 3.6V9.4C21 9.73137 20.7314 10 20.4 10H14.6C14.2686 10 14 9.73137 14 9.4Z' }], ['path', { d: 'M3 9.4V3.6C3 3.26863 3.26863 3 3.6 3H9.4C9.73137 3 10 3.26863 10 3.6V9.4C10 9.73137 9.73137 10 9.4 10H3.6C3.26863 10 3 9.73137 3 9.4Z' }]],
'sparks': [['path', { d: 'M8 15C12.8747 15 15 12.949 15 8C15 12.949 17.1104 15 22 15C17.1104 15 15 17.1104 15 22C15 17.1104 12.8747 15 8 15Z' }], ['path', { d: 'M2 6.5C5.13376 6.5 6.5 5.18153 6.5 2C6.5 5.18153 7.85669 6.5 11 6.5C7.85669 6.5 6.5 7.85669 6.5 11C6.5 7.85669 5.13376 6.5 2 6.5Z' }]],
'graph-up': [['path', { d: 'M20 20H4V4' }], ['path', { d: 'M4 16.5L12 9L15 12L19.5 7.5' }]],
'tools': [['path', { d: 'M10.0503 10.6066L2.97923 17.6777C2.19818 18.4587 2.19818 19.7251 2.97923 20.5061V20.5061C3.76027 21.2872 5.0266 21.2872 5.80765 20.5061L12.8787 13.4351' }], ['path', { d: 'M17.1927 13.7994L21.071 17.6777C21.8521 18.4587 21.8521 19.7251 21.071 20.5061V20.5061C20.29 21.2872 19.0236 21.2872 18.2426 20.5061L12.0341 14.2977' }], ['path', { d: 'M6.73267 5.90381L4.61135 6.61092L2.49003 3.07539L3.90424 1.66117L7.43978 3.78249L6.73267 5.90381ZM6.73267 5.90381L9.5629 8.73404' }], ['path', { d: 'M10.0503 10.6066C9.2065 8.45359 9.37147 5.62861 11.111 3.8891C12.8505 2.14958 16.0607 1.76778 17.8285 2.82844L14.7878 5.86911L14.5052 8.98015L17.6162 8.69754L20.6569 5.65686C21.7176 7.42463 21.3358 10.6349 19.5963 12.3744C17.8567 14.1139 15.0318 14.2789 12.8788 13.435' }]],
'label': [['path', { d: 'M3 17.4V6.6C3 6.26863 3.26863 6 3.6 6H16.6789C16.8795 6 17.0668 6.10026 17.1781 6.26718L20.7781 11.6672C20.9125 11.8687 20.9125 12.1313 20.7781 12.3328L17.1781 17.7328C17.0668 17.8997 16.8795 18 16.6789 18H3.6C3.26863 18 3 17.7314 3 17.4Z' }]],
'double-check': [['path', { d: 'M1.5 12.5L5.57574 16.5757C5.81005 16.8101 6.18995 16.8101 6.42426 16.5757L9 14' }], ['path', { d: 'M16 7L12 11' }], ['path', { d: 'M7 12L11.5757 16.5757C11.8101 16.8101 12.1899 16.8101 12.4243 16.5757L22 7' }]],
'page': [['path', { d: 'M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z' }], ['path', { d: 'M8 10L16 10' }], ['path', { d: 'M8 18L16 18' }], ['path', { d: 'M8 14L12 14' }], ['path', { d: 'M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20' }]],
'nav-arrow-down': [['path', { d: 'M6 9L12 15L18 9' }]],
'check-circle': [['path', { d: 'M7 12.5L10 15.5L17 8.5' }], ['path', { d: 'M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z' }]],
} as const satisfies Record<string, readonly (readonly [string, Record<string, string>])[]>
type UiIconName = keyof typeof UI_ICONS
function UiIcon({ name, size = 16, className }: { name: UiIconName; size?: number; className?: string }) {
return (
<svg className={['ui-icon', className].filter(Boolean).join(' ')} viewBox="0 0 24 24" width={size} height={size} fill="none" stroke="currentColor" strokeWidth={1.6} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
{UI_ICONS[name].map(([tag, attrs], i) => createElement(tag, { key: i, ...attrs }))}
</svg>
)
}
/** One release in the list. */
export type Release = {
/**
* Shown as `v` + version. A version that already starts with `v` or `V` (`'v2.4.0'`) is shown as
* given. Version string, shown as "v" + version ("v" is only added when it doesn't already start
* with v / V).
*/
version: string
/**
* ISO date. A plain `YYYY-MM-DD` is a calendar day, formatted in UTC so it never shows as the day
* before. A full timestamp is shown in the visitor's time zone. A date that doesn't exist
* (`'2026-02-29'`) or can't be parsed is left out, with a one-time console warning in
* development. Years before 100 (`'0001-01-01'`) are kept as written. A plain "YYYY-MM-DD" is
* treated as a calendar day (no time zone shift). Invalid dates are left out (dev warning).
*/
date: string
/** Release heading. */
title: string
/**
* Tag chips and filters. `New`, `Improved` and `Fixed` (any case) get their own colour and icon;
* other tags get a neutral chip. Filters are case-insensitive: `New` and `new` are one chip
* (labelled with the first spelling in the list) and one count. Case-insensitive duplicates on
* one release (`['fixed', 'FIXED']`) show once; empty tags are ignored. A long tag is truncated
* with the full text as a tooltip.
*/
tags?: string[]
/** Release notes, rendered as given (paragraphs, lists…). */
body?: ReactNode
/**
* Anchor + storage id. Defaults to "v" + version (minus any leading v / V) with every run of
* non-alphanumerics turned into "-" ("2.3.0" and "v2.3.0" → "v2-3-0"). `2.3.0` and `v2.3.0` both
* become `v2-3-0`. Must be unique within one changelog.
*
* @default `"v"` + version (minus a leading `v`), non-alphanumerics → `-`
*/
id?: string
}
/** Props for {@link Changelog}. */
export type ChangelogProps = {
/** Newest first. Releases can be added at any time; new ones show up unread. */
releases: Release[]
/**
* localStorage key. The value is `{ seen: string[], at: number, visit: number }`: the ids read,
* when that list was last saved, and when the latest visit started. `sessionStorage` key ``
* `${storageKey}:last-visit` `` pins the previous visit's date for the rest of the browser
* session, so reloads don't move the divider. localStorage key for the visitor's read state.
*
* @default 'changelog'
*/
storageKey?: string
/**
* Release version (`'2.3.0'` or `'v2.3.0'`) or id (`'v2-3-0'`). On a first visit, releases newer
* than it start unread; it and older ones are seen. A value matching no release makes every
* release unread. Accepts the release id / slug ("v2-3-0") or its version ("2.3.0", "v2.3.0").
* Default: only the newest is unread.
*
* @default only the newest is unread
*/
initialSeen?: string
/**
* Prepended to each release's anchor id and link (`#${anchorPrefix}${id}`). Leave empty for plain
* deep links (`#v2-3-0`); set it when two changelogs on one page share release ids.
* @default ''
*/
anchorPrefix?: string
/**
* UI strings. See [ChangelogLabels](#changeloglabels). English defaults; see `ChangelogLabels`.
*
* @default English
*/
labels?: Partial<ChangelogLabels>
/**
* ms a release must stay on screen (60% visible) to count as read. ms a release must stay at
* least 60% visible to be marked read.
*
* @default 2500
*/
dwell?: number
/**
* BCP 47 locale for dates. Defaults to the visitor's locale; pass it when rendering on the
* server.
*
* @default visitor's locale
*/
locale?: string
/**
* Show tag filter chips with counts. Hidden when no release has tags.
*
* @default true
*/
filters?: boolean
/**
* Tag chips shown before the rest fold behind an "N more" chip (`labels.moreFilters`), which
* expands them all and becomes "Fewer". The "All" chip and the active tag are always shown.
* Order: New, Improved, Fixed, then by number of releases, then alphabetically. New, Improved and
* Fixed come first, then by release count.
*
* @default 8
*/
maxFilters?: number
/**
* `'tape'`: a measuring-tape rail with versions as major ticks. `'line'`: a 1px rail with a node
* per release.
*
* @default 'tape'
*/
rail?: 'line' | 'tape'
/** Extra class on the root `<section class="cl-root">`. */
className?: string
/** Inline styles on the root. Use it to set the `--changelog-*` variables. */
style?: CSSProperties
}
/** Every fixed UI string. */
export type ChangelogLabels = {
/** Accessible name of the root `<section>`. @default 'Changelog' */
region: string
/** After the unread count: "3 new since your last visit". @default 'new since your last visit' */
newSince: string
/** Status line with nothing unread. @default 'You’re all caught up' */
caughtUp: string
/** "Mark all as read" button (label and tooltip). @default 'Mark all as read' */
markAll: string
/** Accessible name of the filter chip group. @default 'Filter releases' */
filters: string
/** First filter chip. @default 'All' */
all: string
/** Spoken before an unread release's title. @default 'Unread:' */
unread: string
/** Heading of the divider above the releases from before the last visit (the last-visit date follows it). @default 'Before your last visit' */
sinceLastVisit: string
/** Chip under the version of each release that came out since the last visit. @default 'New to you' */
newToYou: string
/** Chip that reveals the folded tag filters. @default n => `${n} more` */
moreFilters: (hidden: number) => string
/** Chip that folds the extra tag filters again. @default 'Fewer' */
fewerFilters: string
/** Shown instead of the list when `releases` is empty. @default 'No releases yet' */
empty: string
/**
* Display text of a tag, e.g. t => ({ New: 'جديد', Improved: 'محسّن' })[t] ?? t. Translates the
* chip without changing its colour, icon or filter (those follow the tag as written in `releases`).
* @default t => t
*/
tag: (tag: string) => string
}
const DEFAULT_LABELS: ChangelogLabels = {
region: 'Changelog',
newSince: 'new since your last visit',
caughtUp: 'You’re all caught up',
markAll: 'Mark all as read',
filters: 'Filter releases',
all: 'All',
unread: 'Unread:',
sinceLastVisit: 'Before your last visit',
newToYou: 'New to you',
moreFilters: n => `${n} more`,
fewerFilters: 'Fewer',
empty: 'No releases yet',
tag: t => t,
}
/* stored as { seen, at, visit }: the ids read, when that list was last saved, and when the latest visit started */
type ReadState = { seen: string[] }
type Stored = ReadState & { at?: number; visit?: number }
const EASE_OUT = [0.23, 1, 0.32, 1] as const
const stripV = (version: string) => version.replace(/^v(?=\d|$)/i, '')
const slug = (version: string) => version.replace(/[^a-zA-Z0-9]+/g, '-')
const idOf = (r: Release) => r.id ?? `v${slug(stripV(r.version))}`
/** "v" + version, unless the version already carries it. */
const versionLabel = (version: string) => (/^v/i.test(version) ? version : `v${version}`)
/** Other names a release answers to: its version (with or without "v") and the pre-fix id "vv2-3-0". Lowercased. */
const aliasesOf = (r: Release) =>
[r.version, stripV(r.version), `v${stripV(r.version)}`, r.id ? '' : `v${slug(r.version)}`].filter(Boolean).map(a => a.toLowerCase())
const TAG_TONE: Record<string, string> = { new: 'new', improved: 'improved', fixed: 'fixed' }
const TAG_ICON: Record<string, UiIconName> = { new: 'sparks', improved: 'graph-up', fixed: 'tools' }
const tagIcon = (t: string): UiIconName => TAG_ICON[t.toLowerCase()] ?? 'label'
const DAY = /^(\d{4})-(\d{2})-(\d{2})$/
// `process.env.NODE_ENV` is replaced at build time by most bundlers; where it
// isn't and `process` doesn't exist, warn anyway (each warning fires once)
declare const process: { env: Record<string, string | undefined> }
const warned = new Set<string>()
function devWarn(message: string) {
let dev = true
try {
dev = process.env.NODE_ENV !== 'production'
} catch {
/* no process: treat as development */
}
if (!dev || warned.has(message)) return
warned.add(message)
console.warn(message)
}
/**
* A calendar day ("2026-09-22") stays that day everywhere; full timestamps use the visitor's zone.
* Returns null for a date that doesn't exist ("2026-02-29") or can't be parsed.
*/
function formatDate(s: string, day: Intl.DateTimeFormat, local: Intl.DateTimeFormat): string | null {
const m = DAY.exec(s)
if (m) {
const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])]
// setUTCFullYear, not Date.UTC: Date.UTC maps years 0–99 to 1900–1999
const date = new Date(0)
date.setUTCFullYear(y, mo - 1, d)
if (date.getUTCFullYear() === y && date.getUTCMonth() === mo - 1 && date.getUTCDate() === d) return day.format(date)
} else {
const date = new Date(s)
if (!Number.isNaN(date.getTime())) return local.format(date)
}
devWarn(`Changelog: release date "${s}" is not a valid date; it is left out.`)
return null
}
/** Tags with case-insensitive duplicates removed (first spelling wins). */
const uniqueTags = (tags: string[] | undefined) => {
const seen = new Set<string>()
return (tags ?? []).filter(t => {
const k = t.toLowerCase()
if (!t.trim() || seen.has(k)) return false
seen.add(k)
return true
})
}
function Roll({ value, reduced }: { value: number; reduced: boolean }) {
const digits = String(Math.max(0, value)).split('')
return (
<span className="cl-roll" aria-hidden>
{digits.map((d, i) => (
<span key={digits.length - i} className="cl-roll-col">
<motion.span
className="cl-roll-strip"
initial={false}
animate={{ y: `${-Number(d) * 1.2}em` }}
transition={reduced ? { duration: 0 } : { type: 'spring', stiffness: 240, damping: 24 }}
>
{'0123456789'.split('').map(n => (
<span key={n}>{n}</span>
))}
</motion.span>
</span>
))}
</span>
)
}
/**
* Release list that remembers, per visitor, what they have read: unread
* dots, a "Since your last visit" divider, dwell-to-read, "Mark all as
* read", and tag filters. Read state lives in localStorage under `storageKey`.
*/
export function Changelog({
releases,
storageKey = 'changelog',
initialSeen,
anchorPrefix = '',
labels: labelsProp,
dwell = 2500,
locale,
filters = true,
maxFilters = 8,
rail = 'tape',
className,
style,
}: ChangelogProps) {
const reduced = useSyncExternalStore(subscribeReduced, getReduced, getServerReduced)
const labels: ChangelogLabels = { ...DEFAULT_LABELS, ...labelsProp }
const [state, setState] = useState<ReadState | null>(null)
const [lastVisit, setLastVisit] = useState<number | null>(null)
// what was new when this visit started: the divider marks this for the whole visit
const [sessionNew, setSessionNew] = useState<string[] | null>(null)
const [tag, setTag] = useState<string | null>(null)
const [allFilters, setAllFilters] = useState(false)
const uid = useId().replace(/[^a-zA-Z0-9_-]/g, '')
// ids being swept by "Mark all as read", in the order their dots clear
const [sweep, setSweep] = useState<string[] | null>(null)
const nodes = useRef(new Map<string, HTMLElement>())
const ids = useMemo(() => releases.map(idOf), [releases])
/* alias (version, "v"+version, pre-fix id) → id, for `initialSeen` and for read state saved under old ids */
const aliases = useMemo(() => {
const map = new Map<string, string>()
releases.forEach(r => aliasesOf(r).forEach(a => map.has(a) || map.set(a, idOf(r))))
return map
}, [releases])
const loadedKey = useRef<string | null>(null)
/** Start of the current visit, written with every save so read updates never move it. */
const visitRef = useRef<number | null>(null)
const sweepTimer = useRef(0)
/* load read state once releases exist (first visit: everything newer than initialSeen is unread) */
useEffect(() => {
// async releases start as []: seeding now would mark nothing seen and ignore initialSeen
if (!ids.length || loadedKey.current === storageKey) return
loadedKey.current = storageKey
setSessionNew(null)
const now = Date.now()
let saved: Stored | null = null
try {
const raw = localStorage.getItem(storageKey)
if (raw) saved = JSON.parse(raw)
} catch {
/* storage blocked */
}
const resolve = (key: string) => (ids.includes(key) ? key : aliases.get(key.toLowerCase()))
let seen: string[]
if (saved && Array.isArray(saved.seen)) {
seen = saved.seen.map(k => resolve(k) ?? k)
} else {
// first visit: everything from `initialSeen` down counts as already seen
const seenFrom = initialSeen ? resolve(initialSeen) : undefined
const from = initialSeen ? (seenFrom ? ids.indexOf(seenFrom) : -1) : 1
seen = ids.slice(from < 0 ? ids.length : from)
}
/* "last visit" = start of the previous visit, pinned in sessionStorage so reloads in this session keep it */
let last: number | null = saved ? (saved.visit ?? saved.at ?? null) : null
let sameSession = false
try {
const pinned = sessionStorage.getItem(`${storageKey}:last-visit`)
if (pinned !== null) {
sameSession = true
last = pinned ? Number(pinned) : null
} else sessionStorage.setItem(`${storageKey}:last-visit`, last ? String(last) : '')
} catch {
/* storage blocked */
}
visitRef.current = sameSession ? (saved?.visit ?? now) : now
setState({ seen })
setLastVisit(last)
try {
localStorage.setItem(storageKey, JSON.stringify({ seen, at: saved?.at ?? now, visit: visitRef.current }))
} catch {
/* storage blocked */
}
}, [storageKey, ids, aliases, initialSeen])
useEffect(() => () => clearTimeout(sweepTimer.current), [])
const persist = (next: ReadState) => {
setState(next)
try {
localStorage.setItem(storageKey, JSON.stringify({ ...next, at: Date.now(), visit: visitRef.current ?? Date.now() }))
} catch {
/* storage blocked */
}
}
const isUnread = (id: string) => !!state && !sweep && !state.seen.includes(id)
const unreadIds = ids.filter(isUnread)
/* snapshot the visit's new releases once; releases added later join it (derived during render) */
const [snapFor, setSnapFor] = useState<{ state: ReadState | null; ids: string[] }>({ state: null, ids })
if (state && (snapFor.state !== state || snapFor.ids !== ids)) {
setSnapFor({ state, ids })
const unread = ids.filter(id => !state.seen.includes(id))
const cur = sessionNew
const added = cur ? unread.filter(id => !cur.includes(id)) : unread
if (!cur) setSessionNew(unread)
else if (added.length) setSessionNew([...added, ...cur])
}
const lastNewIdx = sessionNew?.length ? Math.max(...sessionNew.map(id => ids.indexOf(id))) : -1
/* the observer lives for the component's life; it reads unread state from here */
const unreadRef = useRef(isUnread)
useLayoutEffect(() => {
unreadRef.current = isUnread
})
const io = useRef<IntersectionObserver | null>(null)
// dwell timers live outside any effect's lifetime: re-renders (which re-attach refs) never reset them
const timers = useRef(new Map<string, number>())
/* reading = lingering: an entry mostly on screen for `dwell` ms is marked read */
useEffect(() => {
const running = timers.current
const observer = new IntersectionObserver(
entries => {
for (const e of entries) {
const id = (e.target as HTMLElement).dataset.id!
if (e.isIntersecting && e.intersectionRatio >= 0.6) {
if (!running.has(id) && unreadRef.current(id)) {
running.set(
id,
window.setTimeout(() => {
running.delete(id)
if (!nodes.current.has(id)) return // filtered away or removed meanwhile
setState(cur => {
if (!cur || cur.seen.includes(id)) return cur
const next = { seen: [...cur.seen, id] }
try {
localStorage.setItem(storageKey, JSON.stringify({ ...next, at: Date.now(), visit: visitRef.current ?? Date.now() }))
} catch {
/* storage blocked */
}
return next
})
}, dwell),
)
}
} else if (running.has(id)) {
clearTimeout(running.get(id))
running.delete(id)
}
}
},
{ threshold: [0, 0.6, 1] },
)
io.current = observer
nodes.current.forEach(el => observer.observe(el))
return () => {
observer.disconnect()
io.current = null
running.forEach(t => clearTimeout(t))
running.clear()
}
}, [dwell, storageKey])
/* once read state loads, re-check what is already on screen (no scroll needed) */
const loaded = state !== null
useEffect(() => {
const observer = io.current
if (!loaded || !observer) return
nodes.current.forEach(el => {
observer.unobserve(el)
observer.observe(el)
})
}, [loaded])
const markAll = () => {
if (!unreadIds.length) return
if (reduced) {
persist({ seen: [...new Set([...(state?.seen ?? []), ...ids])] })
setSessionNew([])
return
}
// dots clear in a cascade top to bottom, then the state settles
const order = unreadIds
setSweep(order)
clearTimeout(sweepTimer.current)
sweepTimer.current = window.setTimeout(() => {
persist({ seen: [...new Set([...(state?.seen ?? []), ...ids])] })
setSessionNew([])
setSweep(null)
}, 120 + order.length * 70 + 250) // outlasts the last dot (80ms + 70ms per dot + 220ms fade) by ~140ms
}
/* [key (lowercased), label (first spelling seen), count]: "New" and "new" are one tag */
const allTags = useMemo(() => {
const found = new Map<string, { label: string; n: number }>()
releases.forEach(r =>
uniqueTags(r.tags).forEach(label => {
const k = label.toLowerCase()
const hit = found.get(k)
if (hit) hit.n++
else found.set(k, { label, n: 1 })
}),
)
const rank = (k: string) => ['new', 'improved', 'fixed'].indexOf(k)
// stable order: New, Improved, Fixed, then the most used, then alphabetically
return [...found.entries()]
.map(([k, v]) => [k, v.label, v.n] as const)
.sort(([a, , na], [b, , nb]) => {
const ra = rank(a)
const rb = rank(b)
if (ra >= 0 || rb >= 0) return (ra < 0 ? 99 : ra) - (rb < 0 ? 99 : rb)
return nb - na || a.localeCompare(b)
})
}, [releases])
const visible = tag ? releases.filter(r => r.tags?.some(t => t.toLowerCase() === tag)) : releases
const fmt = useMemo(() => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric' }), [locale])
const fmtDay = useMemo(
() => new Intl.DateTimeFormat(locale, { day: 'numeric', month: 'short', year: 'numeric', timeZone: 'UTC' }),
[locale],
)
const count = sweep ? 0 : unreadIds.length
const sweepOrder = (id: string) => sweep?.indexOf(id) ?? 0
/* many tags: the first `maxFilters` chips (plus the active one), the rest behind "N more" */
const chipCap = Math.max(1, Math.floor(Number.isFinite(maxFilters) ? maxFilters : 8))
const folded = !allFilters && allTags.length > chipCap + 1
const shownTags = folded ? allTags.filter((e, i) => i < chipCap || e[0] === tag) : allTags
const hiddenTags = allTags.length - shownTags.length
if (!releases.length) {
return (
<section className={['cl-root', className].filter(Boolean).join(' ')} data-rail={rail} data-empty="" style={style} aria-label={labels.region}>
<ChangelogStyles />
<div className="cl-empty">
<UiIcon name="page" size={16} />
<span>{labels.empty}</span>
</div>
</section>
)
}
return (
<section className={['cl-root', className].filter(Boolean).join(' ')} data-rail={rail} style={style} aria-label={labels.region}>
<ChangelogStyles />
<header className="cl-head">
<div className="cl-status" aria-live="polite">
<AnimatePresence mode="popLayout" initial={false}>
{!state ? null : count > 0 ? (
<motion.span
key="new"
className="cl-status-line"
initial={reduced ? { opacity: 0 } : { y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduced ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT }}
>
<i className="cl-pulse" aria-hidden />
{/* one run of text, so the flex gap never widens the space after the number */}
<span className="cl-status-text">
<Roll value={count} reduced={reduced} />
<span className="cl-sr">{count}</span>
{` ${labels.newSince}`}
</span>
</motion.span>
) : (
<motion.span
key="caught"
className="cl-status-line"
initial={reduced ? { opacity: 0 } : { y: '100%', opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={reduced ? { opacity: 0 } : { y: '-100%', opacity: 0 }}
transition={{ duration: 0.28, ease: EASE_OUT, delay: reduced ? 0 : 0.15 }}
>
<UiIcon name="check-circle" size={16} className="cl-ok" />
<span className="cl-status-text">{labels.caughtUp}</span>
</motion.span>
)}
</AnimatePresence>
</div>
<AnimatePresence initial={false}>
{state && count > 0 && (
<motion.button
type="button"
className="cl-markall"
onClick={markAll}
title={labels.markAll}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
whileTap={reduced ? undefined : { scale: 0.96 }}
>
<UiIcon name="double-check" size={15} />
<span className="cl-markall-label">{labels.markAll}</span>
</motion.button>
)}
</AnimatePresence>
</header>
{filters && allTags.length > 0 && (
<div className="cl-filters" role="group" aria-label={labels.filters}>
{[null, ...shownTags].map(entry => {
const t = entry ? entry[0] : null
const on = tag === t
const n = entry ? entry[2] : releases.length
const text = entry ? labels.tag(entry[1]) : labels.all
return (
<button
key={t ?? '\u0000all'}
type="button"
aria-pressed={on}
className="cl-chip"
title={text.length > 24 ? text : undefined}
onClick={() => setTag(t)}
>
{on && (
<motion.span
layoutId={`cl-${uid}-chip`}
className="cl-chip-bg"
transition={reduced ? { duration: 0 } : { type: 'spring', visualDuration: 0.3, bounce: 0.2 }}
/>
)}
<UiIcon name={t ? tagIcon(t) : 'view-grid'} size={14} className="cl-chip-icon" />
<span className="cl-chip-label">{text}</span>
<span className="cl-chip-n">{n}</span>
</button>
)
})}
{(folded || allFilters) && allTags.length > chipCap + 1 && (
<button type="button" className="cl-chip cl-chip-more" aria-expanded={!folded} onClick={() => setAllFilters(!allFilters)}>
<UiIcon name="nav-arrow-down" size={14} className="cl-chip-icon" />
<span className="cl-chip-label">{folded ? labels.moreFilters(hiddenTags) : labels.fewerFilters}</span>
</button>
)}
</div>
)}
<ol className="cl-list">
<AnimatePresence initial={false}>
{visible.map(r => {
const id = idOf(r)
const unread = isUnread(id)
const idx = ids.indexOf(id)
const dateText = formatDate(r.date, fmtDay, fmt)
const tags = uniqueTags(r.tags)
return [
<motion.li
key={id}
ref={el => {
// re-attach on every render is cheap: observe() re-reports, and a running timer is kept
const prev = nodes.current.get(id)
if (el) {
if (prev !== el) {
if (prev) io.current?.unobserve(prev)
nodes.current.set(id, el)
io.current?.observe(el)
}
} else if (prev) {
io.current?.unobserve(prev)
nodes.current.delete(id)
}
}}
data-id={id}
id={`${anchorPrefix}${id}`}
className="cl-rel"
data-unread={unread || undefined}
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.34, ease: [0.32, 0.72, 0, 1] }}
>
<div className="cl-mark">
<a
className="cl-version"
href={`#${anchorPrefix}${id}`}
title={versionLabel(r.version).length > 8 ? versionLabel(r.version) : undefined}
>
{versionLabel(r.version)}
</a>
{dateText !== null && <time dateTime={r.date}>{dateText}</time>}
{/* every release since the last visit says so, read or not: the divider then only heads the older ones */}
{!tag && lastNewIdx < ids.length - 1 && sessionNew?.includes(id) && <span className="cl-fresh">{labels.newToYou}</span>}
</div>
<article className="cl-entry" aria-labelledby={`cl-${uid}-${id}-title`}>
<h3 className="cl-title" id={`cl-${uid}-${id}-title`}>
<AnimatePresence initial={false}>
{(unread || sweep?.includes(id)) && (
<motion.span
key="dot"
className="cl-dot"
initial={reduced ? { opacity: 0 } : { scale: 0 }}
animate={sweep && !reduced ? { scale: 0, opacity: 0 } : { scale: 1, opacity: 1 }}
exit={reduced ? { opacity: 0 } : { scale: 0, opacity: 0 }}
transition={
sweep && !reduced
? { duration: 0.22, ease: EASE_OUT, delay: 0.08 + Math.min(sweepOrder(id), 10) * 0.07 }
: { type: 'spring', visualDuration: 0.3, bounce: 0.4 }
}
>
<span className="cl-sr">{labels.unread} </span>
</motion.span>
)}
</AnimatePresence>
<span className="cl-title-text" dir="auto">
{r.title}
</span>
</h3>
{tags.length > 0 && (
<p className="cl-tags">
{tags.map(t => (
<span
key={t}
className="cl-tag"
data-tone={TAG_TONE[t.toLowerCase()] ?? 'other'}
title={labels.tag(t).length > 20 ? labels.tag(t) : undefined}
>
<UiIcon name={tagIcon(t)} size={11} />
<span className="cl-tag-text" dir="auto">
{labels.tag(t)}
</span>
</span>
))}
</p>
)}
{r.body && (
<div className="cl-body" dir="auto">
{r.body}
</div>
)}
</article>
</motion.li>,
!tag && idx === lastNewIdx && idx < ids.length - 1 && (
<motion.li
key={`${id}-divider`}
className="cl-divider"
aria-hidden
initial={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={reduced ? { opacity: 0 } : { opacity: 0, height: 0 }}
transition={{ duration: 0.3, ease: EASE_OUT }}
>
<span>
<span className="cl-divider-text">
{labels.sinceLastVisit}
{lastVisit ? ` · ${fmt.format(new Date(lastVisit))}` : ''}
</span>
</span>
</motion.li>
),
]
})}
</AnimatePresence>
</ol>
</section>
)
}
/* ────────────────────────────────────────────────────────────────────────── */
/* Styles — injected once, hoisted by React 19 */
/* ────────────────────────────────────────────────────────────────────────── */
function ChangelogStyles() {
return (
<style href="changelog" precedence="medium">
{CSS}
</style>
)
}
const CSS = /* css */ `
.cl-root {
--_ink: var(--changelog-ink, var(--ui-ink, #17140f));
--_radius-sm: var(--ui-radius-sm, 8px);
--_pill: var(--ui-radius-pill, 999px);
--_accent: var(--changelog-accent, var(--ui-accent, #d4452b));
--_mono: var(--changelog-mono, var(--ui-font-mono, ui-monospace, Menlo, monospace));
--_line: color-mix(in oklab, var(--_ink) 13%, transparent);
--_muted: color-mix(in oklab, var(--_ink) 56%, transparent);
--_ok: var(--changelog-ok, var(--ui-ok, #2f7d4f));
--_info: var(--changelog-info, var(--ui-info, #3f7fbf));
--_surface: var(--changelog-surface, var(--ui-surface, #fbf8f1));
--_rail: 112px;
--_gap: 26px;
color: var(--_ink);
-webkit-tap-highlight-color: transparent;
/* layout follows the component's width, not the viewport; min-width 0 so it never forces a grid/flex parent wide */
container-type: inline-size;
min-width: 0;
}
.cl-sr {
position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; border: 0;
overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap;
}
.cl-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 32px; }
/* one 22px line normally; a long status wraps instead of being squeezed away by the button */
.cl-status { position: relative; display: flex; min-height: 22px; overflow: hidden; flex: 1 1 0; min-width: min(40%, 160px); }
.cl-status-line { display: inline-flex; align-items: flex-start; gap: 8px; max-width: 100%; font-size: 14px; font-weight: 550; line-height: 22px; }
.cl-status-line .cl-ok { flex: none; margin-top: 3px; color: var(--_ok); }
.cl-status-text { display: inline-flex; align-items: flex-start; min-width: 0; white-space: pre-wrap; overflow-wrap: anywhere; text-wrap: pretty; }
.cl-status-text .cl-roll { flex: none; margin-top: calc((22px - 1.2em) / 2); }
.cl-pulse { flex: none; margin-top: 7px; width: 8px; height: 8px; border-radius: 50%; background: var(--_accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--_accent) 20%, transparent); }
.cl-root .cl-markall {
flex: 0 1 auto; min-width: 30px; max-width: 50%; display: inline-flex; align-items: center; gap: 6px; height: 30px; padding: 0 10px; margin: 0; border: 0; border-radius: var(--_radius-sm); cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--_muted); background: none;
transition: color 150ms ease, background-color 150ms ease;
}
.cl-markall .ui-icon { flex: none; }
.cl-markall-label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cl-root .cl-markall:hover { color: var(--_ink); background: color-mix(in oklab, var(--_ink) 6%, transparent); }
.cl-markall:focus-visible, .cl-chip:focus-visible, .cl-version:focus-visible { outline: 2px solid var(--_accent); outline-offset: 2px; }
.cl-filters { display: flex; flex-wrap: wrap; gap: 4px; margin: 14px 0 22px; }
.cl-root .cl-chip {
position: relative; display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px; margin: 0; min-width: 0; max-width: 100%;
border: 0; border-radius: var(--_pill); background: none; cursor: pointer;
font: inherit; font-size: 12.5px; color: var(--_muted); text-transform: capitalize;
transition: color 150ms ease;
}
.cl-root .cl-chip[aria-pressed="true"], .cl-root .cl-chip:hover { color: var(--_ink); }
.cl-chip-bg { position: absolute; inset: 0; border-radius: inherit; background: color-mix(in oklab, var(--_ink) 7%, transparent); }
.cl-chip-label, .cl-chip-n, .cl-chip-icon { position: relative; }
.cl-chip-label { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cl-chip-icon { flex: none; opacity: 0.85; }
.cl-chip-n { flex: none; font-family: var(--_mono); font-size: 11px; opacity: 0.7; }
.cl-root .cl-chip-more { text-transform: none; }
.cl-chip-more .cl-chip-icon { transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1); }
.cl-chip-more[aria-expanded="true"] .cl-chip-icon { transform: rotate(180deg); }
.cl-root .cl-list { position: relative; list-style: none; margin: 0; padding: 0; }
/* line: a plain 1px rail, each release a small node on it */
.cl-list::before {
content: ''; position: absolute; top: 4px; bottom: 0; inset-inline-start: calc(var(--_rail) - 1px); width: 1px;
background: color-mix(in oklab, var(--_ink) 16%, transparent);
}
.cl-mark::after {
content: ''; position: absolute; inset-inline-end: -3px; top: 24px; width: 7px; height: 7px; border-radius: 50%; box-sizing: border-box;
background: var(--_surface); border: 1.5px solid color-mix(in oklab, var(--_ink) 40%, transparent);
transition: transform 200ms cubic-bezier(0.23, 1, 0.32, 1), background-color 200ms ease, border-color 200ms ease;
}
.cl-rel:hover .cl-mark::after { transform: scale(1.3); }
/* the unread dot sits right on the rail, over the node, level with the title's first line */
.cl-root[data-rail="line"] .cl-dot { inset-inline-start: calc(-1 * var(--_gap) - 4.5px); top: calc(0.675em - 4px); margin-top: 0; }
/* tape: a rail with minor ticks, versions as major ticks */
.cl-root[data-rail="tape"] .cl-list::before {
content: ''; position: absolute; top: 4px; bottom: 0; inset-inline-start: calc(var(--_rail) - 1px); width: 7px;
background: repeating-linear-gradient(to bottom, var(--_line) 0 1px, transparent 1px 9px);
border-inline-start: 1px solid color-mix(in oklab, var(--_ink) 24%, transparent);
}
.cl-root .cl-rel { position: relative; display: grid; grid-template-columns: var(--_rail) minmax(0, 1fr); overflow: hidden; margin: 0; }
.cl-mark { position: relative; display: flex; flex-direction: column; align-items: flex-end; gap: 2px; min-width: 0; padding: 18px 0 0; padding-inline-end: 18px; }
.cl-root[data-rail="tape"] .cl-mark::after {
/* the major tick */
inset-inline-end: -14px; top: 27px; width: 14px; height: 1.5px; border: 0; border-radius: 0; transform: none;
background: color-mix(in oklab, var(--_ink) 55%, transparent); transition: width 200ms ease, background-color 200ms ease;
}
.cl-root[data-rail="tape"] .cl-rel[data-unread] .cl-mark::after { background: var(--_accent); }
.cl-root[data-rail="tape"] .cl-rel:hover .cl-mark::after { width: 20px; }
/* chrome: a long version truncates (full text in the tooltip) instead of spilling out of the rail; the
padding lets a slightly-too-long one ("v10.12.13") use 10px of the gap before the rail first */
.cl-root .cl-version {
box-sizing: border-box; max-width: calc(100% + 10px); margin-inline-end: -10px; padding-inline-end: 10px;
padding-block: 4px; margin-block: -4px;
overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font: 600 13px/1.3 var(--_mono); color: var(--_ink); text-decoration: none;
}
.cl-fresh {
max-width: 100%; margin-top: 4px; padding: 1px 6px; border: 1px solid color-mix(in oklab, var(--_accent) 45%, transparent); border-radius: var(--_pill);
font: 500 9.5px/1.4 var(--_mono); letter-spacing: 0.04em; text-transform: uppercase; color: var(--_accent);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.cl-mark time { max-width: 100%; font: 400 11px/1.3 var(--_mono); color: var(--_muted); white-space: nowrap; }
.cl-entry { padding: 16px 0 26px; padding-inline-start: var(--_gap); min-width: 0; }
.cl-root .cl-title { position: relative; display: flex; align-items: center; gap: 9px; max-width: 70ch; margin: 0; font-size: 17px; font-weight: 600; line-height: 1.35; letter-spacing: -0.01em; text-wrap: balance; overflow-wrap: anywhere; }
.cl-title-text { min-width: 0; }
.cl-dot {
/* in the gutter at the end of the rail tick, so the title never shifts */
position: absolute; inset-inline-start: -17px; top: 50%; margin-top: -4px;
width: 8px; height: 8px; border-radius: 50%; background: var(--_accent); box-shadow: 0 0 0 3px color-mix(in oklab, var(--_accent) 18%, transparent); }
.cl-root .cl-tags { display: flex; flex-wrap: wrap; gap: 5px; margin: 8px 0 0; }
.cl-tag {
display: inline-flex; align-items: center; gap: 4px; min-width: 0; max-width: 100%; box-sizing: border-box;
padding: 2px 7px; border-radius: calc(var(--_radius-sm) * 0.625); font: 500 10.5px/1.5 var(--_mono); letter-spacing: 0.04em; text-transform: uppercase;
background: color-mix(in oklab, var(--_ink) 6%, transparent); color: color-mix(in oklab, var(--_ink) 70%, transparent);
}
.cl-tag .ui-icon { flex: none; }
.cl-tag-text { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.cl-tag[data-tone="new"] { background: color-mix(in oklab, var(--_accent) 14%, transparent); color: var(--_accent); }
.cl-tag[data-tone="improved"] { background: color-mix(in oklab, var(--_info) 14%, transparent); color: color-mix(in oklab, var(--_info) 85%, var(--_ink)); }
.cl-tag[data-tone="fixed"] { background: color-mix(in oklab, var(--_ok) 15%, transparent); color: color-mix(in oklab, var(--_ok) 85%, var(--_ink)); }
.cl-body { margin-top: 10px; min-width: 0; font-size: 14.5px; line-height: 1.6; color: color-mix(in oklab, var(--_ink) 78%, transparent); text-wrap: pretty; overflow-wrap: anywhere; }
.cl-body pre { max-width: 100%; overflow-x: auto; overflow-wrap: normal; }
.cl-body img, .cl-body video, .cl-body table { max-width: 100%; }
.cl-body > :first-child { margin-top: 0; }
.cl-body > :last-child { margin-bottom: 0; }
.cl-body ul { padding-inline-start: 18px; }
.cl-root .cl-divider { position: relative; margin: 0; overflow: hidden; }
.cl-divider > span {
position: relative; display: flex; align-items: center; gap: 10px; padding: 6px 0 10px; padding-inline-start: calc(var(--_rail) + var(--_gap));
font: 500 10.5px/1 var(--_mono); letter-spacing: 0.08em; text-transform: uppercase; color: var(--_accent);
}
/* a long divider label wraps (extra line-height offset by the margin, so one line is unchanged) */
.cl-root .cl-divider-text { display: block; min-width: 0; padding: 0; line-height: 1.35; margin-block: -0.175em; overflow-wrap: anywhere; text-wrap: balance; }
.cl-divider > span::after { content: ''; flex: 1 0 24px; height: 1px; background: color-mix(in oklab, var(--_accent) 45%, transparent); }
.cl-roll { display: inline-flex; line-height: 1.2; font-variant-numeric: tabular-nums; }
.cl-roll-col { display: inline-block; height: 1.2em; overflow: hidden; }
.cl-roll-strip { display: flex; flex-direction: column; }
.cl-roll-strip > span { height: 1.2em; }
.cl-empty { display: flex; align-items: center; gap: 8px; padding: 14px 0; font-size: 14px; color: var(--_muted); text-wrap: pretty; overflow-wrap: anywhere; }
.cl-empty .ui-icon { flex: none; }
/* narrow: on small screens, and wherever the component itself is narrow (a sidebar, a card) */
@media (max-width: 560px) {
.cl-list { --_rail: 78px; --_gap: 20px; }
/* the narrow rail can't fit "Sep 22, 2026" on one line: wrap instead of clipping the month */
.cl-mark time { white-space: normal; text-align: end; }
/* keep the whole status line: "Mark all as read" shrinks to its icon (name kept for assistive tech) */
.cl-root .cl-markall { width: 30px; padding: 0; justify-content: center; }
.cl-markall-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
}
@container (max-width: 440px) {
.cl-list { --_rail: 78px; --_gap: 20px; }
/* the narrow rail can't fit "Sep 22, 2026" on one line: wrap instead of clipping the month */
.cl-mark time { white-space: normal; text-align: end; }
/* keep the whole status line: "Mark all as read" shrinks to its icon (name kept for assistive tech) */
.cl-root .cl-markall { width: 30px; padding: 0; justify-content: center; }
.cl-markall-label { position: absolute; width: 1px; height: 1px; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
}
/* phones and narrow columns: the version and date move above the entry, so the text gets the full width */
@container (max-width: 400px) {
.cl-list { --_rail: 5px; --_gap: 14px; }
.cl-root .cl-rel { grid-template-columns: minmax(0, 1fr); }
.cl-mark { flex-direction: row; align-items: baseline; justify-content: flex-start; gap: 10px; padding: 16px 0 0; padding-inline-start: var(--_gap); }
.cl-root .cl-version { max-width: 60%; margin-inline-end: 0; padding-inline-end: 0; }
.cl-mark time { white-space: nowrap; text-align: left; }
.cl-mark::after { inset-inline-end: auto; inset-inline-start: 1px; top: 21px; }
.cl-root[data-rail="tape"] .cl-mark::after { inset-inline-end: auto; inset-inline-start: 4px; top: 24px; }
.cl-entry { padding-top: 6px; }
.cl-root[data-rail="line"] .cl-dot { display: none; }
/* many tags: the expanded chips scroll inside a short box instead of pushing the releases off-screen */
.cl-filters { max-height: 116px; overflow-y: auto; scrollbar-width: thin; }
}
/* touch: 44px targets for chips, the mark-all button and the version permalinks */
@media (pointer: coarse) {
.cl-root :is(.cl-chip, .cl-markall) { min-height: 44px; }
.cl-root .cl-markall { min-width: 44px; }
.cl-root .cl-version { padding-block: 13px; margin-block: -13px; }
}
/* touch: 44px targets (WCAG AAA) where they fit; visual sizes on mouse and trackpad are unchanged */
@media (pointer: coarse) {
.cl-root .cl-version { min-inline-size: 44px; }
}
`
export default Changelog