v0.41.1

All releases

Readable release notes. The changelog on the site is now a list of short summaries, one per release, saying what each release is for. Every release has its own page with the specs and bugs behind it as drawers and the full list of changes (). This release also fixes plan validate --against, which missed an ID collision with anything main created after a branch split off (), and plan create milestone, which named the file after the title instead of the version ().

Changes

  • An open drawer on a page using the default layout now reaches the top and bottom of the viewport. The layout's section spacing (* + [data-rune], 4rem above and below each rune block in an article) also matched the drawer's <dialog>, which the drawer pipeline places at the end of the article, and its specificity beat the drawer's own margin: 0. The panel therefore stopped 4rem short of both edges. Both section-spacing rules now skip dialog, so prose after a closed drawer gains no gap either. The no-JS fallback, a <section> in the flow, keeps its spacing, and docs-layout pages were never affected.
  • refrakt plan validate --against <ref> now catches the collision it was built for: an ID the base branch took after your branch split off, while your branch took the same one. It used to read any base file missing from your working tree as one your branch had deleted, which also skipped every file the base added later, so two branches that each claimed the next free ID passed with "No ID collisions".
  • The check now reads the merge-base of HEAD and the ref. A base file that existed there and is gone from your tree was deleted or renamed by your branch, and is still not a collision; one that did not exist there is. A file the base renamed or deleted after the branch point is not a collision either.
  • Because it needs the merge-base, a ref with no shared history, or a shallow clone that stops short of it, is now an error rather than a clean run. Fetch the history first (git fetch --unshallow, or fetch-depth: 0 with actions/checkout).
  • refrakt plan create milestone --id v1.2.0 --title "…" now writes milestones/v1.2.0.md. It used to name the file after the title (milestones/<title-slug>.md), contrary to the convention that a milestone's filename is its name, and two milestones with the same title could not both be created. plan init, and so create-refrakt --type plan, now writes its example milestone as milestones/v0.1.0.md instead of milestones/first-release.md.
  • refrakt plan migrate filenames now renames a milestone whose filename is not <name>.md, so a plan directory that picked up title-named milestones can be repaired with --apply --git. It used to skip milestones entirely. Its result no longer has a skipped.milestones count; milestones are counted under skipped.alreadyCorrect or planned like any other file.

Release notes on the site changelog

SPEC-176implemented

Release notes on the site changelog

The site changelog (site/content/releases.md, before this spec) is generated from the per-package changesets and reads like it. It is 1,508 lines over 75 versions, every entry a flat list of bullets written for someone reading a diff. v0.41.0 has around 30, and nothing distinguishes what the release is for from an incidental export added along the way, or says what a user has to do after upgrading.

The plan already knows what each release was for. Every minor release from v0.9.0 to v0.41.0 has a milestone, and each milestone opens with a title and a paragraph saying what it delivers and which specs it implements. This spec gives every release a short, user-facing summary, lists those summaries on /releases, and gives each release a page of its own with the plan entities behind it as drawers and the full list of changes.

sitechangelogreleaseplandocs

Shape of the pages

The index, /releases, one entry per release:

## v0.41.0 - October 9, 2026

**Composed runes you can ship.** Two or three sentences, written for users, on what the
release is for (SPEC-153).

[All 33 changes in v0.41.0 →](/releases/v0.41.0)

A release page, /releases/v0.41.0:

# v0.41.0

Released October 9, 2026 · [All releases](/releases)

**Composed runes you can ship.** The same summary, with its refs as drawers
({% ref "SPEC-153" preview="drawer" /%}).

{% hint type="warning" %}
**Action needed:** only when upgrading needs a change from the reader.
{% /hint %}

## Changes

- the changeset bullets, exactly as generated today

Decisions

D1 — The changelog stays fully generated

Notes are merged in by scripts/generate-changelog.mjs, which already runs in npm run version-packages, so the changesets action runs it when it opens the "chore: version packages" PR. Nobody edits site/content/releases/ by hand, and it stays listed under stale.generated and stale.archival in refrakt.config.json.

D2 — One note per release, in releases/notes/

A note is a Markdoc fragment at releases/notes/v<version>.md, at the repository root. It sits outside site/content so it gets no route of its own, and outside plan/ because it is written for users, not for planning. The generator places its content verbatim as the release's summary. Unlike changeset text, it is not sanitised: its runes are meant to be live. A # or ## heading is rejected, naming the file, because ## would start a new release inside {% changelog %}.

A note for a version that has no changelog entry yet (written ahead of the release) is ignored, not an error.

D3 — Without a note, use the milestone's opening

When no note file exists and a milestone is named after the version, the generator uses the milestone's title (the text after the — in its # heading) in bold, followed by its first paragraph. On the release page, every plain {% ref %} in that paragraph gets preview="drawer", because plan entities have no page on the main site and a plain ref there renders as unlinked text.

The opening is written for maintainers, so it is a fallback, not the goal: a release always gets a summary, and a handwritten note replaces it. A release with neither a note nor a milestone (most patch releases) gets no summary, only the link to its changes.

D4 — The index summarises; each release has its own page

Revised during WORK-641. The first version folded each release's changes into {% details %} on one page.

That version worked, and measured badly. A drawer embeds its entity's whole rendered body, and SvelteKit serialises it into the page data a second time. Plan specs are long design documents, so the 30 drawers the milestone openings produced took /releases from 2.0 MB to 6.8 MB, and the page would grow with every release.

So drawers live where they are read:

  • /releases (site/content/releases/index.md) lists every release with its summary and a link to its page. Refs are flattened to their label or ID as plain text, so the index carries no drawers; a file-ref keeps its link and loses its drawer preview. A summary paragraph opening with Background:, the drawer list the draft command writes, is left out, because flattened it is a row of IDs that link nowhere. It measured 377 KB.
  • /releases/v<version> carries the summary with its drawers and the full list of changes under ## Changes. A release gets a page when it has a summary or at least one change.

The changeset bullets keep today's processing (parseChangelog, processBulletBlock, fence and table stripping). A page whose version no longer has a changelog entry is removed when the generator runs.

released-in is set on a spec after the release ships, so it is empty while the note is being written. The milestone links exist from the start: the work and bug items with milestone="vX.Y.Z", and their source specs. A draft command (WORK-643) writes those as drawer refs, so the person releasing writes only the opening sentences and decides whether an action-needed block is needed. The draft marks what is left with TODO: lines, since refrakt content has no comment syntax, and a test fails while any remains in a note, so an unfinished draft cannot ship.

D6 — Refs in notes are checked in tests

refrakt validate --deep does not report a {% ref %} to an ID that does not exist; only the site build does, as one warning among hundreds. A test in scripts/ fails when a note references an ID the plan does not declare, so a mistyped ID fails CI instead of shipping a dead drawer.

D7 — Plan entities are shown only as drawers

The plan site is being retired, and plan content on the main site will be shown only through drawers. So a drawer has no "view full page" footer link and refs nested inside a drawer body render as unlinked text. Both are acceptable, and neither renders a broken link. This spec does not add page URLs for plan entities on the main site.

The SvelteKit prerender crawls drawer bodies like any other content, so a broken link inside a drawn entity fails the site build. WORK-641 hit one: SPEC-054 linked SPEC-046 as [SPEC-046](/plan/specs/SPEC-046), a path from the old plan site. Plan content links other plan entities with {% ref %}, which cannot break this way; a hard-coded path into the plan site is a defect to fix where it is found.

Scope of the migration

The new generator applies to every version at once, because the pages are regenerated from the changelogs. The summary is filled as follows:

ReleasesCountSummary
Milestone, no note27Milestone opening (D3)
v0.39.0, v0.40.0, v0.41.03Handwritten notes
v0.11.0, v0.12.0, v0.24.0, v0.31.0, v0.36.0 to v0.38.07Notes replacing an opening that did not read as a summary (WORK-644)
v0.4.0 to v0.9.0 minor releases6One-line note linking the whats-new-in-0-x blog post
Patch releases~28None, only the link to their changes
v0.2.0, v0.3.0, v0.10.03None

The 38 milestone openings are reviewed; one that reads badly as a summary gets a note file, and the milestone itself is left alone.

Non-goals

  • Rewriting existing changeset text, or writing notes for every past release.
  • Feeding notes into the npm or GitHub release bodies. The changesets action writes those from the changesets, and that is unchanged.
  • Making refrakt validate --deep catch unresolved refs, and the styling and build-log volume of unresolved refs inside drawers. Each is worth fixing on its own; D6 covers what release notes need.

Implementation

  • WORK-641: the generator merges notes, falls back to the milestone opening, and writes the index and the release pages (D1–D4).
  • WORK-642: the test that every ref in a note resolves (D6).
  • WORK-643: the draft command and the RELEASING.md step (D5).
  • WORK-644: the backfill and the review of milestone openings.

plan validate --against misses a collision with an ID the base added after the branch point

BUG-040fixed

plan validate --against misses a collision with an ID the base added after the branch point

refrakt plan validate --against <ref> exists to catch an ID collision on the branch, before the merge makes every reference to that ID ambiguous (SPEC-135 D8, WORK-582). It does not catch the most common one: two branches that each create the next free ID. It reports "No ID collisions" and exits 0, so the PR job passes it.

The cause is the deletion check in collisionsFrom (plugins/plan/src/commands/against.ts):

planvalidateagainstidsci
// The base's claimant is gone from the branch, so the merge leaves one
// file holding the ID. A rename, a move, or a deliberate replacement …
if (!existsSync(join(cwd, refPath))) continue;

A base file missing from the working tree is read as "the branch deleted or renamed it". But a file the base added after the branch point is missing from the working tree too, and it is the second claimant this check is looking for. Both cases are skipped.

Every test in plugins/plan/test/against.test.ts compares against the branch's own base commit, so a base-side file absent from the tree can only have been deleted by the branch. No test advances the base after the branch point, which is the case that fails.

Found on claude/site-changelog-readability-k4p902. Against origin/main it reported "No ID collisions with origin/main (894 ids compared)" while the branch and main each held their own SPEC-160, WORK-639 and WORK-640. Plain plan validate reported all three as duplicates once main was merged in.

Steps to Reproduce

  1. On main, commit plan/work/WORK-001-alpha.md.
  2. Branch off, and add plan/work/WORK-002-from-branch.md there.
  3. Back on main, commit plan/work/WORK-002-from-main.md.
  4. On the branch, run refrakt plan validate --against main.

Expected

A collision on WORK-002, naming both files, and a non-zero exit.

Actual

No ID collisions with main (2 ids compared)., exit 0.

Environment

  • @refrakt-md/plan at the current main.
  • refrakt plan migrate ids --against shares readRefIds with the validator through baseClaimants, but it only acts on duplicates already present in the working tree, so it is not affected.

Approach

Tell the two cases apart with the merge-base, which git already knows. A base-side file that is absent from the working tree:

  • existed at git merge-base HEAD <ref>: the branch deleted or renamed it. One claimant survives the merge, so not a collision. This is the case the check was written for.
  • did not exist there: the base added it after the branch point. Both claimants survive the merge, so it is a collision.

When no merge-base exists (unrelated histories), fail loudly rather than guess, matching how an unresolvable ref is handled.

Acceptance Criteria

  • A collision with an ID the base added after the branch point is reported, naming both files
  • A base claimant the branch deleted or renamed is still not reported
  • A ref with no merge-base with HEAD fails with an error instead of reporting a clean run
  • against.test.ts has a test that advances the base after the branch point, and the existing tests still pass

References

  • SPEC-135 D8 and WORK-582: the check and why it runs pre-merge.

Resolution

Completed: 2026-10-15

Branch: claude/site-changelog-readability-k4p902 PR: refrakt-md/refrakt#704

What was done

  • plugins/plan/src/commands/against.ts: branchPointPaths lists plan files at git merge-base HEAD <ref>; collisionsFrom forgives a base claimant missing from the tree only if it existed there, plus a mirror rule for base-side renames/deletions. No merge-base is an error naming the fix (git fetch --unshallow).
  • cli-plugin.ts wires it into plan validate --against.
  • against.test.ts: five tests that advance the base after the branch point, plus unrelated history. Verified the main regression tests fail against the old condition.
  • .github/workflows/validate.yml: fetch-depth: 0; base fetched without --depth=1; base ref via env.
  • CLAUDE.md notes the history requirement; patch changeset.

Notes

  • migrate ids --against is unaffected: it does not call the new function.

Completed: 2026-10-16

Branch: claude/site-changelog-readability-k4p902 PR: refrakt-md/refrakt#704

What was done

  • plugins/plan/src/commands/against.ts: branchPointPaths lists plan files at git merge-base HEAD <ref>; collisionsFrom forgives a base claimant missing from the tree only if it existed there, plus a mirror rule for base-side renames/deletions. No merge-base is an error naming the fix (git fetch --unshallow).
  • cli-plugin.ts wires it into plan validate --against.
  • against.test.ts: five tests that advance the base after the branch point, plus unrelated history. Verified the main regression tests fail against the old condition.
  • .github/workflows/validate.yml: the --against step runs last and unshallows the checkout itself (git fetch --unshallow) before fetching the base; base ref via env.
  • CLAUDE.md notes the history requirement; patch changeset.

Notes

  • migrate ids --against is unaffected: it does not call the new function.
  • The first version used fetch-depth: 0 on the checkout. That made two tests that build real plan content time out (~170s on 3,473 commits against ~17s shallow), because the plan pipeline reads git history for timestamps and plan-history. The checkout stays shallow; only the last step pays for full history (~6s).

plan create milestone names the file after the title, not the version

BUG-041fixed

plan create milestone names the file after the title, not the version

Milestones keep their semver name as the filename (v1.0.0.md). CLAUDE.md states the convention, and so does the comment above the line in runCreate that breaks it (plugins/plan/src/commands/create.ts):

planclicreatemilestonesfilenames
// … Milestones use their semver name as the whole filename.
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '');
const fileName = isAutoIdType(type) ? `${id}-${slug}.md` : `${slug}.md`;

For a milestone, ${slug}.md is built from the title. --id sets only the name attribute inside the file.

This is how 17 milestones, v0.20.1 through v0.41.0, came to be filed under a slug (composed-runes-you-can-ship.md for v0.41.0, and so on). They were renamed by hand on claude/site-changelog-readability-k4p902. That branch also reproduced the bug once more when it created v0.41.1. Until it is fixed, every milestone created with the CLI drifts again.

Nothing repairs the drift afterwards. plan migrate filenames skips milestones (plugins/plan/src/commands/migrate.ts, if (entity.type === 'milestone')), and plan validate does not compare a milestone's filename with its name.

A test pins the wrong behaviour: create.test.ts, "throws on duplicate file for non-prefixed types (milestone)", expects v1.0 and v2.0 with the same title to collide on the filename.

Steps to Reproduce

  1. refrakt plan create milestone --id v9.9.9 --title "Something to ship"

Expected

plan/milestones/v9.9.9.md.

Actual

plan/milestones/something-to-ship.md, containing {% milestone name="v9.9.9" … %}.

Environment

  • @refrakt-md/plan at the current main.

Acceptance Criteria

  • plan create milestone --id <name> writes milestones/<name>.md, whatever the title
  • Two milestones with different names and the same title both create, and the test that expected them to collide is replaced by one that expects two files
  • Creating a milestone whose name already exists still fails with the duplicate-ID error
  • plan migrate filenames renames a milestone whose filename is not <name>.md, and still never touches one that already is
  • Docs that describe milestone filenames match the behaviour

References

  • The 17 renames: commit "plan: rename slug-named milestone files to their semver names" on claude/site-changelog-readability-k4p902.

Resolution

Completed: 2026-10-15

Branch: claude/site-changelog-readability-k4p902 PR: refrakt-md/refrakt#704

What was done

  • create.ts: a milestone is written as <name>.md.
  • migrate.ts: migrate filenames renames a milestone whose filename is not <name>.md (a missing name is reported); skipped.milestones removed from the result and CLI output.
  • Tests: four that pinned the slug filename (create x2, plan init, create-refrakt scaffold) now assert the version filename; new tests for rename and missing name.
  • plan-cli docs and CLAUDE.md describe milestone renames; patch changeset.

Notes

  • A dry run over this repository plans nothing: all 941 files, milestones included, are already correctly named.