Skip to content

ci(DST-1526): lint docs prose with Vale in pre-commit and CI - #5779

Open
aromko wants to merge 7 commits into
mainfrom
ci/DST-1526-prose-lint
Open

ci(DST-1526): lint docs prose with Vale in pre-commit and CI#5779
aromko wants to merge 7 commits into
mainfrom
ci/DST-1526-prose-lint

Conversation

@aromko

@aromko aromko commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Description

Prose style rules for the docs were carried in people's heads and in review comments, so the AI relearned them by being corrected after the fact. This encodes them as Vale checks wired into the pre-commit hook and a CI check, with the writing guidance in CLAUDE.md.

Three rules block CI: no em dashes, no semicolons in prose, no en dash asides. Table cells are exempt, because there an em dash is a legitimate "not applicable" marker, and ranges keep the en dash so quoted component output stays accurate. Scope is prose written for a reader (docs/content/**, .changeset/*.md, published READMEs, top-level markdown); prose written for an agent is deliberately excluded, as is docs/legal/ and everything generated.

All 135 existing violations across 28 files are rewritten here, so the check starts green rather than accumulating suppressions as files are touched. Those edits are punctuation only: four words change beyond punctuation and capitalisation, and line counts and code fences are unchanged in every file.

N/A — no UI changes, so no screenshots and no VRT. No unit tests: the deliverable is a lint configuration, and its behaviour is verified by the check itself passing on this branch.

Closes DST-1526

Test Instructions

  1. pnpm install, then pnpm lint:prose — expect exit 0. First run downloads the pinned Vale 3.19.0 binary (~1s, checksum verified); later runs are ~200ms.
  2. Add an em dash to any prose line in docs/content/**/*.mdx, run pnpm lint:prose again, and confirm it fails naming the file, line and rule.
  3. Stage that file and try to commit — the pre-commit hook should block it.
  4. Add an em dash to CLAUDE.md or anything under .memory/ instead, and confirm it does not block: agent-facing prose is out of scope on purpose.
  5. Check a table cell still accepts one, e.g. | — (no class) |.

Breaking Changes

No

Checklist

  • Storybook preview and Marigold docs preview are available
  • Stories added/updated (with component-test tag where applicable)
  • Unit tests added/updated
  • Component documentation added/updated (if it exists)
  • Accessibility reviewed against ARIA APG (for new/changed interactive components)
  • Visual regression tests updated (for UI changes)
  • Changeset added (pnpm changeset)

Notes for the reviewer

Two things worth a closer look:

packages/cli/README.md is 67 of the 135 fixes — half the prose churn in one file.

The en dash rule is the one to scrutinise. It exists because German uses a spaced en dash where English uses an em dash, so it guards a slip this team is likely to make rather than one already made. Its token matches the aside form only ([a-zA-Z] – [a-z]), so 4–9, Jan 1, 2025 – Dec 31, 2025 and Reservix – Marigold stay legal. That matters: Intl.DateTimeFormat.formatRange really does emit a spaced U+2013, so the <DateFormat> doc lines quoting it are accurate and must not be "fixed". It is the same call #5496 made by hand, now enforced rather than remembered.

Why the binary is downloaded rather than an npm dependency. The @vvago/vale wrapper derives its download URL from its own package version and is stalled at 3.17.1, which ignores the table.cell exclusion and reports only the first match per paragraph (it under-reported this repo by 10 findings). scripts/install-vale.mjs pins 3.19.0 and verifies the SHA-256, and renovate.json gains a regex custom manager so the pin is visible to Renovate, with those bumps held back from automerge since 3.18 and 3.19 both changed parsing semantics.

Encodes the prose rules that until now lived in review comments: no em dashes,
no semicolons in prose, no en dash asides. `pnpm lint:prose` runs Vale over the
docs site, the changesets and the published READMEs, wired into lint-staged and
a CI check.

Scope is prose written for a reader. Prose written for an agent (CLAUDE.md,
.memory/, .claude/, docs/superpowers/, packages/*/src/**/README.md) is out, as
is docs/legal/ and everything generated. Changesets are linted at the source,
because the release notes they generate are not tracked.

The binary is pinned to 3.19.0 and downloaded by scripts/install-vale.mjs with
checksum verification, rather than via the @vvago/vale wrapper. The wrapper
derives its download URL from its own package version and is stalled at 3.17.1,
which has two defects that matter here: it ignores the table.cell scope
exclusion, so it flags the em dash in `| — (no class) |`, and it reports only
the first match per paragraph, missing 10 findings in this repo. Pinning 3.19.0
also means MDX parses natively, so no mdx2vast bridge and no `vale sync`.

Notes for anyone changing this:

- Vale matches the path sections in .vale.ini against the path it is handed, so
  an absolute path matches no section and lints with no rules. That is why the
  hook passes `--relative`; without it the gate silently passes everything.
- Exclusion is an empty `BasedOnStyles =`. `= NONE` is a fatal error.
- A path-section `*` spans `/`, so `[packages/*/README.md]` also matches
  packages/cli/src/lib/validate/README.md, hence the later `[packages/*/src/**]`
  section.
- The lint-staged commands are one array, not two globs: separate glob entries
  run concurrently, which would race prettier's write against Vale's read.
- Use `pnpm lint:prose`, not `pnpm exec vale`. pnpm exec puts a relative
  ./node_modules/.bin first on PATH, and Go refuses to resolve an executable
  through a relative PATH entry.

lint-staged was previously undeclared and resolved through the npx cache, so it
is now a devDependency. It pins 17.3.0 rather than latest because .npmrc sets
minimum-release-age to 7 days and 17.4.1 is newer than that.
Fixes every violation the new prose lint reports, so the check starts green
rather than accumulating suppressions as files are touched. 105 em dashes, 27
prose semicolons and 3 en dashes across 28 files.

Punctuation only. The rewrites replace a dash or semicolon with a comma, a
colon, or a sentence break, and four words change beyond that: two connectives
where a dash was joining a list, one relative pronoun where a dash introduced a
parenthetical, and a slash spelled out as "or". Line counts and code fences are
unchanged in every file.

Two thirds of the em dashes were the `- **Term** — definition` list form, which
these docs already write as `- **Term**: definition` 122 times against 13 with a
dash.

One factual correction rides along: numericformat/index.mdx documented
`"1,000 – 5,000"`, but Intl.NumberFormat.formatRange returns "1,000–5,000" with
no spaces. The spaced en dash in dateformat/index.mdx and release-2025-09-01.mdx
is left alone, because Intl.DateTimeFormat.formatRange really does emit U+2013
with spaces and those lines quote rendered output. That is the same call #5496
made by hand; the en dash rule now encodes it by matching only the aside form.
Adds a Prose Style section and one Workflow line, so an agent writes compliant
prose from the start instead of learning the rules by being corrected.

The rules are restated here rather than linked, because the load-bearing part is
the scope paragraph and that has no home in a rule file. Without it the section
reads as a repo-wide ban, and the agent starts stripping em dashes out of
.memory/ and .claude/, where the density is deliberate and .claude/README.md
positively requires the character in a skill description.

The en dash rule carries its reasoning into the message: German uses a spaced en
dash where English uses an em dash, so it is an easy slip for this team to make.
Drops the root `postinstall` hook. Nine workflows run `pnpm install` and none of
them use Vale, while `prose.yml` — the one job that does — never runs it. Since
`cache: 'pnpm'` caches the store rather than node_modules, the hook fired on all
of them: about 176MB of release egress and 15s of runner time per push for a
binary that was never executed, and a GitHub release outage could fail an install
for Test and Release.

The two call sites that need the binary already install it themselves, so the
download moves to first use. `pnpm lint:prose` and the pre-commit hook each cost
34ms when the binary is already present.

Also from the review pass:

- `prose.yml` runs `pnpm lint:prose` instead of re-spelling it as two steps, so
  the local command and the CI check cannot drift apart.
- `renovate.json` gains a regex custom manager for the pinned version. Every
  other external pin here sits where a manager reads it (`.node-version`,
  `packageManager`, action refs); this one was invisible, on the one tool whose
  release notes matter, since 3.18 changed MDX parsing and 3.19 changed
  JSX-children scoping. Those bumps are excluded from automerge for the same
  reason: green CI proves the current corpus passes, not that the scope
  semantics the rule comments rely on still hold.
- `install-vale.mjs`: one `valeVersion()` helper replaces a single-use wrapper
  and a duplicated exec, and `fetchOrDie` drops a parameter that restated its
  own URL. The `existsSync` guard went with it; the catch it sat in already
  covers a missing, truncated or wrong-arch binary, which is what makes the
  self-heal work.
- `Semicolon.yml` gains the `heading` scope its two siblings had, which was
  omitted with no stated reason.
- `EnDash.yml` documents its regex, which is the least obvious in the set, and
  drops the semicolon from its own message — the sibling rule forbids exactly
  that, and a CI failure message is the prose developers read most often.
@changeset-bot

changeset-bot Bot commented Sep 2, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 5f38f3e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 6 packages
Name Type
@marigold/components Minor
@marigold/docs Minor
@marigold/system Minor
@marigold/theme-rui Minor
@marigold/cli Patch
@marigold/icons Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Sep 2, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
marigold-docs Ready Ready Preview Sep 3, 2026 7:49am UTC
marigold-storybook Ready Ready Preview Sep 3, 2026 7:49am UTC
1 Skipped Deployment
Project Deployment Actions Updated
marigold-production Ignored Ignored Sep 3, 2026 7:49am UTC

Request Review

@github-actions github-actions Bot added type:docs Improvements or additions to documentation type:feature New feature or component type:infrastructure Tooling and other chores labels Sep 2, 2026
@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for Marigold Code Coverage

Status Category Percentage Covered / Total
🔵 Lines 98.83% 3061 / 3097
🔵 Statements 97.86% 3215 / 3285
🔵 Functions 98.01% 838 / 855
🔵 Branches 91.45% 2022 / 2211
File CoverageNo changed files found.
Generated in workflow #23797 for commit 5f38f3e by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

Reverts the prose job to the two explicit steps. Routing it through
`pnpm lint:prose` read tidier but cost 16.5s of the job's 29s: pnpm 11 verifies
dependencies before running a script, so a job with no `node_modules` installed
the whole workspace — husky and the docs registry build included — to run a
0.2s check that needs none of it. The previous commit's claim that this job runs
with "no `pnpm install` and no build" was made true again rather than left
aspirational, and the two steps now carry a comment saying they have to stay in
step with `lint:prose`.

`CLAUDE.md` said CI "runs the same command", which the spelled-out steps make
false in the literal sense; it now says the check runs the same rules.

Also fixes the five violations that arrived with the merge from `main`, in
changesets and Stepper docs written before this gate existed. Four semicolons
and two em dashes, punctuation only. This is the standing cost of a repo-wide
gate: work that lands between the cleanup and the merge brings its own
violations, and the branch has to absorb them to stay green.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

Removes every comment added by this branch across the installer, the Vale
config, the three rule files, the workflow, the pre-commit hook and the
gitignore entry. The reasoning they carried is in this branch's commit messages.

Renovate's `description` keys are left in place: they are functional config that
shows up in the PRs it opens, not annotations on code.
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Accessibility tests executed. Download the report here.

Comment thread .prettierignore
.npmrc
.nvmrc
.prettierignore
.vale.ini

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 .vale/bin/ needs to be ignored here too, or pnpm format breaks for everyone.

.gitignore hides the downloaded binary from git, but Prettier does not read .gitignore, and prettier --write "**/*" matches dotted directories. That is exactly why .vale.ini had to be added on this line. Reproduced on this branch:

$ npx prettier --check "**/*"
[error] No parser could be inferred for file ".vale/bin/vale".

The sequence hits every developer: commit once, the pre-commit hook downloads .vale/bin/vale, then the next pnpm format exits non-zero. CLAUDE.md tells people to run it before committing. CI stays green because the Format job never installs Vale, so nothing catches this before it lands.

Suggested change
.vale.ini
.vale.ini
.vale/bin/

Comment thread .lintstagedrc
@@ -1,3 +1,3 @@
{
"*": "prettier --write"
"*": ["prettier --write", "node scripts/install-vale.mjs", ".vale/bin/vale"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ This defeats the installer's own platform fallback.

scripts/install-vale.mjs prints no pinned build for <key>; skipping and exits 0 when there is no asset for the platform, but the next command runs .vale/bin/vale unconditionally, so the commit fails with ENOENT instead of skipping.

Windows is the realistic case. There is no win32-x64 entry in ASSETS, and adding one would need zip extraction and a vale.exe name rather than tar xzf plus vale. As it stands a Windows contributor cannot commit at all.

Either add the win32 build to the script, or route the vale step through a small wrapper that no-ops when the binary is absent, so the graceful degradation the script already implements actually reaches the caller.

Comment thread .lintstagedrc
@@ -1,3 +1,3 @@
{
"*": "prettier --write"
"*": ["prettier --write", "node scripts/install-vale.mjs", ".vale/bin/vale"]

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Two smaller things about this line.

Scope it. The * glob hands every staged file to Vale, including PNGs, SVGs and pnpm-lock.yaml. Nothing matches a .vale.ini section so nothing is reported, but the hook pays for it on every commit. The installer also receives the same filename list and ignores it, which reads as a lint-staged task but is really a setup step, so .husky/pre-commit ahead of npx lint-staged is a better home for it.

Suggested change
"*": ["prettier --write", "node scripts/install-vale.mjs", ".vale/bin/vale"]
"*": "prettier --write",
"*.{md,mdx}": ".vale/bin/vale"

(pairs with moving node scripts/install-vale.mjs into the husky hook)

--relative is load-bearing and invisible. If that flag is what makes the repo-relative .vale.ini section globs match, then dropping it later makes Vale silently lint nothing and exit 0 rather than failing loudly. One sentence in the CLAUDE.md Prose Style section would keep that from being rediscovered the hard way.

@@ -0,0 +1,16 @@
---
'@marigold/docs': patch

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ @marigold/cli needs a patch bump too.

npm publishes README.md with the package, and packages/cli/README.md is rewritten in 57 lines here (67 of the 135 fixes by your own count). Without a bump the npm page keeps the old prose indefinitely, so the rewrite never reaches the place the README is actually read.

Suggested change
'@marigold/docs': patch
'@marigold/docs': patch
'@marigold/cli': patch

The alternative is dropping {packages,themes,config}/*/README.md from .vale.ini scope, which would also remove half the prose churn from this PR.

Three rules block CI: no em dashes, no semicolons in prose, and no en dash asides. Table
cells are exempt, because there an em dash is a legitimate "not applicable" marker, and
ranges keep the en dash so quoted component output stays accurate. All 135 existing
violations across 30 files are rewritten in this change, so the check starts green.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 The file count disagrees with itself. This says 30 files, the PR description says 28, and the branch touches 32 prose files (33 changed minus this changeset). Since this text ships to the published changelog, either fix the number or drop it and keep the violation count.

While you are in here: DST-1526 lists "Rewriting existing docs to pass" under Not included. I think overruling that is right, because vale . lints the whole repo, so fix-forward would leave the check red on day one no matter which files a PR touches. Worth saying so explicitly in the PR description so the reporter sees it was a decision rather than an oversight.

Focus highlight marks the item the user is currently navigating to via keyboard. Unlike selection, which shows a confirmed choice, focus indicates where the user's cursor is right now. Two intensities handle different contexts: a subtle tint for menu items and drop zones, and a bolder fill for inline segments like date fields where the focused part needs to stand out clearly.

A focus highlight is not a focus indicator on its own. `focus-highlight` composites to around 1.05:1 against the surface it tints, well under the 3:1 that [WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) asks of a state indicator, and in a menu the same tint fires on hover because focus follows the mouse. Pair it with a focus ring`ui-state-focus` where there is a border to recolor, `ui-state-focus-item` on borderless rows and let the tint track the cursor while the ring proves where keyboard focus is.
A focus highlight is not a focus indicator on its own. `focus-highlight` composites to around 1.05:1 against the surface it tints, well under the 3:1 that [WCAG 1.4.11](https://www.w3.org/WAI/WCAG22/Understanding/non-text-contrast.html) asks of a state indicator, and in a menu the same tint fires on hover because focus follows the mouse. Pair it with a focus ring: `ui-state-focus` where there is a border to recolor, `ui-state-focus-item` on borderless rows, and let the tint track the cursor while the ring proves where keyboard focus is.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The parenthetical became a false list.

The opening dash turned into a colon and the closing dash into a comma, so and let the tint track the cursor now reads as the third item after the two token names rather than as the resumption of the sentence.

Keeping the closing boundary fixes it:

Pair it with a focus ring, ui-state-focus where there is a border to recolor and ui-state-focus-item on borderless rows. Let the tint track the cursor while the ring proves where keyboard focus is.

This is the one prose edit in the set where the rewrite costs the reader something. The rest hold up well.

#### Aligning with a bled container

`--bleed-px` is the one you want when content sits edge-to-edge and needs to line back up with the container's title. Any content area rendered with `bleed` publishes it`Panel.Content`, `Panel.CollapsibleContent`, `Card.Content`, `Card.Footer` and `Drawer.Content` always holding that container's own horizontal padding. The same areas clear it again when they are not bled, so it is in effect only inside the nearest bled container and never leaks into a nested one that keeps its padding.
`--bleed-px` is the one you want when content sits edge-to-edge and needs to line back up with the container's title. Any content area rendered with `bleed` publishes it: `Panel.Content`, `Panel.CollapsibleContent`, `Card.Content`, `Card.Footer` and `Drawer.Content`, always holding that container's own horizontal padding. The same areas clear it again when they are not bled, so it is in effect only inside the nearest bled container and never leaks into a nested one that keeps its padding.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Same shape as the token-overview comment, milder. The colon opens a list of components and always holding that container's own horizontal padding now dangles off the end of it, so it reads as attaching to Drawer.Content rather than to all five. A period after Drawer.Content and a fresh clause ("Each holds that container's own horizontal padding.") would keep the scope clear.

---

**Marigold 11 is HereJust in Time for Valentine's Day!** 💖 Happy Friday and Happy Valentine's Day! Whether you're celebrating with chocolates, flowers, or just a quiet night of coding, we've got something special for youMarigold 11 is officially here! This release is packed with improvements, thoughtful refinements, and a few features we think you'll love. So, grab a heart-shaped cookie (or two), and let's dive into what's new! 🚀
**Marigold 11 is Here: Just in Time for Valentine's Day!** 💖 Happy Friday and Happy Valentine's Day! Whether you're celebrating with chocolates, flowers, or just a quiet night of coding, we've got something special for you. Marigold 11 is officially here! This release is packed with improvements, thoughtful refinements, and a few features we think you'll love. So, grab a heart-shaped cookie (or two), and let's dive into what's new! 🚀

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Consider excluding docs/content/releases/blog/** from the lint.

The generated release.mdx pages are already out of scope for being generated. These dated announcements are equally historical, and an em dash in "Marigold 11 is Here—Just in Time for Valentine's Day!" is idiomatic marketing typography rather than the slip the rule is aimed at. Restyling eight published posts is the one part of this cleanup that rewrites the past instead of the present, and the exclusion would shrink the diff by eight files.

Not blocking, and it is a judgement call rather than a defect.

- list
- heading
tokens:
- '[a-zA-Z] – [a-z]'

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Noting the gap so it is on the record rather than rediscovered: the token misses an aside that opens with a capital or a digit, e.g. the API – React Aria wraps it – handles focus. Your PR description already explains that this is the price of keeping Reservix – Marigold and Jan 1 – Dec 31 legal, which is the right trade. No change requested.

Comment thread README.md
Releases are published by CI, not locally. `.github/workflows/release.yml` runs
[`changesets/action`](https://github.com/changesets/action) on every push to `main`: it either opens a
"release: version packages" PR that consumes the pending changesets, oronce that PR is merged
"release: version packages" PR that consumes the pending changesets, or, once that PR is merged,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Unrelated to this line, but this is the nearest anchor in the diff: the Command overview table above (around line 78) gains no pnpm lint:prose row, even though CLAUDE.md points people at the script. Worth a row next to pnpm lint.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

type:docs Improvements or additions to documentation type:feature New feature or component type:infrastructure Tooling and other chores

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants