Add self-hosted React docs app to replace Mintlify - #15
Merged
Conversation
Renders the existing .mdx content with the same design as the Mintlify-hosted
site, as a static build that any host can serve. The .mdx files and docs.json
remain the source of truth and are unchanged; docs.json still drives navigation.
Stack: Vite 6 + React 19, @mdx-js/rollup, Shiki (same library and themes
Mintlify used), Tailwind v4 + typography, Mermaid, MiniSearch. `npm run build`
emits dist/ with all 35 routes prerendered to real HTML plus a 404 fallback.
Design parity, measured against a captured snapshot of the live site:
- 99.75% of 134,416 geometry assertions across 136 selectors x 35 pages
- mean 1.10% per-slice pixel difference
- 293/293 heading anchors reproduce the old site's ids exactly, so in-page
and cross-page links keep resolving
Two non-obvious things the design depends on, both documented in theme.css:
the prose overrides must be unlayered (the typography plugin emits into
@layer utilities), and Mintlify applies a global body-weight rule that makes
every font-semibold inert, so bold is signalled by colour rather than weight.
Deliberate departures from the old site:
- No AI assistant UI (navbar button, per-code-block sparkle, floating ask
bar) — all Mintlify SaaS with no self-hosted equivalent. Consequences:
wider search field, 99px code padding instead of 131px, pages 86px shorter.
- No "Powered by Mintlify".
- Code set in IBM Plex Mono (OFL) rather than proprietary paperMono; the two
measure an identical 8.0px advance at 14px, so code alignment is unchanged.
- Footer social icons inlined as SVG instead of fetched from a FontAwesome CDN.
- Images served locally, which lets style.css's image-rendering: pixelated
rule finally apply to the pixel-art card icons.
- Guides card grid has 16px spacing restored (style.css forced gap: 0).
Also included:
- scripts/compare.mjs, the parity harness (geometry + pixel diff modes) that
produced the numbers above
- Dockerfile + nginx config + railway.json for deployment. Build context is
the repo root, since the content is the app's input.
- concepts/overview.mdx: point the Get Started link at /quickstart instead of
the old turingdb.mintlify.app host
Contributor
|
Preview deployment for your docs. Learn more about Mintlify Previews.
💡 Tip: Enable Workflows to automatically generate PRs for you. |
The app was scaffolded under site/ so it could sit alongside the existing
Mintlify setup during the port. Mintlify is gone, so the split no longer earns
anything: content and app now share one root.
Layout changes:
- site/{src,scripts,public,deploy,Dockerfile,package.json,...} -> repo root
- fonts/, logo/, images/ and favicon.png -> public/
public/ is now the single home for served assets. That removes the ~39 MB
of duplication between images/ and site/public/images/ and lets
scripts/copy-assets.mjs go entirely — there is nothing left to copy. The
.mdx files reference these as absolute /images/... URLs, so no content
changed.
Mintlify files removed:
- README.md — was the unmodified "Mintlify Starter Kit" template; replaced
with real documentation for this repo
- style.css — Mintlify's custom-CSS injection point. All ten of its rules were
ported into src/theme.css during the port, so it was dead weight.
- snippets/snippet-intro.mdx — Mintlify snippet-convention boilerplate, never
referenced by docs.json
- docs.json: dropped "$schema" (pointing at mintlify.com) and "theme": "mint".
Everything else is retained: name/colors/logo/favicon/navigation/navbar/
footer are read by the app, and appearance/background/fonts are kept as
design metadata.
Remaining mentions of Mintlify in the tree are comments explaining why a
measured value is what it is. Those are worth keeping.
Path references updated: vite.config.ts and gen-routes.mjs (contentRoot is now
the project root), src/docs.config.ts, theme.css @source globs, the Dockerfile
(context is the app root, so it is much simpler), railway.json, and the ignore
files.
Verified after the move: clean build produces 35 prerendered pages, tsc passes,
anchor parity still 293/293, Docker image builds and serves every route plus
assets with a correct 404. Geometry parity 99.74% — the delta from 99.75% is the
Guides card gap, now recorded in compare.mjs as intentional rather than left
looking like a regression.
475 KB image at the repository root with no reference from any page, config or stylesheet. It also sat outside public/, so it was never served — it did not appear in the build output, and removing it leaves dist/ byte-identical. Not to be confused with public/images/zero_lock.png, a different image (208 KB, different checksum) that concepts/zero_locking.mdx does use. That one stays.
rjb32
force-pushed
the
self-hosted-docs-app
branch
from
August 3, 2026 17:27
30ab828 to
5045c7a
Compare
Adds .github/workflows/deploy.yml. There was no CI in this repo before — the old
setup used Mintlify's GitHub App, which builds nothing we control.
pull request -> main : build check only (gen, tsc, anchor test, build, assert
all 35 pages prerendered)
push -> main : the same check, then `railway up`, then wait until the
live index.html byte-matches this commit's build and
smoke-test four routes plus a 404
Deploys are gated on `github.event_name != 'pull_request'`, so a fork PR can
never trigger one. Concurrency cancels in-flight PR checks but never an in-flight
deploy.
Requires a RAILWAY_TOKEN repository secret (a project token for the production
environment). It cannot be read in an `if:` expression, so the job checks it in
the shell and fails with an actionable message instead of half-deploying.
Two supporting changes, both needed to make the build job pass from a clean
checkout — found by replaying the job in a pristine copy of the tree:
- `npm run gen` now runs before `tsc`. routes.generated.ts and
search-index.generated.json are gitignored, so a fresh checkout has neither
and the typecheck failed on 13 missing-module errors.
- scripts/test-slug.mjs now checks against scripts/fixtures/anchors.json, a
committed record of all 293 heading ids the previous site published, instead
of reading a local capture directory that only exists on one machine. It
crashed outright in CI before. `--update` regenerates the fixture from a
capture. esbuild is now a declared devDependency rather than relied on as a
transitive dep of Vite.
Content text moves from gray-400 (#A0A6A2, 7.9:1 against the page background) to gray-200 (#E0E6E2, 15.5:1) — level with the headings. Routed through a single token, --color-prose-text, rather than editing the nine places that painted gray-400. Changing that one line now dials the whole site. Sidebar, TOC and navbar keep gray-400 on purpose, so chrome still recedes behind content. <strong> had to change with it. The previous design signalled bold purely by colour, because the global body-weight rule flattens every font-semibold to 300. Against a gray-200 body, gray-50 bold separates by 1.20:1 and pure white by 1.27:1 — neither is perceptible. So <strong> now carries weight 500 and #fff, and is exempted from the body-weight rule. Emphasis reads as emphasis again. compare.mjs records both as intentional, but narrowly: instead of excluding `color` on a growing list of selectors, it accepts two exact substitutions (gray-400 -> gray-200, gray-50 -> white). Any other colour difference still fails, so this cannot mask a real regression. Geometry parity stays at 99.75%.
…ressions
Typography
Ark Pixel is no longer applied. It is a 16px bitmap face: fine at display
sizes, but it was also carrying the TOC and the sidebar group titles at
14-16px, where it is genuinely hard to scan. All text is now IBM Plex Sans.
- added real weights: Regular 400, SemiBold 600, and a true Italic. Previously
only Light 300 was loaded, so any bold or italic was synthesised.
- body copy 300 -> 400. Light stems are thin at 16px on a dark background.
- headings 400/+0.06em -> 600/-0.01em. The wide tracking existed to stop
bitmap glyphs reading cramped; on a proportional face it looks loose.
- TOC links and sidebar group titles 16px -> 14px with normal tracking. The
1rem bump existed so the bitmap font landed on whole pixels.
- <strong> and inline code follow the body to 600 and 400 respectively.
The font file is still shipped: point --font-heading back at "Ark Pixel" in
theme.css to restore the old look.
Preview URL resolution (real bug, found while checking the above)
Every page except / threw React #418 — "server rendered text didn't match" —
and silently discarded the prerendered tree. Cause was not the app: `vite
preview` serves dist/index.html for any clean URL, so /quickstart was being
hydrated against the home page's HTML. Production was always correct, because
nginx does `try_files $uri $uri/index.html`.
Added a preview-only middleware that resolves directory indexes the same way,
so local preview matches production. All 14 sampled routes are now clean.
Two CSS regressions fixed
- links inside a callout took the brand green instead of the callout's own
colour. The generic .dark .mdx-content a.link rule ties on specificity and
was declared later, so it won.
- a <Steps> block lost its 40px top margin when it followed a heading, from
the same margin-collapse rule the table wrapper was already exempt from.
compare.mjs
Typography is now expressed as substitutions (weight 300->400, 400->600,
300->600, 500->600; family ark pixel -> ibm plex sans) rather than blanket
per-selector exclusions, so an unexpected weight or family still fails. Only
the selectors whose font SIZE changed get a box/position exemption.
99.66% of 126,156 assertions pass, 6 critical failures — down from 24 before
the two fixes above.
Ark Pixel 16px carries the page title, every h1-h6 (step titles included), the
sidebar main group titles and "On this page". Everything that is scanned rather
than read once stays on IBM Plex Sans: body copy, sidebar page links, and the TOC
entries — which is the one place the previous revision was right to pull the
bitmap face, and the only spot where this differs from the original design.
Two things the face forces:
- Weight 400, not 600. Ark Pixel ships one weight. Measured in Chromium, 500 is
pixel-for-pixel identical to 400 and 600 synthesises a smeared bold (2671
differing pixels on a 25-character string). So the 600 rules on headings and
on the nav labels are gone, and headings return to 400 with +0.06em tracking —
the tracking is what stops bitmap glyphs reading cramped. A link inside a
heading now inherits the heading's weight instead of asking for 600.
- The two nav labels are pinned to 1rem, up from 0.875rem. 16px is not a size
choice but the font's design size; at 14px every stem falls on 7/8ths of a
device pixel and the glyphs grey out. The original did the same, for the same
reason.
New --font-nav-label token for the sidebar group titles; --font-nav keeps the
proportional stack for the nav items below them. "On this page" is a real <h2> and
takes --font-heading. Both stacks match the reference's exactly.
compare.mjs
Display type matches the captured reference again, so most of the typography
exemptions no longer earn their keep and are removed — family, size, weight and
tracking on headings, the page title and the nav labels are now checked rather
than skipped. What remains is the body weight (Light -> Regular, the one change
that reflows prose), the TOC entries, and the 16px "On this page".
99.67% of 127,945 assertions pass, 6 critical failures — the same six the last
commit documented as pre-existing, on ~1,800 more assertions. Mean per-slice
pixel difference 3.76%, down from 4.20%.
Sidebar auto-scroll (outside the typography change)
16px group titles put the sidebar back in the regime REST_RATIO was fitted for,
which exposed a 3-4px miss on four graph_dev pages that the blanket exemption
had been hiding. Re-solved against all 35 reference offsets: 0.44 -> 0.437, which
is 35/35 within 2px (was 31/35, worst 4px). Preferred over re-adding an exemption
that would blind the harness to real sidebar regressions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Light is a display weight, so it is applied where the type is large enough to carry thin stems and nowhere else: page title 36px 300 h3 20px 400 h1 30px 300 h4 18px 400 h2 24px 300 h5/h6 16px 400 Below 24px the size step over 16px body copy is small, and a 300-weight heading ends up physically thinner than the paragraph it introduces — hierarchy running backwards, across 95 h3s in the content. The cut-off is asserted twice, in the prose block and in the #content-area rule, because the `!important` that beats `font-semibold` would otherwise flatten the h3-and-below rule. Tracking goes with the face: +0.06em -> -0.01em. The wide setting existed to give bitmap glyphs air; on a proportional face it reads loose. Ark Pixel keeps the two nav labels — the sidebar group titles and "On this page" — at its own 16px, weight 400/500, +0.08em. "On this page" now reads --font-nav-label rather than --font-heading: it is an <h2> in the DOM but a nav label on the page, and the old coupling meant it followed the titles anywhere they went. That is what lets the title face change on its own. Nothing new is shipped: the Light cut of IBM Plex Sans was already loaded — it carried the whole site's body copy before 05c2d00. compare.mjs The titles leave the reference again (family, weight and tracking all move), so their letterSpacing/width/height/rect exemptions come back, plus a 400->300 weight substitution for the display sizes and a fontFamily exemption on the TOC header, whose stack now differs from the reference's in its fallbacks only. 99.65% of 127,521 assertions pass, 6 critical failures — the same six, pre-existing and none typographic. Mean per-slice pixel difference 3.94%, between Ark Pixel's 3.76% and SemiBold's 4.20%. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The sidebar group titles and "On this page" follow the titles onto Plex Light, so
Ark Pixel is now applied nowhere. Two settings went with the face, both of which
existed only to serve a 16px bitmap grid:
- the +0.08em tracking, which was spacing out bitmap glyphs. A proportional
label wants 0.
- the inherited weights. The sidebar label sat at 400 and the TOC header at 500
(its font-medium class, which the global body-weight rule leaves alone because
the button is an `h2 *`). Both are asserted at 300 now, or the TOC header would
out-weigh the sidebar label beside it.
The 1rem stays. It was a pixel-grid accommodation rather than a design choice, so
dropping it to 0.875rem would have been defensible — but at 300 the label is
already lighter than its own 14.4px/400 items, and size plus colour (gray-200 over
gray-400) are what still make it read as a heading. At 14px it would be smaller,
lighter and barely brighter than the list it heads.
Ark Pixel is still shipped in public/fonts and either --font-heading or
--font-nav-label brings it back in one line; theme.css, the README and
THIRD-PARTY-NOTICES all say so rather than leaving a dead 58KB asset unexplained.
The two tokens are kept separate for the same reason they earned their keep here:
the seam is what let the titles and the labels move one at a time.
compare.mjs
The labels leave the reference on tracking and weight, so: a 500->300
substitution for the TOC header, letterSpacing on it as well (ours is now
`normal`, which the numeric ≤1px tolerance cannot bridge), and letterSpacing plus
width on the sidebar labels. Their font SIZE is unchanged, so nothing below them
moves and their rects stay checked.
99.62% of 127,354 assertions pass, 6 critical failures — the same six,
pre-existing and none typographic. Mean per-slice pixel difference 4.09%; the
trend as the titles moved is 3.76% (Ark Pixel), 3.94% (Plex Light titles), 4.09%
(labels following).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Renders the existing .mdx content with the same design as the Mintlify-hosted site, as a static build that any host can serve. The .mdx files and docs.json remain the source of truth and are unchanged; docs.json still drives navigation.
Stack: Vite 6 + React 19, @mdx-js/rollup, Shiki (same library and themes Mintlify used), Tailwind v4 + typography, Mermaid, MiniSearch.
npm run buildemits dist/ with all 35 routes prerendered to real HTML plus a 404 fallback.Design parity, measured against a captured snapshot of the live site:
Two non-obvious things the design depends on, both documented in theme.css: the prose overrides must be unlayered (the typography plugin emits into @layer utilities), and Mintlify applies a global body-weight rule that makes every font-semibold inert, so bold is signalled by colour rather than weight.
Deliberate departures from the old site:
Also included: