Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(token lab): editable color tokens #428

Merged
merged 2 commits into from
Jan 28, 2025
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
1 change: 1 addition & 0 deletions packages/tiles/src/SubtleInput/SubtleInput.css
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
outline: none;
padding: 0;
text-indent: var(--tz-space-200);
field-sizing: content;
Copy link
Contributor Author

@lilnasy lilnasy Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixes a layout issue on chromium

Screen.Recording.2025-01-27.093656.mp4


&[type="number"] {
text-align: right;
Expand Down
1 change: 1 addition & 0 deletions packages/token-lab/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"react-dom": "^19.0.0"
},
"dependencies": {
"@radix-ui/react-popover": "^1.1.5",
"@terrazzo/fonts": "workspace:^",
"@terrazzo/icons": "workspace:^",
"@terrazzo/parser": "workspace:^",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,151 @@
.btn {
background: none;
border: 1px solid var(--tz-border-3);
border-radius: 0;
font-size: inherit;
font-weight: inherit;
.container {
display: grid;
grid-auto-flow: column;
width: 28rem;
height: 1.5rem;
gap: 1rem;
grid-template-columns: 3fr 20fr 5fr;
contain: inline-size;
Comment on lines +4 to +7
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most parameters for the layout of the row are centralized here.

place-items: center;
}

.swatch {
background: var(--color);
border: 1px solid oklch(0 0 0 / 20%);
border-radius: 0.25rem;
padding: 0;
width: 100%;
height: 100%;
cursor: pointer;

&:hover {
border-color: var(--tz-color-border-2);
}

&:focus-visible {
outline: 2px solid var(--tz-color-focus);
outline: 2px solid var(--tz-color-base-lime-800);
outline-offset: 2px;
Comment on lines -14 to +26
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This token does not exist. Could there be others leftover from refactors?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes we’ve gone through a few refactors and there are some stragglers. Thanks for fixing!

}
}

.swatch {
background: var(--color);
.popover {
display: grid;
background: var(--tz-color-bg-1);
padding: 1rem;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

image

This should be using an elevated token instead, but I couldn't find an analogue. The color picker is also hardcoded to this token anyway. Elevated tokens seems like a broad effort.

filter: drop-shadow(4px 8px 8px oklch(0.1 0 0 / 0.5));
place-items: center;
border-radius: 1rem;
transition-property: opacity, translate;
transition-duration: 0.2s;
transition-timing-function: ease-in-out;
}

.popover::after {
content: '';
display: block;
background-color: var(--tz-color-bg-1);
height: 1rem;
width: 1rem;
}

.popover[data-side=bottom] {
padding-top: 0; /* the triangle is going to do double duty as the top padding */
@starting-style {
opacity: 0;
translate: 0 2rem;
}
&::after {
Comment on lines +53 to +56
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First starting style rule of the codebase 🎉

grid-row: 1;
translate: 0 -8px;
mask: conic-gradient(
from 135deg at top,
black 90deg,
transparent 91deg,
transparent 359deg,
black 360deg
) top / 100% 50% no-repeat;
}
}

.popover[data-side=top] {
padding-bottom: 0;
@starting-style {
opacity: 0;
translate: 0 -2rem;
}
&::after {
grid-row: 2;
translate: 0 8px;
mask: conic-gradient(
from -45deg at bottom,
black 90deg,
transparent 91deg,
transparent 359deg,
black 360deg
) bottom / 100% 50% no-repeat;
}
}

.channels {
display: grid;
width: 100%;
grid-template-columns: repeat(4, 1fr);
outline: var(--tz-border-3);
border-radius: 0.25rem;
cursor: pointer;
height: 2rem;
width: 3rem;
height: 100%;
contain: inline-size;
}

.colorSpaceDropdown {
font-family: var(--tz-font-mono);
color: var(--tz-color-text-1);
height: 100%;
contain: size paint;
border-radius: initial;
display: grid;
padding: 0 0.5rem;
justify-content: stretch;

& > :nth-child(1) {
grid-area: 1/1;
justify-self: start;
}
& > :nth-child(2) {
grid-area: 1/1;
justify-self: end;
}
& :global(.tz-select-item-inner) {
display: block;
}
& :global(.tz-select-item-icon) {
display: none;
}
&:hover, &:focus-visible {
outline-color: transparent;
background: var(--tz-color-bg-2);
}
}

.colorSpaceDropdownItem {
font-family: var(--tz-font-mono);
}

.channel {
display: inline-block;
padding: 0 0.5rem;
font-family: var(--tz-font-mono);
border-left: var(--tz-border-3);
color: var(--tz-color-text-1);
align-content: center;
}

.alpha {
display: inline-block;
width: 100%;
height: 100%;
padding: 0 0.5rem;
font-family: var(--tz-font-mono);
outline: var(--tz-border-3);
border-radius: 0.25rem;
align-content: center;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
declare const classNames: {
readonly btn: "btn";
readonly container: "container";
readonly swatch: "swatch";
readonly popover: "popover";
readonly channels: "channels";
readonly colorSpaceDropdown: "colorSpaceDropdown";
readonly "tz-select-item-inner": "tz-select-item-inner";
readonly "tz-select-item-icon": "tz-select-item-icon";
readonly colorSpaceDropdownItem: "colorSpaceDropdownItem";
readonly channel: "channel";
readonly alpha: "alpha";
};
export = classNames;
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import * as Popover from '@radix-ui/react-popover';
import { ChevronDown } from '@terrazzo/icons';
import ColorPicker, { COLOR_PICKER_SPACES } from '@terrazzo/react-color-picker';
import { Select, SelectItem } from '@terrazzo/tiles';
import type { ColorValueNormalized } from '@terrazzo/token-tools';
import useColor from '@terrazzo/use-color';
import useColor, { ColorOutput } from '@terrazzo/use-color';
import c from './EditableColorToken.module.css';

export interface EditableColorTokenProps {
Expand All @@ -9,11 +13,67 @@ export interface EditableColorTokenProps {
}

export default function EditableColorToken({ id, mode = '.', value }: EditableColorTokenProps) {
const [color] = useColor(value);
const [color, setColor] = useColor(value);
const channels = normalizedChannels(color);

return (
<button className={c.btn} type='button' aria-label={`Edit ${id}`}>
<div className={c.swatch} style={{ '--color': color.css }} />
</button>
);
return <div className={c.container}>
<Popover.Root>
<Popover.Trigger className={c.swatch} aria-label={`Edit ${id}`} style={{ '--color': color.css }}/>
<Popover.Portal>
<Popover.Content className={c.popover}>
<ColorPicker color={color} setColor={setColor} />
</Popover.Content>
</Popover.Portal>
</Popover.Root>
<div className={c.channels}>
<Select
className={c.colorSpaceDropdown}
value={color.original.mode}
trigger={color.original.mode}
triggerIcon={<ChevronDown />}
onValueChange={space => setColor(color[space as keyof typeof color] ?? color.original)}
>
{Object.entries(COLOR_PICKER_SPACES).map(([id, label]) => (
<SelectItem className={c.colorSpaceDropdownItem} key={id} value={id}>
{label}
</SelectItem>
))}
</Select>
{channels.map((v, i) => <output className={c.channel} key={i}>{trimTrailingZeros(String(v).slice(0, 6))}</output>)}
</div>
<output className={c.alpha}>{trimTrailingZeros(String((color.original.alpha * 100)).slice(0, 6))}%</output>
Comment on lines +42 to +44
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Went with 5 digits (6 characters) of precision to match the design, but it feels kinda excessive.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if using Intl.NumberFormat() would help simplify this. 🤔

It could just as well complicate it too though. Just a thought.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I’ve gone back-and-forth about this. If we’re shooting for 24-bit color, we need 10–12 digits or so:

1 / 2 ** 24 = 0.000000059604

16-bit color is probably a better target because today many devices are in the ~10 bit color range. 16-bit color is fairly safely-represented at ~7 significant digits.

1 / 2 ** 16 = 0.000015

The fewer decimals you have, the less fidelity and colors will “snap” to the wrong color. The designs probably should have more decimals than they do now.

Also +1 I think Intl.NumberFormat() would probably be a better choice that’s more optimized by the JS engine. The RegEx isn’t slow per-se, but on higher refresh rate devices every little slowdown could result in dropping frames while sliding.

</div>;
}

function normalizedChannels({ original }: ColorOutput): ColorValueNormalized["channels"] {
switch (original.mode) {
case 'a98':
case 'lrgb':
case 'p3':
case 'prophoto':
case 'rec2020':
case 'rgb':
return [original.r, original.g, original.b];
case 'hsl':
case 'okhsl':
return [original.h, original.s, original.l];
case 'hsv':
case 'okhsv':
return [original.h, original.s, original.v];
case 'hwb':
return [original.h, original.w, original.b];
case 'lab':
case 'oklab':
return [original.l, original.a, original.b];
case 'lch':
case 'oklch':
return [original.l, original.c, original.h];
case 'xyz50':
case 'xyz65':
return [original.x, original.y, original.z];
}
}

function trimTrailingZeros(value: string) {
return value.replace(/\.0+$/, '').replace(/(?<=\.[^\.]+)0+$/, '');
}
Comment on lines +77 to 79
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

0.2300 => 0.23
56.421 => 56.421
123.00 => 123

2 changes: 2 additions & 0 deletions packages/use-color/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export interface ColorOutput {
lrgb: Lrgb;
okhsl: Okhsl;
okhsv: Okhsv;
lab: Lab;
oklab: Oklab;
oklch: Oklch;
p3: P3;
prophoto: Prophoto;
rec2020: Rec2020;
Expand Down
12 changes: 9 additions & 3 deletions packages/use-color/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
// @ts-check
import { tokenToCulori } from '@terrazzo/token-tools';
import {
inGamut,
Expand Down Expand Up @@ -59,13 +60,13 @@ export function cleanValue(value, precision = 5, normalized = true) {
}

/** Primary parse logic */
export function parse(color) {
export function parse(/** @type {import("./index.d.ts").ColorInput} */ color) {
if (color && typeof color === 'object') {
let normalizedColor = color;

// DTCG tokens: convert to Culori format
if (color.colorSpace && Array.isArray(color.channels)) {
normalizedColor = tokenToCulori();
normalizedColor = tokenToCulori(color);
Copy link
Contributor Author

@lilnasy lilnasy Jan 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The color token component would not even render without this change.

video.mp4

}
if (!normalizedColor.mode) {
throw new Error(`Invalid Culori color: ${JSON.stringify(normalizedColor)}`);
Expand Down Expand Up @@ -248,6 +249,11 @@ export function createMemoizedColor(color) {
this.lab = COLORSPACES.lab.converter(color);
return this.lab;
},
get lch() {
delete this.lch;
this.lch = COLORSPACES.lch.converter(color);
return this.lch;
},
get lrgb() {
delete this.lrgb;
this.lrgb = COLORSPACES.lrgb.converter(color);
Expand Down Expand Up @@ -318,7 +324,7 @@ export function createMemoizedColor(color) {
}

/** memoize Culori colors and reduce unnecessary updates */
export default function useColor(color) {
export default function useColor(/** @type {import("./index.d.ts").ColorInput} */ color) {
const [innerColor, setInnerColor] = useState(createMemoizedColor(parse(color)));
const setColorOutput = useCallback((newColor) => {
if (newColor) {
Expand Down
Loading
Loading