| 1 | /* signal — graphite and one live cobalt. Structure lives in tokens.css; |
| 2 | this file is colour, type and shape. |
| 3 | |
| 4 | ── Direction ───────────────────────────────────────────────────────── |
| 5 | THESIS: contrast is spent, not sprinkled. One cobalt marks the place a |
| 6 | decision is being made and nothing else borrows it. The arrangement |
| 7 | this refuses is the default product-dashboard palette — mid-grey text, |
| 8 | an accent tint smeared across every surface, and pillowy corners that |
| 9 | make a destructive button look like a suggestion. |
| 10 | OWN-WORLD: an anodised instrument faceplate. Cool graphite neutrals |
| 11 | with a blue undertone, silk-screened labels, one cobalt indicator that |
| 12 | reads as powered. Corners are cut, not moulded — 4px and 2px, the |
| 13 | radius of a milled edge. Shadows are short and dense: a panel seated |
| 14 | on a panel, never floating over it. Type is grotesk-first, the |
| 15 | workshop label face rather than a display serif. |
| 16 | MODE: operate. Assertive is not the same as loud — the page is calm |
| 17 | graphite until something is actionable, and then it is unmistakable. |
| 18 | RISK, stated: near-black plus one bright accent is a well-worn look, |
| 19 | and the way this earns its own ground is restraint plus the shape |
| 20 | axis, not a brighter blue. If a screen ends up with cobalt in four |
| 21 | places, the theme is being used against its grain. |
| 22 | FINISH: every pair in both schemes is measured below and gated by |
| 23 | ui/contrast_test.go; the direction above is the contract the palette |
| 24 | is audited against. |
| 25 | |
| 26 | ── One block, both schemes ─────────────────────────────────────────── |
| 27 | Every token is declared once, as light-dark(<light>, <dark>), under a |
| 28 | single :root that sets color-scheme: light dark. The two rules at the |
| 29 | bottom are the whole toggle: setting color-scheme on |
| 30 | :root[data-theme=…] re-resolves every light-dark() in the file at |
| 31 | once, in both directions. Tokens with one value in both schemes — the |
| 32 | font stack, the radii — are written plain. |
| 33 | |
| 34 | light-dark() is a colour function: it may only stand where a <color> |
| 35 | is expected. So a shadow token wraps its colour in it and writes the |
| 36 | geometry once, outside the call — `0 4px 10px light-dark(a, b)`, never |
| 37 | `light-dark(0 4px 10px a, …)`, which no browser would accept as a |
| 38 | box-shadow. |
| 39 | |
| 40 | ── WCAG 2.2 AA, measured ───────────────────────────────────────────── |
| 41 | Normal text needs 4.5:1; large text and UI component boundaries need |
| 42 | 3:1. Every pair below was computed from these exact hex values, in |
| 43 | both schemes. If you change a token, recompute its rows. |
| 44 | |
| 45 | LIGHT ratio requirement |
| 46 | --rst-text on --rst-surface 19.32 4.5:1 text |
| 47 | --rst-text on --rst-bg 17.72 4.5:1 text |
| 48 | --rst-text on --rst-surface-2 16.93 4.5:1 text |
| 49 | --rst-text on --rst-accent-soft 16.68 4.5:1 text |
| 50 | --rst-text-muted on --rst-surface 8.76 4.5:1 text |
| 51 | --rst-text-muted on --rst-bg 8.04 4.5:1 text |
| 52 | --rst-text-muted on --rst-surface-2 7.68 4.5:1 text |
| 53 | --rst-text-muted on --rst-accent-soft 7.57 4.5:1 text |
| 54 | --rst-text-faint on --rst-surface 6.13 4.5:1 text |
| 55 | --rst-text-faint on --rst-bg 5.62 4.5:1 text |
| 56 | --rst-text-faint on --rst-surface-2 5.37 4.5:1 text |
| 57 | --rst-text-faint on --rst-accent-soft 5.29 4.5:1 text |
| 58 | --rst-accent on --rst-surface 5.50 4.5:1 text + focus ring |
| 59 | --rst-accent on --rst-bg 5.04 4.5:1 text + focus ring |
| 60 | --rst-accent on --rst-surface-2 4.82 4.5:1 text |
| 61 | --rst-accent on --rst-accent-soft 4.75 4.5:1 text |
| 62 | --rst-on-accent on --rst-accent 5.50 4.5:1 button label |
| 63 | --rst-on-accent on --rst-accent-strong 7.77 4.5:1 button label, hover |
| 64 | --rst-line-strong on --rst-surface 4.70 3:1 control border |
| 65 | --rst-line-strong on --rst-bg 4.31 3:1 control border |
| 66 | --rst-line-strong on --rst-surface-2 4.12 3:1 control border |
| 67 | pill neutral fg on its own tint 8.36 4.5:1 text |
| 68 | pill positive fg on its own tint 6.07 4.5:1 text |
| 69 | pill warning fg on its own tint 6.21 4.5:1 text |
| 70 | pill negative fg on its own tint 6.00 4.5:1 text |
| 71 | --rst-on-accent on negative fg (fill) 7.58 4.5:1 danger button label |
| 72 | |
| 73 | DARK ratio requirement |
| 74 | --rst-text on --rst-surface 15.05 4.5:1 text |
| 75 | --rst-text on --rst-bg 16.30 4.5:1 text |
| 76 | --rst-text on --rst-surface-2 15.59 4.5:1 text |
| 77 | --rst-text on --rst-accent-soft 14.47 4.5:1 text |
| 78 | --rst-text-muted on --rst-surface 7.85 4.5:1 text |
| 79 | --rst-text-muted on --rst-bg 8.51 4.5:1 text |
| 80 | --rst-text-muted on --rst-surface-2 8.14 4.5:1 text |
| 81 | --rst-text-muted on --rst-accent-soft 7.55 4.5:1 text |
| 82 | --rst-text-faint on --rst-surface 6.58 4.5:1 text |
| 83 | --rst-text-faint on --rst-bg 7.13 4.5:1 text |
| 84 | --rst-text-faint on --rst-surface-2 6.82 4.5:1 text |
| 85 | --rst-text-faint on --rst-accent-soft 6.33 4.5:1 text |
| 86 | --rst-accent on --rst-surface 6.06 4.5:1 text + focus ring |
| 87 | --rst-accent on --rst-bg 6.56 4.5:1 text + focus ring |
| 88 | --rst-accent on --rst-surface-2 6.28 4.5:1 text |
| 89 | --rst-accent on --rst-accent-soft 5.82 4.5:1 text |
| 90 | --rst-on-accent on --rst-accent 6.79 4.5:1 button label |
| 91 | --rst-on-accent on --rst-accent-strong 9.77 4.5:1 button label, hover |
| 92 | --rst-line-strong on --rst-surface 4.65 3:1 control border |
| 93 | --rst-line-strong on --rst-bg 5.03 3:1 control border |
| 94 | --rst-line-strong on --rst-surface-2 4.81 3:1 control border |
| 95 | pill neutral fg on its own tint 9.26 4.5:1 text |
| 96 | pill positive fg on its own tint 9.07 4.5:1 text |
| 97 | pill warning fg on its own tint 10.00 4.5:1 text |
| 98 | pill negative fg on its own tint 7.84 4.5:1 text |
| 99 | --rst-on-accent on negative fg (fill) 9.25 4.5:1 danger button label |
| 100 | |
| 101 | The tightest pair in the set is --rst-accent on --rst-accent-soft in |
| 102 | light, at 4.75:1 against a 4.5:1 floor — the cobalt is as bright as it |
| 103 | can be and still be readable as text on its own tint. Lighten it and |
| 104 | the gate goes red; that is the intended tension. |
| 105 | |
| 106 | --rst-line is a hairline divider, not a control border, so 1.4.11's |
| 107 | 3:1 does not apply to it. Control borders use --rst-line-strong. |
| 108 | |
| 109 | --rst-header-rule is not in the table either, and deliberately so |
| 110 | (design doc §6-v2.2): the page header's rule is decoration, not |
| 111 | structure — the heading's size, weight and spacing carry the |
| 112 | structure — so no contrast floor applies to it and it is not in |
| 113 | ui/contrast_test.go's pair table. Do not argue it up to 3:1. |
| 114 | |
| 115 | --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and |
| 116 | --rst-overlay are not in the table: they paint a translucent layer |
| 117 | over whatever sits underneath, not text over a fixed background, so a |
| 118 | contrast ratio does not apply. |
| 119 | ──────────────────────────────────────────────────────────────────── */ |
| 120 | |
| 121 | :root { |
| 122 | color-scheme: light dark; |
| 123 | |
| 124 | /* Type: grotesk first, on purpose. The stack asks for the neutral |
| 125 | workhorse grotesks a machine actually has before it falls back to |
| 126 | whatever the platform calls its UI face. No webfont: a theme is one |
| 127 | stylesheet and must not open a connection. */ |
| 128 | --rst-font: "Helvetica Neue", Helvetica, "Segoe UI Variable Text", "Segoe UI", Roboto, Arial, ui-sans-serif, system-ui, sans-serif; |
| 129 | |
| 130 | /* Shape: milled, not moulded. */ |
| 131 | --rst-radius: 4px; |
| 132 | --rst-radius-sm: 2px; |
| 133 | --rst-radius-pill: 999px; |
| 134 | |
| 135 | /* Surfaces and lines: graphite with a blue undertone, so the cobalt |
| 136 | reads as the only warm-running thing on the panel. */ |
| 137 | --rst-bg: light-dark(#f4f5f8, #0b0d10); |
| 138 | --rst-surface: light-dark(#ffffff, #14171c); |
| 139 | --rst-surface-2: light-dark(#eef0f4, #101318); |
| 140 | --rst-line: light-dark(#dde0e7, #232830); |
| 141 | --rst-line-strong: light-dark(#6d7482, #7b828f); |
| 142 | |
| 143 | /* The page header's rule, derived rather than authored: the cobalt |
| 144 | folded into the line at 45%, so a theme that changes its accent |
| 145 | gets a matching rule with no second value to keep in step. signal |
| 146 | spends the tint it earns — the rule is visibly blue, which is the |
| 147 | loudest this theme is anywhere it is not asking for a click. |
| 148 | Decorative: it carries no contrast floor. */ |
| 149 | --rst-header-rule: color-mix(in oklab, var(--rst-accent) 45%, var(--rst-line)); |
| 150 | |
| 151 | /* Text */ |
| 152 | --rst-text: light-dark(#0c0e12, #e9ebef); |
| 153 | --rst-text-muted: light-dark(#454b57, #a4acb9); |
| 154 | --rst-text-faint: light-dark(#5b6270, #959dac); |
| 155 | |
| 156 | /* Accent: one electric cobalt. It appears where a decision is made and |
| 157 | nowhere else. */ |
| 158 | --rst-accent: light-dark(#1a56ff, #6d90ff); |
| 159 | --rst-accent-strong: light-dark(#0f3fd6, #9ab2ff); |
| 160 | --rst-accent-soft: light-dark(#e9eeff, #121a30); |
| 161 | --rst-on-accent: light-dark(#ffffff, #05070d); |
| 162 | |
| 163 | /* Status tones: saturated and unambiguous. A pill is never the |
| 164 | accent, so an error can never read as a link. */ |
| 165 | --rst-tone-neutral-fg: light-dark(#3b414d, #c6cdd8); |
| 166 | --rst-tone-neutral-bg: light-dark(#e6e8ee, #232830); |
| 167 | --rst-tone-positive-fg: light-dark(#08624a, #4fdba2); |
| 168 | --rst-tone-positive-bg: light-dark(#d6efe6, #082720); |
| 169 | --rst-tone-warning-fg: light-dark(#7a4708, #f3c669); |
| 170 | --rst-tone-warning-bg: light-dark(#f8e5c2, #2a2009); |
| 171 | --rst-tone-negative-fg: light-dark(#a8121f, #ff8f99); |
| 172 | --rst-tone-negative-bg: light-dark(#fbdee0, #2e1418); |
| 173 | |
| 174 | /* Depth: short throw, tight blur, high alpha. Seated, not floating. */ |
| 175 | --rst-shadow-pop: 0 4px 10px light-dark(rgba(12, 14, 18, 0.22), rgba(0, 0, 0, 0.62)); |
| 176 | --rst-shadow-knob: 0 1px 2px light-dark(rgba(12, 14, 18, 0.45), rgba(0, 0, 0, 0.6)); |
| 177 | --rst-shadow-lift: 0 1px 2px light-dark(rgba(12, 14, 18, 0.2), rgba(0, 0, 0, 0.5)); |
| 178 | --rst-overlay: light-dark(rgba(9, 11, 16, 0.55), rgba(3, 4, 7, 0.72)); |
| 179 | } |
| 180 | |
| 181 | /* The whole toggle. Setting color-scheme re-resolves every light-dark() |
| 182 | above, so an explicit choice beats the OS in both directions without |
| 183 | restating a colour. */ |
| 184 | :root[data-theme="light"] { color-scheme: light; } |
| 185 | :root[data-theme="dark"] { color-scheme: dark; } |
| 186 | |