trykeyline 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +18 -0
- package/LICENSE +21 -0
- package/README.md +117 -2
- package/bin/keyline.mjs +280 -0
- package/package.json +38 -4
- package/plugins/keyline/.claude-plugin/plugin.json +14 -0
- package/plugins/keyline/plugin.json +22 -0
- package/plugins/keyline/skills/keyline/SKILL.md +47 -0
- package/plugins/keyline/skills/keyline/commands/audit.md +30 -0
- package/plugins/keyline/skills/keyline/commands/color.md +7 -0
- package/plugins/keyline/skills/keyline/commands/critique.md +20 -0
- package/plugins/keyline/skills/keyline/commands/depth.md +7 -0
- package/plugins/keyline/skills/keyline/commands/init.md +16 -0
- package/plugins/keyline/skills/keyline/commands/layout.md +7 -0
- package/plugins/keyline/skills/keyline/commands/motion.md +7 -0
- package/plugins/keyline/skills/keyline/commands/patterns.md +7 -0
- package/plugins/keyline/skills/keyline/commands/polish.md +19 -0
- package/plugins/keyline/skills/keyline/commands/refine.md +19 -0
- package/plugins/keyline/skills/keyline/commands/sync.md +15 -0
- package/plugins/keyline/skills/keyline/commands/type.md +7 -0
- package/plugins/keyline/skills/keyline/commands/ux-laws.md +26 -0
- package/plugins/keyline/skills/keyline/domains/color/deriving-a-ramp.md +144 -0
- package/plugins/keyline/skills/keyline/domains/color/guide.md +207 -0
- package/plugins/keyline/skills/keyline/domains/color/status-and-exceptions.md +174 -0
- package/plugins/keyline/skills/keyline/domains/color/theming-mechanics.md +121 -0
- package/plugins/keyline/skills/keyline/domains/depth/elevation-recipes.md +185 -0
- package/plugins/keyline/skills/keyline/domains/depth/fallbacks-and-performance.md +132 -0
- package/plugins/keyline/skills/keyline/domains/depth/guide.md +226 -0
- package/plugins/keyline/skills/keyline/domains/depth/translucent-materials.md +159 -0
- package/plugins/keyline/skills/keyline/domains/layout/forks-gating-and-disclosures.md +201 -0
- package/plugins/keyline/skills/keyline/domains/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/domains/layout/measure-and-rhythm.md +193 -0
- package/plugins/keyline/skills/keyline/domains/layout/radius-lines-and-layers.md +122 -0
- package/plugins/keyline/skills/keyline/domains/motion/guards.md +162 -0
- package/plugins/keyline/skills/keyline/domains/motion/guide.md +198 -0
- package/plugins/keyline/skills/keyline/domains/motion/ladder-and-curves.md +139 -0
- package/plugins/keyline/skills/keyline/domains/motion/patterns.md +196 -0
- package/plugins/keyline/skills/keyline/domains/motion/performance.md +121 -0
- package/plugins/keyline/skills/keyline/domains/patterns/actions-and-forms.md +204 -0
- package/plugins/keyline/skills/keyline/domains/patterns/agents-and-live-states.md +111 -0
- package/plugins/keyline/skills/keyline/domains/patterns/collections-and-dashboards.md +131 -0
- package/plugins/keyline/skills/keyline/domains/patterns/copy.md +123 -0
- package/plugins/keyline/skills/keyline/domains/patterns/guide.md +174 -0
- package/plugins/keyline/skills/keyline/domains/patterns/iteration.md +70 -0
- package/plugins/keyline/skills/keyline/domains/type/display-tier.md +108 -0
- package/plugins/keyline/skills/keyline/domains/type/guide.md +227 -0
- package/plugins/keyline/skills/keyline/domains/type/ladders.md +169 -0
- package/plugins/keyline/skills/keyline/domains/type/numerals-and-loading.md +107 -0
- package/plugins/keyline/skills/keyline/domains/type/prose-and-emphasis.md +125 -0
- package/plugins/keyline/skills/keyline/domains/type/type-audit.md +87 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/color-tokens.css +299 -0
- package/plugins/keyline/skills/keyline/glass/color/assets/theme.ts +119 -0
- package/plugins/keyline/skills/keyline/glass/color/guide.md +195 -0
- package/plugins/keyline/skills/keyline/glass/color/intent-ramp.md +136 -0
- package/plugins/keyline/skills/keyline/glass/color/theming.md +106 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/GlassPanel.tsx +171 -0
- package/plugins/keyline/skills/keyline/glass/depth/assets/glass.css +322 -0
- package/plugins/keyline/skills/keyline/glass/depth/blur-and-tint.md +50 -0
- package/plugins/keyline/skills/keyline/glass/depth/depth-cues.md +87 -0
- package/plugins/keyline/skills/keyline/glass/depth/guide.md +147 -0
- package/plugins/keyline/skills/keyline/glass/depth/performance-and-fallbacks.md +37 -0
- package/plugins/keyline/skills/keyline/glass/layout/adaptive-presentations.md +231 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/SectionNav.tsx +244 -0
- package/plugins/keyline/skills/keyline/glass/layout/assets/layout-tokens.css +201 -0
- package/plugins/keyline/skills/keyline/glass/layout/guide.md +259 -0
- package/plugins/keyline/skills/keyline/glass/layout/measure-and-rhythm.md +134 -0
- package/plugins/keyline/skills/keyline/glass/layout/radius-and-elevation.md +103 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/DropMenu.tsx +106 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-presets.ts +210 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/motion-tokens.css +176 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useReveal.ts +65 -0
- package/plugins/keyline/skills/keyline/glass/motion/assets/useScrollTick.ts +183 -0
- package/plugins/keyline/skills/keyline/glass/motion/easing-and-duration.md +108 -0
- package/plugins/keyline/skills/keyline/glass/motion/guide.md +255 -0
- package/plugins/keyline/skills/keyline/glass/motion/reduced-motion.md +81 -0
- package/plugins/keyline/skills/keyline/glass/motion/scroll-performance.md +58 -0
- package/plugins/keyline/skills/keyline/glass/motion/shared-element.md +161 -0
- package/plugins/keyline/skills/keyline/glass/motion/sound.md +195 -0
- package/plugins/keyline/skills/keyline/glass/motion/springs.md +51 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/prose.tsx +268 -0
- package/plugins/keyline/skills/keyline/glass/type/assets/type-tokens.css +416 -0
- package/plugins/keyline/skills/keyline/glass/type/guide.md +220 -0
- package/plugins/keyline/skills/keyline/glass/type/long-form-prose.md +151 -0
- package/plugins/keyline/skills/keyline/glass/type/scale.md +69 -0
- package/plugins/keyline/skills/keyline/glass/type/weight-and-tracking.md +61 -0
- package/plugins/keyline/skills/keyline/scripts/check/css.mjs +112 -0
- package/plugins/keyline/skills/keyline/scripts/check/design.mjs +67 -0
- package/plugins/keyline/skills/keyline/scripts/check/files.mjs +44 -0
- package/plugins/keyline/skills/keyline/scripts/check/markup.mjs +149 -0
- package/plugins/keyline/skills/keyline/scripts/check/rules.mjs +584 -0
- package/plugins/keyline/skills/keyline/scripts/check.mjs +297 -0
- package/plugins/keyline/skills/keyline/scripts/contrast.mjs +153 -0
- package/plugins/keyline/skills/keyline/scripts/scan.mjs +340 -0
- package/plugins/keyline/skills/keyline/system/design-md.md +136 -0
- package/plugins/keyline/skills/keyline/system/detection.md +53 -0
- package/plugins/keyline/skills/keyline/system/guide.md +97 -0
- package/plugins/keyline/skills/keyline/system/presets.md +58 -0
- package/plugins/keyline/skills/keyline/system/roles.md +150 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
# Layout and Hierarchy
|
|
2
|
+
|
|
3
|
+
Layout says what belongs together, what outranks what and what sits on top, before anyone has read a word. **Space and structure are signal, so every value must mean one thing**: a gap says whether things belong together, a radius how deeply an object is nested, a line's weight whether it divides or bounds, a z-index how permanent a layer is. A value picked for how it looks rather than what it says is how a layout drifts into feeling arbitrary, and why the drift is so hard to name in review.
|
|
4
|
+
|
|
5
|
+
This guide speaks in roles such as `text.secondary`; the code says whatever `DESIGN.md` maps them to. The laws hold under every preset; how wide, how loose and how round are dials, set per preset and overridden with a reason.
|
|
6
|
+
|
|
7
|
+
## Before you start
|
|
8
|
+
|
|
9
|
+
Reads `DESIGN.md` `## Layout`.
|
|
10
|
+
|
|
11
|
+
## Laws
|
|
12
|
+
|
|
13
|
+
### 1. Running text has one reading measure, and it doesn't move between templates
|
|
14
|
+
|
|
15
|
+
**Every block of running prose sits at `measure.reading`, defined once and shared by every template that shows prose.** Past about 80 characters the return sweep lands on the wrong line; under about 55 the eye spends more effort returning than reading. So the measure is set in characters of `type.body` and resolved to a width, not picked as a round pixel number. Sharing it buys **positional continuity**: clicking from an index summary to its detail page reads as the summary expanding, not as arriving somewhere else, and a template with its own width breaks that on every route in or out.
|
|
16
|
+
|
|
17
|
+
Non-prose content — an image wall, a data table or a diagram at `measure.wide`, a full-bleed band on an expressive page — may leave the measure, as may chrome and rails. **No paragraph longer than two lines may.** An article's cover — image or diagram — may run at `measure.wide` between the head and the body; images inside the body stay at the measure unless marked wide. **A divider is as wide as what it divides**: in a single-column reading layout, the measure, not the viewport. If a `type.title` wraps to four lines at the narrowest measure — the smallest viewport minus two `gutter`s, 320px say — the fix is usually the ladder (the type guide), not a responsive type step.
|
|
18
|
+
|
|
19
|
+
### 2. Space groups, and asymmetry is how it groups
|
|
20
|
+
|
|
21
|
+
**The gap inside a group must be visibly smaller than the gap around it, so a heading sits closer to what it introduces than to what came before.** With equal margins it belongs to neither neighbour. The ratio `space.heading-above : space.heading-below` matters more than the values: **between 2:1 and 5:1.** Below 2:1 the grouping is ambiguous; above 5:1 the group looks marooned. The Glass preset runs 44px above a prose heading and 12px below, about 3.7:1; the same shape holds at every scale.
|
|
22
|
+
|
|
23
|
+
This is the law of **proximity**: things near each other are read as one group before anything else about them is seen, so a gap that doesn't match the grouping overrides every other cue.
|
|
24
|
+
|
|
25
|
+
**Symmetric spacing is right only between peers.** Whether the gap between two sections is enough on its own, or needs a line or a panel, is law 3. Spacing values come from roles — `space.tight`, `space.group`, `space.section`, the heading pair — on one base unit, with half-steps where a ratio needs them; a one-off `margin-top: 37px` is either a missing role or a mistake, and review should be able to tell which.
|
|
26
|
+
|
|
27
|
+
**Clutter is a spacing problem before it is a colour or type one.** A screen that reads as cluttered or "randomly padded" has gaps that don't group: fix the gaps, the padding and the order of tiers first, and only then touch colour or weight. Inside a card the tiers part by space — the meta line, `space.tight`, the title, `space.group`, the details — not by rules.
|
|
28
|
+
|
|
29
|
+
→ Spacing ladders per preset, section rhythm, page padding and chrome clearance, viewport units: `domains/layout/measure-and-rhythm.md`
|
|
30
|
+
|
|
31
|
+
### 3. Sections stack, and every boundary between them can be seen
|
|
32
|
+
|
|
33
|
+
**A page is its sections stacked in reading order — or a content column beside a rail — each opened by its heading, and the reader can see where one ends and the next begins without reading. A grid of cards is for a collection of like objects, never for arranging a page's unlike sections.** A bento mosaic sizes each box to fill the mosaic rather than fit its content, turns reading order into a puzzle, and gives every region its own edge, padding and radius. Unboxing that mosaic is half the fix; the other half is putting back a boundary, or the sections run together into one long scroll of equal weight.
|
|
34
|
+
|
|
35
|
+
**What divides two sections is chosen by what they hold**, from this table — the one place Keyline decides whether a region is boxed (the depth guide builds whatever surface it gets):
|
|
36
|
+
|
|
37
|
+
| The sections | The divider |
|
|
38
|
+
|---|---|
|
|
39
|
+
| Peer regions of a working page — a dashboard's overview, trends and action items; an admin page's bands; a record's detail groups | A full-bleed hairline (`hairline.chrome` in `border.divider`) between each, running to the panel's edges, with `space.section` above and below; none after the last |
|
|
40
|
+
| Unlike regions within one section — a chart over the table it summarises, content beside its rail | A hairline between them: horizontal, or vertical and centred in the gap beside a rail |
|
|
41
|
+
| Prose sections in a reading column — an article, docs, an essay | Space and the heading pair (law 2); a measure-wide rule only at a change of subject — an appendix, the author block, related reading — never at every heading |
|
|
42
|
+
| Groups of settings or form fields that save or reset together | A panel each: a hairline frame at `radius.card`, no shadow, its title and one line of description at the top, its save action inside it |
|
|
43
|
+
| A landing page's sections | Space at the section gap; a section of another kind — a logo strip, a testimonial, the closing call to action — becomes a full-bleed tinted band, and the band's edges are the divider |
|
|
44
|
+
| Sections on a tinted page ground — a dashboard whose page is `surface.subtle` with its regions as surfaces on it | Each region is a surface (`elevation.flat` or `raised` on `surface.raised`, depth law 1); the ground between them is the divider, so no lines |
|
|
45
|
+
| Items in a collection | Hairlines between rows, or a grid of like cards — not a section boundary |
|
|
46
|
+
|
|
47
|
+
**An existing design's divider stays.** Improving a design whose sections are panels keeps the panels and makes them quieter — one edge, nothing boxed inside — rather than unboxing it into a page that runs together; this table is for a new system (→ Refining an existing design, below). A shadow on a panel that doesn't float is reported to `depth`, not removed here. **One device per kind of boundary across the product** (similarity): lines between some peer sections and space between others reads as unfinished. **The test is a squint**: blur a screenshot until the text goes, and each section should still read as its own block. Where two run together, move one device up — space, then a line, then a band or a panel — and where the page reads as stripes or a stack of boxes, move one down.
|
|
48
|
+
|
|
49
|
+
**Common region is real, which is why boxes are scarce.** A shared boundary makes things read as one group strongly enough to beat proximity, so boxing every region erases the grouping it was meant to show. Inside a section, the quietest device that holds a group wins: space (law 2), then a tint or a hairline, and a card only for one object the reader acts on (depth law 2). Figures are the commonest case: a set of stats is one row, each figure over its label, parted by vertical hairlines, not a tile per figure; the row wraps and stacks cleanly where tiles become a column of boxes. Where the figures count what the table below holds, they are also its filter. Boxed figures are a decision to record, and the boxes stay quiet: no hover lift, no icon tile, no more padding above than beside.
|
|
50
|
+
|
|
51
|
+
The quiet devices carry the rest, one job each, and **each has one style across the product** — two table styles or two group-label styles read as two products stitched together. Which style is a dial:
|
|
52
|
+
|
|
53
|
+
- **A group label** in editorial is `type.label` in sentence case over a hairline rule. In product it is one group bar — a tinted band holding a chevron, the name, a count and the add action, sticky while its group scrolls. The tint is earned by being a control; a tinted strip behind a heading that does none of this is still decoration.
|
|
54
|
+
- **A table** in editorial has no frame: a hairline under its header, `border.divider` between rows, and its first column flush with the heading above, so the table's edge is the text's edge. In product it has one shared frame — `radius.card` in a hairline, a tinted header row, hairlines between rows only.
|
|
55
|
+
- **Page-level tabs** are text on one shared hairline, with `rule.emphasis` under the current tab and no box round the row. A segmented control is for switching a mode between two or three options, not for navigation.
|
|
56
|
+
- **Chips, badges and tags share one shape**, one height, padding and radius per size, and vary only in fill and text. Three chip shapes on one screen are three components nobody chose.
|
|
57
|
+
|
|
58
|
+
→ The product page — header bar, stat strip, table frame, group bars, toolbars, forms and dashboards: `domains/layout/measure-and-rhythm.md`
|
|
59
|
+
|
|
60
|
+
### 4. Rails anchor to the same frame as the content
|
|
61
|
+
|
|
62
|
+
**A sticky sidebar, table of contents or margin note is positioned against the same centred frame as the content column, so the gap between them holds as the window widens.** Against the viewport, a rail drifts from the text it indexes: fine at 1280px, a stranger at 2400px.
|
|
63
|
+
|
|
64
|
+
```css
|
|
65
|
+
.frame { position: relative; max-width: var(--measure-frame); margin-inline: auto; }
|
|
66
|
+
.frame > .rail {
|
|
67
|
+
position: absolute; inset-block: 0; inset-inline-start: var(--gutter);
|
|
68
|
+
width: var(--measure-rail);
|
|
69
|
+
}
|
|
70
|
+
.rail > .rail-inner {
|
|
71
|
+
position: sticky; top: var(--rail-top);
|
|
72
|
+
max-height: calc(100dvh - var(--rail-top) - var(--space-section));
|
|
73
|
+
overflow-y: auto;
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
The absolute wrapper spans the section so the sticky child has a track, and keeps the column centred, where a flex row would push it off. **A sticky panel is capped to the dynamic viewport and scrolls inside itself** — `dvh`, because `vh` ignores mobile browser chrome. Where the rail no longer fits, it becomes an in-flow disclosure above the content, never simply hidden. A shell's navigation and inspector are rails too, sized as regions of the shell, not floated against the window. Anything fixed to an edge reserves its own clearance, checked template by template.
|
|
78
|
+
|
|
79
|
+
### 5. Nested radii: inner = outer − padding
|
|
80
|
+
|
|
81
|
+
**Where one rounded object sits in another's corner, the inner radius is the outer radius minus the gap between their edges.** This is geometry, not taste: only then do the curves share a centre. Too large and the corner pinches; too small and the gap swells at the corners — visible even to people who couldn't say what's wrong. A 16px container with 4px of padding holds 12px buttons.
|
|
82
|
+
|
|
83
|
+
The law binds concentric corners only, inset by less than the outer radius; anything inset further or set away from the corners takes the ladder's radius for its size, never a square corner by subtraction. Improving an existing design, it never sharpens a corner (→ Refining an existing design, below). Radius scales with size, not importance, and `radius.pill` is only for things with no top and bottom.
|
|
84
|
+
|
|
85
|
+
### 6. A line's weight says what it's doing
|
|
86
|
+
|
|
87
|
+
**Lines are assigned by job — `hairline.chrome` for a divider inside one object, `hairline.object` for an object's edge, `rule.emphasis` for a deliberate mark such as a quote bar or a focus ring — and an internal divider is never more prominent than its object's edge**, or the object reads as two. Where chrome and object share a weight, the divider steps down in colour (`border.divider` against `border.default`). Three jobs, at most three weights. **A frame and the dividers inside it are one drawing**: same weight, same inset, meeting cleanly, or a table's edge and its row rules disagree in a way readers see as "the thickness is off" without naming it. Hairlines are drawn as borders, never as half-pixel boxes; focus rings and `prefers-contrast: more` never take a half pixel. On a translucent or unknown backdrop the line is translucent (`border.layered`; its colour is the colour guide's). A non-structural divider is `aria-hidden`, and an `<hr>` needs its UA border reset.
|
|
88
|
+
|
|
89
|
+
### 7. The z-index ladder is documented, sparse, and ordered by permanence
|
|
90
|
+
|
|
91
|
+
**Every z-index comes from one named ladder with gaps between the rungs; a literal `z-index: 9999` is a bug waiting for its pair.** The longer something stays on screen, the higher it sits; the skip link beats everything. Gaps of ten let a later layer, like the Glass preset's once-a-session greeting overlay, take a free rung without renumbering. Two pairs are constraints, not choices:
|
|
92
|
+
|
|
93
|
+
- **A dismiss catcher sits above the page and directly below what it dismisses.** `z.dismiss` serves the navigation; a content popover renders its own catcher in its portal, catcher first.
|
|
94
|
+
- **`z.skip-link` sits above `z.overlay`**, so a keyboard user tabbing during a splash or a modal can always get past it.
|
|
95
|
+
|
|
96
|
+
| Role | Default | Holds |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `z.skip-link` | 100 | The skip link |
|
|
99
|
+
| `z.toast` | 70 | Toasts, over modals |
|
|
100
|
+
| `z.overlay` | 60 | Modals, sheets, once-a-session overlays |
|
|
101
|
+
| `z.nav` | 50 | Fixed navigation |
|
|
102
|
+
| `z.dismiss` | 40 | The navigation's tap-outside catchers |
|
|
103
|
+
| `z.popover` | 20 | Hover cards, dropdowns, content menus |
|
|
104
|
+
| `z.raised` | 10 | A hovered or focused item |
|
|
105
|
+
| `z.below` | −10 | A highlight behind content |
|
|
106
|
+
|
|
107
|
+
A negative layer needs an `isolation: isolate` parent, and a transformed or filtered ancestor traps `position: fixed` descendants — check every ancestor before reaching for a higher number.
|
|
108
|
+
|
|
109
|
+
→ Nesting worked through, corner shape, hairlines as borders, the catcher pair in code, containing blocks: `domains/layout/radius-lines-and-layers.md`
|
|
110
|
+
|
|
111
|
+
### 8. Fork the component when the interaction model changes, not when the arrangement does
|
|
112
|
+
|
|
113
|
+
**Arrangement changes — column count, wrapping, padding, a card moving from below its row to beside it — are breakpoint work. Interaction-model changes — hover-revealed versus tap-to-open, a sticky rail versus an inline disclosure — are two components sharing their data and row rendering**, because one component serving both grows a hover path, a touch path, a collapse rule only one uses, and state that means different things in each. Two mounted presentations holding state need a sync point or a shared store.
|
|
114
|
+
|
|
115
|
+
**Breakpoints are named for what changes at them and derived from the content** (`bp.rail`, `bp.shell`), not copied from a device list. **Inside a shell they are measured on the container, not the window**: the content panel narrows when the sidebar opens, so a list and detail split on window width crushes the detail. Query the panel; the list gives way first, and the detail sizes its own gutters, rail and labels so its actions never clip. **Item size and column count are separate decisions**: "make them 25% smaller" keeps the count unless the count was asked about. **Capability, not width, decides hover**: every hover style is gated on `(hover: hover) and (pointer: fine)` together, decoration included; `any-hover` is too permissive for a touchscreen laptop. **Never hide an affordance where it can't come back**: a dock that collapses on scroll and expands on hover loses navigation on a phone for good, so check capability where you hide, not where you reveal. Every hover that carries information or an action has a touch path; decoration needs no touch equivalent, but is still gated.
|
|
116
|
+
|
|
117
|
+
### 9. A view choice is about the thing, so it doesn't persist by default
|
|
118
|
+
|
|
119
|
+
**When the reader can switch between presentations of one collection, the choice resets on arrival unless you can name why it shouldn't, and the switch carries the reader's position across.** Persist preferences about the *reader* — theme, sound, motion, density — not about a *thing*, or readers land in a mode picked weeks ago for another collection and don't remember choosing it. A named saved view and board-or-list on a page worked daily are the exceptions, each persisted visibly and recorded (→ `domains/layout/forks-gating-and-disclosures.md`, whether it persists). Which second view a collection gets is patterns law 10. On the switch, open the incoming view on the item nearest the viewport centre; leaving a grid at item 60 and arriving at item 1 is worse than not switching.
|
|
120
|
+
|
|
121
|
+
### 10. Owner-curated content disappears when empty
|
|
122
|
+
|
|
123
|
+
**When a collection the site's owner curates is empty, its route, navigation entry, section and any divider around it all go — derived from one flag.** When the data is the user's own, the reader can act, so it gets an empty state instead (patterns law 9).
|
|
124
|
+
|
|
125
|
+
"No posts yet" on a portfolio publishes an absence to visitors who can do nothing about it: an admission, not information. Navigation, router, section list, sitemap and metadata all read the one flag, because which routes exist is content, not routing; sections are filtered before dividers are placed, and an emptied route redirects somewhere real, not to a 404. At every count the layout looks deliberate: one or two items in a three-up grid stay item-sized, not stretched.
|
|
126
|
+
|
|
127
|
+
→ Forks and the touch audit, container breakpoints, reader-chosen views and which a collection gets, gating code for route tables and file-based routers, empty-state anatomy: `domains/layout/forks-gating-and-disclosures.md`
|
|
128
|
+
|
|
129
|
+
### 11. A disclosure hides its content; it doesn't unmount it
|
|
130
|
+
|
|
131
|
+
**Collapsed content stays in the served markup — a native `<details>`, or animated to zero height with `inert` and `aria-hidden` — and is unmounted only when it is genuinely expensive or genuinely absent**, because content that exists only after a click is lost to crawlers, find-in-page and text export. `inert` makes keeping it safe: without it, collapsed content is still tabbable and announced, so a keyboard user tabs into invisible prose and a screen reader reads a section shown as closed. `aria-hidden` alongside covers engines without `inert`; `display: none` can't be animated, so it's no answer where the close is. Prefer `<details>` wherever the animation allows. A menu's wrapper stays mounted while its panel unmounts, so `aria-controls` always resolves. A collapsed group's bar carries a summary and opens to a search (patterns law 8).
|
|
132
|
+
|
|
133
|
+
### 12. Structure is accessibility, and it's decided with the layout
|
|
134
|
+
|
|
135
|
+
**Landmarks, heading levels, the skip link and anchor offsets are layout decisions, made in the same pass as the layout, not retrofitted.**
|
|
136
|
+
|
|
137
|
+
- A skip link off-screen by transform (never `display: none`, which drops it from the tab order), on `z.skip-link`, targeting an `id="main"` on every template.
|
|
138
|
+
- Every `<nav>` labelled — three unlabelled navs make a page unnavigable by landmark.
|
|
139
|
+
- **Heading levels follow structure, not size.** Two headings at one size can legitimately be `h3` and `h4`; never pick a level for its default style.
|
|
140
|
+
- `scroll-margin-top` on every anchor target, matched to the scroll spy's reading line, or a jump lands above the line and the contents highlights the previous section.
|
|
141
|
+
- `aria-current` on the active row, `aria-pressed` on toggles, `aria-expanded` and `aria-controls` on disclosure and menu triggers; decorative spans `aria-hidden`; in-page links as real anchors with the handler layered on top.
|
|
142
|
+
- Print is a presentation: chrome hidden; scroll-revealed content forced visible per motion law 11.
|
|
143
|
+
|
|
144
|
+
### 13. A target is as big as the pointer needs, and placed where the pointer already is
|
|
145
|
+
|
|
146
|
+
**Every control has a hit area of at least 24 × 24px under a fine pointer and 44 × 44px on touch, and the action used most sits closest to where the reader's attention already is.** That is Fitts's law. **The hit area is not the drawing**: an icon stays 16px while padding or a `::before` inset grows the target round it, so size never has to be bought with visual weight. Adjacent targets on touch keep at least 8px between their hit areas, or the larger finger presses both.
|
|
147
|
+
|
|
148
|
+
Distance is the other half. A menu opens where it was clicked; a dialog's primary button sits where the eye finishes the dialog; a row's actions sit on the row, not in a toolbar across the screen. **The edges of the screen are infinitely deep for a mouse** — a pointer can't overshoot them — which is why the dock and the menu bar live there, and why a destructive action never takes the corner where a hurried flick lands (patterns law 3). Below a phone width, the frequent actions move within thumb reach, at the bottom, not into a top corner.
|
|
149
|
+
|
|
150
|
+
## Dials
|
|
151
|
+
|
|
152
|
+
| Dial | editorial | product | expressive |
|
|
153
|
+
|---|---|---|---|
|
|
154
|
+
| `measure.reading`, in characters of `type.body` | 65–75; default 70 | 60–70; default 65 — prose inside the product only | 55–70; default 62 |
|
|
155
|
+
| Page structure | One column every template shares | App shell: a navigation sidebar, content and an optional inspector; every page under its own header bar | Sections may go full-bleed; text at the measure |
|
|
156
|
+
| `measure.wide` | ≈ 1.5 × `measure.reading` | = `measure.app` | Card grids and multi-column text cap at ≈ 1200–1280px; only full-bleed bands and media run edge to edge |
|
|
157
|
+
| `measure.app` | — | Content fills its panel, left-aligned; `measure.form` 640–760px; nav 220–264px; inspector 320–400px | — |
|
|
158
|
+
| `gutter` (smallest width) | 24–32px | 16px, growing to 24–32px from tablet up | 20–24px |
|
|
159
|
+
| Column grid: `grid.columns` / `space.column` | None — the measure is the grid | 4 / 8 / 12 on the content panel, measured on the container; 16px, 24px from tablet up | 4 / 8 / 12 on the window for card grids and section layouts; 16px, 24px from tablet up; prose still at the measure |
|
|
160
|
+
| `target.min`, fine pointer / touch | 24 / 44px | 24 / 44px | 24 / 48px |
|
|
161
|
+
| `space.tight` / `space.group` / `space.section` | 8 / 16–24 / 48–72px | 4–8 / 12–16 / 24–32px | 8–12 / 24–32 / 96–160px |
|
|
162
|
+
| Heading pair ratio, above : below | 3:1–4:1 | 2:1–3:1 | 3:1–5:1 |
|
|
163
|
+
| Section divider values (law 3's table picks the device) | Rule 1px, measure-wide; `space.section` 48–72px either side | Hairline 0.5px, full-bleed; `space.section` 32px either side; settings panels in a 0.5px frame | Bands full-bleed, tint one step off the page; rules, where used, 1px |
|
|
164
|
+
| Tables and group labels | No frame; label over a rule | One shared frame with a tinted header row; one tinted, sticky group bar | No frame; label over a rule |
|
|
165
|
+
| Stats | One hairline-parted row | A strip under the page header whose figures filter the table | One row |
|
|
166
|
+
| Page top space | 80–112px; ≈ 48–64px at phone widths | 16–24px under the page's header bar | Set by the hero; without one, as editorial |
|
|
167
|
+
| Radius: `small` / `control` / `card` / `container` | 4 / 8 / 12 / 16px | 4–6 / 8 / 12 / 16px | Either 0 throughout, or 8 / 12 / 20 / 28px |
|
|
168
|
+
| Corner shape | `superellipse(1.5)` as enhancement | `superellipse(1.5)` as enhancement | Any, applied globally; `superellipse(1.5–2)` when round |
|
|
169
|
+
| `hairline.chrome` / `hairline.object` / `rule.emphasis` | 0.5 / 1 / 2px | 0.5 / 0.5 / 2px, divider a colour step lighter | 1 / 1–2 / 3–4px |
|
|
170
|
+
| Breakpoints | 2–3, on the window; the third where the header's links stop fitting | 3, on containers | 3–4, on the window |
|
|
171
|
+
| Rail appears at | `measure.reading` + 2 × (rail + gap) + gutters ≈ 1200–1280px | Always a region; collapses at `bp.shell` | Rarely has rails |
|
|
172
|
+
|
|
173
|
+
The `glass` preset takes its values from `glass/layout/`.
|
|
174
|
+
|
|
175
|
+
**The measure.** Editorial's committed reader sits high, since long lines are efficient in sustained reading when leading pays for the return sweep. Product text is scanned in bursts and sits lower; expressive body runs larger, so fewer characters fit. Law 1's bounds hold on every preset.
|
|
176
|
+
|
|
177
|
+
**Density.** Product halves editorial's spacing, since a working reader compares side by side and scrolling is friction, but keeps the asymmetry: a dense form with equal gaps is where grouping goes first. Expressive sections are scenes with large gaps, and the heading ratio still holds inside them.
|
|
178
|
+
|
|
179
|
+
**The shell.** In product, navigation is one surface — a sidebar holding every destination, no top bar repeating it, which would split navigation across two edges — and every page sits under its own header bar, which titles the page rather than navigating. Pages fill the content panel, and the shell, not the page, sets width and outer padding (→ `domains/layout/measure-and-rhythm.md`).
|
|
180
|
+
|
|
181
|
+
**Tables and group labels.** Editorial tables share a text column's edge, so a frame only adds a box. A product panel has no column to align to: tables run full width beside toolbars and figures, so a frame says where the data stops, and a reader scrolling hundreds of rows in collapsible groups needs the group's name and its controls in view. What both keep is law 3: one table style and one group-label style, used identically everywhere.
|
|
182
|
+
|
|
183
|
+
**Radius.** A sharp system (0 throughout) is a legitimate expressive character that satisfies the nesting law trivially; what costs is mixing a round button into a square card. Round systems read modern through a soft corner, drawn as a light superellipse where the browser supports `corner-shape` (`radius-lines-and-layers.md`).
|
|
184
|
+
|
|
185
|
+
**Hairlines.** Product draws every structural line at 0.5px, because a dense screen is mostly rows and a 1px rule on each reads heavy; editorial keeps a 1px object edge for the few objects it has.
|
|
186
|
+
|
|
187
|
+
**Breakpoints.** A single-column site needs two: where the column becomes padding-limited, and where a rail fits. A header with many links needs a third, where they stop fitting on one line, or between the two it has nowhere to go.
|
|
188
|
+
|
|
189
|
+
**The column grid.** A page of unlike things side by side — a dashboard's panels, a landing page's feature rows, a card collection beside filters — needs a shared set of edges, or every block picks its own width and nothing lines up. A column grid supplies them: columns, the gaps between them (`space.column`), and the page's outer padding (`gutter`; Material calls this the *margin*, and its *gutter* is our `space.column`). The usual counts are 4 columns on a phone, 8 on a tablet and 12 above, with the gap at 16px growing to 24px. **Material's thresholds — 600, 905 and 1240px — are a starting point, not the law**: move each to where this content actually stops fitting, and name it for what changes (law 8). Past the widest step the content stops growing, capped at `measure.wide`, and the outer padding takes the extra width rather than the columns stretching across a 2560px screen.
|
|
190
|
+
|
|
191
|
+
The grid is a set of edges to align to, not a set of boxes. A block spans whole columns and nothing starts inside a column gap; the gap is part of the spacing system, on the base unit like every other value. **Editorial has no column grid**: one reading column is its own grid, and a 12-column grid behind a single measure only adds numbers. Column count is arrangement, so it changes at breakpoints; item size is a separate decision (law 8). In a product shell the grid lives inside the content panel and is measured on that container, so opening the sidebar drops a step rather than squeezing twelve columns into a narrowed panel.
|
|
192
|
+
|
|
193
|
+
```css
|
|
194
|
+
.grid { display: grid; grid-template-columns: repeat(var(--grid-columns), minmax(0, 1fr)); gap: var(--space-column); }
|
|
195
|
+
@container panel (min-width: 600px) { .grid { --grid-columns: 8; --space-column: 24px; } }
|
|
196
|
+
@container panel (min-width: 905px) { .grid { --grid-columns: 12; } }
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
## Building it for a project
|
|
200
|
+
|
|
201
|
+
1. **Read the preset and the areas.** A `product` app with `app/(docs)/** → editorial` gets a shell for the app and a single column for the docs, sharing one `measure.reading`.
|
|
202
|
+
2. **Set the measure from the type**: a character count from the dials for `family.text` at `type.body`, resolved to a width and checked at the narrowest viewport (`domains/layout/measure-and-rhythm.md`).
|
|
203
|
+
3. **Derive the frame.** With a rail, `measure.frame` = `measure.reading` + 2 × (`measure.rail` + gap) + 2 × `gutter`, and `bp.rail` is that width; for a shell, set the region widths and where the inspector, then the nav, collapses.
|
|
204
|
+
4. **Build spacing from ratios**: the base (4px) and `space.section`, the heading pair at the preset's ratio, then `space.tight` and `space.group` so every group's inner gap is at most half its outer one.
|
|
205
|
+
5. **Stack the sections and pick each boundary's divider** from law 3's table, one device per kind of boundary — or the one the existing design already uses: each a heading and its content, figures in hairline-parted rows; list the things that are single objects, which alone get a card (the depth guide). Pick the one table style and the one group-label style for the preset.
|
|
206
|
+
6. **Build the radius ladder inward** from `radius.container` through every concentric nesting (law 5), and **set the corner shape** from the dial in the same step (`radius-lines-and-layers.md`).
|
|
207
|
+
7. **Assign the three line jobs** per the preset, drawn as borders; no divider outranks its object's edge, and a frame matches the dividers inside it.
|
|
208
|
+
8. **Write the z ladder** from law 7, dropping unused rungs and keeping the gaps.
|
|
209
|
+
9. **List the forks and the gates**: which components ship twice and why; which collections are owner-curated (gated) and which are user data (empty states).
|
|
210
|
+
10. **Under product or expressive, set the column grid** if the pages place unlike blocks side by side: column counts per step, `space.column`, and each breakpoint moved to where the content stops fitting. Skip it for a single reading column.
|
|
211
|
+
11. **Size every target**: icon buttons, close buttons, row actions and chips that act get the dial's hit area, grown by padding or a pseudo-element rather than a bigger icon.
|
|
212
|
+
|
|
213
|
+
→ Worked example (*Manifest*, a fictional `product` app with `editorial` docs) and its Tailwind and JS-theme forms: `domains/layout/measure-and-rhythm.md`
|
|
214
|
+
|
|
215
|
+
## Auditing existing code
|
|
216
|
+
|
|
217
|
+
Start from the setup scan's frequencies for measures, spacing, radii, z-index and breakpoints, then collect per law:
|
|
218
|
+
|
|
219
|
+
| Law | Look for | Evidence |
|
|
220
|
+
|---|---|---|
|
|
221
|
+
| 1 | Distinct `max-width` / `max-w-*` on prose templates; prose in full-width containers; product pages setting their own width or outer padding | Measures by route |
|
|
222
|
+
| 2 | Headings with equal margins or a ratio under 2:1; one-off spacing values; rules parting a card's tiers | Ratio per heading style; strays with counts |
|
|
223
|
+
| 3 | Sections that run together under a squint, or a page of stripes and boxes; peer sections divided by more than one device; card grids arranging unlike sections; existing panels unboxed; a tile per stat, or stat boxes that lift or hold icon tiles; tinted strips that aren't group controls; more than one table or group-label style, or the preset's other one; boxed tab rows; more than one chip shape | Per screen: each box and what it holds; table, group-label and chip styles with counts |
|
|
224
|
+
| 4 | Viewport-positioned sticky elements; `100vh`; sticky panels without `overflow-y`; fixed bottom chrome | File and line; pages ending under the chrome |
|
|
225
|
+
| 5 | Rounded elements in a rounded element's corner; square inner corners where the inset passed the outer radius | Outer, padding, inner, mismatch |
|
|
226
|
+
| 6 | Border widths by job; dividers stronger than their object's edge; frames heavier than their dividers; half-pixel lines drawn as boxes; `<hr>` without a border reset | Counts per weight; offenders |
|
|
227
|
+
| 7 | Every `z-index` / `z-*` literal | Sorted values and what each holds; catchers above menus; overlays above the skip link; transformed ancestors of fixed elements |
|
|
228
|
+
| 8 | `onMouseEnter`/`:hover` with no touch path; `any-hover`; hide-on-scroll without a capability check; window media queries on components inside a resizable panel | Each behaviour's touch equivalent, or "none"; panes that clip with the sidebar open |
|
|
229
|
+
| 9 | View toggles written to storage | Key, what it controls, any reason |
|
|
230
|
+
| 10 | "No … yet" on owner-curated collections; grids that stretch one or two items | Route or component |
|
|
231
|
+
| 11 | Disclosure content rendered conditionally; collapsed content without `inert` | Component, and what's missing from the HTML |
|
|
232
|
+
| 12 | Skip link and `id="main"`; unlabelled `<nav>`; heading levels chosen for size; anchors without `scroll-margin-top`; no print reset for hidden reveals | Per template |
|
|
233
|
+
| 13 | Icon, close and row-action buttons under 24px (44px on touch) with no padding or pseudo-element growing the hit area; touch targets closer than 8px; frequent actions far from what they act on; destructive actions in a corner | Element, drawn size, hit size |
|
|
234
|
+
| Dials | Blocks side by side that share no edges; a 12-column grid behind a single reading column; columns stretching past `measure.wide` | Page, and the edges that don't line up |
|
|
235
|
+
|
|
236
|
+
In a browser, resize from 320px to 2560px watching the rail gap, tab through collapsed disclosures, and run find-in-page on a closed one.
|
|
237
|
+
|
|
238
|
+
## Refining an existing design
|
|
239
|
+
|
|
240
|
+
A preset's dials describe a new system. Where one disagrees with the design being improved, this decides, and each call is recorded under `## Decisions` so the next run makes the same one.
|
|
241
|
+
|
|
242
|
+
- **Dividers.** The design's section device stays (law 3). One thing changes either way: a row of stat tiles folds into one strip of figures (law 3), because that is a law about figures, not a divider dial — unless keeping the tiles is recorded under `## Decisions`.
|
|
243
|
+
- **Corners.** The design's radii are the floor, and the nesting law never sharpens one. Where inner = outer − inset would come out sharper than the inner corner is now, raise the outer radius or trim the inset until they share a centre; if neither fits, the corners aren't concentric and the inner keeps its radius.
|
|
244
|
+
- **Line weight.** The design's line weights stay, like its corners. Product's 0.5px hairline is for a new system, or for an existing line that reads heavy and whose colour is strong enough to survive halving; a border already near the surface colour keeps its 1px.
|
|
245
|
+
|
|
246
|
+
## Writing back to DESIGN.md
|
|
247
|
+
|
|
248
|
+
- **Layout table rows** for every role set or found — measures, gutter, spacing roles, the heading pair and its ratio, the radius ladder and its nestings, the three line jobs, the z ladder, breakpoints named for what changes — plus one line per forked component naming the interaction model each version serves.
|
|
249
|
+
- **The gate list**: owner-curated collections (gated) and user data (empty states).
|
|
250
|
+
- **Decisions** worth recording here: a persisted view preference, a layer added to the ladder, a template allowed a different measure.
|
|
251
|
+
- **Proposed roles** for a job with no row — a rail width, a frame width, a toast layer.
|
|
252
|
+
|
|
253
|
+
## Checklist
|
|
254
|
+
|
|
255
|
+
- [ ] The narrowest measure fits the longest `type.title` without a responsive type step — except an editorial article title's one phone step (type dials).
|
|
256
|
+
- [ ] Stats sit in one row parted by hairlines; tabs are text on a shared hairline; one table style, one group-label style and one chip shape per size.
|
|
257
|
+
- [ ] Below `bp.rail` the rail becomes an in-flow disclosure; fixed chrome has clearance on every template.
|
|
258
|
+
- [ ] No transformed or filtered ancestor traps a fixed descendant; negative layers sit in an isolated parent.
|
|
259
|
+
- [ ] Grids look deliberate at 1, 2 and n items.
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
# Measure and rhythm
|
|
2
|
+
|
|
3
|
+
## Deriving `measure.reading`
|
|
4
|
+
|
|
5
|
+
Set the measure in characters of `type.body`, then resolve it; a pixel number chosen first is a guess about an unmeasured font.
|
|
6
|
+
|
|
7
|
+
```css
|
|
8
|
+
:root {
|
|
9
|
+
--measure-reading: 60ch; /* ≈ 70 characters */
|
|
10
|
+
}
|
|
11
|
+
.prose { max-width: var(--measure-reading); margin-inline: auto; }
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
`ch` is the advance of the digit zero, which runs 10–20% wider than an average lowercase letter in most text faces, so `60ch` usually holds about 70 real characters. Measure once with the real font and copy, then keep the unit, which follows the font when the family changes. For a pixel token, resolve from the average character width and record both in `DESIGN.md`:
|
|
15
|
+
|
|
16
|
+
| `type.body` | Average character | 60 characters | 68 characters | 75 characters |
|
|
17
|
+
|---|---|---|---|---|
|
|
18
|
+
| 15px sans | ≈ 7.5px | 450px | 510px | 560px |
|
|
19
|
+
| 16px sans | ≈ 8px | 480px | 545px | 600px |
|
|
20
|
+
| 17px serif | ≈ 8.3px | 500px | 565px | 620px |
|
|
21
|
+
| 18px sans | ≈ 9px | 540px | 610px | 675px |
|
|
22
|
+
|
|
23
|
+
These are text widths; add two `gutter`s for the container. A longer line needs more leading for the return sweep — a pairing the type guide tunes against a stable measure.
|
|
24
|
+
|
|
25
|
+
### Check the narrowest case
|
|
26
|
+
|
|
27
|
+
The narrowest measure is the smallest supported viewport minus two `gutter`s — 360 − 2 × 20 = 320px, say. If the longest real `type.title` breaks into four lines there, or a long word overflows, fix the type ladder rather than shrinking the title on phones; a responsive type step usually means one size is too large for the narrowest column. The exception is an editorial article title, which takes its one phone step from the type dials. For a word that can't fit (a long compound, a URL), give prose `overflow-wrap: anywhere` rather than letting it widen the page.
|
|
28
|
+
|
|
29
|
+
### Why every template shares it
|
|
30
|
+
|
|
31
|
+
An index summary and its detail page are one piece of content at two lengths. With a 720px article and a 640px index, every click moves the text sideways and the reader rereads the first line to find their place — on every navigation, which is why a second measure needs a decision in `DESIGN.md`, not just a class.
|
|
32
|
+
|
|
33
|
+
## Page structure per preset
|
|
34
|
+
|
|
35
|
+
### Editorial — one column
|
|
36
|
+
|
|
37
|
+
One container, one measure, every template; non-text content leaves the column by being placed outside it, never by widening it.
|
|
38
|
+
|
|
39
|
+
### Product — an app shell
|
|
40
|
+
|
|
41
|
+
A working reader needs navigation, content and detail together, so the page is regions, each sized by the `measure.app` dial:
|
|
42
|
+
|
|
43
|
+
| Region | Holds | Collapses |
|
|
44
|
+
|---|---|---|
|
|
45
|
+
| Navigation | Every destination | Last — to an icon rail, then, below `bp.shell`, a sheet that opens always expanded |
|
|
46
|
+
| Content | The page, filling its panel, left-aligned at one `gutter`; forms and settings cap at `measure.form` | Never |
|
|
47
|
+
| Inspector | Detail on the selection | First — to an overlay sheet on `z.overlay` |
|
|
48
|
+
|
|
49
|
+
Settings help, onboarding copy, a changelog entry and an empty state's explanation still sit at `measure.reading`; a 1200px-wide paragraph of help text in a settings pane is the most common product failure of law 1. The inspector is detail, so it collapses first; navigation is how the reader leaves, so it stays longest. A centred column inside the content panel wastes the width a working reader opened the window for, and a page that sets its own `max-width` or padding drifts from its neighbours: the shell owns both.
|
|
50
|
+
|
|
51
|
+
**The sidebar.** Navigation is this one surface, with the reader's own controls — account, role, theme — in its footer; a top bar repeating it puts a second band of chrome over every screen. Primary destinations sit at the top; the long tail goes in collapsible folders, open state remembered and the current page's folder forced open; administration lives in the account menu, not among destinations. Rows are dense, about 30px, with the workspace name at the nav's own size, so the sidebar reads as one list. Collapsed to an icon rail, icons hold their position while the width animates, labels become tooltips and counts become dots; the rail is toggled by a button and a shortcut and remembered, since it is a preference about the reader (layout law 9).
|
|
52
|
+
|
|
53
|
+
**Improving one screen of an existing app keeps its navigation surface.** Moving navigation is a restructure, and the icon rail a feature: neither is done unasked; list them under `## Open issues`.
|
|
54
|
+
|
|
55
|
+
### The product page
|
|
56
|
+
|
|
57
|
+
Top to bottom, every page in the shell has the same parts, so a reader learns one page and can read them all:
|
|
58
|
+
|
|
59
|
+
| Part | Shape |
|
|
60
|
+
|---|---|
|
|
61
|
+
| Header bar | About 44px; breadcrumb at the start, the page's own actions at the end as quiet ghost buttons; the page title at the nav's size, not a display heading |
|
|
62
|
+
| Stat strip | The figures under the header (patterns law 12 picks which), parted by hairlines, at the type guide's figure size; each filters the table below to what it counts, and shows when it's the active filter |
|
|
63
|
+
| Toolbar | Search, then filters as icons with tooltips — a text label on every filter crowds the bar — then the view switch; on touch the filters open as a labelled sheet |
|
|
64
|
+
| Content | The table, board or list, at the panel's full width |
|
|
65
|
+
|
|
66
|
+
**The table.** One shared component on every page: `radius.card` in a hairline frame, a tinted header row with `text.primary` labels, rows around 44px, hairlines between rows only (no column rules), a hover wash. A table that doesn't use it is a second table style.
|
|
67
|
+
|
|
68
|
+
**The group bar.** One shared component: a tinted band about 36px tall holding a chevron, the group's name, its count and an add action, sticky under the header row while its group scrolls. How long lists open, summarise and search is patterns law 8.
|
|
69
|
+
|
|
70
|
+
**Forms.** A form or settings page caps at `measure.form`, left-aligned in the panel: at the panel's full width a switch ends up a screen's width from its label. The field that decides the rest goes first, at the form's full width — a category before the free text that depends on it.
|
|
71
|
+
|
|
72
|
+
**Overviews and dashboards.** What a dashboard holds and in what order is patterns law 12; on the page, each of its sections is parted from the next by a full-bleed hairline (layout law 3).
|
|
73
|
+
|
|
74
|
+
### Expressive — bands with a measure inside
|
|
75
|
+
|
|
76
|
+
Sections may run to the viewport edge as bands — a colour field, a full-bleed image, a product shot — but the text inside sits at `measure.reading`, or narrower for a short statement. A two-line headline may span the band; the paragraph under it may not. Card grids and multi-column text inside a band cap at `measure.wide`, about 1200–1280px; only the band itself and its media run edge to edge, or a three-up grid on a 2560px screen becomes three strips.
|
|
77
|
+
|
|
78
|
+
```css
|
|
79
|
+
.band { padding-block: var(--space-section); }
|
|
80
|
+
.band > .text { max-width: var(--measure-reading); margin-inline: auto; padding-inline: var(--gutter); }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Spacing ladders per preset
|
|
84
|
+
|
|
85
|
+
One base unit (4px), a role for every job. `space.tight`, `space.group`, `space.section` and the pair ratio are the layout dials; the heading pair's defaults land inside that ratio. Values are defaults; the ratios must survive.
|
|
86
|
+
|
|
87
|
+
| Role | editorial | product | expressive |
|
|
88
|
+
|---|---|---|---|
|
|
89
|
+
| `space.heading-above` | 40–48px | 16–24px | 48–80px |
|
|
90
|
+
| `space.heading-below` | 12px | 8px | 16px |
|
|
91
|
+
|
|
92
|
+
Half-steps (6px, 10px, 14px, 44px) are legitimate when a ratio lands between whole steps; a value used once with no role behind it isn't.
|
|
93
|
+
|
|
94
|
+
## Asymmetry at every scale
|
|
95
|
+
|
|
96
|
+
| Relationship | Inner | Outer | Ratio |
|
|
97
|
+
|---|---|---|---|
|
|
98
|
+
| Prose heading → its paragraph | 12px | 44px above | ≈ 3.7:1 |
|
|
99
|
+
| List items → the next block | 8px | 24px | 3:1 |
|
|
100
|
+
| Card title → its body, cards in a stack | 6px | 20px between cards | ≈ 3.3:1 |
|
|
101
|
+
| Inside a dense card: meta line → title, title → details | 6px | 12px | 2:1 |
|
|
102
|
+
| Board cards → the next lane | 8px between cards | 12px between lanes | 1.5:1, held by the lane's own edge |
|
|
103
|
+
| Field label → its input, fields in a form | 4–6px | 16px | ≈ 3:1 |
|
|
104
|
+
| Role title → its dates | 2px | 6px | 3:1 |
|
|
105
|
+
|
|
106
|
+
Ratios cluster between 2:1 and 4:1. A form with `gap: 16px` on everything puts each label as far from its input as from the one above, and on a long form readers attach labels to the wrong field. A border round every field only adds noise over the spacing bug; tighten label-to-input to 4–6px and the form groups itself. The same goes for a screen called cluttered: check its gaps against this table — padding inside a card (12px on a dense board), tiers within it, gaps between peers — before reaching for colour, weight or another rule.
|
|
107
|
+
|
|
108
|
+
## Section rhythm
|
|
109
|
+
|
|
110
|
+
Layout law 3's table picks each boundary's divider; this is how each is built. **Peers get symmetric space**: sections on a dashboard or an admin page are siblings, so each gets the same space above and below its divider. **A change of subject gets asymmetry**, with more space above the rule than below, because the content below belongs to the heading that follows.
|
|
111
|
+
|
|
112
|
+
Peer sections of a working page — a full-bleed hairline, none after the last:
|
|
113
|
+
|
|
114
|
+
```css
|
|
115
|
+
.section { margin-inline: calc(var(--gutter) * -1); padding: var(--space-section) var(--gutter); }
|
|
116
|
+
.section:not(:last-child) { border-bottom: var(--hairline-chrome) solid var(--border-divider); }
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The negative margin lets the line reach the panel's edges while the content keeps its gutter; a line that stops at the gutter reads as a rule under one section rather than a boundary between two.
|
|
120
|
+
|
|
121
|
+
A change of subject in a reading column — a measure-wide rule, the first section without one:
|
|
122
|
+
|
|
123
|
+
```css
|
|
124
|
+
.major-section {
|
|
125
|
+
margin-top: var(--space-section); /* e.g. 64px */
|
|
126
|
+
padding-top: calc(var(--space-section) * 0.625); /* e.g. 40px */
|
|
127
|
+
border-top: var(--hairline-object) solid var(--border-divider);
|
|
128
|
+
}
|
|
129
|
+
.major-section:first-child { border-top: 0; padding-top: 0; }
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
A settings group — a panel with its own save:
|
|
133
|
+
|
|
134
|
+
```css
|
|
135
|
+
.settings-group { border: var(--hairline-object) solid var(--border-default); border-radius: var(--radius-card); padding: var(--space-group); }
|
|
136
|
+
.settings-group + .settings-group { margin-top: var(--space-group); }
|
|
137
|
+
.settings-group > .row + .row { border-top: var(--hairline-chrome) solid var(--border-divider); }
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
In prose, a short closing block that reads as a coda follows on without a rule; ruling it off promotes it to a peer. A band — a logo strip, a stat band — keeps the edges that mark it as a band. **Decide the kind of each boundary in the layout code beside the sections**, so it applies to whichever subset is present, and never expose it as a setting: someone will switch every rule on, or every rule off.
|
|
141
|
+
|
|
142
|
+
## Page padding and chrome clearance
|
|
143
|
+
|
|
144
|
+
- **Top space signals a document.** Editorial pages, and expressive ones without a hero, start well below the top edge; product content starts close under the page's header bar, which already framed it (values: the layout dials).
|
|
145
|
+
- **Fixed chrome reserves its own clearance.** Under a fixed bottom bar or dock, the page's bottom padding must exceed the bar's height plus its offset on every template whose content can end near the viewport bottom; a page ending in a footer can rely on the footer's padding.
|
|
146
|
+
- **Audit it per template.** The long article ends in a padded footer; the short index without one puts its last item under the bar — the layout bug that most often survives review, because the test page is always a long one.
|
|
147
|
+
|
|
148
|
+
## Viewport units
|
|
149
|
+
|
|
150
|
+
| Unit | Means | Use |
|
|
151
|
+
|---|---|---|
|
|
152
|
+
| `dvh` | The visible height, tracking mobile browser chrome | The default for anything sized to the viewport |
|
|
153
|
+
| `svh` | The height with chrome fully shown | A hero that must never be cut off |
|
|
154
|
+
| `lvh` | The height with chrome fully hidden | Rarely — a background that must always cover |
|
|
155
|
+
| `vh` | Equal to `lvh` on most mobile browsers | Avoid; a `100vh` element's bottom sits under the address bar |
|
|
156
|
+
|
|
157
|
+
A sticky rail is capped at `calc(100dvh - top offset - bottom margin)` with `overflow-y: auto`.
|
|
158
|
+
|
|
159
|
+
## Worked example: *Manifest*
|
|
160
|
+
|
|
161
|
+
A fictional freight-tracking app — `product`, docs area `editorial`, Inter at 14px in the app and 16px in the docs:
|
|
162
|
+
|
|
163
|
+
```css
|
|
164
|
+
:root {
|
|
165
|
+
--measure-reading: 34rem; /* 544px ≈ 65 characters of Inter at 16px — docs and all help text */
|
|
166
|
+
--measure-app: 100%; /* content fills its panel, left-aligned */
|
|
167
|
+
--shell-nav: 240px;
|
|
168
|
+
--shell-inspector: 360px;
|
|
169
|
+
--gutter: 16px; /* the smallest width; 24px from tablet up */
|
|
170
|
+
--measure-form: 45rem; /* 720px — forms and settings in the panel */
|
|
171
|
+
|
|
172
|
+
--space-tight: 6px;
|
|
173
|
+
--space-group: 16px;
|
|
174
|
+
--space-section: 32px;
|
|
175
|
+
--space-heading-above: 24px; /* 3:1 */
|
|
176
|
+
--space-heading-below: 8px;
|
|
177
|
+
|
|
178
|
+
--radius-container: 16px; /* panel, 4px padding … */
|
|
179
|
+
--radius-card: 12px; /* … holds 12px rows */
|
|
180
|
+
--radius-control: 8px; /* button, 2px inset … */
|
|
181
|
+
--radius-small: 6px; /* … holds a 6px badge */
|
|
182
|
+
--radius-pill: 9999px;
|
|
183
|
+
|
|
184
|
+
--hairline-chrome: 0.5px; /* a border: one device pixel on 2×, 1px on 1×; divider uses border.divider */
|
|
185
|
+
--hairline-object: 0.5px; /* table frames match their row rules */
|
|
186
|
+
--rule-emphasis: 2px;
|
|
187
|
+
|
|
188
|
+
--z-below: -10; --z-raised: 10; --z-popover: 20; --z-dismiss: 40;
|
|
189
|
+
--z-nav: 50; --z-overlay: 60; --z-toast: 70; --z-skip-link: 100;
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The same values land as `--spacing-*`, `--radius-*` and `--container-*` variables in a Tailwind v4 `@theme` block, under `theme.extend.spacing`, `borderRadius`, `maxWidth` and `zIndex` in a Tailwind config, or as `space`, `radii`, `sizes` and `zIndices` keys in a JS theme object — whichever the project already uses, under its own names.
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# Radius, lines and layers
|
|
2
|
+
|
|
3
|
+
## The nesting rule, worked through
|
|
4
|
+
|
|
5
|
+
**Inner radius = outer radius − the gap between their edges, at concentric corners.** Only then do the two curves share a centre; any other value makes the corners visibly disagree. The rule binds only an inner corner tucked inside the outer one's curve, inset by less than the outer radius. Inset that far or further, or set away from the corners, an element shares no centre with its container and takes the ladder's radius for its own size — never a square corner produced by the subtraction.
|
|
6
|
+
|
|
7
|
+
| Nesting | Outer | Gap | Inner | Note |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| A container holding icon buttons | 16px | 4px | 12px | |
|
|
10
|
+
| An icon button holding an avatar | 12px | 2px inset | 10px | |
|
|
11
|
+
| A sheet holding text rows | 16px | 6px | 10px → snap to 12px | 2px on a 44px row is invisible |
|
|
12
|
+
| A search input holding a chip | 8px | 3px | 5px → snap to 4px | |
|
|
13
|
+
| A card with a flush image | 12px | 0 | 12px, or clip with `overflow: hidden` | Zero gap, same radius |
|
|
14
|
+
| A padded card holding an icon tile | 12px | 16px | `radius.control` for a 32px tile | Inset past the outer radius: not concentric, sized by the ladder |
|
|
15
|
+
| A search field holding a `kbd` hint | 8px | 8px | `radius.small` | Not concentric; square would read as a different system |
|
|
16
|
+
|
|
17
|
+
**Snap within 2px, follow exactly above that.** The ladder is worth more than 1–2px of exactness, which below about 8px the eye barely resolves; a mismatch of 4px or more is what people notice without naming.
|
|
18
|
+
|
|
19
|
+
Derive the ladder from the nestings, and write the subtraction into the token so a later padding change — the usual way a correct nesting quietly breaks — can't break it:
|
|
20
|
+
|
|
21
|
+
```css
|
|
22
|
+
:root {
|
|
23
|
+
--radius-container: 16px;
|
|
24
|
+
--container-pad: 4px;
|
|
25
|
+
--radius-control: calc(var(--radius-container) - var(--container-pad)); /* 12px */
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Ladders per preset
|
|
30
|
+
|
|
31
|
+
| Role | editorial | product | expressive (round) | expressive (sharp) |
|
|
32
|
+
|---|---|---|---|---|
|
|
33
|
+
| `radius.pill` | full | full | full | full, or 0 |
|
|
34
|
+
| `radius.small` | 4px | 4–6px | 8px | 0 |
|
|
35
|
+
| `radius.control` | 8px | 8px | 12px | 0 |
|
|
36
|
+
| `radius.card` | 12px | 12px | 20px | 0 |
|
|
37
|
+
| `radius.container` | 16px | 16px | 28px | 0 |
|
|
38
|
+
|
|
39
|
+
Product matches editorial, because smaller radii (6px controls, 8px cards) read boxy and dated; with content unboxed (layout law 3), the few cards don't tile edge to edge, which is where large radii leave triangular gaps. Both draw the corner as a light superellipse (below). An existing design's radii are the floor (layout guide, Refining an existing design). Expressive may go either way, but never mixes round and square components or keeps four near-identical radii that are really one.
|
|
40
|
+
|
|
41
|
+
**Radius scales with size, not importance**, and sub-proportionally: a 400px panel gets a larger radius than a 40px button because it is larger, but a proportional one reads as a lozenge. **`radius.pill` is for things with no top and bottom** — tags, dots, toggles, progress tracks. A paragraph in a pill-ended card collides with the curve.
|
|
42
|
+
|
|
43
|
+
## Corner shape
|
|
44
|
+
|
|
45
|
+
A superellipse eases curvature into the straight edge where a quarter-circle `border-radius` stops dead, drawing a better corner at the same radius.
|
|
46
|
+
|
|
47
|
+
```css
|
|
48
|
+
*, *::before, *::after { corner-shape: superellipse(1.5); }
|
|
49
|
+
.pill, .avatar, .dot { corner-shape: round; } /* full radii: a squircle here just swells */
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
`superellipse(k)` uses the exponent 2^k: 1 is an ordinary round corner, 2 a full squircle, 1.5 about halfway — craft rather than effect. Editorial and product use 1.5 by default, which keeps a round system feeling current; expressive may use any shape if it's applied globally. Pills and circles keep `round`. It is progressive enhancement: unsupported browsers draw normal rounding at the same radius, so nothing may depend on it.
|
|
53
|
+
|
|
54
|
+
## Line weights by job
|
|
55
|
+
|
|
56
|
+
| Role | Job | editorial | product | expressive |
|
|
57
|
+
|---|---|---|---|---|
|
|
58
|
+
| `hairline.chrome` | A divider inside one object — toolbar groups, menu rows, table rows | 0.5px | 0.5px, `border.divider` | 1px |
|
|
59
|
+
| `hairline.object` | An object's edge — a card, an input, a disclosure, a table frame | 1px | 0.5px, `border.default` | 1–2px |
|
|
60
|
+
| `rule.emphasis` | A deliberate mark — a quote bar, a focus ring, an active tab | 2px | 2px | 3–4px |
|
|
61
|
+
|
|
62
|
+
Where chrome and object share a weight, the divider must step down in colour so it never outranks the edge.
|
|
63
|
+
|
|
64
|
+
### Half a pixel, drawn as a border
|
|
65
|
+
|
|
66
|
+
A `0.5px` border is one device pixel on a 2× screen and rounds up to a full pixel on 1×: browsers snap any border width between 0 and 1 device pixel up to one. A box half a pixel tall with a background gets no such snapping — on 1× it antialiases to a faint smear or nothing. So every hairline is a border, including a divider between rows. A `box-shadow` ring doesn't snap either: on 1× its half pixel antialiases to a faint line, which the depth guide's product ring edge wants, and why it swaps to a real border under `prefers-contrast: more` (`domains/depth/elevation-recipes.md`).
|
|
67
|
+
|
|
68
|
+
```css
|
|
69
|
+
:root { --hairline: 0.5px; }
|
|
70
|
+
@media (prefers-contrast: more) { :root { --hairline: 1px; } }
|
|
71
|
+
|
|
72
|
+
.row + .row { border-block-start: var(--hairline) solid var(--border-divider); }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Three things never take the half pixel, on purpose:
|
|
76
|
+
|
|
77
|
+
- **Focus rings**, on `rule.emphasis`: a half-pixel ring is too faint to find.
|
|
78
|
+
- **`prefers-contrast: more`**, whose whole job is a real edge, so the token swaps to 1px rather than each component guessing.
|
|
79
|
+
- **Deliberate thick marks** — the current tab's underline, a quote or note bar.
|
|
80
|
+
|
|
81
|
+
A vertical divider is a border too, and spans the content, not the padding: a 24px line in a 40px row, centred.
|
|
82
|
+
|
|
83
|
+
```css
|
|
84
|
+
.divider-v { block-size: 24px; align-self: center; border-inline-start: var(--hairline-chrome) solid var(--border-divider); }
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### A frame matches what it frames
|
|
88
|
+
|
|
89
|
+
Where a frame holds dividers — a product table, a grouped list, a settings panel — the outer edge and the inner lines are read as one grid, so they share a weight and an inset and meet without a step. A 1px frame round 0.5px row rules looks like a border drawn on afterwards; a header rule inset 12px under a frame drawn edge to edge looks misaligned. The divider may step down in colour, never in weight.
|
|
90
|
+
|
|
91
|
+
Two resets that cause visible bugs:
|
|
92
|
+
|
|
93
|
+
- An `<hr>` carries a UA border: give it `border: 0` before a `background` and `block-size`, or it draws the browser's line and yours a few pixels apart.
|
|
94
|
+
- A divider that is rhythm rather than structure gets `aria-hidden`; an `<hr>` announces itself as a separator.
|
|
95
|
+
|
|
96
|
+
On translucent or unknown backdrops every line uses `border.layered` (colour law 4).
|
|
97
|
+
|
|
98
|
+
## The z-index ladder
|
|
99
|
+
|
|
100
|
+
The rungs and their defaults are layout law 7's table; every preset shares them. What follows is how they hold up in code.
|
|
101
|
+
|
|
102
|
+
Ordered by permanence, gaps of ten. A stacked modal (a confirm over a dialog) needs no new rung: rendered later in the same portal, it stacks by document order. **Portals make the ladder global**: inside a card, a menu is trapped by every stacking context above it whatever its number, so overlays, menus escaping a scroll container, and toasts render into a root-level portal.
|
|
103
|
+
|
|
104
|
+
### The two pairs
|
|
105
|
+
|
|
106
|
+
```html
|
|
107
|
+
<!-- Catcher above the page, below the menu it dismisses. -->
|
|
108
|
+
<div class="catcher" style="position: fixed; inset: 0; z-index: var(--z-dismiss)" aria-hidden="true"></div>
|
|
109
|
+
<div class="menu" style="position: relative; z-index: var(--z-nav)">…</div>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
A catcher on `z.nav` or above eats the menu's own clicks, so the menu seems to ignore every tap. A content popover on `z.popover` sits below `z.dismiss` and can't borrow the navigation's catcher: render its own immediately before it in the same portal, and the two stack by document order.
|
|
113
|
+
|
|
114
|
+
## Stacking-context techniques
|
|
115
|
+
|
|
116
|
+
**Negative z needs an isolated parent.** At `z-index: -10` alone, a highlight meant to slide behind a row of items but in front of their container's background drops behind the container too; `isolation: isolate` on the container scopes it.
|
|
117
|
+
|
|
118
|
+
**A lift on hover needs one on focus.** An item raised to `z.raised` on hover, so its neighbour doesn't clip its shadow, needs the same on `:focus-within`, or keyboard focus shows it clipped.
|
|
119
|
+
|
|
120
|
+
**A full-width strip passes clicks through.** A fixed bar spanning the viewport for centring takes `pointer-events: none`, its content `pointer-events: auto`, or the invisible strip swallows every click along the bottom of the page.
|
|
121
|
+
|
|
122
|
+
**Containing blocks.** A `transform`, `filter`, `backdrop-filter`, `perspective`, `contain: paint` or `will-change: transform` on an ancestor makes it the containing block for `position: fixed` descendants: a full-screen overlay renders the size of its parent strip, or a fixed header scrolls away. Check every ancestor for those properties before changing a z-index.
|