Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
132 changes: 132 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,138 @@ npm run lint # Check code rules
npm run dist # Build distribution files
```

## Styling

Components are styled with plain CSS in a sibling `.css` file, imported for side effects.

### Theme tokens

Theme values are defined once, in JavaScript, and projected into CSS custom properties by
`src/theme/theme.css`. That file is **generated** from `src/theme/palette.ts` and
`src/theme.ts` — never edit it by hand. `npm run build` regenerates it as its first step, so
a published package can never ship a stale one. To refresh it without a full build:

```
npm run generate:theme-css
```

It is committed as well as generated, because jest and ladle read `src/` directly and never
run the build — `src/theme/tokens.spec.ts` fails when the committed copy is stale, which is
what makes CI (which runs lint and test, not build) catch it.

**Never write a theme value as a literal in a component stylesheet** — reference the token
instead:

```css
/* no */
.thing { border-color: #d5d5d5; }

/* yes */
.thing { border-color: var(--ox-color-pale); }
```

Tokens are `--ox-`-prefixed, so they will not collide with a consuming app's own variables.
Color tokens are the kebab-case form of the `src/theme/palette.ts` key
(`palette.neutralLighter` → `--ox-color-neutral-lighter`); there are also `--ox-color-link`,
`--ox-color-link-hover`, `--ox-z-index-*` and `--ox-padding-navbar-*`.

Any component whose CSS uses a token must import the token file alongside its own
stylesheet:

```ts
import './MyComponent.css';
import '../theme/theme.css';
```

There is no bundler in the build — `build.bash` copies CSS 1:1 — so nothing resolves an
`@import` on our behalf and a stylesheet does not drag `theme.css` in by itself. Forget the
import and every `var(--ox-*)` in that file quietly takes its fallback, which usually looks
right on screen because the fallback is the literal the token replaced. `tokens.spec.ts`
fails when a stylesheet reads a token and the module importing it does not import the
tokens, so this cannot be forgotten rather than merely being documented.

Consumers of the package need do nothing: the import rides along with the component,
bundlers deduplicate it, and `sideEffects` in `package.json` keeps it from being
tree-shaken. An app that wants the tokens without rendering one of our components — to
build its own styles on the palette, say — can load the file directly:

```ts
import '@openstax/ui-components/theme/theme.css';
```

### Component override hooks and when to bind in JS

A component may expose its own `--component-*` custom property so consumers can restyle it.
Declare the **default in the stylesheet** with the token as the fallback, and do not bind it
from JavaScript:

```css
.tabs [role="tab"] { border-color: var(--tabs-active-border-color, var(--ox-color-dark-green)); }
```

Bind a custom property from JavaScript only when its value genuinely varies at runtime — a
variant lookup, a numeric prop, a disabled state. A static color pushed through an inline
style is duplication with extra steps, and it wins over the cascade in ways callers do not
expect.

Widen the component's `style` prop to `CSSPropertiesWithVariables` (from `src/types`) so
callers can set these without a cast.

### What CI enforces

`src/theme/tokens.spec.ts` fails the build if:

- the committed `theme.css` is not what the generator produces from the JS theme (the build
regenerates it; this is what stops a stale copy reaching jest, ladle or a reviewer)
- a component stylesheet writes a color literal that duplicates a theme value
- a component stylesheet introduces a color that is not a theme value and not in the
`KNOWN_OFF_PALETTE` allowlist
- a component stylesheet reads an `--ox-*` token that does not exist
- a stylesheet reads a token and the module that imports it does not import `theme.css`

The color check parses declarations rather than grepping for hex, so it reads a value the
way the property does. What that covers:

- Hex, and the color functions — `rgb()`, `hsl()`, `hwb()`, `lab()`, `lch()`, `oklab()`,
`oklch()`, `color()` and their `a` variants — in any declaration, whatever the property.
- A bare name such as `whitesmoke` only where the property could take a color: a property
whose name contains `color`, one of the color shorthands (`background`, `border`,
`outline`, `box-shadow`, `text-decoration`, `fill` and the rest), or a custom property,
which has no grammar to go on. `var()` keeps the gate of the property it is written in,
since its fallback is whatever that property makes of it.
- Functions that merely *contain* colors (`var()`, `color-mix()`, `light-dark()`, the
gradients) are descended into rather than read as literals, so a value built out of
tokens stays clean. The gradients and `color-mix()` open the gate on their own, because
`linear-gradient(red, blue)` is a gradient between two colors whatever it is assigned to.
- A translucent color where its opaque channels are a theme value — `rgba(0, 0, 0, 0.2)` is
black at 20% and there is no token form for it. A new hue smuggled in through `rgba()` is
still refused.

What it does not cover, deliberately: a bare name outside a color context. `animation-name:
red`, `font-family: white` and `grid-area: gold` are identifiers that happen to spell
colors, and are not reported.

What it does not cover, for now: only the literal ASCII spelling of a *name* is recognized.
Escapes are decoded in color names themselves, but not in property or function names, so
`c\6f lor: red` — which is `color: red` to a browser — is missed. Nothing we ship is
written that way; CORE-2885 tracks it along with the rest of the lexing work.

The check has its own tests, so what is claimed above is pinned rather than asserted.

The stylesheets migrated before the tokens existed are listed in `PENDING_SWEEP` in
`tokens.spec.ts` and are exempt from the duplicate-literal check until they are swept —
from that check only. A color the theme does not have is still refused in those files, so
the list defers work already owed rather than opening a gap. The list is asserted to be
exactly the set of files that still carry duplicates, so it cannot drift: you cannot exempt
a clean file, and you cannot sweep a file without removing it from the list. Do not add to
it — new stylesheets are expected to use the tokens from the start.

Adding a genuinely new color is therefore a deliberate act: put it in `palette.ts` if it is
part of the design, or in the allowlist with a reason if it is a one-off we are keeping.

Breakpoints are the known gap — `@media (min-width: var(--x))` is not valid CSS, so
breakpoint values are still repeated in media queries and are not covered by the tests.

## Testing Across Projects

To test changes before tagging:
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,11 @@
"import": "./dist/esm/index.js",
"require": "./dist/cjs/index.js"
},
"./*.css": {
"browser": "./dist/esm/*.css",
"import": "./dist/esm/*.css",
"require": "./dist/cjs/*.css"
},
"./*": {
"browser": "./dist/esm/*.js",
"import": "./dist/esm/*.js",
Expand All @@ -40,6 +45,7 @@
],
"scripts": {
"build": "./scripts/build.bash",
"generate:theme-css": "./scripts/generate-theme-css.bash",
"clean": "rm -rf ./dist",
"build:clean": "npm-run-all clean build",
"dist": "npm-run-all clean build",
Expand Down
5 changes: 5 additions & 0 deletions scripts/build.bash
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@ project_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd

cd "$project_dir"

# theme.css is generated from the JS theme, so regenerate before anything copies it.
# Committed too, because jest and ladle read src/ directly and never run this script;
# src/theme/tokens.spec.ts fails when the committed file is stale.
./scripts/generate-theme-css.bash

tsc_args=(--noEmit false --declaration)

npx tsc --project tsconfig.without-specs.esm.json "${tsc_args[@]}"
Expand Down
26 changes: 26 additions & 0 deletions scripts/generate-theme-css.bash
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# spell-checker: ignore pipefail outdir
#
# Regenerates src/theme/theme.css from the JavaScript theme. The committed file is checked
# against this output by src/theme/tokens.spec.ts, so CI fails if palette.ts or theme.ts
# changes without this being re-run.
set -euo pipefail; if [ -n "${DEBUG-}" ]; then set -x; fi

project_dir="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." >/dev/null 2>&1 && pwd )"
cd "$project_dir"

# The theme is TypeScript and the repo has no TS runner outside jest, so compile the
# renderer and its (small, pure) import graph to a throwaway directory and run that.
outdir="$(mktemp -d)"
trap 'rm -rf "$outdir"' EXIT

npx tsc src/theme/themeCss.ts \
--outDir "$outdir" --rootDir . \
--module commonjs --target es2019 --moduleResolution node --esModuleInterop --skipLibCheck

node -e "
const fs = require('fs');
const { renderThemeCss } = require('$outdir/src/theme/themeCss');
fs.writeFileSync('src/theme/theme.css', renderThemeCss());
console.log('wrote src/theme/theme.css');
"
8 changes: 4 additions & 4 deletions src/theme/buttons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@ const asButtonStyleSetTypes = <T>(st: { [K in keyof T]: ButtonStyleSet }) => st;
const buttonStyleSets = asButtonStyleSetTypes({
primary: {
background: palette.orange,
backgroundActive: "#b03808",
backgroundHover: "#be3c08",
backgroundActive: palette.orangeActive,
backgroundHover: palette.orangeHover,
color: palette.white,
outline: palette.white,
shadow: palette.black,
Expand All @@ -36,8 +36,8 @@ const buttonStyleSets = asButtonStyleSetTypes({
},
secondary: {
background: palette.darkGray,
backgroundActive: "#4c4c4c",
backgroundHover: "#646464",
backgroundActive: palette.darkGrayActive,
backgroundHover: palette.darkGrayHover,
color: palette.white,
outline: palette.white,
shadow: palette.black,
Expand Down
8 changes: 7 additions & 1 deletion src/theme/palette.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ export const palette = {
tangerine: "#ffbd3e",
gray: "#5e5e5e",
darkGray: "#757575",
// button variants
darkGrayHover: "#646464",
darkGrayActive: "#4c4c4c",
pale: "#d5d5d5",
light: "#e4e4e4",
white: "#ffffff",
Expand All @@ -34,5 +37,8 @@ export const palette = {
neutralFeedback: "#555", // another dark gray
neutralDarker: "#424242", // very dark gray
black: "#000000",
orange: "#D4450C"
orange: "#D4450C",
// button variants
orangeHover: "#be3c08",
orangeActive: "#b03808"
} as const;
74 changes: 74 additions & 0 deletions src/theme/theme.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* Global theme tokens.
*
* GENERATED FILE — do not edit by hand. Run `npm run generate:theme-css`.
*
* Projected from the JavaScript theme, which stays the single source of truth:
* src/theme/palette.ts (colors) and src/theme.ts (link colors, z-index, padding).
* src/theme/tokens.spec.ts fails if this file is out of date, so add the value to the
* JS theme and regenerate rather than editing here.
*
* Component CSS should reference these rather than repeating a literal. Where a
* component exposes its own --component-* override hook, use the token as the
* fallback: var(--tabs-border-color, var(--ox-color-pale)).
*
* Imported for side effects by every component whose CSS depends on it, the same
* way component stylesheets are imported. Consumers need do nothing.
*/
:root {
/* palette — src/theme/palette.ts */
--ox-color-red: #ca2026;
--ox-color-danger: #c2002f;
--ox-color-dark-red: #c22032;
--ox-color-light-red: #e298a0;
--ox-color-pale-red: #FBE7EA;
--ox-color-green: #77af42;
--ox-color-light-green: #8bc753;
--ox-color-pale-green: #e0edd3;
--ox-color-dark-green: #63a524;
--ox-color-darker-green: #4e7226;
--ox-color-pale-yellow: #ffffbb;
--ox-color-teal: #0dc0de;
--ox-color-dark-teal: #007297;
--ox-color-blue: #007da4;
--ox-color-medium-blue: #026AA1;
--ox-color-light-blue: #34bdd8;
--ox-color-neutral-light-blue: #0dc0dc;
--ox-color-tangerine: #ffbd3e;
--ox-color-gray: #5e5e5e;
--ox-color-dark-gray: #757575;
--ox-color-dark-gray-hover: #646464;
--ox-color-dark-gray-active: #4c4c4c;
--ox-color-pale: #d5d5d5;
--ox-color-light: #e4e4e4;
--ox-color-white: #ffffff;
--ox-color-neutral-lightest: #f9f9f9;
--ox-color-neutral-cool: #f6f7f8;
--ox-color-neutral-bright: #f5f5f5;
--ox-color-neutral-lighter: #f1f1f1;
--ox-color-neutral-light: #e5e5e5;
--ox-color-neutral-medium: #a0a0a0;
--ox-color-neutral: #818181;
--ox-color-neutral-thin: #6f6f6f;
--ox-color-neutral-dark: #5f6163;
--ox-color-neutral-feedback: #555;
--ox-color-neutral-darker: #424242;
--ox-color-black: #000000;
--ox-color-orange: #D4450C;
--ox-color-orange-hover: #be3c08;
--ox-color-orange-active: #b03808;

/* link colors — src/theme.ts */
--ox-color-link: #026AA1;
--ox-color-link-hover: #005481;

/* z-index scale — src/theme.ts */
--ox-z-index-navbar: 10;
--ox-z-index-sidebar: 20;
--ox-z-index-modals: 30;
--ox-z-index-toasts: 40;

/* padding — src/theme.ts */
--ox-padding-navbar-mobile: 1.6rem;
--ox-padding-navbar-desktop: 3.2rem;
}
69 changes: 69 additions & 0 deletions src/theme/themeCss.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { palette } from "./palette";
import { colors, padding, zIndex } from "../theme";

const kebab = (key: string) => key.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();

type Token = readonly [name: string, value: string];

/**
* The tokens theme.css is made of, grouped the way the file is laid out. This is the
* single description of the projection from the JavaScript theme into CSS custom
* properties: `renderThemeCss` writes it out, and `tokens.spec.ts` checks the committed
* file still matches. Nothing reads the CSS to decide what the tokens are.
*/
export const themeTokenGroups: ReadonlyArray<{ label: string; tokens: Token[] }> = [
{
label: "palette — src/theme/palette.ts",
tokens: Object.entries(palette).map(([key, value]) => [`--ox-color-${kebab(key)}`, value] as const),
},
{
label: "link colors — src/theme.ts",
tokens: [
["--ox-color-link", colors.link.color],
["--ox-color-link-hover", colors.link.hover],
],
},
{
label: "z-index scale — src/theme.ts",
tokens: Object.entries(zIndex).map(([key, value]) => [`--ox-z-index-${kebab(key)}`, String(value)] as const),
},
{
label: "padding — src/theme.ts",
tokens: [
["--ox-padding-navbar-mobile", `${padding.navbar.mobile}rem`],
["--ox-padding-navbar-desktop", `${padding.navbar.desktop}rem`],
],
},
];

/** Every token as a flat name -> value map. */
export const themeTokens = (): Map<string, string> =>
new Map(themeTokenGroups.flatMap((group) => group.tokens));

const header = `/*
* Global theme tokens.
*
* GENERATED FILE — do not edit by hand. Run \`npm run generate:theme-css\`.
*
* Projected from the JavaScript theme, which stays the single source of truth:
* src/theme/palette.ts (colors) and src/theme.ts (link colors, z-index, padding).
* src/theme/tokens.spec.ts fails if this file is out of date, so add the value to the
* JS theme and regenerate rather than editing here.
*
* Component CSS should reference these rather than repeating a literal. Where a
* component exposes its own --component-* override hook, use the token as the
* fallback: var(--tabs-border-color, var(--ox-color-pale)).
*
* Imported for side effects by every component whose CSS depends on it, the same
* way component stylesheets are imported. Consumers need do nothing.
*/`;

export const renderThemeCss = () => {
const body = themeTokenGroups
.map(({ label, tokens }) =>
[` /* ${label} */`, ...tokens.map(([name, value]) => ` ${name}: ${value};`)].join("\n")
)
.join("\n\n");

return `${header}\n:root {\n${body}\n}\n`;
};
Loading
Loading