From 1f24d54b186c479d3a3adaf8a28fcecd68a0e333 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 15:07:28 +0000 Subject: [PATCH 1/7] CORE-2731: generate a global CSS token file from the theme Splits the pure theme data out of theme.ts into themeData.ts (no styled-components import, so a build-time generator can read it), projects it to CSS custom properties in themeCss.ts, and generates src/app/theme.css from that. Replaces the 21-token :root block that was hand-copied into index.css. Link colours and mainContentBackground were separate sources of truth; they now come from the theme, so #027EB5 and its 17 lowercase CSS copies cannot diverge. theme.spec.ts enforces it: the committed theme.css must equal the generator's output, no stylesheet may read a --color-*/--z-index-*/--padding-* token that does not exist, and no breakpoint may sit within 1em of a theme breakpoint without being one. The two colour checks are locked to a committed baseline (184 duplicated literals, 37 unrecognised) so new violations fail CI now while the sweep works through the existing ones. The audit is shared with the baseline generator so they cannot drift, and has 46 tests of its own covering hex/rgba/hsl/named-colour syntaxes, gradient stops, comments and selectors. Co-Authored-By: Claude Opus 5 --- package.json | 6 +- script/generate-theme-baseline.ts | 23 ++ script/generate-theme-css.ts | 14 + .../components/Typography/Links.constants.ts | 21 +- src/app/content/components/BookBanner.css | 4 +- src/app/content/components/BookBanner.tsx | 5 - src/app/content/components/constants.ts | 3 +- src/app/theme.baseline.json | 227 +++++++++++ src/app/theme.css | 90 +++++ src/app/theme.spec.ts | 117 ++++++ src/app/theme.ts | 147 +------ src/app/themeCss.ts | 69 ++++ src/app/themeData.ts | 164 ++++++++ src/index.css | 47 +-- src/test/cssColors.spec.ts | 218 ++++++++++ src/test/cssColors.ts | 378 ++++++++++++++++++ 16 files changed, 1335 insertions(+), 198 deletions(-) create mode 100644 script/generate-theme-baseline.ts create mode 100644 script/generate-theme-css.ts create mode 100644 src/app/theme.baseline.json create mode 100644 src/app/theme.css create mode 100644 src/app/theme.spec.ts create mode 100644 src/app/themeCss.ts create mode 100644 src/app/themeData.ts create mode 100644 src/test/cssColors.spec.ts create mode 100644 src/test/cssColors.ts 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..6e1e087227 --- /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 colour 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 colour 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/constants.ts b/src/app/content/components/constants.ts index 57e5ddbaca..68d7132d5f 100644 --- a/src/app/content/components/constants.ts +++ b/src/app/content/components/constants.ts @@ -43,7 +43,8 @@ export const searchSidebarTopOffset = bookBannerMobileMiniHeight export const contentTextWidth = 82.5; -export const mainContentBackground = '#fff'; +// the theme's white; referenced from CSS as var(--color-neutral-base) +export const mainContentBackground = theme.color.neutral.base; export const maxContentGutter = 6; export const contentWrapperMaxWidth = contentTextWidth + sidebarDesktopWidth + verticalNavbarMaxWidth; diff --git a/src/app/theme.baseline.json b/src/app/theme.baseline.json new file mode 100644 index 0000000000..6a9cab124c --- /dev/null +++ b/src/app/theme.baseline.json @@ -0,0 +1,227 @@ +{ + "duplicates": [ + "app/components/Button.css: #000 is --color-text-black", + "app/components/Button.css: #0064a0 is --color-link-hover", + "app/components/Button.css: #0064a0 is --color-link-hover", + "app/components/Button.css: #0064a0 is --color-link-hover", + "app/components/Button.css: #027eb5 is --color-link", + "app/components/Button.css: #027eb5 is --color-link", + "app/components/Button.css: #027eb5 is --color-link", + "app/components/Button.css: #424242 is --color-text-default", + "app/components/Button.css: #818181 is --color-secondary-light-gray-darkest", + "app/components/Button.css: #8b8b8b is --color-secondary-light-gray-darker", + "app/components/Button.css: #949494 is --color-secondary-light-gray-base", + "app/components/Button.css: #b03808 is --color-primary-orange-darkest", + "app/components/Button.css: #be3c08 is --color-primary-orange-darker", + "app/components/Button.css: #c1c1c1 is --color-disabled-foreground", + "app/components/Button.css: #c1c1c1 is --color-disabled-foreground", + "app/components/Button.css: #d4450c is --color-primary-orange-base", + "app/components/Button.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Button.css: #e5e5e5 is --color-neutral-darkest", + "app/components/Button.css: #f1f1f1 is --color-neutral-page-background", + "app/components/Button.css: #f1f1f1 is --color-neutral-page-background", + "app/components/Button.css: #fafafa is --color-neutral-darker", + "app/components/Button.css: #fff is --color-white", + "app/components/Button.css: #fff is --color-white", + "app/components/Button.css: #fff is --color-white", + "app/components/Button.css: #fff is --color-white", + "app/components/Checkbox.css: #424242 is --color-text-default", + "app/components/Checkbox.css: #424242 is --color-text-default", + "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", + "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", + "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", + "app/components/Checkbox.css: #f1f1f1 is --color-neutral-page-background", + "app/components/Checkbox.css: #f1f1f1 is --color-neutral-page-background", + "app/components/Checkbox.css: white is --color-white", + "app/components/DotMenu.css: #424242 is --color-text-default", + "app/components/DotMenu.css: #5e6062 is --color-primary-gray-base", + "app/components/DotMenu.css: #5e6062 is --color-primary-gray-base", + "app/components/DotMenu.css: #818181 is --color-secondary-light-gray-darkest", + "app/components/Dropdown.css: #424242 is --color-text-default", + "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Dropdown.css: #f5f5f5 is --color-neutral-form-background", + "app/components/Dropdown.css: #f5f5f5 is --color-neutral-form-background", + "app/components/Dropdown.css: #fff is --color-white", + "app/components/Footer/Footer.css: #424242 is --color-text-default", + "app/components/Footer/Footer.css: #767676 is --color-primary-yellow-foreground-hover", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", + "app/components/Footer/Footer.css: #fff is --color-white", + "app/components/GoToTopButton.css: #767676 is --color-primary-yellow-foreground-hover", + "app/components/GoToTopButton.css: white is --color-white", + "app/components/Modal/Modal.css: #0064a0 is --color-link-hover", + "app/components/Modal/Modal.css: #027eb5 is --color-link", + "app/components/Modal/Modal.css: #5e6062 is --color-primary-gray-base", + "app/components/Modal/Modal.css: #c5c5c5 is --color-primary-red-foreground-hover", + "app/components/Modal/Modal.css: #f1f1f1 is --color-neutral-page-background", + "app/components/Modal/Modal.css: #fafafa is --color-neutral-darker", + "app/components/Modal/Modal.css: white is --color-white", + "app/components/NavBar/NavBar.css: #0064a0 is --color-link-hover", + "app/components/NavBar/NavBar.css: #007297 is --color-link-focus-outline", + "app/components/NavBar/NavBar.css: #424242 is --color-text-default", + "app/components/NavBar/NavBar.css: #5e6062 is --color-primary-gray-base", + "app/components/NavBar/NavBar.css: #63a524 is --color-primary-green-base", + "app/components/NavBar/NavBar.css: #fff is --color-white", + "app/components/Typography/Headings.css: #424242 is --color-text-default", + "app/content/components/AssignedTopBar.css: #424242 is --color-text-default", + "app/content/components/AssignedTopBar.css: #f1f1f1 is --color-neutral-page-background", + "app/content/components/Attribution.css: #0064a0 is --color-link-hover", + "app/content/components/Attribution.css: #0064a0 is --color-link-hover", + "app/content/components/Attribution.css: #027eb5 is --color-link", + "app/content/components/Attribution.css: #027eb5 is --color-link", + "app/content/components/Attribution.css: #424242 is --color-text-default", + "app/content/components/BuyBook.css: #d4450c is --color-primary-orange-base", + "app/content/components/Content.css: #fafafa is --color-neutral-darker", + "app/content/components/ContentExcerpt.css: #0064a0 is --color-link-hover", + "app/content/components/ContentExcerpt.css: #027eb5 is --color-link", + "app/content/components/ContentExcerpt.css: #424242 is --color-text-default", + "app/content/components/ContentLink.css: #0064a0 is --color-link-hover", + "app/content/components/ContentLink.css: #027eb5 is --color-link", + "app/content/components/ContentPane.css: #fff is --color-white", + "app/content/components/Page/MediaModal.css: white is --color-white", + "app/content/components/Page/PageNotFound.css: #6f6f6f is --color-text-label", + "app/content/components/PrevNextBar.css: #0064a0 is --color-link-hover", + "app/content/components/PrevNextBar.css: #027eb5 is --color-link", + "app/content/components/PrevNextBar.css: #424242 is --color-text-default", + "app/content/components/PrevNextBar.css: #e5e5e5 is --color-neutral-darkest", + "app/content/components/PrevNextBar.css: #e5e5e5 is --color-neutral-darkest", + "app/content/components/SectionHighlights.css: #424242 is --color-text-default", + "app/content/components/SectionHighlights.css: #424242 is --color-text-default", + "app/content/components/SectionHighlights.css: #e5e5e5 is --color-neutral-darkest", + "app/content/components/SectionHighlights.css: #e5e5e5 is --color-neutral-darkest", + "app/content/components/SectionHighlights.css: white is --color-white", + "app/content/components/SectionHighlights.css: white is --color-white", + "app/content/components/Toolbar/styled.css: #424242 is --color-text-default", + "app/content/components/Toolbar/styled.css: #5e6062 is --color-primary-gray-base", + "app/content/components/Topbar/Topbar.css: #0064a0 is --color-link-hover", + "app/content/components/Topbar/Topbar.css: #0064a0 is --color-link-hover", + "app/content/components/Topbar/Topbar.css: #027eb5 is --color-link", + "app/content/components/Topbar/Topbar.css: #fff is --color-white", + "app/content/components/Topbar/Topbar.css: #fff is --color-white", + "app/content/components/Topbar/Topbar.css: white is --color-white", + "app/content/components/Topbar/Topbar.css: white is --color-white", + "app/content/components/popUp/FiltersList.css: #424242 is --color-text-default", + "app/content/components/popUp/FiltersList.css: #5e6062 is --color-primary-gray-base", + "app/content/components/popUp/FiltersList.css: #5e6062 is --color-primary-gray-base", + "app/content/highlights/components/DisplayNote.css: #000 is --color-text-black", + "app/content/highlights/components/DisplayNote.css: #424242 is --color-text-default", + "app/content/highlights/components/DisplayNote.css: #424242 is --color-text-default", + "app/content/highlights/components/DisplayNote.css: #f1f1f1 is --color-neutral-page-background", + "app/content/highlights/components/DisplayNote.css: #fff is --color-white", + "app/content/highlights/components/HighlightStyles.css: #0064a0 is --color-link-hover", + "app/content/highlights/components/HighlightStyles.css: #027eb5 is --color-link", + "app/content/highlights/components/HighlightStyles.css: rgb(13, 192, 220) is --color-secondary-light-blue-base", + "app/content/highlights/components/HighlightStyles.css: rgb(13, 192, 220) is --color-secondary-light-blue-base", + "app/content/highlights/components/HighlightStyles.css: rgb(99, 165, 36) is --color-primary-green-base", + "app/content/highlights/components/HighlightStyles.css: rgb(99, 165, 36) is --color-primary-green-base", + "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", + "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", + "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", + "app/content/highlights/components/Note.css: #027eb5 is --color-link", + "app/content/highlights/components/Note.css: #6f6f6f is --color-text-label", + "app/content/highlights/components/Note.css: #d5d5d5 is --color-neutral-form-border", + "app/content/highlights/components/ShowMyHighlightsStyles.css: white is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightDeleteWrapper.css: #fff is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #424242 is --color-text-default", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #fafafa is --color-neutral-darker", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #fff is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: white is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightListElement.css: white is --color-white", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #0064a0 is --color-link-hover", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #027eb5 is --color-link", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #424242 is --color-text-default", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #818181 is --color-secondary-light-gray-darkest", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #d5d5d5 is --color-neutral-form-border", + "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #f5f5f5 is --color-neutral-form-background", + "app/content/highlights/components/TruncatedText.css: #0064a0 is --color-link-hover", + "app/content/highlights/components/TruncatedText.css: #027eb5 is --color-link", + "app/content/highlights/components/TruncatedText.css: #424242 is --color-text-default", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #424242 is --color-text-default", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #d5d5d5 is --color-neutral-form-border", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #e5e5e5 is --color-neutral-darkest", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fafafa is --color-neutral-darker", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fafafa is --color-neutral-darker", + "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fff is --color-white", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #424242 is --color-text-default", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #5e6062 is --color-primary-gray-base", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", + "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fff is --color-white", + "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: #fff is --color-white", + "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: #fff is --color-white", + "app/content/studyGuides/components/ShowStudyGuides.css: white is --color-white", + "app/content/studyGuides/components/StudyGuidesCTA/StudyGuidesCTA.css: #027eb5 is --color-link", + "app/content/studyGuides/components/StudyGuidesListElement.css: white is --color-white", + "app/content/studyGuides/components/StudyGuidesListElement.css: white is --color-white", + "app/content/styles/PopupStyles.css: #fff is --color-white", + "app/content/styles/PopupStyles.css: #fff is --color-white", + "app/content/styles/PopupStyles.css: #fff is --color-white", + "app/developer/components/Home.css: #424242 is --color-text-default", + "app/errors/components/ErrorBoundary.css: #0064a0 is --color-link-hover", + "app/errors/components/ErrorBoundary.css: #027eb5 is --color-link", + "app/errors/components/ErrorBoundary.css: #424242 is --color-text-default", + "app/errors/components/ErrorIdList.css: #424242 is --color-text-default", + "app/notifications/components/Card.css: #0064a0 is --color-link-hover", + "app/notifications/components/Card.css: #027eb5 is --color-link", + "app/notifications/components/Card.css: #424242 is --color-text-default", + "app/notifications/components/Card.css: #424242 is --color-text-default", + "app/notifications/components/Card.css: #e5e5e5 is --color-neutral-darkest", + "app/notifications/components/Card.css: #fafafa is --color-neutral-darker", + "app/notifications/components/Card.css: #fafafa is --color-neutral-darker", + "app/notifications/components/Card.css: #fff is --color-white", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #c22032 is --color-secondary-red-base", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #fdbd3e is --color-secondary-gold-base", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #fdbd3e is --color-secondary-gold-base" + ], + "unknown": [ + "app/components/Footer/Footer.css: #3b3b3b", + "app/components/NavBar/NavBar.css: #5f6163", + "app/components/NavBar/NavBar.css: #5f6163", + "app/components/NavBar/NavBar.css: #5f6163", + "app/content/components/LabsCall.css: #6922ea", + "app/content/components/LabsCall.css: #cacaca", + "app/content/components/Page/PageContent.css: #006880", + "app/content/components/Page/PageContent.css: #00c3ed", + "app/content/components/Page/PageContent.css: #141a3e", + "app/content/components/Page/PageContent.css: #4e6f01", + "app/content/components/Page/PageContent.css: #545ec8", + "app/content/components/Page/PageContent.css: #560131", + "app/content/components/Page/PageContent.css: #8f7700", + "app/content/components/Page/PageContent.css: #92d101", + "app/content/components/Page/PageContent.css: #c8f5ff", + "app/content/components/Page/PageContent.css: #cbcfff", + "app/content/components/Page/PageContent.css: #de017e", + "app/content/components/Page/PageContent.css: #def99f", + "app/content/components/Page/PageContent.css: #fed200", + "app/content/components/Page/PageContent.css: #ff9e4b", + "app/content/components/Page/PageContent.css: #ffc5e1", + "app/content/components/Page/PageContent.css: #ffea00", + "app/content/components/Page/PageContent.css: #ffff8a", + "app/content/components/Topbar/Topbar.css: #efeff1", + "app/content/highlights/components/DisplayNote.css: #c2c2c2", + "app/content/highlights/components/ShowMyHighlightsStyles.css: #dedede", + "app/content/styles/PopupStyles.css: #405c7d", + "app/content/styles/PopupStyles.css: #e8e8e8", + "app/developer/components/Books.css: #ccc", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #976502", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #976502", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #c23834", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #c23834", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #e297a0", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #e297a0", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #f8e8eb", + "app/notifications/components/ToastNotifications/ToastNotifications.css: #fff5e0" + ] +} diff --git a/src/app/theme.css b/src/app/theme.css new file mode 100644 index 0000000000..39c5a47bb3 --- /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: #888888; + --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: #888888; + --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: #888888; + --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..c9b42be2b7 --- /dev/null +++ b/src/app/theme.spec.ts @@ -0,0 +1,117 @@ +/** + * 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 colour 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 colour fails CI today, and + * the list can only shrink. After removing some, run `yarn generate:theme-baseline` + * and check the counts went down. + */ +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 colour 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 colour beyond the baseline', () => { + expect(colorViolations(srcDir).duplicates).toEqual(baseline().duplicates); + }); + + it('do not introduce an unrecognised colour 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 = stylesheetFiles(srcDir).reduce((result: string[], file) => { + const read = stripNoise(fs.readFileSync(file, 'utf8')).match(/var\(\s*(--[\w-]+)/g) || []; + const offenders = read + .map((match) => match.replace(/var\(\s*/, '')) + .filter((name) => globalFamilies.test(name) && !declared.has(name)) + .map((name) => `${relative(file)}: ${name}`); + + return [...result, ...offenders]; + }, []); + + 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. + 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 = stylesheetFiles(srcDir).reduce((result: string[], file) => { + const queries = stripNoise(fs.readFileSync(file, 'utf8')) + .match(/\((?:min|max)-width:\s*[\d.]+em\)/g) || []; + const offenders = 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)) + .map(({query}) => `${relative(file)}: ${query}`); + + return [...result, ...offenders]; + }, []); + + expect(suspicious).toEqual([]); + }); +}); diff --git a/src/app/theme.ts b/src/app/theme.ts index 2d0a7913ca..1a8d23aca0 100644 --- a/src/app/theme.ts +++ b/src/app/theme.ts @@ -1,13 +1,17 @@ import { FlattenSimpleInterpolation } from 'styled-components'; import { css } from 'styled-components/macro'; // 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. @@ -43,13 +47,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 = ` @@ -57,109 +54,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)`; @@ -191,24 +85,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..ef9a11a34e --- /dev/null +++ b/src/app/themeCss.ts @@ -0,0 +1,69 @@ +/** + * 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 lowercased on the way out. The JS theme mixes cases (`#027EB5` + * next to `#d4450c`) and CSS is case-insensitive here, so normalising means a + * token's value has exactly one spelling and stylelint's color-hex-case is + * satisfied without having to touch the published JS values. + */ +const normalizeValue = (value: string) => + /^#[0-9a-fA-F]{3,8}$/.test(value) ? value.toLowerCase() : value; + +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..7a855db200 --- /dev/null +++ b/src/app/themeData.ts @@ -0,0 +1,164 @@ +/** + * 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. + * + * Every value here is projected into a CSS custom property in `theme.css` by + * `themeCss.ts`. Do not hand-copy a value from here into a stylesheet — reference + * its token instead. `theme.spec.ts` fails the build if you do. + */ + +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', +}; + +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..19576bcd74 100644 --- a/src/index.css +++ b/src/index.css @@ -8,6 +8,9 @@ @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. See README "Styling". */ +@import "./app/theme.css"; + body, #root, html { @@ -209,47 +212,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..a0ce031919 --- /dev/null +++ b/src/test/cssColors.spec.ts @@ -0,0 +1,218 @@ +import { + colorKey, + declarationValues, + describeColor, + findColors, + opaqueKey, + stripNoise, + stylesheetColors, +} from './cssColors'; + +const literals = (css: string) => stylesheetColors(css).map((found) => found.literal); + +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 colour', () => { + expect(stripNoise('a { content: "tan"; }')).toContain('content: ""'); + }); + + it('removes url() payloads', () => { + expect(stripNoise('a { background: url(data:image/svg+xml;base64,Zm9v) no-repeat; }')) + .toContain('url() no-repeat'); + }); + + it('handles an escaped quote inside a string', () => { + expect(stripNoise('a { content: "a\\"b"; }')).toContain('content: ""'); + }); + + it('tolerates an unterminated comment', () => { + expect(stripNoise('a { color: red; /* oops')).toContain('color: red;'); + }); +}); + +describe('declarationValues', () => { + it('reads declarations at the top level of a rule', () => { + expect(declarationValues('a { color: red; background: blue; }')) + .toEqual(['red', 'blue']); + }); + + it('reads declarations nested in @media', () => { + expect(declarationValues('@media (max-width: 50em) { a { color: red; } }')) + .toEqual(['red']); + }); + + it('does not mistake a pseudo-class selector for a declaration', () => { + expect(declarationValues('a:hover { color: red; }')).toEqual(['red']); + }); + + it('does not mistake @keyframes percentages for declarations', () => { + expect(declarationValues('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) + .toEqual(['0', '1']); + }); + + it('ignores at-rules outside a block, such as @import', () => { + expect(declarationValues('@import "./theme.css";')).toEqual([]); + }); + + it('reads a declaration with no trailing semicolon', () => { + expect(declarationValues('a { color: red }')).toEqual(['red']); + }); + + it('does not split on a semicolon inside parentheses', () => { + const values = declarationValues('a { background: url(x;y); color: red; }'); + expect(values).toContain('red'); + }); + + it('keeps a custom property declaration', () => { + expect(declarationValues(':root { --color-x: #fff; }')).toEqual(['#fff']); + }); +}); + +describe('findColors', () => { + it('finds a hex literal', () => { + expect(literals('a { color: #ff0000; }')).toEqual(['#ff0000']); + }); + + it('finds a bare named colour in a shorthand', () => { + expect(literals('a { border: 0.1rem solid red; }')).toEqual(['red']); + }); + + it('finds colours 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 colour', () => { + expect(literals('.red { opacity: 1; }')).toEqual([]); + }); + + it('does not treat content: "tan" as a colour', () => { + expect(literals('a { content: "tan"; }')).toEqual([]); + }); + + it('does not treat a colour inside a comment as a colour', () => { + expect(literals('a { /* was #ff0000 */ color: var(--x); }')).toEqual([]); + }); + + it('does not treat transparent or currentcolor as comparable colours', () => { + expect(literals('a { color: currentcolor; background: transparent; }')).toEqual([]); + }); + + it('does not treat a non-colour keyword as a colour', () => { + expect(literals('a { transition: all 0.2s linear; }')).toEqual([]); + }); + + it('finds several colours 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(); + }); +}); + +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 colour', () => { + 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('notacolour')).toBeNull(); + }); + + it('returns null for a malformed hex length', () => { + expect(describeColor('#12345')).toBeNull(); + }); +}); + +describe('colour keys', () => { + it('treats an opaque colour as equal however it is written', () => { + expect(colorKey(describeColor('#fff')!)).toEqual(colorKey(describeColor('white')!)); + }); + + it('distinguishes a translucent colour from its opaque form', () => { + expect(colorKey(describeColor('rgba(0, 0, 0, 0.2)')!)) + .not.toEqual(colorKey(describeColor('#000')!)); + }); + + it('recognises a translucent colour 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..bce1a671d7 --- /dev/null +++ b/src/test/cssColors.ts @@ -0,0 +1,378 @@ +/** + * Colour 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 colours in shorthands and colours 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; +} + +/** 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', +}; + +/** + * Colour functions are terminal — we try to resolve them and flag them. + * Anything else that happens to *contain* a colour (`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 colour-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']; + +/** Removes comments, string contents and url() payloads, preserving structure. */ +export const stripNoise = (css: string): string => { + let out = ''; + let index = 0; + + while (index < css.length) { + const rest = css.slice(index); + + if (rest.startsWith('/*')) { + const end = css.indexOf('*/', index + 2); + index = end === -1 ? css.length : end + 2; + out += ' '; + continue; + } + + const quote = css[index]; + if (quote === '"' || quote === '\'') { + let cursor = index + 1; + while (cursor < css.length && css[cursor] !== quote) { + cursor += css[cursor] === '\\' ? 2 : 1; + } + index = cursor + 1; + out += '""'; + continue; + } + + const url = /^url\(/i.exec(rest); + if (url) { + let depth = 1; + let cursor = index + url[0].length; + while (cursor < css.length && depth > 0) { + if (css[cursor] === '(') { depth++; } + if (css[cursor] === ')') { depth--; } + cursor++; + } + index = cursor; + out += 'url()'; + continue; + } + + out += css[index]; + index++; + } + + return out; +}; + +/** + * Pulls declaration values out of a stylesheet at any nesting depth, so `@media` + * blocks are covered. Selectors and at-rule preludes are discarded (they end at a + * `{`), which is what keeps `a:hover` and `@keyframes` percentages from being read + * as declarations. + */ +export const declarationValues = (css: string): string[] => { + const values: string[] = []; + const stripped = stripNoise(css); + let buffer = ''; + let depth = 0; + let parens = 0; + + const flush = () => { + const separator = buffer.indexOf(':'); + if (depth > 0 && separator !== -1) { + const value = buffer.slice(separator + 1).trim(); + if (value) { values.push(value); } + } + buffer = ''; + }; + + for (const character of stripped) { + if (character === '(') { parens++; } + if (character === ')') { parens = Math.max(0, parens - 1); } + + if (parens === 0 && character === '{') { buffer = ''; depth++; continue; } + if (parens === 0 && character === '}') { flush(); depth = Math.max(0, depth - 1); continue; } + if (parens === 0 && character === ';') { flush(); continue; } + + buffer += character; + } + + return values; +}; + +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); + if (percent) { return Math.round(clamp(parseFloat(percent[1]), 100) * 2.55); } + 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; +}; + +const fromHex = (literal: string): Rgba | 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; + + if (full.length !== 6 && full.length !== 8) { return null; } + + 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 colour 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 colour literal in a declaration value, at any depth. Functions that + * merely contain colours are descended into; colour functions are terminal. + */ +export const findColors = (value: string): 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)); + } + + 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_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { + found.push({literal: word[0], rgba: describeColor(word[0])}); + } + index += word[0].length; + continue; + } + + index++; + } + + return found; +}; + +/** Every colour literal written in a stylesheet, in source order. */ +export const stylesheetColors = (css: string): FoundColor[] => + declarationValues(css).reduce( + (result: FoundColor[], value) => [...result, ...findColors(value)], + [] + ); + +/** Canonical key for comparing two colours. Opaque colours 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 colour key to the token that declares it. */ +export const themeColorIndex = (): {[key: string]: string} => themeTokens() + .reduce((result: {[key: string]: string}, [name, value]) => { + const rgba = describeColor(value); + return rgba === null ? result : {...result, [colorKey(rgba)]: `--${name}`}; + }, {}); + +/** + * Colours that are deliberately not theme values, so they are never reported as + * unrecognised. Each entry needs a reason: a colour 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): string[] => fs.readdirSync(dir, {withFileTypes: true}) + .reduce((result: string[], entry) => { + const target = path.join(dir, entry.name); + if (entry.isDirectory()) { return [...result, ...walk(target)]; } + return entry.name.endsWith('.css') ? [...result, target] : result; + }, []); + + 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[]; +} + +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(({literal, rgba}) => { + 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(`${name}: ${literal} 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(`${name}: ${literal}`); + } + }); + }); + + return {duplicates: duplicates.sort(), unknown: unknown.sort()}; +}; From a29abe4a64188d26530deb7ef1f4713290d4195b Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 15:15:01 +0000 Subject: [PATCH 2/7] CORE-2731: document the token pattern, fix lint and coverage - Generator shortens hex to the 3-digit form where equivalent, so theme.css satisfies stylelint's color-hex-length as well as color-hex-case. - flatten()'s default prefix was an unreachable branch; jest enforces 100% branch coverage on src/app. - BookBanner snapshots: the two static z-index bindings are gone. The diff is style-attribute-only, and the removed values (69, 68) are what the new calc(var(--z-index-navbar) - 1 / - 2) resolves to. - PLAIN_CSS_MIGRATION_GUIDE.md: Pattern 2.5 described the index.css :root block as hand-copied and needing manual syncing, which is what this ticket removes. Rewritten around the generated file, plus what CI now enforces, the baseline ratchet and the breakpoint gap. Co-Authored-By: Claude Opus 5 --- PLAIN_CSS_MIGRATION_GUIDE.md | 132 +++++++++++------- .../__snapshots__/BookBanner.spec.tsx.snap | 20 --- src/app/theme.css | 6 +- src/app/themeCss.ts | 24 +++- src/test/cssColors.spec.ts | 1 - 5 files changed, 103 insertions(+), 80 deletions(-) diff --git a/PLAIN_CSS_MIGRATION_GUIDE.md b/PLAIN_CSS_MIGRATION_GUIDE.md index 23c0a49cea..f904523535 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:** -- 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 +**When to use a global token:** +- Any static colour, 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 colours requiring dynamic property access (`theme.color.primary[bookTheme]`) +- Colours with runtime computations (highlight colours 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 colour import theme from '../theme'; export function Card({ className, style, ...props }) { @@ -244,7 +246,7 @@ export function Card({ className, style, ...props }) {
@@ -253,43 +255,58 @@ 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. -Root-level CSS variables follow the pattern: `--color-{category}-{property}` +**Naming convention:** -- `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` +A token name is the kebab-case form of its path in `themeData.ts`: -Note: camelCase properties in theme.ts become kebab-case in CSS variable names. +- `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`) -**Available Root-Level Variables:** +The one exception is `--color-link`, rather than `--color-link-base`. -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. +See `src/app/theme.css` for the full list of 80 tokens. + +**What CI enforces** (`src/app/theme.spec.ts`): + +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 colour literal that duplicates a theme value. +3. No stylesheet introduces a colour 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`. + +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. + +**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 +565,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 +664,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 colours 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/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/theme.css b/src/app/theme.css index 39c5a47bb3..1b5933dd50 100644 --- a/src/app/theme.css +++ b/src/app/theme.css @@ -22,7 +22,7 @@ --color-neutral-page-background: #f1f1f1; --color-primary-blue-base: #002468; --color-primary-blue-foreground: #fff; - --color-primary-blue-foreground-hover: #888888; + --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; @@ -31,7 +31,7 @@ --color-primary-gray-foreground: #fff; --color-primary-gray-foreground-hover: #424242; --color-primary-gray-light: #767676; - --color-primary-gray-medium: #888888; + --color-primary-gray-medium: #888; --color-primary-gray-lighter: #c5c5c5; --color-primary-gray-lightest: #ededed; --color-primary-green-base: #63a524; @@ -42,7 +42,7 @@ --color-primary-light-blue-foreground-hover: #424242; --color-primary-midnight-base: #003e52; --color-primary-midnight-foreground: #fff; - --color-primary-midnight-foreground-hover: #888888; + --color-primary-midnight-foreground-hover: #888; --color-primary-orange-base: #d4450c; --color-primary-orange-darker: #be3c08; --color-primary-orange-darkest: #b03808; diff --git a/src/app/themeCss.ts b/src/app/themeCss.ts index ef9a11a34e..a804d71e13 100644 --- a/src/app/themeCss.ts +++ b/src/app/themeCss.ts @@ -19,13 +19,23 @@ const GENERATED_HEADER = [ ].join('\n'); /** - * Hex values are lowercased on the way out. The JS theme mixes cases (`#027EB5` - * next to `#d4450c`) and CSS is case-insensitive here, so normalising means a - * token's value has exactly one spelling and stylelint's color-hex-case is - * satisfied without having to touch the published JS values. + * 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) => - /^#[0-9a-fA-F]{3,8}$/.test(value) ? value.toLowerCase() : value; +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') @@ -36,7 +46,7 @@ const kebabCase = (value: string) => value * `{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]> => +const flatten = (source: object, prefix: string[]): Array<[string, string]> => Object.entries(source).reduce((result: Array<[string, string]>, [key, value]) => { const path = [...prefix, kebabCase(key)]; diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts index a0ce031919..97767b9672 100644 --- a/src/test/cssColors.spec.ts +++ b/src/test/cssColors.spec.ts @@ -2,7 +2,6 @@ import { colorKey, declarationValues, describeColor, - findColors, opaqueKey, stripNoise, stylesheetColors, From 6bfa13975a4067c83541b60bfd9e2febc3e6dede Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 15:52:24 +0000 Subject: [PATCH 3/7] CORE-2731: address review on the colour audit and the baseline ratchet Copilot's review found four real defects in the audit and two documentation claims that were not true. All six are fixed here; no rendered output changes. cssColors.ts: - Percentage channels scaled by the decimal 2.55, so `rgb(50%, 50%, 50%)` keyed as 127,127,127 while `#808080` keyed as 128,128,128 and the two spellings of one grey could never match. Now `(value / 100) * 255`. - fromHex validated the expanded length and not the digits, so `#ggg` came back as an Rgba of NaNs rather than null -- and a malformed *theme* value would then have passed the "every colour token resolves" spec while generating invalid CSS. The hex grammar is checked before parsing. - The declaration walk discarded the property name, so any identifier that happens to be a named colour was read as one: `animation-name: red` and `font-family: black` were palette violations. It now returns {context, property, value}, and a bare identifier is only a colour where the property can hold one. Hex and the colour functions are unambiguous and stay in scope everywhere. - Baseline entries were file + literal, so removing one `#fff` and adding another elsewhere in the same file left the sorted array unchanged and the ratchet passed. An occurrence is now identified by the declaration it was written in -- selectors, at-rules and property. Declaration context rather than line/column on purpose: a line number churns the baseline whenever an unrelated rule is inserted above one, and a baseline regenerated for an unrelated reason is where a new colour would hide. theme.spec.ts: the breakpoint proximity bound was `< 1`, so 74em -- the example in its own comment and in the migration guide -- was exactly on the boundary and not caught. Now `<= 1`; no width currently in use lands there. themeData.ts said every value is projected into a token, but the breakpoints deliberately are not, since a media query cannot read a custom property. index.css pointed at a README "Styling" section that does not exist; it points at PLAIN_CSS_MIGRATION_GUIDE.md Pattern 2.5 now. The baseline is regenerated for the new entry format. The counts are unchanged at 184 duplicates and 37 unrecognised, including all 14 bare `white` keywords, so the named-colour narrowing lost no real coverage. cssColors.spec.ts grows from 46 tests to 80, covering each of the above in both directions. Co-Authored-By: Claude Opus 5 (1M context) --- PLAIN_CSS_MIGRATION_GUIDE.md | 14 +- src/app/theme.baseline.json | 442 +++++++++++++++++------------------ src/app/theme.spec.ts | 8 +- src/app/themeData.ts | 13 +- src/index.css | 5 +- src/test/cssColors.spec.ts | 126 ++++++++-- src/test/cssColors.ts | 145 ++++++++++-- 7 files changed, 488 insertions(+), 265 deletions(-) diff --git a/PLAIN_CSS_MIGRATION_GUIDE.md b/PLAIN_CSS_MIGRATION_GUIDE.md index f904523535..40dcb6bfbc 100644 --- a/PLAIN_CSS_MIGRATION_GUIDE.md +++ b/PLAIN_CSS_MIGRATION_GUIDE.md @@ -297,13 +297,25 @@ See `src/app/theme.css` for the full list of 80 tokens. 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`. + which catches a `75em` mistyped as `74em`. The bound is inclusive, so `74em` and + `76em` are both caught. 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. +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. + +Hex literals and `rgb()`/`hsl()`/`oklch()` are audited in every declaration. Bare named +colours (`white`, `tan`) are only read as colours in properties that can take one, so +`animation-name: red` is left alone. `src/test/cssColors.ts` has the list. + **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. diff --git a/src/app/theme.baseline.json b/src/app/theme.baseline.json index 6a9cab124c..f6fd4066ec 100644 --- a/src/app/theme.baseline.json +++ b/src/app/theme.baseline.json @@ -1,227 +1,227 @@ { "duplicates": [ - "app/components/Button.css: #000 is --color-text-black", - "app/components/Button.css: #0064a0 is --color-link-hover", - "app/components/Button.css: #0064a0 is --color-link-hover", - "app/components/Button.css: #0064a0 is --color-link-hover", - "app/components/Button.css: #027eb5 is --color-link", - "app/components/Button.css: #027eb5 is --color-link", - "app/components/Button.css: #027eb5 is --color-link", - "app/components/Button.css: #424242 is --color-text-default", - "app/components/Button.css: #818181 is --color-secondary-light-gray-darkest", - "app/components/Button.css: #8b8b8b is --color-secondary-light-gray-darker", - "app/components/Button.css: #949494 is --color-secondary-light-gray-base", - "app/components/Button.css: #b03808 is --color-primary-orange-darkest", - "app/components/Button.css: #be3c08 is --color-primary-orange-darker", - "app/components/Button.css: #c1c1c1 is --color-disabled-foreground", - "app/components/Button.css: #c1c1c1 is --color-disabled-foreground", - "app/components/Button.css: #d4450c is --color-primary-orange-base", - "app/components/Button.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Button.css: #e5e5e5 is --color-neutral-darkest", - "app/components/Button.css: #f1f1f1 is --color-neutral-page-background", - "app/components/Button.css: #f1f1f1 is --color-neutral-page-background", - "app/components/Button.css: #fafafa is --color-neutral-darker", - "app/components/Button.css: #fff is --color-white", - "app/components/Button.css: #fff is --color-white", - "app/components/Button.css: #fff is --color-white", - "app/components/Button.css: #fff is --color-white", - "app/components/Checkbox.css: #424242 is --color-text-default", - "app/components/Checkbox.css: #424242 is --color-text-default", - "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", - "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", - "app/components/Checkbox.css: #b03808 is --color-primary-orange-darkest", - "app/components/Checkbox.css: #f1f1f1 is --color-neutral-page-background", - "app/components/Checkbox.css: #f1f1f1 is --color-neutral-page-background", - "app/components/Checkbox.css: white is --color-white", - "app/components/DotMenu.css: #424242 is --color-text-default", - "app/components/DotMenu.css: #5e6062 is --color-primary-gray-base", - "app/components/DotMenu.css: #5e6062 is --color-primary-gray-base", - "app/components/DotMenu.css: #818181 is --color-secondary-light-gray-darkest", - "app/components/Dropdown.css: #424242 is --color-text-default", - "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Dropdown.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Dropdown.css: #f5f5f5 is --color-neutral-form-background", - "app/components/Dropdown.css: #f5f5f5 is --color-neutral-form-background", - "app/components/Dropdown.css: #fff is --color-white", - "app/components/Footer/Footer.css: #424242 is --color-text-default", - "app/components/Footer/Footer.css: #767676 is --color-primary-yellow-foreground-hover", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #d5d5d5 is --color-neutral-form-border", - "app/components/Footer/Footer.css: #fff is --color-white", - "app/components/GoToTopButton.css: #767676 is --color-primary-yellow-foreground-hover", - "app/components/GoToTopButton.css: white is --color-white", - "app/components/Modal/Modal.css: #0064a0 is --color-link-hover", - "app/components/Modal/Modal.css: #027eb5 is --color-link", - "app/components/Modal/Modal.css: #5e6062 is --color-primary-gray-base", - "app/components/Modal/Modal.css: #c5c5c5 is --color-primary-red-foreground-hover", - "app/components/Modal/Modal.css: #f1f1f1 is --color-neutral-page-background", - "app/components/Modal/Modal.css: #fafafa is --color-neutral-darker", - "app/components/Modal/Modal.css: white is --color-white", - "app/components/NavBar/NavBar.css: #0064a0 is --color-link-hover", - "app/components/NavBar/NavBar.css: #007297 is --color-link-focus-outline", - "app/components/NavBar/NavBar.css: #424242 is --color-text-default", - "app/components/NavBar/NavBar.css: #5e6062 is --color-primary-gray-base", - "app/components/NavBar/NavBar.css: #63a524 is --color-primary-green-base", - "app/components/NavBar/NavBar.css: #fff is --color-white", - "app/components/Typography/Headings.css: #424242 is --color-text-default", - "app/content/components/AssignedTopBar.css: #424242 is --color-text-default", - "app/content/components/AssignedTopBar.css: #f1f1f1 is --color-neutral-page-background", - "app/content/components/Attribution.css: #0064a0 is --color-link-hover", - "app/content/components/Attribution.css: #0064a0 is --color-link-hover", - "app/content/components/Attribution.css: #027eb5 is --color-link", - "app/content/components/Attribution.css: #027eb5 is --color-link", - "app/content/components/Attribution.css: #424242 is --color-text-default", - "app/content/components/BuyBook.css: #d4450c is --color-primary-orange-base", - "app/content/components/Content.css: #fafafa is --color-neutral-darker", - "app/content/components/ContentExcerpt.css: #0064a0 is --color-link-hover", - "app/content/components/ContentExcerpt.css: #027eb5 is --color-link", - "app/content/components/ContentExcerpt.css: #424242 is --color-text-default", - "app/content/components/ContentLink.css: #0064a0 is --color-link-hover", - "app/content/components/ContentLink.css: #027eb5 is --color-link", - "app/content/components/ContentPane.css: #fff is --color-white", - "app/content/components/Page/MediaModal.css: white is --color-white", - "app/content/components/Page/PageNotFound.css: #6f6f6f is --color-text-label", - "app/content/components/PrevNextBar.css: #0064a0 is --color-link-hover", - "app/content/components/PrevNextBar.css: #027eb5 is --color-link", - "app/content/components/PrevNextBar.css: #424242 is --color-text-default", - "app/content/components/PrevNextBar.css: #e5e5e5 is --color-neutral-darkest", - "app/content/components/PrevNextBar.css: #e5e5e5 is --color-neutral-darkest", - "app/content/components/SectionHighlights.css: #424242 is --color-text-default", - "app/content/components/SectionHighlights.css: #424242 is --color-text-default", - "app/content/components/SectionHighlights.css: #e5e5e5 is --color-neutral-darkest", - "app/content/components/SectionHighlights.css: #e5e5e5 is --color-neutral-darkest", - "app/content/components/SectionHighlights.css: white is --color-white", - "app/content/components/SectionHighlights.css: white is --color-white", - "app/content/components/Toolbar/styled.css: #424242 is --color-text-default", - "app/content/components/Toolbar/styled.css: #5e6062 is --color-primary-gray-base", - "app/content/components/Topbar/Topbar.css: #0064a0 is --color-link-hover", - "app/content/components/Topbar/Topbar.css: #0064a0 is --color-link-hover", - "app/content/components/Topbar/Topbar.css: #027eb5 is --color-link", - "app/content/components/Topbar/Topbar.css: #fff is --color-white", - "app/content/components/Topbar/Topbar.css: #fff is --color-white", - "app/content/components/Topbar/Topbar.css: white is --color-white", - "app/content/components/Topbar/Topbar.css: white is --color-white", - "app/content/components/popUp/FiltersList.css: #424242 is --color-text-default", - "app/content/components/popUp/FiltersList.css: #5e6062 is --color-primary-gray-base", - "app/content/components/popUp/FiltersList.css: #5e6062 is --color-primary-gray-base", - "app/content/highlights/components/DisplayNote.css: #000 is --color-text-black", - "app/content/highlights/components/DisplayNote.css: #424242 is --color-text-default", - "app/content/highlights/components/DisplayNote.css: #424242 is --color-text-default", - "app/content/highlights/components/DisplayNote.css: #f1f1f1 is --color-neutral-page-background", - "app/content/highlights/components/DisplayNote.css: #fff is --color-white", - "app/content/highlights/components/HighlightStyles.css: #0064a0 is --color-link-hover", - "app/content/highlights/components/HighlightStyles.css: #027eb5 is --color-link", - "app/content/highlights/components/HighlightStyles.css: rgb(13, 192, 220) is --color-secondary-light-blue-base", - "app/content/highlights/components/HighlightStyles.css: rgb(13, 192, 220) is --color-secondary-light-blue-base", - "app/content/highlights/components/HighlightStyles.css: rgb(99, 165, 36) is --color-primary-green-base", - "app/content/highlights/components/HighlightStyles.css: rgb(99, 165, 36) is --color-primary-green-base", - "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", - "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", - "app/content/highlights/components/HighlightStyles.css: rgba(0, 0, 0, 1) is --color-text-black", - "app/content/highlights/components/Note.css: #027eb5 is --color-link", - "app/content/highlights/components/Note.css: #6f6f6f is --color-text-label", - "app/content/highlights/components/Note.css: #d5d5d5 is --color-neutral-form-border", - "app/content/highlights/components/ShowMyHighlightsStyles.css: white is --color-white", - "app/content/highlights/components/SummaryPopup/HighlightDeleteWrapper.css: #fff is --color-white", - "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #424242 is --color-text-default", - "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #fafafa is --color-neutral-darker", - "app/content/highlights/components/SummaryPopup/HighlightListElement.css: #fff is --color-white", - "app/content/highlights/components/SummaryPopup/HighlightListElement.css: white is --color-white", - "app/content/highlights/components/SummaryPopup/HighlightListElement.css: white is --color-white", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #0064a0 is --color-link-hover", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #027eb5 is --color-link", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #424242 is --color-text-default", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #818181 is --color-secondary-light-gray-darkest", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #d5d5d5 is --color-neutral-form-border", - "app/content/highlights/components/SummaryPopup/HighlightsHelpInfo.css: #f5f5f5 is --color-neutral-form-background", - "app/content/highlights/components/TruncatedText.css: #0064a0 is --color-link-hover", - "app/content/highlights/components/TruncatedText.css: #027eb5 is --color-link", - "app/content/highlights/components/TruncatedText.css: #424242 is --color-text-default", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #424242 is --color-text-default", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #d5d5d5 is --color-neutral-form-border", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #e5e5e5 is --color-neutral-darkest", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fafafa is --color-neutral-darker", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fafafa is --color-neutral-darker", - "app/content/keyboardShortcuts/components/ShowKeyboardShortcuts.css: #fff is --color-white", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #424242 is --color-text-default", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #5e6062 is --color-primary-gray-base", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fafafa is --color-neutral-darker", - "app/content/search/components/SearchResultsSidebar/SearchResultsSidebar.css: #fff is --color-white", - "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: #fff is --color-white", - "app/content/search/components/SearchResultsSidebar/SidebarSearchInput.css: #fff is --color-white", - "app/content/studyGuides/components/ShowStudyGuides.css: white is --color-white", - "app/content/studyGuides/components/StudyGuidesCTA/StudyGuidesCTA.css: #027eb5 is --color-link", - "app/content/studyGuides/components/StudyGuidesListElement.css: white is --color-white", - "app/content/studyGuides/components/StudyGuidesListElement.css: white is --color-white", - "app/content/styles/PopupStyles.css: #fff is --color-white", - "app/content/styles/PopupStyles.css: #fff is --color-white", - "app/content/styles/PopupStyles.css: #fff is --color-white", - "app/developer/components/Home.css: #424242 is --color-text-default", - "app/errors/components/ErrorBoundary.css: #0064a0 is --color-link-hover", - "app/errors/components/ErrorBoundary.css: #027eb5 is --color-link", - "app/errors/components/ErrorBoundary.css: #424242 is --color-text-default", - "app/errors/components/ErrorIdList.css: #424242 is --color-text-default", - "app/notifications/components/Card.css: #0064a0 is --color-link-hover", - "app/notifications/components/Card.css: #027eb5 is --color-link", - "app/notifications/components/Card.css: #424242 is --color-text-default", - "app/notifications/components/Card.css: #424242 is --color-text-default", - "app/notifications/components/Card.css: #e5e5e5 is --color-neutral-darkest", - "app/notifications/components/Card.css: #fafafa is --color-neutral-darker", - "app/notifications/components/Card.css: #fafafa is --color-neutral-darker", - "app/notifications/components/Card.css: #fff is --color-white", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #c22032 is --color-secondary-red-base", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #fdbd3e is --color-secondary-gold-base", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #fdbd3e is --color-secondary-gold-base" + "app/components/Button.css: .button-default { background-color: #fff } is --color-white", + "app/components/Button.css: .button-default { border: #d5d5d5 } is --color-neutral-form-border", + "app/components/Button.css: .button-default { color: #424242 } is --color-text-default", + "app/components/Button.css: .button-default:active { background-color: #e5e5e5 } is --color-neutral-darkest", + "app/components/Button.css: .button-default:hover { background-color: #fafafa } is --color-neutral-darker", + "app/components/Button.css: .button-disabled, .button[disabled] { background-color: #f1f1f1 } is --color-neutral-page-background", + "app/components/Button.css: .button-disabled, .button[disabled] { color: #c1c1c1 } is --color-disabled-foreground", + "app/components/Button.css: .button-disabled:hover, .button-disabled:active, .button-disabled:focus, .button[disabled]:hover, .button[disabled]:active, .button[disabled]:focus { background-color: #f1f1f1 } is --color-neutral-page-background", + "app/components/Button.css: .button-disabled:hover, .button-disabled:active, .button-disabled:focus, .button[disabled]:hover, .button[disabled]:active, .button[disabled]:focus { color: #c1c1c1 } is --color-disabled-foreground", + "app/components/Button.css: .button-link { color: #027eb5 } is --color-link", + "app/components/Button.css: .button-link-decorated::after { background-color: #027eb5 } is --color-link", + "app/components/Button.css: .button-link-decorated:focus { color: #0064a0 } is --color-link-hover", + "app/components/Button.css: .button-link-decorated:hover { color: #0064a0 } is --color-link-hover", + "app/components/Button.css: .button-link:hover { color: #0064a0 } is --color-link-hover", + "app/components/Button.css: .button-primary { background-color: #d4450c } is --color-primary-orange-base", + "app/components/Button.css: .button-primary { color: #fff } is --color-white", + "app/components/Button.css: .button-primary:active { background-color: #b03808 } is --color-primary-orange-darkest", + "app/components/Button.css: .button-primary:focus { box-shadow: #000 } is --color-text-black", + "app/components/Button.css: .button-primary:focus { outline: #fff } is --color-white", + "app/components/Button.css: .button-primary:hover { background-color: #be3c08 } is --color-primary-orange-darker", + "app/components/Button.css: .button-secondary { background-color: #949494 } is --color-secondary-light-gray-base", + "app/components/Button.css: .button-secondary { color: #fff } is --color-white", + "app/components/Button.css: .button-secondary:active { background-color: #818181 } is --color-secondary-light-gray-darkest", + "app/components/Button.css: .button-secondary:hover { background-color: #8b8b8b } is --color-secondary-light-gray-darker", + "app/components/Button.css: .button-transparent { color: #027eb5 } is --color-link", + "app/components/Checkbox.css: .checkbox-icon { color: white } is --color-white", + "app/components/Checkbox.css: .checkbox-label input:checked + .checkbox-custom { background-color: #b03808 } is --color-primary-orange-darkest", + "app/components/Checkbox.css: .checkbox-label input:not(:checked) + .checkbox-custom { border: #424242 } is --color-text-default", + "app/components/Checkbox.css: .checkbox-label.checkbox-disabled { color: #424242 } is --color-text-default", + "app/components/Checkbox.css: .checkbox-label.focus-within .checkbox-custom { border: #b03808 } is --color-primary-orange-darkest", + "app/components/Checkbox.css: .checkbox-label.focus-within { background-color: #f1f1f1 } is --color-neutral-page-background", + "app/components/Checkbox.css: .checkbox-label:focus-within .checkbox-custom { border: #b03808 } is --color-primary-orange-darkest", + "app/components/Checkbox.css: .checkbox-label:focus-within { background-color: #f1f1f1 } is --color-neutral-page-background", + "app/components/DotMenu.css: .dot-menu-dropdown .focus-within .dot-menu-icon { color: #5e6062 } is --color-primary-gray-base", + "app/components/DotMenu.css: .dot-menu-dropdown:focus-within .dot-menu-icon { color: #5e6062 } is --color-primary-gray-base", + "app/components/DotMenu.css: .dot-menu-icon { color: #424242 } is --color-text-default", + "app/components/DotMenu.css: .dot-menu-icon:hover { color: #818181 } is --color-secondary-light-gray-darkest", + "app/components/Dropdown.css: .dropdown-list li button, .dropdown-list li a { color: #424242 } is --color-text-default", + "app/components/Dropdown.css: .dropdown-list li button:focus, .dropdown-list li a:focus { background: #d5d5d5 } is --color-neutral-form-border", + "app/components/Dropdown.css: .dropdown-list { background: #f5f5f5 } is --color-neutral-form-background", + "app/components/Dropdown.css: .dropdown-menu { border: #d5d5d5 } is --color-neutral-form-border", + "app/components/Dropdown.css: .dropdown-menu.context-menu { background-color: #f5f5f5 } is --color-neutral-form-background", + "app/components/Dropdown.css: .dropdown-menu.context-menu { border: #d5d5d5 } is --color-neutral-form-border", + "app/components/Dropdown.css: .dropdown-menu.text-resizer-menu { background: #fff } is --color-white", + "app/components/Dropdown.css: .dropdown-transparent .dropdown-focus-wrapper > .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=\"\"]::-moz-range-thumb { background: white } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"\"]::-moz-range-track { background: #fff } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"\"]::-moz-range-track { background: #fff } is --color-white", + "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-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=\"\"] { 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: #3b3b3b", - "app/components/NavBar/NavBar.css: #5f6163", - "app/components/NavBar/NavBar.css: #5f6163", - "app/components/NavBar/NavBar.css: #5f6163", - "app/content/components/LabsCall.css: #6922ea", - "app/content/components/LabsCall.css: #cacaca", - "app/content/components/Page/PageContent.css: #006880", - "app/content/components/Page/PageContent.css: #00c3ed", - "app/content/components/Page/PageContent.css: #141a3e", - "app/content/components/Page/PageContent.css: #4e6f01", - "app/content/components/Page/PageContent.css: #545ec8", - "app/content/components/Page/PageContent.css: #560131", - "app/content/components/Page/PageContent.css: #8f7700", - "app/content/components/Page/PageContent.css: #92d101", - "app/content/components/Page/PageContent.css: #c8f5ff", - "app/content/components/Page/PageContent.css: #cbcfff", - "app/content/components/Page/PageContent.css: #de017e", - "app/content/components/Page/PageContent.css: #def99f", - "app/content/components/Page/PageContent.css: #fed200", - "app/content/components/Page/PageContent.css: #ff9e4b", - "app/content/components/Page/PageContent.css: #ffc5e1", - "app/content/components/Page/PageContent.css: #ffea00", - "app/content/components/Page/PageContent.css: #ffff8a", - "app/content/components/Topbar/Topbar.css: #efeff1", - "app/content/highlights/components/DisplayNote.css: #c2c2c2", - "app/content/highlights/components/ShowMyHighlightsStyles.css: #dedede", - "app/content/styles/PopupStyles.css: #405c7d", - "app/content/styles/PopupStyles.css: #e8e8e8", - "app/developer/components/Books.css: #ccc", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #976502", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #976502", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #c23834", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #c23834", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #e297a0", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #e297a0", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #f8e8eb", - "app/notifications/components/ToastNotifications/ToastNotifications.css: #fff5e0" + "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.spec.ts b/src/app/theme.spec.ts index c9b42be2b7..f8373e8fee 100644 --- a/src/app/theme.spec.ts +++ b/src/app/theme.spec.ts @@ -23,6 +23,9 @@ const relative = (file: string) => path.relative(srcDir, file); * the check means enforcement starts now: a new duplicated colour 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')); @@ -85,7 +88,8 @@ describe('stylesheets', () => { // 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. + // 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, @@ -106,7 +110,7 @@ describe('stylesheets', () => { size: parseFloat((/([\d.]+)em/.exec(query) as RegExpExecArray)[1]), })) .filter(({size}) => !exact.has(size)) - .filter(({size}) => themeBreaks.some((themeBreak) => Math.abs(themeBreak - size) < 1)) + .filter(({size}) => themeBreaks.some((themeBreak) => Math.abs(themeBreak - size) <= 1)) .map(({query}) => `${relative(file)}: ${query}`); return [...result, ...offenders]; diff --git a/src/app/themeData.ts b/src/app/themeData.ts index 7a855db200..54d7e6ef26 100644 --- a/src/app/themeData.ts +++ b/src/app/themeData.ts @@ -6,9 +6,15 @@ * 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. * - * Every value here is projected into a CSS custom property in `theme.css` by - * `themeCss.ts`. Do not hand-copy a value from here into a stylesheet — reference - * its token instead. `theme.spec.ts` fails the build if you do. + * The colour, 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 { @@ -135,6 +141,7 @@ export const color = { 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 diff --git a/src/index.css b/src/index.css index 19576bcd74..741e43c454 100644 --- a/src/index.css +++ b/src/index.css @@ -8,7 +8,10 @@ @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. See README "Styling". */ +/* + * 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, diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts index 97767b9672..851e801c1e 100644 --- a/src/test/cssColors.spec.ts +++ b/src/test/cssColors.spec.ts @@ -1,13 +1,15 @@ import { colorKey, - declarationValues, + 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', () => { @@ -32,41 +34,90 @@ describe('stripNoise', () => { }); }); -describe('declarationValues', () => { +describe('declarations', () => { it('reads declarations at the top level of a rule', () => { - expect(declarationValues('a { color: red; background: blue; }')) - .toEqual(['red', 'blue']); + expect(values('a { color: red; background: blue; }')).toEqual(['red', 'blue']); }); it('reads declarations nested in @media', () => { - expect(declarationValues('@media (max-width: 50em) { a { color: red; } }')) - .toEqual(['red']); + expect(values('@media (max-width: 50em) { a { color: red; } }')).toEqual(['red']); }); it('does not mistake a pseudo-class selector for a declaration', () => { - expect(declarationValues('a:hover { color: red; }')).toEqual(['red']); + expect(values('a:hover { color: red; }')).toEqual(['red']); }); it('does not mistake @keyframes percentages for declarations', () => { - expect(declarationValues('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) + expect(values('@keyframes f { 0% { opacity: 0; } 100% { opacity: 1; } }')) .toEqual(['0', '1']); }); it('ignores at-rules outside a block, such as @import', () => { - expect(declarationValues('@import "./theme.css";')).toEqual([]); + expect(values('@import "./theme.css";')).toEqual([]); }); it('reads a declaration with no trailing semicolon', () => { - expect(declarationValues('a { color: red }')).toEqual(['red']); + expect(values('a { color: red }')).toEqual(['red']); }); it('does not split on a semicolon inside parentheses', () => { - const values = declarationValues('a { background: url(x;y); color: red; }'); - expect(values).toContain('red'); + expect(values('a { background: url(x;y); color: red; }')).toContain('red'); }); it('keeps a custom property declaration', () => { - expect(declarationValues(':root { --color-x: #fff; }')).toEqual(['#fff']); + 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']); + }); +}); + +describe('takesColor', () => { + it.each(['color', 'background-color', 'border-top-color', '-webkit-text-fill-color'])( + 'accepts %s, which names a colour', (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 colour', (property) => { + expect(takesColor(property)).toBe(false); + } + ); + + it('sees through a vendor prefix', () => { + expect(takesColor('-webkit-box-shadow')).toBe(true); }); }); @@ -140,6 +191,40 @@ describe('findColors', () => { 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; }'], + ])('does not read %s as a named colour', (_case, css) => { + expect(literals(css)).toEqual([]); + }); + + it.each([ + ['a colour property', 'a { color: red; }'], + ['a shorthand', 'a { border: 0.1rem solid red; }'], + ['a custom property', 'a { --x: red; }'], + ])('still reads a named colour in %s', (_case, css) => { + expect(literals(css)).toEqual(['red']); + }); + + it('still reads hex and rgb() in a property that cannot take a named colour', () => { + // only the bare-identifier case is property-sensitive: `#fff` and `rgb(...)` are + // colours 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 colour 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', () => { @@ -198,6 +283,21 @@ describe('describeColor', () => { it('returns null for a malformed hex length', () => { expect(describeColor('#12345')).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 colour. + 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('colour keys', () => { diff --git a/src/test/cssColors.ts b/src/test/cssColors.ts index bce1a671d7..abb2f57f01 100644 --- a/src/test/cssColors.ts +++ b/src/test/cssColors.ts @@ -28,6 +28,26 @@ export interface FoundColor { 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 colour 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 colour 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', @@ -137,23 +157,29 @@ export const stripNoise = (css: string): string => { }; /** - * Pulls declaration values out of a stylesheet at any nesting depth, so `@media` - * blocks are covered. Selectors and at-rule preludes are discarded (they end at a - * `{`), which is what keeps `a:hover` and `@keyframes` percentages from being read - * as declarations. + * 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 colour scan. + * + * The property name is kept as well as the value. Two things need it: a bare + * identifier is only a colour 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. */ -export const declarationValues = (css: string): string[] => { - const values: string[] = []; +export const declarations = (css: string): Declaration[] => { + const found: Declaration[] = []; const stripped = stripNoise(css); + const stack: string[] = []; let buffer = ''; - let depth = 0; let parens = 0; const flush = () => { const separator = buffer.indexOf(':'); - if (depth > 0 && separator !== -1) { + if (stack.length > 0 && separator !== -1) { const value = buffer.slice(separator + 1).trim(); - if (value) { values.push(value); } + const property = buffer.slice(0, separator).trim().toLowerCase(); + if (value) { found.push({context: stack.join(' '), property, value}); } } buffer = ''; }; @@ -162,14 +188,44 @@ export const declarationValues = (css: string): string[] => { if (character === '(') { parens++; } if (character === ')') { parens = Math.max(0, parens - 1); } - if (parens === 0 && character === '{') { buffer = ''; depth++; continue; } - if (parens === 0 && character === '}') { flush(); depth = Math.max(0, depth - 1); continue; } + if (parens === 0 && character === '{') { + stack.push(buffer.replace(/\s+/g, ' ').trim()); + buffer = ''; + continue; + } + if (parens === 0 && character === '}') { flush(); stack.pop(); continue; } if (parens === 0 && character === ';') { flush(); continue; } buffer += character; } - return values; + return found; +}; + +/** + * Properties whose value can hold a ``, directly or inside a shorthand. + * + * Hex and the colour functions are only ever colours, 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 colours 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)); @@ -177,7 +233,11 @@ 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); - if (percent) { return Math.round(clamp(parseFloat(percent[1]), 100) * 2.55); } + // 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 colour 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; }; @@ -189,13 +249,22 @@ const alphaChannel = (raw?: string): number | null => { 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 + * colour. A malformed *theme* value would then pass the "every colour 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; - if (full.length !== 6 && full.length !== 8) { return null; } - return { a: full.length === 8 ? parseInt(full.slice(6, 8), 16) / 255 : 1, b: parseInt(full.slice(4, 6), 16), @@ -235,8 +304,13 @@ export const describeColor = (literal: string): Rgba | null => { /** * Finds every colour literal in a declaration value, at any depth. Functions that * merely contain colours are descended into; colour functions are terminal. + * + * `named` says whether a bare identifier may be read as a colour, which depends on the + * property the value belongs to — see `takesColor`. Hex and the colour 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): FoundColor[] => { +export const findColors = (value: string, named: boolean): FoundColor[] => { const found: FoundColor[] = []; let index = 0; @@ -258,7 +332,7 @@ export const findColors = (value: string): FoundColor[] => { if (COLOR_FUNCTIONS.includes(call[1].toLowerCase())) { found.push({literal, rgba: describeColor(literal)}); } else { - found.push(...findColors(args)); + found.push(...findColors(args, named)); } index = cursor; @@ -275,7 +349,7 @@ export const findColors = (value: string): FoundColor[] => { const word = /^-?[a-zA-Z][\w-]*/.exec(rest); if (word) { const name = word[0].toLowerCase(); - if (NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { + if (named && NAMED_COLORS[name] && !COLOR_KEYWORDS.includes(name)) { found.push({literal: word[0], rgba: describeColor(word[0])}); } index += word[0].length; @@ -289,9 +363,12 @@ export const findColors = (value: string): FoundColor[] => { }; /** Every colour literal written in a stylesheet, in source order. */ -export const stylesheetColors = (css: string): FoundColor[] => - declarationValues(css).reduce( - (result: FoundColor[], value) => [...result, ...findColors(value)], +export const stylesheetColors = (css: string): StylesheetColor[] => + declarations(css).reduce( + (result: StylesheetColor[], {context, property, value}) => [ + ...result, + ...findColors(value, takesColor(property)).map((found) => ({...found, context, property})), + ], [] ); @@ -346,6 +423,24 @@ export interface ColorViolations { unknown: string[]; } +/** + * How a colour 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 colour 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 colour 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[] = []; @@ -354,12 +449,14 @@ export const colorViolations = (srcDir: string): ColorViolations => { stylesheetFiles(srcDir).forEach((file) => { const name = path.relative(srcDir, file); - stylesheetColors(fs.readFileSync(file, 'utf8')).forEach(({literal, rgba}) => { + 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(`${name}: ${literal} is ${values[colorKey(rgba)]}`); + duplicates.push(`${occurrence(name, found)} is ${values[colorKey(rgba)]}`); return; } @@ -369,7 +466,7 @@ export const colorViolations = (srcDir: string): ColorViolations => { 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(`${name}: ${literal}`); + unknown.push(occurrence(name, found)); } }); }); From 748432d12df3eeec31d8f01e22aa0b1cf051c916 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 18:57:51 +0000 Subject: [PATCH 4/7] CORE-2731: pin the property gate through the recursive descent The three defects named on ui-components#143 -- hex grammar, the named-colour property gate and the 2.55 percentage scaling -- were already fixed here in 6bfa139, and each is pinned by a test that fails when reverted. That note was written from #143's state rather than this branch's; nothing in cssColors.ts changes here. Diffing the two checkers properly did turn up one real gap, in the tests: findColors recurses into var(), color-mix() and the gradients, threading the `named` flag down to itself. Nothing asserted that it does. Passing `true` instead of `named` in the recursive call left the whole suite green, so the property gate was only actually guarded at the top level of a declaration value. Covered now from both sides -- `animation-name: var(--enter, red)` finds nothing, `background: linear-gradient(to top, red, transparent)` still finds red -- and the first was confirmed load-bearing by leaking the flag and watching it fail alone. Also brought across three cases #143 has and this did not: a named colour in box-shadow, a named colour in a vendor-prefixed property, and the #1234567 / #123456789 malformed hex lengths. The colour-bearing property lists still differ between the two repos and are deliberately left alone -- this one enumerates the eleven border shorthands that can hold a colour where #143 uses startsWith('border'), and additionally covers mask-image, border-image and text-stroke. Converging them is CORE-2736. 80 -> 86 cases in cssColors.spec.ts. theme.baseline.json regenerates byte-identical at 184/37. Co-Authored-By: Claude Opus 5 (1M context) --- src/test/cssColors.spec.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts index 851e801c1e..0a4ec0bb57 100644 --- a/src/test/cssColors.spec.ts +++ b/src/test/cssColors.spec.ts @@ -197,6 +197,9 @@ describe('findColors', () => { ['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 colour', (_case, css) => { expect(literals(css)).toEqual([]); }); @@ -205,6 +208,11 @@ describe('findColors', () => { ['a colour 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 colours 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 colour in %s', (_case, css) => { expect(literals(css)).toEqual(['red']); }); @@ -280,9 +288,11 @@ describe('describeColor', () => { expect(describeColor('notacolour')).toBeNull(); }); - it('returns null for a malformed hex length', () => { - expect(describeColor('#12345')).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) => { From 250a488edc9390524f47a3682a2f47aa7b1779b3 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 20:49:32 +0000 Subject: [PATCH 5/7] CORE-2731: keep selector strings out of the baseline's blind spot stripNoise blanked string contents before `declarations` recorded `context`, so two rules differing only inside a selector string reduced to one identity. That was live in the committed baseline -- five entries carried `input[type=""]` or `[data-active=""]` -- and reachable, not just latent: the tree already contains [data-loading="true"]/[data-loading="false"], [data-fading-in="true"]/[data-fading-in="false"], [data-type="page"]/[data-type="equation"]/[data-type="composite-page"] and [data-testid="chapter-title"]/[data-testid="section-title"]. Moving a literal between either half of any of those left the sorted baseline unchanged, which is precisely the hole the declaration identity exists to close. A string is noise in a declaration value -- `content: "#fff"` is not a colour -- and meaning in a selector, so one blanked copy cannot serve both. stripNoise now blanks to spaces rather than deleting, making its output the same length as its input, and `declarations` walks two aligned copies: structure and values from the copy with strings blanked, `context` sliced from the copy that keeps them. url() parentheses are kept either way, since the walk balances them to know a `;` inside url() is not a separator. Three cases pin it, each confirmed by making the specific mistake and watching only it fail: context from the blanked copy, values from the preserving copy, and a brace inside a selector string opening a block. The length invariant is covered separately over unterminated strings, url() and comments -- it is load-bearing now, because a length change misaligns context against structure and would surface only as a wrong selector in a baseline entry. Also: the mainContentBackground comment claimed the value is referenced from CSS as var(--color-neutral-base). It is not -- ContentPane binds it as --main-content-background and ContentPane.css reads that -- so the comment would have sent the sweep looking for a reference that does not exist. Baseline regenerates at 184/37, unchanged; the five entries gain their real selectors. cssColors.spec.ts 86 -> 96 cases. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/content/components/constants.ts | 5 +- src/app/theme.baseline.json | 10 +-- src/test/cssColors.spec.ts | 51 ++++++++++++-- src/test/cssColors.ts | 90 ++++++++++++++++++------- 4 files changed, 121 insertions(+), 35 deletions(-) diff --git a/src/app/content/components/constants.ts b/src/app/content/components/constants.ts index 68d7132d5f..5bb7db30f6 100644 --- a/src/app/content/components/constants.ts +++ b/src/app/content/components/constants.ts @@ -43,7 +43,10 @@ export const searchSidebarTopOffset = bookBannerMobileMiniHeight export const contentTextWidth = 82.5; -// the theme's white; referenced from CSS as var(--color-neutral-base) +// 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; diff --git a/src/app/theme.baseline.json b/src/app/theme.baseline.json index f6fd4066ec..0353f34cbe 100644 --- a/src/app/theme.baseline.json +++ b/src/app/theme.baseline.json @@ -106,17 +106,17 @@ "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=\"\"]::-moz-range-thumb { background: white } is --color-white", - "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"\"]::-moz-range-track { background: #fff } is --color-white", - "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-webkit-slider-runnable-track, .topbar-text-resizer-menu .controls input[type=\"\"]::-moz-range-track { background: #fff } is --color-white", - "app/content/components/Topbar/Topbar.css: .topbar-text-resizer-menu .controls input[type=\"\"]::-webkit-slider-thumb { background: white } is --color-white", + "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=\"\"] { background: #fff } is --color-white", + "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", diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts index 0a4ec0bb57..9e183d31c9 100644 --- a/src/test/cssColors.spec.ts +++ b/src/test/cssColors.spec.ts @@ -17,21 +17,41 @@ describe('stripNoise', () => { }); it('removes string contents so content: "tan" is not a colour', () => { - expect(stripNoise('a { content: "tan"; }')).toContain('content: ""'); + expect(stripNoise('a { content: "tan"; }')).not.toContain('tan'); }); it('removes url() payloads', () => { - expect(stripNoise('a { background: url(data:image/svg+xml;base64,Zm9v) no-repeat; }')) - .toContain('url() no-repeat'); + 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"; }')).toContain('content: ""'); + 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', () => { @@ -91,6 +111,29 @@ describe('declarations', () => { 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 colours', () => { + // the other half of the same change: context keeps strings, values must not, or + // `content: "#fff"` starts reading as a colour. + 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', () => { diff --git a/src/test/cssColors.ts b/src/test/cssColors.ts index abb2f57f01..5b01da790f 100644 --- a/src/test/cssColors.ts +++ b/src/test/cssColors.ts @@ -109,8 +109,23 @@ const COLOR_FUNCTIONS = [ */ const COLOR_KEYWORDS = ['transparent', 'currentcolor', 'inherit', 'initial', 'unset', 'revert', 'none']; -/** Removes comments, string contents and url() payloads, preserving structure. */ -export const stripNoise = (css: string): string => { +/** + * Blanks the parts of a stylesheet that can hold colour-shaped text without meaning a + * colour: 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 colour — 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; @@ -119,8 +134,9 @@ export const stripNoise = (css: string): string => { if (rest.startsWith('/*')) { const end = css.indexOf('*/', index + 2); - index = end === -1 ? css.length : end + 2; - out += ' '; + const stop = end === -1 ? css.length : end + 2; + out += pad(stop - index); + index = stop; continue; } @@ -130,22 +146,30 @@ export const stripNoise = (css: string): string => { while (cursor < css.length && css[cursor] !== quote) { cursor += css[cursor] === '\\' ? 2 : 1; } - index = cursor + 1; - out += '""'; + 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 = index + url[0].length; + 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; - out += 'url()'; continue; } @@ -156,6 +180,9 @@ export const stripNoise = (css: string): string => { 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 @@ -166,37 +193,50 @@ export const stripNoise = (css: string): string => { * identifier is only a colour 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 stripped = stripNoise(css); + const values = stripNoise(css); + const selectors = blankNoise(css, true); const stack: string[] = []; - let buffer = ''; + let start = 0; let parens = 0; - const flush = () => { - const separator = buffer.indexOf(':'); + const flush = (end: number) => { + const segment = values.slice(start, end); + const separator = segment.indexOf(':'); + if (stack.length > 0 && separator !== -1) { - const value = buffer.slice(separator + 1).trim(); - const property = buffer.slice(0, separator).trim().toLowerCase(); + const value = segment.slice(separator + 1).trim(); + const property = segment.slice(0, separator).trim().toLowerCase(); if (value) { found.push({context: stack.join(' '), property, value}); } } - buffer = ''; + + start = end + 1; }; - for (const character of stripped) { + 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 && character === '{') { - stack.push(buffer.replace(/\s+/g, ' ').trim()); - buffer = ''; - continue; + 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); } - if (parens === 0 && character === '}') { flush(); stack.pop(); continue; } - if (parens === 0 && character === ';') { flush(); continue; } - - buffer += character; } return found; From 6703f43870a8e8f65f8c25d7e824ac85a40c12ee Mon Sep 17 00:00:00 2001 From: Roy Johnson Date: Tue, 1 Sep 2026 16:36:46 -0500 Subject: [PATCH 6/7] colour -> color --- PLAIN_CSS_MIGRATION_GUIDE.md | 16 ++--- e2e_tests/e2e/ui/pages/home.py | 12 ++-- .../e2e/ui/test_highlight_box_save_note.py | 12 ++-- e2e_tests/e2e/ui/test_highlight_editbox.py | 2 +- script/generate-theme-baseline.ts | 4 +- .../highlights/components/ColorPicker.tsx | 2 +- .../__snapshots__/ContextMenu.spec.tsx.snap | 2 +- .../__snapshots__/ColorPicker.spec.tsx.snap | 2 +- src/app/theme.spec.ts | 10 ++-- src/app/themeData.ts | 2 +- src/test/cssColors.spec.ts | 54 ++++++++--------- src/test/cssColors.ts | 58 +++++++++---------- 12 files changed, 88 insertions(+), 88 deletions(-) diff --git a/PLAIN_CSS_MIGRATION_GUIDE.md b/PLAIN_CSS_MIGRATION_GUIDE.md index 40dcb6bfbc..593bce682b 100644 --- a/PLAIN_CSS_MIGRATION_GUIDE.md +++ b/PLAIN_CSS_MIGRATION_GUIDE.md @@ -227,18 +227,18 @@ unprefixed (`--section-bg`, `--popup-padding`) is a component-local override hoo a global token. **When to use a global token:** -- Any static colour, z-index or page padding — i.e. the value does not depend on props +- 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 colours requiring dynamic property access (`theme.color.primary[bookTheme]`) -- Colours with runtime computations (highlight colours via the Color library) +- Book-specific theme colors requiring dynamic property access (`theme.color.primary[bookTheme]`) +- 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 a static colour +// ❌ Before: component-level binding for a static color import theme from '../theme'; export function Card({ className, style, ...props }) { @@ -291,8 +291,8 @@ See `src/app/theme.css` for the full list of 80 tokens. 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 colour literal that duplicates a theme value. -3. No stylesheet introduces a colour that is neither a theme value nor explicitly +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. @@ -313,7 +313,7 @@ baseline to need regenerating, if you move a declaration or rename a selector; t counts are what should not go up. Hex literals and `rgb()`/`hsl()`/`oklch()` are audited in every declaration. Bare named -colours (`white`, `tan`) are only read as colours in properties that can take one, so +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. **Breakpoints are the known gap.** `@media (min-width: var(--x))` is not valid CSS, so @@ -682,7 +682,7 @@ style={{ '--banner-bg': colors.base }} — 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 colours come from the theme too; import them from +- 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/script/generate-theme-baseline.ts b/script/generate-theme-baseline.ts index 6e1e087227..8a6d9a8aa9 100644 --- a/script/generate-theme-baseline.ts +++ b/script/generate-theme-baseline.ts @@ -1,10 +1,10 @@ /** * Rewrites src/app/theme.baseline.json from the current stylesheets. * - * The baseline records the colour violations that predate the token file, so that + * 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 colour that belongs in a token. + * up. If they go up, you have added a hardcoded color that belongs in a token. */ import fs from 'fs'; import path from 'path'; 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) =>
path.relative(srcDir, file); /** - * The colour violations that already existed when the token file was introduced and + * 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 colour fails CI today, and + * 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. * @@ -37,7 +37,7 @@ describe('theme.css', () => { expect(fs.readFileSync(themeCssPath, 'utf8')).toEqual(themeCss()); }); - it('resolves every colour token to real channels', () => { + 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() @@ -54,11 +54,11 @@ describe('stylesheets', () => { expect(stylesheetFiles(srcDir).length).toBeGreaterThan(50); }); - it('do not duplicate a theme colour beyond the baseline', () => { + it('do not duplicate a theme color beyond the baseline', () => { expect(colorViolations(srcDir).duplicates).toEqual(baseline().duplicates); }); - it('do not introduce an unrecognised colour beyond the baseline', () => { + it('do not introduce an unrecognised color beyond the baseline', () => { expect(colorViolations(srcDir).unknown).toEqual(baseline().unknown); }); diff --git a/src/app/themeData.ts b/src/app/themeData.ts index 54d7e6ef26..2b4ccfa0c4 100644 --- a/src/app/themeData.ts +++ b/src/app/themeData.ts @@ -6,7 +6,7 @@ * 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 colour, padding and z-index values here are projected into CSS custom + * 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. * diff --git a/src/test/cssColors.spec.ts b/src/test/cssColors.spec.ts index 9e183d31c9..86aaa9fa58 100644 --- a/src/test/cssColors.spec.ts +++ b/src/test/cssColors.spec.ts @@ -16,7 +16,7 @@ describe('stripNoise', () => { expect(stripNoise('a { /* #ff0000 */ color: red; }')).not.toContain('#ff0000'); }); - it('removes string contents so content: "tan" is not a colour', () => { + it('removes string contents so content: "tan" is not a color', () => { expect(stripNoise('a { content: "tan"; }')).not.toContain('tan'); }); @@ -124,9 +124,9 @@ describe('declarations', () => { .toEqual(['.x[data-loading="true"]', '.x[data-loading="false"]']); }); - it('still blanks strings in the value, where they are not colours', () => { + 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 colour. + // `content: "#fff"` starts reading as a color. expect(declarations('a { content: "#fff"; }')).toEqual([]); }); @@ -138,7 +138,7 @@ describe('declarations', () => { describe('takesColor', () => { it.each(['color', 'background-color', 'border-top-color', '-webkit-text-fill-color'])( - 'accepts %s, which names a colour', (property) => { + 'accepts %s, which names a color', (property) => { expect(takesColor(property)).toBe(true); } ); @@ -154,7 +154,7 @@ describe('takesColor', () => { }); it.each(['animation-name', 'font-family', 'transition-property', 'grid-area'])( - 'rejects %s, where an identifier is not a colour', (property) => { + 'rejects %s, where an identifier is not a color', (property) => { expect(takesColor(property)).toBe(false); } ); @@ -169,11 +169,11 @@ describe('findColors', () => { expect(literals('a { color: #ff0000; }')).toEqual(['#ff0000']); }); - it('finds a bare named colour in a shorthand', () => { + it('finds a bare named color in a shorthand', () => { expect(literals('a { border: 0.1rem solid red; }')).toEqual(['red']); }); - it('finds colours in gradient stops', () => { + it('finds colors in gradient stops', () => { expect(literals('a { background: linear-gradient(to top, #fff 0%, #000 100%); }')) .toEqual(['#fff', '#000']); }); @@ -207,27 +207,27 @@ describe('findColors', () => { expect(found[0].rgba).toBeNull(); }); - it('does not treat a class selector named .red as a colour', () => { + 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 colour', () => { + it('does not treat content: "tan" as a color', () => { expect(literals('a { content: "tan"; }')).toEqual([]); }); - it('does not treat a colour inside a comment as a colour', () => { + 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 colours', () => { + it('does not treat transparent or currentcolor as comparable colors', () => { expect(literals('a { color: currentcolor; background: transparent; }')).toEqual([]); }); - it('does not treat a non-colour keyword as a colour', () => { + it('does not treat a non-color keyword as a color', () => { expect(literals('a { transition: all 0.2s linear; }')).toEqual([]); }); - it('finds several colours in one declaration', () => { + it('finds several colors in one declaration', () => { expect(literals('a { box-shadow: 0 0 0 red, 0 0 0 #00f; }')).toEqual(['red', '#00f']); }); @@ -243,31 +243,31 @@ describe('findColors', () => { // 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 colour', (_case, css) => { + ])('does not read %s as a named color', (_case, css) => { expect(literals(css)).toEqual([]); }); it.each([ - ['a colour property', 'a { color: red; }'], + ['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 colours on the property must not + // 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 colour in %s', (_case, css) => { + ])('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 colour', () => { + 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 - // colours wherever they are written, so they stay in scope everywhere. + // 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 colour was written in', () => { + 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', @@ -295,7 +295,7 @@ describe('describeColor', () => { expect(describeColor('#027EB5')).toEqual(describeColor('#027eb5')); }); - it('resolves a named colour', () => { + it('resolves a named color', () => { expect(describeColor('white')).toEqual({a: 1, b: 255, g: 255, r: 255}); }); @@ -328,7 +328,7 @@ describe('describeColor', () => { }); it('returns null for an unknown identifier', () => { - expect(describeColor('notacolour')).toBeNull(); + expect(describeColor('notacolor')).toBeNull(); }); it.each(['#12345', '#1234567', '#123456789'])( @@ -340,7 +340,7 @@ describe('describeColor', () => { 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 colour. + // {r: NaN, g: NaN, b: NaN} and read as a resolved color. expect(describeColor(literal)).toBeNull(); } ); @@ -353,17 +353,17 @@ describe('describeColor', () => { }); }); -describe('colour keys', () => { - it('treats an opaque colour as equal however it is written', () => { +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 colour from its opaque form', () => { + 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 colour by its opaque channels', () => { + 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 index 5b01da790f..80b7394d9d 100644 --- a/src/test/cssColors.ts +++ b/src/test/cssColors.ts @@ -1,8 +1,8 @@ /** - * Colour auditing for plain-CSS stylesheets, used by src/app/theme.spec.ts. + * 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 colours in shorthands and colours in gradient stops — + * `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 @@ -33,7 +33,7 @@ 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 colour occurrence's identity in the baseline. + * 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` */ @@ -42,7 +42,7 @@ export interface Declaration { value: string; } -/** A colour literal together with the declaration it was written in. */ +/** A color literal together with the declaration it was written in. */ export interface StylesheetColor extends FoundColor { context: string; property: string; @@ -95,8 +95,8 @@ const NAMED_COLORS: {[name: string]: string} = { }; /** - * Colour functions are terminal — we try to resolve them and flag them. - * Anything else that happens to *contain* a colour (`var`, `color-mix`, the + * 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 = [ @@ -104,14 +104,14 @@ const COLOR_FUNCTIONS = [ ]; /** - * Keywords that are colour-valued but carry no fixed channels, so there is nothing + * 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 colour-shaped text without meaning a - * colour: comments, string contents and `url()` payloads. + * 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 @@ -119,7 +119,7 @@ const COLOR_KEYWORDS = ['transparent', 'currentcolor', 'inherit', 'initial', 'un * differently-blanked copy. * * `keepStrings` is what that second copy is for. A string is noise inside a declaration - * value — `content: "#fff"` is not a colour — but it is *meaning* inside a selector: + * 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. @@ -187,10 +187,10 @@ 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 colour scan. + * `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 colour in a property that takes one (`animation-name: red` is + * 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. * @@ -245,10 +245,10 @@ export const declarations = (css: string): Declaration[] => { /** * Properties whose value can hold a ``, directly or inside a shorthand. * - * Hex and the colour functions are only ever colours, so they are read wherever they + * 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 colours are therefore only read here. Custom properties have + * would be wrong. Named colors are therefore only read here. Custom properties have * no property grammar at all, so they count. */ const COLOR_SHORTHANDS = [ @@ -275,7 +275,7 @@ const channel = (raw: string): number | null => { 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 colour must agree, or they + // 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; @@ -293,7 +293,7 @@ const alphaChannel = (raw?: string): number | 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 - * colour. A malformed *theme* value would then pass the "every colour token resolves" + * 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})$/; @@ -314,7 +314,7 @@ const fromHex = (literal: string): Rgba | null => { }; /** - * Resolves a colour literal to channels, or null when it cannot be resolved + * 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. */ @@ -342,11 +342,11 @@ export const describeColor = (literal: string): Rgba | null => { }; /** - * Finds every colour literal in a declaration value, at any depth. Functions that - * merely contain colours are descended into; colour functions are terminal. + * 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 colour, which depends on the - * property the value belongs to — see `takesColor`. Hex and the colour functions are + * `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. */ @@ -402,7 +402,7 @@ export const findColors = (value: string, named: boolean): FoundColor[] => { return found; }; -/** Every colour literal written in a stylesheet, in source order. */ +/** Every color literal written in a stylesheet, in source order. */ export const stylesheetColors = (css: string): StylesheetColor[] => declarations(css).reduce( (result: StylesheetColor[], {context, property, value}) => [ @@ -412,7 +412,7 @@ export const stylesheetColors = (css: string): StylesheetColor[] => [] ); -/** Canonical key for comparing two colours. Opaque colours ignore alpha. */ +/** 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}`; @@ -425,7 +425,7 @@ export const opaqueKey = (rgba: Rgba): string => `${rgba.r},${rgba.g},${rgba.b}` * baseline would otherwise be generated by different logic than it is checked with. */ -/** Maps a canonical colour key to the token that declares it. */ +/** Maps a canonical color key to the token that declares it. */ export const themeColorIndex = (): {[key: string]: string} => themeTokens() .reduce((result: {[key: string]: string}, [name, value]) => { const rgba = describeColor(value); @@ -433,8 +433,8 @@ export const themeColorIndex = (): {[key: string]: string} => themeTokens() }, {}); /** - * Colours that are deliberately not theme values, so they are never reported as - * unrecognised. Each entry needs a reason: a colour only belongs here if snapping it + * 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. */ @@ -464,17 +464,17 @@ export interface ColorViolations { } /** - * How a colour occurrence is identified in the baseline. + * 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 colour past the + * 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 colour hides. + * 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. */ From 39ab368b82822b31f54a316d9db992d470e795d9 Mon Sep 17 00:00:00 2001 From: OpenStaxClaude Date: Tue, 1 Sep 2026 22:26:15 +0000 Subject: [PATCH 7/7] CORE-2731: accumulate the color audit incrementally stylesheetColors built its result by spreading into a new array per declaration, which is quadratic in the declarations of a single stylesheet. Replaced with push, along with the same shape in the three other places on the whole-tree scan path: themeColorIndex's object spread per token, the stylesheetFiles directory walk, and the two per-file reduces in theme.spec.ts. Measured over the tree it audits -- 102 files, 3193 declarations -- this is not a speedup: 26.5ms median before, 26.1ms after. The quadratic term is bounded per file rather than across the tree, because colorViolations already accumulated with push and stylesheetColors runs once per stylesheet; the largest file has 210 declarations. Worth doing anyway since it is strictly less work for the same number of lines, and the bound only holds until someone writes a bigger stylesheet, but the comment on stylesheetColors says why rather than implying a win that is not there. theme.baseline.json regenerates byte-identical at 184/37, which is what makes this a pure refactor. Co-Authored-By: Claude Opus 5 (1M context) --- src/app/theme.spec.ts | 26 ++++++++++---------- src/test/cssColors.ts | 55 ++++++++++++++++++++++++++++--------------- 2 files changed, 50 insertions(+), 31 deletions(-) diff --git a/src/app/theme.spec.ts b/src/app/theme.spec.ts index d96e7d6c55..4d3f4a2a64 100644 --- a/src/app/theme.spec.ts +++ b/src/app/theme.spec.ts @@ -69,15 +69,16 @@ describe('stylesheets', () => { const declared = new Set(themeTokens().map(([name]) => `--${name}`)); const globalFamilies = /^--(color|z-index|padding)-/; - const missing = stylesheetFiles(srcDir).reduce((result: string[], file) => { + const missing: string[] = []; + + for (const file of stylesheetFiles(srcDir)) { const read = stripNoise(fs.readFileSync(file, 'utf8')).match(/var\(\s*(--[\w-]+)/g) || []; - const offenders = read + + read .map((match) => match.replace(/var\(\s*/, '')) .filter((name) => globalFamilies.test(name) && !declared.has(name)) - .map((name) => `${relative(file)}: ${name}`); - - return [...result, ...offenders]; - }, []); + .forEach((name) => missing.push(`${relative(file)}: ${name}`)); + } expect(missing).toEqual([]); }); @@ -101,20 +102,21 @@ describe('stylesheets', () => { [] )); - const suspicious = stylesheetFiles(srcDir).reduce((result: string[], file) => { + const suspicious: string[] = []; + + for (const file of stylesheetFiles(srcDir)) { const queries = stripNoise(fs.readFileSync(file, 'utf8')) .match(/\((?:min|max)-width:\s*[\d.]+em\)/g) || []; - const offenders = queries + + 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)) - .map(({query}) => `${relative(file)}: ${query}`); - - return [...result, ...offenders]; - }, []); + .forEach(({query}) => suspicious.push(`${relative(file)}: ${query}`)); + } expect(suspicious).toEqual([]); }); diff --git a/src/test/cssColors.ts b/src/test/cssColors.ts index 80b7394d9d..2f2844069e 100644 --- a/src/test/cssColors.ts +++ b/src/test/cssColors.ts @@ -402,15 +402,24 @@ export const findColors = (value: string, named: boolean): FoundColor[] => { return found; }; -/** Every color literal written in a stylesheet, in source order. */ -export const stylesheetColors = (css: string): StylesheetColor[] => - declarations(css).reduce( - (result: StylesheetColor[], {context, property, value}) => [ - ...result, - ...findColors(value, takesColor(property)).map((found) => ({...found, context, property})), - ], - [] - ); +/** + * 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 => @@ -426,11 +435,16 @@ export const opaqueKey = (rgba: Rgba): string => `${rgba.r},${rgba.g},${rgba.b}` */ /** Maps a canonical color key to the token that declares it. */ -export const themeColorIndex = (): {[key: string]: string} => themeTokens() - .reduce((result: {[key: string]: string}, [name, value]) => { +export const themeColorIndex = (): {[key: string]: string} => { + const index: {[key: string]: string} = {}; + + for (const [name, value] of themeTokens()) { const rgba = describeColor(value); - return rgba === null ? result : {...result, [colorKey(rgba)]: `--${name}`}; - }, {}); + if (rgba !== null) { index[colorKey(rgba)] = `--${name}`; } + } + + return index; +}; /** * Colors that are deliberately not theme values, so they are never reported as @@ -441,14 +455,17 @@ export const themeColorIndex = (): {[key: string]: string} => themeTokens() export const KNOWN_OFF_PALETTE: {[key: string]: string} = {}; export const stylesheetFiles = (srcDir: string): string[] => { - const walk = (dir: string): string[] => fs.readdirSync(dir, {withFileTypes: true}) - .reduce((result: string[], entry) => { + 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()) { return [...result, ...walk(target)]; } - return entry.name.endsWith('.css') ? [...result, target] : result; - }, []); + if (entry.isDirectory()) { walk(target, into); } + else if (entry.name.endsWith('.css')) { into.push(target); } + } + + return into; + }; - return walk(srcDir) + 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