diff --git a/PLAIN_CSS_MIGRATION_GUIDE.md b/PLAIN_CSS_MIGRATION_GUIDE.md index 23c0a49cea..593bce682b 100644 --- a/PLAIN_CSS_MIGRATION_GUIDE.md +++ b/PLAIN_CSS_MIGRATION_GUIDE.md @@ -212,31 +212,33 @@ export function Button({ children, ...props }) { } ``` -### Pattern 2.5: Using Root-Level CSS Variables for Static Theme Colors +### Pattern 2.5: Using Global Theme Tokens for Static Theme Values -For static theme colors that don't require dynamic property access (like `theme.color.text.*`, `theme.color.neutral.*`, `theme.color.disabled.*`), use root-level CSS variables defined in `src/index.css` instead of binding them at the component level. +For theme values that don't require dynamic property access, reference the global +tokens in `src/app/theme.css` instead of binding them at the component level. -**Benefits:** -- Reduces code duplication across components -- Improves maintainability by centralizing static color definitions -- Establishes clear patterns for future migrations -- Keeps a documented mapping from `src/app/theme.ts` into shared CSS variables +**`src/app/theme.css` is generated — do not edit it.** `src/app/themeData.ts` is the +source of truth, `src/app/themeCss.ts` owns the projection, and +`yarn generate:theme-css` writes the file. It is also regenerated as part of +`yarn build` and `yarn start`, so a build cannot ship a stale copy. -**Important:** The root-level variables in `src/index.css` are copied from `src/app/theme.ts`; they are not automatically generated or verified by the process described in this guide. When updating these values, keep `src/index.css` and `src/app/theme.ts` in sync manually. -**When to use root-level variables:** -- Static colors that never change based on props -- Colors used across multiple components -- Colors from `theme.color.text.*`, `theme.color.neutral.*`, `theme.color.disabled.*`, and `theme.color.primary.gray.*` +The token families are `--color-*`, `--z-index-*` and `--padding-*`. Anything else +unprefixed (`--section-bg`, `--popup-padding`) is a component-local override hook, not +a global token. -**When to use component-level bindings:** +**When to use a global token:** +- Any static color, z-index or page padding — i.e. the value does not depend on props +- Values used across more than one component + +**When to bind from JavaScript instead:** - Book-specific theme colors requiring dynamic property access (`theme.color.primary[bookTheme]`) -- Colors with runtime computations (highlight colors using Color library) -- Any color that changes based on props or state +- Colors with runtime computations (highlight colors via the Color library) +- Any value that changes based on props or state **Example:** ```typescript -// ❌ Before: Component-level binding for static color +// ❌ Before: component-level binding for a static color import theme from '../theme'; export function Card({ className, style, ...props }) { @@ -244,7 +246,7 @@ export function Card({ className, style, ...props }) {
@@ -253,43 +255,70 @@ export function Card({ className, style, ...props }) { ``` ```typescript -// ✅ After: Use root-level CSS variable +// ✅ After: reference the token in CSS export function Card({ className, style, ...props }) { - return ( -
- ); + return
; } ``` ```css /* Component.css */ .modal-card { - color: var(--color-text-default); /* References root-level variable */ + color: var(--color-text-default); background: var(--color-neutral-base); } ``` -**Naming Convention:** +Note that removing an inline default changes cascade precedence: a rule setting the +variable on an ancestor element used to lose to the inline style and now applies. Grep +for the variable before removing its binding. + +**Naming convention:** + +A token name is the kebab-case form of its path in `themeData.ts`: + +- `color.text.default` → `--color-text-default` +- `color.neutral.pageBackground` → `--color-neutral-page-background` +- `color.primary.gray.base` → `--color-primary-gray-base` +- `zIndex.navbar` → `--z-index-navbar` +- `padding.page.desktop` → `--padding-page-desktop` (emitted in `rem`) + +The one exception is `--color-link`, rather than `--color-link-base`. + +See `src/app/theme.css` for the full list of 80 tokens. + +**What CI enforces** (`src/app/theme.spec.ts`): -Root-level CSS variables follow the pattern: `--color-{category}-{property}` +1. The committed `theme.css` matches the generator's output, so the CSS cannot go stale + against the JS. If this fails, run `yarn generate:theme-css`. +2. No stylesheet writes a color literal that duplicates a theme value. +3. No stylesheet introduces a color that is neither a theme value nor explicitly + allowlisted in `KNOWN_OFF_PALETTE` (in `src/test/cssColors.ts`) with a reason. +4. No stylesheet reads a `--color-*`/`--z-index-*`/`--padding-*` token that doesn't + exist — which also keeps component-local variables out of those namespaces. +5. No `@media` breakpoint sits within 1em of a theme breakpoint without being one, + which catches a `75em` mistyped as `74em`. The bound is inclusive, so `74em` and + `76em` are both caught. -- `theme.color.text.default` → `--color-text-default` -- `theme.color.neutral.pageBackground` → `--color-neutral-page-background` -- `theme.color.primary.gray.base` → `--color-primary-gray-base` -- `theme.color.disabled.foreground` → `--color-disabled-foreground` +Checks 2 and 3 are currently locked to a baseline in `src/app/theme.baseline.json`, +recording the violations that predate the token file. **New** violations fail +immediately; the baseline only shrinks. If you remove some, run +`yarn generate:theme-baseline` — the counts it prints should go down. -Note: camelCase properties in theme.ts become kebab-case in CSS variable names. +Each baseline entry names the file, the selector and at-rule it sits under, and the +property, not just the literal — `app/components/Button.css: .btn:hover { color: #fff } +is --color-neutral-base`. That is what stops a removed `#fff` and a newly added one +elsewhere in the same file from cancelling out. Expect the entry to change, and the +baseline to need regenerating, if you move a declaration or rename a selector; the +counts are what should not go up. -**Available Root-Level Variables:** +Hex literals and `rgb()`/`hsl()`/`oklch()` are audited in every declaration. Bare named +colors (`white`, `tan`) are only read as colors in properties that can take one, so +`animation-name: red` is left alone. `src/test/cssColors.ts` has the list. -See `src/index.css` for the complete list of available root-level CSS variables. These include: -- Text colors: `--color-text-black`, `--color-text-default`, `--color-text-label`, `--color-text-white` -- Neutral colors: `--color-neutral-base`, `--color-neutral-darker`, `--color-neutral-darkest`, `--color-neutral-page-background`, etc. -- Disabled colors: `--color-disabled-base`, `--color-disabled-foreground` -- Primary gray colors: `--color-primary-gray-base`, `--color-primary-gray-darker`, etc. +**Breakpoints are the known gap.** `@media (min-width: var(--x))` is not valid CSS, so +breakpoint values stay duplicated in stylesheets (`75em` appears well over a hundred +times). Check 5 is the cheap mitigation; a real fix needs a preprocessor step. ### Pattern 3: Component with Dynamic Theme Access @@ -548,19 +577,33 @@ test('renders button with correct class', () => { ## Common Pitfalls -### 1. Don't Duplicate Theme in CSS +### 1. Don't Duplicate Theme Values in CSS -**❌ Wrong:** +**❌ Wrong:** hand-copying the value. `src/app/theme.spec.ts` fails on this. +```css +.button { + background: #d4450c; /* duplicates color.primary.orange.base */ +} +``` + +**❌ Also wrong:** declaring your own token for it. `theme.css` is generated; a second +declaration is a second source of truth. ```css :root { - --color-orange: #d4450c; /* Duplicates theme.ts */ + --color-orange: #d4450c; } ``` -**✅ Right:** +**✅ Right:** reference the generated token. +```css +.button { + background: var(--color-primary-orange-base); +} +``` + +**✅ Also right,** when the value genuinely depends on props — bind it from JavaScript: ```typescript -// Bind from theme.ts at component level -style={{ '--button-bg': theme.color.primary.orange.base }} +style={{ '--button-bg': theme.color.primary[bookTheme].base }} ``` ### 2. Use classNames Library for Conditional Classes @@ -633,11 +676,14 @@ const colors = theme.color.primary[bookTheme]; style={{ '--banner-bg': colors.base }} ``` -### 6. Remember That theme.ts is Still the Source of Truth +### 6. Remember Where the Source of Truth Is -- Don't hardcode theme values in CSS -- Always reference theme.ts when you need theme values -- The theme object is unchanged and fully accessible in JavaScript +- `src/app/themeData.ts` holds the values. `src/app/theme.css` is **generated** from it + — never edit the CSS by hand, and run `yarn generate:theme-css` after changing the data. +- `theme.ts` re-exports that data, so `theme.color.x` in JavaScript is unchanged. +- Don't hardcode a theme value in CSS, and don't declare a second token for one. +- Link colors come from the theme too; import them from + `components/Typography/Links.constants.ts` in JS, or use `var(--color-link)` in CSS. ## Migration Checklist diff --git a/e2e_tests/e2e/ui/pages/home.py b/e2e_tests/e2e/ui/pages/home.py index 3c6d44bbb4..2fced83bc2 100644 --- a/e2e_tests/e2e/ui/pages/home.py +++ b/e2e_tests/e2e/ui/pages/home.py @@ -232,28 +232,28 @@ async def fill_highlight_box_note_field(self, value): await self.page.locator("#note-textarea").fill(value) @pytest.mark.asyncio - async def highlight_box_colours_are_visible(self): + async def highlight_box_colors_are_visible(self): return ( await self.page.locator("div") - .get_by_test_id("highlight-colours-picker") + .get_by_test_id("highlight-colors-picker") .is_visible() ) @pytest.mark.asyncio - async def click_highlight_box_purple_colour(self): + async def click_highlight_box_purple_color(self): await self.page.locator("div").get_by_title("purple").first.click() @pytest.mark.asyncio - async def click_highlights_option_green_colour(self): + async def click_highlights_option_green_color(self): await self.page.locator("div").get_by_title("green").first.click() @property - def highlights_option_text_colour_check_purple(self): + def highlights_option_text_color_check_purple(self): return self.page.locator(".highlight-content-wrapper").filter( has_text="notepurple") @property - def highlights_option_text_colour_check_green(self): + def highlights_option_text_color_check_green(self): return self.page.locator(".highlight-content-wrapper").filter( has_text="notegreen") diff --git a/e2e_tests/e2e/ui/test_highlight_box_save_note.py b/e2e_tests/e2e/ui/test_highlight_box_save_note.py index 2e281597b4..bdd7674045 100644 --- a/e2e_tests/e2e/ui/test_highlight_box_save_note.py +++ b/e2e_tests/e2e/ui/test_highlight_box_save_note.py @@ -88,7 +88,7 @@ async def test_overlapping_highlights( @pytest.mark.parametrize( "book_slug, page_slug", [("astronomy-2e", "9-3-impact-craters")] ) -async def test_highlight_box_note_colours( +async def test_highlight_box_note_colors( chrome_page, base_url, book_slug, page_slug, rex_user, rex_password ): @@ -98,7 +98,7 @@ async def test_highlight_box_note_colours( await chrome_page.goto(f"{base_url}/books/{book_slug}/pages/{page_slug}") home = HomeRex(chrome_page) - # THEN: Book page opens, highlight box appears with colours and highlighted text can get different colour + # THEN: Book page opens, highlight box appears with colors and highlighted text can get different color await chrome_page.keyboard.press("Escape") await home.select_text() @@ -108,7 +108,7 @@ async def test_highlight_box_note_colours( assert await home.highlight_box_is_visible() - await home.click_highlight_box_purple_colour() + await home.click_highlight_box_purple_color() await home.click_highlight_box_note_field() @@ -125,15 +125,15 @@ async def test_highlight_box_note_colours( in await home.highlights_option_page.inner_text() ) - assert await home.highlights_option_text_colour_check_purple.is_visible() + assert await home.highlights_option_text_color_check_purple.is_visible() await home.click_highlights_option_page_menu() - await home.click_highlights_option_green_colour() + await home.click_highlights_option_green_color() await chrome_page.keyboard.press("Escape") - assert await home.highlights_option_text_colour_check_green.is_visible() + assert await home.highlights_option_text_color_check_green.is_visible() # THEN: Delete the created highlight diff --git a/e2e_tests/e2e/ui/test_highlight_editbox.py b/e2e_tests/e2e/ui/test_highlight_editbox.py index 4f26764a9f..357876d2fc 100644 --- a/e2e_tests/e2e/ui/test_highlight_editbox.py +++ b/e2e_tests/e2e/ui/test_highlight_editbox.py @@ -29,7 +29,7 @@ async def test_highlight_editbox_opens_on_one_click( assert await home.highlight_box_is_visible() - assert await home.highlight_box_colours_are_visible() + assert await home.highlight_box_colors_are_visible() assert await home.highlight_box_trash_icon_is_visible() await home.click_highlight_box_trash_icon() diff --git a/package.json b/package.json index 3f63a5d323..7bdd179a44 100644 --- a/package.json +++ b/package.json @@ -59,12 +59,14 @@ "lint:css:plain": "stylelint --config .stylelintrc.css.json 'src/**/*.css'", "lint:css:plain-fix": "stylelint --fix --config .stylelintrc.css.json 'src/**/*.css'", "lint:bash": "shellcheck $(find . -type f \\( -iname '*\\.sh' -or -iname '*\\.bash' \\) | grep -v -e .venv -e node_modules)", - "prestart": "npm run-script build:css", + "prestart": "npm run-script build:css && npm run-script generate:theme-css", "start": "./script/start.bash", "start:static": "export REACT_APP_ENV=${REACT_APP_ENV:-test} && npm run-script build && npm run-script prerender:local && npm run-script server", "clean": "rm -rf ./build", - "build": "export DISABLE_NEW_JSX_TRANSFORM=true && npm run-script build:css && npm run-script build:js", + "build": "export DISABLE_NEW_JSX_TRANSFORM=true && npm run-script build:css && npm run-script generate:theme-css && npm run-script build:js", "build:css": "lessc --source-map --source-map-include-source ./generic-styles/index.less ./src/content.css", + "generate:theme-css": "node ./script/entry generate-theme-css", + "generate:theme-baseline": "node ./script/entry generate-theme-baseline", "build:js": "export REACT_APP_ENV=${REACT_APP_ENV:-production} && GENERATE_SOURCEMAP=${GENERATE_SOURCEMAP:-true} craco build", "build:clean": "npm run-script clean && npm run-script build", "prerender:local": "REACT_APP_ENV=${REACT_APP_ENV:-production} node ./script/entry prerender/local", diff --git a/script/generate-theme-baseline.ts b/script/generate-theme-baseline.ts new file mode 100644 index 0000000000..8a6d9a8aa9 --- /dev/null +++ b/script/generate-theme-baseline.ts @@ -0,0 +1,23 @@ +/** + * Rewrites src/app/theme.baseline.json from the current stylesheets. + * + * The baseline records the color violations that predate the token file, so that + * src/app/theme.spec.ts can fail on *new* ones while the sweep works through the old + * ones. Run this after removing violations — the counts it prints should go down, never + * up. If they go up, you have added a hardcoded color that belongs in a token. + */ +import fs from 'fs'; +import path from 'path'; +import { colorViolations } from '../src/test/cssColors'; + +const srcDir = path.join(__dirname, '..', 'src'); +const target = path.join(srcDir, 'app', 'theme.baseline.json'); +const violations = colorViolations(srcDir); + +fs.writeFileSync(target, `${JSON.stringify(violations, null, 2)}\n`); + +// eslint-disable-next-line no-console +console.log( + `wrote ${path.relative(process.cwd(), target)}: ` + + `${violations.duplicates.length} duplicates, ${violations.unknown.length} unrecognised` +); diff --git a/script/generate-theme-css.ts b/script/generate-theme-css.ts new file mode 100644 index 0000000000..13118e5cef --- /dev/null +++ b/script/generate-theme-css.ts @@ -0,0 +1,14 @@ +/** + * Writes src/app/theme.css from the JS theme. Run via `yarn generate:theme-css`. + * The projection itself lives in src/app/themeCss.ts so that it can be unit tested. + */ +import fs from 'fs'; +import path from 'path'; +import { themeCss } from '../src/app/themeCss'; + +const target = path.join(__dirname, '..', 'src', 'app', 'theme.css'); + +fs.writeFileSync(target, themeCss()); + +// eslint-disable-next-line no-console +console.log(`wrote ${path.relative(process.cwd(), target)}`); diff --git a/src/app/components/Typography/Links.constants.ts b/src/app/components/Typography/Links.constants.ts index eb8d17b4c4..b61c3146bb 100644 --- a/src/app/components/Typography/Links.constants.ts +++ b/src/app/components/Typography/Links.constants.ts @@ -1,15 +1,20 @@ /** * Link Color Constants * - * Single source of truth for link colors used across Typography components. - * These constants are: - * - Imported by Button.tsx (ButtonLink component) and bound as CSS variables - * - Imported by Typography.legacy.ts for styled-components css fragments - * - Imported by NavBar/index.tsx for focus outline color + * Re-exported from `app/themeData`, which is the single source of truth and the + * origin of the `--color-link-*` CSS tokens. This module remains the import site + * for JS consumers: + * - Button.tsx (ButtonLink component) binds them as CSS variables + * - Typography.legacy.ts uses them in styled-components css fragments + * - NavBar/index.tsx uses linkFocusOutline for its focus outline color + * + * Stylesheets must not hand-copy these values; use var(--color-link), + * var(--color-link-hover) or var(--color-link-focus-outline) instead. * * This module has no side effects (no React, no CSS imports). */ +import { linkColors } from '../../themeData'; -export const linkColor = '#027EB5'; -export const linkHover = '#0064A0'; -export const linkFocusOutline = '#007297'; +export const linkColor = linkColors.base; +export const linkHover = linkColors.hover; +export const linkFocusOutline = linkColors.focusOutline; diff --git a/src/app/content/components/BookBanner.css b/src/app/content/components/BookBanner.css index 8eefabe62c..6dfcd63787 100644 --- a/src/app/content/components/BookBanner.css +++ b/src/app/content/components/BookBanner.css @@ -101,14 +101,14 @@ .book-banner-bar-wrapper.variant-big { height: var(--banner-desktop-big-height); position: relative; - z-index: var(--z-index-big); + z-index: calc(var(--z-index-navbar) - 1); } /* Mini variant styles */ .book-banner-bar-wrapper.variant-mini { height: var(--banner-desktop-mini-height); position: sticky; - z-index: var(--z-index-mini); + z-index: calc(var(--z-index-navbar) - 2); margin-top: calc(-1 * var(--banner-desktop-mini-height)); } diff --git a/src/app/content/components/BookBanner.tsx b/src/app/content/components/BookBanner.tsx index b5ebd6ec16..fef6e035ac 100644 --- a/src/app/content/components/BookBanner.tsx +++ b/src/app/content/components/BookBanner.tsx @@ -148,9 +148,6 @@ export const BarWrapper = React.forwardRef( )})` : undefined; - const zIndexBig = theme.zIndex.navbar - 1; - const zIndexMini = theme.zIndex.navbar - 2; - return (
( '--banner-desktop-mini-height': `${bookBannerDesktopMiniHeight}rem`, '--banner-mobile-big-height': `${bookBannerMobileBigHeight}rem`, '--banner-mobile-mini-height': `${bookBannerMobileMiniHeight}rem`, - '--z-index-big': zIndexBig, - '--z-index-mini': zIndexMini, '--max-nav-width': `${maxNavWidth}rem`, ...style, } as React.CSSProperties} diff --git a/src/app/content/components/__snapshots__/BookBanner.spec.tsx.snap b/src/app/content/components/__snapshots__/BookBanner.spec.tsx.snap index 45716de38b..e4e008572c 100644 --- a/src/app/content/components/__snapshots__/BookBanner.spec.tsx.snap +++ b/src/app/content/components/__snapshots__/BookBanner.spec.tsx.snap @@ -16,8 +16,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -92,8 +90,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -174,8 +170,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -250,8 +244,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -332,8 +324,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -391,8 +381,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -455,8 +443,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -515,8 +501,6 @@ Array [ "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } > @@ -578,8 +562,6 @@ exports[`BookBanner without unsaved changes renders empty state with no page or "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } /> @@ -598,8 +580,6 @@ exports[`BookBanner without unsaved changes wrapper transition matches snapshot "--max-nav-width": "128rem", "--page-padding-desktop": "3.2rem", "--page-padding-mobile": "1.6rem", - "--z-index-big": 69, - "--z-index-mini": 68, } } /> diff --git a/src/app/content/components/constants.ts b/src/app/content/components/constants.ts index 57e5ddbaca..5bb7db30f6 100644 --- a/src/app/content/components/constants.ts +++ b/src/app/content/components/constants.ts @@ -43,7 +43,11 @@ export const searchSidebarTopOffset = bookBannerMobileMiniHeight export const contentTextWidth = 82.5; -export const mainContentBackground = '#fff'; +// The theme's white, rather than a third copy of '#fff'. ContentPane still binds it +// as --main-content-background and ContentPane.css reads that, so this is a JS value +// today, not a token reference. The sweep should make the CSS default +// var(--main-content-background, var(--color-neutral-base)) and drop the binding. +export const mainContentBackground = theme.color.neutral.base; export const maxContentGutter = 6; export const contentWrapperMaxWidth = contentTextWidth + sidebarDesktopWidth + verticalNavbarMaxWidth; diff --git a/src/app/content/highlights/components/ColorPicker.tsx b/src/app/content/highlights/components/ColorPicker.tsx index bd6b63ce83..9abf278faf 100644 --- a/src/app/content/highlights/components/ColorPicker.tsx +++ b/src/app/content/highlights/components/ColorPicker.tsx @@ -119,7 +119,7 @@ const ColorPicker = ({className, ...props}: Props) => { onKeyDown={handleKeyNavigation} onFocus={focusOnSelected} role='radiogroup' - data-testid='highlight-colours-picker' + data-testid='highlight-colors-picker' > Choose highlight color {highlightStyles.map((style) =>
.dropdown-menu { border: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-bottom-link { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-button { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-copyrights a { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-inner { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-link { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-manage-cookies-flex-link.footer-manage-cookies-flex-link { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-manage-cookies-link.footer-manage-cookies-link { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-mission a { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/Footer/Footer.css: .footer-social-icon { background-color: #767676 } is --color-primary-yellow-foreground-hover", + "app/components/Footer/Footer.css: .footer-social-icon { color: #fff } is --color-white", + "app/components/Footer/Footer.css: .footer-top { background-color: #424242 } is --color-text-default", + "app/components/Footer/Footer.css: .footer-wrapper { color: #d5d5d5 } is --color-neutral-form-border", + "app/components/GoToTopButton.css: .go-to-top-circle { background: #767676 } is --color-primary-yellow-foreground-hover", + "app/components/GoToTopButton.css: .go-to-top-circle { color: white } is --color-white", + "app/components/Modal/Modal.css: .modal-card a { color: #027eb5 } is --color-link", + "app/components/Modal/Modal.css: .modal-card a:hover { color: #0064a0 } is --color-link-hover", + "app/components/Modal/Modal.css: .modal-card { background-color: white } is --color-white", + "app/components/Modal/Modal.css: .modal-close-icon { color: #c5c5c5 } is --color-primary-red-foreground-hover", + "app/components/Modal/Modal.css: .modal-close-icon:hover { color: #5e6062 } is --color-primary-gray-base", + "app/components/Modal/Modal.css: .modal-header { background: #f1f1f1 } is --color-neutral-page-background", + "app/components/Modal/Modal.css: .modal-header { border-bottom: #fafafa } is --color-neutral-darker", + "app/components/NavBar/NavBar.css: .navbar-bar-wrapper { background: #fff } is --color-white", + "app/components/NavBar/NavBar.css: .navbar-dropdown-list > li a:focus-visible { outline: #007297 } is --color-link-focus-outline", + "app/components/NavBar/NavBar.css: .navbar-dropdown-list > li a:hover { color: #0064a0 } is --color-link-hover", + "app/components/NavBar/NavBar.css: .navbar-link { color: #5e6062 } is --color-primary-gray-base", + "app/components/NavBar/NavBar.css: .navbar-link:hover, .navbar-link:active, .navbar-link:focus { border-bottom-color: #63a524 } is --color-primary-green-base", + "app/components/NavBar/NavBar.css: .navbar-link:hover, .navbar-link:active, .navbar-link:focus { color: #424242 } is --color-text-default", + "app/components/Typography/Headings.css: .typography-heading { color: #424242 } is --color-text-default", + "app/content/components/AssignedTopBar.css: .topbar-wrapper.assigned-topbar-wrapper { background-color: #f1f1f1 } is --color-neutral-page-background", + "app/content/components/AssignedTopBar.css: .topbar-wrapper.assigned-topbar-wrapper { color: #424242 } is --color-text-default", + "app/content/components/Attribution.css: .attribution-content a { color: #027eb5 } is --color-link", + "app/content/components/Attribution.css: .attribution-content a:hover { color: #0064a0 } is --color-link-hover", + "app/content/components/Attribution.css: .attribution-details { color: #424242 } is --color-text-default", + "app/content/components/Attribution.css: .attribution-summary { color: #027eb5 } is --color-link", + "app/content/components/Attribution.css: .attribution-summary:hover, .attribution-summary:focus { color: #0064a0 } is --color-link-hover", + "app/content/components/BuyBook.css: .buy-book-link { color: #d4450c } is --color-primary-orange-base", + "app/content/components/Content.css: .content-background { background-color: #fafafa } is --color-neutral-darker", + "app/content/components/ContentExcerpt.css: .content-excerpt a { color: #027eb5 } is --color-link", + "app/content/components/ContentExcerpt.css: .content-excerpt a:hover { color: #0064a0 } is --color-link-hover", + "app/content/components/ContentExcerpt.css: .content-excerpt { color: #424242 } is --color-text-default", + "app/content/components/ContentLink.css: .content-link { color: #027eb5 } is --color-link", + "app/content/components/ContentLink.css: .content-link:hover, .content-link:focus { color: #0064a0 } is --color-link-hover", + "app/content/components/ContentPane.css: @media screen .content-pane-wrapper { background-color: #fff } is --color-white", + "app/content/components/Page/MediaModal.css: .media-modal-scrollable-content { background: white } is --color-white", + "app/content/components/Page/PageNotFound.css: .page-not-found-wrapper { color: #6f6f6f } is --color-text-label", + "app/content/components/PrevNextBar.css: .prev-next-bar-wrapper { border-bottom: #e5e5e5 } is --color-neutral-darkest", + "app/content/components/PrevNextBar.css: .prev-next-bar-wrapper { border-top: #e5e5e5 } is --color-neutral-darkest", + "app/content/components/PrevNextBar.css: .prev-next-bar-wrapper { color: #424242 } is --color-text-default", + "app/content/components/PrevNextBar.css: .prev-next-link { color: #027eb5 } is --color-link", + "app/content/components/PrevNextBar.css: .prev-next-link:hover, .prev-next-link:focus { color: #0064a0 } is --color-link-hover", + "app/content/components/SectionHighlights.css: .highlight-section { background: #e5e5e5 } is --color-neutral-darkest", + "app/content/components/SectionHighlights.css: .highlight-section { color: #424242 } is --color-text-default", + "app/content/components/SectionHighlights.css: .highlight-wrapper { border: #e5e5e5 } is --color-neutral-darkest", + "app/content/components/SectionHighlights.css: .highlights-chapter { color: #424242 } is --color-text-default", + "app/content/components/SectionHighlights.css: @media print .highlight-section { background: white } is --color-white", + "app/content/components/SectionHighlights.css: @media print .highlights-chapter { background: white } is --color-white", + "app/content/components/Toolbar/styled.css: .toolbar-left-arrow { color: #5e6062 } is --color-primary-gray-base", + "app/content/components/Toolbar/styled.css: .toolbar-left-arrow:hover { color: #424242 } is --color-text-default", + "app/content/components/Topbar/Topbar.css: .topbar-search-results-text-button.toolbar-plain-button { color: #027eb5 } is --color-link", + "app/content/components/Topbar/Topbar.css: .topbar-search-results-text-button.toolbar-plain-button:hover, .topbar-search-results-text-button.toolbar-plain-button:focus, .topbar-search-results-text-button.toolbar-plain-button:focus-visible { color: #0064a0 } is --color-link-hover", + "app/content/components/Topbar/Topbar.css: .topbar-search-results-text-button:hover, .topbar-search-results-text-button:focus { color: #0064a0 } is --color-link-hover", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"range\"]::-moz-range-thumb { background: white } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"range\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"range\"]::-moz-range-track { background: #fff } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"range\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"range\"]::-moz-range-track { background: #fff } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"range\"]::-webkit-slider-thumb { background: white } is --color-white", + "app/content/components/popUp/FiltersList.css: .filters-list { color: #424242 } is --color-text-default", + "app/content/components/popUp/FiltersList.css: .filters-list-close-button svg { color: #5e6062 } is --color-primary-gray-base", + "app/content/components/popUp/FiltersList.css: .filters-list-item-label { color: #5e6062 } is --color-primary-gray-base", + "app/content/highlights/components/DisplayNote.css: .display-note .dropdown.focus-within .menu-icon { color: #424242 } is --color-text-default", + "app/content/highlights/components/DisplayNote.css: .display-note .dropdown:focus-within .menu-icon { color: #424242 } is --color-text-default", + "app/content/highlights/components/DisplayNote.css: .display-note { background: #f1f1f1 } is --color-neutral-page-background", + "app/content/highlights/components/DisplayNote.css: .display-note[data-active=\"true\"] { background: #fff } is --color-white", + "app/content/highlights/components/DisplayNote.css: @media (max-width: 75em) and (pointer: coarse), (max-width: 75em) and (hover: none) .display-note > .note-label { color: #000 } is --color-text-black", + "app/content/highlights/components/HighlightStyles.css: .blue-sticky-note .sticky-note-bullet::after { background: rgb(13, 192, 220) } is --color-secondary-light-blue-base", + "app/content/highlights/components/HighlightStyles.css: .blue-sticky-note { background: rgb(13, 192, 220) } is --color-secondary-light-blue-base", + "app/content/highlights/components/HighlightStyles.css: .first-image, .second-image { box-shadow: rgba(0, 0, 0, 1) } is --color-text-black", + "app/content/highlights/components/HighlightStyles.css: .general-text-wrapper a { color: #027eb5 } is --color-link", + "app/content/highlights/components/HighlightStyles.css: .general-text-wrapper a:hover { color: #0064a0 } is --color-link-hover", + "app/content/highlights/components/HighlightStyles.css: .green-sticky-note .sticky-note-bullet::after { background: rgb(99, 165, 36) } is --color-primary-green-base", + "app/content/highlights/components/HighlightStyles.css: .green-sticky-note { background: rgb(99, 165, 36) } is --color-primary-green-base", + "app/content/highlights/components/HighlightStyles.css: .sticky-note { box-shadow: rgba(0, 0, 0, 1) } is --color-text-black", + "app/content/highlights/components/HighlightStyles.css: .sticky-note-bullet::after { box-shadow: rgba(0, 0, 0, 1) } is --color-text-black", + "app/content/highlights/components/Note.css: .note-label { color: #027eb5 } is --color-link", + "app/content/highlights/components/Note.css: .note-textarea { border: #d5d5d5 } is --color-neutral-form-border", + "app/content/highlights/components/Note.css: .note-textarea { color: #6f6f6f } is --color-text-label", + "app/content/highlights/components/ShowMyHighlightsStyles.css: @media print .show-my-highlights-body { background: white } is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightDeleteWrapper.css: .highlight-delete-wrapper span { color: #fff } is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: .highlight-content-wrapper .highlight-note-text { color: #424242 } is --color-text-default", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: .highlight-outer-wrapper { background: #fff } is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: .highlight-outer-wrapper:not(:last-child) { border-bottom: #fafafa } is --color-neutral-darker", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: @media print .highlight-content-wrapper .content-excerpt { background-color: white } is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: @media print .highlight-outer-wrapper { background: white } is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info a { color: #027eb5 } is --color-link", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info a:hover { color: #0064a0 } is --color-link-hover", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info { background-color: #f5f5f5 } is --color-neutral-form-background", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info { border: #d5d5d5 } is --color-neutral-form-border", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info { color: #424242 } is --color-text-default", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: .highlights-help-info__close-icon { color: #818181 } is --color-secondary-light-gray-darkest", + "app/content/highlights/components/TruncatedText.css: .truncated-text-link { color: #027eb5 } is --color-link", + "app/content/highlights/components/TruncatedText.css: .truncated-text-link:hover { color: #0064a0 } is --color-link-hover", + "app/content/highlights/components/TruncatedText.css: .truncated-text-note { color: #424242 } is --color-text-default", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .keyboard-shortcut-key { background-color: #fafafa } is --color-neutral-darker", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .keyboard-shortcut-key { border: #d5d5d5 } is --color-neutral-form-border", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .keyboard-shortcuts-card { background-color: #fff } is --color-white", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .keyboard-shortcuts-card { border: #e5e5e5 } is --color-neutral-darkest", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .popup-body.keyboard-shortcuts-body { background: #fafafa } is --color-neutral-darker", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: .popup-body.keyboard-shortcuts-body { color: #424242 } is --color-text-default", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .close-icon { color: #5e6062 } is --color-primary-gray-base", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .close-icon:hover { color: #424242 } is --color-text-default", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .loading-wrapper { background-color: #fafafa } is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .nav-item { background: #fafafa } is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .related-key-terms { background-color: #fff } is --color-white", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .search-query-wrapper { background: #fafafa } is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: .search-results-bar { background-color: #fafafa } is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: .styled-search-wrapper .topbar-search-input-wrapper { background: #fff } is --color-white", + "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: .styled-search-wrapper--with-background { background: #fff } is --color-white", + "app/content/studyGuides/components/ShowStudyGuides.css: @media print .study-guides-body { background: white } is --color-white", + "app/content/studyGuides/components/StudyGuidesCTA/StudyGuidesCTA.css: .study-guides-cta-link { color: #027eb5 } is --color-link", + "app/content/studyGuides/components/StudyGuidesListElement.css: @media print .study-guides-list-element-content .content-excerpt { background-color: white } is --color-white", + "app/content/studyGuides/components/StudyGuidesListElement.css: @media print .study-guides-list-element-outer:not(:last-child) { border-color: white } is --color-white", + "app/content/styles/PopupStyles.css: .popup-body { background: #fff } is --color-white", + "app/content/styles/PopupStyles.css: .popup-close-icon { color: #fff } is --color-white", + "app/content/styles/PopupStyles.css: .popup-modal { background: #fff } is --color-white", + "app/developer/components/Home.css: .developer-home-style { color: #424242 } is --color-text-default", + "app/errors/components/ErrorBoundary.css: .error-wrapper .body-error-text a { color: #027eb5 } is --color-link", + "app/errors/components/ErrorBoundary.css: .error-wrapper .body-error-text a:hover { color: #0064a0 } is --color-link-hover", + "app/errors/components/ErrorBoundary.css: .error-wrapper .body-error-text { color: #424242 } is --color-text-default", + "app/errors/components/ErrorIdList.css: .error-id-list { color: #424242 } is --color-text-default", + "app/notifications/components/Card.css: .notification-body > div { background-color: #fff } is --color-white", + "app/notifications/components/Card.css: .notification-body > div { border-color: #e5e5e5 } is --color-neutral-darkest", + "app/notifications/components/Card.css: .notification-header { background-color: #fafafa } is --color-neutral-darker", + "app/notifications/components/Card.css: .notification-header { color: #424242 } is --color-text-default", + "app/notifications/components/Card.css: .notification-p a, .notification-header a { color: #027eb5 } is --color-link", + "app/notifications/components/Card.css: .notification-p a:hover, .notification-header a:hover { color: #0064a0 } is --color-link-hover", + "app/notifications/components/Card.css: .notification-p { color: #424242 } is --color-text-default", + "app/notifications/components/Card.css: @media (max-width: 75em) .notification-body { background-color: #fafafa } is --color-neutral-darker", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-warning { border-color: #fdbd3e } is --color-secondary-gold-base", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .toast-close-button-error { color: #c22032 } is --color-secondary-red-base", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .toast-close-button-warning:hover { color: #fdbd3e } is --color-secondary-gold-base" + ], + "unknown": [ + "app/components/Footer/Footer.css: .footer-bottom { background-color: #3b3b3b }", + "app/components/NavBar/NavBar.css: .navbar-dropdown-list > li a { color: #5f6163 }", + "app/components/NavBar/NavBar.css: .navbar-overlay-heading { color: #5f6163 }", + "app/components/NavBar/NavBar.css: .navbar-times-icon { color: #5f6163 }", + "app/content/components/LabsCall.css: .labs-call-link { background-color: #6922ea }", + "app/content/components/LabsCall.css: .labs-call-wrapper { box-shadow: #cacaca }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-blue-focus-border: #006880 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-blue-focused: #00c3ed }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-blue-passive: #c8f5ff }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-green-focus-border: #4e6f01 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-green-focused: #92d101 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-green-passive: #def99f }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-pink-focus-border: #560131 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-pink-focused: #de017e }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-pink-passive: #ffc5e1 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-purple-focus-border: #141a3e }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-purple-focused: #545ec8 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-purple-passive: #cbcfff }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-yellow-focus-border: #8f7700 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-yellow-focused: #fed200 }", + "app/content/components/Page/PageContent.css: .page-content { --highlight-yellow-passive: #ffff8a }", + "app/content/components/Page/PageContent.css: @media screen .page-content .search-highlight, .page-content .search-highlight .math { background-color: #ffea00 }", + "app/content/components/Page/PageContent.css: @media screen .page-content .search-highlight[aria-current], .page-content .search-highlight[aria-current] .math { background-color: #ff9e4b }", + "app/content/components/Topbar/Topbar.css: .topbar-hr { border-top: #efeff1 }", + "app/content/highlights/components/DisplayNote.css: .display-note-close-icon { color: #c2c2c2 }", + "app/content/highlights/components/ShowMyHighlightsStyles.css: .show-my-highlights-body { background: #dedede }", + "app/content/styles/PopupStyles.css: .popup-close-icon:hover { color: #e8e8e8 }", + "app/content/styles/PopupStyles.css: .popup-header { background: #405c7d }", + "app/developer/components/Books.css: .developer-book-li small { color: #ccc }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-error .toast-header { color: #c23834 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-error { background: #f8e8eb }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-error { border-color: #e297a0 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-warning .toast-header { color: #976502 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .banner-body-warning { background: #fff5e0 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .toast-close-button-error:hover { color: #e297a0 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .toast-close-button-warning { color: #976502 }", + "app/notifications/components/ToastNotifications/ToastNotifications.css: .toast-error-id { color: #c23834 }" + ] +} diff --git a/src/app/theme.css b/src/app/theme.css new file mode 100644 index 0000000000..1b5933dd50 --- /dev/null +++ b/src/app/theme.css @@ -0,0 +1,90 @@ +/* + * GENERATED FILE — do not edit. + * + * Generated from src/app/themeData.ts by src/app/themeCss.ts. + * Run `yarn generate:theme-css` to regenerate; src/app/theme.spec.ts fails if + * this file and the JS theme disagree. + */ + +:root { + --color-black: #000; + --color-disabled-base: #f1f1f1; + --color-disabled-foreground: #c1c1c1; + --color-link: #027eb5; + --color-link-focus-outline: #007297; + --color-link-hover: #0064a0; + --color-neutral-base: #fff; + --color-neutral-darker: #fafafa; + --color-neutral-darkest: #e5e5e5; + --color-neutral-foreground: #424242; + --color-neutral-form-background: #f5f5f5; + --color-neutral-form-border: #d5d5d5; + --color-neutral-page-background: #f1f1f1; + --color-primary-blue-base: #002468; + --color-primary-blue-foreground: #fff; + --color-primary-blue-foreground-hover: #888; + --color-primary-deep-green-base: #067056; + --color-primary-deep-green-foreground: #fff; + --color-primary-deep-green-foreground-hover: #c5c5c5; + --color-primary-gray-base: #5e6062; + --color-primary-gray-darker: #424242; + --color-primary-gray-foreground: #fff; + --color-primary-gray-foreground-hover: #424242; + --color-primary-gray-light: #767676; + --color-primary-gray-medium: #888; + --color-primary-gray-lighter: #c5c5c5; + --color-primary-gray-lightest: #ededed; + --color-primary-green-base: #63a524; + --color-primary-green-foreground: #000; + --color-primary-green-foreground-hover: #424242; + --color-primary-light-blue-base: #0dc0dc; + --color-primary-light-blue-foreground: #000; + --color-primary-light-blue-foreground-hover: #424242; + --color-primary-midnight-base: #003e52; + --color-primary-midnight-foreground: #fff; + --color-primary-midnight-foreground-hover: #888; + --color-primary-orange-base: #d4450c; + --color-primary-orange-darker: #be3c08; + --color-primary-orange-darkest: #b03808; + --color-primary-orange-foreground: #fff; + --color-primary-orange-foreground-hover: #ededed; + --color-primary-raise-green-base: #0a5b50; + --color-primary-raise-green-foreground: #fff; + --color-primary-raise-green-foreground-hover: #c5c5c5; + --color-primary-red-base: #c22032; + --color-primary-red-foreground: #fff; + --color-primary-red-foreground-hover: #c5c5c5; + --color-primary-yellow-base: #f4d019; + --color-primary-yellow-foreground: #000; + --color-primary-yellow-foreground-hover: #767676; + --color-secondary-deep-green-base: #0c9372; + --color-secondary-gold-base: #fdbd3e; + --color-secondary-light-blue-base: #0dc0dc; + --color-secondary-light-gray-base: #949494; + --color-secondary-light-gray-darker: #8b8b8b; + --color-secondary-light-gray-darkest: #818181; + --color-secondary-light-gray-foreground: #fff; + --color-secondary-red-base: #c22032; + --color-text-black: #000; + --color-text-default: #424242; + --color-text-label: #6f6f6f; + --color-text-white: #fff; + --color-white: #fff; + --z-index-highlight-inline-card: 10; + --z-index-content-notifications: 20; + --z-index-topbar: 30; + --z-index-overlay: 40; + --z-index-sidebar: 50; + --z-index-toolbar: 60; + --z-index-navbar: 70; + --z-index-mobile-menu: 80; + --z-index-sidebar-mobile-medium: 90; + --z-index-keyboard-shortcuts-popup: 100; + --z-index-highlight-summary-popup: 110; + --z-index-highlights-help-info-mobile: 120; + --z-index-nudge-overlay: 130; + --z-index-error-popup: 140; + --z-index-focused-hidden-link: 150; + --padding-page-desktop: 3.2rem; + --padding-page-mobile: 1.6rem; +} diff --git a/src/app/theme.spec.ts b/src/app/theme.spec.ts new file mode 100644 index 0000000000..4d3f4a2a64 --- /dev/null +++ b/src/app/theme.spec.ts @@ -0,0 +1,123 @@ +/** + * Keeps the theme and the stylesheets honest. + * + * Jest maps `*.css` imports to a style mock, so these read the stylesheets off disk + * with `fs` instead of importing them. The audit itself lives in src/test/cssColors.ts, + * shared with `script/generate-theme-baseline.ts` so the two cannot disagree. + */ +import fs from 'fs'; +import path from 'path'; +import { colorViolations, describeColor, stripNoise, stylesheetFiles } from '../test/cssColors'; +import theme from './theme'; +import { themeCss, themeTokens } from './themeCss'; + +const srcDir = path.join(__dirname, '..'); +const themeCssPath = path.join(__dirname, 'theme.css'); +const baselinePath = path.join(__dirname, 'theme.baseline.json'); + +const relative = (file: string) => path.relative(srcDir, file); + +/** + * The color violations that already existed when the token file was introduced and + * that the sweep subtasks are working through. Locking the list rather than skipping + * the check means enforcement starts now: a new duplicated color fails CI today, and + * the list can only shrink. After removing some, run `yarn generate:theme-baseline` + * and check the counts went down. + * + * Each entry identifies the declaration the literal was written in, not just the file + * and the literal -- see `occurrence` in src/test/cssColors.ts for why. + */ +const baseline = (): {duplicates: string[], unknown: string[]} => + JSON.parse(fs.readFileSync(baselinePath, 'utf8')); + +describe('theme.css', () => { + it('is exactly what the generator produces from the JS theme', () => { + // One equality rather than several assertions, so a missing token, an orphan token + // and a stale value all fail the same way. Run `yarn generate:theme-css`. + expect(fs.readFileSync(themeCssPath, 'utf8')).toEqual(themeCss()); + }); + + it('resolves every color token to real channels', () => { + // Guards the index the audit is built on: a token whose value cannot be resolved + // would silently drop out of it and then be reported as unrecognised everywhere. + const unresolvable = themeTokens() + .filter(([name]) => name.startsWith('color-')) + .filter(([, value]) => describeColor(value) === null) + .map(([name]) => `--${name}`); + + expect(unresolvable).toEqual([]); + }); +}); + +describe('stylesheets', () => { + it('were found, so the audit cannot pass vacuously', () => { + expect(stylesheetFiles(srcDir).length).toBeGreaterThan(50); + }); + + it('do not duplicate a theme color beyond the baseline', () => { + expect(colorViolations(srcDir).duplicates).toEqual(baseline().duplicates); + }); + + it('do not introduce an unrecognised color beyond the baseline', () => { + expect(colorViolations(srcDir).unknown).toEqual(baseline().unknown); + }); + + it('do not read a global token that does not exist', () => { + // Catches a typo in a --color-*/--z-index-*/--padding-* reference, which would + // otherwise fall through to its fallback, or to nothing, silently. It also keeps + // component-local variables out of the global families' namespace. + const declared = new Set(themeTokens().map(([name]) => `--${name}`)); + const globalFamilies = /^--(color|z-index|padding)-/; + + const missing: string[] = []; + + for (const file of stylesheetFiles(srcDir)) { + const read = stripNoise(fs.readFileSync(file, 'utf8')).match(/var\(\s*(--[\w-]+)/g) || []; + + read + .map((match) => match.replace(/var\(\s*/, '')) + .filter((name) => globalFamilies.test(name) && !declared.has(name)) + .forEach((name) => missing.push(`${relative(file)}: ${name}`)); + } + + expect(missing).toEqual([]); + }); + + it('do not use a breakpoint suspiciously close to a theme breakpoint', () => { + // @media (min-width: var(--x)) is not valid CSS, so breakpoint values stay + // duplicated -- 75em appears well over a hundred times. Banning component-specific + // breakpoints outright would be wrong (Footer legitimately uses 37.5em, 60.1em and + // 90em), so this catches the failure the duplication actually causes instead: a + // value meant to be a theme breakpoint but mistyped, e.g. 74em or 75.5em, which + // silently stops matching where the theme's own queries match. The bound is + // inclusive so that 74em -- exactly 1em out, and the likeliest typo -- is caught. + const themeBreaks = [ + theme.breakpoints.mobileSmallBreak, + theme.breakpoints.mobileMediumBreak, + theme.breakpoints.mobileBreak, + ]; + // the desktop side of a max-width query is the theme value + 0.0625, per desktopBreak + const exact = new Set(themeBreaks.reduce( + (result: number[], size) => [...result, size, size + 0.0625], + [] + )); + + const suspicious: string[] = []; + + for (const file of stylesheetFiles(srcDir)) { + const queries = stripNoise(fs.readFileSync(file, 'utf8')) + .match(/\((?:min|max)-width:\s*[\d.]+em\)/g) || []; + + queries + .map((query) => ({ + query, + size: parseFloat((/([\d.]+)em/.exec(query) as RegExpExecArray)[1]), + })) + .filter(({size}) => !exact.has(size)) + .filter(({size}) => themeBreaks.some((themeBreak) => Math.abs(themeBreak - size) <= 1)) + .forEach(({query}) => suspicious.push(`${relative(file)}: ${query}`)); + } + + expect(suspicious).toEqual([]); + }); +}); diff --git a/src/app/theme.ts b/src/app/theme.ts index 8d8dee00e7..b4b30074e9 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -1,11 +1,15 @@ // based on https://sketchviewer.com/sketches/59766aabb57e8900114c89ce/latest/ +import { + color, + desktopBreak, + mobileBreak, + mobileMediumBreak, + mobileSmallBreak, + padding, + zIndex, +} from './themeData'; -export interface ColorSet { - base: string; - foreground: string; - darker?: string; - darkest?: string; -} +export type { ColorSet } from './themeData'; /** * CSS class name for visually hiding content while keeping it accessible to screen readers. @@ -41,13 +45,6 @@ export const hiddenButAccessible = ` border: 0; `; -const textColors = { - black: '#000', - default: '#424242', - label: '#6f6f6f', - white: '#fff', -}; - // Browser default outline for focus items per // https://css-tricks.com/copy-the-browsers-native-focus-styles/ export const defaultFocusOutline = ` @@ -55,109 +52,6 @@ export const defaultFocusOutline = ` outline: 0.2rem auto -webkit-focus-ring-color; `; -const grayColors = { - base: '#5e6062', - darker: '#424242', - foreground: textColors.white, - foregroundHover: '#424242', - light: '#767676', // lightest allowed for text on white background - medium: '#888888', // suitable for darkening white on a dark background - lighter: '#c5c5c5', - lightest: '#ededed', -}; - -const padding = { - page: { - desktop: 3.2, - mobile: 1.6, - }, -}; - -const color = { - black: '#000', - disabled: { - base: '#f1f1f1', - foreground: '#c1c1c1', - }, - neutral: { - base: '#fff', - darker: '#fafafa', - darkest: '#e5e5e5', - foreground: textColors.default, - formBackground: '#f5f5f5', - formBorder: '#d5d5d5', - pageBackground: '#f1f1f1', - }, - primary: { - 'blue': { - base: '#002468', - foreground: textColors.white, - foregroundHover: grayColors.medium, - }, - 'deep-green': { - base: '#067056', - foreground: textColors.white, - foregroundHover: grayColors.lighter, - }, - 'gray': grayColors, - 'green': { - base: '#63a524', - foreground: textColors.black, - foregroundHover: grayColors.darker, - }, - 'light-blue': { - base: '#0DC0DC', - foreground: textColors.black, - foregroundHover: grayColors.darker, - }, - 'midnight': { - base: '#003e52', - foreground: textColors.white, - foregroundHover: grayColors.medium, - }, - 'orange': { - base: '#d4450c', - darker: '#be3c08', - darkest: '#b03808', - foreground: textColors.white, - foregroundHover: grayColors.lightest, - }, - 'raise-green': { - base: '#0a5b50', - foreground: textColors.white, - foregroundHover: grayColors.lighter, - }, - 'red': { - base: '#C22032', - foreground: textColors.white, - foregroundHover: grayColors.lighter, - }, - 'yellow': { - base: '#f4d019', - foreground: textColors.black, - foregroundHover: grayColors.light, - }, - }, - secondary: { - deepGreen: {base: '#0c9372'}, - gold: {base: '#fdbd3e'}, - lightBlue: {base: '#0dc0dc'}, - lightGray: { - base: '#949494', - darker: '#8b8b8b', - darkest: '#818181', - foreground: textColors.white, - }, - red: {base: '#c22032'}, - }, - text: textColors, - white: '#fff', -}; - -const mobileSmallBreak = 30; // 480px -const mobileMediumBreak = 50; // 800px -const mobileBreak = 75; // 1200px -const desktopBreak = mobileBreak + .0625; // 1201px const mobileSmallQuery = `(max-width: ${mobileSmallBreak}em)`; const mobileMediumQuery = `(max-width: ${mobileMediumBreak}em)`; const mobileQuery = `(max-width: ${mobileBreak}em)`; @@ -174,24 +68,5 @@ export default { }, color, padding, - zIndex: [ - 'highlightInlineCard', - 'contentNotifications', - 'topbar', - 'overlay', - 'sidebar', - 'toolbar', - 'navbar', - 'mobileMenu', - 'sidebarMobileMedium', - 'keyboardShortcutsPopup', - 'highlightSummaryPopup', - 'highlightsHelpInfoMobile', - 'nudgeOverlay', - 'errorPopup', - 'focusedHiddenLink', - ].reduce((result, key, index) => { - result[key] = (index + 1) * 10; - return result; - }, {} as {[key: string]: number}), + zIndex, }; diff --git a/src/app/themeCss.ts b/src/app/themeCss.ts new file mode 100644 index 0000000000..a804d71e13 --- /dev/null +++ b/src/app/themeCss.ts @@ -0,0 +1,79 @@ +/** + * Projection from the JS theme data to CSS custom properties. + * + * `theme.css` is generated from this, not hand-written — run + * `yarn generate:theme-css` after changing anything in `themeData.ts`. + * `theme.spec.ts` asserts the committed file matches this output exactly, so a + * missing token, an orphan token and a stale value all fail the same way. + */ +import { color, padding, zIndex } from './themeData'; + +const GENERATED_HEADER = [ + '/*', + ' * GENERATED FILE — do not edit.', + ' *', + ' * Generated from src/app/themeData.ts by src/app/themeCss.ts.', + ' * Run `yarn generate:theme-css` to regenerate; src/app/theme.spec.ts fails if', + ' * this file and the JS theme disagree.', + ' */', +].join('\n'); + +/** + * Hex values are normalised on the way out: lowercased, and shortened to the 3-digit + * form where it is equivalent. The JS theme mixes cases and lengths (`#027EB5` next to + * `#d4450c`, `#888888` next to `#fff`), so normalising gives each token exactly one + * spelling and satisfies stylelint's color-hex-case and color-hex-length without + * having to touch the JS values other code reads. + */ +const normalizeValue = (value: string) => { + if (!/^#[0-9a-fA-F]{6}$/.test(value)) { + return /^#[0-9a-fA-F]{3,8}$/.test(value) ? value.toLowerCase() : value; + } + + const [r1, r2, g1, g2, b1, b2] = value.slice(1).toLowerCase(); + + return r1 === r2 && g1 === g2 && b1 === b2 + ? `#${r1}${g1}${b1}` + : `#${r1}${r2}${g1}${g2}${b1}${b2}`; +}; + +const kebabCase = (value: string) => value + .replace(/([a-z0-9])([A-Z])/g, '$1-$2') + .toLowerCase(); + +/** + * Flattens a nested record of strings into `[dashed-path, value]` pairs. + * `{neutral: {formBorder: '#d5d5d5'}}` becomes `[['neutral-form-border', '#d5d5d5']]`. + * Keys that are already kebab-case (`light-blue`) pass through unchanged. + */ +const flatten = (source: object, prefix: string[]): Array<[string, string]> => + Object.entries(source).reduce((result: Array<[string, string]>, [key, value]) => { + const path = [...prefix, kebabCase(key)]; + + return typeof value === 'object' + ? [...result, ...flatten(value, path)] + : [...result, [path.join('-'), normalizeValue(String(value))]]; + }, []); + +/** + * The tokens `theme.css` declares, in the order they are written. + * + * `--color-link` rather than `--color-link-base` is the one special case: the + * bare name reads better at the call site and matches ui-components. + */ +export const themeTokens = (): Array<[string, string]> => [ + ...flatten(color, ['color']).map(([name, value]): [string, string] => + [name === 'color-link-base' ? 'color-link' : name, value]), + ...flatten(zIndex, ['z-index']), + ...flatten(padding, ['padding']).map(([name, value]): [string, string] => + [name, `${value}rem`]), +]; + +export const themeCss = () => [ + GENERATED_HEADER, + '', + ':root {', + ...themeTokens().map(([name, value]) => ` --${name}: ${value};`), + '}', + '', +].join('\n'); diff --git a/src/app/themeData.ts b/src/app/themeData.ts new file mode 100644 index 0000000000..2b4ccfa0c4 --- /dev/null +++ b/src/app/themeData.ts @@ -0,0 +1,171 @@ +/** + * Pure theme data: colors, padding, z-indexes and breakpoint sizes. + * + * This module deliberately has no imports and no side effects, so that it can be + * read by `script/generate-theme-css.ts` at build time without pulling + * styled-components (or React, or any CSS) into the generator. `theme.ts` spreads + * everything here into its default export, so `theme.color.x` paths are unchanged. + * + * The color, padding and z-index values here are projected into CSS custom + * properties in `theme.css` by `themeCss.ts`. Do not hand-copy one of those into a + * stylesheet — reference its token instead. `theme.spec.ts` fails the build if you do. + * + * The breakpoint sizes at the bottom of this file are the exception: they have no + * token, because `@media (min-width: var(--x))` is not valid CSS, so a media query + * cannot read a custom property. Write the em value in the query. `theme.spec.ts` + * covers that duplication differently — it fails a query whose width is within 1em of + * a theme breakpoint without being one, which is the typo the duplication invites. + */ + +export interface ColorSet { + base: string; + foreground: string; + darker?: string; + darkest?: string; +} + +export const textColors = { + black: '#000', + default: '#424242', + label: '#6f6f6f', + white: '#fff', +}; + +export const grayColors = { + base: '#5e6062', + darker: '#424242', + foreground: textColors.white, + foregroundHover: '#424242', + light: '#767676', // lightest allowed for text on white background + medium: '#888888', // suitable for darkening white on a dark background + lighter: '#c5c5c5', + lightest: '#ededed', +}; + +/** + * Link colors. Re-exported by `components/Typography/Links.constants.ts`, which is + * where JS consumers should keep importing them from. + */ +export const linkColors = { + base: '#027EB5', + focusOutline: '#007297', + hover: '#0064A0', +}; + +export const padding = { + page: { + desktop: 3.2, + mobile: 1.6, + }, +}; + +export const color = { + black: '#000', + disabled: { + base: '#f1f1f1', + foreground: '#c1c1c1', + }, + link: linkColors, + neutral: { + base: '#fff', + darker: '#fafafa', + darkest: '#e5e5e5', + foreground: textColors.default, + formBackground: '#f5f5f5', + formBorder: '#d5d5d5', + pageBackground: '#f1f1f1', + }, + primary: { + 'blue': { + base: '#002468', + foreground: textColors.white, + foregroundHover: grayColors.medium, + }, + 'deep-green': { + base: '#067056', + foreground: textColors.white, + foregroundHover: grayColors.lighter, + }, + 'gray': grayColors, + 'green': { + base: '#63a524', + foreground: textColors.black, + foregroundHover: grayColors.darker, + }, + 'light-blue': { + base: '#0DC0DC', + foreground: textColors.black, + foregroundHover: grayColors.darker, + }, + 'midnight': { + base: '#003e52', + foreground: textColors.white, + foregroundHover: grayColors.medium, + }, + 'orange': { + base: '#d4450c', + darker: '#be3c08', + darkest: '#b03808', + foreground: textColors.white, + foregroundHover: grayColors.lightest, + }, + 'raise-green': { + base: '#0a5b50', + foreground: textColors.white, + foregroundHover: grayColors.lighter, + }, + 'red': { + base: '#C22032', + foreground: textColors.white, + foregroundHover: grayColors.lighter, + }, + 'yellow': { + base: '#f4d019', + foreground: textColors.black, + foregroundHover: grayColors.light, + }, + }, + secondary: { + deepGreen: {base: '#0c9372'}, + gold: {base: '#fdbd3e'}, + lightBlue: {base: '#0dc0dc'}, + lightGray: { + base: '#949494', + darker: '#8b8b8b', + darkest: '#818181', + foreground: textColors.white, + }, + red: {base: '#c22032'}, + }, + text: textColors, + white: '#fff', +}; + +// Breakpoints. No CSS token — see the note at the top of this file. +export const mobileSmallBreak = 30; // 480px +export const mobileMediumBreak = 50; // 800px +export const mobileBreak = 75; // 1200px +export const desktopBreak = mobileBreak + .0625; // 1201px + +export const zIndexOrder = [ + 'highlightInlineCard', + 'contentNotifications', + 'topbar', + 'overlay', + 'sidebar', + 'toolbar', + 'navbar', + 'mobileMenu', + 'sidebarMobileMedium', + 'keyboardShortcutsPopup', + 'highlightSummaryPopup', + 'highlightsHelpInfoMobile', + 'nudgeOverlay', + 'errorPopup', + 'focusedHiddenLink', +]; + +export const zIndex = zIndexOrder.reduce((result, key, index) => { + result[key] = (index + 1) * 10; + return result; +}, {} as {[key: string]: number}); diff --git a/src/index.css b/src/index.css index 9bd99b9a3b..741e43c454 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,12 @@ @import url("https://fonts.googleapis.com/css2?family=IBM+Plex+Mono&display=swap"); @import url("https://fast.fonts.net/t/1.css?apiType=css&projectid=437b6557-ce99-4f35-97ff-64a93247731f"); +/* + * Global theme tokens, generated from src/app/themeData.ts -- do not edit theme.css. + * See PLAIN_CSS_MIGRATION_GUIDE.md, "Pattern 2.5: Using Global Theme Tokens". + */ +@import "./app/theme.css"; + body, #root, html { @@ -209,47 +215,3 @@ noscript { font-weight: 900; font-style: italic; } - -/* - * Root-level CSS variables for static theme colors. - * - * Source of truth: src/app/theme.ts - * - * These variables intentionally mirror a static subset of the theme values so - * plain CSS can use them without dynamic property access. If any corresponding - * color value changes in src/app/theme.ts, this block must be updated in the - * same change to keep the duplicated values in sync. - * - * Dynamic colors (e.g., book themes) should continue using component-level - * bindings as per the hybrid approach documented in PLAIN_CSS_MIGRATION_GUIDE.md - */ -:root { - /* Text colors from theme.color.text */ - --color-text-black: #000; - --color-text-default: #424242; - --color-text-label: #6f6f6f; - --color-text-white: #fff; - - /* Neutral colors from theme.color.neutral */ - --color-neutral-base: #fff; - --color-neutral-darker: #fafafa; - --color-neutral-darkest: #e5e5e5; - --color-neutral-foreground: #424242; - --color-neutral-form-background: #f5f5f5; - --color-neutral-form-border: #d5d5d5; - --color-neutral-page-background: #f1f1f1; - - /* Disabled colors from theme.color.disabled */ - --color-disabled-base: #f1f1f1; - --color-disabled-foreground: #c1c1c1; - - /* Primary gray colors from theme.color.primary.gray */ - --color-primary-gray-base: #5e6062; - --color-primary-gray-darker: #424242; - --color-primary-gray-foreground: #fff; - --color-primary-gray-foreground-hover: #424242; - --color-primary-gray-light: #767676; - --color-primary-gray-medium: #888; - --color-primary-gray-lighter: #c5c5c5; - --color-primary-gray-lightest: #ededed; -} diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts new file mode 100644 index 0000000000..86aaa9fa58 --- /dev/null +++ b/src/test/cssColors.spec.ts @@ -0,0 +1,370 @@ +import { + colorKey, + declarations, + describeColor, + opaqueKey, + stripNoise, + stylesheetColors, + takesColor, +} from './cssColors'; + +const literals = (css: string) => stylesheetColors(css).map((found) => found.literal); +const values = (css: string) => declarations(css).map((declaration) => declaration.value); + +describe('stripNoise', () => { + it('removes block comments', () => { + expect(stripNoise('a { /* #ff0000 */ color: red; }')).not.toContain('#ff0000'); + }); + + it('removes string contents so content: "tan" is not a color', () => { + expect(stripNoise('a { content: "tan"; }')).not.toContain('tan'); + }); + + it('removes url() payloads', () => { + const blanked = stripNoise('a { background: url(data:image/svg+xml;base64,Zm9v) no-repeat; }'); + expect(blanked).not.toContain('base64'); + expect(blanked).toContain('no-repeat'); + }); + + it('keeps the url() parentheses, which are structure rather than noise', () => { + // declarations balances parens to know a `;` inside url() is not a separator + expect(stripNoise('a { background: url(x;y); }')).toContain('url('); + expect(stripNoise('a { background: url(x;y); }')).toContain(')'); + }); + + it('handles an escaped quote inside a string', () => { + expect(stripNoise('a { content: "a\\"b"; }')).not.toContain('b"'); + }); + + it('tolerates an unterminated comment', () => { + expect(stripNoise('a { color: red; /* oops')).toContain('color: red;'); + }); + + it.each([ + ['a comment', 'a { /* note */ color: red; }'], + ['a string', 'a { content: "tan"; }'], + ['an unterminated string', 'a { content: "tan }'], + ['a url()', 'a { background: url(data:image/svg+xml;base64,Zm9v); }'], + ['an unterminated url()', 'a { background: url(oops }'], + ['an unterminated comment', 'a { color: red; /* oops'], + ])('blanks %s without changing the length', (_case, css) => { + // declarations addresses two differently-blanked copies with one index, so this + // is load-bearing rather than cosmetic: a length change silently misaligns context. + expect(stripNoise(css)).toHaveLength(css.length); + }); +}); + +describe('declarations', () => { + it('reads declarations at the top level of a rule', () => { + expect(values('a { color: red; background: blue; }')).toEqual(['red', 'blue']); + }); + + it('reads declarations nested in @media', () => { + expect(values('@media (max-width: 50em) { a { color: red; } }')).toEqual(['red']); + }); + + it('does not mistake a pseudo-class selector for a declaration', () => { + expect(values('a:hover { color: red; }')).toEqual(['red']); + }); + + it('does not mistake @keyframes percentages for declarations', () => { + expect(values('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) + .toEqual(['0', '1']); + }); + + it('ignores at-rules outside a block, such as @import', () => { + expect(values('@import "./theme.css";')).toEqual([]); + }); + + it('reads a declaration with no trailing semicolon', () => { + expect(values('a { color: red }')).toEqual(['red']); + }); + + it('does not split on a semicolon inside parentheses', () => { + expect(values('a { background: url(x;y); color: red; }')).toContain('red'); + }); + + it('keeps a custom property declaration', () => { + expect(values(':root { --color-x: #fff; }')).toEqual(['#fff']); + }); + + it('lower-cases the property name', () => { + expect(declarations('a { COLOR: red; }')[0].property).toEqual('color'); + }); + + it('records the selector as context', () => { + expect(declarations('a:hover .thing { color: red; }')[0].context) + .toEqual('a:hover .thing'); + }); + + it('collapses whitespace in the context', () => { + expect(declarations('a,\n b {\n color: red;\n}')[0].context).toEqual('a, b'); + }); + + it('nests the at-rule prelude and the selector in the context', () => { + expect(declarations('@media (max-width: 50em) { a { color: red; } }')[0].context) + .toEqual('@media (max-width: 50em) a'); + }); + + it('pops the context again after a nested block closes', () => { + const parsed = declarations('@media (max-width: 50em) { a { color: red; } } b { color: blue; }'); + expect(parsed.map(({context}) => context)) + .toEqual(['@media (max-width: 50em) a', 'b']); + }); + + it('keeps string contents in the context, so attribute selectors stay distinct', () => { + // the baseline identifies an occurrence by its context, so two rules that differ + // only inside a selector string must not reduce to the same one -- otherwise a + // literal could move between them and the ratchet would see no change. + const parsed = declarations( + '.x[data-loading="true"] { color: #fff; } .x[data-loading="false"] { color: #fff; }' + ); + + expect(parsed.map(({context}) => context)) + .toEqual(['.x[data-loading="true"]', '.x[data-loading="false"]']); + }); + + it('still blanks strings in the value, where they are not colors', () => { + // the other half of the same change: context keeps strings, values must not, or + // `content: "#fff"` starts reading as a color. + expect(declarations('a { content: "#fff"; }')).toEqual([]); + }); + + it('does not let a brace inside a selector string open a block', () => { + expect(declarations('.x[data-glyph="{"] { color: red; }')) + .toEqual([{context: '.x[data-glyph="{"]', property: 'color', value: 'red'}]); + }); +}); + +describe('takesColor', () => { + it.each(['color', 'background-color', 'border-top-color', '-webkit-text-fill-color'])( + 'accepts %s, which names a color', (property) => { + expect(takesColor(property)).toBe(true); + } + ); + + it.each(['background', 'border', 'border-left', 'box-shadow', 'outline', 'fill'])( + 'accepts the %s shorthand', (property) => { + expect(takesColor(property)).toBe(true); + } + ); + + it('accepts a custom property, which has no grammar to go on', () => { + expect(takesColor('--book-banner-background')).toBe(true); + }); + + it.each(['animation-name', 'font-family', 'transition-property', 'grid-area'])( + 'rejects %s, where an identifier is not a color', (property) => { + expect(takesColor(property)).toBe(false); + } + ); + + it('sees through a vendor prefix', () => { + expect(takesColor('-webkit-box-shadow')).toBe(true); + }); +}); + +describe('findColors', () => { + it('finds a hex literal', () => { + expect(literals('a { color: #ff0000; }')).toEqual(['#ff0000']); + }); + + it('finds a bare named color in a shorthand', () => { + expect(literals('a { border: 0.1rem solid red; }')).toEqual(['red']); + }); + + it('finds colors in gradient stops', () => { + expect(literals('a { background: linear-gradient(to top, #fff 0%, #000 100%); }')) + .toEqual(['#fff', '#000']); + }); + + it('descends into var() fallbacks rather than treating var() as a literal', () => { + expect(literals('a { color: var(--x, #fff); }')).toEqual(['#fff']); + }); + + it('descends into color-mix() over tokens and finds nothing', () => { + expect(literals('a { color: color-mix(in srgb, var(--a), var(--b)); }')).toEqual([]); + }); + + it('finds rgba()', () => { + expect(literals('a { box-shadow: 0 0 0.2rem rgba(0, 0, 0, 0.2); }')) + .toEqual(['rgba(0, 0, 0, 0.2)']); + }); + + it('finds hsl(), which resolves to null so it cannot pass silently', () => { + const found = stylesheetColors('a { color: hsl(0deg 100% 50%); }'); + expect(found).toHaveLength(1); + expect(found[0].rgba).toBeNull(); + }); + + it('finds oklch(), which resolves to null', () => { + expect(stylesheetColors('a { color: oklch(0.7 0.1 200); }')[0].rgba).toBeNull(); + }); + + it('flags rgba() over a var() channel list rather than skipping it', () => { + const found = stylesheetColors('a { color: rgba(var(--channels), 0.2); }'); + expect(found).toHaveLength(1); + expect(found[0].rgba).toBeNull(); + }); + + it('does not treat a class selector named .red as a color', () => { + expect(literals('.red { opacity: 1; }')).toEqual([]); + }); + + it('does not treat content: "tan" as a color', () => { + expect(literals('a { content: "tan"; }')).toEqual([]); + }); + + it('does not treat a color inside a comment as a color', () => { + expect(literals('a { /* was #ff0000 */ color: var(--x); }')).toEqual([]); + }); + + it('does not treat transparent or currentcolor as comparable colors', () => { + expect(literals('a { color: currentcolor; background: transparent; }')).toEqual([]); + }); + + it('does not treat a non-color keyword as a color', () => { + expect(literals('a { transition: all 0.2s linear; }')).toEqual([]); + }); + + it('finds several colors in one declaration', () => { + expect(literals('a { box-shadow: 0 0 0 red, 0 0 0 #00f; }')).toEqual(['red', '#00f']); + }); + + it('tolerates an unbalanced function call', () => { + expect(() => literals('a { color: rgb(0, 0, 0; }')).not.toThrow(); + }); + + it.each([ + ['an animation name', 'a { animation-name: red; }'], + ['a font family', 'a { font-family: black; }'], + ['a transitioned property', 'a { transition-property: tan; }'], + ['a grid area', 'a { grid-area: navy; }'], + // the property gate has to survive the descent into a function, not just the + // top level of the value -- findColors passes `named` down to itself. + ['a var() fallback under one', 'a { animation-name: var(--enter, red); }'], + ])('does not read %s as a named color', (_case, css) => { + expect(literals(css)).toEqual([]); + }); + + it.each([ + ['a color property', 'a { color: red; }'], + ['a shorthand', 'a { border: 0.1rem solid red; }'], + ['a custom property', 'a { --x: red; }'], + ['a box-shadow', 'a { box-shadow: 0 0 0.2rem red; }'], + ['a vendor-prefixed property', 'a { -webkit-text-fill-color: red; }'], + // the other side of the descent: gating named colors on the property must not + // stop finding them inside a function the walk descends into. + ['a gradient stop', 'a { background: linear-gradient(to top, red, transparent); }'], + ])('still reads a named color in %s', (_case, css) => { + expect(literals(css)).toEqual(['red']); + }); + + it('still reads hex and rgb() in a property that cannot take a named color', () => { + // only the bare-identifier case is property-sensitive: `#fff` and `rgb(...)` are + // colors wherever they are written, so they stay in scope everywhere. + expect(literals('a { animation-name: #fff; transition-property: rgb(0, 0, 0); }')) + .toEqual(['#fff', 'rgb(0, 0, 0)']); + }); + + it('records the declaration each color was written in', () => { + expect(stylesheetColors('@media (max-width: 50em) { .a:hover { color: #fff; } }')) + .toEqual([{ + context: '@media (max-width: 50em) .a:hover', + literal: '#fff', + property: 'color', + rgba: {a: 1, b: 255, g: 255, r: 255}, + }]); + }); +}); + +describe('describeColor', () => { + it('expands 3-digit hex', () => { + expect(describeColor('#fff')).toEqual({a: 1, b: 255, g: 255, r: 255}); + }); + + it('reads 8-digit hex alpha', () => { + expect(describeColor('#00000033')?.a).toBeCloseTo(0.2, 1); + }); + + it('reads 4-digit hex', () => { + expect(describeColor('#0000')).toEqual({a: 0, b: 0, g: 0, r: 0}); + }); + + it('is case insensitive', () => { + expect(describeColor('#027EB5')).toEqual(describeColor('#027eb5')); + }); + + it('resolves a named color', () => { + expect(describeColor('white')).toEqual({a: 1, b: 255, g: 255, r: 255}); + }); + + it('reads comma-separated rgb()', () => { + expect(describeColor('rgb(255, 0, 0)')).toEqual({a: 1, b: 0, g: 0, r: 255}); + }); + + it('reads space-separated rgb() with a slash alpha', () => { + expect(describeColor('rgb(255 0 0 / 0.5)')).toEqual({a: 0.5, b: 0, g: 0, r: 255}); + }); + + it('reads percentage channels', () => { + expect(describeColor('rgb(100%, 0%, 0%)')).toEqual({a: 1, b: 0, g: 0, r: 255}); + }); + + it('reads a percentage alpha', () => { + expect(describeColor('rgba(0, 0, 0, 20%)')?.a).toBeCloseTo(0.2); + }); + + it('returns null for hsl()', () => { + expect(describeColor('hsl(0, 100%, 50%)')).toBeNull(); + }); + + it('returns null for a non-numeric channel', () => { + expect(describeColor('rgb(var(--x), 0, 0)')).toBeNull(); + }); + + it('returns null for the wrong number of channels', () => { + expect(describeColor('rgb(0, 0)')).toBeNull(); + }); + + it('returns null for an unknown identifier', () => { + expect(describeColor('notacolor')).toBeNull(); + }); + + it.each(['#12345', '#1234567', '#123456789'])( + 'returns null for the malformed hex length %s', (literal) => { + expect(describeColor(literal)).toBeNull(); + } + ); + + it.each(['#ggg', '#gggggg', '#12345g'])( + 'returns null for %s rather than a set of NaN channels', (literal) => { + // the length is right, so only checking the length would hand back + // {r: NaN, g: NaN, b: NaN} and read as a resolved color. + expect(describeColor(literal)).toBeNull(); + } + ); + + it('rounds a percentage channel the same way as its integer spelling', () => { + // 50% of 255 is 127.5, which rounds to 128. Scaling by the decimal 2.55 gives + // 127.49999999999999 and rounds to 127, so the two spellings would disagree. + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual({a: 1, b: 128, g: 128, r: 128}); + expect(describeColor('rgb(50%, 50%, 50%)')).toEqual(describeColor('rgb(128, 128, 128)')); + }); +}); + +describe('color keys', () => { + it('treats an opaque color as equal however it is written', () => { + expect(colorKey(describeColor('#fff')!)).toEqual(colorKey(describeColor('white')!)); + }); + + it('distinguishes a translucent color from its opaque form', () => { + expect(colorKey(describeColor('rgba(0, 0, 0, 0.2)')!)) + .not.toEqual(colorKey(describeColor('#000')!)); + }); + + it('recognises a translucent color by its opaque channels', () => { + expect(opaqueKey(describeColor('rgba(0, 0, 0, 0.2)')!)) + .toEqual(opaqueKey(describeColor('#000')!)); + }); +}); diff --git a/src/test/cssColors.ts b/src/test/cssColors.ts new file mode 100644 index 0000000000..2f2844069e --- /dev/null +++ b/src/test/cssColors.ts @@ -0,0 +1,532 @@ +/** + * color auditing for plain-CSS stylesheets, used by src/app/theme.spec.ts. + * + * This parses declarations rather than grepping for `#hex`, because a grep misses + * `rgba()`, `hsl()`, named colors in shorthands and colors in gradient stops — + * all of which can silently duplicate or diverge from a theme value. + * + * Lives under src/test/ because it is test infrastructure rather than app code, so + * it is outside jest's `collectCoverageFrom`. It has its own spec regardless: without + * one, "CI enforces the palette" would be an assertion rather than a tested guarantee. + */ + +import fs from 'fs'; +import path from 'path'; +import { themeTokens } from '../app/themeCss'; + +export interface Rgba { + r: number; + g: number; + b: number; + a: number; +} + +export interface FoundColor { + /** the literal exactly as written, e.g. `rgba(0, 0, 0, 0.2)` */ + literal: string; + /** resolved channels, or null when this syntax cannot be resolved statically */ + rgba: Rgba | null; +} + +/** A declaration, with enough of its surroundings to identify it again. */ +export interface Declaration { + /** + * The selectors and at-rule preludes the declaration sits inside, outermost first, + * whitespace-collapsed: `@media (max-width: 75em) .book-banner .title`. Used as the + * stable half of a color occurrence's identity in the baseline. + */ + context: string; + /** lower-cased property name, e.g. `background-color` or `--book-banner-height` */ + property: string; + /** everything to the right of the `:` */ + value: string; +} + +/** A color literal together with the declaration it was written in. */ +export interface StylesheetColor extends FoundColor { + context: string; + property: string; +} + +/** https://www.w3.org/TR/css-color-4/#named-colors */ +const NAMED_COLORS: {[name: string]: string} = { + aliceblue: '#f0f8ff', antiquewhite: '#faebd7', aqua: '#00ffff', aquamarine: '#7fffd4', + azure: '#f0ffff', beige: '#f5f5dc', bisque: '#ffe4c4', black: '#000000', + blanchedalmond: '#ffebcd', blue: '#0000ff', blueviolet: '#8a2be2', brown: '#a52a2a', + burlywood: '#deb887', cadetblue: '#5f9ea0', chartreuse: '#7fff00', chocolate: '#d2691e', + coral: '#ff7f50', cornflowerblue: '#6495ed', cornsilk: '#fff8dc', crimson: '#dc143c', + cyan: '#00ffff', darkblue: '#00008b', darkcyan: '#008b8b', darkgoldenrod: '#b8860b', + darkgray: '#a9a9a9', darkgreen: '#006400', darkgrey: '#a9a9a9', darkkhaki: '#bdb76b', + darkmagenta: '#8b008b', darkolivegreen: '#556b2f', darkorange: '#ff8c00', + darkorchid: '#9932cc', darkred: '#8b0000', darksalmon: '#e9967a', + darkseagreen: '#8fbc8f', darkslateblue: '#483d8b', darkslategray: '#2f4f4f', + darkslategrey: '#2f4f4f', darkturquoise: '#00ced1', darkviolet: '#9400d3', + deeppink: '#ff1493', deepskyblue: '#00bfff', dimgray: '#696969', dimgrey: '#696969', + dodgerblue: '#1e90ff', firebrick: '#b22222', floralwhite: '#fffaf0', + forestgreen: '#228b22', fuchsia: '#ff00ff', gainsboro: '#dcdcdc', + ghostwhite: '#f8f8ff', gold: '#ffd700', goldenrod: '#daa520', gray: '#808080', + green: '#008000', greenyellow: '#adff2f', grey: '#808080', honeydew: '#f0fff0', + hotpink: '#ff69b4', indianred: '#cd5c5c', indigo: '#4b0082', ivory: '#fffff0', + khaki: '#f0e68c', lavender: '#e6e6fa', lavenderblush: '#fff0f5', lawngreen: '#7cfc00', + lemonchiffon: '#fffacd', lightblue: '#add8e6', lightcoral: '#f08080', + lightcyan: '#e0ffff', lightgoldenrodyellow: '#fafad2', lightgray: '#d3d3d3', + lightgreen: '#90ee90', lightgrey: '#d3d3d3', lightpink: '#ffb6c1', + lightsalmon: '#ffa07a', lightseagreen: '#20b2aa', lightskyblue: '#87cefa', + lightslategray: '#778899', lightslategrey: '#778899', lightsteelblue: '#b0c4de', + lightyellow: '#ffffe0', lime: '#00ff00', limegreen: '#32cd32', linen: '#faf0e6', + magenta: '#ff00ff', maroon: '#800000', mediumaquamarine: '#66cdaa', + mediumblue: '#0000cd', mediumorchid: '#ba55d3', mediumpurple: '#9370db', + mediumseagreen: '#3cb371', mediumslateblue: '#7b68ee', mediumspringgreen: '#00fa9a', + mediumturquoise: '#48d1cc', mediumvioletred: '#c71585', midnightblue: '#191970', + mintcream: '#f5fffa', mistyrose: '#ffe4e1', moccasin: '#ffe4b5', + navajowhite: '#ffdead', navy: '#000080', oldlace: '#fdf5e6', olive: '#808000', + olivedrab: '#6b8e23', orange: '#ffa500', orangered: '#ff4500', orchid: '#da70d6', + palegoldenrod: '#eee8aa', palegreen: '#98fb98', paleturquoise: '#afeeee', + palevioletred: '#db7093', papayawhip: '#ffefd5', peachpuff: '#ffdab9', + peru: '#cd853f', pink: '#ffc0cb', plum: '#dda0dd', powderblue: '#b0e0e6', + purple: '#800080', rebeccapurple: '#663399', red: '#ff0000', rosybrown: '#bc8f8f', + royalblue: '#4169e1', saddlebrown: '#8b4513', salmon: '#fa8072', + sandybrown: '#f4a460', seagreen: '#2e8b57', seashell: '#fff5ee', sienna: '#a0522d', + silver: '#c0c0c0', skyblue: '#87ceeb', slateblue: '#6a5acd', slategray: '#708090', + slategrey: '#708090', snow: '#fffafa', springgreen: '#00ff7f', steelblue: '#4682b4', + tan: '#d2b48c', teal: '#008080', thistle: '#d8bfd8', tomato: '#ff6347', + turquoise: '#40e0d0', violet: '#ee82ee', wheat: '#f5deb3', white: '#ffffff', + whitesmoke: '#f5f5f5', yellow: '#ffff00', yellowgreen: '#9acd32', +}; + +/** + * color functions are terminal — we try to resolve them and flag them. + * Anything else that happens to *contain* a color (`var`, `color-mix`, the + * gradients) is descended into instead. + */ +const COLOR_FUNCTIONS = [ + 'rgb', 'rgba', 'hsl', 'hsla', 'hwb', 'lab', 'lch', 'oklab', 'oklch', 'color', +]; + +/** + * Keywords that are color-valued but carry no fixed channels, so there is nothing + * to compare against the theme. They are never flagged. + */ +const COLOR_KEYWORDS = ['transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert', 'none']; + +/** + * Blanks the parts of a stylesheet that can hold color-shaped text without meaning a + * color: comments, string contents and `url()` payloads. + * + * Blanked to spaces rather than deleted, so the result is the same length as the input + * and every character keeps its original index. `declarations` relies on that: it finds + * structure in the blanked text and then slices the corresponding span out of a second, + * differently-blanked copy. + * + * `keepStrings` is what that second copy is for. A string is noise inside a declaration + * value — `content: "#fff"` is not a color — but it is *meaning* inside a selector: + * `[data-loading="true"]` and `[data-loading="false"]` are different rules, and blanking + * both to `[data-loading=""]` would make them one declaration as far as the baseline is + * concerned, so a literal could move between them without the ratchet noticing. + */ +const blankNoise = (css: string, keepStrings: boolean): string => { + const pad = (length: number) => ' '.repeat(Math.max(0, length)); + let out = ''; + let index = 0; + + while (index < css.length) { + const rest = css.slice(index); + + if (rest.startsWith('/*')) { + const end = css.indexOf('*/', index + 2); + const stop = end === -1 ? css.length : end + 2; + out += pad(stop - index); + index = stop; + continue; + } + + const quote = css[index]; + if (quote === '"' || quote === '\'') { + let cursor = index + 1; + while (cursor < css.length && css[cursor] !== quote) { + cursor += css[cursor] === '\\' ? 2 : 1; + } + const stop = Math.min(cursor + 1, css.length); + // blanked whole, quotes included: nothing downstream needs the quotes, and + // keeping them would have to handle an unterminated string running off the end. + out += keepStrings ? css.slice(index, stop) : pad(stop - index); + index = stop; + continue; + } + + const url = /^url\(/i.exec(rest); + if (url) { + const open = index + url[0].length; + let depth = 1; + let cursor = open; + while (cursor < css.length && depth > 0) { + if (css[cursor] === '(') { depth++; } + if (css[cursor] === ')') { depth--; } + cursor++; + } + // the parens themselves are structure -- `declarations` balances them -- so only + // the payload between them is blanked. + const closed = depth === 0; + const payloadEnd = closed ? cursor - 1 : cursor; + out += css.slice(index, open) + pad(payloadEnd - open) + (closed ? ')' : ''); + index = cursor; + continue; + } + + out += css[index]; + index++; + } + + return out; +}; + +/** Noise blanked for reading declaration values: strings go too. */ +export const stripNoise = (css: string): string => blankNoise(css, false); + +/** + * Pulls declarations out of a stylesheet at any nesting depth, so `@media` blocks are + * covered. Selectors and at-rule preludes end at a `{` and become the declaration's + * `context` rather than being read as declarations themselves, which is what keeps + * `a:hover` and `@keyframes` percentages out of the color scan. + * + * The property name is kept as well as the value. Two things need it: a bare + * identifier is only a color in a property that takes one (`animation-name: red` is + * an animation), and the baseline needs a way to tell two occurrences of the same + * literal in the same file apart. + * + * Two blanked copies of the source are walked in step. Structure is read from `values`, + * where strings are gone, so a `;` or `{` inside one cannot split a declaration. The + * `context` is sliced out of `selectors`, where string contents survive, so that + * `[data-loading="true"]` and `[data-loading="false"]` stay distinguishable. Both are + * the same length as the input, which is what lets one index address both. + */ +export const declarations = (css: string): Declaration[] => { + const found: Declaration[] = []; + const values = stripNoise(css); + const selectors = blankNoise(css, true); + const stack: string[] = []; + let start = 0; + let parens = 0; + + const flush = (end: number) => { + const segment = values.slice(start, end); + const separator = segment.indexOf(':'); + + if (stack.length > 0 && separator !== -1) { + const value = segment.slice(separator + 1).trim(); + const property = segment.slice(0, separator).trim().toLowerCase(); + if (value) { found.push({context: stack.join(' '), property, value}); } + } + + start = end + 1; + }; + + for (let index = 0; index < values.length; index++) { + const character = values[index]; + + if (character === '(') { parens++; } + if (character === ')') { parens = Math.max(0, parens - 1); } + if (parens !== 0) { continue; } + + if (character === '{') { + stack.push(selectors.slice(start, index).replace(/\s+/g, ' ').trim()); + start = index + 1; + } else if (character === '}') { + flush(index); + stack.pop(); + } else if (character === ';') { + flush(index); + } + } + + return found; +}; + +/** + * Properties whose value can hold a ``, directly or inside a shorthand. + * + * Hex and the color functions are only ever colors, so they are read wherever they + * appear. A bare identifier is not: `animation-name: red` names a keyframe animation + * and `font-family: black` names a font, and reporting either as a palette violation + * would be wrong. Named colors are therefore only read here. Custom properties have + * no property grammar at all, so they count. + */ +const COLOR_SHORTHANDS = [ + 'background', 'background-image', 'border', 'border-block', 'border-block-end', + 'border-block-start', 'border-bottom', 'border-image', 'border-image-source', + 'border-inline', 'border-inline-end', 'border-inline-start', 'border-left', + 'border-right', 'border-top', 'box-shadow', 'caret', 'column-rule', 'fill', 'filter', + 'backdrop-filter', 'list-style', 'mask', 'mask-image', 'outline', 'stroke', + 'text-decoration', 'text-emphasis', 'text-shadow', 'text-stroke', +]; + +export const takesColor = (property: string): boolean => { + if (property.startsWith('--')) { return true; } + + const name = property.replace(/^-(?:webkit|moz|ms|o)-/, ''); + + return name.includes('color') || COLOR_SHORTHANDS.includes(name); +}; + +const clamp = (value: number, max: number) => Math.min(max, Math.max(0, value)); + +const channel = (raw: string): number | null => { + const text = raw.trim(); + const percent = /^(-?[\d.]+)%$/.exec(text); + // scale by 255/100 rather than by the decimal 2.55, which is not representable in + // binary: 50 * 2.55 is 127.49999999999999 and rounds to 127, where 50% of 255 is + // 127.5 and rounds to 128. The two spellings of the same color must agree, or they + // get different keys and the audit misclassifies one of them. + if (percent) { return Math.round((clamp(parseFloat(percent[1]), 100) / 100) * 255); } + return /^-?[\d.]+$/.test(text) ? Math.round(clamp(parseFloat(text), 255)) : null; +}; + +const alphaChannel = (raw?: string): number | null => { + if (raw === undefined) { return 1; } + const text = raw.trim(); + const percent = /^(-?[\d.]+)%$/.exec(text); + if (percent) { return clamp(parseFloat(percent[1]), 100) / 100; } + return /^-?[\d.]+$/.test(text) ? clamp(parseFloat(text), 1) : null; +}; + +/** + * Only the four lengths CSS defines, and only hex digits. Checking the grammar rather + * than just the length matters: `#ggg` would otherwise expand to six characters, + * `parseInt` them to NaN, and hand back an Rgba of NaNs that reads as a resolved + * color. A malformed *theme* value would then pass the "every color token resolves" + * spec while generating invalid CSS. + */ +const HEX = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/; + +const fromHex = (literal: string): Rgba | null => { + if (!HEX.test(literal.toLowerCase())) { return null; } + + const digits = literal.slice(1); + const expand = (text: string) => text.split('').map((c) => c + c).join(''); + const full = digits.length === 3 || digits.length === 4 ? expand(digits) : digits; + + return { + a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1, + b: parseInt(full.slice(4, 6), 16), + g: parseInt(full.slice(2, 4), 16), + r: parseInt(full.slice(0, 2), 16), + }; +}; + +/** + * Resolves a color literal to channels, or null when it cannot be resolved + * statically. Returning null is deliberate: `hsl()`, `oklch()` and `color()` fail + * the audit rather than passing silently, so the escape hatch stays explicit. + */ +export const describeColor = (literal: string): Rgba | null => { + const text = literal.trim(); + + if (text.startsWith('#')) { return fromHex(text.toLowerCase()); } + + const named = NAMED_COLORS[text.toLowerCase()]; + if (named) { return fromHex(named); } + + const fn = /^(rgba?)\((.*)\)$/i.exec(text); + if (!fn) { return null; } + + const args = fn[2].includes(',') + ? fn[2].split(',') + : fn[2].replace(/\//g, ' ').trim().split(/\s+/); + + if (args.length < 3 || args.length > 4) { return null; } + + const [r, g, b] = args.slice(0, 3).map(channel); + const a = alphaChannel(args[3]); + + return r === null || g === null || b === null || a === null ? null : {a, b, g, r}; +}; + +/** + * Finds every color literal in a declaration value, at any depth. Functions that + * merely contain colors are descended into; color functions are terminal. + * + * `named` says whether a bare identifier may be read as a color, which depends on the + * property the value belongs to — see `takesColor`. Hex and the color functions are + * unambiguous and are found either way. It has no default: defaulting it to `true` + * would quietly restore the over-eager behaviour for any caller that forgot it. + */ +export const findColors = (value: string, named: boolean): FoundColor[] => { + const found: FoundColor[] = []; + let index = 0; + + while (index < value.length) { + const rest = value.slice(index); + + const call = /^([a-z][\w-]*)\(/i.exec(rest); + if (call) { + let depth = 1; + let cursor = index + call[0].length; + while (cursor < value.length && depth > 0) { + if (value[cursor] === '(') { depth++; } + if (value[cursor] === ')') { depth--; } + cursor++; + } + const literal = value.slice(index, cursor); + const args = literal.slice(call[0].length, literal.endsWith(')') ? -1 : undefined); + + if (COLOR_FUNCTIONS.includes(call[1].toLowerCase())) { + found.push({literal, rgba: describeColor(literal)}); + } else { + found.push(...findColors(args, named)); + } + + index = cursor; + continue; + } + + const hex = /^#[0-9a-fA-F]{3,8}\b/.exec(rest); + if (hex) { + found.push({literal: hex[0], rgba: describeColor(hex[0])}); + index += hex[0].length; + continue; + } + + const word = /^-?[a-zA-Z][\w-]*/.exec(rest); + if (word) { + const name = word[0].toLowerCase(); + if (named && NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { + found.push({literal: word[0], rgba: describeColor(word[0])}); + } + index += word[0].length; + continue; + } + + index++; + } + + return found; +}; + +/** + * Every color literal written in a stylesheet, in source order. + * + * Accumulated with `push` rather than by spreading into a new array per declaration: + * this runs over every stylesheet in the tree, so the quadratic version was copying + * every color found so far once per subsequent declaration. + */ +export const stylesheetColors = (css: string): StylesheetColor[] => { + const found: StylesheetColor[] = []; + + for (const {context, property, value} of declarations(css)) { + for (const color of findColors(value, takesColor(property))) { + found.push({...color, context, property}); + } + } + + return found; +}; + +/** Canonical key for comparing two colors. Opaque colors ignore alpha. */ +export const colorKey = (rgba: Rgba): string => + rgba.a === 1 ? `${rgba.r},${rgba.g},${rgba.b}` : `${rgba.r},${rgba.g},${rgba.b},${rgba.a}`; + +/** Key ignoring alpha, so `rgba(0, 0, 0, 0.2)` can be recognised as the theme's black. */ +export const opaqueKey = (rgba: Rgba): string => `${rgba.r},${rgba.g},${rgba.b}`; + +/** + * Below: the stylesheet audit itself. It lives here rather than in theme.spec.ts so + * that the spec and `script/generate-theme-baseline.ts` cannot drift apart — the + * baseline would otherwise be generated by different logic than it is checked with. + */ + +/** Maps a canonical color key to the token that declares it. */ +export const themeColorIndex = (): {[key: string]: string} => { + const index: {[key: string]: string} = {}; + + for (const [name, value] of themeTokens()) { + const rgba = describeColor(value); + if (rgba !== null) { index[colorKey(rgba)] = `--${name}`; } + } + + return index; +}; + +/** + * Colors that are deliberately not theme values, so they are never reported as + * unrecognised. Each entry needs a reason: a color only belongs here if snapping it + * to the nearest palette entry would be a visual change, which is a design decision + * rather than a refactor. + */ +export const KNOWN_OFF_PALETTE: {[key: string]: string} = {}; + +export const stylesheetFiles = (srcDir: string): string[] => { + const walk = (dir: string, into: string[]): string[] => { + for (const entry of fs.readdirSync(dir, {withFileTypes: true})) { + const target = path.join(dir, entry.name); + if (entry.isDirectory()) { walk(target, into); } + else if (entry.name.endsWith('.css')) { into.push(target); } + } + + return into; + }; + + return walk(srcDir, []) + // generated from the LESS in generic-styles/; styles book content we do not own + .filter((file) => file !== path.join(srcDir, 'content.css')) + // the generated token file is the one place a theme value may be written out + .filter((file) => file !== path.join(srcDir, 'app', 'theme.css')) + .sort(); +}; + +export interface ColorViolations { + /** literals that exactly duplicate a value a token already declares */ + duplicates: string[]; + /** literals that are neither a theme value nor allowlisted, including unresolvable ones */ + unknown: string[]; +} + +/** + * How a color occurrence is identified in the baseline. + * + * File and literal alone are not enough: two `#fff`s in one file would be + * interchangeable, so deleting one and writing a new one somewhere else in that file + * would leave the sorted baseline unchanged and slip a fresh hardcoded color past the + * ratchet. Naming the selector and property pins each occurrence to the declaration it + * was written in. + * + * Deliberately not a line number, which would be a stricter identity but would also + * churn the baseline every time an unrelated rule is inserted above one — and a + * baseline regenerated for an unrelated reason is exactly where a new color hides. + * What is left uncaught is a literal moving between two declarations that share a file, + * a selector and a property, which is to say the same declaration written twice. + */ +const occurrence = (file: string, found: StylesheetColor) => + `${file}: ${found.context} { ${found.property}: ${found.literal} }`; + +export const colorViolations = (srcDir: string): ColorViolations => { + const values = themeColorIndex(); + const duplicates: string[] = []; + const unknown: string[] = []; + + stylesheetFiles(srcDir).forEach((file) => { + const name = path.relative(srcDir, file); + + stylesheetColors(fs.readFileSync(file, 'utf8')).forEach((found) => { + const {rgba} = found; + + if (rgba && values[colorKey(rgba)]) { + // an exact match is a duplicate. `rgba(0, 0, 0, 0.2)` is not: it is black at + // 20% and has no token form, so it falls through to the check below and + // passes there on its opaque channels. + duplicates.push(`${occurrence(name, found)} is ${values[colorKey(rgba)]}`); + return; + } + + const recognised = rgba !== null + && (values[opaqueKey(rgba)] || KNOWN_OFF_PALETTE[colorKey(rgba)]); + + if (!recognised) { + // rgba === null lands here on purpose: hsl(), oklch() and color() cannot be + // resolved statically, so they fail rather than passing silently. + unknown.push(occurrence(name, found)); + } + }); + }); + + return {duplicates: duplicates.sort(), unknown: unknown.sort()}; +};