Skip to content
Merged
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
2 changes: 1 addition & 1 deletion .storybook/assets/example-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Example layout for the Optics design system" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.0/dist/css/optics.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.1/dist/css/optics.min.css" />
<!-- <link rel="stylesheet" href="../src/optics.css" /> Enable this during development to see local changes -->
<script type="module" src="https://esm.sh/invokers-polyfill"></script>
<title>Optics Example Layout</title>
Expand Down
2 changes: 1 addition & 1 deletion .storybook/assets/login-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Example layout for the Optics design system" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.0/dist/css/optics.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.1/dist/css/optics.min.css" />
<!-- <link rel="stylesheet" href="../src/optics.css" /> Enable this during development to see local changes -->
<title>Optics Login Layout</title>
<style>
Expand Down
2 changes: 1 addition & 1 deletion .storybook/assets/spinner-layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Example layout for the Optics design system" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.0/dist/css/optics.min.css" />
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@rolemodel/optics@2.6.1/dist/css/optics.min.css" />
<!-- <link rel="stylesheet" href="../src/optics.css" /> Enable this during development to see local changes -->
<title>Optics Spinner Layout</title>
<style>
Expand Down
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,23 @@ All notable changes to this project will be documented in this file.

This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [2.6.1] - 2026-10-21

### Theme Token Changes

- Add a `--mode` option to the contrast checker by @Jeremy-Walton in https://github.com/RoleModel/optics/pull/371
- `optics-check-contrast` takes `--mode=light` or `--mode=dark` to check only one color mode. Use it if your app forces one mode, such as with `data-theme-mode="light"`. Both modes are still checked by default. See [Contrast Checker](https://docs.optics.rolemodel.design/?path=/docs/tools-contrast-checker--docs)

### Dependencies

- Add a `--mode` option to the contrast checker by @Jeremy-Walton in https://github.com/RoleModel/optics/pull/371
- Includes the vite bump from 8.1.4 to 8.1.6 from https://github.com/RoleModel/optics/pull/369. Development dependencies were updated in the lockfile. It does not affect the shipped CSS.

### Documentation Changes

- Add a `--mode` option to the contrast checker by @Jeremy-Walton in https://github.com/RoleModel/optics/pull/371
- The [Contrast Checker](https://docs.optics.rolemodel.design/?path=/docs/tools-contrast-checker--docs) page lists the `--mode` option and the default for each option.

## [2.6.0] - 2026-10-21

### Theme Token Changes
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@rolemodel/optics",
"version": "2.6.0",
"version": "2.6.1",
"packageManager": "yarn@4.8.1",
"description": "Optics is a css package that provides base styles and components that can be integrated and customized in a variety of projects.",
"main": "dist/css/optics.css",
Expand Down
9 changes: 5 additions & 4 deletions src/stories/Tools/ContrastChecker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -34,10 +34,11 @@ Pass your theme files or your compiled CSS. Your `--op-*` values are applied on

## Options

- `--hsl` or `--oklch`: the build to check against. By default it is detected from your files (`-s` or `hsl()` means HSL, `-c` or `oklch()` means OKLCH).
- `--min=4.5`: the ratio `on` text needs.
- `--alt-min=4.5`: the ratio `on-alt` text needs. Use `--alt-min=3` to allow AA large text.
- `--report`: list every pair and its ratio, without failing.
- `--hsl` or `--oklch`: the build to check against. Default: detected from your files (`-s` or `hsl()` means HSL, `-c` or `oklch()` means OKLCH).
- `--mode=light` or `--mode=dark`: check only one color mode. Use it if your app forces one mode, such as with `data-theme-mode="light"`. Default: both modes.
- `--min`: the ratio `on` text needs. Default: `4.5`.
- `--alt-min`: the ratio `on-alt` text needs. Use `--alt-min=3` to allow AA large text. Default: `4.5`.
- `--report`: list every pair and its ratio, without failing. Default: off, so only failures are listed.

It exits with `1` if any pair fails, and `2` if it can't read your theme.

Expand Down
5 changes: 3 additions & 2 deletions src/tools/check-contrast.cjs
Original file line number Diff line number Diff line change
@@ -1,18 +1,19 @@
#!/usr/bin/env node
/**
* Checks WCAG 2.1 contrast between every Optics scale step and its `on` text
* colors, in light and dark mode, for your theme:
* colors, in light and dark mode (or just one, with --mode), for your theme:
*
* --op-color-primary-plus-five vs --op-color-primary-on-plus-five (needs --min)
* --op-color-primary-plus-five vs --op-color-primary-on-plus-five-alt (needs --alt-min)
*
* Usage:
* optics-check-contrast [files...] [--hsl | --oklch] [--min=4.5] [--alt-min=4.5] [--report]
* optics-check-contrast [files...] [--hsl | --oklch] [--mode=light | --mode=dark] [--min=4.5] [--alt-min=4.5] [--report]
*
* files CSS with your --op-* overrides: theme files, or your compiled CSS bundle.
* They are applied on top of the Optics tokens, the last value of each token wins.
* --hsl Check against the HSL tokens (optics.css). Detected from -s / hsl() in your files.
* --oklch Check against the OKLCH tokens (optics-oklch.css). Detected from -c / oklch().
* --mode Check only light or dark mode. Both are checked by default.
* --report List every pair, and don't fail.
*
* Tokens are read without their selectors, so overrides scoped to a class or
Expand Down
5 changes: 3 additions & 2 deletions src/tools/contrast/contrast-check.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ const ColorPair = require('./color-pair.cjs')
const ContrastResult = require('./contrast-result.cjs')
const ThemedColor = require('./colors/themed-color.cjs')

// Measures every scale step against its `on` and `on-alt` text colors, in light and dark mode.
// Measures every scale step against its `on` and `on-alt` text colors, in light and dark mode, or just --mode.
class ContrastCheck {
#theme
#options
Expand All @@ -15,10 +15,11 @@ class ContrastCheck {

// Yields one result per pair and mode, so a report can print each one as it is measured.
*results() {
const modes = this.#options.mode ? [this.#options.mode] : ThemedColor.MODES
for (const pair of ColorPair.findAll(this.#theme)) {
const background = this.#colorOf(pair.background)
const text = this.#colorOf(pair.text)
for (const mode of ThemedColor.MODES) {
for (const mode of modes) {
const ratio = background.in(mode).contrastWith(text.in(mode))
// NaN < min is false, so an unmeasurable pair would otherwise pass.
if (!Number.isFinite(ratio))
Expand Down
5 changes: 3 additions & 2 deletions src/tools/contrast/contrast-reporter.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,11 @@ class ContrastReporter {
}

finish() {
const { min, altMin, report } = this.#options
const { mode, min, altMin, report } = this.#options
const passed = this.#count - this.#failures.length
const checked = mode ? ` (${mode})` : ''
this.#print(
`Optics ${this.#build.name} contrast: ${passed}/${this.#count} pairs pass (on >= ${min}, on-alt >= ${altMin})`
`Optics ${this.#build.name} contrast${checked}: ${passed}/${this.#count} pairs pass (on >= ${min}, on-alt >= ${altMin})`
)
if (report || !this.#failures.length) return 0

Expand Down
9 changes: 8 additions & 1 deletion src/tools/contrast/tool-options.cjs
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
const CheckError = require('./check-error.cjs')
const ThemedColor = require('./colors/themed-color.cjs')

const DEFAULT_MIN = 4.5
const FLAG = /^--([a-z-]+)(?:=(.*))?$/

// The command-line arguments: optics-check-contrast [files...] [--hsl | --oklch] [--min=4.5] [--alt-min=4.5] [--report]
// The command-line arguments: optics-check-contrast [files...] [--hsl | --oklch] [--mode=light | --mode=dark] [--min=4.5] [--alt-min=4.5] [--report]
class ToolOptions {
static parse(args) {
const flags = {}
Expand All @@ -19,6 +20,7 @@ class ToolOptions {
constructor(files, flags) {
this.files = files
this.buildName = forcedBuildName(flags)
this.mode = mode(flags.mode)
this.min = threshold(flags.min)
this.altMin = threshold(flags['alt-min'])
this.report = Boolean(flags.report)
Expand All @@ -31,6 +33,11 @@ function forcedBuildName(flags) {
return undefined
}

function mode(value) {
if (value === undefined || ThemedColor.MODES.includes(value)) return value
throw new CheckError('--mode must be light or dark')
}

function threshold(value) {
if (value === undefined) return DEFAULT_MIN
// A bare --min (true) or empty --min= ('') would become 1 or 0 with Number(), so require a numeric string.
Expand Down
40 changes: 37 additions & 3 deletions test/contrast/check-contrast.test.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,11 @@ const AMBIGUOUS = 'Could not tell if your theme is HSL or OKLCH. Pass --hsl or -
const onBase = (color, channel = '--op-color-primary-c: 0.1;') =>
`:root { --op-color-primary-on-base: ${color}; ${channel} }`
const HSL_CHANNEL = '--op-color-primary-s: 1%;'
const DARK_ONLY_FAILURE = ':root { --op-color-primary-h: 60; --op-color-primary-c: 0.2; --op-color-primary-l: 0.9; }'

// Each case gives `css` (theme files to pass, as theme-0.css and on), `files` (other files it can @import)
// and `args`, then either the `error` it must stop with (exit 2), or the `exit` code and the lines it must print.
// and `args`, then either the `error` it must stop with (exit 2), or the `exit` code, the lines it must print,
// and the lines it `omits`.
const CASES = [
// Results and flags
{
Expand Down Expand Up @@ -47,10 +49,41 @@ const CASES = [
},
{
name: 'fails a pair only in dark mode',
css: ':root { --op-color-primary-h: 60; --op-color-primary-c: 0.2; --op-color-primary-l: 0.9; }',
css: DARK_ONLY_FAILURE,
exit: 1,
prints: ' --op-color-primary-on-minus-four (dark): 4.40 < 4.5',
},
{
name: 'checks only light mode with --mode=light',
css: DARK_ONLY_FAILURE,
args: ['--mode=light'],
exit: 0,
prints: 'Optics oklch contrast (light): 228/228 pairs pass (on >= 4.5, on-alt >= 4.5)',
},
{
name: 'checks only dark mode with --mode=dark',
css: DARK_ONLY_FAILURE,
args: ['--mode=dark'],
exit: 1,
prints: [
'Optics oklch contrast (dark): 226/228 pairs pass (on >= 4.5, on-alt >= 4.5)',
' --op-color-primary-on-minus-four (dark): 4.40 < 4.5',
],
},
{
name: 'lists only the chosen mode with --mode and --report',
css: OKLCH_THEME,
args: ['--mode=dark', '--report'],
exit: 0,
prints: 'FAIL 4.26 dark --op-color-primary-on-plus-one',
omits: 'pass 5.98 light --op-color-primary-on-base',
},
...['--mode', '--mode=', '--mode=night'].map((flag) => ({
name: `rejects ${flag}`,
css: OKLCH_THEME,
args: [flag],
error: '--mode must be light or dark',
})),
{
name: 'lists every pair with --report, and does not fail',
css: OKLCH_THEME,
Expand Down Expand Up @@ -340,7 +373,7 @@ describe('optics-check-contrast', { concurrency: true }, () => {

after(() => fs.rmSync(tempDir, { recursive: true, force: true }))

CASES.forEach(({ name, css = [], files = {}, args = [], error, exit = 2, prints = [] }, index) => {
CASES.forEach(({ name, css = [], files = {}, args = [], error, exit = 2, prints = [], omits = [] }, index) => {
test(name, () => {
const dir = path.join(tempDir, 'themes', `case-${index}`)
fs.mkdirSync(dir)
Expand All @@ -360,6 +393,7 @@ describe('optics-check-contrast', { concurrency: true }, () => {
assert.strictEqual(status, exit, output)
for (const line of error ? [`optics-check-contrast: ${error}`] : [prints].flat())
assert.ok(lines.includes(line), `Missing line: ${line}\n\n${output}`)
for (const line of [omits].flat()) assert.ok(!lines.includes(line), `Unexpected line: ${line}\n\n${output}`)
})
})
})
Loading
Loading