| 1 | /* plain — the skeleton: greyscale, system type, almost no shape. |
| 2 | Structure lives in tokens.css; this file is colour, type and shape. |
| 3 | |
| 4 | The look: nothing. plain is the theme you pick when you intend to |
| 5 | write your own — a working, accessible, entirely unstyled-looking app |
| 6 | that shows you the structure and none of the taste, so the first |
| 7 | colour you add is visibly yours. There is no accent hue: the accent |
| 8 | is near-black in light and near-white in dark, which is to say a link |
| 9 | reads as text with an underline on hover and a primary button reads as |
| 10 | a filled rectangle. That is deliberate. Add the hue. |
| 11 | |
| 12 | Status still has to signal. A skeleton that renders an error the same |
| 13 | grey as a success is not minimal, it is broken, so the four tone |
| 14 | pairs keep conventional hues at very low chroma — a green you would |
| 15 | call grey-green, an ochre, a brick — each still clearing 4.5:1 on its |
| 16 | own tint. Colour is never the only channel (the pills carry words), |
| 17 | but it is not thrown away either. |
| 18 | |
| 19 | Shadows are real and minimal. tokens.css paints with |
| 20 | var(--rst-shadow-*) unconditionally and a theme cannot opt out of a |
| 21 | property it must declare, so "no shadows" here means one hairline |
| 22 | layer at low alpha rather than a value the CSS parser would drop. |
| 23 | You get an edge, not a lift. |
| 24 | |
| 25 | ── One block, both schemes ─────────────────────────────────────────── |
| 26 | Every token is declared once, as light-dark(<light>, <dark>), under a |
| 27 | single :root that sets color-scheme: light dark. The two rules at the |
| 28 | bottom are the whole toggle: setting color-scheme on |
| 29 | :root[data-theme=…] re-resolves every light-dark() in the file at |
| 30 | once, in both directions. Tokens with one value in both schemes — the |
| 31 | font stack, the radii — are written plain. |
| 32 | |
| 33 | light-dark() is a colour function: it may only stand where a <color> |
| 34 | is expected. So a shadow token wraps its colour in it and writes the |
| 35 | geometry once, outside the call — `0 8px 24px light-dark(a, b)`, never |
| 36 | `light-dark(0 8px 24px a, …)`, which no browser would accept as a |
| 37 | box-shadow. |
| 38 | |
| 39 | ── WCAG 2.2 AA, measured ───────────────────────────────────────────── |
| 40 | Normal text needs 4.5:1; large text and UI component boundaries need |
| 41 | 3:1. Every pair below was computed from these exact hex values, in |
| 42 | both schemes. If you change a token, recompute its rows. |
| 43 | |
| 44 | LIGHT ratio requirement |
| 45 | --rst-text on --rst-surface 18.88 4.5:1 text |
| 46 | --rst-text on --rst-bg 18.88 4.5:1 text |
| 47 | --rst-text on --rst-surface-2 17.17 4.5:1 text |
| 48 | --rst-text on --rst-accent-soft 16.13 4.5:1 text |
| 49 | --rst-text-muted on --rst-surface 8.86 4.5:1 text |
| 50 | --rst-text-muted on --rst-bg 8.86 4.5:1 text |
| 51 | --rst-text-muted on --rst-surface-2 8.06 4.5:1 text |
| 52 | --rst-text-muted on --rst-accent-soft 7.57 4.5:1 text |
| 53 | --rst-text-faint on --rst-surface 6.01 4.5:1 text |
| 54 | --rst-text-faint on --rst-bg 6.01 4.5:1 text |
| 55 | --rst-text-faint on --rst-surface-2 5.46 4.5:1 text |
| 56 | --rst-text-faint on --rst-accent-soft 5.13 4.5:1 text |
| 57 | --rst-accent on --rst-surface 18.88 4.5:1 text + focus ring |
| 58 | --rst-accent on --rst-bg 18.88 4.5:1 text + focus ring |
| 59 | --rst-accent on --rst-surface-2 17.17 4.5:1 text |
| 60 | --rst-accent on --rst-accent-soft 16.13 4.5:1 text |
| 61 | --rst-on-accent on --rst-accent 18.88 4.5:1 button label |
| 62 | --rst-on-accent on --rst-accent-strong 21.00 4.5:1 button label, hover |
| 63 | --rst-line-strong on --rst-surface 4.54 3:1 control border |
| 64 | --rst-line-strong on --rst-bg 4.54 3:1 control border |
| 65 | --rst-line-strong on --rst-surface-2 4.13 3:1 control border |
| 66 | pill neutral fg on its own tint 8.75 4.5:1 text |
| 67 | pill positive fg on its own tint 6.46 4.5:1 text |
| 68 | pill warning fg on its own tint 6.09 4.5:1 text |
| 69 | pill negative fg on its own tint 7.55 4.5:1 text |
| 70 | --rst-on-accent on negative fg (fill) 9.44 4.5:1 danger button label |
| 71 | |
| 72 | DARK ratio requirement |
| 73 | --rst-text on --rst-surface 16.01 4.5:1 text |
| 74 | --rst-text on --rst-bg 17.12 4.5:1 text |
| 75 | --rst-text on --rst-surface-2 16.60 4.5:1 text |
| 76 | --rst-text on --rst-accent-soft 13.87 4.5:1 text |
| 77 | --rst-text-muted on --rst-surface 8.55 4.5:1 text |
| 78 | --rst-text-muted on --rst-bg 9.14 4.5:1 text |
| 79 | --rst-text-muted on --rst-surface-2 8.86 4.5:1 text |
| 80 | --rst-text-muted on --rst-accent-soft 7.40 4.5:1 text |
| 81 | --rst-text-faint on --rst-surface 6.86 4.5:1 text |
| 82 | --rst-text-faint on --rst-bg 7.33 4.5:1 text |
| 83 | --rst-text-faint on --rst-surface-2 7.11 4.5:1 text |
| 84 | --rst-text-faint on --rst-accent-soft 5.94 4.5:1 text |
| 85 | --rst-accent on --rst-surface 16.01 4.5:1 text + focus ring |
| 86 | --rst-accent on --rst-bg 17.12 4.5:1 text + focus ring |
| 87 | --rst-accent on --rst-surface-2 16.60 4.5:1 text |
| 88 | --rst-accent on --rst-accent-soft 13.87 4.5:1 text |
| 89 | --rst-on-accent on --rst-accent 17.12 4.5:1 button label |
| 90 | --rst-on-accent on --rst-accent-strong 19.17 4.5:1 button label, hover |
| 91 | --rst-line-strong on --rst-surface 5.54 3:1 control border |
| 92 | --rst-line-strong on --rst-bg 5.93 3:1 control border |
| 93 | --rst-line-strong on --rst-surface-2 5.75 3:1 control border |
| 94 | pill neutral fg on its own tint 9.05 4.5:1 text |
| 95 | pill positive fg on its own tint 8.70 4.5:1 text |
| 96 | pill warning fg on its own tint 8.82 4.5:1 text |
| 97 | pill negative fg on its own tint 7.63 4.5:1 text |
| 98 | --rst-on-accent on negative fg (fill) 8.93 4.5:1 danger button label |
| 99 | |
| 100 | The tightest pair in the set is --rst-text-faint on the accent tint — |
| 101 | 5.13:1 in light, 5.94:1 in dark, against a 4.5:1 floor. Everything |
| 102 | else has room, which is what a greyscale palette buys you. |
| 103 | |
| 104 | --rst-line is a hairline divider, not a control border, so 1.4.11's |
| 105 | 3:1 does not apply to it. Control borders use --rst-line-strong. |
| 106 | |
| 107 | --rst-header-rule is not in the table either, and deliberately so |
| 108 | (design doc §6-v2.2): the page header's rule is decoration, not |
| 109 | structure — the heading's size, weight and spacing carry the |
| 110 | structure — so no contrast floor applies to it and it is not in |
| 111 | ui/contrast_test.go's pair table. Do not argue it up to 3:1. |
| 112 | |
| 113 | --rst-shadow-pop, --rst-shadow-knob, --rst-shadow-lift and |
| 114 | --rst-overlay are not in the table: they paint a translucent layer |
| 115 | over whatever sits underneath, not text over a fixed background, so a |
| 116 | contrast ratio does not apply. |
| 117 | ──────────────────────────────────────────────────────────────────── */ |
| 118 | |
| 119 | :root { |
| 120 | color-scheme: light dark; |
| 121 | |
| 122 | /* Type: whatever the machine already has. */ |
| 123 | --rst-font: system-ui, -apple-system, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; |
| 124 | |
| 125 | /* Shape: barely rounded. A corner you notice is a decision plain has |
| 126 | not made yet. */ |
| 127 | --rst-radius: 4px; |
| 128 | --rst-radius-sm: 3px; |
| 129 | --rst-radius-pill: 999px; |
| 130 | |
| 131 | /* Surfaces and lines */ |
| 132 | --rst-bg: light-dark(#ffffff, #0f0f0f); |
| 133 | --rst-surface: light-dark(#ffffff, #171717); |
| 134 | --rst-surface-2: light-dark(#f4f4f4, #131313); |
| 135 | --rst-line: light-dark(#e0e0e0, #2b2b2b); |
| 136 | --rst-line-strong: light-dark(#767676, #8f8f8f); |
| 137 | |
| 138 | /* The page header's rule. plain has no accent hue to fold in, so it |
| 139 | is the line and nothing else — the same derived token every theme |
| 140 | declares, resolving here to plain grey. Decorative: it carries no |
| 141 | contrast floor. */ |
| 142 | --rst-header-rule: var(--rst-line); |
| 143 | |
| 144 | /* Text */ |
| 145 | --rst-text: light-dark(#111111, #f2f2f2); |
| 146 | --rst-text-muted: light-dark(#4a4a4a, #b3b3b3); |
| 147 | --rst-text-faint: light-dark(#636363, #a0a0a0); |
| 148 | |
| 149 | /* Accent: none. The "accent" is the text colour, so a link is text |
| 150 | that underlines on hover and a primary button is a filled |
| 151 | rectangle. */ |
| 152 | --rst-accent: light-dark(#111111, #f2f2f2); |
| 153 | --rst-accent-strong: light-dark(#000000, #ffffff); |
| 154 | --rst-accent-soft: light-dark(#ededed, #242424); |
| 155 | --rst-on-accent: light-dark(#ffffff, #0f0f0f); |
| 156 | |
| 157 | /* Status tones: conventional hues held to very low chroma. Muted |
| 158 | enough to read as greyscale at a glance, separated enough that an |
| 159 | error is not a success. */ |
| 160 | --rst-tone-neutral-fg: light-dark(#3f3f3f, #c8c8c8); |
| 161 | --rst-tone-neutral-bg: light-dark(#eaeaea, #262626); |
| 162 | --rst-tone-positive-fg: light-dark(#2e5b3e, #9fc8ac); |
| 163 | --rst-tone-positive-bg: light-dark(#e4ebe6, #1a231d); |
| 164 | --rst-tone-warning-fg: light-dark(#6a5320, #d3c08a); |
| 165 | --rst-tone-warning-bg: light-dark(#efeadd, #262218); |
| 166 | --rst-tone-negative-fg: light-dark(#7a2c2c, #dba3a3); |
| 167 | --rst-tone-negative-bg: light-dark(#f0e3e3, #2a1c1c); |
| 168 | |
| 169 | /* Depth: one hairline layer. Real values, minimal effect. */ |
| 170 | --rst-shadow-pop: 0 1px 3px light-dark(rgba(0, 0, 0, 0.14), rgba(0, 0, 0, 0.5)); |
| 171 | --rst-shadow-knob: 0 1px 1px light-dark(rgba(0, 0, 0, 0.22), rgba(0, 0, 0, 0.4)); |
| 172 | --rst-shadow-lift: 0 1px 1px light-dark(rgba(0, 0, 0, 0.07), rgba(0, 0, 0, 0.3)); |
| 173 | --rst-overlay: light-dark(rgba(0, 0, 0, 0.4), rgba(0, 0, 0, 0.6)); |
| 174 | } |
| 175 | |
| 176 | /* The whole toggle. Setting color-scheme re-resolves every light-dark() |
| 177 | above, so an explicit choice beats the OS in both directions without |
| 178 | restating a colour. */ |
| 179 | :root[data-theme="light"] { color-scheme: light; } |
| 180 | :root[data-theme="dark"] { color-scheme: dark; } |
| 181 | |