Skip to content
misoto22 design

Templates

Article

One post, rendered from a real Markdown file through the site’s own pipeline.

The reading surface, against markup nobody in this repository authored: headings, tables, footnotes, LaTeX as MathML, task lists, fenced code, a flow diagram and a numbered pipeline, all out of one .md file.

Foundations2026-09-06 · 5 min

One factor moves them all

What went wrong when the radius scale was four independent numbers

Henry ChenDesign systems

The radius scale used to be four numbers typed by hand, and a theme that wanted square corners typed four more. It looked fine at the setting somebody checked and was wrong at every other one. This is the arithmetic of why, and what replaced it.

The nesting law

Two rounded edges separated by a gap of pp are concentric only when the inner radius is the outer minus the gap:

rinner=max(0,  routerp)r_{\text{inner}} = \max\left(0,\; r_{\text{outer}} - p\right)

Anything else pinches at the corner. The gap between the two curves is pp along the straight edges and narrows as it turns, and the eye reads that narrowing long before anyone can name it. A panel inset by 16px inside a frame rounded to rr therefore wants r16r - 16, and a frame wrapped 16px outside a panel rounded to rr wants r+16r + 16.

Depth is a hairline and a change of ground, never a blur.

The White Reset, law 2

Why four numbers could not hold it

A theme that re-types every step can put the steps out of proportion with each other — and once they are out of proportion, no single inset is right for two different settings at the same time. Here is what the old round theme did:

Step Default round Ratio
--radius-sm 6px 10px 1.67
--radius 8px 14px 1.75
--radius-lg 14px 22px 1.57
--radius-pill 999px 999px 1.00

Three different ratios and an exemption. The pill was exempt on the grounds that a pill is a shape rather than a corner — which is true, and which is also how a "square corners" theme shipped with every button still a capsule.[1]

What replaced it

One factor, and the ladder derived from it:

:root,
[data-radius] {
  --radius-factor: 1;
  --radius-xs: calc(4px * var(--radius-factor));
  --radius-sm: calc(6px * var(--radius-factor));
  --radius: calc(8px * var(--radius-factor));
  --radius-lg: calc(12px * var(--radius-factor));
  --radius-pill: calc(999px * var(--radius-factor));
}

A theme now sets one number. sharp sets it to 0 and everything squares off together, the pill included; round sets it to 2 and every step doubles, so the proportions hold.

The numbers themselves are not invented either, and that is deliberate: 8px for a control and 12px for a panel is where Tailwind's scale, shadcn's --radius ± 2 and ± 4, Radix Themes' steps 3 and 4, and Material's xs/sm/md all land within a pixel of each other. A design system is allowed one opinion per decision, and this was not the decision worth spending it on.

Both directions of the nesting law are named, so a surface never has to guess:

  1. --radius-row subtracts — a row inside a panel padded by 6px.
  2. --radius-frame adds — a frame sitting 16px outside a --radius-lg panel.

Where the tokens are read

--radius-factorthe one number a theme sets
The laddertokens.css
--radius-xsmarks
--radius-smchips
--radiuscontrols
--radius-lgpanels
--radius-pillcapsules
Derivedthe nesting law
--radius-rowouter − 6px
menu rows
--radius-frameouter + 16px
preview frames
One number, and everything that reads it.

How a corner gets its number

  1. A theme sets the factordata-radius="sharp" · --radius-factor: 0
  2. The ladder recomputescalc(<n>px * factor) — five steps, one ratio
  3. The nesting law derives two more--radius-row subtracts · --radius-frame adds
  4. A component reads a steprounded-(--radius-lg) — never a literal
  5. The corner is drawnconcentric with whatever contains it

The declaration sits on :root and on [data-radius], and that is load-bearing rather than tidy. A custom property substitutes var() where it is declared, so a ladder written only on the root bakes in the root's factor and a themed subtree deeper in the page never reaches it — which is exactly the case the themes page exercises, five radii on five wrappers of one document.


The cost of getting it wrong

Let nn be the number of nested surfaces on a screen and kk the number of radius settings a theme offers. Hand-typed steps make the number of arrangements somebody has to check n×kn \times k; a derived ladder makes it nn, because the proportions are a property of the ladder rather than of the setting. On this system that was 12×312 \times 3 against 1212 — and the twenty-four nobody checked are where every mismatched corner lived.

Press K and type radius to see the tokens themselves.


  1. A true circle is different again, and stays a circle: an avatar, a status dot, a spinner, a radio button. Those are geometry, not corners, and a radio that stopped being round would stop being distinguishable from a checkbox. ↩︎

RadiusTokensTheming

Built from

ArticleDiagramStepsAvatarBadgeTagSeparatorButton

Nothing here was styled specially for the template. That is the only arrangement where it stays honest as the system changes — a template with its own CSS stops being a test of the components and becomes a screenshot.

Source

TSX
'use client'

import { Article, Avatar, Badge, Button, Diagram, Separator, Steps, Tag } from '@misoto22/design'
import { ArrowLeft, Link2 } from 'lucide-react'
import { post } from '@/lib/posts'

const POST = post('one-factor-moves-them-all')

/**
 * One post, rendered from real Markdown.
 *
 * This is the template that tests the READING surface, and it is the only one
 * whose content does not come from a literal in this file: the body is a
 * `.md` file under `src/content/posts`, put through the site's own build-time
 * pipeline. That is deliberate, and it is what makes the test worth anything —
 * a page of hand-written JSX proves the components look right and proves
 * nothing about what happens when a pipeline hands the system a `<table>` it
 * did not author.
 *
 * What the file exercises, and therefore what this proves the system styles:
 * headings and their anchors, a standfirst, body copy on the measure, links,
 * emphasis, inline and fenced code, a blockquote with an attribution, ordered,
 * unordered and task lists, a GFM table wide enough to need its own scroll,
 * a horizontal rule, footnotes with their back-references, keyboard keys,
 * LaTeX both inline and displayed — rendered to MathML, so there is no second
 * stylesheet and no font to load — and a flow diagram authored as a fenced
 * ```diagram block and a numbered pipeline as a ```steps one.
 *
 * The blocks are prose and diagrams in the order they appeared: a diagram is a
 * component and cannot arrive as an HTML string, so the pipeline lifts each one
 * out and this maps over what is left. Everything else is one `Article`.
 */
export function Post() {
  return (
    <div className="flex flex-col">
      <header className="flex items-center justify-between gap-4 border-b border-(--rule) px-6 py-4 @3xl:px-10">
        <Button size="sm" variant="ghost" className="gap-2">
          <ArrowLeft size={14} strokeWidth={1.5} aria-hidden />
          Field Notes
        </Button>
        <Button size="sm" variant="ghost" iconOnly aria-label="Copy a link to this post">
          <Link2 size={14} strokeWidth={1.5} aria-hidden />
        </Button>
      </header>

      <div className="grid gap-10 px-6 py-10 @3xl:grid-cols-[minmax(0,1fr)_13rem] @3xl:px-10 @3xl:py-14">
        <div className="flex min-w-0 flex-col gap-8">
          <div className="flex max-w-(--w-reading) flex-col gap-4">
            <div className="flex flex-wrap items-center gap-2">
              <Badge tone="outline">{POST.meta.category}</Badge>
              <span className="mono-meta text-(--ink-3-aa)">
                {POST.meta.date} · {POST.readingMinutes} min
              </span>
            </div>
            <h1 className="m-0 font-heading text-[length:var(--fs-lead)] font-normal leading-[1.12] tracking-[-0.02em] text-(--ink)">
              {POST.meta.title}
            </h1>
            <p className="m-0 text-[length:var(--fs-item)] leading-[1.55] text-(--ink-2)">
              {POST.meta.subtitle}
            </p>
            <div className="flex items-center gap-2.5 pt-1">
              <Avatar fallback="HC" alt="" size="sm" />
              <span className="text-[13px] text-(--ink)">{POST.meta.author}</span>
              <span className="mono-meta text-(--ink-3-aa)">{POST.meta.role}</span>
            </div>
          </div>

          <Separator />

          {/* One Article per prose run, and the diagram between them as a real
              component. The blocks are already in order — nothing here decides
              where anything goes. */}
          <div className="flex flex-col gap-6">
            {POST.blocks.map((block, index) => {
              if (block.kind === 'diagram') {
                return <Diagram key={index} spec={block.spec} className="max-w-(--w-reading)" />
              }
              if (block.kind === 'steps') {
                return <Steps key={index} {...block.spec} className="max-w-(--w-reading) py-2" />
              }
              return <Article key={index} as="div" html={block.html} />
            })}
          </div>

          <Separator />

          <div className="flex flex-wrap items-center gap-1.5">
            {POST.meta.tags.map((tag) => (
              <Tag key={tag}>{tag}</Tag>
            ))}
          </div>
        </div>

        {/* The contents rail, built from the headings the pipeline collected —
            not from a second list somebody keeps in step by hand. */}
        <nav aria-label="On this page" className="max-@3xl:hidden">
          <div className="sticky top-6 flex flex-col gap-2 border-s border-(--rule) ps-4">
            <span className="eyebrow text-(--ink-3-aa)">On this page</span>
            {POST.toc.map((item) => (
              <a
                key={item.id}
                href={`#${item.id}`}
                className={`text-[13px] leading-snug text-(--ink-3-aa) transition-colors duration-(--duration-fast) hover:text-(--ink) ${
                  item.level === 3 ? 'ps-3' : ''
                }`}
              >
                {item.text}
              </a>
            ))}
          </div>
        </nav>
      </div>
    </div>
  )
}