| 1 | --- |
| 2 | name: Fiesta |
| 3 | description: A quiet, correct interface baseline built to be taken over. |
| 4 | colors: |
| 5 | bg: "#fbfbfc" |
| 6 | surface: "#ffffff" |
| 7 | surface-2: "#f3f3f5" |
| 8 | line: "#e6e6ea" |
| 9 | line-strong: "#868691" |
| 10 | text: "#18181b" |
| 11 | text-muted: "#52525c" |
| 12 | text-faint: "#6b6b75" |
| 13 | accent: "#1f56d6" |
| 14 | accent-strong: "#1844ac" |
| 15 | accent-soft: "#ecf1ff" |
| 16 | on-accent: "#ffffff" |
| 17 | accent-fill: "#2257df" |
| 18 | on-accent-fill: "#ffffff" |
| 19 | danger-fill: "#c62f2f" |
| 20 | control-border: "#d0d4dc" |
| 21 | tone-neutral-fg: "#374151" |
| 22 | tone-neutral-bg: "#eef0f2" |
| 23 | tone-positive-fg: "#166534" |
| 24 | tone-positive-bg: "#dcfce7" |
| 25 | tone-warning-fg: "#854d0e" |
| 26 | tone-warning-bg: "#fef3c7" |
| 27 | tone-negative-fg: "#b91c1c" |
| 28 | tone-negative-bg: "#fee2e2" |
| 29 | typography: |
| 30 | display: |
| 31 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 32 | fontSize: "2.1rem" |
| 33 | fontWeight: 650 |
| 34 | lineHeight: 1.1 |
| 35 | headline: |
| 36 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 37 | fontSize: "1.25rem" |
| 38 | fontWeight: 600 |
| 39 | lineHeight: 1.25 |
| 40 | letterSpacing: "-0.012em" |
| 41 | title: |
| 42 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 43 | fontSize: "0.875rem" |
| 44 | fontWeight: 600 |
| 45 | lineHeight: 1.4 |
| 46 | body: |
| 47 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 48 | fontSize: "0.875rem" |
| 49 | fontWeight: 400 |
| 50 | lineHeight: 1.6 |
| 51 | label: |
| 52 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 53 | fontSize: "0.71875rem" |
| 54 | fontWeight: 650 |
| 55 | letterSpacing: "0" |
| 56 | textTransform: "none" |
| 57 | field: |
| 58 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 59 | fontSize: "1.0625rem" |
| 60 | fontWeight: 550 |
| 61 | stat: |
| 62 | fontFamily: "system-ui, -apple-system, \"Segoe UI\", Roboto, \"Helvetica Neue\", Arial, sans-serif" |
| 63 | fontSize: "1.5rem" |
| 64 | fontWeight: 650 |
| 65 | lineHeight: 1.1 |
| 66 | mono: |
| 67 | fontFamily: "ui-monospace, SFMono-Regular, Menlo, Consolas, monospace" |
| 68 | fontSize: "0.78125rem" |
| 69 | rounded: |
| 70 | bar: "2px" |
| 71 | sm: "4px" |
| 72 | md: "6px" |
| 73 | pill: "999px" |
| 74 | spacing: |
| 75 | "1": "0.25rem" |
| 76 | "2": "0.5rem" |
| 77 | "3": "0.75rem" |
| 78 | "4": "1rem" |
| 79 | "5": "1.5rem" |
| 80 | "6": "2.5rem" |
| 81 | components: |
| 82 | button: |
| 83 | backgroundColor: "{colors.surface}" |
| 84 | textColor: "{colors.text}" |
| 85 | typography: "{typography.title}" |
| 86 | rounded: "{rounded.sm}" |
| 87 | padding: "0.45rem 0.75rem" |
| 88 | button-primary: |
| 89 | backgroundColor: "{colors.accent-fill}" |
| 90 | textColor: "{colors.on-accent-fill}" |
| 91 | typography: "{typography.title}" |
| 92 | rounded: "{rounded.sm}" |
| 93 | padding: "0.45rem 0.75rem" |
| 94 | button-primary-hover: |
| 95 | backgroundColor: "#1d4dc8" |
| 96 | textColor: "{colors.on-accent-fill}" |
| 97 | input: |
| 98 | backgroundColor: "{colors.surface}" |
| 99 | textColor: "{colors.text}" |
| 100 | rounded: "{rounded.sm}" |
| 101 | padding: "0.45rem 0.6rem" |
| 102 | card: |
| 103 | backgroundColor: "{colors.surface}" |
| 104 | textColor: "{colors.text}" |
| 105 | rounded: "{rounded.md}" |
| 106 | padding: "1.1rem 1.25rem" |
| 107 | status-pill: |
| 108 | backgroundColor: "{colors.tone-neutral-bg}" |
| 109 | textColor: "{colors.tone-neutral-fg}" |
| 110 | rounded: "{rounded.pill}" |
| 111 | padding: "0.15rem 0.5rem" |
| 112 | --- |
| 113 | |
| 114 | # Design System: Fiesta |
| 115 | |
| 116 | <!-- |
| 117 | Scope note, because this system has three themes and two colour schemes. |
| 118 | The frontmatter carries the `day` theme's LIGHT values, because |
| 119 | fiesta.ThemeNames()[0] is `day` and it is the theme fiesta.css carries. |
| 120 | The normative source in the repository is one custom property per colour |
| 121 | holding a light-dark() pair, so a single-value frontmatter slot cannot |
| 122 | hold it; .impeccable/design.json carries both halves per token under |
| 123 | extensions.colorMeta. Do not treat a hex here as the only value. |
| 124 | Source of truth: css/tokens.css (structure) and css/themes/*.css (colour, |
| 125 | type family, shape). |
| 126 | --> |
| 127 | |
| 128 | ## Overview |
| 129 | |
| 130 | **Creative North Star: "A Default With a Point of View"** |
| 131 | |
| 132 | This is the interface an application has before anyone has styled it, |
| 133 | and it should look like someone chose it. `day` is near-greyscale with |
| 134 | hairline borders, one cobalt for anything that acts, crisp corners, and |
| 135 | natural depth: light comes from above, cards rest on the page, controls |
| 136 | press in and slowly lift. It aims to read as professional, in the family |
| 137 | of the tools people already trust, and to be recognisably this system's. |
| 138 | |
| 139 | Remixability is still the thesis. Every colour, radius, shadow, |
| 140 | highlight and strength resolves through a custom property, and the three |
| 141 | shipped themes prove the axes work rather than offering a menu of three |
| 142 | answers: `day` turns depth on, `plain` and `signal` turn it off and |
| 143 | render as they always did. An app that wants a fourth writes one `:root` |
| 144 | block. Files are delivered once and app-owned from that moment: edit |
| 145 | them, or delete what you do not use. |
| 146 | |
| 147 | The system is deliberately under-designed at the component level so an |
| 148 | app's own character can land on top without a fight. It is explicitly |
| 149 | not the SaaS gradient template — no purple-to-blue washes, glassmorphism, |
| 150 | oversized rounded cards or decorative blobs — and explicitly not a |
| 151 | vendor-branded admin kit that stamps someone else's identity on an app |
| 152 | and resists removal. Both hand you an identity you did not choose, which |
| 153 | is the one thing this system exists not to do. |
| 154 | |
| 155 | **Key Characteristics:** |
| 156 | |
| 157 | - Small, dense type: a 14px base with 12.5px and 11.5px steps below it. |
| 158 | - Near-flat surfaces separated by 1px hairlines; depth is a theme axis. |
| 159 | - One accent, carrying action and position; status carries its own |
| 160 | four-tone palette. |
| 161 | - Every colour is a `light-dark()` pair, so light and dark are one |
| 162 | declaration rather than two stylesheets. |
| 163 | - The CSS floor is Chrome 123, Safari 17.5, Firefox 121, set by `light-dark()`; anything at or below it is used with no fallback. |
| 164 | - Works with JavaScript off. Scripts are enhancement and each is |
| 165 | deletable on its own. |
| 166 | |
| 167 | ## Colors |
| 168 | |
| 169 | A restrained near-neutral palette with a single working blue, held to |
| 170 | WCAG 2.2 AA by a contrast gate over documented pairs in every theme and |
| 171 | both schemes. |
| 172 | |
| 173 | ### Primary |
| 174 | |
| 175 | - **Interface Blue** (`#1f56d6` light, `#7aa2ff` dark): the accent, a |
| 176 | cobalt. It marks what you can act on and where you are: focus rings, |
| 177 | links, an active tab, the current menu item. It belongs to the chrome, |
| 178 | not to the content. |
| 179 | - **Accent Fill** (`#2257df` light, `#2f61e8` dark): the primary |
| 180 | button's fill, with a hover fill of its own (`#1d4dc8` light, |
| 181 | `#2956d0` dark) and a white label (`on-accent-fill`). A filled control |
| 182 | never darkens by filter: every state is a colour, gated. |
| 183 | - **Danger Fill** (`#c62f2f` light, `#c43232` dark): the danger button's |
| 184 | fill, the same way, with a white label. |
| 185 | - **Interface Blue Deep** (`#1844ac` light, `#a3c0ff` dark): kept for a |
| 186 | theme written before the fills; `tokens.css` reads it only as the |
| 187 | primary hover's fallback. |
| 188 | - **Interface Blue Wash** (`#ecf1ff` light, `#16234a` dark): the tint |
| 189 | behind a current menu item, and the fallback for the hover and press |
| 190 | tints in a theme that declares none. Carries no text of its own beyond |
| 191 | the accent and the muted greys, both gated against it. |
| 192 | |
| 193 | ### Neutral |
| 194 | |
| 195 | - **Page** (`#fbfbfc` light, `#0c0d0f` dark): the page ground, one step |
| 196 | below the surface, so a card reads as resting on it. |
| 197 | - **Surface** (`#ffffff` light, `#141518` dark): cards, panels, inputs |
| 198 | and menus. |
| 199 | - **Surface Sunken** (`#f3f3f5` light, `#18191d` dark): the recessed |
| 200 | step, for table header rows, the second tone in a stack and a hovered |
| 201 | ghost button. |
| 202 | - **Hairline** (`#e6e6ea` light, `#26282e` dark): every divider, card |
| 203 | edge and table rule. |
| 204 | - **Control Edge** (`#868691` light, `#71737c` dark): the border of a |
| 205 | field and a select. Deliberately much darker |
| 206 | than the hairline, and held at 3:1 against its backgrounds because it |
| 207 | is a control boundary (WCAG 1.4.11), not decoration. |
| 208 | - **Control Border** (`#d0d4dc` light, `#343843` dark): a plain button's |
| 209 | edge, softer than the field border on purpose. The label identifies |
| 210 | the button, so the edge is decoration under 1.4.11 and carries no 3:1 |
| 211 | floor. |
| 212 | - **Ink** (`#18181b` light, `#ededf0` dark): body text. |
| 213 | - **Ink Muted** (`#52525c` light, `#a1a1aa` dark): secondary text, |
| 214 | subheads, cell values that are not the row's name. |
| 215 | - **Ink Faint** (`#6b6b75` light, `#8c8c96` dark): hints, captions, |
| 216 | counts. Still gated at 4.5:1; "faint" is a role, not a licence to |
| 217 | drop below the floor. |
| 218 | |
| 219 | ### Tertiary |
| 220 | |
| 221 | The four status tones, each a foreground/background pair used together: |
| 222 | **Neutral** (`#374151` on `#eef0f2`), **Positive** (`#166534` on |
| 223 | `#dcfce7`), **Warning** (`#854d0e` on `#fef3c7`), **Negative** |
| 224 | (`#b91c1c` on `#fee2e2`). They exist so status never borrows the accent. |
| 225 | |
| 226 | ### Named Rules |
| 227 | |
| 228 | **The Gated Pair Rule.** A colour that carries text owes 4.5:1 against |
| 229 | the surface it actually sits on, and a control boundary owes 3:1 — and |
| 230 | owes it in every theme and both schemes, in the contrast gate, before it |
| 231 | ships. Adding a colour means adding its pair to that gate. A tone chosen |
| 232 | against a tinted ground says nothing about the same tone on a plain card. |
| 233 | |
| 234 | **The Two-Signal Rule.** State is never colour alone. A status pill |
| 235 | renders its label, a meter prints its fraction as text beside the bar, |
| 236 | and a stat's change carries its own `+` or `−` sign. Colour is the |
| 237 | second signal, never the first. |
| 238 | |
| 239 | **The Light-Dark Rule.** Every colour is declared once as |
| 240 | `light-dark(<light>, <dark>)` under `color-scheme: light dark`. There is |
| 241 | no dark stylesheet and no second palette to keep in step; an explicit |
| 242 | theme choice re-resolves the same declarations by setting |
| 243 | `color-scheme`. |
| 244 | |
| 245 | ## Typography |
| 246 | |
| 247 | **Display / Body Font:** the platform UI stack — `system-ui`, |
| 248 | `-apple-system`, `"Segoe UI"`, `Roboto`, `"Helvetica Neue"`, `Arial`, |
| 249 | `sans-serif`. `signal` leads the same stack with `"Helvetica Neue"`. |
| 250 | **Mono Font:** `ui-monospace, SFMono-Regular, Menlo, Consolas, |
| 251 | monospace`, on the `rst-mono` utility. It is a real second family and |
| 252 | not a weight of the first — reference codes, identifiers and machine |
| 253 | values are set in it so they read as things to be copied rather than |
| 254 | read. |
| 255 | |
| 256 | **Character:** the type has no personality of its own on purpose. The |
| 257 | family is a theme axis, and the shipped default is whatever the reader's |
| 258 | OS considers correct — which is the right answer for an application |
| 259 | frame and the wrong answer for a brand, so an app that wants a voice |
| 260 | sets one property. |
| 261 | |
| 262 | ### Hierarchy |
| 263 | |
| 264 | - **Display** (650, 2.1rem, 1.1): the lead reading in a stat band, and |
| 265 | nothing else. The only genuinely large type in the system. |
| 266 | - **Headline** (600, 1.25rem, 1.25, −0.012em in `day`; 1.375rem and |
| 267 | −0.015em in `plain` and `signal`): a screen's `<h1>` in the page |
| 268 | header. Capped at 1.25rem under 34rem. |
| 269 | - **Field** (550, 1.0625rem = 17px, inherited 1.5): the form's headline |
| 270 | input — a title, a name — and the only control that leaves the body |
| 271 | step. Its label stays at Label size, so the emphasis is carried by |
| 272 | the reader's own words rather than by the form's furniture. |
| 273 | - **Title** (600, 0.875rem, 1.4): section headings inside a card head, |
| 274 | button labels, a row's name. A section `<h2>` separates by weight, not |
| 275 | size: body-sized in `plain` and `signal`, a step below it (0.8125rem) |
| 276 | in `day`. |
| 277 | - **Body** (400, 0.875rem = 14px, 1.6): everything else. Subheads cap at |
| 278 | 44rem. |
| 279 | - **Label** (650, 0.71875rem = 11.5px): the eyebrow over a value, such |
| 280 | as stat labels, table column heads and the nav rail's section names. |
| 281 | Sentence case with no tracking in `day`; uppercase at +0.06em in |
| 282 | `plain` and `signal`. |
| 283 | |
| 284 | ### Steps outside the roles |
| 285 | |
| 286 | Four literal sizes appear in `tokens.css` outside the roles above, and |
| 287 | they are recorded rather than tidied away — two have since become roles, |
| 288 | which is what recording them was for: |
| 289 | |
| 290 | - **1.5rem** — a stat's number when it is not the lead. A role now |
| 291 | (`stat`), because a dashboard uses it as one. |
| 292 | - **1.25rem**: the headline's cap under 34rem, and `day`'s headline at |
| 293 | every width. A step of `headline` rather than a size of its own. |
| 294 | - **1.0625rem** — a role now (`field`, and `--rst-fs-lg`), because a |
| 295 | form's headline input reads as one. The large person avatar's initials |
| 296 | and the shell's brand land on the same value and stay literals: they |
| 297 | are sized to fit a circle, so the match is a coincidence rather than a |
| 298 | shared step, and tying them to the type scale would move them the next |
| 299 | time the scale moves. |
| 300 | - **0.6875rem** — the ordinary person avatar's initials. |
| 301 | |
| 302 | That last one is decoration sized to fit a circle, not type a reader |
| 303 | parses, which is why it is not a role. |
| 304 | |
| 305 | ### Named Rules |
| 306 | |
| 307 | **The Small-Base Rule.** The base is 14px, not 16px, and every step is |
| 308 | declared in `rem` so the whole scale tracks a reader who has raised |
| 309 | their browser default. Do not convert any of it to `px`. |
| 310 | |
| 311 | **The Weight-Not-Size Rule.** Hierarchy inside a screen is carried by |
| 312 | weight and colour before size. A card's `<h2>` is body-sized at 600; |
| 313 | reaching for a larger size to make a section feel important is how this |
| 314 | system loses its density. |
| 315 | |
| 316 | The one exception is a form's headline field (the Field role above), |
| 317 | and it is an exception because of what is being sized. Everywhere else |
| 318 | the rule governs *chrome* — a heading the system wrote, labelling |
| 319 | content underneath it — and chrome has no business growing. A title |
| 320 | input is the opposite case: the large thing is what the reader typed, |
| 321 | which is the only content on a form that is not furniture. The |
| 322 | exception is deliberately narrow, one control on one form; a second |
| 323 | large field on the same screen is the density loss this rule is about. |
| 324 | |
| 325 | **The Grouped Number Rule.** Every number a person reads as a quantity is |
| 326 | grouped for their locale — `54,173` in English, `54.173` in German, |
| 327 | `٥٤٬١٧٣` in Arabic. Never a bare run of digits. Grouping is not "add a |
| 328 | comma": the separator, the group size and the digits themselves all |
| 329 | change with the locale, and a number's readability is the whole reason |
| 330 | the rule exists — `54173` is counted, `54,173` is read. |
| 331 | |
| 332 | The exception is exact and small: **an identifier is not a quantity.** |
| 333 | Reference codes, order numbers, years, versions and port numbers are |
| 334 | labels that happen to be made of digits, and grouping one changes what it |
| 335 | appears to be. Order 4471 is not order 4,471. |
| 336 | |
| 337 | The rule is met. `{{number $L .Count}}` groups a quantity and `{{money $L .Amount .Currency}}` writes money, both on the server, in the reader's language, from CLDR data. In the browser, `Intl` is at most an enhancement. |
| 338 | |
| 339 | Money has two rules of its own. The currency always comes from the data, never from the reader: an amount is stored as a whole number of its currency's smallest unit, with its ISO 4217 code beside it, so a price in euros is in euros on every page. And every digit of that smallest unit is shown: 1.5 dinars is `KWD 1.500`, never `KWD 1.5`. |
| 340 | |
| 341 | Each language's formatting data is a pack an app links only when it offers that language, so a language costs nothing until then. Templates are built once, and the localizer in each page's data chooses the language, so one request's language never reaches another's page. |
| 342 | |
| 343 | ## Layout |
| 344 | |
| 345 | A single centred content column, capped at **64rem** with `1.5rem 1rem |
| 346 | 2.5rem` of padding, holding every screen. The column is markup an app |
| 347 | emits (`rst-page`); no partial brings its own. |
| 348 | |
| 349 | The column ends with a footer row (`rst-page-foot`) for the language and colour-scheme menus: muted, under a hairline, with the menus at the inline end. The page header stays the first thing on screen. |
| 350 | |
| 351 | **Spacing** is a six-step rem scale — 0.25 / 0.5 / 0.75 / 1 / 1.5 / |
| 352 | 2.5rem — and screens stack vertically in reading order: page header, |
| 353 | then section head plus card, then the next. Horizontal arrangement is |
| 354 | reserved for the idioms that ship it (card heads, field rows, toolbars, |
| 355 | list-row cells, segmented tabs, stat bands). |
| 356 | |
| 357 | **Breakpoints.** 800px is the system's real line: `rst-m-hide` columns |
| 358 | fold away, the sidebar rail collapses into a disclosure, and the topbar |
| 359 | takes its narrow layout. 34rem (544px) stacks the page header's title |
| 360 | and action. **320px is a written commitment** — the layout reflows to it |
| 361 | without a horizontal scrollbar. |
| 362 | |
| 363 | **Density** is high by intent: 14px base, 0.45rem control padding, rows |
| 364 | that fit a working screenful. The floor on that density is WCAG 2.2's |
| 365 | Target Size (2.5.8) — pagination chips measure about 30px, row-action |
| 366 | pills about 27px and buttons about 34px on their smaller axis, all clear |
| 367 | of the 24px minimum. Do not shrink them without re-measuring. |
| 368 | |
| 369 | The scrollbar gutter is reserved whether or not there is a scrollbar in |
| 370 | it (`--rst-scrollbar-gutter`), so a short page and a long one are the |
| 371 | same width and moving between them does not slide the layout sideways. |
| 372 | |
| 373 | ## Elevation & Depth |
| 374 | |
| 375 | Depth is a theme axis, and `day` turns it on. The light comes from |
| 376 | above: a 1px highlight along a raised edge (`--rst-depth-hl`), a short |
| 377 | contact shadow where an object meets the page (`--rst-depth-contact`), |
| 378 | soft ambient light around it (`--rst-depth-ambient`), a face shade for |
| 379 | filled controls (`--rst-depth-face`, 0.04 in `day`), and a 1px drop for |
| 380 | a pressed button (`--rst-press-drop`). Buttons are a touch proud of the |
| 381 | page and press in; cards rest on it; menus float, with tight elevation; |
| 382 | fields sit slightly into it. `plain` and `signal` set every one flat, |
| 383 | and a theme written before the axis is read through fallbacks that are |
| 384 | today's values. |
| 385 | |
| 386 | ### Shadow Vocabulary (the `day` theme) |
| 387 | |
| 388 | - **Pop** (`0 2px 4px light-dark(rgba(16,24,40,0.06), rgba(0,0,0,0.35)), |
| 389 | 0 8px 16px -4px light-dark(rgba(16,24,40,0.1), rgba(0,0,0,0.5))`): an |
| 390 | open menu, a dropdown panel, a modal: surfaces that float over the |
| 391 | page. Real elevation, kept tight. |
| 392 | - **Knob** (`0 1px 2px light-dark(rgba(16,24,40,0.18), rgba(0,0,0,0.4))`): |
| 393 | the moving part of a control, such as a switch's knob. |
| 394 | - **Lift** (`0 1px 2px light-dark(rgba(16,24,40,0.08), rgba(0,0,0,0.3))`): |
| 395 | the barely-there separation on a raised strip. |
| 396 | - **Overlay** (`light-dark(rgba(0,0,0,0.45), rgba(0,0,0,0.6))`): the |
| 397 | modal scrim. Deeper in dark on purpose: black reads lighter against a |
| 398 | dark backdrop and needs more alpha to still dim. |
| 399 | |
| 400 | ## The light |
| 401 | |
| 402 | `light.js` moves the light. The shadows' light comes from the hour: the |
| 403 | left in the morning, overhead at noon, the right in the evening, so |
| 404 | shadows lean away from it by up to 1.5px and the top highlight leans |
| 405 | toward it. With a mouse, a button catches a sheen where the pointer is |
| 406 | (white on a filled button, a faint accent tint on a plain one in light |
| 407 | mode), and a card's rim brightens near the pointer over about a second |
| 408 | and fades when it leaves. On a phone a pressed button shows the press, |
| 409 | not the sheen, and a tapped card catches the light briefly. The whole |
| 410 | page's light can follow the pointer instead: set `FOLLOW` to `true` at |
| 411 | the top of the file. It is off because every write of it restyles the |
| 412 | page. |
| 413 | |
| 414 | It does not run under reduced motion, does nothing under forced colours, |
| 415 | reads no sensor, and never captures the pointer. For the still design, |
| 416 | leave `light.js` out; nothing else asks for it. |
| 417 | |
| 418 | ## Shapes |
| 419 | |
| 420 | Soft rectangles, and the exact softness is a theme axis: `day` uses 6px |
| 421 | on cards and 4px on controls, `plain` 4px and 3px, `signal` 4px and 2px. |
| 422 | A pill radius (999px) is reserved for status pills and count chips — |
| 423 | things that are read as tokens rather than operated. |
| 424 | |
| 425 | A 2px radius is the thin-bar step, and it is not on the theme axis: the |
| 426 | capacity meter and a running job's progress bar both use it, at a height |
| 427 | of 4px where a themed 8px corner would swallow the bar whole. |
| 428 | |
| 429 | Borders do most of the structural work: 1px hairlines between and around |
| 430 | surfaces, and a distinctly darker 1px edge on anything operable. Focus |
| 431 | is a 2px accent outline offset 2px, never a border swap, so focus never |
| 432 | changes an element's size. |
| 433 | |
| 434 | **Known gap, recorded rather than smoothed over.** `tokens.css` also |
| 435 | hard-codes 9px, 7px, 4px, 10px and 14px radii in a handful of places — |
| 436 | the combobox and date-picker lists, and a few small chrome pieces — that |
| 437 | do not resolve through `var(--rst-radius*)`. A theme that changes its |
| 438 | corner language does not change those. It is a real limit on the |
| 439 | remixability this system claims, it predates this record, and it is not |
| 440 | something to fix by editing one component in passing. |
| 441 | |
| 442 | **The Derived Rule Rule.** The page header's underline is not authored |
| 443 | per theme: it is `color-mix(in oklab, var(--rst-accent) N%, var(--rst-line))`, |
| 444 | mixed at 45% in `signal`, and left as the plain hairline in `plain` and |
| 445 | in `day`, which keeps its cobalt for things that act. A theme that |
| 446 | changes its accent gets a matching rule with no second value to keep in |
| 447 | step. It is decorative and carries no contrast floor. |
| 448 | |
| 449 | ## Components |
| 450 | |
| 451 | ### Hover and press |
| 452 | |
| 453 | Hover is quiet and press is clear. A plain button's hover is a light |
| 454 | grey face and a darker border; a filled button's is a deeper fill; a |
| 455 | ghost's is a surface tint. Rows, menu items and sidebar items lift: a 5% |
| 456 | accent tint (9% in dark), a highlight along the top edge, no sheen. The |
| 457 | current sidebar item is a selected thumb, and hovering another is a |
| 458 | half-raised preview of it. These tints and lifts apply only on a |
| 459 | device that can hover, so a tap leaves no tint behind; the phone index |
| 460 | rows keep their tap tint on purpose. |
| 461 | |
| 462 | A press is instant and slow to leave. The button drops 1px, loses its |
| 463 | highlight and outer shadow for an inset one, switches its sheen off and |
| 464 | darkens, most under the pointer; on release the darkness lifts over |
| 465 | about 0.7s, the spot under the finger last. A row pressed, or its link |
| 466 | tapped, takes a stronger tint and sinks. Under reduced motion nothing |
| 467 | moves and nothing fades. |
| 468 | |
| 469 | ### Buttons |
| 470 | |
| 471 | - **Shape:** crisp (`{rounded.sm}`, 4px in `day`). |
| 472 | - **Default:** control face, control-border edge (softer than the field |
| 473 | border on purpose), Ink label at 600 and 12.5px, `0.45rem 0.75rem` |
| 474 | padding. Hover: a light grey face and a darker border. |
| 475 | - **Primary:** the accent fill with a 4% top-to-bottom shade and a white |
| 476 | label; hover deepens the fill. |
| 477 | - **Danger:** the danger fill, the same way. |
| 478 | - **Ghost:** no fill, muted label; hover a surface tint, with the label |
| 479 | darkening to Ink rather than picking up the accent. The accent means |
| 480 | "this goes somewhere new", and a ghost Cancel does not. |
| 481 | - **Press:** every variant sinks and darkens; see Hover and press. |
| 482 | - **Focus:** 2px accent outline, 2px offset. |
| 483 | |
| 484 | ### Cards / Containers |
| 485 | |
| 486 | - **Corner Style:** `{rounded.md}` (6px in `day`). |
| 487 | - **Background:** Surface, on a Page ground one step below it, with the |
| 488 | hairline painted as the outer stop of the rim gradient. To recolour a |
| 489 | card, set `--rst-surface` on it, which also recolours the surfaces |
| 490 | inside it; a `background` of your own paints over the hairline and a |
| 491 | `background-color` sits under the face. |
| 492 | - **Shadow Strategy:** at rest, a hairline and two ambient layers lit |
| 493 | from above; the rim catches the light near the pointer. Menus float |
| 494 | with real but tight elevation. |
| 495 | - **Internal Padding:** `1.1rem 1.25rem`. A card head (`rst-box-head`) |
| 496 | sits *outside* the card, holding an `<h2>` and one compact button. |
| 497 | - **Note:** list cards and data grids hold rows only and are unpadded by |
| 498 | design; prose, forms and links go in a padded box. |
| 499 | |
| 500 | ### Inputs / Fields |
| 501 | |
| 502 | - **Style:** Surface fill, 1px Control Edge (`--rst-line-strong`) |
| 503 | border, a 1px inset shadow so the field sits into the page, |
| 504 | `{rounded.sm}`, `0.45rem 0.6rem` padding, full width by default. |
| 505 | - **Focus:** the accent border, a 3px accent halo, and the 2px outline |
| 506 | offset 2px. |
| 507 | - **Error:** the message is text, and the field is described by it via |
| 508 | `aria-describedby`; the required marker uses the negative tone. |
| 509 | |
| 510 | ### Navigation |
| 511 | |
| 512 | The rail sits on the page background. The current item is a surface |
| 513 | thumb with a top highlight, a hairline ring and a contact shadow, marked |
| 514 | by `aria-current` in the markup; a hovered item is the thumb's |
| 515 | half-raised preview. Below 800px the rail collapses into a |
| 516 | `<details>` disclosure with a menu icon. Menus are |
| 517 | `<details name="rst-menus">`, so opening one closes the rest with no |
| 518 | script; `fiesta.js` adds outside-click and Escape as enhancement. |
| 519 | |
| 520 | **The Row Rule.** A dropdown never sits on a line of its own. Put it in a row, beside what it acts on: a page header, a section head, a list's toolbar, a bar, or the footer row. A menu above a page's title pushes the title down and reads as the page's first action. |
| 521 | |
| 522 | ### Status Pill |
| 523 | |
| 524 | A pill-radius tinted chip carrying a foreground/background tone pair and |
| 525 | **always its label**. The leading dot is decoration on top of the word, |
| 526 | never a substitute for it. |
| 527 | |
| 528 | ### Stat Band (signature) |
| 529 | |
| 530 | The instrument strip a dashboard opens with: a row of stat cells in one |
| 531 | card, divided by inline-start hairlines, wrapping rather than squeezing |
| 532 | so any number of cells works. One cell may be marked `lead`, which is |
| 533 | the same component at 2.1rem instead of 1.5rem — there is no separate |
| 534 | headline component. Numbers are `tabular-nums` so a polled figure does |
| 535 | not jitter its neighbours. A change carries its own sign in the text and |
| 536 | a tone the caller supplies; the tone is never derived from the sign, |
| 537 | because a falling number is good about as often as it is bad. |
| 538 | |
| 539 | ## Do's and Don'ts |
| 540 | |
| 541 | ### Do: |
| 542 | |
| 543 | - **Do** change the system by setting custom properties. Every colour, |
| 544 | radius, shadow and type family resolves through one; a fourth theme is |
| 545 | a single `:root` block. |
| 546 | - **Do** declare colours as `light-dark(<light>, <dark>)` pairs under |
| 547 | `color-scheme: light dark`, so light and dark stay one declaration. |
| 548 | - **Do** add any new colour that carries text to the contrast gate, at |
| 549 | 4.5:1 against the surface it actually sits on, in all three themes and |
| 550 | both schemes. |
| 551 | - **Do** give state a second signal besides colour — a label, a sign, a |
| 552 | number as text. |
| 553 | - **Do** group the digits of any number a person reads as a quantity, for |
| 554 | their locale — and never group an identifier, a year or a version. |
| 555 | - **Do** keep type in `rem` and the base at 14px, so the scale tracks a |
| 556 | reader's own browser setting. |
| 557 | - **Do** use logical properties (`border-inline-start`, |
| 558 | `margin-block-end`) so borders and dividers land correctly in Arabic. |
| 559 | - **Do** let the scriptless rendering be the real one; add script only as |
| 560 | enhancement that can be deleted. |
| 561 | |
| 562 | ### Don't: |
| 563 | |
| 564 | - **Don't** reach for the SaaS gradient template — purple-to-blue |
| 565 | washes, glassmorphism, oversized rounded cards, decorative blobs. |
| 566 | - **Don't** stamp a vendor kit's identity onto an app. This system is |
| 567 | under-designed on purpose so the app's own character can land on top. |
| 568 | - **Don't** use the accent as decoration or mood. It marks what you can |
| 569 | act on and where you are; status has its own four tones. |
| 570 | - **Don't** float a resting surface. A card rests on the page with the |
| 571 | theme's ambient light; a pop shadow means something has left the page |
| 572 | plane. |
| 573 | - **Don't** make a section feel important by making it bigger. Weight and |
| 574 | colour carry hierarchy before size; a card's `<h2>` is body-sized. |
| 575 | - **Don't** shrink control padding without re-measuring against WCAG |
| 576 | 2.2's 24px target-size minimum. |
| 577 | - **Don't** compose a heading, a paragraph and a button side by side in a |
| 578 | flex row. Screens stack vertically; horizontal arrangement is reserved |
| 579 | for the idioms that ship it. |
| 580 | - **Don't** put a dropdown on a line of its own, or above a page's title. Put it in a row beside what it acts on. |
| 581 | |