/* rastrillo/ui — design tokens and the component classes the shipped partials use (see ui_test.go's TestAllPartialsAreDefined for the current, authoritative list — deliberately not counted here, so this comment cannot go stale as the set grows). rastrillo new writes this file once into your app's static/ directory. From then on it is yours: edit it, replace it, delete rules you do not want. Nothing regenerates it, and rastrillo.Serve never serves it — your app's own static handler does. Every class, attribute and custom property is prefixed rst- — except the single .icon rule, which styles the class rastrillo.Icon emits — so dropping this file into an app with existing CSS has exactly one known collision surface. There are no imports, no remote assets and no webfont: this stylesheet fetches nothing, from anywhere, ever. That is a test, not a wish (ui_test.go, TestTokensCSSIsSelfContained) — which is also why this comment cannot spell those constructs out literally. TWO SPELLINGS, ON PURPOSE, FOR ONE RELEASE. Every component rule below names its class and its attribute twin: `.rst-btn, [rst-btn]`, `.rst-btn--primary, [rst-btn~="primary"]`, `.rst-callout__body, [rst-callout-body]`. The attribute spelling is the vocabulary this library is moving to (design spec §6-v3); the class spelling is what every app and every shipped partial writes today. Pairing them here first is what makes the move non-breaking in both directions — this file is written into your static/ once and frozen there, while the partials upgrade with the module, so an app that has not refreshed its copy must keep styling either way. The grammar, so a rule you add follows it: the KIND is a bare attribute; a VARIANT is a token in that attribute's value, matched with ~= so `rst-btn="primary compact"` composes the way a class list does; a PART is its own flat attribute (`rst-callout-body`), never a value — the value slot means variant on every attribute or the grammar is unreadable; and rst-tone carries the four tones that data-tone carries today. Both members of a pair weigh exactly the same: a class and an attribute selector are both (0,1,0), so pairing changes no cascade anywhere. ui_test.go's TestEveryClassSelectorHasAnAttributeTwin holds all of that, including the specificity, and the browser drive proves the two spellings compute identical styles. Utilities keep the class spelling and are not paired: rst-sr-only, rst-mono, rst-m-hide, rst-grow, rst-nm, rst-danger, rst-cell-mut. They are not kinds — they are cross-cutting styling, which is what class is for, and they stay class after the move. Colour, the type family and shape are not here: they live in themes/day.css (or whichever theme was scaffolded), which rastrillo new writes beside this file as static/theme.css. This file is structure — layout, spacing, the type scale, and the component classes, every one of them painted with var(--rst-*). Swap the theme file and the app changes palette, corner and depth; nothing here is touched. Radius and shadow used to live in the scale block below. They moved to the themes in v2, because a theme that can only change colour cannot change how an app feels: day is soft (8px/6px, wide low-alpha shadows), plain is nearly square (4px/3px, one hairline layer) and signal is milled (4px/2px, short dense shadows). tokens.css still paints with var(--rst-radius*) and var(--rst-shadow-*) everywhere; it just no longer decides what they are. Padding values are load-bearing for SC 2.5.8 Target Size (AA, new in WCAG 2.2): pagination chips measure about 30px, row action pills about 27px, and the button sizes about 26px (sm), 34px (default) and 42px (lg) on their smaller axis, all clear of the 24px minimum. Do not shrink them without re-measuring; the button block shows the arithmetic. Type sizes are rem, not px, so the whole scale tracks a reader who has raised their browser's default font size. Spacing is rem for the same reason. Anything you add here should follow suit. ──────────────────────────────────────────────────────────────────── */ /* ── Scale: type and spacing. Theme-independent. Radius is not here — it is part of the theme's shape axis; see themes/. ────────────── */ :root { --rst-fs-lg: 1.0625rem; /* 17px — the one step above base */ --rst-fs-base: 0.875rem; /* 14px at the browser default */ --rst-fs-sm: 0.78125rem; /* 12.5px */ --rst-fs-xs: 0.71875rem; /* 11.5px */ --rst-sp-1: 0.25rem; --rst-sp-2: 0.5rem; --rst-sp-3: 0.75rem; --rst-sp-4: 1rem; --rst-sp-5: 1.5rem; --rst-sp-6: 2.5rem; /* The tap floor on small or touch screens: 44px at the default root, and it tracks a reader who raised their default size. Declared here so an app's own CSS can use it; only the touch query below reads it, so desktop density never sees it. */ --rst-tap: 2.75rem; /* The target floor in force, which every control's floor at the end of this file reads: 32px on a desktop with a mouse, raised to --rst-tap by the touch query. One token rather than a second copy of the floor block, so a control cannot be floored on one kind of screen and forgotten on the other. */ --rst-target: 2rem; /* The header strip at the top of a phone screen: the back strip on a content page, the index's header row, and the bar of a shell that has one. 56px at the default root, a mobile app's top bar, with the 44px target centred in it: the strip at the bare tap height read as cramped beside every native header. Border included, so the strip and the scroll padding that keeps a #fragment's heading out from under it are the same number, and a strip resized here cannot leave a heading half hidden. */ --rst-shell-strip: 3.5rem; /* The list grid's kebab column. A variable rather than the literal 32px the samples used to write, so the column can grow to a 44px kebab on touch screens (the touch block redefines it). An app that keeps a literal 32px still works: the kebab is justify-self: end, so it overflows 12px toward the inline start, into the grid's 0.85rem gap, without reaching the cell beside it. */ --rst-col-menu: 32px; /* The scrollbar's gutter, reserved whether or not there is a scrollbar in it, so a short page and a long one are the same width and clicking between them does not slide the layout sideways. A token rather than a class for three reasons: the opt-out is one line in your own stylesheet (--rst-scrollbar-gutter: auto), it layers like every other token, and custom properties are untouched by the markup migration, so this survives it without an edit. What it costs, chosen knowingly: a page too short to scroll now reserves the strip as well, so there is a thin empty band at the trailing edge where there was none. That is the price of never shifting. both-edges would double the band to keep centred content exactly centred, and was not taken: this system's pages are already a max-width column inside a wider ground, so the asymmetry does not land where it would be seen. Do not over-claim what it fixes. macOS overlay scrollbars take no layout space at all, so on a default Mac there was never anything to shift. This is real on Windows and Linux, on a Mac set to "always show scrollbars", and inside the design gallery's own preview iframes. Chrome 94+, Firefox 97+, Safari 18.2+; an older engine ignores the declaration and lands on the behaviour it has today. */ --rst-scrollbar-gutter: stable; /* The light. light.js sets these from the hour (its FOLLOW flag, off, would ease them toward the pointer); without it they hold straight-above, which is the static design. Shadows fall away from the light by up to 1.5px across and 1.2px down; the top highlight leans toward it by up to 1px. */ --rst-light-x: 0; --rst-light-y: -1; --rst-shadow-x: calc(var(--rst-light-x) * -1.5px); --rst-shadow-y: calc(1px + (var(--rst-light-y) + 1) * 0.6px); --rst-hl-x: calc(var(--rst-light-x) * 1px); } /* ── Small or touch screens: the type scale moves up one step ─────── One query decides "a phone, a tablet, or a window too narrow to read at desktop density", and every touch-only rule in this file sits in it. pointer: coarse is the PRIMARY pointer, so a touchscreen laptop driven by its trackpad keeps desktop density; any-pointer would switch a mouse user's whole interface because a touchscreen exists. max-width: 40rem catches a narrow desktop window and the design gallery's 390px Mobile tab, a fine pointer showing what a phone shows (a rem in a media query is the browser's initial size, not the root's). The shells' 800px is a different axis: between 640 and 800px under a mouse is a split window, which gets the narrow LAYOUT and keeps mouse DENSITY. The four tokens move and nothing else: the root size, the rem spacing and every px geometry stay, and controls grow because their text grows and because the floor block at the end of this file gives them a minimum box. Display sizes (page titles, stat numbers, avatar initials) stay literal on purpose, so a 22px title sits over 16px body text. ─────────────────────────────────────────────────────── */ @media (pointer: coarse), (max-width: 40rem) { :root { --rst-fs-lg: 1.1875rem; /* 19px */ --rst-fs-base: 1rem; /* 16px */ --rst-fs-sm: 0.875rem; /* 14px */ --rst-fs-xs: 0.8125rem; /* 13px */ } } /* ── Base. Three rules, so a freshly scaffolded app looks finished before it has any CSS of its own. Delete them if your app owns its own base styles. ──────────────────────────────────────────── */ /* The gutter is declared on the root element, which is where the viewport's scrollbar lives. It also fixes a second instance of the same bug that nobody had attributed to the scrollbar: the modal's scroll lock below (body:has(.rst-backdrop) { overflow: hidden }) takes the scrollbar away the instant a modal opens, and without a reserved gutter the whole page slides sideways while the reader watches. One declaration, both cases. */ html { scrollbar-gutter: var(--rst-scrollbar-gutter); } body { margin: 0; background: var(--rst-bg); color: var(--rst-text); font-family: var(--rst-font); font-size: var(--rst-fs-base); line-height: 1.5; } /* The content column every screen sits in. Your page markup emits this; no partial does. 64rem, widened from 52rem after the v2 page went live: 52rem is a reading measure, and this column holds list grids and side-by-side form fields, which a reading measure squeezes. */ .rst-page, [rst-page] { box-sizing: border-box; margin: 0 auto; max-width: 64rem; padding: var(--rst-sp-5) var(--rst-sp-4) var(--rst-sp-6); } /* rastrillo.Icon emits class="icon" with no width or height, so the icon sizes from here and tracks the text beside it: an icon in a heading is the heading's size, one in a line of prose the line's. */ .icon { block-size: 1em; flex: none; inline-size: 1em; } /* Inside a control an icon is 16px, whatever text sits beside it. Sized from the font, icons in controls came out anywhere from 9 to 20px (a caret in a small summary, a menu icon in a big button), so two icons in one toolbar disagreed. The controls are the ones the target floor at the end of this file holds; a control added there belongs here too. :where() keeps this at .icon's own weight, so a component that sizes its icon (the 12px caret, .rst-caret) still wins. Not the list grid's title: it is the row's name, text that an icon should follow, and the row is its target. */ :where(.rst-btn, .rst-search, .rst-search__clear, .rst-ftok a, .rst-help, .rst-dropdown > summary, .rst-dropdown__menu a, .rst-dropdown__menu button, .rst-dropdown__menu .rst-menu-group > summary, .rst-row-menu__panel a, .rst-row-menu__panel button, .rst-locale button, .rst-scheme button, .rst-combo__option, .rst-dtp__row, .rst-dtp__pick, .rst-cal__nav, .rst-row__action, .rst-row-menu > summary, .rst-pagination > a, .rst-seg-tabs a, .rst-switch, .rst-choice__cards label, .rst-tblock__head, .rst-bulkbar__close, .rst-bulkbar__escalate, .rst-modal-close, .rst-modal-panel > nav a, .rst-back-nav a, .rst-signin__providers > a, .rst-shell__brand, .rst-shell__nav a, .rst-shell__menu > summary, .rst-shell__chrome > summary, .rst-shell__account > summary, .rst-shell__back > a) .icon, :where([rst-btn], [rst-search], [rst-search-clear], [rst-ftok] a, [rst-help], [rst-dropdown] > summary, [rst-dropdown-menu] a, [rst-dropdown-menu] button, [rst-dropdown-menu] [rst-menu-group] > summary, [rst-row-menu-panel] a, [rst-row-menu-panel] button, [rst-locale] button, [rst-scheme] button, [rst-combo-option], [rst-dtp-row], [rst-dtp-pick], [rst-cal-nav], [rst-row-action], [rst-row-menu] > summary, [rst-pagination] > a, [rst-seg-tabs] a, [rst-switch], [rst-choice-cards] label, [rst-tblock-head], [rst-bulkbar-close], [rst-bulkbar-escalate], [rst-modal-close], [rst-modal-panel] > nav a, [rst-back-nav] a, [rst-signin-providers] > a, [rst-shell-brand], [rst-shell-nav] a, [rst-shell-menu] > summary, [rst-shell-chrome] > summary, [rst-shell-account] > summary, [rst-shell-back] > a) .icon { block-size: 1rem; inline-size: 1rem; } /* Focus is visible everywhere inside the app column (.rst-page) and inside any component rendered outside one. :where() contributes no specificity, so any rule below can override it. */ :where(.rst-page, .rst-page-header, .rst-list, .rst-lbar, .rst-search, .rst-empty, .rst-pagination) :focus-visible, :where([rst-page], [rst-page-header], [rst-list], [rst-lbar], [rst-search], [rst-empty], [rst-pagination]) :focus-visible { outline: 2px solid var(--rst-accent); outline-offset: 2px; } /* A press is a number so a release can fade it: a gradient cannot transition, a registered number inside one can. */ @property --rst-press { syntax: ""; inherits: false; initial-value: 0; } /* The hover tint's own number, and the press's. A row, a tile and a rail item fade their tint in on hover; when that fade was a background-color transition, a scheme change while one was hovered retargeted it, and the one item under the pointer took a quarter of a second to reach the new scheme while its text changed at once (Astra A3). What eases now is a percentage: the tint, the lift's shadows and the press are re-resolved from it every frame, so the colours come from the current scheme and the flip lands on the next frame with no script's help. Two numbers, so a release still eases from the press tint back to the hover tint. Every rule that reads them sets both: without @property (Firefox before 128) an unset var() invalidates the whole declaration, and a hover that set only --rst-hover lost its background until pressed. */ @property --rst-hover { syntax: ""; inherits: false; initial-value: 0%; } @property --rst-pressed { syntax: ""; inherits: false; initial-value: 0%; } /* A card's light is registered so it does not inherit: unregistered, a write of --rst-card-x on a card of 500 rows restyled all 1,500 descendants, 16ms a frame while the pointer moved, and nothing below a card reads these. */ @property --rst-card-x { syntax: ""; inherits: false; initial-value: 50%; } @property --rst-card-y { syntax: ""; inherits: false; initial-value: 0%; } @property --rst-card-lit { syntax: ""; inherits: false; initial-value: 0; } /* ── Buttons: page-header's primary action, empty-state's CTA. ────── */ /* Every variant composes its own rest, hover, press, focus, disabled and busy from named tokens, in this order, at one specificity, so the cascade never decides a state. The press rules come last. Every token is read through a fallback that is today's value, so a theme written before the axis renders as it did. A filled button's hover reads its own hover fill: a filter on the fill scaled the label too, and an earlier hover rule once won the label back to the accent on the red fill at 1.05:1, at the moment a reader committed to a delete. The face is four background layers: the sheen, the press spot, the press wash, the face shade. The press is instant and the release fades over 0.7s through --rst-press; the strengths are per-scheme number tokens composed as rgb(0 0 0 / calc(press * strength)), because a color-mix() whose percentage carried calc(var()) blanked every button in Safari. The sheen is on while hovered and not pressed, and only where a pointer can hover. border-box because rst-btn goes on as often as on