diff --git a/changelog.d/contrast-matrix-coverage.added.md b/changelog.d/contrast-matrix-coverage.added.md new file mode 100644 index 0000000..008c2c9 --- /dev/null +++ b/changelog.d/contrast-matrix-coverage.added.md @@ -0,0 +1 @@ +Contrast matrix now asserts the focus ring on background (light + dark, WCAG SC 1.4.11 non-text 3:1), dark-mode `primary-foreground` on `primary`, dark-mode `destructive-foreground` on `destructive`, and the default link color (teal-600) on background. diff --git a/changelog.d/dark-card-visibility.fixed.md b/changelog.d/dark-card-visibility.fixed.md new file mode 100644 index 0000000..6fd3a51 --- /dev/null +++ b/changelog.d/dark-card-visibility.fixed.md @@ -0,0 +1 @@ +Dark mode card surfaces are now visible. `--card`/`--popover`/`--muted` bumped from `#131820` (1.08:1 vs background — visually invisible) to `#1A2030` (1.19:1), and `--border`/`--input` bumped from `#1E293B` (1.32:1 vs background, 1.22:1 vs card) to `#334155` (1.87:1 / 1.57:1) so card surfaces and their borders are clearly distinguishable. diff --git a/changelog.d/quarto-secondary-fill.fixed.md b/changelog.d/quarto-secondary-fill.fixed.md new file mode 100644 index 0000000..1457a57 --- /dev/null +++ b/changelog.d/quarto-secondary-fill.fixed.md @@ -0,0 +1 @@ +Quarto SCSS `$secondary` now resolves to the secondary surface fill (`#F2F4F7`) instead of the secondary foreground (`#101828`). Bootstrap's `$secondary` is a fill color (e.g. `.btn-secondary` background), so paper renders previously got a near-black secondary button where Bootstrap expects a light gray. diff --git a/scripts/generate-css.ts b/scripts/generate-css.ts index 4189915..d6eb297 100644 --- a/scripts/generate-css.ts +++ b/scripts/generate-css.ts @@ -277,7 +277,10 @@ function buildQuartoScss(): string { "/*-- scss:defaults --*/", "", `$primary: ${rootColorsLight["--primary"]};`, - `$secondary: ${rootColorsLight["--secondary-foreground"]};`, + // Bootstrap's $secondary is a *fill* color (e.g. .btn-secondary background), + // not a foreground. Use --secondary, the light gray surface, so paper + // buttons match the dashboard's secondary surface, not its dark text. + `$secondary: ${rootColorsLight["--secondary"]};`, `$success: ${semanticFills.success};`, `$warning: ${semanticFills.warning};`, `$danger: ${semanticFills.error};`, diff --git a/src/theme/quarto.scss b/src/theme/quarto.scss index b6cad9c..e571533 100644 --- a/src/theme/quarto.scss +++ b/src/theme/quarto.scss @@ -15,7 +15,7 @@ /*-- scss:defaults --*/ $primary: #2C7A7B; -$secondary: #101828; +$secondary: #F2F4F7; $success: #22C55E; $warning: #FEC601; $danger: #EF4444; diff --git a/src/theme/tokens.css b/src/theme/tokens.css index 91d42dc..1a43f8f 100644 --- a/src/theme/tokens.css +++ b/src/theme/tokens.css @@ -124,7 +124,7 @@ --secondary-foreground: #F5F5F5; /* Muted */ - --muted: #131820; + --muted: #1A2030; --muted-foreground: #9CA3AF; /* Accent */ @@ -135,17 +135,17 @@ --destructive: #F87171; --destructive-foreground: #0B0E14; - /* Chrome */ - --border: #1E293B; - --input: #1E293B; + /* Chrome. --border bumped from #1E293B (1.32:1 on background, 1.22:1 on card — visually invisible) to #334155 (1.87:1 on bg, 1.57:1 on card). */ + --border: #334155; + --input: #334155; --ring: #38B2AC; - /* Card */ - --card: #131820; + /* Card. Bumped from #131820 (1.08:1 on background — invisible) to #1A2030 (1.19:1 on bg). Together with the bumped --border, the card surface is now clearly distinguishable. */ + --card: #1A2030; --card-foreground: #F5F5F5; /* Popover */ - --popover: #131820; + --popover: #1A2030; --popover-foreground: #F5F5F5; /* Charts (lifted up the brand scale for dark backgrounds) */ diff --git a/src/theme/tokens.ts b/src/theme/tokens.ts index d4da38a..92e0789 100644 --- a/src/theme/tokens.ts +++ b/src/theme/tokens.ts @@ -78,6 +78,9 @@ const lightSections: CssSection[] = [ }, }, { + // --ring at teal-500 (#319795) clears WCAG SC 1.4.11 (3:1 non-text) + // against white at 3.51:1 — only ~0.5 above the floor. If you nudge the + // ring lighter (toward teal-400), re-verify against the contrast matrix. name: "Chrome", declarations: { "--border": "#E2E8F0", @@ -212,7 +215,7 @@ const darkSections: CssSection[] = [ { name: "Muted", declarations: { - "--muted": "#131820", + "--muted": "#1A2030", "--muted-foreground": "#9CA3AF", }, }, @@ -231,24 +234,30 @@ const darkSections: CssSection[] = [ }, }, { - name: "Chrome", + name: + "Chrome. --border bumped from #1E293B (1.32:1 on background, 1.22:1 " + + "on card — visually invisible) to #334155 (1.87:1 on bg, 1.57:1 on " + + "card).", declarations: { - "--border": "#1E293B", - "--input": "#1E293B", + "--border": "#334155", + "--input": "#334155", "--ring": "#38B2AC", }, }, { - name: "Card", + name: + "Card. Bumped from #131820 (1.08:1 on background — invisible) to " + + "#1A2030 (1.19:1 on bg). Together with the bumped --border, the card " + + "surface is now clearly distinguishable.", declarations: { - "--card": "#131820", + "--card": "#1A2030", "--card-foreground": "#F5F5F5", }, }, { name: "Popover", declarations: { - "--popover": "#131820", + "--popover": "#1A2030", "--popover-foreground": "#F5F5F5", }, }, @@ -672,6 +681,25 @@ export const contrastPairs: readonly ContrastPair[] = [ minRatio: 4.5, mode: "light", }, + { + // SC 1.4.11 (Non-text Contrast, AA): focus indicators must clear 3:1 + // against the adjacent background. The ring sits on --background, so we + // pin its visibility there. + description: "ring on background (light, non-text)", + fg: rootColorsLight["--ring"], + bg: rootColorsLight["--background"], + minRatio: 3, + mode: "light", + }, + { + // Default link styling resolves to teal-600. Asserted as small-text AA so + // links stay legible inside paragraph copy. + description: "link (teal-600) on background (light)", + fg: palette.teal[600], + bg: rootColorsLight["--background"], + minRatio: 4.5, + mode: "light", + }, // ----- Dark mode ----- { description: "foreground on background (dark)", @@ -729,4 +757,31 @@ export const contrastPairs: readonly ContrastPair[] = [ minRatio: 4.5, mode: "dark", }, + { + // Dark-mode primary fill (teal-400) is light enough that we can ink it + // with near-black text. Pin the AA guarantee. + description: "primary-foreground on primary (dark)", + fg: rootColorsDark["--primary-foreground"], + bg: rootColorsDark["--primary"], + minRatio: 4.5, + mode: "dark", + }, + { + // Same pattern for destructive: dark-mode destructive is red-400, so we + // ink with near-black to clear AA. + description: "destructive-foreground on destructive (dark)", + fg: rootColorsDark["--destructive-foreground"], + bg: rootColorsDark["--destructive"], + minRatio: 4.5, + mode: "dark", + }, + { + // Focus indicator must clear SC 1.4.11 (3:1 non-text) against the + // adjacent background in dark mode too. + description: "ring on background (dark, non-text)", + fg: rootColorsDark["--ring"], + bg: rootColorsDark["--background"], + minRatio: 3, + mode: "dark", + }, ];