('shows the proper heading', ({ result }) => {
16 | const { getByText } = result;
17 |
18 | expect(getByText('OddContrast')).toBeInTheDocument();
19 | });
20 | });
21 |
--------------------------------------------------------------------------------
/src/lib/icons/Switch.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
12 |
17 |
18 |
--------------------------------------------------------------------------------
/test/fixtures.ts:
--------------------------------------------------------------------------------
1 | import { type PlainColorObject, serialize } from 'colorjs.io/fn';
2 |
3 | import { ColorSpace } from '$lib/stores';
4 |
5 | export const HSL = ColorSpace.get('hsl');
6 |
7 | export const HSL_WHITE: PlainColorObject = {
8 | space: HSL,
9 | coords: HSL.white,
10 | alpha: 1,
11 | };
12 |
13 | export const HSL_BLACK: PlainColorObject = {
14 | space: HSL,
15 | coords: [0, 0, 0],
16 | alpha: 1,
17 | };
18 |
19 | export const HSL_WHITE_SERIALIZED = serialize(HSL_WHITE, { inGamut: false });
20 |
21 | export const OUT_OF_BOUNDED_GAMUTS: PlainColorObject = {
22 | space: ColorSpace.get('oklch'),
23 | coords: [1, 1, 1],
24 | alpha: 1,
25 | };
26 |
--------------------------------------------------------------------------------
/src/sass/config/color/_ui.scss:
--------------------------------------------------------------------------------
1 | @use '../tools';
2 | @use 'brand';
3 | @use 'sass:color';
4 |
5 | /// ## UI Colors
6 | /// ------------
7 | /// @group color
8 | /// @colors ui-colors
9 |
10 | $text: hsl(230deg 25% 20%);
11 | $bg: white;
12 | $bg-messages: tools.tint(brand.$brand-blue, 94%);
13 | $border: tools.tint($text, 50%);
14 | $border-light: tools.tint(brand.$brand-blue, 65%);
15 | $warning: tools.shade(brand.$brand-pink, 5%);
16 | $action: tools.shade(brand.$brand-pink, 15%);
17 | $active: brand.$brand-blue;
18 | $success: oklab(51.527% -0.099 0.0131);
19 | $action-light: color.adjust(
20 | brand.$brand-blue,
21 | $saturation: -45%,
22 | $lightness: 10%
23 | );
24 |
--------------------------------------------------------------------------------
/.sassdocrc:
--------------------------------------------------------------------------------
1 | src: './src/sass/**/*.scss'
2 | dest: './static/styleguide'
3 | theme: 'herman'
4 | shortcutIcon: './static/favicon.ico'
5 | verbose: true
6 | herman:
7 | extraLinks:
8 | - name: 'Accoutrement'
9 | url: 'https://oddbird.net/accoutrement/'
10 | - name: 'Herman'
11 | url: 'https://oddbird.net/herman/'
12 | displayColors:
13 | - 'hex'
14 | - 'hsl'
15 | customCSS: './static/built/app.css'
16 | sass:
17 | implementation: 'sass-embedded'
18 | jsonFile: './static/built/json.css'
19 | sassOptions:
20 | loadPaths:
21 | - 'src/sass'
22 | use:
23 | - 'config'
24 | display:
25 | access:
26 | - 'public'
27 |
--------------------------------------------------------------------------------
/.dockerignore:
--------------------------------------------------------------------------------
1 | **/*.DS_Store
2 | **/*.cover
3 | **/*.css.map
4 | **/*.js.map
5 | **/*.log
6 | **/*.md
7 | **/*.mo
8 | **/*.orig
9 | **/*.pot
10 | **/*.py[cod]
11 | **/*.rst
12 | **/*.sage.py
13 | **/*.spec
14 | **/*.sql
15 | **/*.swo
16 | **/*.swp
17 | **/*~
18 | **/Dockerfile
19 | .devcontainer/
20 | .dockerignore
21 | .git
22 | .github/
23 | .nvmrc
24 | .svelte-kit/
25 | .vscode/
26 | coverage/
27 | docker-compose.yml
28 | node_modules/
29 | npm-debug.log
30 | static/built/
31 | static/styleguide/
32 |
33 | # Yarn
34 | .pnp.*
35 | .yarn/*
36 | yarn-error.log
37 | !.yarn/patches
38 | !.yarn/plugins
39 | !.yarn/releases
40 | !.yarn/sdks
41 | !.yarn/versions
42 |
43 | !.svelte-kit/tsconfig.json
44 | !README.md
45 |
--------------------------------------------------------------------------------
/test/lib/components/GamutSelect.spec.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/svelte';
2 | import { get } from 'svelte/store';
3 |
4 | import Gamut from '$lib/components/GamutSelect.svelte';
5 | import { gamut, INITIAL_VALUES, reset } from '$lib/stores';
6 |
7 | describe('Space', () => {
8 | afterEach(() => {
9 | reset();
10 | });
11 |
12 | it('renders editable gamut select', async () => {
13 | const { getByLabelText } = render(Gamut);
14 |
15 | expect(get(gamut)).toBe(INITIAL_VALUES.gamut);
16 |
17 | const select = getByLabelText('Show Gamut');
18 | await fireEvent.change(select, { target: { value: 'rec2020' } });
19 |
20 | expect(get(gamut)).toBe('rec2020');
21 | });
22 | });
23 |
--------------------------------------------------------------------------------
/src/routes/+layout.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 | OddContrast
18 |
19 | {#if CONTEXT === 'production'}
20 |
25 | {/if}
26 |
27 |
28 | {@render children()}
29 |
--------------------------------------------------------------------------------
/src/lib/icons/Warning.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
12 |
17 |
18 |
--------------------------------------------------------------------------------
/test/lib/components/Footer.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/svelte';
2 | import MockDate from 'mockdate';
3 |
4 | import Footer from '$lib/components/Footer.svelte';
5 |
6 | describe('Footer', () => {
7 | afterEach(() => {
8 | MockDate.reset();
9 | });
10 |
11 | it('shows the copyright year (current year)', () => {
12 | MockDate.set('2022-04-01');
13 | const { getByText } = render(Footer);
14 |
15 | expect(getByText('© 2022 OddBird.', { exact: false })).toBeVisible();
16 | });
17 |
18 | it('shows the copyright year (range of years)', () => {
19 | MockDate.set('2023-04-01');
20 | const { getByText } = render(Footer);
21 |
22 | expect(getByText('© 2022–2023 OddBird.', { exact: false })).toBeVisible();
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/lib/components/util/ExternalLink.svelte:
--------------------------------------------------------------------------------
1 |
14 |
15 |
16 | {@render children?.()}{#if showNewTabIcon}(opens in a new tab) {/if}
21 |
--------------------------------------------------------------------------------
/src/lib/icons/Check.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
12 |
17 |
18 |
19 |
25 |
--------------------------------------------------------------------------------
/test/lib/components/colors/Formats.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/svelte';
2 |
3 | import Formats from '$lib/components/colors/Formats.svelte';
4 | import { HSL_WHITE } from '$test/fixtures';
5 |
6 | describe('Formats', () => {
7 | it('shows the background header', () => {
8 | const { getByText } = render(Formats, {
9 | type: 'bg',
10 | color: HSL_WHITE,
11 | format: 'hsl',
12 | });
13 |
14 | expect(getByText('Background Color')).toBeVisible();
15 | });
16 |
17 | it('shows the foreground header', () => {
18 | const { getByText } = render(Formats, {
19 | type: 'fg',
20 | color: HSL_WHITE,
21 | format: 'hsl',
22 | });
23 |
24 | expect(getByText('Foreground Color')).toBeVisible();
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/svelte.config.js:
--------------------------------------------------------------------------------
1 | import adapter from '@sveltejs/adapter-auto';
2 | import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
3 |
4 | /** @type {import('@sveltejs/kit').Config} */
5 | const config = {
6 | // Consult https://kit.svelte.dev/docs/integrations#preprocessors
7 | // for more information about preprocessors
8 | preprocess: [vitePreprocess()],
9 |
10 | onwarn: (
11 | /** @type {{ code: string; }} */ warning,
12 | /** @type {(...arg: any[]) => void} */ handler,
13 | ) => {
14 | if (warning.code === 'vite-plugin-svelte-preprocess-many-dependencies') {
15 | return;
16 | }
17 | handler(warning);
18 | },
19 |
20 | kit: {
21 | alias: { $src: 'src', $test: 'test' },
22 | adapter: adapter(),
23 | env: { publicPrefix: '' },
24 | },
25 | };
26 |
27 | export default config;
28 |
--------------------------------------------------------------------------------
/test/lib/components/SpaceSelect.spec.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/svelte';
2 | import { get } from 'svelte/store';
3 |
4 | import Space from '$lib/components/SpaceSelect.svelte';
5 | import { bg, fg, INITIAL_VALUES, reset } from '$lib/stores';
6 |
7 | describe('Space', () => {
8 | afterEach(() => {
9 | reset();
10 | });
11 |
12 | it('renders editable space select', async () => {
13 | const { getByLabelText } = render(Space);
14 |
15 | expect(get(bg).space.id).toBe(INITIAL_VALUES.format);
16 | expect(get(fg).space.id).toBe(INITIAL_VALUES.format);
17 |
18 | const select = getByLabelText('Color Format');
19 | await fireEvent.change(select, { target: { value: 'hsl' } });
20 |
21 | expect(get(bg).space.id).toBe('hsl');
22 | expect(get(fg).space.id).toBe('hsl');
23 | });
24 | });
25 |
--------------------------------------------------------------------------------
/src/lib/components/Header.svelte:
--------------------------------------------------------------------------------
1 |
6 |
7 |
8 |
9 |
10 | OddContrast
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
32 |
--------------------------------------------------------------------------------
/src/sass/config/_focus.scss:
--------------------------------------------------------------------------------
1 | /// # Focus Styling
2 | /// @group focus
3 |
4 | /// Consistent focus ring
5 | /// is applied by default on element-focus,
6 | /// but some patterns (like radio-buttons) also require
7 | /// focus-ring applied to a sibling or parent element.
8 | /// @example html
9 | ///
10 | ///
11 | /// focus-ring example
12 | ///
13 | ///
14 | /// @group focus
15 | @mixin focus-ring() {
16 | /* stylelint-disable declaration-block-no-redundant-longhand-properties */
17 | outline-color: var(--focus-ring, currentColor);
18 | outline-offset: var(--outline-offset, 0);
19 | outline-style: var(--outline-style, solid);
20 | outline-width: var(--outline-width, var(--border-width-md));
21 | /* stylelint-enable declaration-block-no-redundant-longhand-properties */
22 | }
23 |
--------------------------------------------------------------------------------
/src/lib/components/Footer.svelte:
--------------------------------------------------------------------------------
1 |
9 |
10 |
11 |
12 | © {start}{year > start ? `–${year}` : ''} OddBird. Built with Color.js .
15 |
16 |
17 | {#each SOCIAL_LINKS as { name, icon, href } (icon)}
18 |
19 |
20 |
21 | {name}
23 |
24 | {/each}
25 |
26 |
27 |
--------------------------------------------------------------------------------
/src/sass/patterns/_a11y.scss:
--------------------------------------------------------------------------------
1 | @use '../config';
2 |
3 | /// # Accessibility
4 | /// Helpers and utilities formaking the site more accessible.
5 | /// @group a11y
6 |
7 | // Hidden Everywhere
8 | // -----------------
9 | /// The `hidden` html attribute
10 | /// hides content from both display and screen readers.
11 | /// @group a11y
12 | [hidden] {
13 | display: none !important; /* stylelint-disable-line declaration-no-important */
14 | }
15 |
16 | // Screen Reader Only
17 | // ------------------
18 | /// A class to hide content visually,
19 | /// while remaining visible to screen readers.
20 | /// Interactive elements will also become visble
21 | /// when focused or active.
22 | /// @group a11y
23 | .sr-only {
24 | &:not(:focus, :active) {
25 | @include config.is-hidden;
26 | }
27 | }
28 |
29 | .small-only {
30 | @include config.above('sm-page-break') {
31 | @include config.is-hidden;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/test/lib/components/colors/Output.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/svelte';
2 | import { serialize, to } from 'colorjs.io/fn';
3 |
4 | import Output from '$lib/components/colors/Output.svelte';
5 | import { HSL_WHITE, HSL_WHITE_SERIALIZED } from '$test/fixtures';
6 |
7 | describe('Output', () => {
8 | it('renders color in selected format', () => {
9 | const { getByText } = render(Output, {
10 | type: 'bg',
11 | color: HSL_WHITE,
12 | format: 'hsl',
13 | });
14 |
15 | expect(getByText(HSL_WHITE_SERIALIZED)).toBeVisible();
16 | });
17 |
18 | it('renders color in other format', () => {
19 | const { getByText } = render(Output, {
20 | type: 'bg',
21 | color: HSL_WHITE,
22 | format: 'oklch',
23 | });
24 |
25 | expect(
26 | getByText(serialize(to(HSL_WHITE, 'oklch'), { inGamut: false })),
27 | ).toBeVisible();
28 | });
29 | });
30 |
--------------------------------------------------------------------------------
/src/lib/components/util/CopyButton.svelte:
--------------------------------------------------------------------------------
1 |
23 |
24 |
25 | {#if !justCopied}
26 |
27 | Click to copy
28 | {:else}
29 |
30 | Copied
31 | {/if}
32 |
33 |
--------------------------------------------------------------------------------
/test/lib/components/colors/Sliders.spec.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/svelte';
2 | import { get, writable } from 'svelte/store';
3 |
4 | import Sliders from '$lib/components/colors/Sliders.svelte';
5 | import { HSL_WHITE } from '$test/fixtures';
6 |
7 | describe('Sliders', () => {
8 | it('renders editable sliders', async () => {
9 | const color = writable(HSL_WHITE);
10 | const { getByLabelText } = render(Sliders, {
11 | type: 'bg',
12 | color,
13 | format: 'hsl',
14 | });
15 | const sliders = {
16 | h: getByLabelText('Hue'),
17 | s: getByLabelText('Saturation'),
18 | l: getByLabelText('Lightness'),
19 | };
20 | await fireEvent.input(sliders.h, { target: { value: '1' } });
21 | await fireEvent.input(sliders.s, { target: { value: '2' } });
22 | await fireEvent.input(sliders.l, { target: { value: '3' } });
23 |
24 | expect(get(color).coords).toEqual([1, 2, 3]);
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/lib/icons/Twitter.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/src/lib/icons/NewTab.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
12 |
17 |
22 |
23 |
24 |
29 |
--------------------------------------------------------------------------------
/src/lib/icons/Clipboard.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
10 |
14 |
15 |
--------------------------------------------------------------------------------
/src/sass/patterns/_lists.scss:
--------------------------------------------------------------------------------
1 | ul {
2 | list-style: none;
3 | margin: 0;
4 | padding-left: 0;
5 | }
6 |
7 | li {
8 | padding-bottom: var(--li-padding-bottom, var(--gutter));
9 | }
10 |
11 | [data-list='inline'] {
12 | align-items: center;
13 | display: flex;
14 | gap: var(--shim);
15 | }
16 |
17 | dl {
18 | display: grid;
19 | grid-template-columns: auto 1fr;
20 | margin-block: var(--half-shim) var(--double-gutter);
21 |
22 | &:last-child {
23 | margin-block-end: 0;
24 | }
25 | }
26 |
27 | dd {
28 | margin-inline-start: var(--description-margin-inline, var(--gutter));
29 | }
30 |
31 | [data-list-item-heading] {
32 | font-size: var(--small);
33 | font-weight: bold;
34 | transition:
35 | color var(--fast),
36 | opacity var(--fast);
37 | }
38 |
39 | [data-list-item-heading~='target'] {
40 | --icon-size: var(--small);
41 |
42 | display: flex;
43 | gap: var(--shim);
44 |
45 | [data-icon] {
46 | margin-block-start: var(--quarter-shim);
47 | opacity: var(--target-icon-opacity, 0);
48 | }
49 | }
50 |
--------------------------------------------------------------------------------
/.github/dependabot.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 |
3 | updates:
4 | - package-ecosystem: github-actions
5 | directory: '/'
6 | schedule:
7 | interval: weekly
8 | time: '04:00'
9 | timezone: America/New_York
10 |
11 | - package-ecosystem: npm
12 | directory: '/'
13 | versioning-strategy: increase
14 | schedule:
15 | interval: weekly
16 | time: '04:00'
17 | timezone: America/New_York
18 | groups:
19 | prod-major:
20 | dependency-type: production
21 | update-types:
22 | - 'major'
23 | prod-minor:
24 | dependency-type: production
25 | update-types:
26 | - 'minor'
27 | - 'patch'
28 | dev-major:
29 | dependency-type: development
30 | update-types:
31 | - 'major'
32 | dev-minor:
33 | dependency-type: development
34 | update-types:
35 | - 'minor'
36 | - 'patch'
37 | ignore:
38 | - dependency-name: '@types/node'
39 | update-types:
40 | - 'version-update:semver-major'
41 |
--------------------------------------------------------------------------------
/src/lib/icons/Copy.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
11 |
15 |
19 |
20 |
--------------------------------------------------------------------------------
/static/favicon.svg:
--------------------------------------------------------------------------------
1 |
2 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/test/lib/components/Ratio.spec.ts:
--------------------------------------------------------------------------------
1 | import { render, waitFor } from '@testing-library/svelte';
2 | import { tick } from 'svelte';
3 |
4 | import Ratio from '$lib/components/ratio/index.svelte';
5 | import { fg, reset } from '$lib/stores';
6 |
7 | describe('Ratio', () => {
8 | afterEach(() => {
9 | reset();
10 | });
11 |
12 | it('displays passing/failing ratio', () => {
13 | const { getByText, queryAllByText, queryByText } = render(Ratio);
14 |
15 | expect(getByText('7.09:1')).toBeVisible();
16 | expect(queryAllByText('Pass')).not.toBeNull();
17 | expect(queryByText('Fail')).toBeNull();
18 | });
19 |
20 | it('updates ratio when color changes', async () => {
21 | const { getByText, queryByText, queryAllByText } = render(Ratio);
22 | fg.update((val) => {
23 | val.coords = [0.5, 0.5, 0.5];
24 | return val;
25 | });
26 | await tick();
27 |
28 | await waitFor(() => {
29 | expect(getByText('3.22:1')).toBeVisible();
30 | expect(queryByText('Pass')).not.toBeNull();
31 | expect(queryAllByText('Fail')).not.toBeNull();
32 | });
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/src/lib/icons/GitHub.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
11 |
12 |
13 |
19 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { fileURLToPath, URL } from 'node:url';
2 |
3 | import { sveltekit } from '@sveltejs/kit/vite';
4 | import { svelteTesting } from '@testing-library/svelte/vite';
5 | import { NodePackageImporter } from 'sass-embedded';
6 | import { defineConfig } from 'vitest/config';
7 |
8 | export default defineConfig({
9 | plugins: [sveltekit(), svelteTesting()],
10 | css: {
11 | preprocessorOptions: {
12 | scss: {
13 | loadPaths: [fileURLToPath(new URL('./src/sass/', import.meta.url))],
14 | importers: [new NodePackageImporter()],
15 | },
16 | },
17 | },
18 | test: {
19 | include: ['./test/**/*.{test,spec}.{js,mjs,cjs,ts,mts,cts,jsx,tsx}'],
20 | globals: true,
21 | environment: 'jsdom',
22 | watch: false,
23 | setupFiles: './test/setup.ts',
24 | clearMocks: true,
25 | reporters: 'dot',
26 | coverage: {
27 | enabled: true,
28 | reporter: ['text-summary', 'html'],
29 | include: ['src/**/*.{js,ts,svelte}'],
30 | exclude: [
31 | 'src/**/*.d.ts',
32 | 'src/routes/styleguide/**/*',
33 | 'src/routes/+layout.*',
34 | ],
35 | skipFull: true,
36 | reportOnFailure: true,
37 | },
38 | },
39 | });
40 |
--------------------------------------------------------------------------------
/src/sass/config/animation/_index.scss:
--------------------------------------------------------------------------------
1 | @use 'pkg:sassdoc-theme-herman' as herman;
2 | @use '../tools';
3 | @use 'easing';
4 | @use 'times';
5 | @use 'sass:meta';
6 | @forward 'easing';
7 | @forward 'times';
8 |
9 | /// # Animation Config
10 | /// Accoutrement maps for storing global animation tokens.
11 | /// @link https://www.oddbird.net/accoutrement/docs/animate.html
12 | /// Accoutrement Animate
13 | /// @group animation
14 |
15 | /// ## Easing
16 | /// ---------
17 | /// Named easings that can be re-used to create consistent movement.
18 | /// @group animation
19 | /// @example scss
20 | /// @use 'config/animation/easing';
21 | /// @use 'config/tools';
22 | /// @use 'sass:meta';
23 | ///
24 | /// @each $name, $easing in tools.compile-easing(meta.module-variables('easing')) {
25 | /// /* #{$name}: #{$easing}; */
26 | /// }
27 |
28 | /// ## Times
29 | /// ---------
30 | /// Named times that can be re-used to create consistent motion timing.
31 | /// @group animation
32 | /// @example scss
33 | /// @use 'config/animation/times';
34 | /// @use 'config/tools';
35 | /// @use 'sass:meta';
36 | ///
37 | /// @each $name, $time in tools.compile-times(meta.module-variables('times')) {
38 | /// /* #{$name}: #{$time}; */
39 | /// }
40 |
--------------------------------------------------------------------------------
/src/lib/icons/OddBird.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
9 |
10 |
11 |
17 |
--------------------------------------------------------------------------------
/src/lib/components/SpaceSelect.svelte:
--------------------------------------------------------------------------------
1 |
25 |
26 |
27 | Color Format
28 |
29 | {#each spaces as space (space.id)}
30 | {#if space}
31 | {space.name}
32 | {/if}
33 | {/each}
34 |
35 |
36 |
--------------------------------------------------------------------------------
/src/sass/initial/_links.scss:
--------------------------------------------------------------------------------
1 | @use '../config';
2 |
3 | /// # Link Defaults
4 | /// Initial global defaults for links
5 | /// @group links
6 |
7 | // Hide default browser focus for mouse-users but interactive elements will
8 | // have custom focus styles applied
9 | :focus:not(:focus-visible) {
10 | outline: none;
11 | }
12 |
13 | // Focus
14 | // -----
15 | /// Show focus with keyboard navigation using focus-visible
16 | /// @group links
17 | :focus-visible {
18 | @include config.focus-ring;
19 | }
20 |
21 | // Links
22 | // -----
23 | /// Basic link (and link-states) apply action and interaction text colors.
24 | /// Text underlines are also applied
25 | /// @group links
26 | /// @example html
27 | /// This text contains a link
28 | a {
29 | &:link,
30 | &:visited {
31 | color: var(--link, var(--action));
32 | text-decoration: solid underline;
33 | text-decoration-thickness: var(--line-thickness, var(--border-width));
34 | transition:
35 | color var(--fast),
36 | text-decoration-thickness var(--fast) transform var(--fast);
37 | transform: scale(1);
38 | }
39 |
40 | &:hover,
41 | &:focus {
42 | color: var(--link-focus, var(--active));
43 |
44 | --line-thickness: var(--border-width-lg);
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/test/lib/components/colors/FormatGroup.spec.ts:
--------------------------------------------------------------------------------
1 | import { render } from '@testing-library/svelte';
2 |
3 | import FormatGroup from '$lib/components/colors/FormatGroup.svelte';
4 | import { FORMAT_GROUPS } from '$src/lib/constants';
5 | import { HSL_WHITE, OUT_OF_BOUNDED_GAMUTS } from '$test/fixtures';
6 |
7 | describe('FormatGroup', () => {
8 | it('renders selected group', () => {
9 | const FORMAT_GROUP = FORMAT_GROUPS[0]!;
10 | const { getByTestId, getByText } = render(FormatGroup, {
11 | type: 'bg',
12 | color: HSL_WHITE,
13 | formatGroup: FORMAT_GROUP,
14 | });
15 |
16 | expect(getByText(FORMAT_GROUP.name)).toBeVisible();
17 | FORMAT_GROUP.formats.forEach((format) => {
18 | expect(getByTestId(`format-${format}`)).toBeVisible();
19 | });
20 | });
21 |
22 | it('renders warning if out of gamut', () => {
23 | const FORMAT_GROUP = FORMAT_GROUPS[0]!;
24 | const { getByTestId, getByText } = render(FormatGroup, {
25 | type: 'bg',
26 | color: OUT_OF_BOUNDED_GAMUTS,
27 | formatGroup: FORMAT_GROUP,
28 | });
29 |
30 | expect(getByText(FORMAT_GROUP.name)).toBeVisible();
31 | FORMAT_GROUP.formats.forEach((format) => {
32 | expect(getByTestId(`format-${format}`)).toBeVisible();
33 | });
34 | expect(getByText('outside the sRGB gamut.')).toBeVisible();
35 | });
36 | });
37 |
--------------------------------------------------------------------------------
/.svelte-kit/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "paths": {
4 | "$src": [
5 | "../src"
6 | ],
7 | "$src/*": [
8 | "../src/*"
9 | ],
10 | "$test": [
11 | "../test"
12 | ],
13 | "$test/*": [
14 | "../test/*"
15 | ],
16 | "$lib": [
17 | "../src/lib"
18 | ],
19 | "$lib/*": [
20 | "../src/lib/*"
21 | ],
22 | "$app/types": [
23 | "./types/index.d.ts"
24 | ]
25 | },
26 | "rootDirs": [
27 | "..",
28 | "./types"
29 | ],
30 | "verbatimModuleSyntax": true,
31 | "isolatedModules": true,
32 | "lib": [
33 | "esnext",
34 | "DOM",
35 | "DOM.Iterable"
36 | ],
37 | "moduleResolution": "bundler",
38 | "module": "esnext",
39 | "noEmit": true,
40 | "target": "esnext"
41 | },
42 | "include": [
43 | "ambient.d.ts",
44 | "non-ambient.d.ts",
45 | "./types/**/$types.d.ts",
46 | "../vite.config.js",
47 | "../vite.config.ts",
48 | "../src/**/*.js",
49 | "../src/**/*.ts",
50 | "../src/**/*.svelte",
51 | "../tests/**/*.js",
52 | "../tests/**/*.ts",
53 | "../tests/**/*.svelte"
54 | ],
55 | "exclude": [
56 | "../node_modules/**",
57 | "../src/service-worker.js",
58 | "../src/service-worker/**/*.js",
59 | "../src/service-worker.ts",
60 | "../src/service-worker/**/*.ts",
61 | "../src/service-worker.d.ts",
62 | "../src/service-worker/**/*.d.ts"
63 | ]
64 | }
--------------------------------------------------------------------------------
/src/sass/config/_index.scss:
--------------------------------------------------------------------------------
1 | @forward 'tools';
2 | @forward 'animation';
3 | @forward 'color';
4 | @forward 'focus';
5 | @forward 'fonts';
6 | @forward 'scale';
7 | @forward 'utilities';
8 |
9 | // To turn Sass tokens into CSS custom properties:
10 | // - load each color module with `@use`
11 | // - use the `tools.add-*` mixin with the `sass:meta` module to create a
12 | // map of the variables in the imported module
13 | // - in `initial/_root.scss` call the `config.*--()` mixin to create
14 | // custom properties
15 | @use 'tools';
16 | @use 'sass:meta';
17 | @use 'animation/easing';
18 | @use 'animation/times';
19 | @use 'color/brand';
20 | @use 'color/ui' as color-ui;
21 | @use 'scale/ratio';
22 | @use 'scale/layout';
23 | @use 'scale/spacing';
24 | @use 'scale/text';
25 | @use 'scale/ui' as scale-ui;
26 | @include tools.add-colors(meta.module-variables('brand'));
27 | @include tools.add-colors(meta.module-variables('color-ui'));
28 | @include tools.add-sizes(meta.module-variables('ratio'));
29 | @include tools.add-sizes(meta.module-variables('layout'));
30 | @include tools.add-sizes(meta.module-variables('spacing'));
31 | @include tools.add-sizes(meta.module-variables('text'));
32 | @include tools.add-sizes(meta.module-variables('scale-ui'));
33 | @include tools.add-easing(meta.module-variables('easing'));
34 | @include tools.add-times(meta.module-variables('times'));
35 |
--------------------------------------------------------------------------------
/src/lib/components/colors/Output.svelte:
--------------------------------------------------------------------------------
1 |
26 |
27 |
28 |
29 |
30 | {targetColorValue}
31 |
32 |
33 |
34 |
35 |
47 |
--------------------------------------------------------------------------------
/src/lib/components/colors/SupportWarning.svelte:
--------------------------------------------------------------------------------
1 |
28 |
29 | {#if !isSupported}
30 |
31 | {spaceObject.name} is
32 |
35 | not supported by your current browser .
37 |
38 | {/if}
39 |
40 |
45 |
--------------------------------------------------------------------------------
/src/lib/components/util/Icon.svelte:
--------------------------------------------------------------------------------
1 |
41 |
42 |
48 |
--------------------------------------------------------------------------------
/src/lib/components/colors/index.svelte:
--------------------------------------------------------------------------------
1 |
10 |
11 | Check the contrast ratio between two colors
12 |
13 |
14 |
15 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
46 |
--------------------------------------------------------------------------------
/.stylelintrc.yml:
--------------------------------------------------------------------------------
1 | # See https://stylelint.io/user-guide/rules/list
2 | # Also https://github.com/kristerkari/stylelint-scss#list-of-rules
3 |
4 | extends:
5 | - stylelint-config-standard-scss
6 |
7 | rules:
8 | # Default rules:
9 | # - https://github.com/stylelint-scss/stylelint-config-standard-scss/blob/main/index.js
10 | # - https://github.com/stylelint-scss/stylelint-config-recommended-scss/blob/master/index.js
11 | # - https://github.com/stylelint/stylelint-config-standard/blob/main/index.js
12 | # - https://github.com/stylelint/stylelint-config-recommended/blob/main/index.js
13 |
14 | # possible errors (these are all on by default)
15 | no-descending-specificity: null
16 |
17 | # limit language features
18 | color-function-notation: null
19 | color-named: always-where-possible
20 | custom-property-pattern: null
21 | declaration-block-no-redundant-longhand-properties:
22 | - true
23 | - ignoreShorthands:
24 | - grid-template
25 | declaration-no-important: true
26 | function-url-no-scheme-relative: true
27 | number-max-precision: null
28 | selector-class-pattern: null
29 |
30 | # Sass
31 | scss/at-function-pattern:
32 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$'
33 | - message: 'Expected function to be kebab-case'
34 | scss/at-mixin-pattern:
35 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$'
36 | - message: 'Expected mixin to be kebab-case'
37 | scss/at-rule-conditional-no-parentheses: null
38 | scss/dollar-variable-pattern:
39 | - '^([_|-]*[a-z][a-z0-9]*)(-[a-z0-9]+)*-*$'
40 | - message: 'Expected variable to be kebab-case'
41 |
--------------------------------------------------------------------------------
/src/lib/components/colors/Formats.svelte:
--------------------------------------------------------------------------------
1 |
35 |
36 |
37 |
{displayType} Color
38 | {#each otherFormats as formatGroup (formatGroup.name)}
39 |
40 | {/each}
41 |
42 |
43 |
52 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | BSD 3-Clause License
2 |
3 | Copyright (c) 2022-2023, OddBird
4 | All rights reserved.
5 |
6 | Redistribution and use in source and binary forms, with or without
7 | modification, are permitted provided that the following conditions are met:
8 |
9 | 1. Redistributions of source code must retain the above copyright notice, this
10 | list of conditions and the following disclaimer.
11 |
12 | 2. Redistributions in binary form must reproduce the above copyright notice,
13 | this list of conditions and the following disclaimer in the documentation
14 | and/or other materials provided with the distribution.
15 |
16 | 3. Neither the name of the copyright holder nor the names of its
17 | contributors may be used to endorse or promote products derived from
18 | this software without specific prior written permission.
19 |
20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30 |
--------------------------------------------------------------------------------
/src/lib/components/ratio/Result.svelte:
--------------------------------------------------------------------------------
1 |
15 |
16 |
17 |
18 | {#if pass}
19 |
20 | Pass
21 | {:else}
22 |
23 | Fail
24 | {/if}
25 |
26 |
27 | {level}
28 | {type}
29 |
30 |
31 |
32 |
63 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # OddContrast
2 |
3 | _OddBird's color contrast checker for modern CSS color formats_
4 |
5 | [](https://app.netlify.com/sites/oddcontrast/deploys)
6 |
7 | ## Developing
8 |
9 | To develop with local versions of Node/Yarn, first install the frontend
10 | dependencies with `yarn install`, then:
11 |
12 | ```bash
13 | yarn serve
14 |
15 | # or start the server and open the app in a new browser tab
16 | yarn serve --open
17 | ```
18 |
19 | ## Developing (Docker-based)
20 |
21 | If you are using VS Code devcontainers to manage Node/Yarn versions, open the
22 | project folder and run "Re-open in container", then:
23 |
24 | ```bash
25 | yarn serve
26 |
27 | # or start the server and open the app in a new browser tab
28 | yarn serve --open
29 | ```
30 |
31 | ## Building
32 |
33 | To create a production version of your app:
34 |
35 | ```bash
36 | yarn build
37 | ```
38 |
39 | You can preview the production build with `yarn preview`.
40 |
41 | ## Deploying
42 |
43 | The staging site is automatically deployed via Netlify to
44 | every time a commit is made on the `main`
45 | branch.
46 |
47 | ---
48 |
49 | ## Sponsor OddBird's OSS Work
50 |
51 | At OddBird, we love contributing to the languages & tools developers rely on.
52 | We're currently working on polyfills
53 | for new Popover & Anchor Positioning functionality,
54 | as well as CSS specifications for functions, mixins, and responsive typography.
55 | Help us keep this work sustainable
56 | and centered on your needs as a developer!
57 | We display sponsor logos and avatars
58 | on our [website](https://www.oddbird.net/oddcontrast/#open-source-sponsors).
59 |
60 | [Sponsor OddBird's OSS Work](https://opencollective.com/oddbird-open-source)
61 |
--------------------------------------------------------------------------------
/src/sass/config/_fonts.scss:
--------------------------------------------------------------------------------
1 | @use 'tools';
2 | @use 'pkg:sassdoc-theme-herman' as herman;
3 |
4 | /// # Fonts Config
5 | /// Accoutrement maps for storing global font tokens.
6 | /// @link https://www.oddbird.net/accoutrement/docs/type.html
7 | /// Accoutrement Type
8 | /// @group fonts
9 |
10 | // Body Font
11 | // ------------
12 | /// Freight Sans Pro provides a sans-serif option
13 | /// in the same family as our serif typeface.
14 | /// @group fonts
15 | /// @font body (normal, bold)
16 | ///
17 | ///
18 | /// @link https://www.oddbird.net/accoutrement/docs/type.html
19 | /// Accoutrement Type
20 | $body-font: (
21 | 'name': 'freight-sans-pro',
22 | 'stack': (
23 | 'Helvetica Neue',
24 | 'Helvetica',
25 | 'Arial',
26 | 'sans-serif',
27 | ),
28 | 'source': 'https://fonts.adobe.com/fonts/freight-sans',
29 | );
30 |
31 | @include tools.add-font('body', $body-font);
32 | @include herman.add('font', 'body', $body-font);
33 |
34 | // Code Font
35 | // ---------
36 | /// Source Code Pro provides a nice-looking monospace option
37 | /// for code.
38 | /// @group fonts
39 | /// @font code (300, normal)
40 | ///
41 | ///
42 | /// @link https://www.oddbird.net/accoutrement/docs/type.html
43 | /// Accoutrement Type
44 | $code-font: (
45 | 'name': 'source-code-pro',
46 | 'stack': (
47 | 'Consolas',
48 | 'Menlo',
49 | 'Monaco',
50 | 'Courier New',
51 | 'monospace',
52 | ),
53 | 'source': 'https://fonts.adobe.com/fonts/source-code-pro',
54 | );
55 |
56 | @include tools.add-font('code', $code-font);
57 | @include herman.add('font', 'code', $code-font);
58 |
--------------------------------------------------------------------------------
/.vscode/settings.json:
--------------------------------------------------------------------------------
1 | {
2 | "editor.bracketPairColorization.enabled": true,
3 | "editor.codeActionsOnSave": {
4 | "source.fixAll": "explicit"
5 | },
6 | "editor.defaultFormatter": "esbenp.prettier-vscode",
7 | "editor.formatOnSave": true,
8 | "editor.insertSpaces": true,
9 | "editor.rulers": [80],
10 | "editor.tabSize": 2,
11 | "eslint.useFlatConfig": true,
12 | "eslint.validate": ["javascript", "typescript", "svelte"],
13 | "eslint.workingDirectories": [{ "mode": "auto" }],
14 | "files.eol": "\n",
15 | "files.insertFinalNewline": true,
16 | "files.trimFinalNewlines": true,
17 | "files.trimTrailingWhitespace": true,
18 | "prettier.documentSelectors": ["**/*.svg"],
19 | "scss.lint.unknownAtRules": "ignore",
20 | "scss.validate": false,
21 | "stylelint.validate": ["scss"],
22 | "svelte.enable-ts-plugin": true,
23 | "typescript.preferences.quoteStyle": "single",
24 | "typescript.tsdk": "node_modules/typescript/lib",
25 | "[html]": {
26 | "editor.formatOnSave": false
27 | },
28 | "[svelte]": {
29 | "editor.defaultFormatter": "svelte.svelte-vscode"
30 | },
31 | "[scss]": {
32 | "editor.codeActionsOnSave": {
33 | "source.fixAll.stylelint": "explicit"
34 | }
35 | },
36 | "files.exclude": {
37 | "**/.git": true,
38 | "**/.DS_Store": true,
39 | ".coverage": true,
40 | "coverage": true,
41 | ".tags": true,
42 | ".cache": true,
43 | "collected-assets": true,
44 | "staticfiles": true,
45 | "**/*.egg-info": true
46 | },
47 | "search.exclude": {
48 | "**/node_modules": true,
49 | "**/*.css.map": true,
50 | "**/*.js.map": true,
51 | "yarn.lock": true,
52 | "yarn-debug.log": true,
53 | "yarn-error.log": true,
54 | ".svelte-kit": true,
55 | "static/built": true,
56 | "static/styleguide": true,
57 | ".yarn": true
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/src/app.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
18 |
22 |
23 |
24 |
25 |
29 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
44 | %sveltekit.head%
45 |
46 |
47 | %sveltekit.body%
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/lib/components/colors/FormatGroup.svelte:
--------------------------------------------------------------------------------
1 |
27 |
28 |
29 |
30 |
{formatGroup.name}
31 | {#if !isInGamut}
32 | Selected color is outside the {formatGroup.gamutName} gamut.
38 | {/if}
39 |
40 | {#each formatGroup.formats as format (format)}
41 |
42 | {/each}
43 |
44 |
45 |
60 |
--------------------------------------------------------------------------------
/.github/workflows/test.yml:
--------------------------------------------------------------------------------
1 | name: Lint & Test
2 |
3 | on:
4 | push:
5 | pull_request:
6 | types: [reopened]
7 |
8 | concurrency:
9 | group: ${{ github.workflow }}-${{ github.ref }}
10 | cancel-in-progress: true
11 |
12 | jobs:
13 | build:
14 | name: Build
15 | runs-on: ubuntu-latest
16 | steps:
17 | - uses: actions/checkout@v6
18 | - run: corepack enable
19 | - uses: actions/setup-node@v6
20 | with:
21 | node-version-file: .nvmrc
22 | cache: yarn
23 | - name: Build
24 | run: |
25 | yarn install --immutable
26 | yarn build
27 | - name: Upload built files
28 | uses: actions/upload-artifact@v6
29 | with:
30 | name: built
31 | path: .svelte-kit
32 | include-hidden-files: true
33 |
34 | test:
35 | name: Test
36 | runs-on: ubuntu-latest
37 | needs: build
38 | steps:
39 | - uses: actions/checkout@v6
40 | - run: corepack enable
41 | - uses: actions/setup-node@v6
42 | with:
43 | node-version-file: .nvmrc
44 | cache: yarn
45 | - name: Download built files
46 | uses: actions/download-artifact@v7
47 | with:
48 | name: built
49 | path: .svelte-kit
50 | - name: Test
51 | run: |
52 | yarn install --immutable
53 | yarn test
54 |
55 | lint:
56 | name: Lint
57 | runs-on: ubuntu-latest
58 | needs: build
59 | steps:
60 | - uses: actions/checkout@v6
61 | - run: corepack enable
62 | - uses: actions/setup-node@v6
63 | with:
64 | node-version-file: .nvmrc
65 | cache: yarn
66 | - name: Download built files
67 | uses: actions/download-artifact@v7
68 | with:
69 | name: built
70 | path: .svelte-kit
71 | - name: Lint
72 | run: |
73 | yarn install --immutable
74 | yarn lint:ci
75 |
--------------------------------------------------------------------------------
/src/sass/patterns/_buttons.scss:
--------------------------------------------------------------------------------
1 | /// # Button Pattern
2 | /// @group buttons
3 |
4 | @use '../config';
5 |
6 | button {
7 | font-family: inherit;
8 | font-size: inherit;
9 | }
10 |
11 | // Basic Buttons
12 | // -------------
13 | /// @group buttons
14 | /// @example html
15 | /// Base Button
16 | [data-btn] {
17 | appearance: none;
18 | align-items: center;
19 | background-color: var(--btn-bg-color, var(--bg));
20 | border: var(--btn-border-width, var(--border-width, 0)) solid
21 | var(--btn-border-color-active, var(--btn-border-color, var(--text)));
22 | border-radius: var(--border-radius);
23 | color: var(--btn-color, var(--text));
24 | cursor: pointer;
25 | display: inline-flex;
26 | padding: var(--btn-padding-block, var(--half-shim))
27 | var(--btn-padding-inline, var(--gutter));
28 | transition:
29 | color var(--fast),
30 | background-color var(--fast),
31 | transform var(--fast);
32 |
33 | &:hover,
34 | &:focus {
35 | background-color: var(--btn-bg-color-active, var(--text));
36 | color: var(--btn-color-active, var(--action));
37 | }
38 | }
39 |
40 | [data-btn~='icon'] {
41 | --btn-bg-color-active: transparent;
42 | --btn-border-width: 0;
43 | --btn-padding-inline: var(--half-shim);
44 | --btn-color-active: var(--action);
45 | --btn-color: var(--action-light);
46 |
47 | &:focus-visible {
48 | --outline-width: 0;
49 |
50 | transform: scale(1.15);
51 | }
52 | }
53 |
54 | [data-btn~='switch'] {
55 | --icon-size: var(--icon-medium);
56 |
57 | block-size: fit-content;
58 | margin-block-end: var(--spacer);
59 |
60 | @include config.below('sm-page-break') {
61 | [data-icon] {
62 | transform: rotate(90deg);
63 | }
64 | }
65 |
66 | &:hover,
67 | &:focus {
68 | --outline-width: thin;
69 |
70 | transform: var(--transform, rotate(180deg));
71 |
72 | @media (prefers-reduced-motion: reduce) {
73 | --transform: scale(1.1);
74 | }
75 | }
76 | }
77 |
--------------------------------------------------------------------------------
/src/routes/+page.svelte:
--------------------------------------------------------------------------------
1 |
47 |
48 |
52 |
53 |
54 |
55 |
56 |
57 |
58 |
59 |
60 |
--------------------------------------------------------------------------------
/test/lib/components/CopyButton.spec.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, render, waitFor } from '@testing-library/svelte';
2 |
3 | import CopyButton from '$lib/components/util/CopyButton.svelte';
4 |
5 | function getFirstNonCommentChild(node: Node) {
6 | return [...node.childNodes].find((x) => x.nodeType !== Node.COMMENT_NODE);
7 | }
8 |
9 | describe('Copy Button', () => {
10 | beforeEach(() => {
11 | vi.useFakeTimers();
12 | });
13 |
14 | afterEach(() => {
15 | vi.restoreAllMocks();
16 | });
17 |
18 | it('renders a button', () => {
19 | const { getByRole } = render(CopyButton, {
20 | props: { text: 'Copy' },
21 | });
22 | const button = getByRole('button');
23 |
24 | expect(getFirstNonCommentChild(button)).toHaveAttribute(
25 | 'data-icon',
26 | 'clipboard',
27 | );
28 | });
29 |
30 | it('copies content', async () => {
31 | const spy = vi.spyOn(navigator.clipboard, 'writeText');
32 | const { getByRole } = render(CopyButton, {
33 | props: { text: 'Copy' },
34 | });
35 | const button = getByRole('button');
36 |
37 | expect(getFirstNonCommentChild(button)).toHaveAttribute(
38 | 'data-icon',
39 | 'clipboard',
40 | );
41 |
42 | await fireEvent.click(button);
43 |
44 | expect(spy).toHaveBeenCalledExactlyOnceWith('Copy');
45 | });
46 |
47 | it('swaps icons', async () => {
48 | const { getByRole } = render(CopyButton, {
49 | props: { text: 'Copy' },
50 | });
51 | const button = getByRole('button');
52 |
53 | expect(getFirstNonCommentChild(button)).toHaveAttribute(
54 | 'data-icon',
55 | 'clipboard',
56 | );
57 |
58 | await fireEvent.click(button);
59 |
60 | expect(getFirstNonCommentChild(button)).toHaveAttribute(
61 | 'data-icon',
62 | 'copy',
63 | );
64 |
65 | vi.runAllTimers();
66 | await waitFor(() => {
67 | expect(getFirstNonCommentChild(button)).toHaveAttribute(
68 | 'data-icon',
69 | 'clipboard',
70 | );
71 | });
72 | });
73 | });
74 |
--------------------------------------------------------------------------------
/src/lib/stores.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ColorSpace,
3 | HSL,
4 | Lab,
5 | LCH,
6 | OKLab,
7 | OKLCH,
8 | P3,
9 | type PlainColorObject,
10 | REC_2020,
11 | sRGB,
12 | } from 'colorjs.io/fn';
13 | import { get, writable } from 'svelte/store';
14 |
15 | // eslint-disable-next-line import/no-unresolved
16 | import { browser, dev } from '$app/environment';
17 | import type { ColorFormatId, ColorGamutId } from '$lib/constants';
18 |
19 | // Register supported color spaces
20 | ColorSpace.register(HSL);
21 | ColorSpace.register(Lab);
22 | ColorSpace.register(LCH);
23 | ColorSpace.register(OKLab);
24 | ColorSpace.register(OKLCH);
25 | ColorSpace.register(P3);
26 | ColorSpace.register(sRGB);
27 |
28 | // Register necessary default fallback color space
29 | ColorSpace.register(REC_2020);
30 |
31 | export { ColorSpace };
32 |
33 | export const INITIAL_VALUES = {
34 | format: 'p3' as ColorFormatId,
35 | gamut: null as ColorGamutId,
36 | bg_coord: [0.0967, 0.167, 0.4494] as [number, number, number],
37 | fg_coord: [0.951, 0.675, 0.7569] as [number, number, number],
38 | alpha: 1,
39 | };
40 |
41 | const INITIAL_BG = {
42 | space: ColorSpace.get(INITIAL_VALUES.format),
43 | coords: INITIAL_VALUES.bg_coord,
44 | alpha: INITIAL_VALUES.alpha,
45 | };
46 | const INITIAL_FG = {
47 | space: ColorSpace.get(INITIAL_VALUES.format),
48 | coords: INITIAL_VALUES.fg_coord,
49 | alpha: INITIAL_VALUES.alpha,
50 | };
51 |
52 | export const format = writable(INITIAL_VALUES.format);
53 | export const gamut = writable(INITIAL_VALUES.gamut);
54 | export const bg = writable(INITIAL_BG);
55 | export const fg = writable(INITIAL_FG);
56 |
57 | export const reset = () => {
58 | bg.set(INITIAL_BG);
59 | fg.set(INITIAL_FG);
60 | };
61 |
62 | export const switchColors = () => {
63 | const temp = get(bg);
64 | bg.set(get(fg));
65 | fg.set(temp);
66 | };
67 |
68 | /* v8 ignore next 5 -- @preserve */
69 | if (browser && dev) {
70 | bg.subscribe(($bg) => (window.bg = $bg));
71 | fg.subscribe(($fg) => (window.fg = $fg));
72 | window.ColorSpace = ColorSpace;
73 | }
74 |
--------------------------------------------------------------------------------
/src/lib/icons/Mastodon.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
9 |
10 |
--------------------------------------------------------------------------------
/src/lib/constants.ts:
--------------------------------------------------------------------------------
1 | type ColorSpaceId = 'hsl' | 'lab' | 'lch' | 'oklab' | 'oklch' | 'p3' | 'srgb';
2 |
3 | export type ColorFormatId = ColorSpaceId | 'hex';
4 |
5 | export const SLIDERS: Record = {
6 | hex: ['r', 'g', 'b'],
7 | hsl: ['h', 's', 'l'],
8 | lab: ['l', 'a', 'b'],
9 | lch: ['l', 'c', 'h'],
10 | oklab: ['l', 'a', 'b'],
11 | oklch: ['l', 'c', 'h'],
12 | p3: ['r', 'g', 'b'],
13 | srgb: ['r', 'g', 'b'],
14 | };
15 |
16 | export const FORMATS: ColorFormatId[] = [
17 | 'hex',
18 | 'hsl',
19 | 'lab',
20 | 'lch',
21 | 'oklab',
22 | 'oklch',
23 | 'p3',
24 | 'srgb',
25 | ];
26 |
27 | export type ColorGamutId = 'srgb' | 'p3' | 'rec2020' | null;
28 |
29 | export const GAMUTS: { name: string; format: ColorGamutId }[] = [
30 | { name: 'None', format: null },
31 | { name: 'sRGB', format: 'srgb' },
32 | { name: 'P3', format: 'p3' },
33 | { name: 'Rec2020', format: 'rec2020' },
34 | ];
35 | export const GAMUT_IDS = GAMUTS.map((gamut) => gamut.format);
36 |
37 | export interface FormatGroup {
38 | name: string;
39 | formats: ColorFormatId[];
40 | gamutFormat?: ColorFormatId;
41 | gamutName?: string;
42 | }
43 |
44 | export const FORMAT_GROUPS: FormatGroup[] = [
45 | {
46 | name: 'sRGB FORMATS',
47 | formats: ['hex', 'hsl', 'srgb'],
48 | gamutFormat: 'srgb',
49 | gamutName: 'sRGB',
50 | },
51 | { name: 'UNBOUNDED SPACES', formats: ['lab', 'lch', 'oklab', 'oklch'] },
52 | {
53 | name: 'DISPLAY P3 SPACE',
54 | formats: ['p3'],
55 | gamutFormat: 'p3',
56 | gamutName: 'P3',
57 | },
58 | ];
59 |
60 | export const RATIOS = {
61 | AA: {
62 | Normal: 4.5,
63 | Large: 3,
64 | },
65 | AAA: {
66 | Normal: 7,
67 | Large: 4.5,
68 | },
69 | };
70 |
71 | export const SOCIAL_LINKS = [
72 | {
73 | name: 'GitHub',
74 | icon: 'github',
75 | href: 'https://github.com/oddbird/oddcontrast',
76 | },
77 | { name: 'OddBird', icon: 'oddbird', href: 'https://www.oddbird.net/' },
78 | { name: 'Twitter', icon: 'twitter', href: 'https://twitter.com/oddbird' },
79 | {
80 | name: 'LinkedIn',
81 | icon: 'linkedin',
82 | href: 'https://www.linkedin.com/company/oddbird',
83 | },
84 | {
85 | name: 'Mastodon',
86 | icon: 'mastodon',
87 | href: 'https://front-end.social/@OddBird',
88 | },
89 | ];
90 |
--------------------------------------------------------------------------------
/src/sass/patterns/_forms.scss:
--------------------------------------------------------------------------------
1 | @use '../initial/type';
2 |
3 | /// # Form Patterns
4 | /// @group forms
5 |
6 | .label,
7 | label {
8 | @include type.heading;
9 | }
10 |
11 | input,
12 | select {
13 | font-family: inherit;
14 | font-size: inherit;
15 | width: 100%;
16 | }
17 |
18 | input {
19 | border-color: var(--input-border-color, var(--border));
20 | border-width: 0 0 var(--border-width) 0;
21 | font-family: inherit;
22 | font-size: inherit;
23 | padding: var(--shim) 0.25ch;
24 |
25 | &:focus-visible {
26 | box-shadow: 0 3px 2px -2px var(--input-shadow-color, currentColor);
27 | outline: none;
28 | }
29 |
30 | [data-needs-changes~='true'] & {
31 | --input-border-color: var(--warning);
32 | --input-shadow-color: var(--warning);
33 | }
34 | }
35 |
36 | select {
37 | border: 0;
38 | box-shadow: 1px 1px var(--half-shim) var(--border-light);
39 | padding: var(--half-shim) var(--shim);
40 | }
41 |
42 | // ## Range Thumbs
43 | // ---------------
44 | /// For some reason you have to style the webkit and moz ranges separately.
45 | /// This is a mixin to keep the code dry
46 | /// @link https://codepen.io/stacy/pen/VwVEOea??editors=0100
47 | /// @group utilities
48 | @mixin range-thumb {
49 | background-color: var(--text);
50 | border: var(--border-width) solid var(--bg);
51 | border-radius: var(--range-thumb-size);
52 | cursor: pointer;
53 | height: var(--range-thumb-size);
54 | outline: var(--thumb-outline-color, transparent) solid var(--border-width-md);
55 | transition: outline var(--fast);
56 | width: var(--range-thumb-size);
57 | }
58 |
59 | @mixin range-thumb-focus {
60 | --thumb-outline-color: var(--action);
61 | }
62 |
63 | input[type='range'] {
64 | border: var(--border-width) solid var(--border);
65 | border-radius: var(--range-input);
66 | box-shadow: 1px 1px var(--border-width-md) 0 var(--border-light);
67 | height: var(--range-input);
68 |
69 | &:focus {
70 | outline: none;
71 | }
72 |
73 | // Range "Thumb"
74 | &::-moz-range-thumb {
75 | @include range-thumb;
76 | }
77 |
78 | &:active::-moz-range-thumb,
79 | &:focus::-moz-range-thumb {
80 | @include range-thumb-focus;
81 | }
82 |
83 | // Chrome, Safari, Opera, and Edge Chromium
84 | &::-webkit-slider-thumb {
85 | @include range-thumb;
86 |
87 | appearance: none;
88 | }
89 |
90 | &:active::-webkit-slider-thumb,
91 | &:focus::-webkit-slider-thumb {
92 | @include range-thumb-focus;
93 | }
94 | }
95 |
--------------------------------------------------------------------------------
/src/sass/initial/_layout.scss:
--------------------------------------------------------------------------------
1 | @use '../config';
2 |
3 | html,
4 | body {
5 | margin: 0;
6 | min-height: 100vh;
7 | overflow-x: hidden;
8 | }
9 |
10 | *,
11 | ::before,
12 | ::after {
13 | box-sizing: border-box;
14 | }
15 |
16 | [data-layout~='app'] {
17 | display: grid;
18 | grid-template:
19 | 'header' auto
20 | 'results' auto
21 | 'main' 1fr
22 | 'footer' auto / 100%;
23 | min-height: 100vh;
24 |
25 | @include config.above('lg-page-break') {
26 | grid-template:
27 | 'header results' auto
28 | 'main results' 1fr
29 | 'footer results' auto / 1fr minmax(300px, 30%);
30 | }
31 |
32 | // padding only for the direct children of app
33 | > [data-layout] {
34 | padding: var(--layout-pad-block, var(--gutter-plus))
35 | var(--layout-pad-inline, var(--double-gutter));
36 | }
37 | }
38 |
39 | [data-layout~='header'] {
40 | display: grid;
41 | gap: var(--shim) var(--double-gutter);
42 | grid-area: header;
43 | grid-template: 'logo' auto 'settings' auto / 1fr;
44 |
45 | @include config.above('sm-page-break') {
46 | --justify-settings: safe end;
47 |
48 | gap: var(--double-gutter);
49 | grid-template: 'logo settings' auto / auto 1fr;
50 | }
51 | }
52 |
53 | [data-layout~='results'] {
54 | grid-area: results;
55 | container: results / inline-size;
56 | }
57 |
58 | [data-layout~='main'] {
59 | --layout-pad-block: 0;
60 | --layout-pad-inline: 0; // allows warning to stretch to the edge
61 |
62 | grid-area: main;
63 |
64 | > * {
65 | padding-inline: var(--double-gutter);
66 | }
67 | }
68 |
69 | [data-layout~='footer'] {
70 | display: grid;
71 | gap: var(--gutter-plus);
72 | grid-area: footer;
73 | grid-template-columns: auto 1fr;
74 | }
75 |
76 | [data-layout~='color-formats'] {
77 | display: grid;
78 |
79 | @include config.above('sm-page-break') {
80 | grid-template-columns: 1fr var(--switch-space) 1fr;
81 | }
82 | }
83 |
84 | [data-content='formats'] {
85 | @include config.above('sm-page-break') {
86 | &:last-of-type {
87 | grid-column: 3;
88 | }
89 | }
90 | }
91 |
92 | [data-layout='color-form'] {
93 | @include config.above('sm-page-break') {
94 | display: grid;
95 | grid-template:
96 | 'bginput switch fginput' auto
97 | 'bgslide . fgslide' auto / 1fr var(--switch-space) 1fr;
98 | }
99 | }
100 |
101 | [data-group='header bg'] {
102 | grid-area: bginput;
103 | }
104 |
105 | [data-group='sliders bg'] {
106 | grid-area: bgslide;
107 | }
108 |
109 | [data-group='header fg'] {
110 | grid-area: fginput;
111 | }
112 |
113 | [data-group='sliders fg'] {
114 | grid-area: fgslide;
115 | }
116 |
117 | [data-column~='tool'] {
118 | container: tool / inline-size;
119 | }
120 |
--------------------------------------------------------------------------------
/src/sass/initial/_type.scss:
--------------------------------------------------------------------------------
1 | @use '../config';
2 |
3 | // Selection
4 | // ---------
5 | /// Selected text is inverted.
6 | /// @group type
7 | /// @example html
8 | /// Select this text to preview
9 | ::selection {
10 | background-color: var(--text);
11 | color: var(--bg);
12 | }
13 |
14 | // Font Loading
15 | // ------------
16 | /// Hide the page visually while fonts are loading,
17 | /// to avoid a flash of unstyled text.
18 | /// @group type
19 | .wf-loading {
20 | @include config.is-hidden;
21 | }
22 |
23 | h1,
24 | h2,
25 | h3 {
26 | font-weight: normal;
27 | margin: 0;
28 | }
29 |
30 | [data-heading] {
31 | font-size: var(--heading-size, var(--medium));
32 | }
33 |
34 | [data-heading~='large'] {
35 | --heading-size: var(--large);
36 |
37 | line-height: 1.1;
38 | }
39 |
40 | @mixin heading() {
41 | display: block;
42 | font-size: var(--heading-size, var(--label-size));
43 | letter-spacing: var(--heading-letterspacing, 0.05rem);
44 | margin-block-end: var(--label-margin-block-end, 0);
45 | text-transform: var(--heading-transform, uppercase);
46 | }
47 |
48 | .section-heading {
49 | @include heading;
50 |
51 | @include config.below('sm-page-break') {
52 | --heading-size: var(--medium);
53 | }
54 | }
55 |
56 | p {
57 | margin: 0;
58 | }
59 |
60 | strong {
61 | font-weight: bold;
62 | }
63 |
64 | [data-color-info] {
65 | display: block;
66 | color: var(--color-info-color);
67 | }
68 |
69 | [data-color-info~='warning'] {
70 | --color-info-color: var(--warning);
71 |
72 | background: var(--warning-bg, transparent);
73 | font-size: var(--color-info-size, var(--warning-size));
74 | margin-bottom: var(--warning-margin-bottom);
75 | padding-block: var(--warning-padding-block);
76 | padding-inline: var(--warning-padding-inline);
77 | text-align: var(--warning-align, left);
78 |
79 | main > & {
80 | --warning-bg: var(--bg-messages);
81 | --warning-margin-bottom: var(--double-gutter);
82 | --warning-padding-block: var(--gutter);
83 | --warning-align: center;
84 | }
85 | }
86 |
87 | [data-color-info~='value'],
88 | [data-input='color'] {
89 | font-size: var(--tool-font-size, var(--small));
90 |
91 | // Container widths here were determined by when the longer color values (p3)
92 | // would overflow the container
93 | @container tool (min-width: 15rem) {
94 | --tool-font-size: 5cqi;
95 | }
96 |
97 | @container tool (min-width: 21rem) {
98 | --tool-font-size: var(--input-small);
99 | }
100 |
101 | @container tool (min-width: 25rem) {
102 | --tool-font-size: var(--medium);
103 | }
104 | }
105 |
106 | // Allow input to continue its font-size growth once more
107 | [data-input='color'] {
108 | @container tool (min-width: 30rem) {
109 | --tool-font-size: var(--input-large);
110 | }
111 | }
112 |
--------------------------------------------------------------------------------
/src/sass/patterns/_icons.scss:
--------------------------------------------------------------------------------
1 | // Icon
2 | // ----
3 | /// By default, icons take on the size of surrounding text.
4 | /// @group icons
5 | /// @example html
6 | ///
7 | ///
8 | [data-icon] {
9 | fill: var(--icon-color, currentcolor);
10 | display: inline-block;
11 | height: var(--icon-height, var(--icon-size, var(--icon-size-default)));
12 | overflow: visible;
13 | width: var(--icon-width, var(--icon-size, var(--icon-size-default)));
14 | }
15 |
16 | /// Small Icon
17 | /// @group icons
18 | /// @example html
19 | ///
20 | ///
21 | [data-icon-size='small'] {
22 | --icon-size: var(--icon-small);
23 | }
24 |
25 | /// Medium Icon
26 | /// @group icons
27 | /// @example html
28 | ///
29 | ///
30 | [data-icon-size='medium'] {
31 | --icon-size: var(--icon-medium);
32 | }
33 |
34 | /// Success Icon
35 | /// @group icons
36 | /// @example html
37 | ///
39 | ///
40 | ///
41 | [data-icon-theme='success'] {
42 | animation: grow-in var(--slow) var(--springy);
43 |
44 | --icon-color: var(--success);
45 | }
46 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "oddcontrast",
3 | "title": "OddContrast",
4 | "version": "0.0.1",
5 | "license": "BSD-3-Clause",
6 | "engines": {
7 | "node": "^22"
8 | },
9 | "type": "module",
10 | "scripts": {
11 | "svelte:serve": "vite dev --host 0.0.0.0",
12 | "svelte:build": "vite build",
13 | "serve": "npm-run-all docs -p \"svelte:serve {@}\" watch:docs --",
14 | "build": "run-s docs svelte:build",
15 | "preview": "vite preview",
16 | "check": "svelte-check",
17 | "sync": "svelte-kit sync",
18 | "tsc": "tsc",
19 | "tsc:tests": "tsc -p test/tsconfig.json",
20 | "lint": "run-s prettier sync check lint:js lint:sass tsc tsc:tests",
21 | "lint:js": "yarn lint:js:ci --fix",
22 | "lint:js:ci": "eslint .",
23 | "prettier": "prettier --write .",
24 | "prettier:ci": "prettier --check .",
25 | "lint:sass": "yarn lint:sass:ci --fix",
26 | "lint:ci": "run-s prettier:ci sync check lint:js:ci lint:sass:ci tsc tsc:tests",
27 | "lint:sass:ci": "stylelint '**/*.scss'",
28 | "docs:json": "sass -p node src/sass/json.scss static/built/json.css",
29 | "docs:sass": "sass -p node src/sass/app.scss static/built/app.css",
30 | "docs:compile": "sassdoc 'src/sass/**/*.scss'",
31 | "docs": "run-s docs:sass docs:json docs:compile",
32 | "watch:docs": "chokidar \"src/sass/**/*.scss\" \"./.sassdocrc\" \"./README.md\" -c \"yarn docs\"",
33 | "test": "vitest",
34 | "test:watch": "yarn test --watch"
35 | },
36 | "dependencies": {
37 | "accoutrement": "^4.0.6",
38 | "colorjs.io": "^0.5.2"
39 | },
40 | "devDependencies": {
41 | "@eslint/js": "^9.39.2",
42 | "@sveltejs/adapter-auto": "^7.0.0",
43 | "@sveltejs/kit": "2.49.2",
44 | "@sveltejs/vite-plugin-svelte": "^6.2.1",
45 | "@testing-library/jest-dom": "^6.9.1",
46 | "@testing-library/svelte": "^5.2.9",
47 | "@types/eslint-config-prettier": "^6.11.3",
48 | "@types/lodash": "^4.17.21",
49 | "@types/node": "^22",
50 | "@typescript-eslint/utils": "^8.49.0",
51 | "@vitest/coverage-v8": "^4.0.15",
52 | "@vitest/eslint-plugin": "^1.5.2",
53 | "chokidar-cli": "^3.0.0",
54 | "eslint": "^9.39.2",
55 | "eslint-config-prettier": "^10.1.8",
56 | "eslint-import-resolver-typescript": "^4.4.4",
57 | "eslint-plugin-import": "^2.32.0",
58 | "eslint-plugin-simple-import-sort": "^12.1.1",
59 | "eslint-plugin-svelte": "^3.13.1",
60 | "jsdom": "^27.3.0",
61 | "lodash": "^4.17.21",
62 | "mockdate": "^3.0.5",
63 | "npm-run-all": "^4.1.5",
64 | "postcss": "^8.5.6",
65 | "prettier": "^3.7.4",
66 | "prettier-plugin-svelte": "^3.4.1",
67 | "sass-embedded": "^1.96.0",
68 | "sassdoc": "^2.7.4",
69 | "sassdoc-theme-herman": "^7.0.0",
70 | "stylelint": "^16.26.1",
71 | "stylelint-config-standard-scss": "^16.0.0",
72 | "svelte": "^5.46.0",
73 | "svelte-check": "^4.3.4",
74 | "svelte-eslint-parser": "^1.4.1",
75 | "typescript": "^5.9.3",
76 | "typescript-eslint": "^8.49.0",
77 | "vite": "^7.3.0",
78 | "vitest": "^4.0.15"
79 | },
80 | "resolutions": {
81 | "@types/node": "^22"
82 | },
83 | "packageManager": "yarn@4.12.0"
84 | }
85 |
--------------------------------------------------------------------------------
/eslint.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable import/no-named-as-default-member */
2 |
3 | import js from '@eslint/js';
4 | import tsParser from '@typescript-eslint/parser';
5 | import vitest from '@vitest/eslint-plugin';
6 | import prettier from 'eslint-config-prettier';
7 | import importPlugin from 'eslint-plugin-import';
8 | import simpleImportSort from 'eslint-plugin-simple-import-sort';
9 | import svelte from 'eslint-plugin-svelte';
10 | import globals from 'globals';
11 | import svelteParser from 'svelte-eslint-parser';
12 | import tseslint from 'typescript-eslint';
13 |
14 | import svelteConfig from './svelte.config.js';
15 |
16 | export default [
17 | {
18 | ignores: [
19 | '.git/*',
20 | '.svelte-kit/*',
21 | '.vscode/*',
22 | '.yarn/*',
23 | '.yarnrc.yml',
24 | 'build/*',
25 | 'coverage/*',
26 | 'node_modules/*',
27 | 'package/*',
28 | 'src/js/api/client/*',
29 | 'static/built/*',
30 | 'static/styleguide/*',
31 | 'yarn.lock',
32 | ],
33 | },
34 | js.configs.recommended,
35 | ...tseslint.configs.recommendedTypeChecked,
36 | ...tseslint.configs.stylisticTypeChecked,
37 | importPlugin.flatConfigs.recommended,
38 | importPlugin.flatConfigs.typescript,
39 | ...svelte.configs['flat/recommended'],
40 | ...svelte.configs['flat/prettier'],
41 | prettier,
42 | {
43 | files: ['**/*.{js,mjs,cjs,svelte,ts,cts,mts}'],
44 | languageOptions: {
45 | globals: {
46 | ...globals.node,
47 | ...globals.es2021,
48 | },
49 | parserOptions: {
50 | project: ['tsconfig.json', 'test/tsconfig.json'],
51 | extraFileExtensions: ['.svelte'],
52 | },
53 | },
54 | plugins: { 'simple-import-sort': simpleImportSort },
55 | settings: {
56 | 'import/resolver': {
57 | typescript: {
58 | project: 'tsconfig.json',
59 | },
60 | },
61 | },
62 | rules: {
63 | 'no-warning-comments': ['warn', { terms: ['todo', 'fixme', '@@@'] }],
64 | 'simple-import-sort/imports': 'warn',
65 | 'simple-import-sort/exports': 'warn',
66 | 'import/first': 'warn',
67 | 'import/newline-after-import': 'warn',
68 | 'import/no-duplicates': ['error', { 'prefer-inline': true }],
69 | 'import/order': 'off',
70 | },
71 | },
72 | {
73 | files: ['**/*.svelte', '*.svelte'],
74 | languageOptions: {
75 | parser: svelteParser,
76 | parserOptions: {
77 | parser: tsParser,
78 | svelteConfig,
79 | },
80 | },
81 | },
82 | {
83 | files: ['src/**/*.{js,mjs,cjs,svelte,ts,cts,mts}'],
84 | languageOptions: {
85 | globals: {
86 | ...globals.browser,
87 | ...globals.es2021,
88 | },
89 | },
90 | },
91 | {
92 | files: ['test/**/*.spec.{js,ts}'],
93 | languageOptions: {
94 | globals: {
95 | ...vitest.environments?.env?.globals,
96 | },
97 | },
98 | plugins: {
99 | vitest,
100 | },
101 | rules: {
102 | ...vitest.configs?.recommended?.rules,
103 | '@typescript-eslint/unbound-method': 'off',
104 | },
105 | },
106 | ];
107 |
--------------------------------------------------------------------------------
/test/lib/utils.spec.ts:
--------------------------------------------------------------------------------
1 | // Color spaces setup happens in stores, imported here as side effect
2 | import '$lib/stores';
3 |
4 | import { type PlainColorObject, serialize, to } from 'colorjs.io/fn';
5 |
6 | import type { ColorFormatId, ColorGamutId } from '$lib/constants';
7 | import {
8 | getSpaceFromFormatId,
9 | hashToStoreValues,
10 | storeValuesToHash,
11 | } from '$lib/utils';
12 |
13 | const cases = [
14 | [
15 | 'valid p3',
16 | 'p3__color(display-p3_1_1_1)__color(display-p3_0.1_0.1_0.1)',
17 | 'color(display-p3 1 1 1)',
18 | 'color(display-p3 0.1 0.1 0.1)',
19 | 'p3',
20 | '',
21 | ],
22 | [
23 | 'valid hex',
24 | 'hex__*132b77__*4a0022__srgb',
25 | 'rgb(7.451% 16.863% 46.667%)',
26 | 'rgb(29.02% 0% 13.333%)',
27 | 'hex',
28 | 'srgb',
29 | ],
30 | [
31 | 'oklab with percents',
32 | 'oklab__oklab(73.4~_0.177_0.107)__oklab(73.3~_0.102_0.0016)__p3',
33 | 'oklab(73.4% 0.177 0.107)',
34 | 'oklab(73.3% 0.102 0.0016)',
35 | 'oklab',
36 | 'p3',
37 | ],
38 | [
39 | 'oklab with negative values',
40 | 'oklab__oklab(73.4~_-0.215_-0.215)__oklab(73.3~_-0.298_-0.317)__rec2020',
41 | 'oklab(73.4% -0.215 -0.215)',
42 | 'oklab(73.3% -0.298 -0.317)',
43 | 'oklab',
44 | 'rec2020',
45 | ],
46 | ];
47 | describe('Utils', () => {
48 | describe('hashToStoreValues', () => {
49 | beforeEach(() => {
50 | // eslint-disable-next-line @typescript-eslint/no-empty-function
51 | vi.spyOn(console, 'error').mockImplementation(() => {});
52 | });
53 | test.each([
54 | ['empty', ''],
55 | ['missing colors', 'lab'],
56 | ['missing fg', 'lab__lab(20.069_15.532_-47.05)__'],
57 | [
58 | 'unknown space',
59 | 'xyz__color(xyz-d50_0.2_0.1_0.4)__color(xyz-d50_0.2_0.1_0.4)',
60 | ],
61 | ['invalid bg', 'lab__lab(invalid)__lab(13.296_34.096_1.1789)'],
62 | ['invalid fg', 'lab__lab(13.296_34.096_1.1789)__lab(invalid)'],
63 | ])('%s: %s returns undefined', (_, input) => {
64 | expect(hashToStoreValues(input)).toBeUndefined();
65 | });
66 | test.each(cases)(
67 | '%s: %s returns values',
68 | (_, input, bgExpected, fgExpected, formatExpected, gamutExpected) => {
69 | const { bg, fg, format, gamut } = hashToStoreValues(input) as {
70 | bg: PlainColorObject;
71 | fg: PlainColorObject;
72 | format: ColorFormatId;
73 | gamut: ColorGamutId;
74 | };
75 | expect(format).toBe(formatExpected);
76 | expect(serialize(bg)).toBe(bgExpected);
77 | expect(serialize(fg)).toBe(fgExpected);
78 | expect(gamut ?? '').toBe(gamutExpected);
79 | },
80 | );
81 | });
82 | describe('storeValuesToHash', () => {
83 | test.each(cases)(
84 | '%s: returns %s',
85 | (_, output, bgInput, fgInput, format, gamut) => {
86 | const space = getSpaceFromFormatId(format as ColorFormatId);
87 | const bg = to(bgInput, space);
88 | const fg = to(fgInput, space);
89 | const res = storeValuesToHash(
90 | bg,
91 | fg,
92 | format as ColorFormatId,
93 | gamut as ColorGamutId,
94 | );
95 | expect(res).toBe(output);
96 | },
97 | );
98 | });
99 | });
100 |
--------------------------------------------------------------------------------
/.devcontainer/devcontainer.json:
--------------------------------------------------------------------------------
1 | // See https://aka.ms/vscode-remote/devcontainer.json for format details.
2 | {
3 | "name": "OddContrast",
4 | "dockerComposeFile": ["../docker-compose.yml", "./docker-compose.dev.yml"],
5 | "service": "web",
6 | "workspaceFolder": "/app",
7 | "shutdownAction": "stopCompose",
8 | "customizations": {
9 | "vscode": {
10 | "extensions": [
11 | "csstools.postcss",
12 | "dbaeumer.vscode-eslint",
13 | "esbenp.prettier-vscode",
14 | "formulahendry.auto-rename-tag",
15 | "ms-azuretools.vscode-docker",
16 | "ms-vscode.sublime-keybindings",
17 | "naumovs.color-highlight",
18 | "stkb.rewrap",
19 | "stylelint.vscode-stylelint",
20 | "svelte.svelte-vscode",
21 | "syler.sass-indented",
22 | "tyriar.sort-lines",
23 | "xabikos.javascriptsnippets"
24 | ],
25 | "settings": {
26 | "terminal.integrated.profiles.linux": {
27 | "bash": {
28 | "path": "/bin/bash"
29 | }
30 | },
31 | "terminal.integrated.defaultProfile.linux": "bash",
32 | "editor.bracketPairColorization.enabled": true,
33 | "editor.codeActionsOnSave": {
34 | "source.fixAll": "explicit"
35 | },
36 | "editor.defaultFormatter": "esbenp.prettier-vscode",
37 | "editor.formatOnSave": true,
38 | "editor.insertSpaces": true,
39 | "editor.rulers": [80],
40 | "editor.tabSize": 2,
41 | "eslint.useFlatConfig": true,
42 | "eslint.validate": ["javascript", "typescript", "svelte"],
43 | "eslint.workingDirectories": [{ "mode": "auto" }],
44 | "files.eol": "\n",
45 | "files.insertFinalNewline": true,
46 | "files.trimFinalNewlines": true,
47 | "files.trimTrailingWhitespace": true,
48 | "prettier.documentSelectors": ["**/*.svg"],
49 | "remote.extensionKind": {
50 | "ms-azuretools.vscode-docker": "workspace"
51 | },
52 | "scss.lint.unknownAtRules": "ignore",
53 | "scss.validate": false,
54 | "stylelint.validate": ["scss"],
55 | "svelte.enable-ts-plugin": true,
56 | "typescript.preferences.quoteStyle": "single",
57 | "typescript.tsdk": "node_modules/typescript/lib",
58 | "[html]": {
59 | "editor.formatOnSave": false
60 | },
61 | "[svelte]": {
62 | "editor.defaultFormatter": "svelte.svelte-vscode"
63 | },
64 | "[scss]": {
65 | "editor.codeActionsOnSave": {
66 | "source.fixAll.stylelint": "explicit"
67 | }
68 | },
69 | "files.exclude": {
70 | "**/.git": true,
71 | "**/.DS_Store": true,
72 | ".coverage": true,
73 | "coverage": true,
74 | ".tags": true,
75 | ".cache": true,
76 | "collected-assets": true,
77 | "staticfiles": true,
78 | "**/*.egg-info": true
79 | },
80 | "search.exclude": {
81 | "**/node_modules": true,
82 | "**/*.css.map": true,
83 | "**/*.js.map": true,
84 | "yarn.lock": true,
85 | "yarn-debug.log": true,
86 | "yarn-error.log": true,
87 | ".svelte-kit": true,
88 | "static/built": true,
89 | "static/styleguide": true,
90 | ".yarn": true
91 | }
92 | }
93 | }
94 | }
95 | }
96 |
--------------------------------------------------------------------------------
/src/lib/components/ratio/ColorIssues.svelte:
--------------------------------------------------------------------------------
1 |
5 |
6 |
7 |
8 |
Known Color Issues
9 |
10 |
11 | Gamut Mapping Implementation
12 |
13 | Browsers implemented gamut mapping using clipping, which is fast but
14 | provides inferior results compared to the algorithm defined in the CSS Spec . Until browsers are updated, colors that are out of gamut for your
18 | screen may be displayed very differently than expected.
19 |
20 |
21 |
22 | Checking for Out of Gamut Colors
23 |
24 | The new color features in CSS allow for a much wider range of colors,
25 | many of which cannot be shown on many (or any) screens. When selecting
26 | colors, consider that most users will see these colors on a display that
27 | supports the sRGB or P3 gamut.
28 |
29 | There are two primary ways a color can be out of gamut:
30 |
31 |
32 | Choosing a color in a space with a wider gamut, especially when a
33 | channel is near one of the edges of its range.
34 |
35 |
36 | Specifying a channel value that is outside its range. While the
37 | sliders in this tool set hard boundaries, values outside these
38 | boundaries are still valid, and can be entered in the text input.
39 |
40 |
41 |
42 | When a color is out of gamut for the user's screen, the browser will
43 | adjust the color to be in gamut.
44 |
45 |
46 |
47 |
48 | Background Color Alpha Values
49 |
50 |
51 |
52 | WCAG 2 contrast does not consider alpha values. Because we don't know
53 | what is behind your background color, we can't estimate the contrast. If
54 | the background color is not opaque, the contrast ratio is computed
55 | without background or foreground opacity.
56 |
57 |
58 |
59 |
60 | Foreground Color Alpha Values
61 |
62 |
63 |
64 | WCAG 2 contrast does not consider alpha values, but we can approximate a
65 | ratio by premultiplying a semi-transparent foreground color in the sRGB
66 | space. In practice, the displayed foreground color may vary depending on
67 | the display and browser.
68 |
69 |
70 |
71 |
72 |
73 |
100 |
--------------------------------------------------------------------------------
/test/lib/components/colors/Header.spec.ts:
--------------------------------------------------------------------------------
1 | import { fireEvent, render } from '@testing-library/svelte';
2 | import { HSL } from 'colorjs.io/fn';
3 | import { get, writable } from 'svelte/store';
4 |
5 | import Header from '$lib/components/colors/Header.svelte';
6 | import { HSL_WHITE, HSL_WHITE_SERIALIZED } from '$test/fixtures';
7 |
8 | describe('Header', () => {
9 | it('updates color (but not space) on input', async () => {
10 | const color = writable(HSL_WHITE);
11 | const { getByLabelText } = render(Header, {
12 | type: 'bg',
13 | color,
14 | format: 'hsl',
15 | });
16 | const input = getByLabelText('Background Color');
17 | await fireEvent.focus(input);
18 | await fireEvent.input(input, { target: { value: 'red' } });
19 | await fireEvent.blur(input);
20 | const actual = get(color);
21 |
22 | expect(actual.space).toEqual(HSL);
23 | expect(actual.coords).toEqual([0, 100, 50]);
24 | });
25 |
26 | it('handles hex with a preceding hash', async () => {
27 | const color = writable(HSL_WHITE);
28 | const { getByLabelText } = render(Header, {
29 | type: 'bg',
30 | color,
31 | format: 'hsl',
32 | });
33 | const input = getByLabelText('Background Color');
34 | await fireEvent.focus(input);
35 | await fireEvent.input(input, { target: { value: '#f00' } });
36 | await fireEvent.blur(input);
37 | const actual = get(color);
38 |
39 | expect(actual.space).toEqual(HSL);
40 | expect(actual.coords).toEqual([0, 100, 50]);
41 | });
42 |
43 | it('handles hex without a preceding hash', async () => {
44 | const color = writable(HSL_WHITE);
45 | const { getByLabelText } = render(Header, {
46 | type: 'bg',
47 | color,
48 | format: 'hsl',
49 | });
50 | const input = getByLabelText('Background Color');
51 | await fireEvent.focus(input);
52 | await fireEvent.input(input, { target: { value: 'f00' } });
53 | await fireEvent.blur(input);
54 | const actual = get(color);
55 |
56 | expect(actual.space).toEqual(HSL);
57 | expect(actual.coords).toEqual([0, 100, 50]);
58 | });
59 |
60 | it('shows error on invalid color', async () => {
61 | vi.spyOn(console, 'error').mockImplementation(() => {
62 | /* do nothing */
63 | });
64 | const color = writable(HSL_WHITE);
65 | const { getByText, getByLabelText } = render(Header, {
66 | type: 'fg',
67 | color,
68 | format: 'hsl',
69 | });
70 | const input = getByLabelText('Foreground Color');
71 | await fireEvent.focus(input);
72 | await fireEvent.input(input, { target: { value: 'foo' } });
73 |
74 | expect(getByText('Could not parse input as a valid color.')).toBeVisible();
75 |
76 | await fireEvent.blur(input);
77 | const actual = get(color);
78 |
79 | expect(input).toHaveValue(HSL_WHITE_SERIALIZED);
80 | expect(actual).toEqual(HSL_WHITE);
81 | });
82 |
83 | describe('on enter', () => {
84 | it('blurs input', async () => {
85 | vi.spyOn(console, 'error').mockImplementation(() => {
86 | /* do nothing */
87 | });
88 | const color = writable(HSL_WHITE);
89 | const { getByText, getByLabelText } = render(Header, {
90 | type: 'fg',
91 | color,
92 | format: 'hsl',
93 | });
94 | const input = getByLabelText('Foreground Color');
95 | await fireEvent.focus(input);
96 | await fireEvent.input(input, { target: { value: 'foo' } });
97 |
98 | expect(
99 | getByText('Could not parse input as a valid color.'),
100 | ).toBeVisible();
101 |
102 | vi.spyOn(input, 'blur');
103 | await fireEvent.keyDown(input, { key: 'Enter' });
104 |
105 | expect(input.blur).toHaveBeenCalledTimes(1);
106 | });
107 | });
108 |
109 | describe('on escape', () => {
110 | it('blurs input', async () => {
111 | const color = writable(HSL_WHITE);
112 | const { getByLabelText } = render(Header, {
113 | type: 'fg',
114 | color,
115 | format: 'hsl',
116 | });
117 | const input = getByLabelText('Foreground Color');
118 | await fireEvent.focus(input);
119 | await fireEvent.input(input, { target: { value: 'red' } });
120 |
121 | vi.spyOn(input, 'blur');
122 | await fireEvent.keyDown(input, { key: 'Esc' });
123 | const actual = get(color);
124 |
125 | expect(input.blur).toHaveBeenCalledTimes(1);
126 | expect(actual.space).toEqual(HSL);
127 | expect(actual.coords).toEqual([0, 100, 50]);
128 | });
129 | });
130 | });
131 |
--------------------------------------------------------------------------------
/src/lib/components/colors/Sliders.svelte:
--------------------------------------------------------------------------------
1 |
92 |
93 |
126 |
127 |
150 |
--------------------------------------------------------------------------------
/src/lib/utils.ts:
--------------------------------------------------------------------------------
1 | import {
2 | clone,
3 | display,
4 | inGamut,
5 | type PlainColorObject,
6 | serialize,
7 | set,
8 | steps,
9 | to,
10 | } from 'colorjs.io/fn';
11 |
12 | import {
13 | type ColorFormatId,
14 | type ColorGamutId,
15 | FORMATS,
16 | GAMUT_IDS,
17 | } from '$lib/constants';
18 |
19 | export const getSpaceFromFormatId = (formatId: ColorFormatId) =>
20 | formatId === 'hex' ? 'srgb' : formatId;
21 |
22 | export const alphaSliderGradient = ({ color }: { color: PlainColorObject }) => {
23 | const start = clone(color);
24 | const end = clone(color);
25 | start.alpha = 0;
26 | end.alpha = 1;
27 | const gradientSteps = steps(start, end, {
28 | steps: 2,
29 | });
30 | return gradientSteps.map((c) => display(c)).join(', ');
31 | };
32 |
33 | export const sliderGradient = ({
34 | color,
35 | channel,
36 | range,
37 | gamut,
38 | }: {
39 | color: PlainColorObject;
40 | channel: string;
41 | range: [number, number];
42 | gamut: ColorGamutId;
43 | }) => {
44 | const start = clone(color);
45 | const end = clone(color);
46 |
47 | set(start, channel, range[0]);
48 | start.alpha = 1;
49 | set(end, channel, range[1]);
50 | end.alpha = 1;
51 |
52 | const gradientSteps = steps(start, end, {
53 | steps: 10,
54 | space: color.space,
55 | hue: 'raw',
56 | // Smaller values will take longer, larger will be less precise and
57 | // produce fuzzy edges. This magic number seems to balance that.
58 | maxDeltaE: gamut === null ? undefined : 2,
59 | });
60 |
61 | if (gamut === null) {
62 | return gradientSteps.map((c) => display(c)).join(', ');
63 | }
64 | let wasInGamut = true;
65 | const inGamutSteps: string[] = [];
66 | const stepWidth = 100 / (gradientSteps.length - 1);
67 |
68 | // Create a linear gradient string, mapping gradientSteps to 0%-100% by
69 | // multiplying its index with `stepWidth`.
70 | gradientSteps.forEach((step, index) => {
71 | if (inGamut(step, gamut)) {
72 | if (wasInGamut === false) {
73 | // Coming back into gamut. Add a transparent gradient step for a crisp
74 | // edge.
75 | inGamutSteps.push(`transparent ${stepWidth * index}%`);
76 | }
77 | wasInGamut = true;
78 | inGamutSteps.push(`${display(step)} ${stepWidth * index}%`);
79 | } else if (wasInGamut === true) {
80 | // Leaving gamut. Add a transparent gradient step at the same percent as
81 | // the previous in gamut step for a crisp edge.
82 | inGamutSteps.push(`transparent ${stepWidth * (index - 1)}%`);
83 |
84 | wasInGamut = false;
85 | }
86 | });
87 |
88 | return inGamutSteps.join(', ');
89 | };
90 |
91 | function decodeColor(colorHash: string, format: ColorFormatId) {
92 | colorHash = colorHash.replaceAll('_', ' ');
93 | colorHash = colorHash.replaceAll('~', '%');
94 | colorHash = colorHash.replaceAll('*', '#');
95 | try {
96 | return to(colorHash, getSpaceFromFormatId(format), { inGamut: true });
97 | } catch (error) {
98 | console.error(error);
99 | return;
100 | }
101 | }
102 |
103 | function encodeColor(color: PlainColorObject, format: ColorFormatId) {
104 | let res = serialize(color, { format, inGamut: false });
105 | res = res.replaceAll(' ', '_');
106 | res = res.replaceAll('%', '~');
107 | res = res.replaceAll('#', '*');
108 | return res;
109 | }
110 |
111 | export const hashToStoreValues = (
112 | hash: string,
113 | ): {
114 | bg: PlainColorObject;
115 | fg: PlainColorObject;
116 | format: ColorFormatId;
117 | gamut: ColorGamutId;
118 | } | void => {
119 | if (hash === '') return;
120 | hash = decodeURIComponent(hash);
121 |
122 | const [formatValue, bgValue, fgValue, gamutValue] = hash.split('__') as [
123 | string,
124 | string,
125 | string,
126 | string,
127 | ];
128 | if (!bgValue || !fgValue) return;
129 |
130 | if (!FORMATS.includes(formatValue as ColorFormatId)) return;
131 | const format = formatValue as ColorFormatId;
132 |
133 | const gamut = GAMUT_IDS.includes(gamutValue as ColorGamutId)
134 | ? (gamutValue as ColorGamutId)
135 | : null;
136 |
137 | const bg = decodeColor(bgValue, format);
138 | if (!bg) return;
139 | const fg = decodeColor(fgValue, format);
140 | if (!fg) return;
141 |
142 | return { bg, fg, format, gamut };
143 | };
144 |
145 | export const storeValuesToHash = (
146 | bg: PlainColorObject,
147 | fg: PlainColorObject,
148 | format: ColorFormatId,
149 | gamut: ColorGamutId,
150 | ) => {
151 | const bgParam = encodeColor(bg, format);
152 | const fgParam = encodeColor(fg, format);
153 | const gamutParam = gamut ? `__${gamut}` : '';
154 | return encodeURIComponent(`${format}__${bgParam}__${fgParam}${gamutParam}`);
155 | };
156 |
--------------------------------------------------------------------------------
/src/lib/icons/Logo.svelte:
--------------------------------------------------------------------------------
1 |
4 |
5 |
6 |
7 |
12 |
16 |
21 |
26 |
31 |
36 |
41 |
46 |
51 |
56 |
61 |
66 |
67 |
68 |
69 |
88 |
--------------------------------------------------------------------------------
/src/lib/components/colors/Header.svelte:
--------------------------------------------------------------------------------
1 |
119 |
120 |
126 |
(isDragging = false)}
135 | >
136 | {#if !colorInGamut}
137 |
138 |
139 | Out of {gamutName ? `${gamutName} ` : ''}gamut
140 |
141 | {/if}
142 |
143 |
144 | {displayType} Color
145 |
146 |
157 |
158 | {#if hasError}
159 |
Could not parse input as a valid color.
160 | {/if}
161 |
162 |
163 |
262 |
--------------------------------------------------------------------------------
/src/lib/components/ratio/index.svelte:
--------------------------------------------------------------------------------
1 |
37 |
38 |
39 |
40 |
Current Ratio
41 |
42 |
43 | {displayRatio}:1
44 |
45 | {#if alphaWarning}
46 |
47 |
48 |
49 | {alphaWarning.message}
50 | Learn more
51 |
52 |
53 | {/if}
54 |
55 |
56 | In WCAG 2, contrast is a measure of the difference in perceived brightness
57 | between two colors, expressed as a ratio. Learn more about contrast ratio requirements .
61 |
62 |
63 |
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
AA Contrast Ratio
73 |
74 |
75 | {RATIOS.AA.Normal} : 1
76 | Normal Text
77 |
78 | {RATIOS.AA.Large} : 1
79 | Large Text
80 |
81 |
82 |
AAA Contrast Ratio
83 |
84 | {RATIOS.AAA.Normal} : 1
85 | Normal Text
86 |
87 | {RATIOS.AAA.Large} : 1
88 | Large Text
89 |
90 |
91 |
Large Text Size
92 |
93 | ≥ 24px
94 | Regular Weight
95 |
96 | ≥ 19px
97 | Bold Weight
98 |
99 |
100 |
101 |
102 |
255 |
--------------------------------------------------------------------------------