Skip to content
Open
Show file tree
Hide file tree
Changes from all 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
90 changes: 90 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,96 @@ 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.
Colour 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';
```

Consumers need do nothing — bundlers deduplicate the import, and `sideEffects` in
`package.json` keeps it from being tree-shaken.

### 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 colour 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 colour literal that duplicates a theme value
- a component stylesheet introduces a colour 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

The colour check parses declarations, so it covers every syntax a colour can be written in —
hex, `rgb()`/`hsl()`/`oklch()`/`color()`, and bare named colours wherever they appear,
including inside shorthands and gradient stops. Functions that merely *contain* colours
(`var()`, `color-mix()`, the gradients) are descended into rather than treated as literals,
so building a value out of tokens stays clean. A translucent colour is accepted when 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 — which still refuses a new hue smuggled in through `rgba()`.

The check has its own tests, so the guarantee is a tested one rather than an asserted one.

Adding a genuinely new colour 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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,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');
"
16 changes: 8 additions & 8 deletions src/components/Button.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,8 @@
white-space: nowrap;

/* Variant-specific styles using CSS custom properties with fallback defaults (primary variant) */
background-color: var(--button-bg, #d4450c);
color: var(--button-color, #ffffff);
background-color: var(--button-bg, var(--ox-color-orange));
color: var(--button-color, var(--ox-color-white));
font-weight: var(--button-font-weight, 700);
}

Expand All @@ -37,16 +37,16 @@
}

.button-base:not([disabled]):hover {
background: var(--button-bg-hover, #be3c08);
background: var(--button-bg-hover, var(--ox-color-orange-hover));
}

.button-base:not([disabled]):active {
background: var(--button-bg-active, #b03808);
background: var(--button-bg-active, var(--ox-color-orange-active));
}

.button-base:focus {
outline: solid var(--button-outline, #ffffff);
box-shadow: inset 0 0 0 0.3rem var(--button-shadow, #424242);
outline: solid var(--button-outline, var(--ox-color-white));
box-shadow: inset 0 0 0 0.3rem var(--button-shadow, var(--ox-color-black));
}

/* Plain button - minimal styling */
Expand All @@ -65,12 +65,12 @@
margin: 0;
padding: 0;
background: none;
color: var(--link-color, #026AA1);
color: var(--link-color, var(--ox-color-link));
text-decoration: none;
}

.button-link:hover,
.button-link:focus {
text-decoration: underline;
color: var(--link-hover-color, #005481);
color: var(--link-hover-color, var(--ox-color-link-hover));
}
9 changes: 2 additions & 7 deletions src/components/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import classNames from 'classnames';
import theme from '../theme';
import { getButtonVariantStyles, ButtonVariant } from "../theme/buttons";
import './Button.css';
import '../theme/theme.css';

// Re-export for backwards compatibility
export { applyButtonVariantStyles } from "../theme/buttons";
Expand Down Expand Up @@ -140,18 +141,12 @@ export const ButtonLink = React.forwardRef<HTMLButtonElement, React.ComponentPro
(props, ref) => {
const { className, style, children, ...otherProps } = props;

const linkStyleVars = {
'--link-color': theme.colors.link.color,
'--link-hover-color': theme.colors.link.hover,
...style
} as React.CSSProperties;

return (
<button
{...otherProps}
ref={ref}
className={classNames('button-link', className)}
style={linkStyleVars}
style={style}
>
{children}
</button>
Expand Down
12 changes: 6 additions & 6 deletions src/components/ButtonBar.css
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
overscroll-behavior: contain;
display: flex;
flex-direction: row;
border: 0.1rem solid var(--button-bar-border-color, #d5d5d5);
border: 0.1rem solid var(--button-bar-border-color, var(--ox-color-pale));
border-radius: 0.5rem;
}

Expand All @@ -17,8 +17,8 @@
outline-offset: -0.1rem;
white-space: nowrap;
padding: 0 1.6rem;
background: #fff;
border-right: 0.1rem solid var(--button-bar-border-color, #d5d5d5);
background: var(--ox-color-white);
border-right: 0.1rem solid var(--button-bar-border-color, var(--ox-color-pale));
}

.button-bar > *:hover {
Expand Down Expand Up @@ -59,12 +59,12 @@
}

.button-bar > *[data-selected=true] {
background: var(--button-bar-selected-bg, #e5e5e5);
box-shadow: inset 0 0 0 0.1rem var(--button-bar-border-color, #d5d5d5);
background: var(--button-bar-selected-bg, var(--ox-color-neutral-light));
box-shadow: inset 0 0 0 0.1rem var(--button-bar-border-color, var(--ox-color-pale));
}

.button-bar > *:hover:not([data-selected=true]) {
background: var(--button-bar-hover-bg, #f1f1f1);
background: var(--button-bar-hover-bg, var(--ox-color-neutral-lighter));
}

@media (forced-colors: active) {
Expand Down
7 changes: 4 additions & 3 deletions src/components/ButtonBar.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { render } from "@testing-library/react";
import { ButtonBar } from "./ButtonBar";
import { palette } from "../theme/palette";

describe("ButtonBar", () => {
it("renders", () => {
Expand Down Expand Up @@ -43,8 +42,10 @@ describe("ButtonBar", () => {
const el = container.querySelector("div") as HTMLElement;

expect(el.style.getPropertyValue("--button-bar-border-color")).toBe("hotpink");
// the variables the caller did not override are still bound
expect(el.style.getPropertyValue("--button-bar-selected-bg")).toBe(palette.neutralLight);
// Defaults for the variables the caller did not override live in the stylesheet
// as var(--x, var(--ox-color-*)), so they are deliberately absent from the inline
// style. tokens.spec.ts is what keeps those defaults honest.
expect(el.style.getPropertyValue("--button-bar-selected-bg")).toBe("");
});

it("still accepts ordinary css properties", () => {
Expand Down
11 changes: 2 additions & 9 deletions src/components/ButtonBar.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import React from "react";
import { palette } from "../theme/palette";
import classNames from "classnames";
import { CSSPropertiesWithVariables } from "../types";
import './ButtonBar.css';
import '../theme/theme.css';

type ButtonBarProps = {
size?: "large" | "medium" | "small";
Expand All @@ -16,7 +16,7 @@ export const ButtonBar = ({
size = "medium",
children,
className,
style: customStyle,
style,
...restProps
}: ButtonBarProps) => {
const buttonBarClass = classNames('button-bar', {
Expand All @@ -25,13 +25,6 @@ export const ButtonBar = ({
'button-bar-large': size === 'large',
}, className);

const style: CSSPropertiesWithVariables = {
'--button-bar-border-color': palette.pale,
'--button-bar-selected-bg': palette.neutralLight,
'--button-bar-hover-bg': palette.neutralLighter,
...customStyle,
};

return (
<div className={buttonBarClass} style={style} {...restProps}>
{children}
Expand Down
24 changes: 12 additions & 12 deletions src/components/Checkbox/Checkbox.css
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,15 @@
}

.checkbox-label--disabled {
color: var(--checkbox-disabled-color, #e5e5e5);
color: var(--checkbox-disabled-color, var(--ox-color-neutral-light));
}

/* Checkbox input/selection styles */
.checkbox-input {
appearance: none;
background-color: var(--checkbox-bg-unchecked, #ffffff);
background-color: var(--checkbox-bg-unchecked, var(--ox-color-white));
opacity: var(--checkbox-opacity, 1);
border: var(--checkbox-border-unchecked, 1px solid #6f6f6f);
border: var(--checkbox-border-unchecked, 1px solid var(--ox-color-neutral-thin));
border-radius: 0.2rem;
transform: translateY(-0.075em);
width: var(--checkbox-size, 1.6rem);
Expand All @@ -31,9 +31,9 @@
border-radius: 0.2rem;
width: var(--checkbox-size, 1.6rem);
height: var(--checkbox-size, 1.6rem);
border: var(--checkbox-border-checked, 1px solid #026AA1);
border: var(--checkbox-border-checked, 1px solid var(--ox-color-medium-blue));
transform: scale(0);
background-color: var(--checkbox-bg, #026AA1);
background-color: var(--checkbox-bg, var(--ox-color-medium-blue));
background-image: var(--checkbox-checkmark, none);
background-size: 80%;
background-position: center;
Expand All @@ -47,7 +47,7 @@

.checkbox-input--disabled {
opacity: 0.4;
border: var(--checkbox-disabled-border, 1px solid #d5d5d5);
border: var(--checkbox-disabled-border, 1px solid var(--ox-color-pale));
}

.checkbox-input--disabled:checked::before {
Expand All @@ -57,9 +57,9 @@
/* TreeCheckbox specific styles */
.checkbox-label [data-slot="selection"] {
appearance: none;
background-color: var(--checkbox-bg-unchecked, #ffffff);
background-color: var(--checkbox-bg-unchecked, var(--ox-color-white));
opacity: var(--checkbox-opacity, 1);
border: var(--checkbox-border-unchecked, 1px solid #6f6f6f);
border: var(--checkbox-border-unchecked, 1px solid var(--ox-color-neutral-thin));
border-radius: 0.2rem;
transform: translateY(-0.075em);
width: var(--checkbox-size, 1.6rem);
Expand All @@ -74,9 +74,9 @@
border-radius: 0.2rem;
width: var(--checkbox-size, 1.6rem);
height: var(--checkbox-size, 1.6rem);
border: var(--checkbox-border-checked, 1px solid #026AA1);
border: var(--checkbox-border-checked, 1px solid var(--ox-color-medium-blue));
transform: scale(0);
background-color: var(--checkbox-bg, #026AA1);
background-color: var(--checkbox-bg, var(--ox-color-medium-blue));
background-image: var(--checkbox-checkmark, none);
background-size: 80%;
background-position: center;
Expand All @@ -92,14 +92,14 @@
content: "";
position: relative;
transform: scale(1);
background-color: var(--checkbox-indeterminate-bg, #026AA1);
background-color: var(--checkbox-indeterminate-bg, var(--ox-color-medium-blue));
border: none;
background-image: var(--checkbox-indeterminate-icon, none);
}

.checkbox-label--disabled [data-slot="selection"] {
opacity: 0.4;
border: var(--checkbox-disabled-border, 1px solid #d5d5d5);
border: var(--checkbox-disabled-border, 1px solid var(--ox-color-pale));
}

.checkbox-label--disabled[data-selected] [data-slot="selection"]::before {
Expand Down
Loading
Loading