Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Add copy buttons to all
 blocks
(function() {
function addCopyButtons() {
document.querySelectorAll('pre code').forEach(function(codeBlock) {
if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;
codeBlock.parentElement.setAttribute('data-copy-added', 'true');
var btn = document.createElement('button');
btn.textContent = 'Copy';
btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';
btn.onmouseover = function() { this.style.opacity = '1'; };
btn.onmouseout = function() { this.style.opacity = '0.7'; };
btn.onclick = function() {
navigator.clipboard.writeText(codeBlock.textContent).then(function() {
btn.textContent = 'Copied!';
setTimeout(function() { btn.textContent = 'Copy'; }, 1500);
});
};
codeBlock.parentElement.style.position = 'relative';
codeBlock.parentElement.appendChild(btn);
});
}
addCopyButtons();
// Re-run on dynamic content
var observer = new MutationObserver(addCopyButtons);
observer.observe(document.body, { childList: true, subtree: true });
})();
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Force GitHub README to respect dark mode (function() { var style = document.createElement('style'); style.textContent = ' .markdown-body { color-scheme: dark light; } .markdown-body pre { background: #161b22 !important; } .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; } .markdown-body table th, .markdown-body table td { border-color: #30363d !important; } .markdown-body img { background: #0d1117; } .markdown-body blockquote { border-left-color: #8b949e; } .markdown-body hr { border-color: #30363d; } '; document.head.appendChild(style); })(); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Highlight search terms from Google/DuckDuckGo/Bing referrer (function() { var ref = document.referrer; var terms = []; if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) { var url = new URL(ref); var q = url.searchParams.get('q') || url.searchParams.get('p'); if (q) { terms = q.split(/\s+/).filter(function(t) { return t.length > 2; }); } } if (terms.length === 0) return; var style = document.createElement('style'); style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }'; document.head.appendChild(style); function highlight(node) { if (node.nodeType === 3) { // text node var text = node.textContent; var found = false; terms.forEach(function(term) { var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\]\\]/g, '\\') + ')', 'gi'); if (regex.test(text)) { found = true; var frag = document.createDocumentFragment(); var parts = text.split(regex); parts.forEach(function(part, i) { if (i % 2 === 0) { frag.appendChild(document.createTextNode(part)); } else { var span = document.createElement('span'); span.className = 'userscript-highlight'; span.textContent = part; frag.appendChild(span); } }); node.parentNode.replaceChild(frag, node); } }); } else if (node.nodeType === 1 && node.childNodes) { // element var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT']; if (!skipTags.includes(node.tagName)) { Array.from(node.childNodes).forEach(highlight); } } } highlight(document.body); // Re-highlight on dynamic content var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1 || node.nodeType === 3) highlight(node); }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Strip utm_, fbclid, gclid, etc. from all links on page (function() { var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid', 'ref', 'ref_src', 'source', 'medium', 'campaign']; function cleanUrl(url) { try { var u = new URL(url, window.location.origin); var changed = false; trackingParams.forEach(function(p) { if (u.searchParams.has(p)) { u.searchParams.delete(p); changed = true; } }); return changed ? u.toString() : url; } catch (e) { return url; } } function cleanLinks() { document.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } cleanLinks(); var observer = new MutationObserver(function(mutations) { mutations.forEach(function(m) { m.addedNodes.forEach(function(node) { if (node.nodeType === 1) { if (node.tagName === 'A') cleanLinks(); node.querySelectorAll('a[href]').forEach(function(a) { var clean = cleanUrl(a.href); if (clean !== a.href) a.href = clean; }); } }); }); }); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + ' feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Auto-enable theater mode on YouTube (function() { function tryTheater() { var btn = document.querySelector('button[aria-label="Theater mode"], ytd-player #player button[title="Theater mode"]'); if (btn && !btn.classList.contains('activated')) { btn.click(); } } // Try immediately tryTheater(); // Try after navigation (SPA) var lastUrl = location.href; setInterval(function() { if (location.href !== lastUrl) { lastUrl = location.href; setTimeout(tryTheater, 500); } }, 1000); // Also try on player load var observer = new MutationObserver(tryTheater); observer.observe(document.body, { childList: true, subtree: true }); })(); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Remove or un-stick sticky/fixed headers that block content (function() { function unstick() { document.querySelectorAll('header, nav, [role="banner"], .header, .navbar, .sticky, .fixed-top, [style*="position: fixed"], [style*="position:sticky"]').forEach(function(el) { if (el.style.position === 'fixed' || el.style.position === 'sticky' || getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') { el.style.position = 'static'; el.style.top = 'auto'; el.style.zIndex = 'auto'; } }); } unstick(); var observer = new MutationObserver(unstick); observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); })(); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + ' feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { // Universal Dark Mode - works on any site (function() { var enabled = true; function applyDarkMode() { if (!enabled) return; // Create style element if it doesn't exist var style = document.getElementById('universal-dark-mode-style'); if (!style) { style = document.createElement('style'); style.id = 'universal-dark-mode-style'; document.head.appendChild(style); } // Dark mode CSS - inverts colors but preserves images/video style.textContent = ' /* Invert everything except media */ html { filter: invert(1) hue-rotate(180deg) !important; background: #1a1a2e !important; } /* Restore images, videos, iframes, canvas */ img, video, iframe, canvas, svg, picture, [style*="background-image"] { filter: invert(1) hue-rotate(180deg) !important; } /* Preserve specific elements that should not be inverted */ .no-dark-mode, .no-dark-mode *, [data-theme="light"], [data-theme="light"], .ace_editor, .ace_editor *, .CodeMirror, .CodeMirror *, .monaco-editor, .monaco-editor *, .markdown-body pre, .markdown-body pre *, .highlight, .highlight *, pre code, pre code * { filter: none !important; } /* Fix common UI elements */ .modal, .popup, .dropdown-menu, .tooltip, .popover { filter: invert(1) hue-rotate(180deg) !important; background: #2d2d44 !important; border-color: #444 !important; } /* Scrollbars */ ::-webkit-scrollbar { background: #1a1a2e !important; } ::-webkit-scrollbar-thumb { background: #444 !important; } ::-webkit-scrollbar-thumb:hover { background: #555 !important; } /* Selection */ ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; } ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; } '; } function removeDarkMode() { var style = document.getElementById('universal-dark-mode-style'); if (style) style.remove(); } // Toggle with Alt+Shift+D document.addEventListener('keydown', function(e) { if (e.altKey && e.shiftKey && e.key === 'D') { e.preventDefault(); enabled = !enabled; if (enabled) { applyDarkMode(); console.log('[Universal Dark Mode] Enabled'); } else { removeDarkMode(); console.log('[Universal Dark Mode] Disabled'); } } }); // Apply on load applyDarkMode(); // Re-apply on dynamic content var observer = new MutationObserver(function(mutations) { if (enabled && !document.getElementById('universal-dark-mode-style')) { applyDarkMode(); } }); observer.observe(document.head, { childList: true }); console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle'); })(); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })(); feat(ui): add Mosaic hover, motionSafe, and alpha CSS utilities by alexcarpenter · Pull Request #8817 · clerk/javascript · GitHub
Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/real-chicken-build.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
---

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

does the changeset just need an empty entry?

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

yeah, since these are don't have any user facing changes atm we can use an empty changeset.

---
Comment thread
alexcarpenter marked this conversation as resolved.
23 changes: 23 additions & 0 deletions eslint.config.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -536,6 +536,29 @@ export default tseslint.config([
'custom-rules/no-physical-css-properties': 'error',
},
},
{
name: 'packages/ui/mosaic',
files: ['packages/ui/src/mosaic/**/*'],
ignores: ['packages/ui/src/mosaic/utils.ts', 'packages/ui/src/mosaic/__tests__/**'],
rules: {
'no-restricted-syntax': [
'error',
{
selector: "Property > Literal.key[value='&:hover']",
message: "Use hover() from mosaic/utils instead of bare '&:hover'.",
},
{
selector: "Property > Literal.key[value='@media (hover: hover)']",
message: "Use hover() from mosaic/utils instead of raw '@media (hover: hover)'.",
},
{
selector: "Property > Literal.key[value='@media (prefers-reduced-motion: no-preference)']",
message:
"Use motionSafe() from mosaic/utils instead of raw '@media (prefers-reduced-motion: no-preference)'.",
},
],
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
62 changes: 0 additions & 62 deletions packages/ui/src/mosaic/Button.tsx

This file was deleted.

49 changes: 49 additions & 0 deletions packages/ui/src/mosaic/__tests__/utils.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
import { describe, expect, it } from 'vitest';

import { defaultMosaicVariables, resolveVariables } from '../variables';
import { alpha, hover, motionSafe } from '../utils';

describe('hover', () => {
it('wraps styles in @media (hover: hover) and &:hover', () => {
expect(hover({ color: 'red' })).toEqual({
'@media (hover: hover)': {
'&:hover': { color: 'red' },
},
});
});

it('passes complex style objects through unchanged', () => {
const styles = { backgroundColor: 'blue', opacity: 0.8, transform: 'scale(1.05)' };
expect(hover(styles)).toEqual({
'@media (hover: hover)': {
'&:hover': styles,
},
});
});
});

describe('motionSafe', () => {
it('wraps styles in @media (prefers-reduced-motion: no-preference)', () => {
expect(motionSafe({ transition: 'transform 200ms ease' })).toEqual({
'@media (prefers-reduced-motion: no-preference)': { transition: 'transform 200ms ease' },
});
});
});

describe('alpha', () => {
it('produces a color-mix expression', () => {
expect(alpha('#000', 50)).toBe('color-mix(in oklab, #000 50%, transparent)');
});

it('accepts any CSS color string', () => {
expect(alpha('oklch(58.5% .233 277.117)', 80)).toBe(
'color-mix(in oklab, oklch(58.5% .233 277.117) 80%, transparent)',
);
});

it('matches theme.alpha output for the same resolved color', () => {
const theme = resolveVariables(defaultMosaicVariables);
const resolvedColor = defaultMosaicVariables.color.primary;
expect(theme.alpha('primary', 60)).toBe(alpha(resolvedColor, 60));
});
});
3 changes: 2 additions & 1 deletion packages/ui/src/mosaic/components/button.tsx
Original file line numberDiff line numberDiff line change
Expand Up@@ -2,6 +2,7 @@ import React from 'react';

import { cva, type VariantProps } from '../cva';
import { useMosaicTheme } from '../MosaicProvider';
import { hover } from '../utils';

export const buttonStyles = cva(theme => ({
base: {
Expand All@@ -27,7 +28,7 @@ export const buttonStyles = cva(theme => ({
primary: {
backgroundColor: theme.color.primary,
color: theme.color.primaryForeground,
'&:hover': { backgroundColor: theme.mix('primary', 'primaryForeground', 12) },
...hover({ backgroundColor: theme.mix('primary', 'primaryForeground', 12) }),
'&:active': { backgroundColor: theme.mix('primary', 'primaryForeground', 24) },
},
},
Expand Down
22 changes: 22 additions & 0 deletions packages/ui/src/mosaic/utils.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
import type { StyleRule } from './cva';

/** Wraps styles in `@media (hover: hover)` so they only apply on hover-capable devices. */
export function hover(styles: StyleRule): StyleRule {
return {
'@media (hover: hover)': {
'&:hover': styles,
},
};
}

/** Wraps styles in `@media (prefers-reduced-motion: no-preference)` so animations only run when the user has not requested reduced motion. */
export function motionSafe(styles: StyleRule): StyleRule {
return {
'@media (prefers-reduced-motion: no-preference)': styles,
};
}

/** Applies opacity to any CSS color via `color-mix`. */
export function alpha(color: string, opacity: number): string {
return `color-mix(in oklab, ${color} ${opacity}%, transparent)`;
}
Comment on lines +20 to +22

@maxyingermaxyingerJun 10, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I guess 2 Qs here.

  1. did you consider relative colors for fading instead of mix? I think in oklab this will still look good, but it's mixing with black at 0 opacity vs strictly fading the color, so you'll get slightly different results at some of the midpoints. What you have above will still look good cus it's in oklab, but might not exactly match something like figma that's strictly altering opacity.
return `oklab(from ${color} l a b / ${opacity})`;

2. I'm guessing this is just more preference, but any apis make you lean towards a percentage vs 0 to 1? I guess I normally assume it's 0 to 1 unless I explicitly pass in a string with %

nvmd. looked below and it's a backwards compat thing with a the mosaic alpha. ignore 2

Copy link
Copy Markdown
MemberAuthor

Choose a reason for hiding this comment

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

great flag, I have been following the approach tw does.

so bg-red-500/50 -> color-mix(in oklab, var(--color-red-500) 50%, transparent)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ahh that makes sense. Yea probably better to be consistent. feels like a framework level css decision anyway. right on

4 changes: 3 additions & 1 deletion packages/ui/src/mosaic/variables.ts
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
import { alpha } from './utils';

// ─── Token Defaults ──────────────────────────────────────────────────────────

/** Baseline design token values. Every Mosaic theme starts from this object. */
Expand DownExpand Up@@ -99,7 +101,7 @@ export function resolveVariables(defaults: MosaicTokens, variables?: MosaicVaria
...tokens,
spacing: (<N extends number>(n: N) => `calc(${tokens.spacing} * ${n})`) as MosaicTheme['spacing'],
alpha: (<K extends keyof MosaicTokens['color'], O extends number>(color: K, opacity: O) =>
`color-mix(in oklab, ${tokens.color[color]} ${opacity}%, transparent)`) as MosaicTheme['alpha'],
alpha(tokens.color[color], opacity)) as MosaicTheme['alpha'],
mix: (<A extends keyof MosaicTokens['color'], B extends keyof MosaicTokens['color'], P extends number>(
a: A,
b: B,
Expand Down
Loading