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:
- The five woff2 and their three licence texts ship in the tarball at
fonts/, which is whatpackage.json’sfilesentry has always named. $slate-font-pathis the URL they are served from, defaulting to/fonts/.- The consumer copies them.
node_modules/@erikakers/slate/fonts/*.woff2into whatever$slate-font-pathresolves 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.