@adrienlcp/styles
v0.19.0
Published
A reset, a reduced-motion switch, default tokens, Sass mixins for self-hosted fonts, container queries, a breakpoint, visually hidden text, a skip link that clears the notch, a fluid size in rem, a spin animation, a PostCSS plugin that lists Arial's metri
Maintainers
Readme
@adrienlcp/styles
The styling floor every app of mine starts from: a reset, a reduced-motion
switch, and the Sass mixins that CSS cannot write yet. Plain .css for what
CSS can say, .sass only for what it cannot — a custom property is not allowed
in a media query or in unicode-range, and CSS has no mixins.
pnpm add @adrienlcp/stylesPlain CSS
| File | Does |
| --- | --- |
| reset.css | Box sizing, zeroed margins and paddings, inherited fonts on controls, bare buttons that fire on the first tap (touch-action: manipulation), links in their parent's color with their underline kept, bare lists, balanced headings, pretty paragraphs, and interpolate-size: allow-keywords so a transition reaches height: auto (Chromium; elsewhere the size snaps as before), no tap highlight on mobile — every pressable then owes its own pressed style —, headings and paragraphs that break a long word rather than overflow, a textarea that resizes only vertically, anchors that scroll to --scroll-offset below the top (the height of a sticky header, 0px until set), and #root isolated so a z-index inside the app never climbs over an overlay portalled to body. It leaves out what an app decides: scrollbar-gutter, font-synthesis, field-sizing on a textarea, and smooth scrolling, which would also animate the router's scroll on every navigation. Inside @layer reset, except [hidden], kept hidden whatever display a component gives it |
| reduced-motion.css | Collapses --transition-fast, --transition-base and --transition-slow to 0ms under prefers-reduced-motion: reduce, ends every keyframe animation at once, looping ones included, and stills view transitions, which React's <ViewTransition> starts whatever the preference. Unlayered, so it beats the tokens wherever they are defined. A scroll-driven animation moves only as the reader scrolls, and a zero duration pins it to its last frame: its element declares --reduced-motion-duration: auto, which reaches none of its descendants |
Import them once — from JavaScript, or from the global stylesheet in Sass:
@use '@adrienlcp/styles/reset.css'
@use '@adrienlcp/styles/reduced-motion.css'reset.css declares a layer: set the order in the document head before any
stylesheet loads, so the reset stays under every component rule —
<style>@layer reset, tokens, base, components;</style>.
Sass
Resolved through the sass export condition, which Vite reads; with the Sass
CLI, use pkg:@adrienlcp/styles/breakpoints and --pkg-importer=node.
containers
A component answers the room it is given, not the screen: the same card lays out the same way in a sidebar on a desktop and full width on a phone.
@use '@adrienlcp/styles/containers'
.card
@include containers.container
.card-body
@include containers.container-wide(30rem)
grid-template-columns: auto 1frcontainer($name: null)setscontainer-type: inline-size, andcontainer-namewhen given one.container-wide($width, $name: null)iswidth >= $widthon the nearest container, or on the one named;container-narrowis its exact complement.- An element queries an ancestor, never itself — the container wraps what changes.
- A container no longer takes its width from its content: inside a flex row
without a width, an
autogrid track or an absolutely positioned box, it collapses to zero. Its parent gives it a width. - With no container above, neither mixin matches. Make
bodyone in the global stylesheet, so an unwrapped component falls back to the page width;position: fixedstays on the viewport. - The width is Sass interpolated into
@container, so pass a value, not a custom property —@containercannot read one either.
breakpoints
For what depends on the device rather than the room a component has: the page shell, a sidebar that turns into a drawer, an overlay pinned to the viewport.
@use '@adrienlcp/styles/breakpoints'
.shell
@include breakpoints.wide
grid-template-columns: 16rem 1fr$wide-screen is 56.25rem — rem in a media query reads the browser's font
size, so a user who raises it gets the narrow layout sooner. wide is
width >= $wide-screen and narrow its exact complement. Another value:
@use '@adrienlcp/styles/breakpoints' with ($wide-screen: 64rem); a container
threshold that follows the shell takes the same value.
fonts
$latin and $latin-ext are the unicode ranges Google Fonts cuts a Latin face
into; font-face declares one self-hosted woff2 file.
@use '@adrienlcp/styles/fonts'
@include fonts.font-face('Archivo', '/fonts/archivo-latin.woff2', fonts.$latin, $weight: 100 900, $stretch: 62% 125%)
@include fonts.font-face('Archivo', '/fonts/archivo-latin-ext.woff2', fonts.$latin-ext, $weight: 100 900, $stretch: 62% 125%)$weight, $style (normal), $stretch (left out) and $display
(optional) are optional. optional keeps the fallback face for the page's
whole life when the font misses the first ~100 ms, so the font never swaps in
under a reader and moves a line; it is cached for the next page. Its preload
keeps the default priority: fetchpriority="low" makes it miss those 100 ms
on purpose.
fallback-faces writes a fallback face per weight band of a variable font,
for the font fontaine scales poorly: fontaine writes one face for the whole
font, measured at its default instance, so a heavier weight wraps later in
Arial than in the web font.
@include fonts.fallback-faces('Sofia Sans', (ascent: 0.9, descent: 0.3, cap-height: 0.655), 0.964556, (300 449: 1.0094, 450 649: 1.0058, 650 800: 1.0567), $trimmed-to-capitals: true)$metricsis the web font'sascent,descentandcap-heightin em, read from itshheaandOS/2tables;$size-adjustthe ratio fontaine computes for the file.- Each
$widthsentry maps a weight range to a width ratio: the band's Arial cut scaled by$size-adjust, over the web font at the weight that band's text is set in, both measured over the app's own text —(arial × size-adjust) / web font. The face is scaled by$size-adjustdivided by that ratio, so it sets the text exactly as wide as the web font. A ratio above 1 means scaled Arial is still too wide. Themeasure-fontbin of@adrienlcp/measure-fontprints them. $size-adjustonly sets the scale the ratios are read on — it cancels out of every face. It defaults to1: pass$widthsby name then,fallback-faces('Onest', $metrics, $widths: (400: 0.9505)), with ratios measured bymeasure-font --size-adjust 1.- A band from
$bold-from(650) up is drawn inArial Bold, and its ratio is measured over Arial Bold: a face that declares its weights is never synthesised bolder. $style: italicwrites the faces for the italic files, drawn inArial ItalicandArial Bold Italicand measured over them. Without them italic text in the fallback takes the upright face — slanted by synthesis at best, upright underfont-synthesis: none— at upright widths. Any other style is a compile error.$figuresmaps the same ranges to the digits' own ratio, or is one ratio for every band —measure-font --figures --figure-feature tnum --figure-feature lnumfortabular-nums lining-numsprints it once when every weight gives the same. Each band gets a second face forU+0030-0039alone, written after it so it wins for the digits, on the ascent and descent of the band's letters. One ratio cannot cover both when the font's digits stand to Arial's otherwise than its letters do: tabular figures scaled on the letters can set 15 to 25 % off — Barlow Condensed's 14 % narrower, arbor's 24 % — and a score jumps when the web font lands. A range missing from$figuresis a compile error.$figure-separators: truewidens that face to,,.and:, so a clock such as0:02sets as wide as the web font draws it.$figuresis afallback-facesargument only. A family left to fontaine gets one face, scaled on its letters: move it tofallback-facesto give its digits their own. A monospace family needs none — its digits are as wide as its letters.$stretchdeclares the faces'font-stretch—70%, or a range like62% 70%— for a font with a width axis. Arial has none, so a condensed title otherwise falls back to full-width Arial and wraps: write one call per width band the app sets, each measured withmeasure-font --axis wdth=70.$trimmed-to-capitalsmoves ascent and descent by the gap between the two capital heights, their sum kept, so a title trimmed to its capitals starts at the same height in both faces.- The faces are named
<family> fallback, as fontaine names its own: skip the family in fontaine'sskipFontFaceGeneration, and listmetricTwins()after it for Linux and Android.
A font token names the fallback face itself. fontaine appends
'<family> fallback' to a font-family declaration it reads, never to a
custom property: a token keeps the family alone, and the metric-matched face
is never used. Write it in:
--font-display: 'Bricolage Grotesque', 'Bricolage Grotesque fallback', sans-seriffindFallbackFailures in the audit lists a token that skips it.
Static files, one per weight. fontaine writes one fallback face per
@font-face, copying its weight and style, but sizes every one from the same
metrics — the family's entry in capsize's metrics collection, the regular cut,
for any Google font, else the file it reads — and draws every one in regular
Arial. The 600 and 700 faces carry the regular cut's size-adjust over
regular Arial, and a bold line wraps at another width than the font's. Skip the
family in skipFontFaceGeneration and write fallback-faces with one band per
file: measure-font measures each file at its own weight against that one
size-adjust, and Arial Bold from $bold-from — lower it to 600 when a 600
file measures closer to Arial Bold.
tokens
The tokens every app names the same way, so an app sets values, not names.
Include defaults first in the app's :root; what the app declares after it
wins.
@use '@adrienlcp/styles/tokens'
@layer tokens
:root
@include tokens.defaults
--gutter: var(--space-l)| Token | Default |
| --- | --- |
| --stroke-hair, --stroke-thin, --stroke-bold | 1px, 1.5px, 2px |
| --hairline, --hairline-strong | a --stroke-hair solid line in --rule, --rule-strong |
| --inset-hairline | the same hairline as an inset box-shadow, which takes no room |
| --outline-thick, --outline-offset | 2px, 3px. A whole number of pixels: Chromium draws an outline in whole device pixels, so a 2.5px ring is 2px on a 1x screen |
| --ring, --ring-offset | an --outline-thick solid outline in --focus, drawn --outline-offset outside the box |
| --underline-offset, --tracking-tight | 0.24em, -0.02em |
| --icon-s, --icon-m, --icon-l | 1rem, 1.25rem, 1.5rem |
| --target | 44px, the smallest touch target |
| --control-height | a drawn control's height: 2.75rem, never below --target |
| --transition-fast, --transition-base, --transition-slow | 150ms, 250ms, 400ms — what reduced-motion.css collapses |
| --ease-out | cubic-bezier(0.16, 1, 0.3, 1) |
| --measure | 34em. Never ch: a ch is the width of the font's zero, which the fallback face and the web font draw differently, so a column in ch rewraps when the web font replaces the fallback |
| --gutter, --gutter-left, --gutter-right | 1rem; a page's sides padded by --gutter, or by the inset a landscape notch covers when it is wider. Set --gutter on :root, where the pair is computed |
| --safe-area-top, --safe-area-right, --safe-area-bottom, --safe-area-left | what the notch, the corners and the home indicator cover under viewport-fit=cover, 0px elsewhere; the bottom reads Android's safe-area-max-inset-bottom first. Pad an edge with max(var(--space-m), var(--safe-area-bottom)) |
--rule, --rule-strong and --focus are the app's palette. Until it declares
them, a line is currentColor mixed toward transparent and the ring is
currentColor: never invisible.
accessibility
@use '@adrienlcp/styles/accessibility'
.icon-label
@include accessibility.visually-hiddenvisually-hiddenhides from sight, not from a screen reader. The focus rings are@adrienlcp/react-aria'sfocusmodule.
The skip link
@use '@adrienlcp/styles/accessibility'
.skip-link
@include accessibility.skip-link
background: var(--ink)
color: var(--paper)
padding: 0 var(--space-s)skip-link($top: var(--space-2xs), $left: var(--gutter-left), $z-index: 30)
parks the link above the viewport, position: fixed, and slides it in on
:focus-visible, so only the keyboard sees it. It shows at
max($top, var(--safe-area-top)), below a notch, and is parked past the top
by that offset too, or its bottom would show under the notch. While a view
transition runs it is hidden: the transition's picture of the page would show
it parked. It writes transition: translate; a link that also scales on press
writes the whole list itself. The look — background, colour, type, padding,
radius — is the app's.
sizes
Every text size and every spacing is rem, so it follows the font size the user
chose and the zoom. fluid lets a display size or a section's spacing grow
with the screen without leaving rem:
@use '@adrienlcp/styles/sizes'
@layer tokens
:root
--text-display: #{sizes.fluid(2rem, 3.5rem)}
--space-section: #{sizes.fluid(3rem, 6rem)}fluid($min, $max, $from: 20rem, $to: 80rem)returnsclamp($min, <rem> + <vw>, $max):$minup to a$from-wide viewport,$maxfrom$to, a straight line between. Every argument is rem, and$maxstays within 2.5 times$min— past that, a 200 % zoom on a wide screen no longer doubles the size (WCAG 1.4.4). Anything else is a compile error.
motion
@use '@adrienlcp/styles/motion'
.spinner
@include motion.spin(calc(var(--transition-slow) * 2))spin($duration)turns the element a full circle every$duration, at a constant speed, forever: a spinner, a sync icon. It writes the individualrotateproperty, so atransformor atranslateon the element still holds. Itsmotion-spinkeyframes come out once per stylesheet, at the root, wherever the first include sits — a layer, a media query. Underprefers-reduced-motion: reduce,reduced-motion.cssends the turn at once and the element rests as its rules draw it.
TypeScript
contrast
A palette is checked where it is written, not by eye: a test lists the token pairs that meet on screen, and fails when one falls under its WCAG minimum in the light or the dark scheme.
import { readFileSync } from 'node:fs'
import { findContrastFailures, WCAG_AA } from '@adrienlcp/styles/contrast'
import { expect, it } from 'vitest'
const TOKENS = readFileSync(new URL('_tokens.sass', import.meta.url), 'utf8')
it('every ink reads on every surface, in both themes', () => {
expect(
findContrastFailures(TOKENS, [
{ foreground: '--ink-soft', background: '--ground', minimum: WCAG_AA.text },
{ foreground: '--focus', background: '--ground', minimum: WCAG_AA.nonText }
])
).toEqual([])
})WCAG_AAistext(4.5),largeTextandnonText(3) — controls, icons, focus rings.- The stylesheet is
.cssor indented.sass. A token isoklch()or hex,light-dark()of those, orvar()of another token. - A failure is
too-low, with the ratio and the schemes it fails in, orunreadable: a token undeclared, declared twice with different values, a reference loop, a colour it cannot read (computed, or another notation), or a translucent background — what shows through decides that contrast. - A translucent foreground is measured over its background. An
oklch()outside sRGB is clipped, as an sRGB screen draws it.
metric-twins
fontaine scales a fallback face to the web
font with size-adjust and the *-override descriptors, but names one local
font in its src: local("Arial"). Linux has no Arial and Android only
Roboto, so there the face fails to load, text paints in an unscaled system font
and moves when the web font swaps in. metricTwins(), listed after fontaine,
names the fonts drawn on the same metrics too:
import { metricTwins } from '@adrienlcp/styles/metric-twins'
import fontaine from 'fontaine/postcss'
export default defineConfig({
css: {
postcss: { plugins: [fontaine({ fallbacks: ['Arial'] }), metricTwins()] }
}
})| A face naming | Also names |
| --- | --- |
| Arial | Liberation Sans, Arimo, Roboto |
| Arial Bold | Arial-BoldMT, Liberation Sans Bold, Arimo Bold, Roboto Bold |
| Arial Italic | Arial-ItalicMT, Liberation Sans Italic, Arimo Italic, Roboto Italic |
| Arial Bold Italic | Arial-BoldItalicMT, Liberation Sans Bold Italic, Arimo Bold Italic, Roboto Bold Italic |
| Courier New | Liberation Mono, Cousine |
| Courier New Bold | CourierNewPS-BoldMT, Liberation Mono Bold, Cousine Bold |
| Times New Roman | Liberation Serif, Tinos |
| Times New Roman Bold | TimesNewRomanPS-BoldMT, Liberation Serif Bold, Tinos Bold |
A bold or italic cut is listed by its own names: local() matches one face,
by its full or PostScript name, never a family.
- Only a
srcthat is onelocal()inside@font-faceis rewritten, its name matched as a browser matches it, ignoring case; a downloaded file or a list stays as written, so the plugin never widens a face twice. metricTwins({ twins: { Helvetica: ['Helvetica', 'Arial'] } })adds a font or replaces a default one.- The plugin is typed on what it reads, so an app needs no
postcssdependency of its own: Vite brings it.
audit
The rules nobody should have to remember, checked in a test that reads every stylesheet and every piece of markup:
import { globSync, readFileSync } from 'node:fs'
import { join } from 'node:path'
import { REACT_ARIA_TOKENS } from '@adrienlcp/react-aria'
import {
findFallbackBandFailures,
findFallbackFailures,
findFontAttributeFailures,
findTokenFailures,
findTypeLiterals,
findUnitFailures,
findUnnamedValues,
findUnreadFontFaces,
webFontFamilies
} from '@adrienlcp/styles/audit'
import { describe, expect, it } from 'vitest'
const SOURCE_FOLDER = join(import.meta.dirname, '../..')
const findSourceFiles = (pattern: string) => globSync(pattern, { cwd: SOURCE_FOLDER })
const readSourceFile = (path: string) => readFileSync(join(SOURCE_FOLDER, path), 'utf8')
const STYLESHEETS = findSourceFiles('**/*.{sass,css}')
const MARKUP = findSourceFiles('**/*.{tsx,svg}')
const STYLESHEET_SOURCES = STYLESHEETS.map(readSourceFile)
const SOURCES = findSourceFiles('**/*.{sass,css,ts,tsx}').map(readSourceFile)
const WEB_FONTS = webFontFamilies(STYLESHEET_SOURCES)
it('[audit] finds the stylesheets it audits', () => {
expect(STYLESHEETS).not.toEqual([])
})
describe.each(STYLESHEETS)('%s', (path) => {
const stylesheet = readSourceFile(path)
it('[units] sizes text, spacing and boxes in rem', () => {
expect(findUnitFailures(stylesheet)).toEqual([])
})
it.skipIf(path.endsWith('_typography.sass'))('[voice] takes its text voice from the typography mixins', () => {
expect(findTypeLiterals(stylesheet)).toEqual([])
})
it('[names] takes its radii, durations, text sizes and insets from tokens', () => {
expect(findUnnamedValues(stylesheet)).toEqual([])
})
it('[fonts] writes every face so the audit reads its family', () => {
expect(findUnreadFontFaces(stylesheet)).toEqual([])
})
it('[fonts] names the fallback face after every web font in a font token', () => {
expect(findFallbackFailures(stylesheet, WEB_FONTS)).toEqual([])
})
})
describe.each(MARKUP)('%s', (path) => {
it('[fonts] sets text in a font token, never a family by name', () => {
expect(findFontAttributeFailures(readSourceFile(path), STYLESHEET_SOURCES)).toEqual([])
})
})
it('[fonts] draws a fallback face for every weight and style it sets, and none it does not', () => {
expect(findFallbackBandFailures(STYLESHEET_SOURCES)).toEqual([])
})
it('[tokens] reads only custom properties that exist, under their one shared name', () => {
expect(findTokenFailures(SOURCES, { provided: REACT_ARIA_TOKENS })).toEqual([])
})REACT_ARIA_TOKENS comes from @adrienlcp/react-aria; an app without
react-aria leaves the options out. The folder is the test's own, never the
working directory: a monorepo whose vitest runs from the root would otherwise
audit nothing.
One tag per check, the same in this package's tests and in an app's, so
pnpm test -- -t fonts runs every font check: [units] for
findUnitFailures, [voice] for findTypeLiterals, [names] for
findUnnamedValues, [fonts] for the five font checks, [tokens] for
findTokenFailures, and [audit] for the test that guards the audit itself.
- Comments are never read, in a stylesheet or a script; a
//inside a string or aurl()is no comment. findUnitFailuresreadsfont-size,font,margin*,padding*, the gaps,text-indent,width,heightand their logical,min-andmax-forms,inset*,top,right,bottom,left,translate,flex-basis, the grid tracks (grid-template-columns,grid-auto-rows, …), thetranslate*()functions of atransform, and every custom property. A failure ispixels— any non-zeropxvalue there,clamp()bounds andcalc()terms included — orviewport-without-rem: a text size driven byvw,vh,vminor a container unit with no rem part, which zoom cannot enlarge. It also listsch, a size, a grid track or a custom property inch— the width of the font's zero, which moves when the web font replaces the fallback —,fractional-outline, anoutline,outline-widthor--outline-*width (the offset aside) that is not a whole number of pixels; a border and a--stroke-*may be fractional. Strokes, outlines, radii, shadows and the touch target stay in px: their properties and their--stroke-*,--outline-*,--radius-*,--shadow-*and--targettokens are not read forpixels.outline: var(--stroke-thin)passes: the token's own declaration is checked.- Pixels a script must match go through a
--*-pxcustom property, which passes: a board cell whose size the script computes in pixels is declared--cell-px: 24pxand read by the stylesheet (calc(var(--cell-px) * 8)) and by the script throughgetComputedStyle. The suffix says the pixels are wanted; any otherpxcustom property ispixels. - A surface sized on the viewport — a scoreboard on a TV — derives its sizes
from a named unit token (
calc(var(--cu) * 4)), which passes. findTypeLiteralslists everyfont-weight,line-heightandletter-spacingwritten as a number or a keyword: a text voice is declared once, in a mixin, and a component includes it.inheritandvar()pass.findUnnamedValueslists a radius (radius), a transition or animation duration or delay (duration) or afont-size(text-size) written as a literal: a radius is a--radius-*token, a duration is written over--transition-*, whichreduced-motion.csscollapses, a text size is a--text-*step.0and0spass; a font size passes when it reads avar()— a fitted floormax(var(--text-s), 7cqi), a unit tokencalc(var(--cu) * 4)—, keeps the parent's size (1em,100%) or is a keyword. It also lists a rawenv(safe-area-inset-*)outside the--safe-area-*tokens, which carry its0pxfallback (safe-area), and a hand-writtenmax()over--safe-area-leftor--safe-area-right(gutter): a page's sides, and whatever sits against them, take--gutter-leftand--gutter-right. Amax()over the top or the bottom inset passes — a sticky bar or a bottom bar writes its own.webFontFamiliesreads the families the stylesheets self-host: everyfonts.font-faceandfonts.fallback-facesinclude, every@font-facewith aurl()source, and the family of a hand-written<family> fallbackface. A family held in a variable the same file assigns once is read, and so is one passed to a face mixin of the app's own — a@mixinwhose face takes its family as a parameter. A family loaded elsewhere — a@fontsourceimport — is added by hand.findUnreadFontFaceslists a face whose family (unread-family) or$widthsbands (unread-weights) the audit cannot read: built in an@eachloop, interpolated, a variable assigned twice, a map from another module, a loop variable of an@eachor@foraround the face — even one a variable of the file shares the name of —, or a face mixin of the same file included with a family it cannot read. Every font check then misses that family and passes in silence, so this one fails instead: write the faces out, one include per file.findFallbackFailureslists a custom property whose font stack names one of those web fonts without'<family> fallback'right after it (missing-fallback, with thefamily): fontaine appends its fallback face to afont-familydeclaration, never inside a custom property, so the token paints in an unscaled system font until the font arrives — for good underfont-display: optionalwhen it misses. A web font listed after a fallback face is a glyph backup the fallback always shadows, and passes.findFallbackBandFailurestakes every stylesheet and checks the bandsfallback-facesdraws — fontaine's own faces copy every@font-face, and are not read — against the weights the stylesheets set, a literal, a keyword or a token:uncovered-weight, afont-weightno band of the family set in the same block covers — with no family there, no band of any family —, which paints in a face scaled for another weight;uncovered-style, a family that serves italic files with no italic band, so italic text in the fallback takes the upright face —emsets italic whether a stylesheet does or not;unused-band, a band no weight falls in,400always counted. A family is reported in lower case.findFontAttributeFailuresreads a.tsxor an.svgand lists afont-familyattribute, afontFamilyprop or style key, or an inlinefont-family:that names a family instead of a font token (font-attribute). SVG text takesvar(--font-…): a family named there gets no fallback face, and one the page does not serve falls back to serif. The SVG is inlined (?react): an SVG shown through<img>reads neither the page's fonts nor its tokens. Avar()fallback is a generic family or another font token:var(--font-letter, 'Georgia')names a family, and fails. Given the stylesheets, as in the example, a token must be declared there as a font stack — an undeclaredvar(--font-typo)paints in the browser's serif, andvar(--font-size-m)holds no family.inheritpasses; an expression —fontFamily={family}— is not read.findTokenFailurestakes every source — stylesheets and the scripts that set a property throughstyle— and lists a name read throughvar()but declared nowhere, not shared and notprovided(undeclared: a rename without an alias); a second size in a family the shared set holds in one (parallel: a--control-*,--outline-*,--ring-*or--target-*name whose suffix is a size step or a size word —--control-m,--control-touch,--outline-thin— or whose value is a bare length; a derived--target-reachor a colour--control-inkpasses); and a second name for a shared value (alias:--timing: cubic-bezier(0.16, 1, 0.3, 1)is--ease-out), compared only on values with a space, a comma or a function. A name built by interpolation —--g#{$n},--pawn-${n}— is not read. The namestokens.defaultsdeclares, and the palette names it reads, pass undeclared.
