# 변경 로그 피드

월별로 새 기능, 개선, 수정을 나눠 보여 주는 업데이트 소식 목록이에요.

- Type: block
- Theme: base
- Tags: arc, block, changelog, release, feed, updates, timeline
- English title: Changelog feed
- URL: https://dsui.vercel.app/items/changelog-feed

## Used items

- [복사 버튼](https://dsui.vercel.app/items/copy-button): 눌러서 값을 클립보드에 복사하고 복사했다고 알려 줘요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| title | text | 제목 | 변경 내역 |
| subtitle | text | 부제 | 하든 4.12가 2026년 9월 18일에 나왔어요 |
| endNote | text | 마무리 문구 | 6월의 하든 4.5 이후 변경 내역은 여기까지예요. |
| showSubscribe | boolean | 구독 폼 | true |

## Source

### changelog-feed.module.css

```css
.feed { box-sizing: border-box; width: min(100%, 920px); margin-inline: auto; overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius-panel); background: var(--surface); color: var(--foreground); font-family: var(--font-body); letter-spacing: var(--tracking-body); }
.feed *, .feed *::before, .feed *::after { box-sizing: border-box; }
.srOnly { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }

/* Header */
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px 24px; padding: 32px 28px 20px; }
.heading { min-width: 0; }
.heading h2 { margin: 0; font: 400 var(--text-3xl)/var(--leading-display) var(--font-display); letter-spacing: var(--tracking-display); }
.heading p { margin: 8px 0 0; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }

/* Subscribe */
.subscribeWrap { position: relative; flex: none; padding-top: 2px; }
.subscribe { height: 38px; overflow: hidden; border: 1px solid var(--accent); border-radius: var(--radius-pill); background: var(--accent); color: var(--accent-foreground); transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard); }
.subscribe[data-state="editing"] { border-color: var(--border-strong); background: var(--surface); color: var(--foreground); }
.subscribe[data-state="done"] { border-color: var(--border); background: var(--surface-muted); color: var(--foreground); }
.subscribe[data-error] { border-color: var(--danger); }
.subscribeInner { position: relative; display: flex; width: max-content; height: 100%; }
.subscribeIdle { display: inline-flex; align-items: center; gap: 7px; height: 100%; padding: 0 17px 0 15px; border: 0; background: transparent; color: inherit; font: 500 var(--text-sm)/1 var(--font-body); white-space: nowrap; cursor: pointer; outline: none; }
.subscribeForm { display: flex; align-items: center; gap: 6px; height: 100%; padding: 0 4px 0 15px; }
.subscribeForm input { width: 188px; min-width: 0; height: 100%; padding: 0; border: 0; background: transparent; color: var(--foreground); font: var(--text-sm)/1 var(--font-body); outline: none; }
.subscribeForm input::placeholder { color: var(--text-muted); }
.subscribeSubmit { display: grid; flex: none; place-items: center; width: 28px; height: 28px; padding: 0; border: 0; border-radius: 50%; background: var(--accent); color: var(--accent-foreground); cursor: pointer; outline: none; transition: transform var(--duration-instant) var(--ease-standard); }
.subscribeSubmit:active { transform: scale(.94); }
.subscribeDone { display: inline-flex; align-items: center; gap: 7px; height: 100%; padding: 0 5px 0 13px; font-size: var(--text-sm); font-weight: 500; white-space: nowrap; }
.drawnCheck { flex: none; color: var(--success); }
.subscribeUndo { height: 26px; margin-left: 4px; padding: 0 10px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-xs)/1 var(--font-body); cursor: pointer; outline: none; transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard); }
.subscribeNote { position: absolute; top: calc(100% + 7px); right: 6px; margin: 0; color: var(--text-muted); font-size: var(--text-xs); line-height: 16px; white-space: nowrap; }
.subscribeNote[data-error] { color: var(--danger); }
.subscribeNote span { display: inline-block; }

/* Filters */
.toolbar { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 12px 16px; padding: 6px 28px 20px; }
.chips { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.chip { display: inline-flex; align-items: center; gap: 8px; height: 34px; padding: 0 13px 0 12px; border: 1px solid var(--border); border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-sm)/1 var(--font-body); cursor: pointer; transition: background-color var(--duration-standard) var(--ease-standard), border-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard); }
.chip:active { transform: scale(.97); }
.chip[aria-pressed="true"] { border-color: var(--foreground); background: var(--foreground); color: var(--background); }
.chipMark { display: grid; flex: none; place-items: center; overflow: hidden; border-radius: 50%; background: var(--kind); transition: background-color var(--duration-standard) var(--ease-standard); }
.chip[aria-pressed="true"] .chipMark { background: var(--mark); }
.chipTick { display: grid; place-items: center; color: var(--tick); }
.chipCount { color: var(--text-muted); font-size: var(--text-xs); font-variant-numeric: tabular-nums; transition: color var(--duration-standard) var(--ease-standard); }
.chip[aria-pressed="true"] .chipCount { color: color-mix(in srgb, var(--background) 62%, transparent); }
.clear { height: 34px; padding: 0 8px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-sm)/1 var(--font-body); cursor: pointer; }
.shown { display: inline-flex; align-items: baseline; gap: 0; margin: 0; color: var(--text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
.shown > span:last-child { display: inline-block; }
.shownCount { display: inline-flex; }

.chip[data-kind="new"], .kind[data-kind="new"] { --kind: var(--accent); --mark: var(--accent); --tick: var(--accent-foreground); }
.chip[data-kind="improved"], .kind[data-kind="improved"] { --kind: var(--text-muted); --mark: var(--background); --tick: var(--foreground); }
.chip[data-kind="fixed"], .kind[data-kind="fixed"] { --kind: var(--success); --mark: var(--success); --tick: var(--background); }

/* Rolling numbers */
.roll { position: relative; display: inline-flex; overflow: hidden; font-variant-numeric: tabular-nums; }
.roll > span { display: inline-block; }

/* Month bar */
.monthBar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 60px; padding: 10px 28px; border-block: 1px solid var(--border); }
.monthLabel { display: flex; align-items: baseline; gap: 12px; min-width: 0; }
.monthText { position: relative; display: inline-flex; font: 500 var(--text-lg)/var(--leading-body) var(--font-body); letter-spacing: var(--tracking-body); white-space: nowrap; }
.monthText > span { display: inline-block; }
.monthYear { display: inline; }
.monthCount { display: inline-flex; align-items: baseline; gap: 0; color: var(--text-muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums; white-space: nowrap; }
.monthNav { display: flex; flex: none; gap: 2px; padding: 3px; border-radius: var(--radius-pill); background: var(--surface-muted); }
.monthNav button { position: relative; min-width: 42px; height: 28px; padding: 0 10px; border: 0; border-radius: var(--radius-pill); background: transparent; color: var(--text-secondary); font: 500 var(--text-xs)/1 var(--font-body); cursor: pointer; transition: color var(--duration-standard) var(--ease-standard); }
.monthNav button[aria-current="true"] { color: var(--foreground); }
.monthPill { position: absolute; inset: 0; border-radius: var(--radius-pill); background: var(--surface); box-shadow: 0 0 0 1px var(--border), var(--shadow-resting); }
.monthShort { position: relative; z-index: 1; }

/* Feed */
.scroller { position: relative; height: 560px; overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; outline: none; -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); mask-image: linear-gradient(to bottom, #000 calc(100% - 36px), transparent); }
.list { position: relative; }
.group { position: relative; }
.groupTitle { margin: 0; padding: 30px 28px 10px; border-bottom: 1px solid var(--border-subtle); color: var(--text-muted); font-size: var(--text-sm); font-weight: 500; }
.entries { position: relative; margin: 0; padding: 0; list-style: none; }
.entry { position: relative; border-bottom: 1px solid var(--border-subtle); background: var(--surface); }
.entries > .entry:last-child { border-bottom: 0; }
.row { display: grid; grid-template-columns: 104px minmax(0, 1fr) 20px; align-items: start; gap: 4px 24px; width: 100%; padding: 22px 28px; border: 0; background: transparent; color: inherit; font: inherit; letter-spacing: inherit; text-align: left; cursor: pointer; transition: background-color var(--duration-standard) var(--ease-standard); }
.meta { display: grid; justify-items: start; gap: 8px; padding-top: 1px; }
.meta time { color: var(--text-secondary); font-size: var(--text-sm); line-height: 20px; font-variant-numeric: tabular-nums; }
.version { padding: 1px 7px; border: 1px solid var(--border); border-radius: 7px; color: var(--text-secondary); font: 500 var(--text-xs)/18px ui-monospace, SFMono-Regular, Menlo, monospace; font-variant-numeric: tabular-nums; letter-spacing: 0; white-space: nowrap; }
.body { display: grid; gap: 6px; min-width: 0; }
.kind { display: inline-flex; align-items: center; gap: 7px; color: var(--text-secondary); font-size: var(--text-xs); font-weight: 500; line-height: 20px; }
.kind i { width: 7px; height: 7px; border-radius: 50%; background: var(--kind); }
.title { color: var(--foreground); font-size: var(--text-base); font-weight: 500; line-height: var(--leading-body); }
.summary { max-width: 62ch; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.chevron { display: grid; place-items: center; width: 20px; height: 20px; margin-top: 1px; color: var(--text-muted); transition: color var(--duration-standard) var(--ease-standard); }
.entry[data-open] .chevron { color: var(--foreground); }
.panel { overflow: hidden; }
.panelInner { padding: 0 72px 28px 156px; }
.details { display: grid; gap: 6px; max-width: 62ch; margin: 0; padding: 0; list-style: none; }
.details li { position: relative; padding-left: 16px; color: var(--text-secondary); font-size: var(--text-sm); line-height: var(--leading-body); }
.details li::before { position: absolute; top: .75em; left: 1px; width: 6px; height: 1px; content: ""; background: var(--text-muted); }
.photo { max-width: 560px; margin: 18px 0 0; }
.photoFrame { position: relative; aspect-ratio: 16 / 10; overflow: hidden; border-radius: 16px; background: var(--surface-muted); }
.photoFrame img { display: block; width: 100%; height: 100%; object-fit: cover; }
.photoFrame::after { position: absolute; inset: 0; border-radius: inherit; content: ""; box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--foreground) 8%, transparent); pointer-events: none; }
.photo figcaption { margin-top: 8px; color: var(--text-muted); font-size: var(--text-xs); line-height: var(--leading-body); }
.code { max-width: 560px; margin-top: 18px; overflow: hidden; border: 1px solid var(--border); border-radius: 16px; background: var(--surface-muted); }
.codeHead { display: flex; align-items: center; justify-content: space-between; gap: 12px; height: 40px; padding: 0 6px 0 14px; border-bottom: 1px solid var(--border); color: var(--text-muted); font-size: var(--text-xs); }
.code pre { margin: 0; padding: 14px 16px 16px; overflow-x: auto; color: var(--foreground); font: var(--text-xs)/1.7 ui-monospace, SFMono-Regular, Menlo, monospace; letter-spacing: 0; tab-size: 2; scrollbar-width: thin; }
.endCap { margin: 0; padding: 32px 28px 56px; color: var(--text-muted); font-size: var(--text-sm); text-align: center; }


@media (hover: hover) and (pointer: fine) {
  .chip:not([aria-pressed="true"]):hover { border-color: var(--border-strong); color: var(--foreground); }
  .clear:hover, .monthNav button:hover, .subscribeUndo:hover { color: var(--foreground); }
  .subscribeUndo:hover { background: var(--surface); }
  .row:hover { background: color-mix(in srgb, var(--surface-muted) 60%, transparent); }
  .row:hover .chevron { color: var(--foreground); }
}

@media (max-width: 640px) {
  .feed { border-radius: var(--radius-panel); }
  .header { flex-direction: column; gap: 18px; padding: 26px 20px 26px; }
  .subscribeNote { right: auto; left: 6px; }
  .subscribeForm input { width: 172px; font-size: var(--text-base); }
  .toolbar { padding: 4px 20px 18px; }
  .monthBar { padding-inline: 20px; }
  .scroller { height: 520px; }
  .groupTitle { padding-inline: 20px; }
  .row { grid-template-columns: minmax(0, 1fr) 20px; grid-template-areas: "meta chevron" "body body"; gap: 10px 12px; padding: 18px 20px; }
  .meta { display: flex; grid-area: meta; align-items: center; gap: 10px; padding-top: 0; }
  .body { grid-area: body; }
  .chevron { grid-area: chevron; margin-top: 0; }
  .panelInner { padding: 0 20px 24px; }
}

@media (max-width: 480px) {
  .monthCount, .monthYear { display: none; }
  .monthText { font-size: var(--text-base); }
  .monthNav button { min-width: 0; padding: 0 9px; }
  .shown { display: none; }
}

@media (prefers-reduced-motion: reduce) {
  .feed *, .feed *::before, .feed *::after { transition-duration: 0ms !important; }
  .chip:active, .subscribeSubmit:active { transform: none; }
}

.heading p:first-child { margin-top: 0; }

```

### changelog-feed.tsx

```tsx
"use client";

import { useCallback, useEffect, useId, useMemo, useRef, useState, type FocusEvent, type FormEvent, type KeyboardEvent, type ReactNode } from "react";
import Image, { type StaticImageData } from "next/image";
import { AnimatePresence, motion, useAnimate, type Transition, type Variants } from "motion/react";
import { useReducedMotion } from "../../lib/use-reduced-motion";
import { CopyButton } from "../../copy-button/copy-button";
import { motionTokens } from "../../lib/motion-tokens";
import { photo } from "../../lib/media";
import styles from "./changelog-feed.module.css";
import { UiIcon } from "../../theme/ui-icon";
import { playUiSound } from "../../sound/play";

export type ChangelogKind = "new" | "improved" | "fixed";
type Kind = ChangelogKind;
export type ChangelogMedia =
  | { type: "photo"; src: StaticImageData | string; alt: string; caption: string; /** Pixel size, needed when `src` is a URL rather than an imported image. */ width?: number; height?: number }
  | { type: "code"; file: string; code: string };
type Media = ChangelogMedia;
export type ChangelogEntry = { id: string; month: string; date: string; iso: string; version: string; kind: Kind; title: string; summary: ReactNode; details: ReactNode[]; media?: Media };
export type ChangelogMonth = { key: string; label: string; short: string };
type Entry = ChangelogEntry;
type Month = ChangelogMonth;

const kindIds: Kind[] = ["new", "improved", "fixed"];

/** Every word the feed says itself. Pass `labels` to translate or reword it; anything left out stays Korean. */
export interface ChangelogLabels {
  kinds: Record<Kind, string>;
  /** The accessible name of the filter chips. */
  filterGroup: string;
  /** Clears the kind filters. */
  clearFilters: string;
  /** The accessible name of the bar that jumps between groups. */
  groupNav: string;
  /** The accessible name of the scrolling list. */
  list: string;
  /** Shown in the group bar when no group matches the filters. */
  empty: string;
  /** "3개 업데이트": the count beside a group name. The number is rendered separately, so this is the words after it. */
  updatesAfterNumber: string;
  /** The words after the number of visible updates: when nothing is filtered, and when some are (`total` is the unfiltered count). */
  shownAll: string;
  /** `{total}` is the unfiltered count. */
  shownSome: string;
  /** Spoken status after a filter changes: `{total}` and `{shown}` are counts. */
  statusAll: string;
  statusSome: string;
  /** The label of a group button for screen readers: `{label}` is the group name. */
  viewGroup: string;
  subscribe: { idle: string; email: string; submit: string; invalid: string; done: string; doneNote: string; undo: string; undoLabel: string };
}

/** Fills `{name}` slots in a label. Labels are plain strings so they can cross from a server component. */
const fill = (template: string, values: Record<string, string | number>) => template.replace(/\{(\w+)\}/g, (slot, name: string) => String(values[name] ?? slot));

const defaultLabels: ChangelogLabels = {
  kinds: { new: "새 기능", improved: "개선", fixed: "수정" },
  filterGroup: "유형별로 거르기",
  clearFilters: "모두 보기",
  groupNav: "월별로 이동",
  list: "릴리스 노트",
  empty: "업데이트가 없어요",
  updatesAfterNumber: "개 업데이트",
  shownAll: "개 업데이트",
  shownSome: "개 / 전체 {total}개",
  statusAll: "업데이트 {total}개를 보여 주고 있어요",
  statusSome: "업데이트 {total}개 중 {shown}개를 보여 주고 있어요",
  viewGroup: "{label} 보기",
  subscribe: { idle: "구독하기", email: "이메일 주소", submit: "릴리스 노트 구독하기", invalid: "올바른 이메일 주소를 입력해 주세요", done: "구독했어요", doneNote: "구독했어요.", undo: "되돌리기", undoLabel: "{email} 구독 취소하기" },
};

/** Sample photos load by URL from public/media, so the block installs without binary imports. */
const ceramics = photo("terracotta-waves"), journal = photo("alpine-lake"), bottle = photo("wine-bar"), interior = photo("living-room"), seaAtDusk = photo("sea-at-dusk");

const exampleMonths: Month[] = [
  { key: "2026-09", label: "2026년 9월", short: "9월" },
  { key: "2026-08", label: "2026년 8월", short: "8월" },
  { key: "2026-07", label: "2026년 7월", short: "7월" },
  { key: "2026-06", label: "2026년 6월", short: "6월" },
];

const webhookSnippet = `import { verifyWebhook } from "@halden/node";

const event = verifyWebhook(body, headers, secret);

if (event.type === "order.paid") {
  await fulfil(event.data.order); // ord_7Hq2Lx
}`;

const dnsSnippet = `Type    Name       Value
CNAME   shop       edge.halden.example
TXT     _halden    verify=hd_4f81c2`;

const exampleEntries: Entry[] = [
  { id: "masonry", month: "2026-09", date: "9월 18일", iso: "2026-09-18", version: "4.12.0", kind: "new", title: "벽돌쌓기형 갤러리 레이아웃", summary: "세로 사진과 가로 사진을 섞어도 한 장도 잘리지 않아요.", details: ["화면 크기마다 열을 2개에서 5개까지 고를 수 있어요.", "이미지를 끌면 움직이는 동안 열이 다시 균형을 맞춰요.", "캡션이 없으면 이미지 대체 텍스트를 보여 줘요."], media: { type: "photo", src: ceramics.src, width: ceramics.width, height: ceramics.height, alt: "파란 하늘을 향해 솟은 물결무늬 테라코타 벽", caption: "가마터 도예 공방 갤러리, 부산" } },
  { id: "resumable", month: "2026-09", date: "9월 18일", iso: "2026-09-18", version: "4.12.0", kind: "improved", title: "연결이 끊겨도 업로드를 이어서 해요", summary: "큰 파일은 처음부터 다시 올리지 않고 마지막으로 끝낸 조각부터 이어서 올려요.", details: ["파일을 8MB 조각으로 나눠 올리고, 조각마다 최대 5번 다시 시도해요.", "페이지를 새로 고쳐도 업로드 대기열이 남아 있어요."] },
  { id: "currency", month: "2026-09", date: "9월 9일", iso: "2026-09-09", version: "4.11.2", kind: "fixed", title: "통화를 바꿔도 할인 코드가 유지돼요", summary: "결제 중 원화에서 달러로 바꿔도 적용한 코드가 사라지지 않아요.", details: ["4.11.0 이후 결제의 0.4%에서 생긴 문제였어요.", "이제 통화를 바꿀 때마다 서버에서 합계를 다시 계산해요."] },
  { id: "webhooks", month: "2026-09", date: "9월 2일", iso: "2026-09-02", version: "4.11.0", kind: "new", title: "주문 웹훅", summary: "주문이 결제, 환불, 배송되면 서명된 요청을 받아요.", details: ["모든 요청에 시각이 담긴 서명이 붙어요.", "전송에 실패하면 72시간 동안 간격을 늘려 가며 다시 보내요."], media: { type: "code", file: "webhooks.ts", code: webhookSnippet } },
  { id: "covers", month: "2026-08", date: "8월 26일", iso: "2026-08-26", version: "4.10.0", kind: "new", title: "전체 폭 커버가 있는 저널 글", summary: "모든 화면에서 가장자리까지 꽉 차는 사진 한 장으로 이야기를 시작해요.", details: ["초점 위치를 정하면 좁은 폰 화면에서도 자르는 위치가 유지돼요.", "커버는 흐릿한 미리보기를 먼저 보여 주고 곧 원본으로 바뀌어요."], media: { type: "photo", src: journal.src, width: journal.width, height: journal.height, alt: "노을 무렵 바위산이 비치는 고요한 알프스 호수", caption: "스튜디오 온새미로의 저널 커버, 서울" } },
  { id: "avif", month: "2026-08", date: "8월 26일", iso: "2026-08-26", version: "4.10.0", kind: "improved", title: "모바일에서 상품 페이지가 38% 빨라졌어요", summary: "이미지를 AVIF로 보내고 기기마다 알맞은 크기를 골라 줘요.", details: ["최대 콘텐츠 표시 시간의 중앙값이 2.9초에서 1.8초로 줄었어요.", "오래된 브라우저에는 WebP나 JPEG로 보내요."] },
  { id: "inventory", month: "2026-08", date: "8월 14일", iso: "2026-08-14", version: "4.9.3", kind: "fixed", title: "세일 중에 재고가 음수가 되지 않아요", summary: "동시에 결제해도 재고를 한 번에 확보해요.", details: ["두 사람이 같은 순간에 마지막 상품을 살 수 없어요.", "8월 9일부터 13일까지 초과 판매된 주문은 자동으로 환불했어요."] },
  { id: "shortcuts", month: "2026-08", date: "8월 5일", iso: "2026-08-05", version: "4.9.0", kind: "improved", title: "에디터 단축키", summary: "마우스 없이 블록을 옮기고, 복제하고, 게시해요.", details: ["어디서든 ⌘K로 명령 메뉴를 열어요.", "⌘D로 선택한 블록을 복제해요.", "⇧⌘P로 현재 페이지를 게시해요."] },
  { id: "variants", month: "2026-07", date: "7월 22일", iso: "2026-07-22", version: "4.8.0", kind: "new", title: "옵션별 사진", summary: "색상이나 크기마다 상품 페이지에 자기 갤러리를 보여 줄 수 있어요.", details: ["구매자가 옵션을 고르면 사진이 바로 바뀌어요.", "사진이 없는 옵션은 상품 갤러리를 대신 보여 줘요."], media: { type: "photo", src: bottle.src, width: bottle.width, height: bottle.height, alt: "저녁 햇살이 드는 바 테이블 위의 레드 와인 병", caption: "발효연구소의 와인 500ml, 대구" } },
  { id: "timezone", month: "2026-07", date: "7월 10일", iso: "2026-07-10", version: "4.7.1", kind: "fixed", title: "예약 게시가 스튜디오 시간대를 따라요", summary: "서울 기준 오전 9시로 예약한 글이 UTC 오전 9시에 올라가지 않아요.", details: ["기존 예약은 7월 10일에 바로잡았어요.", "예약 화면의 모든 시간 옆에 시간대를 보여 줘요."] },
  { id: "domains", month: "2026-07", date: "7월 3일", iso: "2026-07-03", version: "4.7.0", kind: "improved", title: "커스텀 도메인을 1분 안에 확인해요", summary: "DNS 레코드 두 개를 추가하면 하든이 몇 초마다 확인해요.", details: ["레코드가 확인되는 즉시 인증서를 발급해요.", "예전에는 확인에 최대 한 시간이 걸렸어요."], media: { type: "code", file: "DNS 레코드", code: dnsSnippet } },
  { id: "rooms", month: "2026-06", date: "6월 24일", iso: "2026-06-24", version: "4.6.0", kind: "new", title: "쇼핑 가능한 공간 사진", summary: "인테리어 사진 위에 상품을 바로 태그하고, 태그마다 결제 페이지를 연결해요.", details: ["사진을 자르거나 크기를 바꿔도 태그가 따라가요.", "사진 한 장에 상품을 최대 12개까지 달 수 있어요."], media: { type: "photo", src: interior.src, width: interior.width, height: interior.height, alt: "목재 보와 아치형 창, 크림색 소파가 있는 밝은 거실", caption: "메종 오베르의 공간, 제주" } },
  { id: "csv", month: "2026-06", date: "6월 12일", iso: "2026-06-12", version: "4.5.2", kind: "fixed", title: "CSV 내보내기에서 이름이 깨지지 않아요", summary: "홍길동, Zoë, Håkon 같은 이름이 모든 스프레드시트 앱에서 제대로 열려요.", details: ["내보낸 파일은 바이트 순서 표시가 있는 UTF-8로 저장해요.", "4.5.0 이후에 만든 파일은 다시 내보내 주세요."] },
  { id: "search", month: "2026-06", date: "6월 3일", iso: "2026-06-03", version: "4.5.0", kind: "improved", title: "미디어 검색이 피사체와 색상을 이해해요", summary: "사진에 담긴 내용을 입력하면 라이브러리에서 찾아 줘요.", details: ["이미지 4만 장을 200ms 안에 검색해요.", "거의 같은 사진은 묶어서 한 장만 보여 줘요."], media: { type: "photo", src: seaAtDusk.src, width: seaAtDusk.width, height: seaAtDusk.height, alt: "수평선 위로 낮은 섬이 보이는 해 질 녘의 잔잔한 바다", caption: "미디어 라이브러리에서 “해 질 녘 바다”를 검색한 결과" } },
];


const blurSoft = `blur(${motionTokens.blur.soft}px)`;
const blurSubtle = `blur(${motionTokens.blur.subtle}px)`;
const none = "blur(0px)";
const instant: Transition = { duration: 0 };
const exitSpring: Transition = motionTokens.spring.settle;

/** Values rise when they grow and fall when they shrink; the outgoing value leaves the opposite way. */
const roll: Variants = {
  enter: (direction: number) => ({ opacity: 0, y: `${direction * 55}%`, filter: blurSubtle }),
  center: { opacity: 1, y: "0%", filter: none },
  exit: (direction: number) => ({ opacity: 0, y: `${direction * -55}%`, filter: blurSubtle }),
};
const rise: Variants = {
  enter: (direction: number) => ({ opacity: 0, y: direction * 14, filter: blurSoft }),
  center: { opacity: 1, y: 0, filter: none },
  exit: (direction: number) => ({ opacity: 0, y: direction * -14, filter: blurSoft, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } }),
};

/** "2026년 9월"처럼 연도와 월이 함께 있으면 연도를 흐리게, 월을 진하게 보여 줘요. */
function monthLabel(label: string) {
  const [year, ...rest] = label.split(" ");
  return rest.length ? <><span className={styles.monthYear}>{year} </span>{rest.join(" ")}</> : label;
}

function RollingNumber({ value, reduce }: { value: number; reduce: boolean }) {
  const [state, setState] = useState({ value, direction: 1 });
  if (state.value !== value) setState({ value, direction: value > state.value ? 1 : -1 });
  return <span className={styles.roll}>
    <AnimatePresence mode="popLayout" initial={false} custom={state.direction}>
      <motion.span key={value} custom={state.direction} variants={roll} initial="enter" animate="center" exit="exit" transition={reduce ? instant : motionTokens.spring.snappy}>{value}</motion.span>
    </AnimatePresence>
  </span>;
}

const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;
type SubscribeState = "idle" | "editing" | "done";

/** One pill that becomes a field, then a confirmation. The shell morphs its width; the content swaps a beat later inside it. */
function SubscribeControl({ reduce, labels }: { reduce: boolean; labels: ChangelogLabels["subscribe"] }) {
  const inputId = useId();
  const [state, setState] = useState<SubscribeState>("idle");
  const [email, setEmail] = useState("");
  const [error, setError] = useState(false);
  const [width, setWidth] = useState<number | null>(null);
  const innerRef = useRef<HTMLDivElement>(null);
  const inputRef = useRef<HTMLInputElement>(null);
  const idleRef = useRef<HTMLButtonElement>(null);
  const undoRef = useRef<HTMLButtonElement>(null);
  const focusNext = useRef(false);
  const [shakeScope, animate] = useAnimate<HTMLDivElement>();

  useEffect(() => {
    const element = innerRef.current;
    if (!element) return;
    const observer = new ResizeObserver(() => setWidth(element.offsetWidth));
    observer.observe(element);
    return () => observer.disconnect();
  }, []);

  useEffect(() => {
    if (!focusNext.current) return;
    focusNext.current = false;
    const target = state === "editing" ? inputRef.current : state === "done" ? undoRef.current : idleRef.current;
    target?.focus({ preventScroll: true });
  }, [state]);

  function go(next: SubscribeState, focus = true) { focusNext.current = focus; setState(next); }
  function submit(event: FormEvent<HTMLFormElement>) {
    event.preventDefault();
    if (!emailPattern.test(email.trim())) {
      playUiSound("error");
      setError(true);
      if (!reduce && shakeScope.current) animate(shakeScope.current, { x: [0, -6, 5, -3, 2, 0] }, { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.standard] });
      inputRef.current?.focus();
      return;
    }
    playUiSound("click");
    setError(false);
    go("done");
  }
  function onKeyDown(event: KeyboardEvent<HTMLFormElement>) { if (event.key === "Escape") { playUiSound("close"); setError(false); go("idle"); } }
  function onBlur(event: FocusEvent<HTMLFormElement>) { if (!event.currentTarget.contains(event.relatedTarget) && !email.trim()) { playUiSound("close"); setError(false); go("idle", false); } }
  function undo() { playUiSound("click"); setEmail(""); go("idle"); }

  const swap: Transition = reduce ? { duration: motionTokens.duration.instant } : { opacity: { duration: motionTokens.duration.fast, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.fast, delay: motionTokens.delay.micro }, scale: { ...motionTokens.spring.morph, delay: 0.03 } };
  const leave = { opacity: 0, scale: 0.96, filter: blurSubtle, transition: { duration: motionTokens.duration.instant } };
  const note = error ? labels.invalid : state === "done" ? labels.doneNote : "";

  return <div className={styles.subscribeWrap}>
    <div ref={shakeScope}>
      <motion.div className={styles.subscribe} data-state={state} data-error={error || undefined} initial={false} animate={{ width: width ?? "auto" }} transition={reduce ? instant : motionTokens.spring.morph}>
        <div ref={innerRef} className={styles.subscribeInner}>
          <AnimatePresence mode="popLayout" initial={false}>
            {state === "idle" && <motion.button key="idle" ref={idleRef} type="button" className={styles.subscribeIdle} onClick={() => { playUiSound("open"); go("editing"); }} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
              <UiIcon iconRole="notification" size={14} aria-hidden="true" />{labels.idle}
            </motion.button>}
            {state === "editing" && <motion.form key="form" className={styles.subscribeForm} noValidate onSubmit={submit} onKeyDown={onKeyDown} onBlur={onBlur} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
              <label className={styles.srOnly} htmlFor={inputId}>{labels.email}</label>
              <input ref={inputRef} id={inputId} type="email" inputMode="email" autoComplete="email" placeholder="you@example.com" value={email} aria-invalid={error || undefined} onChange={(event) => { setEmail(event.target.value); if (error) setError(false); }} />
              <button type="submit" className={styles.subscribeSubmit} aria-label={labels.submit}><UiIcon iconRole="arrow-right" size={14} /></button>
            </motion.form>}
            {state === "done" && <motion.div key="done" className={styles.subscribeDone} initial={{ opacity: 0, scale: 0.96, filter: blurSubtle }} animate={{ opacity: 1, scale: 1, filter: none }} exit={leave} transition={swap}>
              <svg className={styles.drawnCheck} width={15} height={15} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2.2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
                <motion.path d="M4 12.5l5 5L20 6.5" initial={{ pathLength: 0 }} animate={{ pathLength: 1 }} transition={reduce ? instant : { duration: motionTokens.duration.considered, ease: [...motionTokens.ease.standard], delay: motionTokens.duration.instant }} />
              </svg>
              <span>{labels.done}</span>
              <button ref={undoRef} type="button" className={styles.subscribeUndo} onClick={undo} aria-label={fill(labels.undoLabel, { email: email.trim() })}>{labels.undo}</button>
            </motion.div>}
          </AnimatePresence>
        </div>
      </motion.div>
    </div>
    <p className={styles.subscribeNote} data-error={error || undefined} role="status" aria-live="polite">
      <AnimatePresence mode="popLayout" initial={false}>
        {note && <motion.span key={note} initial={{ opacity: 0, y: -4, filter: blurSubtle }} animate={{ opacity: 1, y: 0, filter: none }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter] }}>{note}</motion.span>}
      </AnimatePresence>
    </p>
  </div>;
}

function EntryMedia({ media }: { media: Media }) {
  if (media.type === "photo") {
    return <figure className={styles.photo}>
      <div className={styles.photoFrame}>{typeof media.src === "string"
        ? <Image src={media.src} alt={media.alt} width={media.width ?? 1600} height={media.height ?? 1067} sizes="(max-width: 700px) 100vw, 560px" />
        : <Image src={media.src} alt={media.alt} placeholder="blur" sizes="(max-width: 700px) 100vw, 560px" />}</div>
      <figcaption>{media.caption}</figcaption>
    </figure>;
  }
  return <div className={styles.code}>
    <div className={styles.codeHead}><span>{media.file}</span><CopyButton value={media.code} label={`${media.file} 복사하기`} iconOnly variant="plain" /></div>
    <pre><code>{media.code}</code></pre>
  </div>;
}

function EntryRow({ entry, open, onToggle, reduce, kindLabel }: { entry: Entry; open: boolean; onToggle: () => void; reduce: boolean; kindLabel: string }) {
  const panelId = useId();
  const layout: Transition = reduce ? instant : motionTokens.spring.smooth;
  return <motion.li
    layout="position"
    className={styles.entry}
    data-open={open || undefined}
    initial={reduce ? { opacity: 0 } : { opacity: 0, filter: blurSubtle }}
    animate={{ opacity: 1, filter: none }}
    exit={reduce ? { opacity: 0, transition: { duration: motionTokens.duration.instant } } : { opacity: 0, scale: 0.98, filter: blurSubtle, transition: { duration: motionTokens.duration.exit, ease: [...motionTokens.ease.standard] } }}
    transition={reduce ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { opacity: { duration: motionTokens.duration.standard }, filter: { duration: motionTokens.duration.standard }, layout }}
  >
    <button type="button" className={styles.row} aria-expanded={open} aria-controls={panelId} onClick={onToggle}>
      <span className={styles.meta}>
        <time dateTime={entry.iso}>{entry.date}</time>
        <span className={styles.version}>v{entry.version}</span>
      </span>
      <span className={styles.body}>
        <span className={styles.kind} data-kind={entry.kind}><i aria-hidden="true" />{kindLabel}</span>
        <span className={styles.title}>{entry.title}</span>
        <span className={styles.summary}>{entry.summary}</span>
      </span>
      <motion.span className={styles.chevron} aria-hidden="true" initial={false} animate={{ rotate: open ? 180 : 0 }} transition={reduce ? instant : motionTokens.spring.snappy}><UiIcon iconRole="chevron-down" size={16} /></motion.span>
    </button>
    <AnimatePresence initial={false}>
      {open && <motion.div key="panel" id={panelId} className={styles.panel} initial={{ height: 0 }} animate={{ height: "auto" }} exit={{ height: 0, transition: reduce ? instant : exitSpring }} transition={reduce ? instant : motionTokens.spring.smooth}>
        <motion.div
          className={styles.panelInner}
          initial={{ opacity: 0, y: 8, filter: blurSoft }}
          animate={{ opacity: 1, y: 0, filter: none }}
          exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }}
          transition={reduce ? { duration: 0, opacity: { duration: motionTokens.duration.instant } } : { y: { ...motionTokens.spring.smooth, delay: motionTokens.delay.micro }, opacity: { duration: motionTokens.duration.standard, delay: motionTokens.delay.micro }, filter: { duration: motionTokens.duration.standard, delay: motionTokens.delay.micro } }}
        >
          <ul className={styles.details}>{entry.details.map((detail, index) => <li key={index}>{detail}</li>)}</ul>
          {entry.media && <EntryMedia media={entry.media} />}
        </motion.div>
      </motion.div>}
    </AnimatePresence>
  </motion.li>;
}

export interface ChangelogFeedProps {
  /** Release notes, newest first. Each entry belongs to one of `months` by key. */
  entries?: ChangelogEntry[];
  /** Months shown in the month bar, newest first. */
  months?: ChangelogMonth[];
  /** The heading of the feed. Pass `null` where the page already has its own title: the feed then shows only the subtitle and is named by `labels.list`. */
  title?: string | null;
  /** One line under the title, such as the latest release. */
  subtitle?: string;
  /** Closing line at the end of the list. */
  endNote?: string;
  /** 처음에 열려 있는 항목의 id예요. 기본은 가장 최근 항목이에요. */
  defaultOpen?: string[];
  /** The feed's own words, for another language or another tone. A group is usually a month; pass versions as `months` to group by release. */
  labels?: Partial<Omit<ChangelogLabels, "kinds" | "subscribe">> & { kinds?: Partial<ChangelogLabels["kinds"]>; subscribe?: Partial<ChangelogLabels["subscribe"]> };
  /** Shows the subscribe control beside the title. Turn it off where there is nothing to subscribe to. Defaults to true. */
  showSubscribe?: boolean;
  className?: string;
}

export function ChangelogFeed({
  entries = exampleEntries,
  months = exampleMonths,
  title = "변경 내역",
  subtitle = "하든 4.12가 2026년 9월 18일에 나왔어요",
  endNote = "6월의 하든 4.5 이후 변경 내역은 여기까지예요.",
  defaultOpen,
  labels: labelOverrides,
  showSubscribe = true,
  className,
}: ChangelogFeedProps = {}) {
  const uid = useId();
  const labels: ChangelogLabels = useMemo(
    () => ({ ...defaultLabels, ...labelOverrides, kinds: { ...defaultLabels.kinds, ...labelOverrides?.kinds }, subscribe: { ...defaultLabels.subscribe, ...labelOverrides?.subscribe } }),
    [labelOverrides],
  );
  const kinds = useMemo(() => kindIds.map((id) => ({ id, label: labels.kinds[id] })), [labels]);
  const totalCount = entries.length;
  const kindCounts = useMemo(() => kinds.reduce<Record<Kind, number>>((counts, kind) => ({ ...counts, [kind.id]: entries.filter((entry) => entry.kind === kind.id).length }), { new: 0, improved: 0, fixed: 0 }), [entries, kinds]);
  const monthOrder = useCallback((key: string) => months.findIndex((month) => month.key === key), [months]);
  const reduce = useReducedMotion() ?? false;
  const [filters, setFilters] = useState<Kind[]>([]);
  const [open, setOpen] = useState<string[]>(() => defaultOpen ?? (entries[0] ? [entries[0].id] : []));
  const [active, setActive] = useState({ key: months[0]?.key ?? "", direction: 1 });
  const scrollRef = useRef<HTMLDivElement>(null);
  const listRef = useRef<HTMLDivElement>(null);
  const groupRefs = useRef(new Map<string, HTMLElement>());
  const frame = useRef(0);

  const visible = useMemo(() => entries.filter((entry) => filters.length === 0 || filters.includes(entry.kind)), [entries, filters]);
  const groups = useMemo(() => months.map((month) => ({ ...month, items: visible.filter((entry) => entry.month === month.key) })).filter((group) => group.items.length > 0), [months, visible]);
  const current = groups.find((group) => group.key === active.key) ?? groups[0];

  const sync = useCallback(() => {
    const scroller = scrollRef.current;
    if (!scroller || groups.length === 0) return;
    const line = scroller.getBoundingClientRect().top + 28;
    let key = groups[0]!.key;
    for (const group of groups) {
      const element = groupRefs.current.get(group.key);
      if (element && element.getBoundingClientRect().top <= line) key = group.key;
    }
    if (scroller.scrollTop > 0 && scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2) key = groups[groups.length - 1]!.key;
    setActive((previous) => previous.key === key ? previous : { key, direction: monthOrder(key) > monthOrder(previous.key) ? 1 : -1 });
  }, [groups, monthOrder]);

  const onScroll = useCallback(() => {
    if (frame.current) return;
    frame.current = requestAnimationFrame(() => { frame.current = 0; sync(); });
  }, [sync]);

  useEffect(() => {
    const list = listRef.current;
    if (!list) return;
    const observer = new ResizeObserver(() => onScroll());
    observer.observe(list);
    return () => observer.disconnect();
  }, [onScroll]);
  useEffect(() => () => cancelAnimationFrame(frame.current), []);

  function toggleFilter(kind: Kind) { playUiSound("tick"); setFilters((previous) => previous.includes(kind) ? previous.filter((item) => item !== kind) : [...previous, kind]); }
  function toggleEntry(id: string) { playUiSound("tick"); setOpen((previous) => previous.includes(id) ? previous.filter((item) => item !== id) : [...previous, id]); }
  function jumpTo(key: string) {
    const scroller = scrollRef.current;
    const element = groupRefs.current.get(key);
    if (!scroller || !element) return;
    playUiSound("tick");
    const top = scroller.scrollTop + element.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
    scroller.scrollTo({ top: Math.max(0, top - 1), behavior: reduce ? "auto" : "smooth" });
  }

  const layout: Transition = reduce ? instant : motionTokens.spring.smooth;

  return <section className={[styles.feed, className].filter(Boolean).join(" ")} aria-labelledby={title ? `${uid}-title` : undefined} aria-label={title ? undefined : labels.list}>
    <header className={styles.header}>
      <div className={styles.heading}>
        {title && <h2 id={`${uid}-title`}>{title}</h2>}
        {subtitle && <p>{subtitle}</p>}
      </div>
      {showSubscribe && <SubscribeControl reduce={reduce} labels={labels.subscribe} />}
    </header>

    <div className={styles.toolbar}>
      <div className={styles.chips} role="group" aria-label={labels.filterGroup}>
        {kinds.map((kind) => {
          const on = filters.includes(kind.id);
          return <button key={kind.id} type="button" className={styles.chip} data-kind={kind.id} aria-pressed={on} onClick={() => toggleFilter(kind.id)}>
            <motion.span className={styles.chipMark} aria-hidden="true" initial={false} animate={{ width: on ? 16 : 7, height: on ? 16 : 7 }} transition={reduce ? instant : motionTokens.spring.morph}>
              <motion.span className={styles.chipTick} initial={false} animate={{ opacity: on ? 1 : 0, scale: on ? 1 : 0.4 }} transition={reduce ? instant : { ...motionTokens.spring.snappy, delay: on ? motionTokens.delay.micro : 0 }}><UiIcon iconRole="check" size={11} strokeWidth={2.6} /></motion.span>
            </motion.span>
            <span>{kind.label}</span>
            <span className={styles.chipCount}>{kindCounts[kind.id]}</span>
          </button>;
        })}
        <AnimatePresence initial={false}>
          {filters.length > 0 && <motion.button key="clear" type="button" className={styles.clear} onClick={() => { playUiSound("tick"); setFilters([]); }} initial={{ opacity: 0, x: -6, filter: blurSubtle }} animate={{ opacity: 1, x: 0, filter: none }} exit={{ opacity: 0, x: -4, filter: blurSubtle, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : motionTokens.spring.snappy}>{labels.clearFilters}</motion.button>}
        </AnimatePresence>
      </div>
      <p className={styles.shown} aria-hidden="true">
        <motion.span className={styles.shownCount} layout="position" transition={layout}><RollingNumber value={visible.length} reduce={reduce} /></motion.span>
        <AnimatePresence mode="popLayout" initial={false}>
          <motion.span key={filters.length === 0 ? "all" : "some"} initial={{ opacity: 0, filter: blurSubtle }} animate={{ opacity: 1, filter: none }} exit={{ opacity: 0, transition: { duration: motionTokens.duration.instant } }} transition={reduce ? instant : { duration: motionTokens.duration.standard }}>{filters.length === 0 ? labels.shownAll : fill(labels.shownSome, { total: totalCount })}</motion.span>
        </AnimatePresence>
      </p>
      <p className={styles.srOnly} role="status" aria-live="polite">{fill(filters.length > 0 ? labels.statusSome : labels.statusAll, { total: totalCount, shown: visible.length })}</p>
    </div>

    <div className={styles.monthBar}>
      <div className={styles.monthLabel} aria-hidden="true">
        <span className={styles.monthText}>
          <AnimatePresence mode="popLayout" initial={false} custom={active.direction}>
            <motion.span key={current?.key ?? "none"} custom={active.direction} variants={rise} initial="enter" animate="center" exit="exit" transition={reduce ? instant : motionTokens.spring.morph}>{current ? monthLabel(current.label) : labels.empty}</motion.span>
          </AnimatePresence>
        </span>
        <motion.span className={styles.monthCount} layout="position" transition={layout}><RollingNumber value={current?.items.length ?? 0} reduce={reduce} />{labels.updatesAfterNumber}</motion.span>
      </div>
      <nav className={styles.monthNav} aria-label={labels.groupNav}>
        {groups.map((group) => {
          const selected = group.key === current?.key;
          return <button key={group.key} type="button" aria-label={fill(labels.viewGroup, { label: group.label })} aria-current={selected ? "true" : undefined} onClick={() => jumpTo(group.key)}>
            {selected && <motion.span layoutId={`${uid}-month`} className={styles.monthPill} transition={reduce ? instant : motionTokens.spring.morph} />}
            <span className={styles.monthShort}>{group.short}</span>
          </button>;
        })}
      </nav>
    </div>

    <motion.div layoutScroll ref={scrollRef} className={styles.scroller} onScroll={onScroll} tabIndex={0} aria-label={labels.list}>
      <div ref={listRef} className={styles.list}>
        <AnimatePresence mode="popLayout" initial={false}>
          {groups.map((group, index) => <motion.section
            key={group.key}
            layout="position"
            className={styles.group}
            aria-labelledby={`${uid}-${group.key}`}
            ref={(element: HTMLElement | null) => { if (element) groupRefs.current.set(group.key, element); else groupRefs.current.delete(group.key); }}
            initial={{ opacity: 0 }}
            animate={{ opacity: 1 }}
            exit={{ opacity: 0, transition: { duration: motionTokens.duration.exit } }}
            transition={{ layout, opacity: { duration: reduce ? motionTokens.duration.instant : motionTokens.duration.standard } }}
          >
            <h3 id={`${uid}-${group.key}`} className={index === 0 ? styles.srOnly : styles.groupTitle}>{group.label}</h3>
            <ul className={styles.entries}>
              <AnimatePresence mode="popLayout" initial={false}>
                {group.items.map((entry) => <EntryRow key={entry.id} entry={entry} open={open.includes(entry.id)} onToggle={() => toggleEntry(entry.id)} reduce={reduce} kindLabel={labels.kinds[entry.kind]} />)}
              </AnimatePresence>
            </ul>
          </motion.section>)}
        </AnimatePresence>
        <motion.p layout="position" transition={{ layout }} className={styles.endCap}>{endNote}</motion.p>
      </div>
    </motion.div>
  </section>;
}

export default ChangelogFeed;

```
