also looking at this
Promote main to production: signed-out chrome and the subscriber tables
#17
54 files
+4252
-1353
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -41,10 +41,17 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind' | ||
| 41 | 41 | * of toCSS(). The stale note mattered, because it is why this |
| 42 | 42 | * app's tokens went to public/app-chrome.css instead. |
| 43 | 43 | * |
| 44 | * One constraint is real, and it is quoting rather than | |
| 45 | * structure: a double-quoted font family in a preflight makes | |
| 46 | * the engine substitute its own stack and swallow the rules | |
| 47 | * after it. Several rules in one getCSS() are fine. | |
| 44 | * One constraint is real, and it is neither quoting nor | |
| 45 | * structure. The engine replaces a `font-family` written | |
| 46 | * literally in a preflight when the rule is in the SPACED | |
| 47 | * form (`sel { prop: value; }`) and the stack begins | |
| 48 | * `ui-sans-serif`; it substitutes the theme's own sans. | |
| 49 | * Quoting makes no difference and nothing after it is | |
| 50 | * swallowed -- this entry claimed both, from a reading rather | |
| 51 | * than a measurement. Behind a custom property, or read | |
| 52 | * through `var()`, nothing is rewritten, which is what both | |
| 53 | * font-family declarations below do. Several rules in one | |
| 54 | * getCSS() are fine. | |
| 48 | 55 | * |
| 49 | 56 | * Anything not on the surviving list is decoration. This file used to carry |
| 50 | 57 | * `content`, `minify`, and `preflight: true` — and that last one is not even a |
| @@ -55,6 +62,690 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind' | ||
| 55 | 62 | * |
| 56 | 63 | * @see https://github.com/cwcss/crosswind |
| 57 | 64 | */ |
| 65 | /* | |
| 66 | * The design tokens. Declared here and nowhere else (stx-standards rule 3, | |
| 67 | * §11.2). | |
| 68 | * | |
| 69 | * They were in public/tokens.css, linked into every route from config/ui.ts, | |
| 70 | * on the claim restated at the top of that file: that `preflights` "is spread | |
| 71 | * into the generator config and then never emitted", proven "with a probe | |
| 72 | * token that appears nowhere in the served CSS on any route". That probe was | |
| 73 | * real, and so was the result. It is also what the `preflights` entry above | |
| 74 | * already records as fixed: stacksjs/stx #1867 unified the two render paths | |
| 75 | * onto one merge, and this config has carried the correction for a while. Only | |
| 76 | * the two stylesheets still said it, which is why 83 declarations lived | |
| 77 | * outside this file. | |
| 78 | * | |
| 79 | * Re-probed on the installed stx 0.2.532 before moving anything, the same way | |
| 80 | * the original probe was run: mergeCssConfig() reports includePreflight true, | |
| 81 | * carries the entries into `config`, and a probe token placed in `preflights` | |
| 82 | * comes back out of toCSS(). DATE_RANGE_PICKER_CSS below had been reaching | |
| 83 | * /analytics the whole time, which is the same proof from the other direction. | |
| 84 | * | |
| 85 | * Still FIRST, which is what the old link order said: before APP_CHROME so | |
| 86 | * that can read the palette, and before /marketing.css, which the marketing | |
| 87 | * layout adds and which declares a richer overlapping set. Later still wins, | |
| 88 | * and marketing.css is unlayered while this is @layer tc-base, so it wins | |
| 89 | * harder than it did. That is the direction it already wanted. | |
| 90 | */ | |
| 91 | const TOKENS = ` | |
| 92 | :root { | |
| 93 | /* The one declaration that reaches the parts of the page we do not draw. | |
| 94 | * | |
| 95 | * Native UI — the select popup, scrollbars, the date and colour pickers, form | |
| 96 | * control backgrounds, the spellcheck underline, the caret — is painted by the | |
| 97 | * browser from \`color-scheme\`, not from our custom properties. Undeclared it | |
| 98 | * defaults to \`light\`, so on a dark loghq the project dropdown opened as a | |
| 99 | * white popup on a #090b11 page and the scrollbars stayed light. No amount of | |
| 100 | * token work reaches those pixels; this is the only thing that does. | |
| 101 | * | |
| 102 | * It is declared on all four blocks below and not once on \`:root\` with both | |
| 103 | * values, because \`color-scheme: light dark\` means "either is fine, follow the | |
| 104 | * OS" — which is wrong the moment someone picks a theme explicitly. The | |
| 105 | * attribute blocks have to be able to override the media query, exactly like | |
| 106 | * every colour here. */ | |
| 107 | color-scheme: light; | |
| 108 | --bg: #fbfbfd; | |
| 109 | --panel: #ffffff; | |
| 110 | --border: rgba(15, 23, 42, 0.09); | |
| 111 | --text: #0b0f19; | |
| 112 | --text-2: #4b5565; | |
| 113 | /* --text-3 was #97a1b2, which measured 2.52:1 against --bg and 2.61:1 | |
| 114 | * against --panel. WCAG AA wants 4.5:1 for body text, so every "subtle" | |
| 115 | * label in the app failed it — 73 elements in light and 82 in dark, measured | |
| 116 | * in a real browser across seven pages. It is the same token in both themes | |
| 117 | * and it failed in both (dark was #667085, 3.73:1 on --panel). | |
| 118 | * | |
| 119 | * #6b7280 gives 4.83:1 on --panel and 4.68:1 on --bg; the dark #79839a gives | |
| 120 | * 4.88:1 and 5.18:1. Both are the smallest step that clears AA on the worse | |
| 121 | * of the two surfaces, so "subtle" still reads as subtle. */ | |
| 122 | --text-3: #6b7280; | |
| 123 | /* Emerald, not the rose this shipped with. | |
| 124 | * | |
| 125 | * The old --accent was #e11d48, which is byte-for-byte --sev-error below. In | |
| 126 | * a product whose whole surface is log severity, the brand colour WAS the | |
| 127 | * error colour: every button, link and focus ring carried the one hue the | |
| 128 | * severity scale reserves for something being wrong. | |
| 129 | * | |
| 130 | * Green collides with --ok instead, which is the milder trade. --ok is a UI | |
| 131 | * success state, not a severity: nothing in the sev-* scale is green, so the | |
| 132 | * severity reading stays unambiguous. Brand-as-healthy misleads nobody; | |
| 133 | * brand-as-error did. | |
| 134 | * | |
| 135 | * #047857 rather than the more obvious #059669: emerald-600 measures 3.65:1 | |
| 136 | * on --bg, under the 4.5 floor this file holds everything else to. This one | |
| 137 | * measures 5.31:1 on --bg and 5.48:1 on --panel, which is also an improvement | |
| 138 | * on the rose it replaces (4.54:1, passing by 0.04). */ | |
| 139 | --accent: #047857; | |
| 140 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 141 | /* Ink for text sitting ON a saturated fill — the accent, --warn, and the | |
| 142 | * Slack/Discord brand colours. | |
| 143 | * | |
| 144 | * It cannot be a hardcoded #fff, which is what the app markup and | |
| 145 | * app-chrome.css assumed. In dark mode the fills get LIGHTER, not darker: | |
| 146 | * --accent is #34d399 and the dashboard's indigo is #818cf8, where white | |
| 147 | * measures 1.92:1 and 2.98:1. The dark value here measures 9.83:1 and 6.34:1 | |
| 148 | * on those same fills, and 6.83:1 on the dark --warn. | |
| 149 | * | |
| 150 | * marketing.css has had this token, correctly themed, since the marketing | |
| 151 | * pages were built; it just never reached the app surface, because that | |
| 152 | * sheet only loads on marketing routes. Declared here it covers both, and | |
| 153 | * marketing.css no longer declares its own copy — see the .reveal note there | |
| 154 | * for what two copies of one token cost. */ | |
| 155 | --accent-ink: #ffffff; | |
| 156 | --ok: #16a34a; | |
| 157 | --warn: #b91c1c; | |
| 158 | --sans: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; | |
| 159 | --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 160 | ||
| 161 | /* Log severity. These were six hex literals inside a levelColor() switch in | |
| 162 | * dashboard.stx, with two of them repeated in the stats array — so the same | |
| 163 | * "warning amber" was written twice and could drift. | |
| 164 | * | |
| 165 | * They were also a single set used on BOTH themes. The dark values below are | |
| 166 | * exactly what shipped before, so dark is unchanged; light gets darker, | |
| 167 | * higher-contrast variants, because #fb7185 on a #fbfbfd background is not a | |
| 168 | * readable label. */ | |
| 169 | --sev-critical: #be123c; | |
| 170 | --sev-error: #e11d48; | |
| 171 | --sev-warning: #b45309; | |
| 172 | --sev-notice: #0369a1; | |
| 173 | --sev-info: #1d4ed8; | |
| 174 | --sev-debug: #64748b; | |
| 175 | } | |
| 176 | ||
| 177 | @media (prefers-color-scheme: dark) { | |
| 178 | :root { | |
| 179 | color-scheme: dark; | |
| 180 | --bg: #090b11; | |
| 181 | --panel: #10131c; | |
| 182 | --border: rgba(148, 163, 184, 0.12); | |
| 183 | --text: #f3f5f9; | |
| 184 | --text-2: #b3bccb; | |
| 185 | --text-3: #79839a; | |
| 186 | --accent: #34d399; | |
| 187 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 188 | --accent-ink: #04140d; | |
| 189 | --ok: #4ade80; | |
| 190 | --warn: #f87171; | |
| 191 | --sev-critical: #f43f5e; | |
| 192 | --sev-error: #fb7185; | |
| 193 | --sev-warning: #f59e0b; | |
| 194 | --sev-notice: #38bdf8; | |
| 195 | --sev-info: #60a5fa; | |
| 196 | --sev-debug: #94a3b8; | |
| 197 | } | |
| 198 | } | |
| 199 | ||
| 200 | :root[data-theme='dark'] { | |
| 201 | color-scheme: dark; | |
| 202 | --bg: #090b11; | |
| 203 | --panel: #10131c; | |
| 204 | --border: rgba(148, 163, 184, 0.12); | |
| 205 | --text: #f3f5f9; | |
| 206 | --text-2: #b3bccb; | |
| 207 | --text-3: #79839a; | |
| 208 | --accent: #34d399; | |
| 209 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 210 | --accent-ink: #04140d; | |
| 211 | --ok: #4ade80; | |
| 212 | --warn: #f87171; | |
| 213 | --sev-critical: #f43f5e; | |
| 214 | --sev-error: #fb7185; | |
| 215 | --sev-warning: #f59e0b; | |
| 216 | --sev-notice: #38bdf8; | |
| 217 | --sev-info: #60a5fa; | |
| 218 | --sev-debug: #94a3b8; | |
| 219 | } | |
| 220 | ||
| 221 | :root[data-theme='light'] { | |
| 222 | color-scheme: light; | |
| 223 | --bg: #fbfbfd; | |
| 224 | --panel: #ffffff; | |
| 225 | --border: rgba(15, 23, 42, 0.09); | |
| 226 | --text: #0b0f19; | |
| 227 | --text-2: #4b5565; | |
| 228 | --text-3: #6b7280; | |
| 229 | --accent: #047857; | |
| 230 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 231 | --accent-ink: #ffffff; | |
| 232 | --ok: #16a34a; | |
| 233 | --warn: #b91c1c; | |
| 234 | --sev-critical: #be123c; | |
| 235 | --sev-error: #e11d48; | |
| 236 | --sev-warning: #b45309; | |
| 237 | --sev-notice: #0369a1; | |
| 238 | --sev-info: #1d4ed8; | |
| 239 | --sev-debug: #64748b; | |
| 240 | } | |
| 241 | ||
| 242 | body { | |
| 243 | background: var(--bg); | |
| 244 | color: var(--text); | |
| 245 | font-family: var(--sans); | |
| 246 | -webkit-font-smoothing: antialiased; | |
| 247 | } | |
| 248 | ||
| 249 | /* Skeleton shimmer, from account.stx's <style> block. Utilities cannot declare | |
| 250 | * a @keyframes, so this is a preflight, which is the documented home for one. | |
| 251 | * | |
| 252 | * Named loghq-pulse rather than pulse on purpose. Crosswind emits its own | |
| 253 | * \`@keyframes pulse\` (0%,100% opacity 1; 50% opacity .5) as soon as any page | |
| 254 | * uses \`animate-pulse\`, into a sheet that is linked AFTER this file. A | |
| 255 | * same-named keyframe there would silently replace this one and change the | |
| 256 | * animation rather than error. | |
| 257 | */ | |
| 258 | @keyframes loghq-pulse { | |
| 259 | 0%, | |
| 260 | 100% { | |
| 261 | opacity: 0.5; | |
| 262 | } | |
| 263 | 50% { | |
| 264 | opacity: 0.9; | |
| 265 | } | |
| 266 | } | |
| 267 | ||
| 268 | /* The one element that uses it, as a named class rather than an arbitrary | |
| 269 | * \`[animation:loghq-pulse_1.4s_ease-in-out_infinite]\` utility. | |
| 270 | * | |
| 271 | * That utility worked. What never worked was the guard beside it, | |
| 272 | * \`motion-reduce:[animation:none]\`: a variant on an arbitrary property is not | |
| 273 | * recognised by crosswind's class extractor, so it produced no rule at all and | |
| 274 | * the skeleton kept pulsing for people who asked their system not to animate. | |
| 275 | * Silent, as that whole class of miss is -- no error, no warning, nothing in | |
| 276 | * the sheet. Filed as stacksjs/ts-css#37; the generator side of it is fixed in | |
| 277 | * 0.3.14 (#36), the extractor is not. | |
| 278 | * | |
| 279 | * Both halves live here, in source order, so the reduced-motion case does not | |
| 280 | * depend on the extractor, on crosswind's output ordering, or on an | |
| 281 | * !important. It also cannot be separated from the keyframes it guards. Note | |
| 282 | * this file is linked BEFORE the generated sheet, which is why the animation | |
| 283 | * is declared here rather than left as a utility for this rule to fight. | |
| 284 | */ | |
| 285 | .skeleton-pulse { | |
| 286 | animation: loghq-pulse 1.4s ease-in-out infinite; | |
| 287 | } | |
| 288 | ||
| 289 | @media (prefers-reduced-motion: reduce) { | |
| 290 | .skeleton-pulse { | |
| 291 | animation: none; | |
| 292 | } | |
| 293 | } | |
| 294 | ||
| 295 | /* Theme-toggle icon swap. Three identical copies of this lived in | |
| 296 | * dashboard.stx, settings.stx and projects/index.stx. | |
| 297 | * | |
| 298 | * It stays CSS because no utility can express it: the selector is an | |
| 299 | * attribute-state descendant, \`[data-t="light"] .ic-sun\`, driven by the | |
| 300 | * \`theme()\` signal through \`:data-t\` on the button. The alternative — a | |
| 301 | * \`:show\` binding on each icon — would need \`style="display:none"\` on both to | |
| 302 | * avoid showing both icons before hydration, trading a state rule for two | |
| 303 | * inline styles and a hydration dependency on something that is currently | |
| 304 | * pure CSS. | |
| 305 | */ | |
| 306 | .theme-btn .ic-sun, | |
| 307 | .theme-btn .ic-moon { | |
| 308 | display: none; | |
| 309 | } | |
| 310 | ||
| 311 | /* Keyed off the ROOT element, not off the button. | |
| 312 | * | |
| 313 | * These used to read \`.theme-btn[data-t='light']\`, where data-t came from a | |
| 314 | * \`:data-t="theme()"\` binding — a client signal. So before hydration the button | |
| 315 | * carried no data-t at all, neither rule matched, and the pair above left BOTH | |
| 316 | * icons display:none: the theme control rendered as an empty box on every cold | |
| 317 | * load. There is no server-known theme to put in a static counterpart either, | |
| 318 | * so the fix is not a static attribute; it is keying off something that IS set | |
| 319 | * before first paint. | |
| 320 | * | |
| 321 | * stx's color-mode boot script sets data-theme on the root above every | |
| 322 | * stylesheet (config/ui.ts app.colorMode), and useColorMode keeps it current | |
| 323 | * afterwards, so the correct icon paints immediately and the mirror signal the | |
| 324 | * binding needed is gone from all three pages. */ | |
| 325 | :root[data-theme='light'] .theme-btn .ic-sun { | |
| 326 | display: block; | |
| 327 | } | |
| 328 | ||
| 329 | :root[data-theme='dark'] .theme-btn .ic-moon { | |
| 330 | display: block; | |
| 331 | } | |
| 332 | ` | |
| 333 | ||
| 334 | /* | |
| 335 | * App-surface CSS that utilities provably cannot express: pseudo-elements, | |
| 336 | * attribute-state selectors, bare element selectors, @keyframes, and the | |
| 337 | * dashboard's page-scoped accent. | |
| 338 | * | |
| 339 | * From public/app-chrome.css, which opened by naming this file as where | |
| 340 | * ch.11.2/11.7 says it belongs and then explaining why it could not be here. | |
| 341 | * See TOKENS above for why that reason no longer holds. | |
| 342 | * | |
| 343 | * Rules that WERE expressible are deliberately absent -- they live as | |
| 344 | * utilities in the markup. Do not move a rule back here because it is more | |
| 345 | * convenient. | |
| 346 | * | |
| 347 | * The cascade, measured rather than reasoned about, because this sheet used to | |
| 348 | * be linked between /tokens.css and /marketing.css and is now layered: | |
| 349 | * | |
| 350 | * against a utility A utility is unlayered and this is @layer tc-base, so a | |
| 351 | * utility wins outright instead of winning ties on order. That is what the | |
| 352 | * old header asked for ("before the Crosswind sheet so utilities still win | |
| 353 | * a specificity tie"), now unconditional. Nothing here relied on beating | |
| 354 | * one by specificity: every selector was checked against the app markup. | |
| 355 | * | |
| 356 | * against marketing.css Zero class names are shared between the two files. | |
| 357 | * The only selectors both write are `:root`, `html` and two at-rules, and | |
| 358 | * they do not collide: this declares `--slack`/`--discord` where marketing | |
| 359 | * declares `--bg`/`--text`/`--surface`, both set `scroll-behavior: smooth` | |
| 360 | * on `html` to the same value, and the three reduced-motion blocks across | |
| 361 | * the two files target disjoint classes with no !important between them. | |
| 362 | * So the layer change reverses nothing. | |
| 363 | * | |
| 364 | * Verified end to end by diffing the computed value of 58 properties on every | |
| 365 | * element of 11 served pages, before against after. | |
| 366 | */ | |
| 367 | const APP_CHROME = ` | |
| 368 | /* --------------------------------------------------------------------------- | |
| 369 | * Page-scoped tokens | |
| 370 | * | |
| 371 | * dashboard is the one surface with its own accent: indigo, so navigation | |
| 372 | * never reads as a severity colour (see 20f40ed). It used to be a \`:root\` | |
| 373 | * block inside dashboard.stx's <style>, scoped only by the accident of which | |
| 374 | * page carried the tag. Declared globally that would repaint the whole app, so | |
| 375 | * it hangs off a wrapper class on the dashboard's root element and cascades to | |
| 376 | * its descendants — including the fixed-position chrome, which is inside the | |
| 377 | * wrapper. | |
| 378 | * ------------------------------------------------------------------------ */ | |
| 379 | .page-dashboard { | |
| 380 | --accent: #4f46e5; | |
| 381 | --rail: rgba(79, 70, 229, 0.14); | |
| 382 | } | |
| 383 | ||
| 384 | @media (prefers-color-scheme: dark) { | |
| 385 | .page-dashboard { | |
| 386 | --accent: #818cf8; | |
| 387 | --rail: rgba(129, 140, 248, 0.16); | |
| 388 | } | |
| 389 | } | |
| 390 | ||
| 391 | :root[data-theme='dark'] .page-dashboard { | |
| 392 | --accent: #818cf8; | |
| 393 | --rail: rgba(129, 140, 248, 0.16); | |
| 394 | } | |
| 395 | ||
| 396 | :root[data-theme='light'] .page-dashboard { | |
| 397 | --accent: #4f46e5; | |
| 398 | --rail: rgba(79, 70, 229, 0.14); | |
| 399 | } | |
| 400 | ||
| 401 | /* Channel brand colours. Only settings uses them, but they are declared here | |
| 402 | * with everything else rather than in a page tag. */ | |
| 403 | :root { | |
| 404 | --slack: #611f69; | |
| 405 | --discord: #5865f2; | |
| 406 | } | |
| 407 | ||
| 408 | @media (prefers-color-scheme: dark) { | |
| 409 | :root { | |
| 410 | --discord: #818cf8; | |
| 411 | } | |
| 412 | } | |
| 413 | ||
| 414 | :root[data-theme='dark'] { | |
| 415 | --discord: #818cf8; | |
| 416 | } | |
| 417 | ||
| 418 | :root[data-theme='light'] { | |
| 419 | --discord: #5865f2; | |
| 420 | } | |
| 421 | ||
| 422 | /* Tabular numerals for the log stream's counts and timestamps, so digits do | |
| 423 | * not jitter as values change. | |
| 424 | * | |
| 425 | * This is a real rule rather than an arbitrary property because the value has | |
| 426 | * to be quoted — \`font-feature-settings: 'tnum' 1\` — and a quoted value cannot | |
| 427 | * survive Crosswind's class-name syntax. Written as | |
| 428 | * [font-feature-settings:'tnum'_1] it generates NOTHING; the three | |
| 429 | * font-feature-settings declarations in the served sheet are all preflight | |
| 430 | * resets, and the computed value on the element stayed \`normal\`. | |
| 431 | * | |
| 432 | * \`tabular-nums\` is not a substitute: it sets font-variant-numeric, a | |
| 433 | * different property, and would not reproduce the measured baseline. | |
| 434 | */ | |
| 435 | .tnum { | |
| 436 | font-feature-settings: 'tnum' 1; | |
| 437 | } | |
| 438 | ||
| 439 | /* --------------------------------------------------------------------------- | |
| 440 | * Bare element selectors — no utility targets an element type | |
| 441 | * ------------------------------------------------------------------------ */ | |
| 442 | html { | |
| 443 | scroll-behavior: smooth; | |
| 444 | } | |
| 445 | ||
| 446 | /* --------------------------------------------------------------------------- | |
| 447 | * settings: the alert-channel toggle | |
| 448 | * | |
| 449 | * The knob is a ::after pseudo-element and both states are attribute-driven. | |
| 450 | * Crosswind generates nothing for \`after:\` or for a \`[data-on]\` variant. | |
| 451 | * ------------------------------------------------------------------------ */ | |
| 452 | .tgl::after { | |
| 453 | content: ''; | |
| 454 | position: absolute; | |
| 455 | top: 2px; | |
| 456 | left: 2px; | |
| 457 | width: 18px; | |
| 458 | height: 18px; | |
| 459 | border-radius: 999px; | |
| 460 | background: #fff; | |
| 461 | transition: transform 0.15s ease; | |
| 462 | } | |
| 463 | ||
| 464 | /* The four rules below carry !important, and they are exactly the ones whose | |
| 465 | * element also carries a utility for the same property. While this was a | |
| 466 | * linked stylesheet they won on specificity -- an attribute-state or | |
| 467 | * tag-scoped selector outranks a single-class utility. As a preflight they are | |
| 468 | * in @layer tc-base and a utility is unlayered, which beats a layer at ANY | |
| 469 | * specificity, so without the marks they stop applying. | |
| 470 | * | |
| 471 | * Measured on a probe page carrying the real class lists from settings.stx and | |
| 472 | * dashboard.stx, with the before and after sheets swapped underneath it: | |
| 473 | * | |
| 474 | * .tgl[data-on='1'] rgb(74,222,128) -> rgba(148,163,184,0.12), ie | |
| 475 | * the toggle stops turning green and reads as off | |
| 476 | * in both states (loses to \`bg-line\`) | |
| 477 | * .seg-slack[data-sel='1'] rgb(97,31,105) -> rgb(16,19,28), the selected | |
| 478 | * channel loses its brand fill (\`bg-panel\`), and | |
| 479 | * .seg's own colour goes with it (\`text-muted\`) | |
| 480 | * .dz-overlay[hidden] display none -> flex, so the delete-zone modal | |
| 481 | * is permanently open over the settings page | |
| 482 | * (loses to \`flex\`) | |
| 483 | * a.pill:hover the hover paint stops arriving at all, because | |
| 484 | * the utilities it has to beat (\`bg-accent\`, | |
| 485 | * \`border-accent\`, \`text-accent-ink\`) carry no | |
| 486 | * hover variant and so apply in every state | |
| 487 | * | |
| 488 | * Marked per declaration rather than per rule: only the properties that | |
| 489 | * actually collide, so the override is exactly as wide as the utility it has | |
| 490 | * to beat. */ | |
| 491 | .tgl[data-on='1'] { | |
| 492 | background: var(--ok) !important; | |
| 493 | } | |
| 494 | ||
| 495 | .tgl[data-on='1']::after { | |
| 496 | transform: translateX(16px); | |
| 497 | } | |
| 498 | ||
| 499 | /* settings: the Slack/Discord channel picker. Selected state is attribute | |
| 500 | * driven, and each brand paints its own fill. */ | |
| 501 | .seg[data-sel='1'] { | |
| 502 | color: var(--accent-ink) !important; | |
| 503 | border-color: transparent !important; | |
| 504 | } | |
| 505 | ||
| 506 | .seg-slack[data-sel='1'] { | |
| 507 | background: var(--slack) !important; | |
| 508 | } | |
| 509 | ||
| 510 | .seg-discord[data-sel='1'] { | |
| 511 | background: var(--discord) !important; | |
| 512 | } | |
| 513 | ||
| 514 | /* settings: delete-zone modal. \`[hidden]\` has to beat the overlay's own | |
| 515 | * \`display: flex\`, which a utility cannot guarantee — the engine's own | |
| 516 | * preflight \`[hidden]{display:none}\` ties with \`.flex\` on specificity and | |
| 517 | * loses on order, and this rule, being layered, loses to it outright. Hence | |
| 518 | * the mark. */ | |
| 519 | .dz-overlay[hidden] { | |
| 520 | display: none !important; | |
| 521 | } | |
| 522 | ||
| 523 | @keyframes dz-fade { | |
| 524 | from { | |
| 525 | opacity: 0; | |
| 526 | } | |
| 527 | ||
| 528 | to { | |
| 529 | opacity: 1; | |
| 530 | } | |
| 531 | } | |
| 532 | ||
| 533 | @keyframes dz-pop { | |
| 534 | from { | |
| 535 | opacity: 0; | |
| 536 | transform: translateY(6px) scale(0.98); | |
| 537 | } | |
| 538 | ||
| 539 | to { | |
| 540 | opacity: 1; | |
| 541 | transform: none; | |
| 542 | } | |
| 543 | } | |
| 544 | ||
| 545 | /* --------------------------------------------------------------------------- | |
| 546 | * dashboard | |
| 547 | * | |
| 548 | * Moved verbatim from dashboard.stx's <style> block. Every family below is | |
| 549 | * entangled with a selector no utility reaches, so splitting them would have | |
| 550 | * meant half a component in each place: | |
| 551 | * | |
| 552 | * .pill* hover/active are tag-scoped (\`a.pill:hover, button.pill:hover\`) | |
| 553 | * on purpose — the disabled pager is a <span class="pill"> and | |
| 554 | * must NOT light up on hover. | |
| 555 | * .log-row \`details[open] > .log-row\` has to outrank the hover state. | |
| 556 | * .search-input ::placeholder and :focus-visible. | |
| 557 | * .chrome-* \`.chrome-pro, .chrome-pro:hover\` cancels .chrome-upgrade's | |
| 558 | * hover: Pro is a status, not an action. | |
| 559 | * summary bare element selector plus ::-webkit-details-marker. | |
| 560 | * :focus-visible a bare-element accessibility rule, not optional. | |
| 561 | * .grain, data-URI backgrounds with prefers-color-scheme variants. | |
| 562 | * select.app-panel | |
| 563 | * .reveal @keyframes plus a reduced-motion variant. | |
| 564 | * | |
| 565 | * What IS expressible went the other way and is now utilities in the markup: | |
| 566 | * .mono, .wordmark, .wordmark-link, .panel, .lvl-chip, .ctx-pre and .icon-btn. | |
| 567 | * ------------------------------------------------------------------------ */ | |
| 568 | .pill { border-radius: 999px; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; } | |
| 569 | a.pill:hover, button.pill:hover { color: var(--text) !important; border-color: color-mix(in srgb, var(--accent) 45%, var(--border)) !important; background: color-mix(in srgb, var(--accent) 6%, transparent) !important; } | |
| 570 | a.pill:active, button.pill:active { transform: translateY(1px); } | |
| 571 | /* Filter-tab state. These were inline style ternaries on every tab, which | |
| 572 | outrank the hover rules above and could not be themed. The two levels differ | |
| 573 | because the severity tabs are the primary control and the range tabs are | |
| 574 | secondary. */ | |
| 575 | .pill-on { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 576 | a.pill-on:hover { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 577 | .pill-off { color: var(--text-2); border-color: var(--border); } | |
| 578 | .pill-on-soft { color: var(--accent); border-color: var(--accent); } | |
| 579 | .pill-off-soft { color: var(--text-3); border-color: var(--border); } | |
| 580 | .log-row { transition: background-color 0.12s ease; } | |
| 581 | .log-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); } | |
| 582 | details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); } | |
| 583 | summary { list-style: none; cursor: pointer; } | |
| 584 | summary::-webkit-details-marker { display: none; } | |
| 585 | .search-input { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; color: var(--text); outline: none; transition: border-color 0.15s ease; } | |
| 586 | .search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } | |
| 587 | .search-input::placeholder { color: var(--text-3); } | |
| 588 | /* Position was an inline style string assembled in JS. It lives here now, so | |
| 589 | the chrome is styleable and themeable like everything else. */ | |
| 590 | .chrome-logout, .chrome-upgrade { position: fixed; top: 16px; z-index: 60; padding: 8px 13px; border-radius: 9px; font-size: 13px; line-height: 1; font-family: var(--sans, ui-sans-serif); } | |
| 591 | .chrome-logout { right: 18px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); transition: color 0.15s ease, border-color 0.15s ease; } | |
| 592 | .chrome-logout:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } | |
| 593 | .chrome-upgrade { right: 112px; font-weight: 600; text-decoration: none; background: var(--accent); color: var(--accent-ink); transition: filter 0.15s ease, transform 0.12s ease; } | |
| 594 | .chrome-upgrade:hover { filter: brightness(1.12); } | |
| 595 | .chrome-upgrade:active { transform: translateY(1px); } | |
| 596 | /* Pro is a status, not an action: no link, no hover lift. */ | |
| 597 | .chrome-pro, .chrome-pro:hover { background: transparent; color: var(--accent); border: 1px solid var(--accent); cursor: default; filter: none; } | |
| 598 | /* The ONLY .reveal in the app. marketing.css carried a second copy with | |
| 599 | different values (0.6s/12px against this one's 0.5s/10px) and a second | |
| 600 | @keyframes of the same name; both sheets load on every marketing page, so the | |
| 601 | marketing copy won there and this one only ever reached the dashboard. These | |
| 602 | are the marketing values, kept because 23 pages used them and one did not. */ | |
| 603 | .reveal { opacity: 0; transform: translateY(12px); animation: reveal 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| 604 | @keyframes reveal { to { opacity: 1; transform: none; } } | |
| 605 | @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } } | |
| 606 | /* Keyboard focus — a visible ring on every interactive control (accessibility, | |
| 607 | not optional). Search gets an inset ring so it doesn't clash with its pill. | |
| 608 | ||
| 609 | This rule used to also declare \`border-radius: 8px\`, which was not a property | |
| 610 | of the ring — it RESHAPED THE ELEMENT for as long as it held focus. Tabbing | |
| 611 | onto a filter tab squared off its 999px pill; the same happened to the pager | |
| 612 | and to every rounded control here, because \`a:focus-visible\` (0,1,1) outranks | |
| 613 | \`.pill\` (0,1,0). Nothing about a keyboard user's focus should change an | |
| 614 | element's geometry. | |
| 615 | ||
| 616 | Nothing replaces it: \`outline\` has followed the element's own border-radius | |
| 617 | since Chrome 94 / Firefox 88 / Safari 16.4, so the ring is correctly rounded | |
| 618 | on a pill and correctly square on a square control, which is what the 8px was | |
| 619 | reaching for in the first place. */ | |
| 620 | a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible { | |
| 621 | outline: 2px solid var(--accent); outline-offset: 2px; | |
| 622 | } | |
| 623 | summary:focus-visible { outline-offset: -2px; } | |
| 624 | .search-input:focus-visible { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } | |
| 625 | /* Physical press feedback on a log row. */ | |
| 626 | .log-row:active { background: color-mix(in srgb, var(--accent) 9%, transparent); } | |
| 627 | /* Log stream aligned as fixed columns: rail · time · level · channel · message. */ | |
| 628 | .log-row { display: grid; grid-template-columns: 3px 4.75rem 3.75rem 6rem minmax(0, 1fr); align-items: center; column-gap: 0.75rem; } | |
| 629 | /* Subtle grain so the flat surfaces don't read as sterile. Fixed, non-blocking, | |
| 630 | barely-there; sits under the floating chrome (z-60). */ | |
| 631 | .grain { position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0.03; | |
| 632 | background-size: 170px 170px; | |
| 633 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } | |
| 634 | @media (prefers-reduced-motion: no-preference) { .grain { opacity: 0.032; } } | |
| 635 | /* Project dropdown: the native select chevron hugged the rounded border. Reset | |
| 636 | the native appearance and draw our own with a comfortable inset + room for the | |
| 637 | value text. Chevron is themed to --text-3 in each mode. */ | |
| 638 | select.app-panel { | |
| 639 | -webkit-appearance: none; -moz-appearance: none; appearance: none; | |
| 640 | /* The one declaration here that a utility also sets: the element carries | |
| 641 | * \`px-3\`, and layered this rule loses to it outright where it used to win | |
| 642 | * on specificity. Measured: padding-right 32px -> 12px, which puts the | |
| 643 | * chevron on top of the project name. The appearance and background | |
| 644 | * properties have no utility on this element and stay unmarked. */ | |
| 645 | padding-right: 2rem !important; background-repeat: no-repeat; | |
| 646 | background-position: right 0.7rem center; background-size: 13px; | |
| 647 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2397a1b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); | |
| 648 | } | |
| 649 | @media (prefers-color-scheme: dark) { select.app-panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); } } | |
| 650 | ||
| 651 | /* Log severity, driven by one data attribute rather than an inline style. | |
| 652 | * | |
| 653 | * The rail and the level badge on a log row both used | |
| 654 | * \`style="background: "\`, and the stat dot and | |
| 655 | * value used \`style="color: "\` — four inline declarations carrying | |
| 656 | * hex literals computed in the view. \`data-level\` on the row sets \`--sev\` once | |
| 657 | * and both children read it, so the colour is named in exactly one place | |
| 658 | * (public/tokens.css) and follows the theme. | |
| 659 | * | |
| 660 | * The default covers a level the server does not recognise: it falls back to | |
| 661 | * the debug tone rather than to no colour, so the rail never disappears. */ | |
| 662 | [data-level] { --sev: var(--sev-debug); } | |
| 663 | [data-level='emergency'], [data-level='alert'], [data-level='critical'] { --sev: var(--sev-critical); } | |
| 664 | [data-level='error'] { --sev: var(--sev-error); } | |
| 665 | [data-level='warning'] { --sev: var(--sev-warning); } | |
| 666 | [data-level='notice'] { --sev: var(--sev-notice); } | |
| 667 | [data-level='info'] { --sev: var(--sev-info); } | |
| 668 | .log-rail { background: var(--sev); min-height: 20px; } | |
| 669 | .log-level { color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); } | |
| 670 | /* One segment of the level-mix bars on /analytics. Reads the same --sev the | |
| 671 | * rail and the chip do, so a colour still means the same severity across the | |
| 672 | * whole app and the analytics page names no palette of its own. */ | |
| 673 | .level-seg { background: var(--sev); } | |
| 674 | ||
| 675 | /* Stat cards. \`data-tone\` is set only when the count is non-zero, so an empty | |
| 676 | * warnings/errors card stays neutral — which is what \`s.on\` expressed inline. */ | |
| 677 | .stat-dot { background: var(--text-3); opacity: 0.35; } | |
| 678 | .stat-value { color: var(--text); } | |
| 679 | [data-tone='warning'] .stat-dot { background: var(--sev-warning); opacity: 1; } | |
| 680 | [data-tone='warning'] .stat-value { color: var(--sev-warning); } | |
| 681 | [data-tone='error'] .stat-dot { background: var(--sev-critical); opacity: 1; } | |
| 682 | [data-tone='error'] .stat-value { color: var(--sev-critical); } | |
| 683 | ||
| 684 | /* --------------------------------------------------------------------------- | |
| 685 | * Narrow viewports | |
| 686 | * | |
| 687 | * This sheet had NO width query at all, while marketing.css has four. The app | |
| 688 | * surface is the half that people actually work in, and it was the half with no | |
| 689 | * mobile behaviour — the two rules below are both fixed geometry that a phone | |
| 690 | * cannot satisfy. | |
| 691 | * | |
| 692 | * Everything here adjusts rules THIS FILE owns. It deliberately does not try to | |
| 693 | * override a utility (the page's own \`px-5 py-10\`, for instance): this sheet is | |
| 694 | * linked before the Crosswind sheet precisely so utilities win a specificity | |
| 695 | * tie, so a \`.page-dashboard { padding-top }\` here would be silently dead. Any | |
| 696 | * responsive change to a utility-styled element belongs in the markup as an | |
| 697 | * \`sm:\`/\`md:\` variant. | |
| 698 | * | |
| 699 | * 40rem rather than a round 375px: the breakpoint is where the content stops | |
| 700 | * fitting, and it is in rem so it also fires for someone at a large default | |
| 701 | * font size on a wider screen, who has the same problem for a different reason. | |
| 702 | * ------------------------------------------------------------------------ */ | |
| 703 | @media (max-width: 40rem) { | |
| 704 | /* The log stream was a single row of five fixed columns: | |
| 705 | * 3px rail + 4.75rem time + 3.75rem level + 6rem channel | |
| 706 | * plus four 0.75rem gaps and the row's own 20px side padding — 323px spoken | |
| 707 | * for before the message column starts. On a 375px phone that leaves the | |
| 708 | * message 52px, about four characters, on the one line that carries what | |
| 709 | * actually happened. The row was not narrow, it was empty. | |
| 710 | * | |
| 711 | * It becomes two rows: metadata above, message across the full width below. | |
| 712 | * Nothing is dropped — the channel is not repeated in the expanded panel, so | |
| 713 | * hiding it would have lost the only place it appears. | |
| 714 | * | |
| 715 | * Addressed by position because these are bare spans in the markup with no | |
| 716 | * hooks of their own, and the order is fixed by the template: rail, time, | |
| 717 | * level, channel, message. */ | |
| 718 | .log-row { | |
| 719 | grid-template-columns: 3px auto auto minmax(0, 1fr); | |
| 720 | column-gap: 0.5rem; | |
| 721 | row-gap: 0.2rem; | |
| 722 | } | |
| 723 | ||
| 724 | /* The rail spans both rows so it stays one continuous severity stripe beside | |
| 725 | * the whole entry rather than a stub next to the timestamp. */ | |
| 726 | .log-row > :nth-child(1) { grid-row: 1 / span 2; } | |
| 727 | .log-row > :nth-child(5) { grid-column: 2 / -1; } | |
| 728 | ||
| 729 | /* The floating chrome is positioned for a desktop margin: \`top: 16px\` with | |
| 730 | * 8px padding puts its lower edge at about 46px, and the page's own content | |
| 731 | * starts at 40px. On a wide screen the centred column leaves that gutter | |
| 732 | * empty so nothing shows; on a phone the column is the full width and the | |
| 733 | * header runs underneath the buttons. | |
| 734 | * | |
| 735 | * Smaller and tighter, so the chrome's lower edge lands near 32px and closes | |
| 736 | * the overlap window rather than merely making it less likely. */ | |
| 737 | .chrome-logout, .chrome-upgrade { | |
| 738 | top: 10px; | |
| 739 | padding: 5px 10px; | |
| 740 | font-size: 12px; | |
| 741 | border-radius: 8px; | |
| 742 | } | |
| 743 | ||
| 744 | .chrome-logout { right: 12px; } | |
| 745 | .chrome-upgrade { right: 88px; } | |
| 746 | } | |
| 747 | ` | |
| 748 | ||
| 58 | 749 | /* |
| 59 | 750 | * The date-range picker's stylesheet, lifted out of a <style> block in |
| 60 | 751 | * resources/views/analytics.stx (stx-standards rule 3). |
| @@ -234,7 +925,13 @@ summary:focus-visible { | ||
| 234 | 925 | ` |
| 235 | 926 | |
| 236 | 927 | export default { |
| 237 | preflights: [{ getCSS: () => DATE_RANGE_PICKER_CSS }], | |
| 928 | preflights: [ | |
| 929 | // In the order the three stylesheets they came from were linked: | |
| 930 | // tokens, then the app chrome that reads them, then page CSS. | |
| 931 | { getCSS: () => TOKENS }, | |
| 932 | { getCSS: () => APP_CHROME }, | |
| 933 | { getCSS: () => DATE_RANGE_PICKER_CSS }, | |
| 934 | ], | |
| 238 | 935 | theme: { |
| 239 | 936 | extend: { |
| 240 | 937 | borderRadius: { |
| @@ -211,23 +211,16 @@ export default { | ||
| 211 | 211 | // /dashboard and /settings, which define their own conflicting versions of |
| 212 | 212 | // the same class names. It lives in resources/layouts/marketing.stx |
| 213 | 213 | // instead, so it arrives with the shell that needs it. |
| 214 | // | |
| 215 | // /tokens.css and /app-chrome.css used to be linked here and are gone: | |
| 216 | // both are `preflights` entries in config/crosswind.ts now, emitted into | |
| 217 | // the generated sheet (stx-standards rule 3). Nothing replaced them in | |
| 218 | // this list, because the generated sheet is injected by the renderer. | |
| 214 | 219 | link: [ |
| 215 | 220 | { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' }, |
| 216 | 221 | { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, |
| 217 | 222 | { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' }, |
| 218 | 223 | { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap' }, |
| 219 | // Design tokens, once. Ten app views each carried their own copy of | |
| 220 | // this block. Deliberately BEFORE /marketing.css, which the marketing | |
| 221 | // layout adds and which defines a richer overlapping set — later wins, | |
| 222 | // which is the intent. | |
| 223 | { rel: 'stylesheet', href: '/tokens.css' }, | |
| 224 | // App-surface CSS that utilities provably cannot express — | |
| 225 | // pseudo-elements, attribute-state selectors, bare element selectors, | |
| 226 | // @keyframes, and the dashboard's page-scoped accent. After | |
| 227 | // /tokens.css so it can read the palette; still before the Crosswind | |
| 228 | // sheet, which the renderer injects last, so a utility continues to | |
| 229 | // win a specificity tie against anything in here. | |
| 230 | { rel: 'stylesheet', href: '/app-chrome.css' }, | |
| 231 | 224 | ], |
| 232 | 225 | }, |
| 233 | 226 | }, |
| @@ -1,370 +0,0 @@ | ||
| 1 | /* App-surface CSS that utilities provably cannot express. | |
| 2 | * | |
| 3 | * Everything here was measured against Crosswind's generated sheet before it | |
| 4 | * was moved: pseudo-elements, attribute-state selectors, bare element | |
| 5 | * selectors, `@keyframes`, and page-scoped custom properties all produce NO | |
| 6 | * rule when written as a utility or variant. The failure is silent — no error, | |
| 7 | * no warning, just a class that does nothing — so the test is always "does the | |
| 8 | * served sheet contain it", never "does it look plausible". | |
| 9 | * | |
| 10 | * `config/crosswind.ts` `preflights` is where ch.11.2/11.7 says this belongs. | |
| 11 | * It does not work on the stx serve path: the entry is spread into the | |
| 12 | * generator config and then never emitted, verified with a probe token that | |
| 13 | * appears nowhere in the served CSS on any route. A linked stylesheet is the | |
| 14 | * substitute, and it is also the router-safe home — the SPA router destroys | |
| 15 | * every <head> style that is not the Crosswind tag, but leaves <link>s alone. | |
| 16 | * | |
| 17 | * Linked once from config/ui.ts `app.head.link`, after /tokens.css so it can | |
| 18 | * read the palette, before the Crosswind sheet so utilities still win a | |
| 19 | * specificity tie against anything here. | |
| 20 | * | |
| 21 | * Rules that WERE expressible are deliberately absent — they live as utilities | |
| 22 | * in the markup. Do not move a rule back here because it is more convenient. | |
| 23 | */ | |
| 24 | ||
| 25 | /* --------------------------------------------------------------------------- | |
| 26 | * Page-scoped tokens | |
| 27 | * | |
| 28 | * dashboard is the one surface with its own accent: indigo, so navigation | |
| 29 | * never reads as a severity colour (see 20f40ed). It used to be a `:root` | |
| 30 | * block inside dashboard.stx's <style>, scoped only by the accident of which | |
| 31 | * page carried the tag. Declared globally that would repaint the whole app, so | |
| 32 | * it hangs off a wrapper class on the dashboard's root element and cascades to | |
| 33 | * its descendants — including the fixed-position chrome, which is inside the | |
| 34 | * wrapper. | |
| 35 | * ------------------------------------------------------------------------ */ | |
| 36 | .page-dashboard { | |
| 37 | --accent: #4f46e5; | |
| 38 | --rail: rgba(79, 70, 229, 0.14); | |
| 39 | } | |
| 40 | ||
| 41 | @media (prefers-color-scheme: dark) { | |
| 42 | .page-dashboard { | |
| 43 | --accent: #818cf8; | |
| 44 | --rail: rgba(129, 140, 248, 0.16); | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | :root[data-theme='dark'] .page-dashboard { | |
| 49 | --accent: #818cf8; | |
| 50 | --rail: rgba(129, 140, 248, 0.16); | |
| 51 | } | |
| 52 | ||
| 53 | :root[data-theme='light'] .page-dashboard { | |
| 54 | --accent: #4f46e5; | |
| 55 | --rail: rgba(79, 70, 229, 0.14); | |
| 56 | } | |
| 57 | ||
| 58 | /* Channel brand colours. Only settings uses them, but they are declared here | |
| 59 | * with everything else rather than in a page tag. */ | |
| 60 | :root { | |
| 61 | --slack: #611f69; | |
| 62 | --discord: #5865f2; | |
| 63 | } | |
| 64 | ||
| 65 | @media (prefers-color-scheme: dark) { | |
| 66 | :root { | |
| 67 | --discord: #818cf8; | |
| 68 | } | |
| 69 | } | |
| 70 | ||
| 71 | :root[data-theme='dark'] { | |
| 72 | --discord: #818cf8; | |
| 73 | } | |
| 74 | ||
| 75 | :root[data-theme='light'] { | |
| 76 | --discord: #5865f2; | |
| 77 | } | |
| 78 | ||
| 79 | /* Tabular numerals for the log stream's counts and timestamps, so digits do | |
| 80 | * not jitter as values change. | |
| 81 | * | |
| 82 | * This is a real rule rather than an arbitrary property because the value has | |
| 83 | * to be quoted — `font-feature-settings: 'tnum' 1` — and a quoted value cannot | |
| 84 | * survive Crosswind's class-name syntax. Written as | |
| 85 | * [font-feature-settings:'tnum'_1] it generates NOTHING; the three | |
| 86 | * font-feature-settings declarations in the served sheet are all preflight | |
| 87 | * resets, and the computed value on the element stayed `normal`. | |
| 88 | * | |
| 89 | * `tabular-nums` is not a substitute: it sets font-variant-numeric, a | |
| 90 | * different property, and would not reproduce the measured baseline. | |
| 91 | */ | |
| 92 | .tnum { | |
| 93 | font-feature-settings: 'tnum' 1; | |
| 94 | } | |
| 95 | ||
| 96 | /* --------------------------------------------------------------------------- | |
| 97 | * Bare element selectors — no utility targets an element type | |
| 98 | * ------------------------------------------------------------------------ */ | |
| 99 | html { | |
| 100 | scroll-behavior: smooth; | |
| 101 | } | |
| 102 | ||
| 103 | /* --------------------------------------------------------------------------- | |
| 104 | * settings: the alert-channel toggle | |
| 105 | * | |
| 106 | * The knob is a ::after pseudo-element and both states are attribute-driven. | |
| 107 | * Crosswind generates nothing for `after:` or for a `[data-on]` variant. | |
| 108 | * ------------------------------------------------------------------------ */ | |
| 109 | .tgl::after { | |
| 110 | content: ''; | |
| 111 | position: absolute; | |
| 112 | top: 2px; | |
| 113 | left: 2px; | |
| 114 | width: 18px; | |
| 115 | height: 18px; | |
| 116 | border-radius: 999px; | |
| 117 | background: #fff; | |
| 118 | transition: transform 0.15s ease; | |
| 119 | } | |
| 120 | ||
| 121 | .tgl[data-on='1'] { | |
| 122 | background: var(--ok); | |
| 123 | } | |
| 124 | ||
| 125 | .tgl[data-on='1']::after { | |
| 126 | transform: translateX(16px); | |
| 127 | } | |
| 128 | ||
| 129 | /* settings: the Slack/Discord channel picker. Selected state is attribute | |
| 130 | * driven, and each brand paints its own fill. */ | |
| 131 | .seg[data-sel='1'] { | |
| 132 | color: var(--accent-ink); | |
| 133 | border-color: transparent; | |
| 134 | } | |
| 135 | ||
| 136 | .seg-slack[data-sel='1'] { | |
| 137 | background: var(--slack); | |
| 138 | } | |
| 139 | ||
| 140 | .seg-discord[data-sel='1'] { | |
| 141 | background: var(--discord); | |
| 142 | } | |
| 143 | ||
| 144 | /* settings: delete-zone modal. `[hidden]` has to beat the overlay's own | |
| 145 | * `display: flex`, which a utility cannot guarantee — Crosswind's preflight | |
| 146 | * `[hidden]{display:none}` ties with `.flex` on specificity and loses on | |
| 147 | * order. */ | |
| 148 | .dz-overlay[hidden] { | |
| 149 | display: none; | |
| 150 | } | |
| 151 | ||
| 152 | @keyframes dz-fade { | |
| 153 | from { | |
| 154 | opacity: 0; | |
| 155 | } | |
| 156 | ||
| 157 | to { | |
| 158 | opacity: 1; | |
| 159 | } | |
| 160 | } | |
| 161 | ||
| 162 | @keyframes dz-pop { | |
| 163 | from { | |
| 164 | opacity: 0; | |
| 165 | transform: translateY(6px) scale(0.98); | |
| 166 | } | |
| 167 | ||
| 168 | to { | |
| 169 | opacity: 1; | |
| 170 | transform: none; | |
| 171 | } | |
| 172 | } | |
| 173 | ||
| 174 | /* --------------------------------------------------------------------------- | |
| 175 | * dashboard | |
| 176 | * | |
| 177 | * Moved verbatim from dashboard.stx's <style> block. Every family below is | |
| 178 | * entangled with a selector no utility reaches, so splitting them would have | |
| 179 | * meant half a component in each place: | |
| 180 | * | |
| 181 | * .pill* hover/active are tag-scoped (`a.pill:hover, button.pill:hover`) | |
| 182 | * on purpose — the disabled pager is a <span class="pill"> and | |
| 183 | * must NOT light up on hover. | |
| 184 | * .log-row `details[open] > .log-row` has to outrank the hover state. | |
| 185 | * .search-input ::placeholder and :focus-visible. | |
| 186 | * .chrome-* `.chrome-pro, .chrome-pro:hover` cancels .chrome-upgrade's | |
| 187 | * hover: Pro is a status, not an action. | |
| 188 | * summary bare element selector plus ::-webkit-details-marker. | |
| 189 | * :focus-visible a bare-element accessibility rule, not optional. | |
| 190 | * .grain, data-URI backgrounds with prefers-color-scheme variants. | |
| 191 | * select.app-panel | |
| 192 | * .reveal @keyframes plus a reduced-motion variant. | |
| 193 | * | |
| 194 | * What IS expressible went the other way and is now utilities in the markup: | |
| 195 | * .mono, .wordmark, .wordmark-link, .panel, .lvl-chip, .ctx-pre and .icon-btn. | |
| 196 | * ------------------------------------------------------------------------ */ | |
| 197 | .pill { border-radius: 999px; transition: color 0.15s ease, border-color 0.15s ease, background-color 0.15s ease, transform 0.12s ease; } | |
| 198 | a.pill:hover, button.pill:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); background: color-mix(in srgb, var(--accent) 6%, transparent); } | |
| 199 | a.pill:active, button.pill:active { transform: translateY(1px); } | |
| 200 | /* Filter-tab state. These were inline style ternaries on every tab, which | |
| 201 | outrank the hover rules above and could not be themed. The two levels differ | |
| 202 | because the severity tabs are the primary control and the range tabs are | |
| 203 | secondary. */ | |
| 204 | .pill-on { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 205 | a.pill-on:hover { color: var(--accent-ink); background: var(--accent); border-color: var(--accent); } | |
| 206 | .pill-off { color: var(--text-2); border-color: var(--border); } | |
| 207 | .pill-on-soft { color: var(--accent); border-color: var(--accent); } | |
| 208 | .pill-off-soft { color: var(--text-3); border-color: var(--border); } | |
| 209 | .log-row { transition: background-color 0.12s ease; } | |
| 210 | .log-row:hover { background: color-mix(in srgb, var(--accent) 4%, transparent); } | |
| 211 | details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); } | |
| 212 | summary { list-style: none; cursor: pointer; } | |
| 213 | summary::-webkit-details-marker { display: none; } | |
| 214 | .search-input { background: var(--panel); border: 1px solid var(--border); border-radius: 999px; color: var(--text); outline: none; transition: border-color 0.15s ease; } | |
| 215 | .search-input:focus { border-color: color-mix(in srgb, var(--accent) 50%, var(--border)); } | |
| 216 | .search-input::placeholder { color: var(--text-3); } | |
| 217 | /* Position was an inline style string assembled in JS. It lives here now, so | |
| 218 | the chrome is styleable and themeable like everything else. */ | |
| 219 | .chrome-logout, .chrome-upgrade { position: fixed; top: 16px; z-index: 60; padding: 8px 13px; border-radius: 9px; font-size: 13px; line-height: 1; font-family: var(--sans, ui-sans-serif); } | |
| 220 | .chrome-logout { right: 18px; font-weight: 500; cursor: pointer; border: 1px solid var(--border); background: var(--panel); color: var(--text-2); transition: color 0.15s ease, border-color 0.15s ease; } | |
| 221 | .chrome-logout:hover { color: var(--text); border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); } | |
| 222 | .chrome-upgrade { right: 112px; font-weight: 600; text-decoration: none; background: var(--accent); color: var(--accent-ink); transition: filter 0.15s ease, transform 0.12s ease; } | |
| 223 | .chrome-upgrade:hover { filter: brightness(1.12); } | |
| 224 | .chrome-upgrade:active { transform: translateY(1px); } | |
| 225 | /* Pro is a status, not an action: no link, no hover lift. */ | |
| 226 | .chrome-pro, .chrome-pro:hover { background: transparent; color: var(--accent); border: 1px solid var(--accent); cursor: default; filter: none; } | |
| 227 | /* The ONLY .reveal in the app. marketing.css carried a second copy with | |
| 228 | different values (0.6s/12px against this one's 0.5s/10px) and a second | |
| 229 | @keyframes of the same name; both sheets load on every marketing page, so the | |
| 230 | marketing copy won there and this one only ever reached the dashboard. These | |
| 231 | are the marketing values, kept because 23 pages used them and one did not. */ | |
| 232 | .reveal { opacity: 0; transform: translateY(12px); animation: reveal 0.6s cubic-bezier(0.16, 1, 0.3, 1) forwards; } | |
| 233 | @keyframes reveal { to { opacity: 1; transform: none; } } | |
| 234 | @media (prefers-reduced-motion: reduce) { .reveal { animation: none; opacity: 1; transform: none; } } | |
| 235 | /* Keyboard focus — a visible ring on every interactive control (accessibility, | |
| 236 | not optional). Search gets an inset ring so it doesn't clash with its pill. | |
| 237 | ||
| 238 | This rule used to also declare `border-radius: 8px`, which was not a property | |
| 239 | of the ring — it RESHAPED THE ELEMENT for as long as it held focus. Tabbing | |
| 240 | onto a filter tab squared off its 999px pill; the same happened to the pager | |
| 241 | and to every rounded control here, because `a:focus-visible` (0,1,1) outranks | |
| 242 | `.pill` (0,1,0). Nothing about a keyboard user's focus should change an | |
| 243 | element's geometry. | |
| 244 | ||
| 245 | Nothing replaces it: `outline` has followed the element's own border-radius | |
| 246 | since Chrome 94 / Firefox 88 / Safari 16.4, so the ring is correctly rounded | |
| 247 | on a pill and correctly square on a square control, which is what the 8px was | |
| 248 | reaching for in the first place. */ | |
| 249 | a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible { | |
| 250 | outline: 2px solid var(--accent); outline-offset: 2px; | |
| 251 | } | |
| 252 | summary:focus-visible { outline-offset: -2px; } | |
| 253 | .search-input:focus-visible { outline: none; border-color: color-mix(in srgb, var(--accent) 60%, var(--border)); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); } | |
| 254 | /* Physical press feedback on a log row. */ | |
| 255 | .log-row:active { background: color-mix(in srgb, var(--accent) 9%, transparent); } | |
| 256 | /* Log stream aligned as fixed columns: rail · time · level · channel · message. */ | |
| 257 | .log-row { display: grid; grid-template-columns: 3px 4.75rem 3.75rem 6rem minmax(0, 1fr); align-items: center; column-gap: 0.75rem; } | |
| 258 | /* Subtle grain so the flat surfaces don't read as sterile. Fixed, non-blocking, | |
| 259 | barely-there; sits under the floating chrome (z-60). */ | |
| 260 | .grain { position: fixed; inset: 0; z-index: 5; pointer-events: none; opacity: 0.03; | |
| 261 | background-size: 170px 170px; | |
| 262 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='170' height='170'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.8' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); } | |
| 263 | @media (prefers-reduced-motion: no-preference) { .grain { opacity: 0.032; } } | |
| 264 | /* Project dropdown: the native select chevron hugged the rounded border. Reset | |
| 265 | the native appearance and draw our own with a comfortable inset + room for the | |
| 266 | value text. Chevron is themed to --text-3 in each mode. */ | |
| 267 | select.app-panel { | |
| 268 | -webkit-appearance: none; -moz-appearance: none; appearance: none; | |
| 269 | padding-right: 2rem; background-repeat: no-repeat; | |
| 270 | background-position: right 0.7rem center; background-size: 13px; | |
| 271 | background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%2397a1b2' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); | |
| 272 | } | |
| 273 | @media (prefers-color-scheme: dark) { select.app-panel { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23667085' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E"); } } | |
| 274 | ||
| 275 | /* Log severity, driven by one data attribute rather than an inline style. | |
| 276 | * | |
| 277 | * The rail and the level badge on a log row both used | |
| 278 | * `style="background: "`, and the stat dot and | |
| 279 | * value used `style="color: "` — four inline declarations carrying | |
| 280 | * hex literals computed in the view. `data-level` on the row sets `--sev` once | |
| 281 | * and both children read it, so the colour is named in exactly one place | |
| 282 | * (public/tokens.css) and follows the theme. | |
| 283 | * | |
| 284 | * The default covers a level the server does not recognise: it falls back to | |
| 285 | * the debug tone rather than to no colour, so the rail never disappears. */ | |
| 286 | [data-level] { --sev: var(--sev-debug); } | |
| 287 | [data-level='emergency'], [data-level='alert'], [data-level='critical'] { --sev: var(--sev-critical); } | |
| 288 | [data-level='error'] { --sev: var(--sev-error); } | |
| 289 | [data-level='warning'] { --sev: var(--sev-warning); } | |
| 290 | [data-level='notice'] { --sev: var(--sev-notice); } | |
| 291 | [data-level='info'] { --sev: var(--sev-info); } | |
| 292 | .log-rail { background: var(--sev); min-height: 20px; } | |
| 293 | .log-level { color: var(--sev); background: color-mix(in srgb, var(--sev) 14%, transparent); } | |
| 294 | /* One segment of the level-mix bars on /analytics. Reads the same --sev the | |
| 295 | * rail and the chip do, so a colour still means the same severity across the | |
| 296 | * whole app and the analytics page names no palette of its own. */ | |
| 297 | .level-seg { background: var(--sev); } | |
| 298 | ||
| 299 | /* Stat cards. `data-tone` is set only when the count is non-zero, so an empty | |
| 300 | * warnings/errors card stays neutral — which is what `s.on` expressed inline. */ | |
| 301 | .stat-dot { background: var(--text-3); opacity: 0.35; } | |
| 302 | .stat-value { color: var(--text); } | |
| 303 | [data-tone='warning'] .stat-dot { background: var(--sev-warning); opacity: 1; } | |
| 304 | [data-tone='warning'] .stat-value { color: var(--sev-warning); } | |
| 305 | [data-tone='error'] .stat-dot { background: var(--sev-critical); opacity: 1; } | |
| 306 | [data-tone='error'] .stat-value { color: var(--sev-critical); } | |
| 307 | ||
| 308 | /* --------------------------------------------------------------------------- | |
| 309 | * Narrow viewports | |
| 310 | * | |
| 311 | * This sheet had NO width query at all, while marketing.css has four. The app | |
| 312 | * surface is the half that people actually work in, and it was the half with no | |
| 313 | * mobile behaviour — the two rules below are both fixed geometry that a phone | |
| 314 | * cannot satisfy. | |
| 315 | * | |
| 316 | * Everything here adjusts rules THIS FILE owns. It deliberately does not try to | |
| 317 | * override a utility (the page's own `px-5 py-10`, for instance): this sheet is | |
| 318 | * linked before the Crosswind sheet precisely so utilities win a specificity | |
| 319 | * tie, so a `.page-dashboard { padding-top }` here would be silently dead. Any | |
| 320 | * responsive change to a utility-styled element belongs in the markup as an | |
| 321 | * `sm:`/`md:` variant. | |
| 322 | * | |
| 323 | * 40rem rather than a round 375px: the breakpoint is where the content stops | |
| 324 | * fitting, and it is in rem so it also fires for someone at a large default | |
| 325 | * font size on a wider screen, who has the same problem for a different reason. | |
| 326 | * ------------------------------------------------------------------------ */ | |
| 327 | @media (max-width: 40rem) { | |
| 328 | /* The log stream was a single row of five fixed columns: | |
| 329 | * 3px rail + 4.75rem time + 3.75rem level + 6rem channel | |
| 330 | * plus four 0.75rem gaps and the row's own 20px side padding — 323px spoken | |
| 331 | * for before the message column starts. On a 375px phone that leaves the | |
| 332 | * message 52px, about four characters, on the one line that carries what | |
| 333 | * actually happened. The row was not narrow, it was empty. | |
| 334 | * | |
| 335 | * It becomes two rows: metadata above, message across the full width below. | |
| 336 | * Nothing is dropped — the channel is not repeated in the expanded panel, so | |
| 337 | * hiding it would have lost the only place it appears. | |
| 338 | * | |
| 339 | * Addressed by position because these are bare spans in the markup with no | |
| 340 | * hooks of their own, and the order is fixed by the template: rail, time, | |
| 341 | * level, channel, message. */ | |
| 342 | .log-row { | |
| 343 | grid-template-columns: 3px auto auto minmax(0, 1fr); | |
| 344 | column-gap: 0.5rem; | |
| 345 | row-gap: 0.2rem; | |
| 346 | } | |
| 347 | ||
| 348 | /* The rail spans both rows so it stays one continuous severity stripe beside | |
| 349 | * the whole entry rather than a stub next to the timestamp. */ | |
| 350 | .log-row > :nth-child(1) { grid-row: 1 / span 2; } | |
| 351 | .log-row > :nth-child(5) { grid-column: 2 / -1; } | |
| 352 | ||
| 353 | /* The floating chrome is positioned for a desktop margin: `top: 16px` with | |
| 354 | * 8px padding puts its lower edge at about 46px, and the page's own content | |
| 355 | * starts at 40px. On a wide screen the centred column leaves that gutter | |
| 356 | * empty so nothing shows; on a phone the column is the full width and the | |
| 357 | * header runs underneath the buttons. | |
| 358 | * | |
| 359 | * Smaller and tighter, so the chrome's lower edge lands near 32px and closes | |
| 360 | * the overlap window rather than merely making it less likely. */ | |
| 361 | .chrome-logout, .chrome-upgrade { | |
| 362 | top: 10px; | |
| 363 | padding: 5px 10px; | |
| 364 | font-size: 12px; | |
| 365 | border-radius: 8px; | |
| 366 | } | |
| 367 | ||
| 368 | .chrome-logout { right: 12px; } | |
| 369 | .chrome-upgrade { right: 88px; } | |
| 370 | } | |
| @@ -1,277 +0,0 @@ | ||
| 1 | /* loghq design tokens — the single source for the app surface. | |
| 2 | * | |
| 3 | * Linked once from config/ui.ts `app.head.link`, so every page gets it without | |
| 4 | * repeating it. Ten app views used to declare their own byte-similar copy of | |
| 5 | * this block: 90 custom-property declarations in four subtly diverging | |
| 6 | * variants, which is how the dashboard's `--mono` stack ended up one entry | |
| 7 | * longer than everyone else's. | |
| 8 | * | |
| 9 | * It is NOT in config/crosswind.ts `preflights`, which is where the styling | |
| 10 | * guide says design tokens belong. Verified against the installed build: | |
| 11 | * `preflights` is spread into the generator config and then never emitted — | |
| 12 | * toCSS() writes the built-in reset only. A probe token placed there appears | |
| 13 | * nowhere in the served CSS on any route. | |
| 14 | * | |
| 15 | * These names are the contract config/crosswind.ts maps its semantic utilities | |
| 16 | * onto — `bg-canvas`, `bg-panel`, `border-line`, `text-ink`, `text-muted`, | |
| 17 | * `text-subtle`, `text-accent`. Renaming one here silently breaks the matching | |
| 18 | * utility everywhere, because an undefined var() resolves to the property's | |
| 19 | * initial value rather than erroring. | |
| 20 | * | |
| 21 | * Marketing pages additionally load /marketing.css, which arrives after this | |
| 22 | * file and defines a richer set of its own (--surface, --accent-line, --r, …). | |
| 23 | * --accent-soft moved the other way, out of marketing.css and into here, the | |
| 24 | * day the compare tables needed a `bg-accent-soft` utility: a utility backed | |
| 25 | * by a token that only marketing pages declare is dead everywhere else and | |
| 26 | * fails silently. marketing.css still consumes it (.live-dot, .cell.tint, | |
| 27 | * .cell.feature) and resolves it from this file. | |
| 28 | * Where the two overlap the values agree; where they differ, marketing.css | |
| 29 | * wins on marketing pages, which is the intent. | |
| 30 | * | |
| 31 | * Both the media query and the [data-theme] attribute are covered, and that | |
| 32 | * pairing is deliberate: the attribute is what the pre-paint theme script and | |
| 33 | * the theme store write, and it has to beat the OS preference so an explicit | |
| 34 | * choice survives. | |
| 35 | */ | |
| 36 | ||
| 37 | :root { | |
| 38 | /* The one declaration that reaches the parts of the page we do not draw. | |
| 39 | * | |
| 40 | * Native UI — the select popup, scrollbars, the date and colour pickers, form | |
| 41 | * control backgrounds, the spellcheck underline, the caret — is painted by the | |
| 42 | * browser from `color-scheme`, not from our custom properties. Undeclared it | |
| 43 | * defaults to `light`, so on a dark loghq the project dropdown opened as a | |
| 44 | * white popup on a #090b11 page and the scrollbars stayed light. No amount of | |
| 45 | * token work reaches those pixels; this is the only thing that does. | |
| 46 | * | |
| 47 | * It is declared on all four blocks below and not once on `:root` with both | |
| 48 | * values, because `color-scheme: light dark` means "either is fine, follow the | |
| 49 | * OS" — which is wrong the moment someone picks a theme explicitly. The | |
| 50 | * attribute blocks have to be able to override the media query, exactly like | |
| 51 | * every colour here. */ | |
| 52 | color-scheme: light; | |
| 53 | --bg: #fbfbfd; | |
| 54 | --panel: #ffffff; | |
| 55 | --border: rgba(15, 23, 42, 0.09); | |
| 56 | --text: #0b0f19; | |
| 57 | --text-2: #4b5565; | |
| 58 | /* --text-3 was #97a1b2, which measured 2.52:1 against --bg and 2.61:1 | |
| 59 | * against --panel. WCAG AA wants 4.5:1 for body text, so every "subtle" | |
| 60 | * label in the app failed it — 73 elements in light and 82 in dark, measured | |
| 61 | * in a real browser across seven pages. It is the same token in both themes | |
| 62 | * and it failed in both (dark was #667085, 3.73:1 on --panel). | |
| 63 | * | |
| 64 | * #6b7280 gives 4.83:1 on --panel and 4.68:1 on --bg; the dark #79839a gives | |
| 65 | * 4.88:1 and 5.18:1. Both are the smallest step that clears AA on the worse | |
| 66 | * of the two surfaces, so "subtle" still reads as subtle. */ | |
| 67 | --text-3: #6b7280; | |
| 68 | /* Emerald, not the rose this shipped with. | |
| 69 | * | |
| 70 | * The old --accent was #e11d48, which is byte-for-byte --sev-error below. In | |
| 71 | * a product whose whole surface is log severity, the brand colour WAS the | |
| 72 | * error colour: every button, link and focus ring carried the one hue the | |
| 73 | * severity scale reserves for something being wrong. | |
| 74 | * | |
| 75 | * Green collides with --ok instead, which is the milder trade. --ok is a UI | |
| 76 | * success state, not a severity: nothing in the sev-* scale is green, so the | |
| 77 | * severity reading stays unambiguous. Brand-as-healthy misleads nobody; | |
| 78 | * brand-as-error did. | |
| 79 | * | |
| 80 | * #047857 rather than the more obvious #059669: emerald-600 measures 3.65:1 | |
| 81 | * on --bg, under the 4.5 floor this file holds everything else to. This one | |
| 82 | * measures 5.31:1 on --bg and 5.48:1 on --panel, which is also an improvement | |
| 83 | * on the rose it replaces (4.54:1, passing by 0.04). */ | |
| 84 | --accent: #047857; | |
| 85 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 86 | /* Ink for text sitting ON a saturated fill — the accent, --warn, and the | |
| 87 | * Slack/Discord brand colours. | |
| 88 | * | |
| 89 | * It cannot be a hardcoded #fff, which is what the app markup and | |
| 90 | * app-chrome.css assumed. In dark mode the fills get LIGHTER, not darker: | |
| 91 | * --accent is #34d399 and the dashboard's indigo is #818cf8, where white | |
| 92 | * measures 1.92:1 and 2.98:1. The dark value here measures 9.83:1 and 6.34:1 | |
| 93 | * on those same fills, and 6.83:1 on the dark --warn. | |
| 94 | * | |
| 95 | * marketing.css has had this token, correctly themed, since the marketing | |
| 96 | * pages were built; it just never reached the app surface, because that | |
| 97 | * sheet only loads on marketing routes. Declared here it covers both, and | |
| 98 | * marketing.css no longer declares its own copy — see the .reveal note there | |
| 99 | * for what two copies of one token cost. */ | |
| 100 | --accent-ink: #ffffff; | |
| 101 | --ok: #16a34a; | |
| 102 | --warn: #b91c1c; | |
| 103 | --sans: 'Space Grotesk', ui-sans-serif, system-ui, sans-serif; | |
| 104 | --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace; | |
| 105 | ||
| 106 | /* Log severity. These were six hex literals inside a levelColor() switch in | |
| 107 | * dashboard.stx, with two of them repeated in the stats array — so the same | |
| 108 | * "warning amber" was written twice and could drift. | |
| 109 | * | |
| 110 | * They were also a single set used on BOTH themes. The dark values below are | |
| 111 | * exactly what shipped before, so dark is unchanged; light gets darker, | |
| 112 | * higher-contrast variants, because #fb7185 on a #fbfbfd background is not a | |
| 113 | * readable label. */ | |
| 114 | --sev-critical: #be123c; | |
| 115 | --sev-error: #e11d48; | |
| 116 | --sev-warning: #b45309; | |
| 117 | --sev-notice: #0369a1; | |
| 118 | --sev-info: #1d4ed8; | |
| 119 | --sev-debug: #64748b; | |
| 120 | } | |
| 121 | ||
| 122 | @media (prefers-color-scheme: dark) { | |
| 123 | :root { | |
| 124 | color-scheme: dark; | |
| 125 | --bg: #090b11; | |
| 126 | --panel: #10131c; | |
| 127 | --border: rgba(148, 163, 184, 0.12); | |
| 128 | --text: #f3f5f9; | |
| 129 | --text-2: #b3bccb; | |
| 130 | --text-3: #79839a; | |
| 131 | --accent: #34d399; | |
| 132 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 133 | --accent-ink: #04140d; | |
| 134 | --ok: #4ade80; | |
| 135 | --warn: #f87171; | |
| 136 | --sev-critical: #f43f5e; | |
| 137 | --sev-error: #fb7185; | |
| 138 | --sev-warning: #f59e0b; | |
| 139 | --sev-notice: #38bdf8; | |
| 140 | --sev-info: #60a5fa; | |
| 141 | --sev-debug: #94a3b8; | |
| 142 | } | |
| 143 | } | |
| 144 | ||
| 145 | :root[data-theme='dark'] { | |
| 146 | color-scheme: dark; | |
| 147 | --bg: #090b11; | |
| 148 | --panel: #10131c; | |
| 149 | --border: rgba(148, 163, 184, 0.12); | |
| 150 | --text: #f3f5f9; | |
| 151 | --text-2: #b3bccb; | |
| 152 | --text-3: #79839a; | |
| 153 | --accent: #34d399; | |
| 154 | --accent-soft: rgba(52, 211, 153, 0.1); | |
| 155 | --accent-ink: #04140d; | |
| 156 | --ok: #4ade80; | |
| 157 | --warn: #f87171; | |
| 158 | --sev-critical: #f43f5e; | |
| 159 | --sev-error: #fb7185; | |
| 160 | --sev-warning: #f59e0b; | |
| 161 | --sev-notice: #38bdf8; | |
| 162 | --sev-info: #60a5fa; | |
| 163 | --sev-debug: #94a3b8; | |
| 164 | } | |
| 165 | ||
| 166 | :root[data-theme='light'] { | |
| 167 | color-scheme: light; | |
| 168 | --bg: #fbfbfd; | |
| 169 | --panel: #ffffff; | |
| 170 | --border: rgba(15, 23, 42, 0.09); | |
| 171 | --text: #0b0f19; | |
| 172 | --text-2: #4b5565; | |
| 173 | --text-3: #6b7280; | |
| 174 | --accent: #047857; | |
| 175 | --accent-soft: rgba(4, 120, 87, 0.08); | |
| 176 | --accent-ink: #ffffff; | |
| 177 | --ok: #16a34a; | |
| 178 | --warn: #b91c1c; | |
| 179 | --sev-critical: #be123c; | |
| 180 | --sev-error: #e11d48; | |
| 181 | --sev-warning: #b45309; | |
| 182 | --sev-notice: #0369a1; | |
| 183 | --sev-info: #1d4ed8; | |
| 184 | --sev-debug: #64748b; | |
| 185 | } | |
| 186 | ||
| 187 | body { | |
| 188 | background: var(--bg); | |
| 189 | color: var(--text); | |
| 190 | font-family: var(--sans); | |
| 191 | -webkit-font-smoothing: antialiased; | |
| 192 | } | |
| 193 | ||
| 194 | /* Skeleton shimmer, from account.stx's <style> block. Utilities cannot declare | |
| 195 | * a @keyframes, and config/crosswind.ts `preflights` — the documented home for | |
| 196 | * one — is never emitted on this serve path, so it lives here. | |
| 197 | * | |
| 198 | * Named loghq-pulse rather than pulse on purpose. Crosswind emits its own | |
| 199 | * `@keyframes pulse` (0%,100% opacity 1; 50% opacity .5) as soon as any page | |
| 200 | * uses `animate-pulse`, into a sheet that is linked AFTER this file. A | |
| 201 | * same-named keyframe there would silently replace this one and change the | |
| 202 | * animation rather than error. | |
| 203 | */ | |
| 204 | @keyframes loghq-pulse { | |
| 205 | 0%, | |
| 206 | 100% { | |
| 207 | opacity: 0.5; | |
| 208 | } | |
| 209 | 50% { | |
| 210 | opacity: 0.9; | |
| 211 | } | |
| 212 | } | |
| 213 | ||
| 214 | /* The one element that uses it, as a named class rather than an arbitrary | |
| 215 | * `[animation:loghq-pulse_1.4s_ease-in-out_infinite]` utility. | |
| 216 | * | |
| 217 | * That utility worked. What never worked was the guard beside it, | |
| 218 | * `motion-reduce:[animation:none]`: a variant on an arbitrary property is not | |
| 219 | * recognised by crosswind's class extractor, so it produced no rule at all and | |
| 220 | * the skeleton kept pulsing for people who asked their system not to animate. | |
| 221 | * Silent, as that whole class of miss is -- no error, no warning, nothing in | |
| 222 | * the sheet. Filed as stacksjs/ts-css#37; the generator side of it is fixed in | |
| 223 | * 0.3.14 (#36), the extractor is not. | |
| 224 | * | |
| 225 | * Both halves live here, in source order, so the reduced-motion case does not | |
| 226 | * depend on the extractor, on crosswind's output ordering, or on an | |
| 227 | * !important. It also cannot be separated from the keyframes it guards. Note | |
| 228 | * this file is linked BEFORE the generated sheet, which is why the animation | |
| 229 | * is declared here rather than left as a utility for this rule to fight. | |
| 230 | */ | |
| 231 | .skeleton-pulse { | |
| 232 | animation: loghq-pulse 1.4s ease-in-out infinite; | |
| 233 | } | |
| 234 | ||
| 235 | @media (prefers-reduced-motion: reduce) { | |
| 236 | .skeleton-pulse { | |
| 237 | animation: none; | |
| 238 | } | |
| 239 | } | |
| 240 | ||
| 241 | /* Theme-toggle icon swap. Three identical copies of this lived in | |
| 242 | * dashboard.stx, settings.stx and projects/index.stx. | |
| 243 | * | |
| 244 | * It stays CSS because no utility can express it: the selector is an | |
| 245 | * attribute-state descendant, `[data-t="light"] .ic-sun`, driven by the | |
| 246 | * `theme()` signal through `:data-t` on the button. The alternative — a | |
| 247 | * `:show` binding on each icon — would need `style="display:none"` on both to | |
| 248 | * avoid showing both icons before hydration, trading a state rule for two | |
| 249 | * inline styles and a hydration dependency on something that is currently | |
| 250 | * pure CSS. | |
| 251 | */ | |
| 252 | .theme-btn .ic-sun, | |
| 253 | .theme-btn .ic-moon { | |
| 254 | display: none; | |
| 255 | } | |
| 256 | ||
| 257 | /* Keyed off the ROOT element, not off the button. | |
| 258 | * | |
| 259 | * These used to read `.theme-btn[data-t='light']`, where data-t came from a | |
| 260 | * `:data-t="theme()"` binding — a client signal. So before hydration the button | |
| 261 | * carried no data-t at all, neither rule matched, and the pair above left BOTH | |
| 262 | * icons display:none: the theme control rendered as an empty box on every cold | |
| 263 | * load. There is no server-known theme to put in a static counterpart either, | |
| 264 | * so the fix is not a static attribute; it is keying off something that IS set | |
| 265 | * before first paint. | |
| 266 | * | |
| 267 | * stx's color-mode boot script sets data-theme on the root above every | |
| 268 | * stylesheet (config/ui.ts app.colorMode), and useColorMode keeps it current | |
| 269 | * afterwards, so the correct icon paints immediately and the mirror signal the | |
| 270 | * binding needed is gone from all three pages. */ | |
| 271 | :root[data-theme='light'] .theme-btn .ic-sun { | |
| 272 | display: block; | |
| 273 | } | |
| 274 | ||
| 275 | :root[data-theme='dark'] .theme-btn .ic-moon { | |
| 276 | display: block; | |
| 277 | } | |
| @@ -25,6 +25,13 @@ const generator = new CSSGenerator(merged.config) | ||
| 25 | 25 | const sheet: string = generator.toCSS(merged.includePreflight, merged.minify) |
| 26 | 26 | // stx pins minify false, so compare without whitespace or trailing semicolons. |
| 27 | 27 | const appPreflights = (theme as any).preflights as { getCSS: () => string }[] |
| 28 | /* | |
| 29 | * Addressed by content, not by index. This was `appPreflights[0]` while the | |
| 30 | * picker was the config's only entry; TOKENS and APP_CHROME joined it when | |
| 31 | * public/tokens.css and public/app-chrome.css moved in, and an index would | |
| 32 | * have silently started asserting about the palette instead. | |
| 33 | */ | |
| 34 | const picker = appPreflights.find(entry => entry.getCSS().includes('.drp-day'))! | |
| 28 | 35 | const flat = sheet.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '').replace(/;\}/g, '}') |
| 29 | 36 | |
| 30 | 37 | function contains(rule: string): boolean { |
| @@ -40,7 +47,9 @@ describe('date range picker CSS reaches the generated sheet', () => { | ||
| 40 | 47 | // the dev-server merge dropped preflights outright (stacksjs/stx#1867). |
| 41 | 48 | // If that ever regresses, every rule in this file goes with it. |
| 42 | 49 | expect(merged.includePreflight).toBe(true) |
| 43 | expect(appPreflights.length).toBe(1) | |
| 50 | // The picker, the design tokens and the app chrome. | |
| 51 | expect(appPreflights.length).toBe(3) | |
| 52 | expect(picker).toBeDefined() | |
| 44 | 53 | // Plus the engine's own reset, which is a preflight too. |
| 45 | 54 | expect(merged.config.preflights.length).toBeGreaterThan(appPreflights.length) |
| 46 | 55 | }) |
| @@ -100,7 +109,7 @@ describe('date range picker CSS reaches the generated sheet', () => { | ||
| 100 | 109 | const rules: string[] = [] |
| 101 | 110 | let depth = 0 |
| 102 | 111 | let current = '' |
| 103 | for (const ch of appPreflights[0].getCSS()) { | |
| 112 | for (const ch of picker.getCSS()) { | |
| 104 | 113 | current += ch |
| 105 | 114 | if (ch === '{') { |
| 106 | 115 | depth++ |
| @@ -0,0 +1,276 @@ | ||
| 1 | /** | |
| 2 | * The design tokens and the app chrome, now that both are declared in the | |
| 3 | * typed config rather than a stylesheet (stx-standards rule 3, §11.2). | |
| 4 | * | |
| 5 | * public/tokens.css and public/app-chrome.css were linked into every route | |
| 6 | * from config/ui.ts, and each opened by saying why it could not be a | |
| 7 | * `preflights` entry. app-chrome.css put it most directly: | |
| 8 | * | |
| 9 | * "`config/crosswind.ts` `preflights` is where ch.11.2/11.7 says this | |
| 10 | * belongs. It does not work on the stx serve path: the entry is spread into | |
| 11 | * the generator config and then never emitted, verified with a probe token | |
| 12 | * that appears nowhere in the served CSS on any route." | |
| 13 | * | |
| 14 | * The probe was real and so was its result -- on the stx of the day. It is | |
| 15 | * also what config/crosswind.ts's own `preflights` note already recorded as | |
| 16 | * fixed by stacksjs/stx #1867, which unified the two render paths onto one | |
| 17 | * merge. The config had carried that correction for a while; only the two | |
| 18 | * stylesheets still said otherwise, and they were the reason 83 declarations | |
| 19 | * lived outside the config. | |
| 20 | * | |
| 21 | * So the first test here is the original probe, re-run against the installed | |
| 22 | * engine and kept, because a claim about the engine is worth exactly as much | |
| 23 | * as its last measurement. | |
| 24 | * | |
| 25 | * The second half is about the cascade. A linked stylesheet is unlayered; a | |
| 26 | * preflight lands in @layer tc-base, and an unlayered rule beats a layered one | |
| 27 | * at ANY specificity. Six declarations in the app chrome relied on outranking | |
| 28 | * a utility that sits on the same element, and they stopped applying when the | |
| 29 | * file moved -- measured on a probe page, not reasoned about: | |
| 30 | * | |
| 31 | * .tgl[data-on='1'] rgb(74,222,128) -> rgba(148,163,184,0.12) | |
| 32 | * .seg-slack[data-sel='1'] rgb(97,31,105) -> rgb(16,19,28) | |
| 33 | * .dz-overlay[hidden] display: none -> display: flex | |
| 34 | * | |
| 35 | * They carry !important now, per declaration rather than per rule. | |
| 36 | */ | |
| 37 | import { describe, expect, test } from 'bun:test' | |
| 38 | import { readdirSync, readFileSync } from 'node:fs' | |
| 39 | import { join } from 'node:path' | |
| 40 | import theme from '../../config/crosswind' | |
| 41 | // The installed stx package does not export these subpaths by bare specifier. | |
| 42 | import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js' | |
| 43 | import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js' | |
| 44 | ||
| 45 | const ROOT = join(import.meta.dir, '../..') | |
| 46 | const PUBLIC = join(ROOT, 'public') | |
| 47 | ||
| 48 | const ENTRIES = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? []) | |
| 49 | .map(entry => entry.getCSS()) | |
| 50 | const TOKENS = ENTRIES.find(css => css.includes('--accent-soft:')) ?? '' | |
| 51 | const CHROME = ENTRIES.find(css => css.includes('.dz-overlay')) ?? '' | |
| 52 | ||
| 53 | async function generate(): Promise<string> { | |
| 54 | const engine = await loadCssEngine() | |
| 55 | if (!engine) | |
| 56 | throw new Error('The installed STX CSS engine is required') | |
| 57 | const merged = mergeCssConfig(engine.defaultConfig || engine.config, theme) | |
| 58 | return new engine.CSSGenerator(merged.config).toCSS(merged.includePreflight, false) | |
| 59 | } | |
| 60 | ||
| 61 | describe('the claim that kept them out of the config', () => { | |
| 62 | test('the original probe, re-run: a preflight IS emitted', async () => { | |
| 63 | const engine = await loadCssEngine() | |
| 64 | if (!engine) | |
| 65 | throw new Error('The installed STX CSS engine is required') | |
| 66 | const probe = { | |
| 67 | ...theme, | |
| 68 | preflights: [...ENTRIES.map(css => ({ getCSS: () => css })), { getCSS: () => ':root { --probe-token: #abcdef; }' }], | |
| 69 | } | |
| 70 | const merged = mergeCssConfig(engine.defaultConfig || engine.config, probe) | |
| 71 | expect(merged.includePreflight).toBe(true) | |
| 72 | expect(merged.config.preflights.length).toBeGreaterThan(ENTRIES.length) | |
| 73 | const css = new engine.CSSGenerator(merged.config).toCSS(merged.includePreflight, false) | |
| 74 | expect(css).toContain('--probe-token: #abcdef') | |
| 75 | }) | |
| 76 | ||
| 77 | test('both entries are found, and by content rather than index', () => { | |
| 78 | // Addressing a preflight by index is how the date-range-picker test | |
| 79 | // started asserting about the palette when these two joined it. | |
| 80 | expect(TOKENS).not.toBe('') | |
| 81 | expect(CHROME).not.toBe('') | |
| 82 | expect(ENTRIES).toHaveLength(3) | |
| 83 | }) | |
| 84 | }) | |
| 85 | ||
| 86 | describe('one declaration site', () => { | |
| 87 | test('§11.2: tokens.css and app-chrome.css are gone, and nothing links them', () => { | |
| 88 | const stylesheets = readdirSync(PUBLIC).filter(name => name.endsWith('.css')) | |
| 89 | expect(stylesheets).not.toContain('tokens.css') | |
| 90 | expect(stylesheets).not.toContain('app-chrome.css') | |
| 91 | ||
| 92 | /* | |
| 93 | * A link entry, not the substring: config/ui.ts still NAMES both files, in | |
| 94 | * the comment saying where they went, and that note is the point of the | |
| 95 | * removal rather than a leftover. | |
| 96 | */ | |
| 97 | const ui = readFileSync(join(ROOT, 'config/ui.ts'), 'utf8') | |
| 98 | const linked = [...ui.matchAll(/rel: 'stylesheet', href: '([^']+)'/g)].map(match => match[1]) | |
| 99 | for (const href of ['/tokens.css', '/app-chrome.css']) | |
| 100 | expect({ href, stillLinked: linked.includes(href) }).toEqual({ href, stillLinked: false }) | |
| 101 | ||
| 102 | // Every local stylesheet it does still link has to exist on disk. | |
| 103 | for (const href of linked.filter(h => h.startsWith('/'))) | |
| 104 | expect({ href, onDisk: stylesheets.includes(href.slice(1)) }).toEqual({ href, onDisk: true }) | |
| 105 | }) | |
| 106 | ||
| 107 | test('public/marketing.css is the one stylesheet left, and it is not site-wide', () => { | |
| 108 | /* | |
| 109 | * Not part of this move and not the same problem. It is linked from | |
| 110 | * resources/layouts/marketing.stx rather than config/ui.ts, because app | |
| 111 | * pages define their own conflicting `.btn` and `.panel`, so it cannot | |
| 112 | * become an unscoped preflight the way these two could. Its 32 token | |
| 113 | * declarations are the remaining §11.2 debt in this app, recorded here so | |
| 114 | * the number is explicit rather than implied by the absence of a check. | |
| 115 | */ | |
| 116 | const marketing = readFileSync(join(PUBLIC, 'marketing.css'), 'utf8') | |
| 117 | const declarations = [...marketing.matchAll(/^\s*(--[a-z][\w-]*)\s*:/gm)].length | |
| 118 | expect({ declarations }).toEqual({ declarations: 35 }) | |
| 119 | expect(readFileSync(join(ROOT, 'resources/layouts/marketing.stx'), 'utf8')).toContain('href="/marketing.css"') | |
| 120 | expect(readFileSync(join(ROOT, 'config/ui.ts'), 'utf8')).not.toContain('href: \'/marketing.css\'') | |
| 121 | }) | |
| 122 | ||
| 123 | test('all four theme blocks, and the chrome, reach the generated sheet', async () => { | |
| 124 | const css = await generate() | |
| 125 | for (const selector of [':root {', '@media (prefers-color-scheme: dark)', ':root[data-theme=\'dark\']', ':root[data-theme=\'light\']']) | |
| 126 | expect({ selector, emitted: css.includes(selector) }).toEqual({ selector, emitted: true }) | |
| 127 | // One value per block, so an empty block would not pass. | |
| 128 | expect(css).toContain('--bg: #fbfbfd') | |
| 129 | expect(css).toContain('--bg: #090b11') | |
| 130 | // And the chrome that reads them. | |
| 131 | for (const rule of ['@keyframes loghq-pulse', '.skeleton-pulse', '--slack: #611f69', '.chrome-logout', '.dz-overlay']) | |
| 132 | expect({ rule, emitted: css.includes(rule) }).toEqual({ rule, emitted: true }) | |
| 133 | // An explicit choice still has to beat the OS preference. | |
| 134 | expect(css.indexOf(':root[data-theme=\'dark\']')).toBeGreaterThan(css.indexOf('prefers-color-scheme')) | |
| 135 | }) | |
| 136 | ||
| 137 | test('the font stacks are not rewritten on the way through', async () => { | |
| 138 | /* | |
| 139 | * This engine replaces a `font-family` written literally in a preflight | |
| 140 | * when the rule is in the spaced form and the stack begins | |
| 141 | * `ui-sans-serif`. Both declarations that moved read a custom property | |
| 142 | * instead, so neither is a candidate -- asserted because it is a property | |
| 143 | * of the engine rather than of this file. | |
| 144 | */ | |
| 145 | const css = await generate() | |
| 146 | expect(css).toContain('--sans: \'Space Grotesk\', ui-sans-serif, system-ui, sans-serif') | |
| 147 | expect(css).toContain('--mono: \'JetBrains Mono\', ui-monospace, SFMono-Regular, Menlo, monospace') | |
| 148 | for (const entry of [TOKENS, CHROME]) { | |
| 149 | for (const match of entry.matchAll(/(?<!-)font-family\s*:\s*([^;}\n]+)/g)) | |
| 150 | expect({ value: match[1].trim(), readsAToken: match[1].includes('var(--') }).toEqual({ value: match[1].trim(), readsAToken: true }) | |
| 151 | } | |
| 152 | }) | |
| 153 | }) | |
| 154 | ||
| 155 | describe('the declarations that have to beat a utility', () => { | |
| 156 | /* | |
| 157 | * These six are the whole cost of the move. Each sits on an element that | |
| 158 | * also carries a utility setting the same property; while this was a linked | |
| 159 | * stylesheet they won on specificity, and layered they lose outright. | |
| 160 | * | |
| 161 | * Measured on a probe page built from the real class lists in settings.stx | |
| 162 | * and dashboard.stx, with the pre-move and post-move sheets swapped under | |
| 163 | * it. Without the marks the toggle stops turning green, the selected | |
| 164 | * Slack/Discord segment loses its brand fill and its ink, and the delete | |
| 165 | * -zone modal is permanently open over the settings page. With them, every | |
| 166 | * computed value matches the pre-move sheet exactly. | |
| 167 | */ | |
| 168 | const MARKED: Array<[string, string[]]> = [ | |
| 169 | ['.tgl[data-on=\'1\']', ['background: var(--ok) !important']], | |
| 170 | ['.seg[data-sel=\'1\']', ['color: var(--accent-ink) !important', 'border-color: transparent !important']], | |
| 171 | ['.seg-slack[data-sel=\'1\']', ['background: var(--slack) !important']], | |
| 172 | ['.seg-discord[data-sel=\'1\']', ['background: var(--discord) !important']], | |
| 173 | ['.dz-overlay[hidden]', ['display: none !important']], | |
| 174 | ['a.pill:hover, button.pill:hover', ['color: var(--text) !important']], | |
| 175 | ['select.app-panel', ['padding-right: 2rem !important']], | |
| 176 | ] | |
| 177 | ||
| 178 | test('each one keeps its mark, in the config and in the sheet', async () => { | |
| 179 | const css = await generate() | |
| 180 | for (const [selector, declarations] of MARKED) { | |
| 181 | expect({ selector, inChrome: CHROME.includes(selector) }).toEqual({ selector, inChrome: true }) | |
| 182 | for (const declaration of declarations) | |
| 183 | expect({ selector, declaration, emitted: css.includes(declaration) }).toEqual({ selector, declaration, emitted: true }) | |
| 184 | } | |
| 185 | }) | |
| 186 | ||
| 187 | test('the utilities they have to beat are still on those elements', () => { | |
| 188 | /* | |
| 189 | * The marks are only correct while the collision exists. If a utility is | |
| 190 | * dropped from the markup the mark becomes noise, and if a new one is | |
| 191 | * added to one of these elements it needs a mark of its own -- which is | |
| 192 | * the failure this names rather than leaves to be rediscovered. | |
| 193 | */ | |
| 194 | const settings = readFileSync(join(ROOT, 'resources/views/settings.stx'), 'utf8') | |
| 195 | const dashboard = readFileSync(join(ROOT, 'resources/views/dashboard.stx'), 'utf8') | |
| 196 | const collisions: Array<[string, string, string, string]> = [ | |
| 197 | ['settings.stx', settings, 'tgl', 'bg-line'], | |
| 198 | ['settings.stx', settings, 'seg-slack', 'bg-panel'], | |
| 199 | ['settings.stx', settings, 'seg', 'border-line'], | |
| 200 | ['settings.stx', settings, 'dz-overlay', 'flex'], | |
| 201 | ['dashboard.stx', dashboard, 'pill', 'bg-accent'], | |
| 202 | ['dashboard.stx', dashboard, 'app-panel', 'px-3'], | |
| 203 | ] | |
| 204 | for (const [name, source, own, utility] of collisions) { | |
| 205 | const carries = [...source.matchAll(/class="([^"{}]*)"/g)] | |
| 206 | .map(match => match[1].split(/\s+/)) | |
| 207 | .some(classes => classes.includes(own) && classes.includes(utility)) | |
| 208 | expect({ file: name, class: own, alsoCarries: utility, found: carries }) | |
| 209 | .toEqual({ file: name, class: own, alsoCarries: utility, found: true }) | |
| 210 | } | |
| 211 | }) | |
| 212 | ||
| 213 | test('nothing else in the chrome outranks a single-class utility unmarked', () => { | |
| 214 | /* | |
| 215 | * The marked declarations were found by walking every selector in the file | |
| 216 | * and keeping the ones above single-class specificity whose element also | |
| 217 | * carries another class. This re-derives that list so an eighth cannot be | |
| 218 | * added quietly: anything with two or more classes, or a class plus a tag, | |
| 219 | * has to either carry a mark or set only properties no utility on that | |
| 220 | * element sets. | |
| 221 | * | |
| 222 | * Per DECLARATION, not per rule. A rule-level check would let an unmarked | |
| 223 | * property hide behind a marked sibling, which select.app-panel has: its | |
| 224 | * padding-right is marked and its six background and appearance | |
| 225 | * properties are not, correctly. | |
| 226 | */ | |
| 227 | const rules = [...CHROME.replace(/\/\*[\s\S]*?\*\//g, '').matchAll(/(?<=^|\})\s*([^{}@][^{}]*?)\s*\{([^{}]*)\}/g)] | |
| 228 | /* | |
| 229 | * Lookbehind, not a consuming `(?:^|\})`. With the brace inside the match | |
| 230 | * the previous rule ate it, the next could not match its own opener, and | |
| 231 | * the walk read every OTHER rule -- which is how select.app-panel's | |
| 232 | * padding-right nearly shipped unmarked. | |
| 233 | */ | |
| 234 | expect(rules.length).toBeGreaterThan(60) | |
| 235 | ||
| 236 | const unmarked: string[] = [] | |
| 237 | for (const [, selectorList, body] of rules) { | |
| 238 | const loose = body.split(';') | |
| 239 | .map(d => d.trim()) | |
| 240 | .filter(d => /^[a-z-]+\s*:/.test(d) && !d.startsWith('--') && !d.includes('!important')) | |
| 241 | .map(d => d.slice(0, d.indexOf(':'))) | |
| 242 | if (loose.length === 0) | |
| 243 | continue | |
| 244 | for (const selector of selectorList.split(',').map(s => s.trim()).filter(Boolean)) { | |
| 245 | const bare = selector.replace(/::?[a-z-]+(\([^)]*\))?/g, '') | |
| 246 | const weight = (bare.match(/\.[\w-]+/g)?.length ?? 0) + (bare.match(/\[[^\]]+\]/g)?.length ?? 0) | |
| 247 | if (weight < 2 && !/^[a-z]+\./.test(bare)) | |
| 248 | continue | |
| 249 | // A ::after or ::before cannot be reached by a utility on the element. | |
| 250 | if (/::(after|before)/.test(selector)) | |
| 251 | continue | |
| 252 | unmarked.push(`${selector} { ${loose.join(', ')} }`) | |
| 253 | } | |
| 254 | } | |
| 255 | ||
| 256 | // Each survivor checked against the markup: no utility on that element | |
| 257 | // sets the property named. | |
| 258 | expect(unmarked.sort()).toEqual([ | |
| 259 | // .stat-dot carries h-1.5/w-1.5/rounded-full/shrink-0/inline-block. | |
| 260 | '[data-tone=\'error\'] .stat-dot { background, opacity }', | |
| 261 | // .stat-value carries font-mono/font-semibold/text-3xl/tnum: no colour. | |
| 262 | '[data-tone=\'error\'] .stat-value { color }', | |
| 263 | '[data-tone=\'warning\'] .stat-dot { background, opacity }', | |
| 264 | '[data-tone=\'warning\'] .stat-value { color }', | |
| 265 | // `border` on a .pill-on sets width and style, never a colour. | |
| 266 | 'a.pill-on:hover { color, background, border-color }', | |
| 267 | // No transform utility on a pill. | |
| 268 | 'a.pill:active { transform }', | |
| 269 | 'button.pill:active { transform }', | |
| 270 | // .log-row carries px-5/py-2.5 only. | |
| 271 | 'details[open] > .log-row { background }', | |
| 272 | // What is left on the select once padding-right took its mark. | |
| 273 | 'select.app-panel { -webkit-appearance, -moz-appearance, appearance, background-repeat, background-position, background-size, background-image }', | |
| 274 | ]) | |
| 275 | }) | |
| 276 | }) | |
| @@ -18,22 +18,35 @@ | ||
| 18 | 18 | * { animation: none } }`. So the spelling is no longer a trap, and the test |
| 19 | 19 | * that failed any view for using it has been removed. |
| 20 | 20 | * |
| 21 | * Both halves still live in public/tokens.css, in source order, and that is | |
| 22 | * still the better home: as utilities the two rules tie on specificity and | |
| 23 | * the guard wins only because the generator happens to emit the media query | |
| 24 | * second. tokens.css makes the order explicit, and it is linked BEFORE the | |
| 25 | * generated sheet, which is why the animation moved there too rather than | |
| 26 | * staying a utility for the guard to fight. | |
| 21 | * Both halves still travel together, in source order, and that is still the | |
| 22 | * better home: as utilities the two rules tie on specificity and the guard | |
| 23 | * wins only because the generator happens to emit the media query second. | |
| 24 | * Declaring them next to each other makes the order explicit. | |
| 25 | * | |
| 26 | * They moved with the rest of public/tokens.css into the TOKENS preflight in | |
| 27 | * config/crosswind.ts (stx-standards rule 3, §11.2), so this now reads them | |
| 28 | * out of the config rather than the deleted stylesheet. The order argument is | |
| 29 | * unchanged -- one block, same specificity, later wins -- but the second test | |
| 30 | * below matters MORE than it did. A preflight lands in @layer tc-base, so an | |
| 31 | * `[animation:loghq-pulse...]` utility on the same element would no longer | |
| 32 | * merely outrank the guard on source order; being unlayered it would beat it | |
| 33 | * outright, at any specificity. | |
| 27 | 34 | */ |
| 28 | 35 | import { describe, expect, test } from 'bun:test' |
| 29 | 36 | import { readFileSync } from 'node:fs' |
| 30 | 37 | import { join } from 'node:path' |
| 38 | import theme from '../../config/crosswind' | |
| 31 | 39 | |
| 32 | 40 | const ROOT = join(import.meta.dir, '../..') |
| 33 | const tokens = readFileSync(join(ROOT, 'public/tokens.css'), 'utf8') | |
| 41 | ||
| 42 | /** The preflight entry that carries the palette and the skeleton. */ | |
| 43 | const tokens = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? []) | |
| 44 | .map(entry => entry.getCSS()) | |
| 45 | .find(css => css.includes('.skeleton-pulse')) ?? '' | |
| 34 | 46 | |
| 35 | 47 | describe('reduced motion', () => { |
| 36 | 48 | test('the skeleton declares its animation and its guard, in that order', () => { |
| 49 | expect(tokens).not.toBe('') | |
| 37 | 50 | const base = tokens.search(/\.skeleton-pulse\s*\{/) |
| 38 | 51 | const guard = tokens.search(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.skeleton-pulse\s*\{/) |
| 39 | 52 | expect(`base declared: ${base >= 0}`).toBe('base declared: true') |
| @@ -46,9 +59,9 @@ describe('reduced motion', () => { | ||
| 46 | 59 | test('the element that pulses is the one the guard names', () => { |
| 47 | 60 | const account = readFileSync(join(ROOT, 'resources/views/account.stx'), 'utf8') |
| 48 | 61 | expect(account).toContain('skeleton-pulse') |
| 49 | // The animation must not also be applied as a utility: that lands in the | |
| 50 | // generated sheet, which is linked after tokens.css, and would outrank the | |
| 51 | // guard on source order while tying on specificity. | |
| 62 | // The animation must not also be applied as a utility. A utility is | |
| 63 | // unlayered and the guard is now in @layer tc-base, so it would not merely | |
| 64 | // outrank the guard on source order -- it would win at any specificity. | |
| 52 | 65 | expect(account).not.toMatch(/\[animation:loghq-pulse/) |
| 53 | 66 | }) |
| 54 | 67 | }) |