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