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 docs/design/design-system-manifest-2026-10.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,8 @@ remote insert is noise (the reference editor also inserts blocks with no entranc
dwell 2.8s (:3606), skeleton shimmer 1.6s (:442). They keep their durations but must use
an `--ease-*` token instead of `ease-out`/`ease`. Adding a new one requires a row here.

Select exit: **none** — nested dismissable layer.

### 2.3 Reduced motion

| Rule | Decision |
Expand Down
33 changes: 33 additions & 0 deletions eslint-rules/design-token-utils.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// Match class-bearing JSX and hoisted style/class constants, as spacing does.
export function classContext(node) {
for (let current = node.parent; current; current = current.parent) {
if (current.type === 'JSXAttribute') return current.name.name === 'className';
if (['JSXElement', 'JSXFragment'].includes(current.type)) return false;
if (current.type === 'VariableDeclarator' && /class|style|motion/i.test(current.id.name ?? '')) return true;
if (current.type === 'Property' && /class|style/i.test(current.key.name ?? current.key.value ?? '')) return true;
}
return false;
}
export function classRule(description, rejects, extra = () => ({})) {
return {
meta: {type: 'suggestion', docs: {description}, schema: [], messages: {token: '{{value}} must use a named design token.'}},
create(context) {
const report = (node, value) => context.report({node, messageId: 'token', data: {value}});
function check(node, value) {
if (!classContext(node)) return;
for (const utility of value.split(/\s+/)) if (rejects(utility, context.filename)) report(node, utility);
}
const additional = extra(report);
return {
...additional,
Literal(node) {
if (typeof node.value === 'string') check(node, node.value);
additional.Literal?.(node);
},
TemplateLiteral(node) {
check(node, node.quasis.map(q => q.value.cooked ?? q.value.raw).join('${}'));
},
};
},
};
}
26 changes: 26 additions & 0 deletions eslint-rules/design-tokens.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import {RuleTester} from 'eslint';
import {noArbitraryMotion} from './no-arbitrary-motion.mjs';
import {noPaletteColor} from './no-palette-color.mjs';
import {noRawZ} from './no-raw-z.mjs';
const tester = new RuleTester({languageOptions: {ecmaVersion: 2022, sourceType: 'module', parserOptions: {ecmaFeatures: {jsx: true}}}});
const jsx = value => `<div className="${value}" />`;
const invalid = values => values.map(value => ({code: jsx(value), errors: [{messageId: 'token'}]}));
tester.run('no-arbitrary-motion', noArbitraryMotion, {
valid: [jsx('duration-0 active:duration-0!'), jsx('duration-(--motion-base) ease-out-soft transition-colors'),
{code: jsx('ease-spring'), filename: '/ui/switch.tsx'},
{code: jsx('zoom-in-95'), filename: '/ui/overlay-motion.ts'},
'const text = "duration-200";'],
invalid: [...invalid(['hover:duration-200', 'duration-[180ms]', 'delay-100', 'delay-[1s]', 'ease-in-out', 'ease-linear', 'ease-[cubic-bezier(0,0,1,1)]', 'animate-[foo_1s]', 'zoom-out-95', 'ease-spring']),
{code: 'const menuClass = `duration-200 ${active}`;', errors: [{messageId: 'token'}]}],
});
tester.run('no-palette-color', noPaletteColor, {
valid: [jsx('bg-muted text-muted-foreground bg-[var(--data-red)] text-[hsl(var(--foreground))]'), '<div style={{color: "var(--obe-code-num)"}} />', '<div title="#ffffff" />'],
invalid: [...invalid(['dark:bg-red-500/20', 'text-slate-100', 'border-t-blue-300', 'bg-[#fff]', 'text-[rgb(0,0,0)]', 'bg-[hsl(0_0%_0%)]']),
{code: '<div style={{color: "#fff"}} />', errors: [{messageId: 'token'}]},
{code: '<div style={{borderColor: "rgb(0,0,0)"}} />', errors: [{messageId: 'token'}]}],
});
tester.run('no-raw-z', noRawZ, {
valid: [jsx('z-0 hover:z-0!'), jsx('z-menu hover:z-sticky'), 'const label = "z-50";'],
invalid: [...invalid(['z-50', '-z-10', 'hover:z-[1]', 'z-[var(--x)]']),
{code: 'const menuStyles = "z-10";', errors: [{messageId: 'token'}]}],
});
8 changes: 8 additions & 0 deletions eslint-rules/no-arbitrary-motion.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import {classRule} from './design-token-utils.mjs';
export const noArbitraryMotion = classRule('Use the DSX motion scale.', (value, filename) => {
if (/(?:^|:)duration-0!?$/.test(value)) return false;
if (/(?:^|:)ease-spring!?$/.test(value) && !/(?:^|\/)switch\.tsx$/.test(filename)) return true;
if (/(?:^|:)zoom-(?:in|out)-95!?$/.test(value)) return !filename.endsWith('/overlay-motion.ts');
return /(?:^|:)(?:(?:duration|delay)-(?:\d+|\[[^\]]+\])|ease-(?:in|out|in-out|linear|\[[^\]]+\])|animate-\[[^\]]+\])!?$/.test(value);
});
export default {rules: {'no-arbitrary-motion': noArbitraryMotion}};
19 changes: 19 additions & 0 deletions eslint-rules/no-palette-color.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {classRule} from './design-token-utils.mjs';
const colorKey = /^(?:color|background(?:Color)?|border(?:Top|Right|Bottom|Left)?(?:Color)?|outline(?:Color)?|fill|stroke|boxShadow|caretColor|textDecorationColor|accentColor)$/;
const literal = /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?)\(\s*[\d.]/i;
export const noPaletteColor = classRule('Use semantic editor or data colours.', value =>
/(?:^|:)(?:bg|text|border(?:-[trblxyse])?|ring(?:-offset)?|fill|stroke|outline|from|via|to|decoration|caret|accent)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:\/[^\s]+)?!?$/.test(value)
|| /(?:^|:)(?:bg|text|border(?:-[trblxyse])?|ring|fill|stroke|outline|from|via|to|decoration|caret|accent)-\[/.test(value) && literal.test(value), report => ({
Literal(node) {
if (typeof node.value !== 'string' || !literal.test(node.value)) return;
const property = node.parent;
if (property.type !== 'Property' || !colorKey.test(property.key.name ?? property.key.value ?? '')) return;
for (let parent = property.parent; parent; parent = parent.parent) {
if (parent.type === 'JSXAttribute') {
if (parent.name.name === 'style') report(node, node.value);
return;
}
}
},
}));
export default {rules: {'no-palette-color': noPaletteColor}};
3 changes: 3 additions & 0 deletions eslint-rules/no-raw-z.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import {classRule} from './design-token-utils.mjs';
export const noRawZ = classRule('Use a named stacking tier.', value => !/(?:^|:)z-0!?$/.test(value) && /(?:^|:)-?z-(?:\d+|\[[^\]]+\])!?$/.test(value));
export default {rules: {'no-raw-z': noRawZ}};
37 changes: 33 additions & 4 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ import nextPlugin from '@next/eslint-plugin-next';
import globals from 'globals';
import e2eIsolation from './eslint-rules/e2e-workspace-isolation.mjs';
import noArbitrarySpacing from './eslint-rules/no-arbitrary-spacing.mjs';
import noArbitraryMotion from './eslint-rules/no-arbitrary-motion.mjs';
import noPaletteColor from './eslint-rules/no-palette-color.mjs';
import noRawZ from './eslint-rules/no-raw-z.mjs';
import noHoverGeometry from './eslint-rules/no-hover-geometry.mjs';

export default tseslint.config(
Expand All @@ -25,8 +28,6 @@ export default tseslint.config(
'**/blob-report/**',
// Rust host + generated Tauri capability schemas.
'packages/app/src-tauri/**',
// shadcn/ui primitives, kept verbatim from upstream.
'packages/ui/src/components/ui/**',
// Vendored UMD bundles (d3 / Observable Plot) inlined into the HTML export.
'packages/ui/src/export/vendor/**',
// Generated mirror of the ledger plugin's PURE report folds (LX-3) —
Expand Down Expand Up @@ -54,16 +55,28 @@ export default tseslint.config(
},
{
// Hover may repaint an element, but it must not alter geometry and shift
// adjacent content. UI primitives remain covered by the upstream ignore.
// adjacent content.
plugins: {'layout-shift': noHoverGeometry},
rules: {'layout-shift/no-hover-geometry': 'error'},
},
{
// Product spacing follows Tailwind's shared scale; bracket-arbitrary values
// for padding, margin, and gaps would silently introduce one-off geometry.
plugins: {tailwind: noArbitrarySpacing},
plugins: {tailwind: {rules: {...noArbitrarySpacing.rules, ...noArbitraryMotion.rules, ...noPaletteColor.rules, ...noRawZ.rules}}},
rules: {'tailwind/no-arbitrary-spacing': 'error'},
},
{
// DSX warn rollout: baseline 13 duration + 3 easing utilities, 208 palette
// utilities, 43 numeric z utilities; preserve existing debt without CI failure.
// Measured with these guards at 1d6ef346: motion 33→16, palette 152→152,
// z 40→40 (includes primitives; excludes generated/vendor sources).
files: ['packages/{ui,web}/src/**/*.{ts,tsx}'],
rules: {
'tailwind/no-arbitrary-motion': 'warn',
'tailwind/no-palette-color': 'warn',
'tailwind/no-raw-z': 'warn',
},
},
{
// Plain Node scripts (build helpers, etc.).
files: ['**/*.{js,cjs,mjs}'],
Expand Down Expand Up @@ -109,4 +122,20 @@ export default tseslint.config(
plugins: {e2e: e2eIsolation},
rules: {'e2e/workspace-isolation': 'error'},
},
{
// Upstream primitives retain their formatting/type lint exemption, but
// DSX guards cover them too (including future overlay motion regressions).
files: ['packages/ui/src/components/ui/**/*.{ts,tsx}'],
rules: {
...Object.fromEntries(Object.keys({
...js.configs.recommended.rules,
...Object.assign({}, ...tseslint.configs.recommended.map(config => config.rules)),
...react.configs.flat.recommended.rules,
}).map(name => [name, 'off'])),
'indent': 'off', 'linebreak-style': 'off', 'quotes': 'off', 'semi': 'off',
'no-eval': 'off', 'no-new-func': 'off',
'layout-shift/no-hover-geometry': 'off',
'tailwind/no-arbitrary-spacing': 'off',
},
},
);
34 changes: 33 additions & 1 deletion packages/ui/scripts/assert-spacing-stylelint.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ async function assertAllowed(declaration) {

async function assertRejected(declaration) {
const warnings = await warningsFor(`.spc2-negative-control { ${declaration}; }`);
if (!warnings.some((warning) => warning.rule === rule)) {
if (!warnings.some((warning) => warning.severity === 'error' && [rule, 'design-tokens/role-values'].includes(warning.rule))) {
throw new Error(`SPC-2 stylelint negative control did not reject ${declaration}`);
}
}
Expand All @@ -33,3 +33,35 @@ await assertRejected('border-radius: calc(var(--radius) + 3px)');
await assertRejected('PADDING: 7PX');

console.log('SPC-2 stylelint controls passed (2 positive, 4 negative)');

// DSX guards: token declarations and semantic colours remain legal.
for (const value of [
'transition: color var(--motion-fast) var(--ease-out-soft)',
'transition-duration: 0s', 'animation-timing-function: linear',
'color: hsl(var(--foreground) / 0.6)', 'border-radius: var(--radius-lg)',
'z-index: var(--z-index-menu)', 'font-size: var(--obe-small-size)',
'font-size: .875em', 'font-size: 1em', '--motion-fast: 120ms', '--obe-code-num: hsl(28 80% 38%)',
]) {
const warnings = await warningsFor(`.obe-control { ${value}; }`);
if (warnings.length) throw new Error(`DSX allowed ${value}: ${JSON.stringify(warnings)}`);
}
for (const value of [
'transition: color 120ms ease', 'transition-duration: 0ms', 'animation: enter .2s ease-out',
'transition-timing-function: cubic-bezier(0, 0, 1, 1)',
'color: #abcdef', 'border: 1px solid rgb(1 2 3)',
'font-size: 14px',
]) {
const warnings = await warningsFor(`.obe-control { ${value}; }`);
if (!warnings.length || warnings.some(w => w.severity !== 'warning')) throw new Error(`DSX warn guard missed ${value}`);
}
for (const value of ['border-radius: 4px', 'border-radius: var(--anything)', 'z-index: 4']) {
const warnings = await warningsFor(`.obe-control { ${value}; }`);
if (!warnings.length || warnings.some(w => w.severity !== 'error')) throw new Error(`DSX error guard missed ${value}`);
}
const {readFileSync} = await import('node:fs');
const css = readFileSync(codeFilename, 'utf8');
const reducedBlocks = css.match(/@media \(prefers-reduced-motion: reduce\)\s*\{[\s\S]*?\n\}/g) ?? [];
if (reducedBlocks.length !== 2 || !reducedBlocks[0].includes('0.01ms !important') || !reducedBlocks[1].includes('.obe-rcursor-label { animation: none; opacity: 1; }')) {
throw new Error('Reduced motion must use only the global switch and remote cursor end-state exception');
}
console.log('DSX stylelint controls passed');
19 changes: 19 additions & 0 deletions packages/ui/scripts/design-token-stylelint.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import stylelint from 'stylelint';
const ruleName = 'design-tokens/role-values';
const radiusAtom = String.raw`(?:0|50%|9999px|var\(--(?:radius-(?:sm|md|lg|xl)|ob-page-radius)\))`;
const radius = new RegExp(`^${radiusAtom}(?:\\s+${radiusAtom}){0,3}$`);
const font = /^(?:var\(--obe-(?:font-size|[\w-]+-size|code-inline)\)|inherit|(?:0?(?:\.\d+)|0|1(?:\.0+)?)em)$/;
const z = /^(?:var\(--z-index-[\w-]+\)|0|auto|-1)$/;
export default stylelint.createPlugin(ruleName, enabled => (root, result) => {
if (!enabled) return;
root.walkDecls(decl => {
const prop = decl.prop.toLowerCase();
const pattern = prop === 'border-radius' ? radius : prop === 'z-index' ? z
: prop === 'font-size' && /\.obe-[\w-]+/.test(decl.parent.selector ?? '') ? font : null;
if (pattern && !pattern.test(decl.value)) stylelint.utils.report({
ruleName, result, node: decl,
severity: prop === 'font-size' ? 'warning' : 'error',
message: `Use a DSX role token for ${prop}: ${decl.value}`,
});
});
});
14 changes: 7 additions & 7 deletions packages/ui/src/blockeditor/__tests__/colors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ function rgba(value: string): number[] {
return [channel(0), channel(8), channel(4), Number(match[4] ?? 1)];
}

function literal(selector: string, property: string): string {
const rule = css.split('\n').find((line) => line.startsWith(`${selector} {`));
const value = rule?.match(new RegExp(`${property}: hsl\\(([^)]+)\\)`))?.[1];
if (!value) throw new Error(`Missing ${selector} ${property}`);
function literal(dark: boolean, role: typeof roles[number], id: string): string {
const theme = css.match(dark ? /\.dark\s*\{([^}]+)\}/ : /:root\s*\{([^}]+)\}/)?.[1];
const property = `--obe-${role}-${id}`;
const value = theme?.match(new RegExp(`${property}: hsl\\(([^)]+)\\)`))?.[1];
if (!value) throw new Error(`Missing ${dark ? '.dark' : ':root'} ${property}`);
return value;
}

Expand Down Expand Up @@ -64,12 +65,11 @@ for (const dark of [false, true]) {
expect(COLOR_TOKENS).toHaveLength(9);
});
for (const {id} of COLOR_TOKENS) {
const selector = `${dark ? '.dark ' : ''}.obe-`;
it(`${id} text meets 4.5:1`, () => {
expect(contrast(rgba(literal(`${selector}fg-${id}`, 'color')), rgba(background))).toBeGreaterThanOrEqual(4.5);
expect(contrast(rgba(literal(dark, 'fg', id)), rgba(background))).toBeGreaterThanOrEqual(4.5);
});
it.each(roles)(`${id} %s matches export hex`, (role) => {
expect(palette[id][role]).toBe(hex(rgba(literal(`${selector}${role}-${id}`, role === 'fg' ? 'color' : 'background-color'))));
expect(palette[id][role]).toBe(hex(rgba(literal(dark, role, id))));
});
}
});
Expand Down
2 changes: 1 addition & 1 deletion packages/ui/src/blockeditor/__tests__/gutterStyles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ describe('column resize styles', () => {

it('keeps a raised, touch-safe one-rem hit zone with a two-pixel hover rule', () => {
const divider = ruleBody('.obe-col-divider');
expect(divider).toMatch(/z-index:\s*4/);
expect(divider).toMatch(/z-index:\s*var\(--z-index-pane-overlay\)/);
expect(divider).toMatch(/width:\s*1rem/);
expect(divider).toMatch(/touch-action:\s*none/);
expect(ruleBody('.obe-col-divider::after')).toMatch(/width:\s*2px/);
Expand Down
8 changes: 5 additions & 3 deletions packages/ui/src/components/ui/context-menu.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { OVERLAY_MOTION } from "./overlay-motion"
import * as React from "react"
import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"
import { CheckIcon, ChevronRightIcon } from "@radix-ui/react-icons"
Expand Down Expand Up @@ -49,7 +50,8 @@ const ContextMenuSubContent = React.forwardRef<
<ContextMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-32 overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-overlay data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:pointer-events-none data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"z-50 min-w-32 overflow-hidden rounded-lg border bg-popover text-popover-foreground shadow-overlay",
OVERLAY_MOTION.menu,
density === "compact" ? "p-0.5" : "p-1",
className
)}
Expand All @@ -72,8 +74,8 @@ const ContextMenuContent = React.forwardRef<
// Match the dropdown: bound to the viewport and scroll rather than
// clip, so the unified page menu's (CM-2) lower items stay reachable
// when the right-click opens near the bottom of the screen.
"z-50 max-h-[var(--radix-context-menu-content-available-height)] min-w-32 overflow-y-auto overflow-x-hidden overscroll-contain rounded-md border bg-popover text-popover-foreground shadow-overlay",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:pointer-events-none data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"z-50 max-h-[var(--radix-context-menu-content-available-height)] min-w-32 overflow-y-auto overflow-x-hidden overscroll-contain rounded-lg border bg-popover text-popover-foreground shadow-overlay",
OVERLAY_MOTION.menu,
density === "compact" ? "p-0.5" : "p-1",
className
)}
Expand Down
9 changes: 6 additions & 3 deletions packages/ui/src/components/ui/dialog.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { OVERLAY_MOTION } from "./overlay-motion"
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { Cross2Icon } from "@radix-ui/react-icons"
Expand Down Expand Up @@ -33,15 +34,16 @@ const DialogOverlay = React.forwardRef<
className={cn(
// Blur strength is driven by the `--overlay-blur` CSS var (set from the
// appearance "blur overlays" preference; defaults to 0 = no blur).
"fixed inset-0 z-50 bg-[hsl(var(--scrim))] backdrop-blur-[var(--overlay-blur)] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
"fixed inset-0 z-50 bg-[hsl(var(--scrim))] backdrop-blur-[var(--overlay-blur)]",
OVERLAY_MOTION.scrim,
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName

export const DIALOG_EXIT_MS = 180; // follows --default-transition-duration
export const DIALOG_EXIT_MS = 120; // mirrors --motion-fast; used by delayed dialog teardown
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
DialogContentProps
Expand All @@ -58,7 +60,8 @@ const DialogContent = React.forwardRef<
className={cn(
// `min-w-0 [&>*]:min-w-0` removes the panel/direct-child auto minimums so long children cannot widen it past max-w-*; deep content still needs min-w-0/truncate/break-*.
// The body may scroll; floating content is portaled so it is not clipped. A future direct child's min-w-* loses to `[&>*]:min-w-0` by utility order, not intent.
"relative grid max-h-[calc(100vh-4rem)] min-w-0 w-full gap-4 overflow-y-auto rounded-lg border bg-background p-6 shadow-overlay duration-[var(--default-transition-duration)] ease-[var(--ease-out-soft)] [&>*]:min-w-0 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95",
"relative grid max-h-[calc(100vh-4rem)] min-w-0 w-full gap-4 overflow-y-auto rounded-xl border bg-background p-6 shadow-overlay [&>*]:min-w-0",
OVERLAY_MOTION.dialog,
dialogContentSizes[size],
className
)}
Expand Down
Loading
Loading