Skip to content
@erikakers/slate

The base layer

src/base/ is the half of a reset that @erikakers/typography declines, plus the eight @font-face rules (five real faces and the three metric-matched fallbacks that stop the swap moving the page; docs/type.md carries the measurements) and the keyboard focus ring. It is smaller than the css/base.css it is ported from by roughly half, and the missing half is the point of this document.

Who owns which rule

The split is read off the type module’s README — its “What the reset declines, and why” table and its “Requirements” section — rather than decided here.

Concern Owner Why
box-sizing: border-box Slate The module’s reset declines it, and its Requirements say the host must set it or safe-box()’s em padding adds to the box instead of insetting it.
Focus ring Slate “Focus styling is out of scope.” The module refuses outline: none and sets nothing else.
Page ground, ::selection Slate A typography module does not own a palette, and contrast (1.4.3) is a decision about a pair.
Document base font Slate The module claims no tag for the document: nothing on html, body or :root past text-size-adjust. See below.
img, picture, video, canvas, svg Slate The module declines replaced elements outright: no text in one to set.
prefers-reduced-motion Slate The module ships no transitions.
hr’s rule Slate The module gives hr rhythm through $rhythm-blocks and no rule of its own.
-webkit-text-size-adjust typography One of its three unconditional WCAG rules. Deleted here, not ported.
:where(input, button, textarea, select) { font: inherit } typography Same: the 1.4.12 form-control rule. Deleted, not ported.
pre { overflow-x } typography Its third unconditional WCAG rule.
margin: 0 on thirteen block elements typography Its reset zeroes them and elements() gives each one back, derived from its role. A Slate rule would fight one half or duplicate the other.

The four typography-owned rows are asserted absent, not reviewed absent. test/unit/base-layer.test.js compiles the two layers separately — a single blob says nothing about which module wrote a rule — and checks each of the four is missing from Slate’s and present in the module’s, so the absence cannot pass by both layers being empty.

The cascade claim is an intersection, not an argument. “No Slate reset rule outranks a type rule for any property they share” is answered by building tag → properties for each layer and intersecting them. Specificity never enters it: two rules that share no property cannot outrank one another whatever their weights. hr is the one tag both layers reach, and they reach different properties.

The document base font, which is here

The bundle’s body also carried font-family: var(--font-ui), a font-size, a font-weight, a line-height and a letter-spacing. Three of those five are ported, and the reason is the same one that paints the ground: the type module claims no tag for the document. It puts nothing on html, body or :root beyond text-size-adjust, and it declines div, span, section, table and the form controls past font: inherit. Left alone, an unclassed <div>, <td>, <button> or <label> renders in the UA serif at the UA size while the <li> beside it is Petrona at 18.5px. A design system whose unclassed content falls back to Times is not delivering its design, and the <li> is the proof that it is a gap rather than a decision.

What lands is the three inputs, not a role, which is what keeps this inside the rule that Slate states no type value of its own:

Input Written as Why that spelling
$scale-base type.scale(0) The module’s base step, rounded by $scale-precision exactly as --type-size-body is.
$leading-base type.$leading-base The input verbatim, which is also what --type-leading-body carries.
$font-family-base type.$font-family-base The input verbatim. The module emits no --type-family-* for the base, only one per role.

Restating 1.15625rem here instead would put a third, differently rounded copy of $scale-base in the output; the module emits 1.1563rem and that is the number a consumer should get, so the number is asked of the module rather than written down. Asked of it rather than referenced through var(--type-*), which the governing rule would also allow, because slate-reset is a public mixin a consumer may include without tokens(): a var() with no declaration behind it is invalid at computed-value time, and the base size would fall back to the UA’s silently, which is the failure this rule exists to remove. test/unit/base-layer.test.js asserts the emitted font-size and line-height against the module’s own --type-size-body and --type-leading-body rather than against literals, so the two cannot drift.

The family is the UI face, as the bundle had it: structure is Hanken and reading is Petrona, so <button>, <td> and <label> inherit from here while <p> takes Petrona from its own role. It tracks a consumer’s $font-family-base rather than being pinned to $slate-family-ui.

It compounds with nothing, checked rather than assumed. A font-size on body multiplies every em-based size under it. Every --type-size-* the module emits is in rem, so no role rescales; the em sizes in its reset are small, sub, sup and code, which are relative to their parent by design and were reading off the UA’s 16px in an unclassed container until now. test/unit/base-layer.test.js asserts both halves.

font-weight and letter-spacing are the two that do not land: the body role carries normal and 0em, which is what the UA already computes, so both declarations would be output that changes nothing.

One thing this exposes upstream. $scale-base and $leading-base have emitted spellings in --type-size-body and --type-leading-body, and $font-family-base has none: the module emits a --type-family-* per role and nothing for the base. So the family is the one of the three no consumer can reach at run time. That is a gap in the module rather than something for Slate to name a token for, and it belongs in a typography ticket alongside #72, #73 and #74.

Four declarations deliberately not ported

-webkit-font-smoothing: antialiased, -moz-osx-font-smoothing: grayscale and text-rendering: optimizeLegibility sat on the bundle’s body and are not here. The type module declines the first two with an argument this repository has no answer to — no finding supports them in either direction, thinning strokes is a legibility bet, and another system’s stylesheet is not evidence — and the third is a hint with a history of dropping glyphs. font-synthesis-weight: none is left out with them, and it is the one worth revisiting: it stops a fallback face faking a weight it has no file for, which matters as soon as a component asks for one. None of the four is a reset rule, and each needs an argument of its own rather than arriving with the port.

The reduced-motion block carries !important, by grant

declaration-no-important is on repository-wide and stays on. .stylelintrc.mjs grants src/base/_reset.scss alone the exception, in an overrides entry that argues for it.

The reason is that no other shape works. Every component rule in this system carries a class, so a (0,0,0) universal rule loses to all of them and the block reduces nothing at all — a gate that turns a working accessibility rule into a decorative one is a gate applied past its argument. The rule’s own argument, inherited from the type module, is WCAG 1.4.12: an !important on line-height, letter-spacing, word-spacing or paragraph spacing defeats a reader’s text-spacing override. That argument is right, and 1.4.12 reserves none of animation-duration, animation-iteration-count or transition-duration.

The grant is by file because stylelint’s rule takes no property list, so the narrowest available scope is the file. test/unit/stylelint-rules.test.js pins the grant, the refusal in every other stylesheet, and that cost stated out loud.

The faces, and the one step Sass cannot take

Sass compiles a url() in a dependency’s stylesheet verbatim. Nothing rewrites it to the consumer’s output directory, and no Sass-only package can make it automatic. So:

  1. The five woff2 and their three licence texts ship in the tarball at fonts/, which is what package.json’s files entry has always named.
  2. $slate-font-path is the URL they are served from, defaulting to /fonts/.
  3. The consumer copies them. node_modules/@erikakers/slate/fonts/*.woff2 into whatever $slate-font-path resolves to in their own build.
// serving them from somewhere else
@use 'pkg:@erikakers/slate/global' with ($slate-font-path: '/assets/type/');

// self-hosting the same faces from your own pipeline
@use 'pkg:@erikakers/slate/global' with ($slate-emit-fonts: false);

They are emitted by default, because a design system whose faces do not load is not delivering its design, and a consumer who does nothing should get the type Slate was drawn with rather than five 404s and fallback text. $slate-emit-fonts: false is the documented escape, and it changes nothing else about the output — asserted, not claimed.

The family name is read off settings/_fonts.scss rather than written twice, so a face and the stack that selects it cannot drift. The build refuses a stack whose head is not the face this package ships for it: the alternative is Slate’s woff2 declared under a family name the consumer chose, every declaration valid CSS and their text rendered in a face their own stack does not name.

The swap jump is not corrected. docs/type.md records why fallback-metrics() is not called and what calling it would need.

The focus ring is its own mixin

slate-focus rather than a block inside slate-reset, for two reasons. A consumer who wants a different ring drops the mixin and draws theirs, instead of fighting :focus-visible at (0,1,0). And the one thing nobody may quietly lose has a name in the API rather than being a paragraph inside something else.

The ring carries no border-radius, and the bundle’s did. css/base.css:58 wrote border-radius: var(--radius-sm) inside the :focus-visible rule, and it came across with the port before this was argued. It cannot do the job it looks like it does: border-radius under :focus-visible sets the element’s radius, never the outline’s, and an outline takes its curve from whatever radius the element already carries. Every engine has drawn it that way since Chrome and Edge 94, Firefox 88, Opera 80 and Safari 16.4 (MDN browser-compat-data records a partial implementation before each of those, noted as “outline does not follow the shape of border-radius”). So the ring was already rounded and the declaration bought nothing.

What it did buy was a shape change. At (0,1,0) it re-corners the focused element itself, so a pill button or a round avatar whose own radius is set at or below that weight and earlier in source order squares to 2px for as long as the keyboard focus is on it. That argument does not depend on the version numbers: in an engine that ignored border-radius on outlines the declaration would be a pure element mutation instead, and in one that honours it the ring is already right. Either way it goes, and the ring is unchanged in every browser that draws a rounded one.

:focus:not(:focus-visible) { outline: none } is not the destructive rule the type module names in its declined list: it removes the ring only where the browser has already decided the focus did not come from the keyboard, and the :focus-visible rule above still fires when it did. Removing the indicator outright fails WCAG 2.4.7, and 2.4.11 and 2.4.13 in 2.2. The pair is asserted as a pair.

What is not here

Element rules for dialog, details, table and the form controls are the component layer’s, and landed in #8; docs/components.md carries them and every decision they made. The rest of the component rules belong to later merge requests. So does anything that would set a block margin on a tag the type module claims. docs/type.md carries that decision and the reasoning behind it, and the component layer obeys it under the same tag-by-tag intersection this layer does.