ReviewOS

also looking at this

loghqorg/loghq

Promote main to production: signed-out chrome and the subscriber tables

#17
Merged glennmichael123 wants to merge main into production
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.

config/crosswind.tsmodified+702-5
Changes to config/crosswind.ts
@@ -41,10 +41,17 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
4141 * of toCSS(). The stale note mattered, because it is why this
4242 * app's tokens went to public/app-chrome.css instead.
4343 *
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.
4855 *
4956 * Anything not on the surviving list is decoration. This file used to carry
5057 * `content`, `minify`, and `preflight: true` — and that last one is not even a
@@ -55,6 +62,690 @@ import type { CrosswindConfig, Theme } from '@cwcss/crosswind'
5562 *
5663 * @see https://github.com/cwcss/crosswind
5764 */
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 */
91const 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
242body {
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 */
367const 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 * ------------------------------------------------------------------------ */
442html {
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; }
569a.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; }
570a.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); }
576a.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); }
582details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); }
583summary { list-style: none; cursor: pointer; }
584summary::-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. */
620a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible {
621 outline: 2px solid var(--accent); outline-offset: 2px;
622}
623summary: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. */
638select.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
58749/*
59750 * The date-range picker's stylesheet, lifted out of a <style> block in
60751 * resources/views/analytics.stx (stx-standards rule 3).
@@ -234,7 +925,13 @@ summary:focus-visible {
234925`
235926
236927export 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 ],
238935 theme: {
239936 extend: {
240937 borderRadius: {
config/ui.tsmodified+5-12
Changes to config/ui.ts
@@ -211,23 +211,16 @@ export default {
211211 // /dashboard and /settings, which define their own conflicting versions of
212212 // the same class names. It lives in resources/layouts/marketing.stx
213213 // 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.
214219 link: [
215220 { rel: 'icon', type: 'image/svg+xml', href: '/favicon.svg' },
216221 { rel: 'preconnect', href: 'https://fonts.googleapis.com' },
217222 { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossorigin: '' },
218223 { 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' },
231224 ],
232225 },
233226 },
public/app-chrome.cssdeleted+0-370
Changes to public/app-chrome.css
@@ -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 * ------------------------------------------------------------------------ */
99html {
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; }
198a.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); }
199a.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); }
205a.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); }
211details[open] > .log-row { background: color-mix(in srgb, var(--accent) 5%, transparent); }
212summary { list-style: none; cursor: pointer; }
213summary::-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. */
249a:focus-visible, button:focus-visible, summary:focus-visible, select:focus-visible {
250 outline: 2px solid var(--accent); outline-offset: 2px;
251}
252summary: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. */
267select.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}
public/tokens.cssdeleted+0-277
Changes to public/tokens.css
@@ -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
187body {
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}
tests/unit/date-range-picker-css.test.tsmodified+11-2
Changes to tests/unit/date-range-picker-css.test.ts
@@ -25,6 +25,13 @@ const generator = new CSSGenerator(merged.config)
2525const sheet: string = generator.toCSS(merged.includePreflight, merged.minify)
2626// stx pins minify false, so compare without whitespace or trailing semicolons.
2727const 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 */
34const picker = appPreflights.find(entry => entry.getCSS().includes('.drp-day'))!
2835const flat = sheet.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\s+/g, '').replace(/;\}/g, '}')
2936
3037function contains(rule: string): boolean {
@@ -40,7 +47,9 @@ describe('date range picker CSS reaches the generated sheet', () => {
4047 // the dev-server merge dropped preflights outright (stacksjs/stx#1867).
4148 // If that ever regresses, every rule in this file goes with it.
4249 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()
4453 // Plus the engine's own reset, which is a preflight too.
4554 expect(merged.config.preflights.length).toBeGreaterThan(appPreflights.length)
4655 })
@@ -100,7 +109,7 @@ describe('date range picker CSS reaches the generated sheet', () => {
100109 const rules: string[] = []
101110 let depth = 0
102111 let current = ''
103 for (const ch of appPreflights[0].getCSS()) {
112 for (const ch of picker.getCSS()) {
104113 current += ch
105114 if (ch === '{') {
106115 depth++
tests/unit/design-tokens.test.tsadded+276-0
Changes to tests/unit/design-tokens.test.ts
@@ -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 */
37import { describe, expect, test } from 'bun:test'
38import { readdirSync, readFileSync } from 'node:fs'
39import { join } from 'node:path'
40import theme from '../../config/crosswind'
41// The installed stx package does not export these subpaths by bare specifier.
42import { loadCssEngine } from '../../node_modules/@stacksjs/stx/dist/dev-server/ts-css.js'
43import { mergeCssConfig } from '../../node_modules/@stacksjs/stx/dist/ts-css-config.js'
44
45const ROOT = join(import.meta.dir, '../..')
46const PUBLIC = join(ROOT, 'public')
47
48const ENTRIES = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? [])
49 .map(entry => entry.getCSS())
50const TOKENS = ENTRIES.find(css => css.includes('--accent-soft:')) ?? ''
51const CHROME = ENTRIES.find(css => css.includes('.dz-overlay')) ?? ''
52
53async 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
61describe('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
86describe('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
155describe('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})
tests/unit/reduced-motion.test.tsmodified+23-10
Changes to tests/unit/reduced-motion.test.ts
@@ -18,22 +18,35 @@
1818 * { animation: none } }`. So the spelling is no longer a trap, and the test
1919 * that failed any view for using it has been removed.
2020 *
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.
2734 */
2835import { describe, expect, test } from 'bun:test'
2936import { readFileSync } from 'node:fs'
3037import { join } from 'node:path'
38import theme from '../../config/crosswind'
3139
3240const ROOT = join(import.meta.dir, '../..')
33const tokens = readFileSync(join(ROOT, 'public/tokens.css'), 'utf8')
41
42/** The preflight entry that carries the palette and the skeleton. */
43const tokens = ((theme as { preflights?: { getCSS: () => string }[] }).preflights ?? [])
44 .map(entry => entry.getCSS())
45 .find(css => css.includes('.skeleton-pulse')) ?? ''
3446
3547describe('reduced motion', () => {
3648 test('the skeleton declares its animation and its guard, in that order', () => {
49 expect(tokens).not.toBe('')
3750 const base = tokens.search(/\.skeleton-pulse\s*\{/)
3851 const guard = tokens.search(/@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.skeleton-pulse\s*\{/)
3952 expect(`base declared: ${base >= 0}`).toBe('base declared: true')
@@ -46,9 +59,9 @@ describe('reduced motion', () => {
4659 test('the element that pulses is the one the guard names', () => {
4760 const account = readFileSync(join(ROOT, 'resources/views/account.stx'), 'utf8')
4861 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.
5265 expect(account).not.toMatch(/\[animation:loghq-pulse/)
5366 })
5467})