From a8a7892f763323a1732650ec9affb577b9fca105 Mon Sep 17 00:00:00 2001 From: Sid Jain Date: Sat, 26 Sep 2026 09:06:09 +0000 Subject: [PATCH 01/15] feat: unify article layout, media bleeds, and sidenotes --- BLOG.md | 3 + docs/article-layout.md | 191 +++++++ docs/article-visual-audit.md | 175 +++++++ next.config.ts | 6 + src/app/(blog)/writing/[slug]/page.tsx | 17 +- src/app/(portfolio)/page.tsx | 4 +- src/app/article.css | 478 ++++++++++++++++++ src/app/globals.css | 3 +- src/app/layout.tsx | 3 +- src/components/ask-ai-widget.tsx | 2 +- src/components/blog/article-prose.tsx | 5 +- src/components/copy-email-button.tsx | 2 +- src/components/mdx/CodeBlock.tsx | 4 +- src/components/mdx/MDXImage.tsx | 3 +- src/components/mdx/Mermaid.tsx | 21 +- src/components/site-footer.tsx | 8 +- src/components/site-header.tsx | 8 +- src/components/site-page.tsx | 5 +- src/components/site-shell.tsx | 15 +- src/components/writing-list.tsx | 4 +- .../blog/2016-music-in-review/page.mdx | 21 +- .../blog/2018-music-in-review/page.mdx | 21 +- .../blog/building-on-zeroclaw/page.mdx | 2 +- .../blog/building-veera-browser/page.mdx | 2 +- .../page.mdx | 2 +- .../page.mdx | 2 +- .../page.mdx | 28 +- .../page.mdx | 2 +- .../page.mdx | 2 +- .../blog/kickback-with-koa-webpack/page.mdx | 40 +- src/content/blog/kitchen-sink/page.mdx | 59 ++- .../no-slots-so-i-built-tranquilo/page.mdx | 2 +- src/lib/rehype-article-grid.mjs | 194 +++++++ src/lib/remark-embed-github.mjs | 2 +- tests/article-grid.test.tsx | 115 +++++ 35 files changed, 1352 insertions(+), 99 deletions(-) create mode 100644 docs/article-layout.md create mode 100644 docs/article-visual-audit.md create mode 100644 src/app/article.css create mode 100644 src/lib/rehype-article-grid.mjs create mode 100644 tests/article-grid.test.tsx diff --git a/BLOG.md b/BLOG.md index dcdc5611..751c7659 100644 --- a/BLOG.md +++ b/BLOG.md @@ -19,6 +19,9 @@ For voice, storytelling, and editorial review, use the [blog-writing skill](.rulesync/skills/blog-writing/SKILL.md). This guide covers the site's MDX and publishing mechanics. +See [Article layout](docs/article-layout.md) for the reading grid, typography, +wide figures, disclosures and responsive sidenotes. + ## Content structure - Preferred layout (folder per post): diff --git a/docs/article-layout.md b/docs/article-layout.md new file mode 100644 index 00000000..6b6989fd --- /dev/null +++ b/docs/article-layout.md @@ -0,0 +1,191 @@ +# Reading layout system + +The shared implementation is `src/app/article.css`. It uses Tailwind v4's existing +spacing unit and type sizes, with CSS Grid named tracks and `grid-cols-subgrid`. +Geist remains the reading face; Instrument Serif supplies titles and section +headings. Geist Mono is reserved for code. The header, writing list, article, +and footer share one reading edge. + +## What the theory supports + +The objective is predictable relationships, legible text, and adaptable content. +It is not a claim that a particular pixel value is scientifically optimal. + +- **Proximity and grouping:** related elements should be closer than unrelated + groups. The perceptual basis is discussed in + [Wagemans et al., _A Century of Gestalt Psychology in Visual Perception I_](https://pmc.ncbi.nlm.nih.gov/articles/PMC3482144/). + Applying it here means more space before a section than between its heading and + its first paragraph. The exact 48:12 relationship is our design choice. +- **Measure and reading behavior:** line length, leading, font metrics, and reading + task interact. [Dyson and Kipping's screen-reading study](https://journals.uc.edu/index.php/vl/article/view/5671) + reports different outcomes for reading speed and subjective preference. + A comfortable measure is a calibrated constraint, not a universal optimum. +- **Adaptability:** [WCAG visual presentation](https://www.w3.org/WAI/WCAG22/Understanding/visual-presentation.html) + discusses constrained line length, leading, and user adjustment. Its AAA + criterion does not prescribe one mandatory default stylesheet. + [WCAG text spacing](https://www.w3.org/WAI/WCAG22/Understanding/text-spacing.html) + requires surviving user overrides; it is not a recommendation to set every + paragraph to those override values by default. +- **Implementation consistency:** Tailwind's `--spacing` is 0.25rem. Using that + module reduces independent decisions. Four pixels is an engineering convention, + not a perceptual law. See [Tailwind's custom-style guidance](https://tailwindcss.com/docs/adding-custom-styles). + +## Horizontal grid + +| Role | Constraint | Reason | +| --------------------- | -------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| Reading column | `--spacing(156)` = 39rem / 624px | Calibrated to about 70 characters on full lines in Geist at 18px; shared with navigation and footer | +| Media | Reading column + two `--spacing(40)` wings = 944px | Gives images, tables, and diagrams room without widening prose | +| Outer inset | `clamp(1rem, 4vw, 1.5rem)` | Preserves a usable edge on small screens, then caps the gutter | +| Annotation | `--spacing(56)` = 224px | Secondary reading column using 14/20 type | +| Annotation separation | `--spacing(8)` = 32px | Separates notes from prose without making them unrelated | +| Portrait image | At most `--spacing(80)` = 320px | Preserves the intended size of phone screenshots | +| Portrait pair | Reading column, 24px internal gap | Uses the same reading edges; stacks when two useful image widths no longer fit | + +An earlier `60ch` measure produced approximately 80–88 characters on full lines +in the opening ZeroClaw paragraphs. A narrower measure produced a median of 71. +The final 39rem width rounds that approximately 621px calibration onto the shared +spacing module. Recheck actual line lengths if the font or body size changes; +`ch` describes the zero glyph, not average letters. + +The two media wings shrink first. Once they reach zero, prose and media shrink +together inside the outer insets. The reading column stays centered with or +without notes. Browser scrollbars consume their normal share of viewport width. + +Sidenotes become available when the **article container** reaches 75rem. The fit +is 39rem prose + two sets of 14rem note + 2rem gap + 1.5rem inset, rounded up from +74rem. The AI action stays in the footer until 92rem, where a separate outer +control area fits beyond both text and notes. The thresholds describe content +capacity rather than device categories. + +## Type roles + +All sizes are rem-based. Line heights are unitless and scale with user font +settings. Pixel equivalents below assume a 16px root. + +| Role | Tailwind size | Leading | Face / weight | +| ----------------------------------- | ----------------- | ---------------------------------------- | ----------------------- | +| Article title | `text-4xl`, 36px | 52px on narrow screens; 44px from 40rem | Instrument Serif, 400 | +| Section, h2 | `text-3xl`, 30px | 44px on narrow screens; 40px above 40rem | Instrument Serif, 400 | +| Subsection, h3 | `text-xl`, 20px | 28px | Geist, 500 | +| h4 | `text-lg`, 18px | 28px | Geist, 600 | +| h5 | `text-lg`, 18px | 28px | Geist, 500 | +| h6 | `text-lg`, 18px | 28px | Geist italic, 500 | +| Article body | `text-lg`, 18px | 28px | Geist, 400 | +| Table / expanded disclosure | `text-base`, 16px | 24px | Geist, 400 | +| Caption / note / disclosure summary | `text-sm`, 14px | 20px | Geist, 400; summary 500 | +| Code | `text-sm`, 14px | 24px | Geist Mono, 400 | +| Toolbar label | `text-xs`, 12px | 20px | Geist, 400 | + +The serif's optical size matters: a 24px Instrument Serif section heading looked +weaker than a 20px Geist subsection. Increasing the section size establishes the +intended hierarchy. Deep levels retain readable sizes and distinguish weight, +style, and spacing. The title remains larger than every section at mobile widths. + +Headings use `text-balance` to even out short multiline titles. Captions, notes, +and disclosure summaries use `text-pretty` to improve their final lines. Long-form +paragraphs retain normal wrapping and a ragged edge. Code preserves whitespace; +its toolbars use normal wrapping. These are browser-native enhancements, with +normal wrapping as the fallback. See [Tailwind text wrapping](https://tailwindcss.com/docs/text-wrap). +Inline links retain the site's wavy underline without gaining an unrelated bold +weight. Real normal and italic Geist faces are loaded. Code stays at the loaded +normal mono weight rather than synthesizing a heavier face. + +## Vertical relationships + +The block owning the new content owns its preceding space. There is no global +row gap combined with inherited margins. Build-time block metadata identifies the +content role and the previous role; CSS applies the following small rule set. + +| Transition | Space | +| --------------------------------------- | -------------------------------------- | +| Paragraph → paragraph / ordinary block | 24px | +| Paragraph → list | 12px | +| Any heading → its content | 12px | +| Heading → another heading | 16px | +| Ordinary content → h2 | 48px | +| Ordinary content → h3 or h4 | 32px | +| Ordinary content → h5 or h6 | 24px | +| Into / out of media | 32px; heading rules take precedence | +| Image → its caption | 12px | +| List item → list item / nested list | 8px | +| Paragraphs inside a quote or disclosure | 16px | +| Adjacent disclosures | 8px, plus each summary's 44px hit area | +| Main content → endnotes | 48px, then 24px inside the separator | +| Paragraphs within a note | 12px | +| Note → next note | 16px | + +The first block has no artificial preceding space. Lists do not contribute +stray first/last item margins. Nested quotes use one 16px indentation step. +These are spacing relationships on a four-pixel module, not a rigid baseline +lattice: image aspect ratios, text wrapping, borders, and reader overrides are +allowed to determine natural height. + +## Content behavior + +Standard top-level Markdown images, linked images, image figures, tables, code +blocks, and Mermaid diagrams use the wider tracks automatically. Images retain their natural +aspect ratio and are not enlarged beyond their intrinsic width. Captions return +to the reading measure. Fenced code, highlighted code figures, and GitHub excerpts share the media +tracks and retain native keyboard scrolling for long lines. Code nested in a +list or disclosure stays inside that parent. Wide code blocks also stop a +sidenote span, so annotations cannot overlap their frame. + +```mdx +
+ +![Describe the useful detail](./illustration.webp) + +
A caption attached to its image.
+
+``` + +Use `article-screenshot` for a single phone capture and `article-screenshot-grid` +for a pair. The optional top-level `article-wide` class also works for custom +compositions. Nested content stays within the space of its parent. + +Native `
` and `` provide progressive disclosure. Summaries +have a 44px minimum hit area, a visible triangle, and a keyboard focus indicator. +Animated walkthroughs sit inside disclosures that can be closed. Failed Mermaid +diagrams expose their authored source in a native disclosure. + +Tables use natural word wrapping rather than breaking column names into +syllables. If their intrinsic columns do not fit, the named table region scrolls +by keyboard. Full GitHub source paths wrap in their toolbar. Toolbars and their control groups +wrap when enlarged text or narrow containers need more room. Read-only GFM task +checkboxes receive names from their item text. + +Use GFM `[^note]` syntax for annotations. The build transform preserves IDs, +numbering, repeated references, paragraphs, and backlinks. Notes with the same +reference block share a margin list and its spacing metadata. Each list spans +following prose rows, stopping before the next note group or wide block. Its +intrinsic height reserves room; a long note cannot overlap the next wide image. +Very long notes can add vertical space, so disclosures suit extended explanations. + +Narrow screens and print retain the same single copy of each note as endnotes. +References inside disclosures or wide figures stay endnotes at every width. +Standard DOM reading order remains prose followed by linked notes; placement +requires no client-side measurement or positioning. + +The three archived music charts were authored with light labels on transparent +backgrounds. Their `article-chart` surface supplies the dark background they need +in both themes. Other images retain their own artwork and the shared subtle outline. + +## Verification + +Use `/writing/kitchen-sink` in development for the complete element vocabulary. +The audit record is [Article visual audit](article-visual-audit.md). Check real +posts too, including older Markdown, linked figures, long code, and large tables. + +Run: + +```sh +bun run lint +bun run build +bun test tests/article-grid.test.tsx tests/mdx-image.test.ts tests/mdx-code-block.test.tsx tests/remark-embed-github.test.ts +``` + +The transform check includes image promotion, linked figures, semantic flow, +named tables and task checkboxes, grouped notes, repeated backlinks, and disclosure +fallback notes. Visual checks remain necessary for hierarchy, grouping, media +scale, and wrapping. Do not infer those qualities from a passing unit test. diff --git a/docs/article-visual-audit.md b/docs/article-visual-audit.md new file mode 100644 index 00000000..590e998b --- /dev/null +++ b/docs/article-visual-audit.md @@ -0,0 +1,175 @@ +# Article visual audit + +Reviewed on 2026-09-24 against fetched `origin/next` at `19ce04e`. The work covers +the complete reading experience: the shared page frame, article title and +metadata, every supported MDX element, annotations, media, disclosures, and the +return to the writing index and footer. Findings below have been implemented. + +## Scope and evidence + +The stack is Next.js 16, React 19, Tailwind v4, MDX, the existing Base UI controls, +and native HTML. Repository instructions and `BLOG.md` informed the review; +the resulting authoring and design contract is [Reading layout system](article-layout.md). +The interface, accessibility, layout, writing, typography, color, and UI skills +were used together. No new dependency or client-side layout engine was added. + +The rendered comparison included [Lee's agents article](https://leerob.com/agents) +and [AI article](https://leerob.com/ai), the deployed ZeroClaw article, and the +local implementation. Measurements below are CSS pixels at a 16px root; they +describe those rendered pages, not universal requirements. + +| Dimension | Lee's observed treatment | Original site | Implemented system | +| --------------- | ---------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------------------- | +| Reading measure | About 600px at desktop | 672px desktop; 704px at an intermediate width | 624px maximum, shared with navigation, index, and footer | +| Body | Iowan Old Style, 17/27.2 | Geist, 16/24, weight 300 | Geist, 18/28, weight 400 | +| Title / section | 30/33 title; 23.2/32.48 section | Title and section both 24px Instrument Serif | 36px title; 30px section; deliberate mobile leading | +| Media | Up to about 1100px | Same narrow measure as prose | Up to 944px, automatically selected by content type | +| Notes | 240px, 13/18.2 sans, 32px separation | Endnotes | 224px, 14/20, 32px separation when the container can fit them | +| Disclosure | Plain native summary, about 13/20.8 | No unified article treatment | Native summary, 14/20, minimum 44px target, compact related rows | +| Vertical flow | Generous section breaks and quieter internal spacing | Independent prose and component margins | One owner per gap, selected by the relationship between adjacent blocks | + +Lee's useful ideas are the separation of reading and media widths, the quieter +secondary typography, the restrained disclosure treatment, and the use of the +margin as supporting space. His exact font metrics and dimensions are not the +basis for this implementation. The existing Geist / Instrument Serif identity +and wavy links remain recognizable. + +### Coverage + +| Domain | Evidence inspected | Result | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------- | +| Accessibility | Browser accessibility tree; keyboard controls, note links, scroll regions, 320px reflow, 200% browser zoom and text resizing, user text-spacing overrides, chart contrast | Findings resolved; assistive-technology limits below | +| Layout | All 16 MDX routes at 1440px and 320px; the kitchen sink across 10 widths; full ZeroClaw and kitchen-sink visual traversal; representative sections in the rest of the archive | Shared grid and flow corrected | +| Writing | Article metadata, captions, disclosure labels, source-path labels, diagram failure message, index titles, archived link paragraphs | Structural and interface copy corrected; essay prose preserved | +| Typography | Rendered heading levels, long titles, prose, emphasis, inline code, notes, captions, lists, quotes, tables, code and toolbars | Explicit role hierarchy and shared leading established | +| Color | Light/dark text, muted labels, code tokens, controls, image outlines, archived SVG charts | Contrast failures corrected without a new palette | +| UI | Toolbar density, disclosure affordances, focus appearance, image frames, mobile floating action, native scrolling, menu and popover behavior | Shared controls and media treatment made consistent | + +The 16 routes were: both music reviews, cursor-following portrait, GPU Postal, +ZeroClaw, Veera, Messenger, JSONB media search, React Native, lead-finding agent, +queue/matchmaker, Koa/Webpack, kitchen sink, Tranquilo, OneCent, and the revived +website. The kitchen sink is a development fixture; the other 15 are real posts. + +The shared frame was also inspected on Home, Writing, Work, Tokens, and Journey. +Home and Writing received desktop/mobile visual checks. Work and Tokens were +available locally only in their data-unavailable states. Journey received a +desktop visual and narrow-width geometry check. This is a complete article-flow +audit with shared-shell regression checks, not an audit of every live dashboard +interaction or every sentence in the archive. + +## Findings and implemented corrections + +Severity describes the original impact. All rows are resolved in this work. +Locations refer to the final implementation; “Before” records observations from +the original site or the partial implementation encountered during this audit. + +| Severity | Domain | Location | Before | After | Why | +| -------- | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- | +| HIGH | Layout | `src/components/ask-ai-widget.tsx:32`; `src/components/site-header.tsx:47`; `src/components/copy-email-button.tsx:45` | The fixed AI action covered mobile reading space. Enlarged text pushed header controls and the footer email past the narrow viewport, and clipped diagram controls inside their frame. | AI action uses the footer until a separate outer control area fits. Header and toolbars wrap; email and footer navigation can shrink and wrap. | Content and controls must remain reachable when space or reader settings change. | +| HIGH | Color | `src/app/article.css:289`; `src/content/blog/2018-music-in-review/page.mdx:12` | Light chart labels on transparent SVGs had approximately 1.37:1 contrast against the light page. | The three charts receive their intended dark surface; label contrast is approximately 12.56:1. | Widening an unreadable chart does not make its information accessible. | +| HIGH | UI | `src/content/blog/kickback-with-koa-webpack/page.mdx:115` and `:445` | Archived animated walkthroughs ran inline without a stopping affordance. | Native closed disclosures require reader initiation and can be closed to stop viewing the animation. | Readers need control over moving explanatory content. | +| HIGH | Writing | `src/components/mdx/Mermaid.tsx:442` | A diagram render failure ended at an error message. | The failure explains that the diagram could not render and offers “View diagram source” with accessible scrolling. | A failure needs a usable recovery path to the information. | +| MEDIUM | Layout | `src/app/article.css:63`; `src/lib/rehype-article-grid.mjs:50` | Margin stacking produced a 56px Markdown-image gap versus 32px for a figure, 32px heading-to-list space, and 48px between consecutive heading levels. | Build-time block roles drive one preceding margin: 24px ordinary flow, 12px heading-to-content, 16px consecutive headings, 32px media, 48px section breaks. Nested spacing is reset separately. | Proximity must describe the content relationship, independent of Markdown syntax. | +| MEDIUM | Layout | `src/app/article.css:8` and `:22`; `src/lib/rehype-article-grid.mjs:83` | Reading edges drifted between header and body. Wide figures required selected manual classes; linked image figures were missed. | Shared 624px reading edge; named wide tracks; automatic plain, Markdown, linked-image and figure handling. Phone pairs retain the reading measure. | A grid must explain the whole page and continue to work for new content. | +| MEDIUM | Typography | `src/app/article.css:44`; `src/app/layout.tsx:18` | Serif headings were weak relative to sans subsections; several deep levels were indistinguishable; type and leading varied by component. | Titles, sections, subsections, body, secondary text, code and controls have explicit roles. Normal and italic Geist faces are loaded. Title remains larger than sections on mobile. | Hierarchy depends on optical font differences as well as nominal size. | +| MEDIUM | Layout | `src/lib/rehype-article-grid.mjs:147`; `src/app/article.css:418` and `:440` | Notes needed treatment for paired references, repeated references, long content, nearby wide media, closed disclosures, and print. | One DOM copy, grouped at the first visible reference; grid spans reserve height before another note group or wide image; narrow screens and print use endnotes. | An annotation should remain connected to its reference without collision or duplicate content. | +| MEDIUM | Typography | `src/app/article.css:293` and `:316` | Table labels broke into fragments; code and diagram toolbars used competing font sizes, leading and spacing. | Natural table wrapping and keyboard overflow; 14/24 code; 12/20 toolbar labels; shared targets and padding; long source paths wrap. | Dense technical content needs internal consistency and an explicit overflow behavior. | +| MEDIUM | Accessibility | `src/lib/rehype-article-grid.mjs:50`; `src/components/mdx/Mermaid.tsx:479`; `src/lib/remark-embed-github.mjs:362` | Read-only task checkboxes lacked names. Some control names omitted their visible wording. | Task names derive from item text; zoom reset includes the displayed percentage; GitHub cards use visible link text as their accessible name. | Native and assistive representations should identify the same content and action. | +| MEDIUM | Writing | `src/content/blog/2016-music-in-review/page.mdx:16`; `src/content/blog/how-we-built-our-react-native-app/page.mdx:29`; `src/content/blog/kickback-with-koa-webpack/page.mdx:16`; `src/components/writing-list.tsx:66` | Older posts skipped section levels, caption-like prose was detached from images, artist links ran into descriptions, and index titles truncated. | Correct h2 structure, native figcaptions, separate link paragraphs, and fully wrapping index titles. | A visual system requires useful document structure and complete labels. | +| LOW | UI | `src/app/article.css:265` | Disclosure spacing and decoration had no consistent relationship to prose, headings or adjacent disclosures. | Plain native triangle; 14/20 summary; 44px target; visible focus; 8px between related disclosures and 12px before their expanded content. | Progressive disclosure should remain easy to discover without competing with the article hierarchy. | + +## Verification + +### Rendered layout + +- All 16 article routes at **1440px and 320px**: no page-level horizontal + overflow. This includes classic scrollbars, which reduce the available width. +- Kitchen-sink geometry at **320, 390, 640, 768, 1024, 1199, 1200, 1440, 1472, + and 1920px**: prose stays within its measure; wings shrink first; annotations + change placement at the capacity boundary; no note collisions. +- **Actual 200% Chrome page zoom** at a 1440px browser size: 720 CSS-pixel + viewport, device-pixel ratio 2, endnotes, no page overflow. This was browser + zoom, not a screenshot scale transform. +- **200% root text size** at 320, 390, 768, and 1024px: header, footer and toolbar failures + found and corrected; rechecked at the narrowest width after the final fix, + including controls clipped by an ancestor rather than page-level overflow. +- **WCAG text-spacing override** at 390px: 1.5 line height, 0.12em letter + spacing, 0.16em word spacing, and 2em paragraph spacing; no page overflow. +- **RTL geometry**: logical insets move the note column to the other side + without page overflow. This is a layout check, not a translated-content audit. +- **Print media and PDF generation**: grid becomes a centered block layout, + notes return to a 624px endnote column, closed disclosure contents become + printable, and interaction controls are hidden. A PDF was generated; every + pagination boundary was not manually inspected. + +### Content and interaction + +- Visually traversed the entire kitchen sink on desktop and mobile: heading + levels, lists and nested lists, task lists, quotes, inline code, fenced code, + plain and highlighted code, native disclosures, images, captions, phone pairs, + GitHub previews and fallbacks, tables, diagrams, dates, and notes. +- Visually traversed ZeroClaw and inspected representative media and text + sections in the remaining real articles. Confirmed all six image figures in + the portrait article now use the wide tracks, including linked figures. +- Keyboard Enter opens and closes native summaries with visible focus. +- Keyboard Enter follows a footnote and its return link; the target lands + approximately 32px below the viewport top and focus returns to the reference. +- ArrowRight scrolls a focused code region (observed 40px movement) and the GPU + Postal table at 320px (34px available and traversed). +- The mobile AI picker opens within the viewport, Escape closes it, and focus + returns to “Ask an AI”. No external assistant action was submitted. +- Light/dark text pairs checked: body approximately **9.93:1 / 12.74:1**; + muted text approximately **5.84:1 / 7.49:1**. Code tokens and controls were + inspected in the rendered fixture as well as in source. +- Lighthouse snapshot: **100 accessibility** on the mobile kitchen sink after + the naming fixes; the home snapshot also scored 100. This is an automated + check, not proof of complete WCAG conformance. + +### Repository checks + +```sh +bun run lint +bun run build +bun test tests/article-grid.test.tsx tests/mdx-image.test.ts tests/mdx-code-block.test.tsx tests/remark-embed-github.test.ts +``` + +The focused checks pass: **9 tests, 61 assertions**. They cover the build-time +transform's structure, grouped and repeated notes, linked figures, accessible +names, image dimensions, code regions, and GitHub embeds. Lint passes with no warnings or errors. The production build passes, including +TypeScript checking and generation of all 38 static pages. GitHub returned some 403 responses during local +builds; the existing graceful link-card fallbacks were exercised. + +### Not verified + +VoiceOver/NVDA reading order and announcements, Safari/Firefox rendering, every +print page break, all live Work/Tokens dashboard data states, and exhaustive +localization. These are coverage limits rather than claimed passes. The design +uses native semantics and single-copy footnotes to keep these paths simple. + +## Verdict + +**Approve for the inspected article flow.** The recorded findings are resolved. +The shared system now governs the complete element vocabulary and its +transitions. The design rationale, calibration assumptions, authoring rules, +and responsive behavior are documented in [Reading layout system](article-layout.md). + +## Follow-up: wrapping and code width + +The article title and heading levels already used `text-balance`. Captions, +footnote paragraphs and disclosure summaries now use `text-pretty`; long-form +paragraphs and control labels retain normal wrapping. Code keeps its authored +whitespace. Browser behavior and fallbacks follow [Tailwind's text-wrap guidance](https://tailwindcss.com/docs/text-wrap). + +Top-level fenced code, syntax-highlighted code figures, raw preformatted blocks, +and GitHub code excerpts now use the same wide columns as images. Nested code +stays in its parent. The grid transform recognizes code before reserving note +spans, so a preceding sidenote cannot extend into a wide code block. The focused +transform check includes highlighted, plain, GitHub, and nested code, plus a note +immediately before wide code. Rendered checks confirmed 944px code blocks at a +1440px viewport and 288px blocks at 320px, with no page overflow. At 200% root +text size, code controls remained inside their frames. + +The metadata row was deliberately simplified in the initial redesign: horizontal +borders removed, the vertical separator replaced with a dot, date/read-time type +increased from 12px to 14px, and wrapping allowed. Its title separation is 12px. +The Markdown action and the date/read-time values were preserved. diff --git a/next.config.ts b/next.config.ts index 4f8ada55..be2cef1c 100644 --- a/next.config.ts +++ b/next.config.ts @@ -89,6 +89,11 @@ const remarkEmbedGitHub = new URL( import.meta.url ).pathname; +const rehypeArticleGrid = new URL( + "src/lib/rehype-article-grid.mjs", + import.meta.url +).pathname; + const withMDX = createMDX({ options: { rehypePlugins: [ @@ -115,6 +120,7 @@ const withMDX = createMDX({ keepBackground: false, }, ], + rehypeArticleGrid, ], remarkPlugins: [ remarkStaticImageImports, diff --git a/src/app/(blog)/writing/[slug]/page.tsx b/src/app/(blog)/writing/[slug]/page.tsx index 6c933c95..9984ae96 100644 --- a/src/app/(blog)/writing/[slug]/page.tsx +++ b/src/app/(blog)/writing/[slug]/page.tsx @@ -10,7 +10,6 @@ import { JsonLd } from "@/components/json-ld"; import MDXImage from "@/components/mdx/MDXImage"; import { SiteMain } from "@/components/site-page"; import { SiteShell } from "@/components/site-shell"; -import { Separator } from "@/components/ui/separator"; import { siteNavigation } from "@/content/site"; import { buildAskAiPrompt } from "@/lib/ask-ai"; import { @@ -88,20 +87,18 @@ export default async function BlogPostPage({ params }: { params: PageParams }) { }), }} > - + -
-
-

- {metadata.title} -

+
+
+

{metadata.title}

-
+
- + {readingTime}
diff --git a/src/app/(portfolio)/page.tsx b/src/app/(portfolio)/page.tsx index 5317119b..b35f77a3 100644 --- a/src/app/(portfolio)/page.tsx +++ b/src/app/(portfolio)/page.tsx @@ -78,12 +78,12 @@ function OpenSource({ github }: Readonly<{ github: GitHubProfile }>) {
} - className="site-row grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 rounded-sm py-2.5 text-start text-base text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring group" + className="site-row grid min-h-11 w-full grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 rounded-sm py-3 text-start text-base text-foreground focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-ring group" href={project.url} rel="noopener noreferrer" target="_blank" > - + {project.name} {project.stars === null ? null : ( diff --git a/src/app/article.css b/src/app/article.css new file mode 100644 index 00000000..19a9368c --- /dev/null +++ b/src/app/article.css @@ -0,0 +1,478 @@ +@layer components { + :root { + /* 156 Tailwind units, calibrated to ~70 characters in Geist at 18px. */ + --site-measure: --spacing(156); + --site-gutter: clamp(--spacing(4), 4vw, --spacing(6)); + } + + .site-container { + width: 100%; + max-width: calc(var(--site-measure) + 2 * var(--site-gutter)); + margin-inline: auto; + padding-inline: var(--site-gutter); + } +} + +@layer utilities { + .article-main { + max-width: none; + padding-inline: 0; + } + + .article-layout { + --article-wing: --spacing(40); + --article-note: --spacing(56); + --article-gap: --spacing(8); + @apply grid text-lg font-normal text-secondary-foreground; + line-height: calc(28 / 18); + grid-template-columns: + [full-start] minmax(var(--site-gutter), 1fr) + [wide-start] minmax(0, var(--article-wing)) + [content-start] min( + calc(100% - 2 * var(--site-gutter)), + var(--site-measure) + ) + [content-end] minmax(0, var(--article-wing)) + [wide-end] minmax(var(--site-gutter), 1fr) [full-end]; + } + + .article-header { + grid-column: content; + @apply mb-12 min-w-0; + } + + .article-title { + @apply text-balance font-serif text-4xl font-normal text-foreground; + line-height: calc(52 / 36); + } + @media (min-width: 40rem) { + .article-title { + line-height: calc(44 / 36); + } + } + .article-meta { + @apply mt-3; + } + + .article-prose { + @apply grid grid-cols-subgrid min-w-0 max-w-none; + grid-column: full; + overflow-wrap: anywhere; + } + + /* One flow scale for blocks and their margin notes. More space before a + group than inside it; semantic neighbours override the default once. */ + .article-prose [data-article-kind] { + --article-space: --spacing(6); + } + .article-prose + :is([data-article-kind="media"], [data-article-after="media"]) { + --article-space: --spacing(8); + } + .article-prose :is([data-article-kind="h3"], [data-article-kind="h4"]) { + --article-space: --spacing(8); + } + .article-prose + :is( + [data-article-kind="h1"], + [data-article-kind="h2"], + [data-article-kind="rule"] + ) { + --article-space: --spacing(12); + } + .article-prose [data-article-kind="list"][data-article-after="paragraph"], + .article-prose [data-article-after^="h"] { + --article-space: --spacing(3); + } + .article-prose [data-article-kind^="h"][data-article-after^="h"] { + --article-space: --spacing(4); + } + .article-prose + [data-article-kind="disclosure"][data-article-after="disclosure"] { + --article-space: --spacing(2); + } + .article-prose [data-article-after="rule"] { + --article-space: --spacing(8); + } + .article-prose [data-article-kind]:not([data-article-after]) { + --article-space: 0px; + } + + .article-block { + grid-column: content; + @apply flow-root min-w-0; + margin-block-start: var(--article-space); + } + .article-block[data-article-wide] { + grid-column: wide; + } + .article-prose + :where( + p, + h1, + h2, + h3, + h4, + h5, + h6, + ul, + ol, + li, + figure, + blockquote, + pre, + details, + hr + ) { + margin-block: 0; + } + .article-prose .article-block > * { + margin-block: 0; + } + + .article-prose :is(h1, h2) { + @apply font-serif text-3xl font-normal text-foreground; + line-height: calc(40 / 30); + } + @media (max-width: 40rem) { + .article-prose :is(h1, h2) { + line-height: calc(44 / 30); + } + } + .article-prose h3 { + @apply font-sans text-xl font-medium text-foreground; + line-height: calc(28 / 20); + } + .article-prose :is(h4, h5, h6) { + @apply font-sans text-lg text-foreground; + line-height: calc(28 / 18); + } + .article-prose h4 { + @apply font-semibold; + } + .article-prose h5 { + @apply font-medium; + } + .article-prose h6 { + @apply font-medium italic; + } + .article-prose :is(h1, h2, h3, h4, h5, h6) { + @apply text-balance scroll-mt-8; + } + .article-prose strong { + @apply font-semibold text-foreground; + } + .article-prose a:not(.github-embed) { + @apply text-primary underline; + font-weight: inherit; + text-decoration-thickness: from-font; + text-decoration-skip-ink: auto; + } + .article-prose a:focus-visible { + @apply rounded-sm outline-2 outline-offset-4 outline-ring; + } + .article-prose a.heading-anchor { + @apply text-inherit no-underline; + } + .article-prose a.heading-anchor:hover { + @apply underline; + } + + .article-prose :is(ul, ol) { + @apply ps-6; + } + .article-prose ul { + list-style-type: disc; + } + .article-prose ol { + list-style-type: decimal; + } + .article-prose li::marker { + @apply text-muted-foreground; + } + .article-prose li + li { + @apply mt-2; + } + .article-prose :is(li, blockquote, details) > * + * { + @apply mt-4; + } + .article-prose li > :is(ul, ol) { + @apply mt-2; + } + .article-prose .contains-task-list { + @apply list-none ps-0; + } + .article-prose .task-list-item { + @apply relative ps-6; + } + .article-prose .task-list-item > input { + @apply absolute start-0 top-2 m-0 size-3; + } + + .article-prose :is(pre, code) { + @apply font-mono; + } + .article-prose code:not(pre code) { + @apply rounded bg-muted px-[0.3em] py-[0.1em] font-normal text-foreground; + font-size: 0.875em; + box-decoration-break: clone; + } + .article-prose blockquote { + @apply border-s-2 border-muted-foreground/40 ps-4 font-normal not-italic; + } + .article-prose hr { + @apply border-border; + } + + .article-prose img { + @apply mx-auto block h-auto max-w-full rounded-lg; + outline: 1px solid light-dark(rgb(0 0 0 / 10%), rgb(255 255 255 / 10%)); + outline-offset: -1px; + } + .article-prose figure > p:has(> img:only-child) { + @apply m-0; + } + .article-prose figcaption { + @apply mx-auto mt-3 text-start font-sans text-sm font-normal text-pretty text-muted-foreground; + max-width: var(--site-measure); + line-height: calc(20 / 14); + } + .article-prose .article-screenshot { + @apply mx-auto max-w-80; + } + .article-prose :is(.article-screenshot, .article-screenshot-grid) { + @apply scroll-mt-8; + } + .article-prose :is(.article-screenshot, .article-screenshot-grid) img { + @apply m-0 w-full; + } + .article-prose .article-screenshot-grid { + @apply mx-auto grid w-full gap-6; + max-width: var(--site-measure); + grid-template-columns: repeat( + auto-fit, + minmax(min(100%, --spacing(64)), 1fr) + ); + } + .article-prose .article-screenshot-grid figure { + @apply m-0 min-w-0 w-full max-w-80 justify-self-center; + } + + .article-prose details { + @apply text-base; + line-height: 1.5; + } + .article-prose summary { + @apply min-h-11 cursor-pointer py-3 font-sans text-sm font-medium text-pretty text-secondary-foreground; + line-height: calc(20 / 14); + } + .article-prose summary:hover { + @apply text-foreground underline; + } + .article-prose summary:focus-visible { + @apply rounded-sm outline-2 outline-offset-4 outline-ring; + } + .article-prose details > :not(summary) { + margin-inline-start: --spacing(4); + } + .article-prose details > summary + * { + @apply mt-3; + } + + .article-block:has(> table) { + @apply overflow-x-auto rounded-lg border border-border; + overscroll-behavior-inline: contain; + } + .article-block:has(> table):focus-visible { + @apply outline-2 outline-offset-4 outline-ring; + } + .article-prose .article-chart { + background: #171717; + } + + .article-prose table { + @apply w-full text-base; + border-collapse: collapse; + overflow-wrap: normal; + line-height: 1.5; + } + .article-prose :is(th, td) { + @apply px-4 py-3 text-start align-top; + } + .article-prose th { + @apply bg-muted font-semibold text-foreground; + } + .article-prose tbody tr:not(:last-child), + .article-prose thead { + @apply border-b border-border; + } + .article-prose tbody tr:last-child { + @apply border-b-0; + } + .article-prose table code { + overflow-wrap: normal; + } + + .article-prose :is(.code-block, .mermaid-block) { + @apply overflow-hidden rounded-lg border border-border bg-muted; + } + .dark .article-prose .code-block { + @apply bg-background; + } + .article-prose + :is(.code-block-toolbar, .github-code-embed-toolbar, .mermaid-toolbar) { + @apply m-0 flex min-h-12 max-w-none flex-wrap items-center justify-between gap-2 px-2 py-1 font-sans text-xs text-wrap; + line-height: calc(20 / 12); + } + .article-prose :is(.code-block-copy, .code-block-language, .mermaid-control) { + @apply inline-flex min-h-10 min-w-10 items-center justify-center gap-1.5 rounded px-2 py-2 font-sans text-xs font-normal text-secondary-foreground; + line-height: calc(20 / 12); + } + .article-prose :is(.code-block-copy, .mermaid-control) { + @apply cursor-pointer; + } + .article-prose :is(.code-block-copy, .mermaid-control):hover:not(:disabled) { + @apply bg-background text-foreground; + } + .article-prose + :is( + .code-block-copy, + .code-block-language, + .mermaid-control + ):focus-visible { + @apply outline-2 outline-offset-1 outline-ring; + } + .article-prose .mermaid-control:disabled { + @apply cursor-default opacity-35; + } + .article-prose + :is(.code-block-copy, .code-block-language, .mermaid-control) + svg { + @apply size-4; + stroke-width: 1.5; + } + @media (pointer: coarse) { + .article-prose + :is(.code-block-copy, .code-block-language, .mermaid-control) { + @apply min-h-11 min-w-11; + } + } + .article-prose .code-block pre > code { + font-size: var(--text-sm); + line-height: calc(24 / 14); + } + .article-prose .github-embed-repository { + @apply whitespace-normal; + overflow-wrap: anywhere; + } + .article-prose .github-code-embed-source { + @apply h-auto min-h-10; + } + .article-prose .mermaid-diagram-type { + line-height: calc(20 / 12); + } + .article-prose .mermaid-toolbar-actions { + @apply min-w-0 max-w-full flex-wrap; + } + .article-prose .github-code-embed-source-label { + line-height: calc(20 / 12); + @apply whitespace-normal; + overflow-wrap: anywhere; + } + .article-prose :is(code[data-theme], code[data-theme] span) { + color: color-mix(in srgb, var(--shiki-light) 60%, var(--foreground)); + font-style: var(--shiki-light-font-style); + font-weight: var(--shiki-light-font-weight); + text-decoration: var(--shiki-light-text-decoration); + } + .dark .article-prose :is(code[data-theme], code[data-theme] span) { + color: color-mix(in srgb, var(--shiki-dark) 78%, var(--foreground)); + font-style: var(--shiki-dark-font-style); + font-weight: var(--shiki-dark-font-weight); + text-decoration: var(--shiki-dark-text-decoration); + } + + .article-prose > .footnotes { + grid-column: content; + @apply mt-12 min-w-0 border-t border-border pt-6 text-sm; + line-height: calc(20 / 14); + } + .article-prose .footnotes :is(p + p, ol + ol) { + @apply mt-3; + } + .article-prose .footnotes p { + @apply text-pretty; + } + .article-prose .footnotes li + li { + @apply mt-4; + } + .article-prose .footnotes li, + .article-prose a[data-footnote-ref] { + @apply scroll-mt-8; + } + .article-prose a[data-footnote-ref] { + @apply no-underline; + } + .article-prose .footnotes .sr-only { + @apply m-0; + } + + /* 39rem measure + 2 × (14rem note + 2rem gap + 1.5rem gutter). */ + @container article (min-width: 75rem) { + .article-block { + grid-row: var(--article-row); + } + .article-prose > .footnotes { + display: contents; + } + .article-prose .footnotes > .article-sidenotes { + grid-column: content-end / full-end; + grid-row: var(--article-row) / span var(--article-span); + width: var(--article-note); + margin-inline-start: var(--article-gap); + margin-block-start: var(--article-space); + @apply self-start text-sm text-muted-foreground; + line-height: calc(20 / 14); + } + .article-prose .footnotes > ol:not(.article-sidenotes) { + grid-column: content; + @apply mt-12; + } + } + + @media print { + .article-layout { + max-width: var(--site-measure); + margin-inline: auto; + } + .article-layout, + .article-prose { + display: block; + } + .article-header { + @apply mb-8; + } + .article-prose > .footnotes { + display: block; + } + .article-prose .footnotes > .article-sidenotes { + width: auto; + margin-inline: 0; + @apply mt-3; + } + .article-prose details::details-content { + content-visibility: visible; + } + .article-prose + :is(.code-block-copy, .mermaid-toolbar, .code-block-language) { + display: none; + } + .article-prose :is(h1, h2, h3, h4, h5, h6) { + break-after: avoid; + } + .article-prose :is(p, li) { + orphans: 3; + widows: 3; + } + } +} diff --git a/src/app/globals.css b/src/app/globals.css index 385721df..0e6d6e92 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "shadcn/tailwind.css"; +@import "./article.css"; @custom-variant dark (&:is(.dark *)); @plugin "@tailwindcss/typography"; @theme inline { @@ -81,7 +82,7 @@ } body { - @apply bg-background text-base font-light text-foreground; + @apply bg-background text-base font-normal text-foreground; } } :root { diff --git a/src/app/layout.tsx b/src/app/layout.tsx index cb4165c8..fd9ae23b 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -15,6 +15,7 @@ import "./globals.css"; const sans = Geist({ display: "swap", + style: ["normal", "italic"], subsets: ["latin"], variable: "--font-geist", }); @@ -93,7 +94,7 @@ export default function RootLayout({ - + ) { return ( -
+
{children}
); diff --git a/src/components/copy-email-button.tsx b/src/components/copy-email-button.tsx index eb3d3d6b..2055b652 100644 --- a/src/components/copy-email-button.tsx +++ b/src/components/copy-email-button.tsx @@ -42,7 +42,7 @@ export function CopyEmailButton({ email }: Readonly<{ email: string }>) { return (
- + {diagramType}
@@ -457,7 +470,7 @@ export default function Mermaid({ chart, className }: Readonly) {