0 reported it broke · the ▶ count is distinct visitors who ran it, counted once each, anonymously
Pick one base size and one ratio and every heading size falls out of the multiplication, instead of being chosen 32px at a time because it felt about right. It opens on a perfect fourth at 16px, laid out as a real article about baking bread rather than the usual "Aa Aa Aa", and line height is part of the model too — it tightens as the type grows, because a 51px headline set at 1.55 falls apart. Hit compare two ratios and the same copy renders at two scales in one grid, h1 beside h1 and body beside body: the golden ratio lands a 110px h1 against the perfect fourth's 51px while the body text stays identical in both columns. Drag the base down to 11px on a minor second and the panel warns that two steps now round to the same pixel — a scale that has run out of room at the small end — while the CSS panel keeps emitting real custom properties in rem or px, semantic (--fs-h1) or numeric (--step-4), with the pixel value in a comment and a copy button.
Source — the code you see is the code that just ran 936 lines 41.4 KB index.html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Type Scale Designer — sloprun.dev</title>
<style>
/* sloprun design tokens — inline this block into every demo (self-contained rule).
Identity: instrument-panel. Machine facts in mono; human words in sans.
Green is EARNED: only for "it ran / it worked" states, never decoration. */
:root {
--bg: #F6F6F3; /* neutral paper, not cream */
--surface: #FFFFFF;
--ink: #1A1D21;
--muted: #5A6068;
--line: #E3E2DC;
--accent: #C05600; /* phosphor amber, darkened for light bg */
--accent-soft: #FFF3E6;
--run: #1A7F37; /* earned green */
--run-soft: #E7F4EA;
--danger: #C93C37;
--radius: 10px;
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
--font-mono: ui-monospace, "SF Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
}
:root[data-theme="dark"] {
--bg: #14161A; --surface: #1C1F26; --ink: #E8E6E1; --muted: #9AA0A8;
--line: #2A2E36; --accent: #FFB454; --accent-soft: #2A2118;
--run: #3FB950; --run-soft: #16281B; --danger: #F47067;
}
body { background: var(--bg); color: var(--ink); font-family: var(--font-sans); }
/* ---- extra surfaces (theme-aware, derived from the token set) ---- */
:root { --sunk: #FAFAF7; --bB: rgba(26,29,33,.34); }
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) { --sunk: #171A20; --bB: rgba(232,230,225,.40); }
}
:root[data-theme="dark"] { --sunk: #171A20; --bB: rgba(232,230,225,.40); }
* { box-sizing: border-box; }
html, body { margin: 0; padding: 0; }
body { line-height: 1.5; -webkit-text-size-adjust: 100%; overflow-x: hidden; }
.wrap { max-width: 1240px; margin: 0 auto; padding: 22px 16px 44px; }
.mono { font-family: var(--font-mono); }
.sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
/* ---- header ---- */
header { margin-bottom: 16px; }
header h1 { font-size: 1.35rem; margin: 0 0 6px; letter-spacing: -0.01em; }
.lede { margin: 0 0 10px; color: var(--muted); max-width: 70ch; font-size: .95rem; }
.spec { display: flex; flex-wrap: wrap; gap: 6px; }
.spec b {
font-family: var(--font-mono); font-size: .7rem; font-weight: 500; letter-spacing: .02em;
padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px;
color: var(--muted); background: var(--surface); white-space: nowrap;
}
/* ---- cards ---- */
.card { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 14px; min-width: 0; }
.card + .card { margin-top: 14px; }
.card > h2 {
font-family: var(--font-mono); font-size: .72rem; font-weight: 600; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin: 0 0 12px;
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.grid { display: grid; gap: 14px; grid-template-columns: 1fr; align-items: start; margin-top: 14px; }
@media (min-width: 1000px) { .grid { grid-template-columns: minmax(0, 1.12fr) minmax(0, 1fr); } }
.rightcol { min-width: 0; }
@media (min-width: 1000px) {
.rightcol { position: sticky; top: 14px; }
/* compare mode earns the full width: specimen spans, readouts sit side by side */
body.cmp .grid { grid-template-columns: minmax(0, 1fr); }
body.cmp .rightcol {
position: static; display: grid; gap: 14px; align-items: start;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
}
body.cmp .rightcol .card + .card { margin-top: 0; }
}
.rightcol .card + .card { margin-top: 14px; }
/* ---- controls ---- */
button, input, select { font: inherit; color: inherit; }
.btn {
font-family: var(--font-mono); font-size: .74rem; padding: 6px 10px;
background: var(--surface); color: var(--muted);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer; white-space: nowrap;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--ink); }
.btn[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); font-weight: 600; }
.btn.primary { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); font-weight: 600; }
.btn.tiny { font-size: .68rem; padding: 4px 8px; }
/* no border-radius here: it would override each control's own corner and make
the shape jump the moment it takes keyboard focus. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.row { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.presets { margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px dashed var(--line); }
.presets .lab { font-family: var(--font-mono); font-size: .64rem; letter-spacing: .08em; text-transform: uppercase; color: var(--muted); margin-right: 2px; }
.ctrlgrid { display: grid; gap: 14px; grid-template-columns: 1fr; }
@media (min-width: 640px) { .ctrlgrid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 1000px) { .ctrlgrid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.field { min-width: 0; }
.field > .lab {
display: block; font-family: var(--font-mono); font-size: .64rem; letter-spacing: .08em;
text-transform: uppercase; color: var(--muted); margin-bottom: 7px;
}
.field .lab .v { color: var(--ink); letter-spacing: 0; text-transform: none; }
.sliderrow { display: flex; gap: 10px; align-items: center; }
input[type="range"] { flex: 1 1 90px; min-width: 80px; accent-color: var(--accent); height: 22px; }
input[type="number"], input.num {
font-family: var(--font-mono); font-size: .78rem; width: 5.4em; padding: 5px 6px;
background: var(--sunk); border: 1px solid var(--line); border-radius: 7px; color: var(--ink);
}
.subrow { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 10px; }
.subrow .mini { flex: 1 1 120px; min-width: 0; }
.mini .lab { display: block; font-family: var(--font-mono); font-size: .62rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); margin-bottom: 4px; }
.mini .lab .v { color: var(--ink); letter-spacing: 0; text-transform: none; }
.ratios { display: grid; grid-template-columns: repeat(auto-fill, minmax(112px, 1fr)); gap: 6px; }
.rbtn {
text-align: left; font-family: var(--font-sans); font-size: .74rem; line-height: 1.25;
padding: 6px 8px; background: var(--sunk); color: var(--ink);
border: 1px solid var(--line); border-radius: 7px; cursor: pointer; min-width: 0;
}
.rbtn .n { display: block; font-family: var(--font-mono); font-size: .72rem; color: var(--muted); }
.rbtn:hover { border-color: var(--accent); }
.rbtn[aria-checked="true"] { background: var(--accent-soft); border-color: var(--accent); }
.rbtn[aria-checked="true"] .n { color: var(--accent); font-weight: 600; }
.grpB .rbtn[aria-checked="true"] { background: var(--sunk); border-color: var(--ink); }
.grpB .rbtn[aria-checked="true"] .n { color: var(--ink); }
.seg { display: inline-flex; border: 1px solid var(--line); border-radius: 7px; overflow: hidden; background: var(--sunk); }
.seg button {
font-family: var(--font-mono); font-size: .7rem; padding: 5px 9px; border: 0; background: transparent;
color: var(--muted); cursor: pointer; text-transform: none; letter-spacing: 0;
}
.card > h2 .btn { text-transform: none; letter-spacing: 0; }
.seg button + button { border-left: 1px solid var(--line); }
.seg button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); font-weight: 600; }
.seg button:focus-visible { outline-offset: -2px; }
/* compare-only controls. Declared AFTER .seg / .row so the hide wins on source order. */
.b-only { display: none; }
body.cmp .b-only { display: block; }
body.cmp .b-only.row { display: flex; }
body.cmp .b-only.seg { display: inline-flex; }
.readout {
margin-top: 14px; padding: 9px 11px; background: var(--sunk); border: 1px solid var(--line);
border-radius: 8px; font-size: .74rem; color: var(--muted);
display: flex; flex-wrap: wrap; gap: 4px 14px;
}
.readout b { color: var(--ink); font-weight: 600; }
.readout .k { color: var(--muted); }
/* ---- specimen ---- */
.cnote {
display: none; font-size: .72rem; color: var(--muted); background: var(--sunk);
border: 1px solid var(--line); border-radius: 8px; padding: 7px 10px; margin-bottom: 12px;
}
body.cmp .cnote { display: block; }
.cnote b { color: var(--ink); font-weight: 600; }
.cols { display: grid; gap: 18px; grid-template-columns: 1fr; }
body.cmp .cols { gap: 14px; }
@media (min-width: 700px) { body.cmp .cols { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.col { min-width: 0; }
body.cmp .col { border: 1px solid var(--line); border-radius: 8px; padding: 10px; background: var(--sunk); }
/* paired compare: matching blocks share a grid row, so the two scales line up */
body.cmp .cols.pair {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
column-gap: 22px; row-gap: 16px;
background: linear-gradient(var(--line), var(--line)) no-repeat 50% 0 / 1px 100%;
}
body.cmp .cols.pair > .blk { margin: 0; min-width: 0; }
body.cmp .cols.pair > .colhead { margin-bottom: 0; }
.colhead {
display: none; font-size: .68rem; letter-spacing: .04em; color: var(--muted);
padding-bottom: 8px; margin-bottom: 10px; border-bottom: 1px solid var(--line);
}
body.cmp .colhead { display: block; }
.colhead .tagA { color: var(--accent); font-weight: 700; }
.colhead .tagB { color: var(--ink); font-weight: 700; }
.doc { min-width: 0; overflow-x: auto; }
.blk { margin: 0 0 4px; padding-left: 0; }
.blk + .blk { margin-top: 14px; }
.tag {
font-family: var(--font-mono); font-size: .62rem; letter-spacing: .05em; color: var(--muted);
display: block; margin-bottom: 3px;
}
.tag i { font-style: normal; color: var(--accent); }
body.nolab .tag { display: none; }
body.nolab .blk + .blk { margin-top: 20px; }
.pushr { margin-left: auto; }
.txt { overflow-wrap: anywhere; hyphens: auto; letter-spacing: -0.005em; }
.txt.p { color: var(--muted); max-width: 38em; letter-spacing: 0; }
.txt.h { font-weight: 650; letter-spacing: -0.018em; }
.txt.k { font-weight: 500; }
/* ---- table ---- */
.tablewrap { overflow-x: auto; }
table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: .74rem; }
th, td { text-align: right; padding: 5px 7px; border-bottom: 1px solid var(--line); white-space: nowrap; }
th:first-child, td:first-child, th.l, td.l { text-align: left; }
thead th { font-size: .62rem; letter-spacing: .07em; text-transform: uppercase; color: var(--muted); font-weight: 600; }
tbody tr.base td { background: var(--accent-soft); }
tbody tr.base td:first-child { font-weight: 700; color: var(--accent); }
td.name { color: var(--ink); }
td.dim { color: var(--muted); }
td.bar { width: 34%; min-width: 90px; padding-right: 2px; }
.bars { display: flex; flex-direction: column; gap: 2px; }
.bars i { display: block; height: 5px; border-radius: 2px; background: var(--accent); min-width: 2px; }
.bars i.b2 { background: var(--bB); }
.warn {
display: none; margin: 10px 0 0; font-size: .68rem; color: var(--accent);
background: var(--accent-soft); border: 1px solid var(--accent); border-radius: 7px; padding: 6px 9px;
}
.warn.on { display: block; }
/* ---- css output ---- */
.css {
margin: 0; background: var(--sunk); border: 1px solid var(--line); border-radius: 8px;
padding: 11px 12px; font-size: .72rem; line-height: 1.6; color: var(--ink);
overflow-x: auto; max-height: 340px; overflow-y: auto; white-space: pre;
}
.css .c { color: var(--muted); }
.css .v { color: var(--accent); }
.copyrow { margin-top: 10px; }
#copyState { font-size: .7rem; color: var(--muted); }
#copyState.ok { color: var(--run); font-weight: 600; }
/* ---- explainer / footer ---- */
details { margin-top: 16px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 10px 14px; }
summary { cursor: pointer; font-family: var(--font-mono); font-size: .72rem; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
details p { font-size: .86rem; color: var(--muted); max-width: 76ch; }
details code { font-family: var(--font-mono); font-size: .92em; color: var(--ink); }
footer { margin-top: 24px; font-family: var(--font-mono); font-size: .7rem; color: var(--muted); }
@media (prefers-reduced-motion: no-preference) {
.btn, .rbtn, .seg button, .bars i, .col { transition: background-color .14s ease, border-color .14s ease, color .14s ease; }
}
</style>
</head>
<body>
<div class="wrap">
<header>
<h1>Type Scale Designer</h1>
<p class="lede">Pick one base size and one ratio, and every other text size on the page falls out of the multiplication. Here is the whole scale on a real article, the exact numbers, and the CSS to paste.</p>
<div class="spec"><b>modular scale</b><b>px + rem</b><b>line-height model</b><b>css custom properties</b><b>compare two ratios</b></div>
</header>
<section class="card">
<h2>Controls</h2>
<div class="presets row">
<span class="lab">start from</span>
<button class="btn" type="button" data-preset="editorial" aria-pressed="false">editorial</button>
<button class="btn" type="button" data-preset="ui" aria-pressed="false">app ui</button>
<button class="btn" type="button" data-preset="poster" aria-pressed="false">poster</button>
<button class="btn" type="button" data-preset="dense" aria-pressed="false">dense table</button>
</div>
<div class="ctrlgrid">
<div class="field">
<label class="lab" for="base">base size <span class="v mono" id="baseOut">16px</span></label>
<div class="sliderrow">
<input type="range" id="base" min="11" max="28" step="1" value="16">
<input type="number" class="num" id="baseNum" min="11" max="28" step="1" value="16" aria-label="Base size in pixels">
</div>
<div class="subrow">
<div class="mini">
<label class="lab" for="up">steps up <span class="v" id="upOut">4</span></label>
<input type="range" id="up" min="1" max="6" step="1" value="4">
</div>
<div class="mini">
<label class="lab" for="down">steps down <span class="v" id="downOut">2</span></label>
<input type="range" id="down" min="0" max="3" step="1" value="2">
</div>
</div>
</div>
<div class="field">
<span class="lab">ratio <span class="v mono" id="ratioAOut"></span></span>
<div class="ratios" id="grpA" role="radiogroup" aria-label="Scale ratio"></div>
<div class="subrow">
<div class="mini">
<label class="lab" for="rcA">custom ratio</label>
<div class="sliderrow">
<input type="range" id="rcA" min="1.05" max="2" step="0.001" value="1.333">
<input type="number" class="num" id="rnA" min="1.05" max="2" step="0.001" value="1.333" aria-label="Custom ratio">
</div>
</div>
</div>
</div>
<div class="field b-only">
<span class="lab">ratio B <span class="v mono" id="ratioBOut"></span></span>
<div class="ratios grpB" id="grpB" role="radiogroup" aria-label="Second scale ratio for comparison"></div>
<div class="subrow">
<div class="mini">
<label class="lab" for="rcB">custom ratio B</label>
<div class="sliderrow">
<input type="range" id="rcB" min="1.05" max="2" step="0.001" value="1.618">
<input type="number" class="num" id="rnB" min="1.05" max="2" step="0.001" value="1.618" aria-label="Custom ratio B">
</div>
</div>
</div>
</div>
<div class="field">
<span class="lab">line height</span>
<div class="subrow" style="margin-top:0">
<div class="mini">
<label class="lab" for="lh">body <span class="v mono" id="lhOut">1.55</span></label>
<input type="range" id="lh" min="1.15" max="1.9" step="0.01" value="1.55">
</div>
<div class="mini">
<label class="lab" for="tight">tighten headings <span class="v mono" id="tightOut">0.09</span></label>
<input type="range" id="tight" min="0" max="0.2" step="0.01" value="0.09">
</div>
</div>
</div>
<div class="field">
<span class="lab">preview typeface</span>
<div class="seg" id="faceSeg" role="group" aria-label="Preview typeface">
<button type="button" data-face="sans" aria-pressed="true">sans</button>
<button type="button" data-face="serif" aria-pressed="false">serif</button>
<button type="button" data-face="mono" aria-pressed="false">mono</button>
</div>
<div class="row" style="margin-top:10px">
<button class="btn" type="button" id="roundBtn" aria-pressed="true">round to whole px</button>
</div>
</div>
<div class="field">
<span class="lab">compare</span>
<div class="row">
<button class="btn" type="button" id="cmpBtn" aria-pressed="false">compare two ratios</button>
</div>
<p class="mono" style="font-size:.68rem;color:var(--muted);margin:9px 0 0;max-width:34ch">Puts a second scale next to this one, same copy, same base — so you can see which ratio actually reads better.</p>
</div>
</div>
<div class="readout mono" id="readout"></div>
</section>
<div class="grid">
<section class="card">
<h2>Specimen
<button class="btn tiny pushr" type="button" id="labBtn" aria-pressed="true">size labels</button>
</h2>
<div class="cnote mono" id="cnote"></div>
<div class="cols" id="specimen"></div>
</section>
<div class="rightcol">
<section class="card">
<h2>Sizes</h2>
<div class="tablewrap">
<table id="tbl">
<thead><tr id="thead"></tr></thead>
<tbody id="tbody"></tbody>
</table>
</div>
<p class="warn mono" id="collide"></p>
</section>
<section class="card">
<h2>CSS
<span class="seg b-only" id="abSeg" role="group" aria-label="Which scale to output">
<button type="button" data-ab="A" aria-pressed="true">scale A</button>
<button type="button" data-ab="B" aria-pressed="false">scale B</button>
</span>
<span class="seg" id="nameSeg" role="group" aria-label="Variable naming">
<button type="button" data-name="semantic" aria-pressed="true">--fs-h1</button>
<button type="button" data-name="numeric" aria-pressed="false">--step-4</button>
</span>
<span class="seg" id="unitSeg" role="group" aria-label="Output unit">
<button type="button" data-unit="rem" aria-pressed="true">rem</button>
<button type="button" data-unit="px" aria-pressed="false">px</button>
</span>
</h2>
<pre class="css mono" id="css" tabindex="0" aria-label="Generated CSS custom properties"></pre>
<div class="row copyrow">
<button class="btn primary" type="button" id="copy">copy css</button>
<span class="mono" id="copyState" role="status" aria-live="polite"></span>
</div>
</section>
</div>
</div>
<details>
<summary>how a modular scale works</summary>
<p>A modular scale is one number multiplied over and over. Body text is the base. One step up is
<code>base × ratio</code>, two steps up is <code>base × ratio²</code>, and a step down divides instead.
Small ratios like 1.125 give you a quiet, dense page where headings barely lift off the text; big ones like
1.618 give you a page that shouts at the top and whispers at the bottom. Nothing in between gets invented,
which is the whole point — the sizes relate to each other instead of being picked one at a time.</p>
<p>Line height here is not one number. It starts at your body value and shrinks as text gets bigger, by the
<em>tighten</em> amount per doubling of size, because a 48px headline set at 1.55 looks like it is falling apart.
The rem column assumes the browser default root of 16px, so <code>1rem</code> is 16px even when your base is 19px.
Rounding to whole pixels is on by default because that is what browsers do to your text anyway; turn it off and
watch the fractional values appear. If two neighbouring steps round to the same pixel, the panel says so —
that is a real scale that has run out of room at the small end.</p>
</details>
<footer>demo · sloprun.dev</footer>
</div>
<script>
(function () {
"use strict";
/* ---------- ratios ---------- */
var RATIOS = [
{ name: "minor second", v: 1.067 },
{ name: "major second", v: 1.125 },
{ name: "minor third", v: 1.200 },
{ name: "major third", v: 1.250 },
{ name: "perfect fourth", v: 1.333 },
{ name: "aug. fourth", v: 1.414 },
{ name: "perfect fifth", v: 1.500 },
{ name: "golden ratio", v: 1.618 },
{ name: "major sixth", v: 1.667 }
];
var NAMES = { 6: "mega", 5: "display", 4: "h1", 3: "h2", 2: "h3", 1: "h4",
0: "body", "-1": "small", "-2": "caption", "-3": "tiny" };
/* everyday sample copy — a page anyone has read before */
var COPY = {
mega: { t: "Slow Sunday", c: "h" },
display: { t: "Bread, mostly waiting", c: "h" },
h1: { t: "How to bake a loaf on a slow Sunday", c: "h" },
h2: { t: "Start the night before", c: "h" },
h3: { t: "Getting the crust right", c: "h" },
h4: { t: "A note on flour", c: "h" },
small: { t: "Makes one loaf. About eighteen hours, and twenty minutes of that is you.", c: "k" },
caption: { t: "The second loaf, which came out better than the first.", c: "p" },
tiny: { t: "Kitchen notebook · page 41", c: "p" }
};
var PARAS = [
"The dough does nearly all of the work while you sleep. You stir four things together in a bowl at night, and by breakfast the kitchen smells like somewhere you would happily queue outside.",
"Flour, water, salt and a pinch of yeast, mixed until there are no dry patches left in the bowl. Cover it with a plate and go to bed. In the morning it should look loose, bubbled and slightly alive.",
"Heat the pot before the dough goes anywhere near it. The steam trapped under the lid is what gives you that thin, crackly shell everyone tears off first while the loaf is still too hot to slice.",
"Bread flour holds more water, so the loaf is still soft on the second day. Plain flour works too; it just goes stale a little sooner, which has never once stopped anyone."
];
var FACES = {
sans: "var(--font-sans)",
serif: "Georgia, 'Iowan Old Style', 'Times New Roman', serif",
mono: "var(--font-mono)"
};
var PRESETS = {
editorial: { base: 19, rA: 1.333, rB: 1.25, up: 4, down: 2, lh: 1.6, tight: 0.10, face: "serif" },
ui: { base: 15, rA: 1.200, rB: 1.125, up: 4, down: 1, lh: 1.5, tight: 0.07, face: "sans" },
poster: { base: 18, rA: 1.618, rB: 1.414, up: 5, down: 1, lh: 1.35, tight: 0.14, face: "sans" },
dense: { base: 13, rA: 1.125, rB: 1.067, up: 3, down: 2, lh: 1.45, tight: 0.05, face: "mono" }
};
var state = {
base: 16, rA: 1.333, rB: 1.618, up: 4, down: 2,
lh: 1.55, tight: 0.09, round: true, cmp: false, labels: true,
face: "sans", naming: "semantic", unit: "rem", ab: "A"
};
var $ = function (id) { return document.getElementById(id); };
/* ---------- math ---------- */
function steps() {
var out = [], i;
for (i = state.up; i >= -state.down; i--) out.push(i);
return out;
}
function rawPx(i, r) { return state.base * Math.pow(r, i); }
function px(i, r) {
var v = rawPx(i, r);
return state.round ? Math.round(v) : Math.round(v * 100) / 100;
}
function lineH(p) {
var v = state.lh - state.tight * (Math.log(p / state.base) / Math.LN2);
v = Math.min(1.9, Math.max(1.05, v));
return Math.round(v * 100) / 100;
}
function rem(p) {
var s = (p / 16).toFixed(4);
if (s.indexOf(".") > -1) s = s.replace(/0+$/, "").replace(/\.$/, "");
return s;
}
function fmtR(r) { return r.toFixed(3).replace(/0+$/, "").replace(/\.$/, ""); }
function ratioName(r) {
for (var i = 0; i < RATIOS.length; i++) {
if (Math.abs(RATIOS[i].v - r) < 0.0005) return RATIOS[i].name;
}
return "custom";
}
function esc(s) {
return String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
}
/* ---------- ratio pickers ---------- */
function buildGroup(el, which) {
var html = "", i;
for (i = 0; i < RATIOS.length; i++) {
html += '<button type="button" class="rbtn" role="radio" tabindex="-1" data-v="' + RATIOS[i].v +
'" aria-checked="false">' +
esc(RATIOS[i].name) + '<span class="n">' + fmtR(RATIOS[i].v) + '×</span></button>';
}
el.innerHTML = html;
el.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest(".rbtn") : null;
if (!b) return;
setRatio(which, parseFloat(b.getAttribute("data-v")));
});
/* one tab stop for the whole group; arrows move and pick, like a real radio group */
el.addEventListener("keydown", function (e) {
var k = e.key, bs, idx, next;
if (k !== "ArrowRight" && k !== "ArrowLeft" && k !== "ArrowDown" &&
k !== "ArrowUp" && k !== "Home" && k !== "End") return;
bs = Array.prototype.slice.call(el.querySelectorAll(".rbtn"));
idx = bs.indexOf(document.activeElement);
if (idx < 0) return;
e.preventDefault();
if (k === "Home") next = 0;
else if (k === "End") next = bs.length - 1;
else if (k === "ArrowRight" || k === "ArrowDown") next = (idx + 1) % bs.length;
else next = (idx - 1 + bs.length) % bs.length;
setRatio(which, parseFloat(bs[next].getAttribute("data-v")));
bs[next].focus();
});
}
function setRatio(which, v) {
v = Math.min(2, Math.max(1.05, v));
if (which === "A") { state.rA = v; $("rcA").value = v; $("rnA").value = fmtR(v); }
else { state.rB = v; $("rcB").value = v; $("rnB").value = fmtR(v); }
render();
}
function syncGroup(el, r) {
var bs = el.querySelectorAll(".rbtn"), i, on, sel = -1;
for (i = 0; i < bs.length; i++) {
on = Math.abs(parseFloat(bs[i].getAttribute("data-v")) - r) < 0.0005;
bs[i].setAttribute("aria-checked", on ? "true" : "false");
if (on && sel < 0) sel = i;
}
/* a custom ratio matches nothing — keep the group reachable by Tab anyway */
if (sel < 0) sel = 0;
for (i = 0; i < bs.length; i++) bs[i].tabIndex = (i === sel ? 0 : -1);
}
/* ---------- specimen ---------- */
function blocks() {
var have = {}, s = steps(), i, out = [];
for (i = 0; i < s.length; i++) have[NAMES[s[i]]] = s[i];
function put(name) {
if (have[name] === undefined) return;
out.push({ i: have[name], name: name, t: COPY[name].t, c: COPY[name].c });
}
function para(n, after) {
if (after && have[after] === undefined) return;
out.push({ i: 0, name: "body", t: PARAS[n], c: "p" });
}
put("mega"); put("display"); put("h1");
if (out.length) para(0);
put("h2"); para(1, "h2");
put("h3"); para(2, "h3");
put("h4"); para(3, "h4");
put("small"); put("caption"); put("tiny");
return out;
}
function blockHTML(b, r) {
var p = px(b.i, r), l = lineH(p);
return '<div class="blk">' +
'<span class="tag">' + esc(b.name) + ' <i>' + p + 'px</i> · ' + l.toFixed(2) + '</span>' +
'<div class="txt ' + b.c + '" style="font-size:' + p + 'px;line-height:' + l + '">' + esc(b.t) + '</div>' +
'</div>';
}
function renderDoc(r) {
var bl = blocks(), html = "", i;
for (i = 0; i < bl.length; i++) html += blockHTML(bl[i], r);
return html;
}
/* side-by-side only pays off once there is room for two columns */
var mqPair = window.matchMedia("(min-width: 700px)");
function renderSpecimen() {
var face = FACES[state.face], el = $("specimen"), html, i, bl;
var headA = '<div class="colhead mono"><span class="tagA">A</span> ' +
esc(ratioName(state.rA)) + ' · ' + fmtR(state.rA) + '×</div>';
var headB = '<div class="colhead mono"><span class="tagB">B</span> ' +
esc(ratioName(state.rB)) + ' · ' + fmtR(state.rB) + '×</div>';
if (state.cmp && mqPair.matches) {
/* one grid, A and B interleaved — each pair of blocks shares a row and lines up */
bl = blocks();
html = headA + headB;
for (i = 0; i < bl.length; i++) html += blockHTML(bl[i], state.rA) + blockHTML(bl[i], state.rB);
el.className = "cols pair";
el.style.fontFamily = face;
el.innerHTML = html;
return;
}
el.className = "cols";
el.style.fontFamily = "";
html = '<div class="col">' + headA +
'<div class="doc" style="font-family:' + face + '">' + renderDoc(state.rA) + '</div></div>';
if (state.cmp) {
html += '<div class="col">' + headB +
'<div class="doc" style="font-family:' + face + '">' + renderDoc(state.rB) + '</div></div>';
}
el.innerHTML = html;
}
/* ---------- table ---------- */
function renderTable() {
var s = steps(), i, k, p, q, head, body = "", maxp = 0;
for (i = 0; i < s.length; i++) {
maxp = Math.max(maxp, px(s[i], state.rA));
if (state.cmp) maxp = Math.max(maxp, px(s[i], state.rB));
}
if (maxp <= 0) maxp = 1;
head = state.cmp
? '<th class="l">step</th><th class="l">name</th><th>A px</th><th>B px</th><th>Δ</th><th class="l">size</th>'
: '<th class="l">step</th><th class="l">name</th><th>px</th><th>rem</th><th>line-h</th><th class="l">size</th>';
$("thead").innerHTML = head;
/* a collision is two neighbouring steps landing on the same whole pixel.
In compare mode B can run out of room while A still has headroom, so both
columns are checked and the warning says which one gave out. */
var cA = false, cB = false, prevA = null, prevB = null;
for (i = 0; i < s.length; i++) {
k = s[i];
p = px(k, state.rA);
if (prevA !== null && p === prevA) cA = true;
prevA = p;
q = state.cmp ? px(k, state.rB) : null;
if (state.cmp) {
if (prevB !== null && q === prevB) cB = true;
prevB = q;
}
var stepLbl = (k > 0 ? "+" + k : String(k));
var bars = '<span class="bars"><i style="width:' + (p / maxp * 100).toFixed(1) + '%"></i>';
var cells;
if (state.cmp) {
var d = p > 0 ? ((q - p) / p * 100) : 0;
var ds = (d >= 0 ? "+" : "") + d.toFixed(0) + "%";
bars += '<i class="b2" style="width:' + (q / maxp * 100).toFixed(1) + '%"></i>';
cells = '<td class="l dim">' + stepLbl + '</td><td class="l name">' + NAMES[k] + '</td>' +
'<td>' + p + '</td><td class="dim">' + q + '</td><td class="dim">' + ds + '</td>';
} else {
cells = '<td class="l dim">' + stepLbl + '</td><td class="l name">' + NAMES[k] + '</td>' +
'<td>' + p + '</td><td class="dim">' + rem(p) + '</td><td class="dim">' + lineH(p).toFixed(2) + '</td>';
}
bars += '</span>';
body += '<tr' + (k === 0 ? ' class="base"' : '') + '>' + cells + '<td class="bar">' + bars + '</td></tr>';
}
$("tbody").innerHTML = body;
var w = $("collide");
if (cA || cB) {
w.textContent =
!state.cmp ? "two steps round to the same px — this ratio has run out of room. widen it or switch rounding off." :
cA && cB ? "in both scales, two steps round to the same px — neither ratio has room left. widen them or switch rounding off." :
"in scale " + (cA ? "A" : "B") + ", two steps round to the same px — that ratio has run out of room. widen it or switch rounding off.";
w.className = "warn on";
} else {
w.textContent = "";
w.className = "warn";
}
}
/* ---------- readout + compare note ---------- */
function renderReadout() {
var s = steps(), top = px(s[0], state.rA), bot = px(s[s.length - 1], state.rA);
/* two scales are on screen in compare mode — say which one these numbers are */
var tag = state.cmp ? " A" : "";
$("readout").innerHTML =
'<span><span class="k">ratio' + tag + '</span> <b>' + fmtR(state.rA) + '×</b> ' + esc(ratioName(state.rA)) + '</span>' +
'<span><span class="k">base</span> <b>' + state.base + 'px</b></span>' +
'<span><span class="k">steps</span> <b>' + s.length + '</b> (+' + state.up + ' … −' + state.down + ')</span>' +
'<span><span class="k">range' + tag + '</span> <b>' + bot + 'px → ' + top + 'px</b></span>' +
'<span><span class="k">body line-h</span> <b>' + lineH(state.base).toFixed(2) + '</b></span>';
if (state.cmp) {
var name = NAMES[s[0]], a = px(s[0], state.rA), b = px(s[0], state.rB);
var d = a > 0 ? (b - a) / a * 100 : 0;
var bigger = d >= 0 ? "bigger" : "smaller";
$("cnote").innerHTML =
'Same copy, same <b>' + state.base + 'px</b> base. At <b>' + name + '</b>, B is <b>' + b + 'px</b> against A’s <b>' +
a + 'px</b> — ' + Math.abs(d).toFixed(0) + '% ' + bigger + '. Body text is identical in both columns; only the jumps change.';
}
}
/* ---------- css output ---------- */
var cssText = "";
function renderCSS() {
var r = (state.cmp && state.ab === "B") ? state.rB : state.rA;
var s = steps(), i, k, p, names = [], vals = [], lhn = [], lhv = [], maxn = 0, out = [];
for (i = 0; i < s.length; i++) {
k = s[i];
p = px(k, r);
var n = state.naming === "semantic" ? "--fs-" + NAMES[k] : "--step-" + k;
var ln = state.naming === "semantic" ? "--lh-" + NAMES[k] : "--step-" + k + "-lh";
names.push(n); vals.push(state.unit === "rem" ? rem(p) + "rem" : p + "px");
lhn.push(ln); lhv.push(lineH(p).toFixed(2));
maxn = Math.max(maxn, n.length, ln.length);
}
function pad(n) { var t = n + ":"; while (t.length < maxn + 1) t += " "; return t; }
out.push("/* type scale · " + ratioName(r) + " " + fmtR(r) + "× · base " + state.base + "px */");
out.push(":root {");
var maxv = 0;
for (i = 0; i < vals.length; i++) maxv = Math.max(maxv, vals[i].length);
function padv(v) { var t = v + ";"; while (t.length < maxv + 1) t += " "; return t; }
for (i = 0; i < names.length; i++) {
out.push(" " + pad(names[i]) + " " +
(state.unit === "rem" ? padv(vals[i]) + " /* " + px(s[i], r) + "px */" : vals[i] + ";"));
}
out.push("");
for (i = 0; i < lhn.length; i++) out.push(" " + pad(lhn[i]) + " " + lhv[i] + ";");
out.push("}");
cssText = out.join("\n");
/* light syntax tint, escaped first */
var html = esc(cssText)
.replace(/(\/\*[^*]*\*\/)/g, '<span class="c">$1</span>')
.replace(/: (\s*)([^;]+);/g, ': $1<span class="v">$2</span>;');
$("css").innerHTML = html;
}
/* a preset stays lit only while every one of its values still holds */
function syncPresets() {
var bs = document.querySelectorAll(".presets button[data-preset]"), i, p, on;
for (i = 0; i < bs.length; i++) {
p = PRESETS[bs[i].getAttribute("data-preset")];
on = !!p && p.base === state.base && p.up === state.up && p.down === state.down &&
p.face === state.face &&
Math.abs(p.rA - state.rA) < 0.0005 && Math.abs(p.rB - state.rB) < 0.0005 &&
Math.abs(p.lh - state.lh) < 0.005 && Math.abs(p.tight - state.tight) < 0.005;
bs[i].setAttribute("aria-pressed", on ? "true" : "false");
}
}
/* ---------- render ---------- */
function render() {
document.body.classList.toggle("cmp", state.cmp);
document.body.classList.toggle("nolab", !state.labels);
$("ratioAOut").textContent = fmtR(state.rA) + "× " + ratioName(state.rA);
$("ratioBOut").textContent = fmtR(state.rB) + "× " + ratioName(state.rB);
syncGroup($("grpA"), state.rA);
syncGroup($("grpB"), state.rB);
syncPresets();
renderReadout();
renderSpecimen();
renderTable();
renderCSS();
}
/* ---------- wiring ---------- */
buildGroup($("grpA"), "A");
buildGroup($("grpB"), "B");
function bindRange(id, key, out, fmt, after) {
var el = $(id);
el.addEventListener("input", function () {
state[key] = parseFloat(el.value);
if (out) $(out).textContent = fmt ? fmt(state[key]) : el.value;
if (after) after();
render();
});
}
bindRange("base", "base", "baseOut", function (v) { return v + "px"; }, function () { $("baseNum").value = state.base; });
$("baseNum").addEventListener("change", function () {
var v = parseInt(this.value, 10);
if (isNaN(v)) v = state.base;
v = Math.min(28, Math.max(11, v));
state.base = v; this.value = v; $("base").value = v; $("baseOut").textContent = v + "px";
render();
});
bindRange("up", "up", "upOut");
bindRange("down", "down", "downOut");
bindRange("lh", "lh", "lhOut", function (v) { return v.toFixed(2); });
bindRange("tight", "tight", "tightOut", function (v) { return v.toFixed(2); });
$("rcA").addEventListener("input", function () { setRatio("A", parseFloat(this.value)); });
$("rcB").addEventListener("input", function () { setRatio("B", parseFloat(this.value)); });
$("rnA").addEventListener("change", function () {
var v = parseFloat(this.value); if (isNaN(v)) v = state.rA; setRatio("A", v);
});
$("rnB").addEventListener("change", function () {
var v = parseFloat(this.value); if (isNaN(v)) v = state.rB; setRatio("B", v);
});
function bindSeg(id, attr, key, extra) {
var el = $(id);
el.addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button[data-" + attr + "]") : null;
if (!b) return;
state[key] = b.getAttribute("data-" + attr);
var bs = el.querySelectorAll("button"), i;
for (i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", bs[i] === b ? "true" : "false");
if (extra) extra();
render();
});
}
bindSeg("faceSeg", "face", "face");
bindSeg("nameSeg", "name", "naming");
bindSeg("unitSeg", "unit", "unit");
bindSeg("abSeg", "ab", "ab");
$("labBtn").addEventListener("click", function () {
state.labels = !state.labels;
this.setAttribute("aria-pressed", state.labels ? "true" : "false");
document.body.classList.toggle("nolab", !state.labels);
});
$("roundBtn").addEventListener("click", function () {
state.round = !state.round;
this.setAttribute("aria-pressed", state.round ? "true" : "false");
render();
});
$("cmpBtn").addEventListener("click", function () {
state.cmp = !state.cmp;
this.setAttribute("aria-pressed", state.cmp ? "true" : "false");
if (!state.cmp) {
state.ab = "A";
var bs = $("abSeg").querySelectorAll("button"), i;
for (i = 0; i < bs.length; i++) bs[i].setAttribute("aria-pressed", bs[i].getAttribute("data-ab") === "A" ? "true" : "false");
}
render();
});
/* presets */
document.querySelector(".presets").addEventListener("click", function (e) {
var b = e.target.closest ? e.target.closest("button[data-preset]") : null;
if (!b) return;
var p = PRESETS[b.getAttribute("data-preset")];
if (!p) return;
state.base = p.base; state.rA = p.rA; state.rB = p.rB;
state.up = p.up; state.down = p.down; state.lh = p.lh; state.tight = p.tight; state.face = p.face;
$("base").value = p.base; $("baseNum").value = p.base; $("baseOut").textContent = p.base + "px";
$("up").value = p.up; $("upOut").textContent = p.up;
$("down").value = p.down; $("downOut").textContent = p.down;
$("lh").value = p.lh; $("lhOut").textContent = p.lh.toFixed(2);
$("tight").value = p.tight; $("tightOut").textContent = p.tight.toFixed(2);
$("rcA").value = p.rA; $("rnA").value = fmtR(p.rA);
$("rcB").value = p.rB; $("rnB").value = fmtR(p.rB);
var fbs = $("faceSeg").querySelectorAll("button"), i;
for (i = 0; i < fbs.length; i++) fbs[i].setAttribute("aria-pressed", fbs[i].getAttribute("data-face") === p.face ? "true" : "false");
render();
});
/* copy */
var copyTimer = null;
function flash(msg, ok) {
var el = $("copyState");
el.textContent = msg;
el.className = "mono" + (ok ? " ok" : "");
if (copyTimer) clearTimeout(copyTimer);
copyTimer = setTimeout(function () { el.textContent = ""; el.className = "mono"; }, 2400);
}
function selectCSS() {
try {
var node = $("css"), sel = window.getSelection(), rg = document.createRange();
rg.selectNodeContents(node); sel.removeAllRanges(); sel.addRange(rg); node.focus();
} catch (err) { /* selection unavailable */ }
}
$("copy").addEventListener("click", function () {
var text = cssText;
var ok = function () { flash("copied ✓ " + text.split("\n").length + " lines", true); };
var no = function () { selectCSS(); flash("clipboard blocked — selected, press Ctrl/Cmd+C", false); };
if (navigator.clipboard && navigator.clipboard.writeText) {
try { navigator.clipboard.writeText(text).then(ok, no); } catch (err) { no(); }
} else {
var ta = document.createElement("textarea");
ta.value = text; ta.setAttribute("readonly", "");
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
document.body.appendChild(ta); ta.select();
var done = false;
try { done = document.execCommand("copy"); } catch (err) { done = false; }
document.body.removeChild(ta);
if (done) ok(); else no();
}
});
/* crossing the two-column threshold changes how the compare view is built */
if (mqPair.addEventListener) mqPair.addEventListener("change", render);
else if (mqPair.addListener) mqPair.addListener(render);
/* ---------- platform theme ---------- */
window.addEventListener("message", function (e) {
var d = e.data;
if (d && d.type === "sloprun:theme" && (d.theme === "light" || d.theme === "dark")) {
document.documentElement.setAttribute("data-theme", d.theme);
}
});
/* ---------- boot ---------- */
$("lhOut").textContent = state.lh.toFixed(2);
$("tightOut").textContent = state.tight.toFixed(2);
render();
})();
</script>
</body>
</html>
source-visible by construction · nothing is published here without its code
Comments
0 totalNo comments yet. If you ran it, say what happened.