Skip to content
Draft
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/mosaic-x-cx-helper.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
56 changes: 56 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -249,6 +249,54 @@ const noPhysicalCssProperties = {
},
};

// Mosaic components expose a stable, consumer-facing class (e.g. `.cl-button`)
// as the first argument to the `cx()` styling helper. Consumers target that
// class in their own CSS, so it is a public contract: it must be an explicit
// string literal in `cl-<name>` kebab-case, never derived from a variable or
// identifier that could be renamed/minified out from under consumers.
const mosaicStableClass = {
meta: {
type: 'problem',
docs: {
description: 'Require cx() to receive a stable cl-* class literal as its first argument',
recommended: false,
},
messages: {
missing: 'cx() must receive a stable class literal (e.g. cx("cl-button", ...)) as its first argument.',
notLiteral:
'The first argument to cx() must be a string literal (e.g. "cl-button"), not {{ got }}. Consumers target this class in their CSS, so it must stay stable and greppable rather than derived from a variable.',
badFormat: 'Stable class "{{ value }}" must be cl-<name> in lowercase kebab-case (e.g. "cl-button").',
},
schema: [],
},
create(context) {
const STABLE_CLASS = /^cl-[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
return {
CallExpression(node) {
if (node.callee.type !== 'Identifier' || node.callee.name !== 'cx') {
return;
}
const first = node.arguments[0];
if (!first) {
context.report({ node, messageId: 'missing' });
return;
}
if (first.type !== 'Literal' || typeof first.value !== 'string') {
context.report({
node: first,
messageId: 'notLiteral',
data: { got: first.type === 'SpreadElement' ? 'a spread' : 'a non-literal expression' },
});
return;
}
if (!STABLE_CLASS.test(first.value)) {
context.report({ node: first, messageId: 'badFormat', data: { value: first.value } });
}
},
};
},
};

export default tseslint.config([
{
name: 'repo/ignores',
Expand Down Expand Up @@ -325,6 +373,7 @@ export default tseslint.config([
'no-global-object': noGlobalObject,
'no-unstable-methods': noUnstableMethods,
'no-physical-css-properties': noPhysicalCssProperties,
'mosaic-stable-class': mosaicStableClass,
},
},
'simple-import-sort': pluginSimpleImportSort,
Expand Down Expand Up @@ -561,6 +610,13 @@ export default tseslint.config([
],
},
},
{
name: 'packages/ui/mosaic-x',
files: ['packages/ui/src/mosaic-x/**/*.{ts,tsx}'],
rules: {
'custom-rules/mosaic-stable-class': 'error',
},
},
{
name: 'packages - vitest',
files: ['packages/*/src/**/*.test.{ts,tsx}'],
Expand Down
2 changes: 2 additions & 0 deletions packages/ui/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
# StyleX migration POC build output (regenerate with `pnpm build:mosaic-x`)
dist-mosaic-x
3 changes: 3 additions & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"build": "pnpm build:umd && pnpm build:esm && pnpm check:no-rhc && pnpm type-check",
"build:analyze": "rspack build --config rspack.config.js --env production --env analyze",
"build:esm": "tsdown",
"build:mosaic-x": "tsdown --config tsdown.mosaic-x.config.mts",
"build:rsdoctor": "RSDOCTOR=true rspack build --config rspack.config.js --env production",
"build:umd": "rspack build --config rspack.config.js --env production",
"bundlewatch": "FORCE_COLOR=1 bundlewatch --config bundlewatch.config.json",
Expand Down Expand Up @@ -103,6 +104,7 @@
"@solana/wallet-adapter-base": "catalog:module-manager",
"@solana/wallet-adapter-react": "catalog:module-manager",
"@solana/wallet-standard": "catalog:module-manager",
"@stylexjs/stylex": "0.19.0",
"@swc/helpers": "catalog:repo",
"copy-to-clipboard": "3.3.3",
"core-js": "catalog:repo",
Expand All @@ -119,6 +121,7 @@
"@rspack/core": "catalog:rspack",
"@rspack/dev-server": "catalog:rspack",
"@rspack/plugin-react-refresh": "catalog:rspack",
"@stylexjs/rollup-plugin": "0.19.0",
"@svgr/rollup": "^8.1.0",
"@svgr/webpack": "^6.5.1",
"@types/webpack-env": "^1.18.8",
Expand Down
112 changes: 112 additions & 0 deletions packages/ui/src/mosaic-x/button.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import * as stylex from '@stylexjs/stylex';
import React from 'react';

import { cx } from './props';
import { colors, radius, space } from './tokens.stylex';

// Helper to keep spacing math readable. `space['--cl-spacing']` compiles to
// `var(--cl-spacing)`, so this yields e.g. `calc(var(--cl-spacing) * 2)`.
const s = (n: number) => `calc(${space['--cl-spacing']} * ${n})`;

const styles = stylex.create({
base: {
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
gap: s(2),
borderRadius: radius['--cl-radius-md'],
borderWidth: '1px',
borderStyle: 'solid',
borderColor: 'transparent',
fontWeight: 500,
fontFamily: 'inherit',
cursor: 'pointer',
transitionProperty: 'background-color, border-color, color, opacity',
transitionDuration: '150ms',
outline: {
default: 'none',
':focus-visible': `2px solid color-mix(in oklab, ${colors['--cl-primary']} 50%, transparent)`,
},
outlineOffset: '2px',
},

// intent × variant
filledPrimary: {
backgroundColor: {
default: colors['--cl-primary'],
':hover': `color-mix(in oklab, ${colors['--cl-primary']}, ${colors['--cl-primary-foreground']} 12%)`,
':active': `color-mix(in oklab, ${colors['--cl-primary']}, ${colors['--cl-primary-foreground']} 24%)`,
},
color: colors['--cl-primary-foreground'],
},
filledDestructive: {
backgroundColor: {
default: colors['--cl-destructive'],
':hover': `color-mix(in oklab, ${colors['--cl-destructive']}, ${colors['--cl-destructive-foreground']} 12%)`,
':active': `color-mix(in oklab, ${colors['--cl-destructive']}, ${colors['--cl-destructive-foreground']} 24%)`,
},
color: colors['--cl-destructive-foreground'],
},
outlinePrimary: {
backgroundColor: 'transparent',
borderColor: colors['--cl-border'],
color: colors['--cl-foreground'],
},

// size
sizeSm: { paddingBlock: s(1), paddingInline: s(2), fontSize: '0.75rem', lineHeight: 'calc(1 / 0.75)' },
sizeMd: { paddingBlock: s(2), paddingInline: s(4), fontSize: '0.875rem', lineHeight: 'calc(1.25 / 0.875)' },

// state / modifiers
fullWidth: { width: '100%' },
disabled: { opacity: 0.5, cursor: 'not-allowed', pointerEvents: 'none' },
});

export interface ButtonProps extends React.ComponentPropsWithRef<'button'> {
intent?: 'primary' | 'destructive';
variant?: 'filled' | 'outline';
size?: 'sm' | 'md';
fullWidth?: boolean;
}

export const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
intent = 'primary',
variant = 'filled',
size = 'md',
fullWidth = false,
disabled = false,
className,
children,
...rest
},
ref,
) {
return (
<button
ref={ref}
type='button'
disabled={disabled}
data-intent={intent}
data-variant={variant}
data-size={size}
{...cx(
// Stable, consumer-facing targeting class: `.cl-button { … }`. Keep this a
// literal (enforced by the custom-rules/mosaic-stable-class lint rule) — it
// is a public styling contract, not an internal identifier.
'cl-button',
className,
styles.base,
variant === 'filled' && intent === 'primary' && styles.filledPrimary,
variant === 'filled' && intent === 'destructive' && styles.filledDestructive,
variant === 'outline' && styles.outlinePrimary,
size === 'sm' ? styles.sizeSm : styles.sizeMd,
fullWidth && styles.fullWidth,
disabled && styles.disabled,
)}
{...rest}
>
{children}
</button>
);
});
106 changes: 106 additions & 0 deletions packages/ui/src/mosaic-x/demo-live.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
<!doctype html>
<!--
LIVE demo — renders the actual built Button component (../../dist-mosaic-x/index.js).
Build first: `pnpm build:mosaic-x`, then serve the package dir over HTTP:
cd packages/ui && python3 -m http.server 4321
and open http://localhost:4321/src/mosaic-x/demo-live.html
(react / react-dom / @stylexjs/stylex are loaded from esm.sh via the import map,
so this page needs network access.)
-->
<html lang="en">

<head>
<meta charset="utf-8" />
<title>mosaic-x — live component demo</title>

<!-- Consumer owns the layer: import our sheet into `components`, override from `overrides`. -->
<style>
@layer components, overrides;
@import url('../../dist-mosaic-x/mosaic.css') layer(components);

body {
font-family: system-ui, sans-serif;
margin: 2rem;
}

h2 {
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #666;
margin: 1.5rem 0 0.5rem;
}

.row {
display: flex;
gap: 0.75rem;
align-items: center;
flex-wrap: wrap;
}

/* Flip this on with the checkbox to see consumer overrides win by layer order.
Custom properties inherit, so setting --cl-primary on body reaches every
button; the rules live in `overrides` so they beat the `components` layer. */
@layer overrides {
body.overrides-on {
--cl-primary: rebeccapurple;
}

body.overrides-on .cl-button {
border-radius: 9999px;
}
}
</style>

<script type="importmap">
{
"imports": {
"react": "https://esm.sh/react@18.3.1",
"react/jsx-runtime": "https://esm.sh/react@18.3.1/jsx-runtime",
"react-dom/client": "https://esm.sh/react-dom@18.3.1/client",
"@stylexjs/stylex": "https://esm.sh/@stylexjs/stylex@0.19.0"
}
}
</script>
</head>

<body>
<label style="display: block; margin-bottom: 1rem">
<input id="toggle" type="checkbox" /> apply consumer overrides (purple + pill)
</label>
<div id="root"></div>

<script type="module">
import React from 'react';
import {createRoot} from 'react-dom/client';
import {Button} from '../../dist-mosaic-x/index.js';

const h = React.createElement;
const Row = (label, kids) => h('div', null, h('h2', null, label), h('div', {className: 'row'}, kids));

function App() {
return h(
React.Fragment,
null,
Row('filled', [
h(Button, {key: 1, intent: 'primary'}, 'Primary'),
h(Button, {key: 2, intent: 'destructive'}, 'Destructive'),
]),
Row('outline', [h(Button, {key: 1, variant: 'outline'}, 'Outline')]),
Row('sizes', [
h(Button, {key: 1, size: 'sm'}, 'Small'),
h(Button, {key: 2, size: 'md'}, 'Medium'),
]),
Row('state', [h(Button, {key: 1, disabled: true}, 'Disabled')]),
);
}

createRoot(document.getElementById('root')).render(h(App));

document.getElementById('toggle').addEventListener('change', e => {
document.body.classList.toggle('overrides-on', e.target.checked);
});
</script>
</body>

</html>
64 changes: 64 additions & 0 deletions packages/ui/src/mosaic-x/demo.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
<!doctype html>
<!--
POC demo. Build first: `pnpm build:mosaic-x`, then open this file in a browser.
Shows the intended consumer model: the consumer imports our stylesheet INTO A
LAYER THEY CONTROL, then puts their own overrides in a later layer (or
unlayered). Their rules win by cascade-layer order — no StyleX/JS on the
consumer side, and layer precedence is the consumer's concern, not ours.
The button markup below is copied verbatim from the server-rendered POC output.
-->
<html lang="en">

<head>
<meta charset="utf-8" />
<title>mosaic-x StyleX POC</title>
<style>
/* ---- CONSUMER CSS (plain, no StyleX) ---- */

/* Consumer declares layer order and imports our sheet into `components`. */
@layer components, overrides;
@import url('../../dist-mosaic-x/mosaic.css') layer(components);

/* Consumer overrides live in a later layer → always win over `components`. */
@layer overrides {

/* 1. Override a design token globally. */
:root {
--cl-primary: rebeccapurple;
}

/* 2. Target the stable class directly. */
.cl-button {
border-radius: 9999px;
}

/* 3. Variant-scoped override via the data-* attribute the component emits. */
.cl-button[data-variant='outline'] {
border-color: rebeccapurple;
color: rebeccapurple;
}
}

body {
font-family: system-ui, sans-serif;
padding: 2rem;
display: flex;
gap: 1rem;
}
</style>
</head>

<body>
<button type="button"
class="cl-button x3nfvp2 x6s0dn4 xl56j7k x19rnl8h xzmjytj xmkeg23 x1y0btm7 x9r1u3d xk50ysn xjb2p0i x1ypdohk x1qsmvhx xx6bhzk x1a2a7pz xy8msr7 x1hl8ikr x1hew001 x1kdqmna x13bm0ie xlgzno x1495f9z x16lbfrg xkpwil5 xp7d87r"
data-intent="primary" data-variant="filled" data-size="md">
Primary (purple + pill)
</button>
<button type="button"
class="cl-button x3nfvp2 x6s0dn4 xl56j7k x19rnl8h xzmjytj xmkeg23 x1y0btm7 xk50ysn xjb2p0i x1ypdohk x1qsmvhx xx6bhzk x1a2a7pz xy8msr7 x1hl8ikr xjbqb8w x1slpqh1 x1hzhwjc x1495f9z x16lbfrg xkpwil5 xp7d87r"
data-intent="primary" data-variant="outline" data-size="md">
Outline (purple border)
</button>
</body>

</html>
4 changes: 4 additions & 0 deletions packages/ui/src/mosaic-x/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export { Button } from './button';
export type { ButtonProps } from './button';
export { cx } from './props';
export { colors, radius, space } from './tokens.stylex';
Loading
Loading