|
1 | 1 | { |
2 | 2 | "schemaVersion": 2, |
3 | | - "generatedAt": "2026-08-24T00:00:00Z", |
| 3 | + "generatedAt": "2026-08-25T00:00:00Z", |
4 | 4 | "title": "Design System: IWAC Theme", |
5 | 5 | "extensions": { |
6 | 6 | "colorMeta": { |
|
231 | 231 | "warning": { |
232 | 232 | "role": "status", |
233 | 233 | "displayName": "Warning Amber", |
234 | | - "canonical": "#de7000", |
| 234 | + "canonical": "#d66800", |
235 | 235 | "dark": "#f99532", |
236 | 236 | "tonalRamp": [ |
237 | 237 | "#340000", |
|
242 | 242 | "#f4842b", |
243 | 243 | "#ffa956", |
244 | 244 | "#ffcd7c" |
245 | | - ] |
| 245 | + ], |
| 246 | + "purpose": "oklch(63.5% 0.18 60), darkened from 66% in 2.14. Also --type-document and --type-entity-organisations, which render as solid dots, so it is calibrated as a non-text mark: 3:1 on all four light surfaces (3.51 / 3.40 / 3.31 / 3.20). Chroma holds at 0.18, keeping the amber identity and ~16 degrees of hue separation from --primary. Not a text colour at any lightness that stays amber. Dark unchanged." |
246 | 247 | }, |
247 | 248 | "error": { |
248 | 249 | "role": "status", |
|
414 | 415 | "name": "shadow-xs", |
415 | 416 | "value": "0 1px 2px 0 rgba(20, 22, 27, 0.06)", |
416 | 417 | "dark": "0 1px 3px 0 rgba(0, 0, 0, 0.12), 0 1px 2px -1px rgba(0, 0, 0, 0.1)", |
417 | | - "purpose": "Minimal ambient depth." |
| 418 | + "purpose": "Minimal ambient depth. Declared in BOTH theme mixins, not composed once on :root." |
418 | 419 | }, |
419 | 420 | { |
420 | 421 | "name": "shadow-sm", |
421 | 422 | "value": "0 1px 3px 0 rgba(9, 11, 15, 0.12), 0 1px 2px -1px rgba(20, 22, 27, 0.06)", |
422 | 423 | "dark": "0 2px 6px -1px rgba(0, 0, 0, 0.18), 0 2px 4px -2px rgba(0, 0, 0, 0.12)", |
423 | | - "purpose": "Default panel shadow (= --panel-shadow)." |
| 424 | + "purpose": "Default panel shadow (= --panel-shadow). Declared in BOTH theme mixins, not composed once on :root." |
424 | 425 | }, |
425 | 426 | { |
426 | 427 | "name": "shadow-md", |
427 | 428 | "value": "0 4px 6px -1px rgba(9, 11, 15, 0.12), 0 2px 4px -2px rgba(20, 22, 27, 0.06)", |
428 | 429 | "dark": "0 6px 16px -4px rgba(0, 0, 0, 0.25), 0 4px 8px -4px rgba(0, 0, 0, 0.25)", |
429 | | - "purpose": "Dropdowns and floating panels." |
| 430 | + "purpose": "Dropdowns and floating panels. Declared in BOTH theme mixins, not composed once on :root." |
430 | 431 | }, |
431 | 432 | { |
432 | 433 | "name": "shadow-lg", |
433 | 434 | "value": "0 10px 15px -3px rgba(9, 11, 15, 0.12), 0 4px 6px -4px rgba(20, 22, 27, 0.06)", |
434 | 435 | "dark": "0 12px 28px -8px rgba(0, 0, 0, 0.35), 0 8px 16px -8px rgba(0, 0, 0, 0.2)", |
435 | | - "purpose": "Modals; rare. Dark is struck from pure black at higher alpha and greater spread — a neutral-ink shadow is invisible on a lamplit ground." |
| 436 | + "purpose": "Modals; rare. Dark is struck from pure black at higher alpha and greater spread — a neutral-ink shadow is invisible on a lamplit ground. Declared in BOTH theme mixins, not composed once on :root." |
436 | 437 | }, |
437 | 438 | { |
438 | 439 | "name": "glow-xs", |
439 | 440 | "value": "0 1px 3px 0 rgba(206, 65, 21, 0.2)", |
440 | 441 | "dark": "0 1px 3px 0 rgba(236, 101, 63, 0.2)", |
441 | | - "purpose": "Filled-primary button, active/pressed." |
| 442 | + "purpose": "Filled-primary button, active/pressed. Declared in BOTH theme mixins, not composed once on :root." |
442 | 443 | }, |
443 | 444 | { |
444 | 445 | "name": "glow-sm", |
445 | 446 | "value": "0 2px 6px -1px rgba(206, 65, 21, 0.25)", |
446 | 447 | "dark": "0 2px 6px -1px rgba(236, 101, 63, 0.25)", |
447 | | - "purpose": "Filled-primary button at rest." |
| 448 | + "purpose": "Filled-primary button at rest. Declared in BOTH theme mixins, not composed once on :root." |
448 | 449 | }, |
449 | 450 | { |
450 | 451 | "name": "glow-md", |
451 | 452 | "value": "0 4px 12px -2px rgba(206, 65, 21, 0.3)", |
452 | 453 | "dark": "0 4px 12px -2px rgba(236, 101, 63, 0.3)", |
453 | | - "purpose": "Filled-primary button on hover." |
| 454 | + "purpose": "Filled-primary button on hover. Declared in BOTH theme mixins, not composed once on :root." |
454 | 455 | }, |
455 | 456 | { |
456 | 457 | "name": "focus-outline", |
457 | 458 | "value": "2px solid #ce4115", |
458 | 459 | "dark": "2px solid #ec653f", |
459 | | - "purpose": "THE default focus indicator, paired with outline-offset: 2px." |
| 460 | + "purpose": "THE default focus indicator, paired with outline-offset: 2px. Declared in BOTH theme mixins, not composed once on :root." |
460 | 461 | }, |
461 | 462 | { |
462 | 463 | "name": "ring-focus", |
463 | 464 | "value": "0 0 0 3px rgba(206, 65, 21, 0.3)", |
464 | 465 | "dark": "0 0 0 3px rgba(236, 101, 63, 0.35)", |
465 | | - "purpose": "Box-shadow focus ring — the exception, for controls an outline would be clipped on. Also the input focus ring, paired with a primary border." |
| 466 | + "purpose": "Box-shadow focus ring — the exception, for controls an outline would be clipped on. Also the input focus ring, paired with a primary border. Declared in BOTH theme mixins, not composed once on :root." |
466 | 467 | }, |
467 | 468 | { |
468 | 469 | "name": "ring-focus-sm", |
469 | 470 | "value": "0 0 0 2px rgba(206, 65, 21, 0.22)", |
470 | 471 | "dark": "0 0 0 2px rgba(236, 101, 63, 0.22)", |
471 | | - "purpose": "Tighter 2px ring for dense controls." |
| 472 | + "purpose": "Tighter 2px ring for dense controls. Declared in BOTH theme mixins, not composed once on :root." |
472 | 473 | } |
473 | 474 | ], |
474 | 475 | "motion": [ |
|
539 | 540 | }, |
540 | 541 | { |
541 | 542 | "name": "--type-document", |
542 | | - "light": "#de7000", |
| 543 | + "light": "#d66800", |
543 | 544 | "dark": "#f99532" |
544 | 545 | }, |
545 | 546 | { |
|
564 | 565 | }, |
565 | 566 | { |
566 | 567 | "name": "--type-entity-organisations", |
567 | | - "light": "#de7000", |
| 568 | + "light": "#d66800", |
568 | 569 | "dark": "#f99532" |
569 | 570 | } |
570 | 571 | ] |
|
782 | 783 | "Density over comfort; typography as architecture, rules as joinery", |
783 | 784 | "Near-white cool-neutral light mode; warm \"lamplit reading room\" dark mode", |
784 | 785 | "One brand accent used sparingly; category encoded as dots, never fills", |
785 | | - "Two themes declared in full — dark is a redeclaration, never an inherited substitution", |
| 786 | + "Two themes declared in full as a mixin pair — dark is a redeclaration, never an inherited substitution, and light is never a gap", |
786 | 787 | "AI-generated content visibly marked (computational honesty)" |
787 | 788 | ], |
788 | 789 | "rules": [ |
|
796 | 797 | "body": "Category is a dot on an outlined chip, never a filled or pastel badge. Ten filled badges per screen is shouting; ten dots is an index.", |
797 | 798 | "section": "colors" |
798 | 799 | }, |
| 800 | + { |
| 801 | + "name": "The Accent-Not-Text Rule", |
| 802 | + "body": "`--primary` is an accent, never running body text. As text it measures 4.67 / 4.51 / 4.40 / 4.25:1 on surface / raised / background / sunken — it clears AA on the two lightest surfaces and misses on the other two, so the same declaration passes or fails depending on which panel it lands in. Sanctioned: links, controls and their states, current/active state, large display text (the 404 numeral, KPI figures), pseudo-element marks (interpuncts, `::marker`, chevrons, dots), and every non-text use. Not sanctioned: paragraph, list-item or table-cell copy, ledes, abstracts, anything set from `--font-serif-text`. Darkening the token is not the fix — the brand seed is admin-configurable, so scoping the use is the durable answer.", |
| 803 | + "section": "colors" |
| 804 | + }, |
799 | 805 | { |
800 | 806 | "name": "The Data-Only Secondary Rule", |
801 | 807 | "body": "#394f68 exists for data. If it appears on a button, link, or focus ring, it is a bug.", |
802 | 808 | "section": "colors" |
803 | 809 | }, |
804 | 810 | { |
805 | | - "name": "The Redeclare-In-Dark Rule", |
806 | | - "body": "A token whose value contains `var(…)` must be declared in the dark block too. Declared once on `:root`, it is substituted against light referents and inherited frozen by every dark descendant — a whole-theme failure that looks like nothing until a contrast probe finds it.", |
| 811 | + "name": "The Mirror-Both-Blocks Rule", |
| 812 | + "body": "A custom property is substituted at computed-value time on the element that *declares* it, so a token whose value contains `var(…)` must be declared in BOTH theme blocks, and the rule runs both directions. A light-only composition is substituted against light referents and inherited frozen by every dark descendant. A dark-only declaration is worse-hidden still: with no light counterpart, `body[data-theme=\"light\"]` cannot win back on a dark-OS machine. Since 2.14 the light and dark token blocks are a mixin pair carrying the same names — shadows, panel tokens, glows and the three composed focus tokens are declared twice by construction.", |
807 | 813 | "section": "colors" |
808 | 814 | }, |
809 | 815 | { |
|
823 | 829 | }, |
824 | 830 | { |
825 | 831 | "name": "The No-14px Rule", |
826 | | - "body": "The type scale deliberately skips 14px; 11px (`--text-2xs`) is the floor. Absolute `font-size` literals fail `npm run check:tokens` — use `--text-*` tokens.", |
| 832 | + "body": "The type scale deliberately skips 14px; 11px (`--text-2xs`) is the floor. Absolute `font-size` literals fail `npm run check:tokens` — use `--text-*` tokens. The check reads the whole value, not just its first token, so `clamp()`, `min()` and `max()` are not an escape hatch: a fluid size composes `--text-*` steps (or `calc()` on them).", |
827 | 833 | "section": "typography" |
828 | 834 | }, |
829 | 835 | { |
|
850 | 856 | "name": "The Outline-First Focus Rule", |
851 | 857 | "body": "`--focus-outline` at 2px offset is the focus indicator. The `--ring-focus` halo is the exception for clipped or oddly-shaped controls, never the default — it is the weaker indicator, and reaching for it by habit is how a surface ends up with no visible focus at all.", |
852 | 858 | "section": "elevation-and-depth" |
| 859 | + }, |
| 860 | + { |
| 861 | + "name": "The One-Spelling Breakpoint Rule", |
| 862 | + "body": "`min-width` sits ON a published breakpoint and `max-width` at breakpoint − 1px — one spelling, not two. Bootstrap's − 0.02px was tolerated until 2.14 and the theme carried both idioms in files that reflow the same header; neither is wrong alone, carrying both is. Guards in this repo and in both consumer modules assert it. `@container` queries are exempt.", |
| 863 | + "section": "layout" |
853 | 864 | } |
854 | 865 | ], |
855 | 866 | "dos": [ |
856 | 867 | "**Do** open sections with 2px ink rules and divide rows with hairlines — typography is the architecture, rules are the joinery.", |
857 | | - "**Do** declare every composed or aliased token in the dark block as well as the light one, so dark pages compose against dark referents.", |
| 868 | + "**Do** declare every composed or aliased token in **both** theme blocks — dark pages must compose against dark referents, and a manual light choice on a dark OS must be able to win back.", |
858 | 869 | "**Do** publish any color that encodes data as a named light/dark pair in `tokens.json`, and let the guards compare it.", |
859 | 870 | "**Do** keep transitions 150–300ms on `cubic-bezier(0.25, 1, 0.5, 1)`; real objects decelerate smoothly.", |
860 | 871 | "**Do** mark AI-generated fields with the EU \"AI GENERATED\" provenance treatment on the label, and open the lede on an ink rule.", |
|
865 | 876 | "donts": [ |
866 | 877 | "**Don't** use warm cream or parchment surfaces — wrong register (\"manuscript museum brochure\").", |
867 | 878 | "**Don't** add atmospheric gradients, gradient bars, glassmorphism (outside the sticky header), or colored side-stripes.", |
868 | | - "**Don't** fill category badges or color `h2`s in primary — category is a dot, primary is an accent.", |
| 879 | + "**Don't** fill category badges, color `h2`s, or set running prose in primary — category is a dot, primary is an accent (4.25:1 on sunken as text).", |
869 | 880 | "**Don't** use cold blue-gray dark surfaces — dark mode is warm (\"lamplit\"), hue ~70–80.", |
870 | 881 | "**Don't** box result rows, filter bars, linked-resource lists, or the AI lede into cards.", |
871 | 882 | "**Don't** replace an outline focus indicator with the `--ring-focus` halo for convenience, and never with `outline: none` alone.", |
872 | 883 | "**Don't** use bouncing/elastic easing or hover-triggered hero animations (the one-time Ken Burns settle is the sanctioned exception).", |
873 | | - "**Don't** pair Besley with `tabular-nums` (broken tnum zero) or track it tighter than -0.01em." |
| 884 | + "**Don't** pair Besley with `tabular-nums` (broken tnum zero) or track it tighter than -0.01em.", |
| 885 | + "**Don't** write an absolute `font-size` literal, inside `clamp()` included, or spell the \"below\" half of a breakpoint pair any way but breakpoint − 1px." |
874 | 886 | ] |
875 | 887 | } |
876 | 888 | } |
0 commit comments