@rdna/radiants 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/base.css +1 -1
  2. package/components/core/AppWindow/AppWindow.meta.ts +69 -0
  3. package/components/core/AppWindow/AppWindow.schema.json +55 -0
  4. package/components/core/AppWindow/AppWindow.test.tsx +150 -0
  5. package/components/core/AppWindow/AppWindow.tsx +830 -0
  6. package/components/core/Button/Button.test.tsx +18 -0
  7. package/components/core/Button/Button.tsx +26 -16
  8. package/components/core/DialPanel/DialPanel.tsx +1 -1
  9. package/components/core/Separator/Separator.tsx +1 -1
  10. package/components/core/Tabs/Tabs.tsx +14 -2
  11. package/components/core/__tests__/smoke.test.tsx +2 -0
  12. package/components/core/index.ts +1 -0
  13. package/contract/system.ts +18 -4
  14. package/dark.css +11 -1
  15. package/eslint/contract.mjs +1 -1
  16. package/eslint/index.mjs +10 -0
  17. package/eslint/rules/no-raw-font-family.mjs +91 -0
  18. package/eslint/rules/no-raw-line-height.mjs +119 -0
  19. package/fonts-core.css +70 -0
  20. package/fonts-editorial.css +45 -0
  21. package/fonts.css +19 -89
  22. package/generated/ai-contract.json +11 -2
  23. package/generated/contract.freshness.json +2 -1
  24. package/generated/eslint-contract.json +35 -4
  25. package/generated/figma/contracts/app-window.contract.json +82 -0
  26. package/generated/figma/primitive/color.tokens.json +9 -0
  27. package/generated/figma/primitive/shape.tokens.json +0 -4
  28. package/generated/figma/primitive/typography.tokens.json +16 -4
  29. package/generated/figma/rdna.tokens.json +28 -11
  30. package/generated/figma/semantic/semantic.tokens.json +3 -3
  31. package/generated/figma/tokens.d.ts +1 -1
  32. package/generated/figma/validation-report.json +1 -1
  33. package/icons/DesktopIcons.tsx +4 -3
  34. package/icons/Icon.tsx +10 -2
  35. package/icons/types.ts +7 -1
  36. package/meta/index.ts +6 -0
  37. package/package.json +5 -3
  38. package/patterns/pretext-type-scale.ts +115 -0
  39. package/pixel-corners.generated.css +15 -0
  40. package/schemas/index.ts +2 -0
  41. package/tokens.css +47 -21
  42. package/typography.css +10 -5
  43. package/fonts/PixelCode-Black-Italic.woff2 +0 -0
  44. package/fonts/PixelCode-Black.woff2 +0 -0
  45. package/fonts/PixelCode-DemiBold-Italic.woff2 +0 -0
  46. package/fonts/PixelCode-DemiBold.woff2 +0 -0
  47. package/fonts/PixelCode-ExtraBlack-Italic.woff2 +0 -0
  48. package/fonts/PixelCode-ExtraBlack.woff2 +0 -0
  49. package/fonts/PixelCode-ExtraBold-Italic.woff2 +0 -0
  50. package/fonts/PixelCode-ExtraBold.woff2 +0 -0
  51. package/fonts/PixelCode-ExtraLight-Italic.woff2 +0 -0
  52. package/fonts/PixelCode-ExtraLight.woff2 +0 -0
  53. package/fonts/PixelCode-Thin-Italic.woff2 +0 -0
  54. package/fonts/PixelCode-Thin.woff2 +0 -0
@@ -35,4 +35,22 @@ describe('Button', () => {
35
35
  expect(btn).not.toBeDisabled();
36
36
  expect(btn).toHaveAttribute('aria-disabled', 'true');
37
37
  });
38
+
39
+ test('forwards anchor props when rendered as a link', () => {
40
+ render(
41
+ <Button
42
+ href="https://example.com"
43
+ target="_blank"
44
+ rel="noopener noreferrer"
45
+ aria-label="Open docs"
46
+ >
47
+ Docs
48
+ </Button>,
49
+ );
50
+
51
+ const link = screen.getByRole('link', { name: 'Open docs' });
52
+ expect(link).toHaveAttribute('href', 'https://example.com');
53
+ expect(link).toHaveAttribute('target', '_blank');
54
+ expect(link).toHaveAttribute('rel', 'noopener noreferrer');
55
+ });
38
56
  });
@@ -39,19 +39,27 @@ interface ButtonOwnProps {
39
39
  compact?: boolean;
40
40
  /** Icon — RDNA icon name (string) or custom ReactNode */
41
41
  icon?: string | React.ReactNode;
42
- /** URL for navigation — renders as anchor element */
43
- href?: string;
44
- /** Target for link navigation */
45
- target?: string;
46
42
  /** Additional className applied to the face element */
47
43
  className?: string;
44
+ /** Applies disabled styling and disables button-mode interaction */
45
+ disabled?: boolean;
48
46
  /** Keep button focusable while disabled — use for loading states */
49
47
  focusableWhenDisabled?: boolean;
50
48
  children?: React.ReactNode;
51
49
  }
52
50
 
53
- type ButtonProps = ButtonOwnProps &
54
- Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonOwnProps>;
51
+ type ButtonButtonProps = ButtonOwnProps &
52
+ Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof ButtonOwnProps | 'href' | 'target'> & {
53
+ href?: undefined;
54
+ target?: never;
55
+ };
56
+
57
+ type ButtonAnchorProps = ButtonOwnProps &
58
+ Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>, keyof ButtonOwnProps> & {
59
+ href: string;
60
+ };
61
+
62
+ type ButtonProps = ButtonButtonProps | ButtonAnchorProps;
55
63
 
56
64
  // ============================================================================
57
65
  // CVA Variants
@@ -159,13 +167,11 @@ export function Button({
159
167
  flush = false,
160
168
  quiet = false,
161
169
  icon,
162
- href,
163
- target,
164
170
  children,
165
171
  className = '',
166
- disabled,
172
+ disabled = false,
167
173
  focusableWhenDisabled,
168
- ...props
174
+ ...elementProps
169
175
  }: ButtonProps) {
170
176
  const dataState = active ? 'selected' : 'default';
171
177
 
@@ -221,11 +227,14 @@ export function Button({
221
227
  </span>
222
228
  );
223
229
 
224
- if (href) {
230
+ if ('href' in elementProps && typeof elementProps.href === 'string') {
231
+ const { href, target, ...anchorProps } = elementProps;
232
+
225
233
  return (
226
234
  <a
227
235
  href={href}
228
236
  target={target}
237
+ {...anchorProps}
229
238
  className={rootClasses}
230
239
  data-rdna="button"
231
240
  data-slot="button-root"
@@ -252,7 +261,7 @@ export function Button({
252
261
  {...(quiet ? { 'data-quiet': '' } : {})}
253
262
  disabled={isDisabled}
254
263
  focusableWhenDisabled={focusableWhenDisabled}
255
- {...props}
264
+ {...elementProps}
256
265
  >
257
266
  {face}
258
267
  </BaseButton>
@@ -270,10 +279,11 @@ interface IconButtonOwnProps extends Omit<ButtonOwnProps, 'children' | 'icon' |
270
279
  'aria-label': string;
271
280
  }
272
281
 
273
- type IconButtonProps = IconButtonOwnProps &
274
- Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, keyof IconButtonOwnProps> & {
275
- focusableWhenDisabled?: boolean;
276
- };
282
+ type IconButtonProps = Omit<
283
+ ButtonButtonProps,
284
+ 'children' | 'icon' | 'iconOnly' | 'textOnly' | 'fullWidth' | 'href' | 'target'
285
+ > &
286
+ IconButtonOwnProps;
277
287
 
278
288
  /**
279
289
  * A square button that shows only an icon.
@@ -74,8 +74,8 @@ export function DialPanel({
74
74
  }: DialPanelProps) {
75
75
  return (
76
76
  <div className={`${width} shrink-0 border-r border-rule flex flex-col overflow-hidden ${className}`}>
77
- {header}
78
77
  <div className="flex-1 min-h-0 overflow-y-auto [&_.dialkit-panel]:!bg-transparent [&_.dialkit-panel]:!border-0 [&_.dialkit-panel]:!shadow-none">
78
+ {header}
79
79
  <DialRoot
80
80
  mode={mode}
81
81
  {...(position ? { position } : {})}
@@ -30,7 +30,7 @@ const separatorVariants = cva('shrink-0', {
30
30
  vertical: 'h-full',
31
31
  },
32
32
  variant: {
33
- solid: 'bg-line',
33
+ solid: 'bg-rule',
34
34
  dashed: 'border-rule border-dashed',
35
35
  decorated: '',
36
36
  },
@@ -283,7 +283,19 @@ function Trigger({ value, children, icon, settings, compact, className = '' }: T
283
283
  if (layout === 'accordion') {
284
284
  const isActive = activeTab === value;
285
285
  return (
286
- <div data-slot="tab-trigger" data-mode="accordion" data-state={isActive ? 'selected' : 'default'}>
286
+ <div
287
+ data-slot="tab-trigger"
288
+ data-mode="accordion"
289
+ data-state={isActive ? 'selected' : 'default'}
290
+ className={isActive
291
+ ? 'bg-card border-r border-r-card relative z-10'
292
+ : ''
293
+ }
294
+ style={isActive
295
+ ? { transform: 'translateY(-1px)', filter: 'drop-shadow(0 1px 0 var(--color-ink))' }
296
+ : { transform: 'translateY(1px)', filter: 'drop-shadow(0 -1px 0 var(--color-ink))' }
297
+ }
298
+ >
287
299
  <div className="flex items-center">
288
300
  <Button
289
301
  mode={compact ? 'pattern' : 'flat'}
@@ -315,7 +327,7 @@ function Trigger({ value, children, icon, settings, compact, className = '' }: T
315
327
  <BaseCollapsible.Panel
316
328
  className="h-[var(--collapsible-panel-height)] overflow-hidden transition-[height] duration-200 ease-out data-[ending-style]:h-0 data-[starting-style]:h-0"
317
329
  >
318
- <div className="p-2 space-y-2 bg-page">
330
+ <div className="p-2 space-y-2 bg-card">
319
331
  {settings}
320
332
  </div>
321
333
  </BaseCollapsible.Panel>
@@ -1,5 +1,6 @@
1
1
  import { render, screen } from '@testing-library/react';
2
2
  import {
3
+ AppWindow,
3
4
  Button,
4
5
  Select,
5
6
  Dialog,
@@ -26,6 +27,7 @@ import {
26
27
  test('core exports render', () => {
27
28
  render(<Button>Test</Button>);
28
29
  expect(screen.getByRole('button', { name: 'Test' })).toBeInTheDocument();
30
+ expect(AppWindow).toBeTruthy();
29
31
  expect(Select).toBeTruthy();
30
32
  expect(Dialog).toBeTruthy();
31
33
  });
@@ -1,5 +1,6 @@
1
1
  // Core component exports
2
2
  export { Alert, alertVariants } from './Alert/Alert';
3
+ export { AppWindow, AppWindowBody, AppWindowSplitView, AppWindowPane } from './AppWindow/AppWindow';
3
4
  export { Badge, badgeVariants } from './Badge/Badge';
4
5
  export { Breadcrumbs } from './Breadcrumbs/Breadcrumbs';
5
6
  export { Button, IconButton, buttonRootVariants, buttonFaceVariants } from './Button/Button';
@@ -20,7 +20,7 @@ export const radiantsSystemContract = {
20
20
  "#fcc383": "sunset-fuzz",
21
21
  "#ff6b63": "sun-red",
22
22
  "#cef5ca": "mint",
23
- "#ffffff": "pure-white",
23
+ "#fffcf3": "pure-white",
24
24
  "#22c55e": "success-mint",
25
25
  },
26
26
  hexToSemantic: {
@@ -29,7 +29,7 @@ export const radiantsSystemContract = {
29
29
  "#95bad2": { text: "link" },
30
30
  "#ff6b63": { bg: "danger", text: "danger" },
31
31
  "#cef5ca": { bg: "success", text: "success" },
32
- "#ffffff": { bg: "card" },
32
+ "#fffcf3": { bg: "card" },
33
33
  "#22c55e": { text: "success" },
34
34
  },
35
35
  oklchToSemantic: {
@@ -40,7 +40,7 @@ export const radiantsSystemContract = {
40
40
  "oklch(0.8546 0.1039 68.93)": { bg: "action-accent" },
41
41
  "oklch(0.7102 0.1823 25.87)": { bg: "action-destructive", text: "status-error" },
42
42
  "oklch(0.9312 0.0702 142.51)": { bg: "status-success", text: "status-success" },
43
- "oklch(1.0000 0.0000 0)": { bg: "card" },
43
+ "oklch(0.9909 0.0123 91.51)": { bg: "card" },
44
44
  "oklch(0.7227 0.1920 149.58)": { text: "status-success" },
45
45
  },
46
46
  removedAliases: [
@@ -134,11 +134,25 @@ export const radiantsSystemContract = {
134
134
  typography: {
135
135
  validSizes: [
136
136
  "text-xs", "text-sm", "text-base", "text-lg",
137
- "text-xl", "text-2xl", "text-3xl",
137
+ "text-xl", "text-2xl", "text-3xl", "text-4xl", "text-5xl",
138
+ "text-fluid-sm", "text-fluid-base", "text-fluid-lg",
139
+ "text-fluid-xl", "text-fluid-2xl", "text-fluid-3xl", "text-fluid-4xl",
138
140
  ],
139
141
  validWeights: [
140
142
  "font-normal", "font-medium", "font-semibold", "font-bold",
141
143
  ],
144
+ validLeading: [
145
+ "leading-tight", "leading-heading", "leading-snug",
146
+ "leading-normal", "leading-relaxed", "leading-none",
147
+ ],
148
+ validTracking: [
149
+ "tracking-tight", "tracking-normal", "tracking-wide",
150
+ ],
151
+ validFontFamilies: [
152
+ "font-sans", "font-heading", "font-mono",
153
+ "font-display", "font-caption",
154
+ "font-mondwest", "font-joystix",
155
+ ],
142
156
  },
143
157
 
144
158
  textLikeInputTypes: ["text", "email", "password", "search", "url", "tel", "number"],
package/dark.css CHANGED
@@ -38,7 +38,7 @@
38
38
  --color-content-secondary: oklch(0.9780 0.0295 94.34 / 0.85); /* was rgba(254, 248, 226, 0.85) */
39
39
  --color-content-inverted: var(--color-ink);
40
40
  --color-content-muted: oklch(0.9780 0.0295 94.34 / 0.6); /* was rgba(254, 248, 226, 0.6) — cream at 60% */
41
- --color-content-link: var(--color-sky-blue); /* Links stay sky-blue for contrast */
41
+ --color-content-link: var(--color-sky-blue); /* Links: lighter sky-blue has good contrast on dark bg */
42
42
 
43
43
  /* ============================================
44
44
  EDGE TOKENS - Adjusted for dark backgrounds
@@ -94,6 +94,16 @@
94
94
  --color-success: var(--color-mint);
95
95
  --color-warning: var(--color-sun-yellow);
96
96
 
97
+ /* ============================================
98
+ DARK MODE — Typography Adjustments
99
+ Text appears heavier on dark backgrounds.
100
+ Antialiased rendering + lighter code weight.
101
+ ============================================ */
102
+
103
+ code, pre {
104
+ font-weight: 300; /* PixelCode Light — compensates for perceived weight gain on dark bg */
105
+ }
106
+
97
107
  /* ============================================
98
108
  TEXT GLOW - Warm phosphor halo on all text
99
109
  Inherits down the tree via text-shadow
@@ -16,7 +16,7 @@ export const EMPTY_CONTRACT = Object.freeze({
16
16
  themeVariants: [],
17
17
  motion: { maxDurationMs: 0, allowedEasings: [], durationTokens: [], easingTokens: [] },
18
18
  shadows: { validStandard: [], validPixel: [], validGlow: [] },
19
- typography: { validSizes: [], validWeights: [] },
19
+ typography: { validSizes: [], validWeights: [], validLeading: [], validFontFamilies: [] },
20
20
  textLikeInputTypes: [],
21
21
  });
22
22
 
package/eslint/index.mjs CHANGED
@@ -21,6 +21,8 @@ import noMixedStyleAuthority from './rules/no-mixed-style-authority.mjs';
21
21
  import noBroadRdnaDisables from './rules/no-broad-rdna-disables.mjs';
22
22
  import noClippedShadow from './rules/no-clipped-shadow.mjs';
23
23
  import noPixelBorder from './rules/no-pixel-border.mjs';
24
+ import noRawLineHeight from './rules/no-raw-line-height.mjs';
25
+ import noRawFontFamily from './rules/no-raw-font-family.mjs';
24
26
 
25
27
  const plugin = {
26
28
  meta: {
@@ -42,6 +44,8 @@ const plugin = {
42
44
  'no-broad-rdna-disables': noBroadRdnaDisables,
43
45
  'no-clipped-shadow': noClippedShadow,
44
46
  'no-pixel-border': noPixelBorder,
47
+ 'no-raw-line-height': noRawLineHeight,
48
+ 'no-raw-font-family': noRawFontFamily,
45
49
  },
46
50
  configs: {},
47
51
  };
@@ -62,6 +66,8 @@ plugin.configs.recommended = {
62
66
  'rdna/no-hardcoded-motion': 'warn',
63
67
  'rdna/no-clipped-shadow': 'warn',
64
68
  'rdna/no-pixel-border': 'warn',
69
+ 'rdna/no-raw-line-height': 'warn',
70
+ 'rdna/no-raw-font-family': 'warn',
65
71
  },
66
72
  };
67
73
 
@@ -78,6 +84,8 @@ plugin.configs.internals = {
78
84
  'rdna/no-hardcoded-motion': 'warn',
79
85
  'rdna/no-clipped-shadow': 'warn',
80
86
  'rdna/no-pixel-border': 'warn',
87
+ 'rdna/no-raw-line-height': 'warn',
88
+ 'rdna/no-raw-font-family': 'warn',
81
89
  },
82
90
  };
83
91
 
@@ -95,6 +103,8 @@ plugin.configs['recommended-strict'] = {
95
103
  'rdna/no-hardcoded-motion': 'error',
96
104
  'rdna/no-clipped-shadow': 'error',
97
105
  'rdna/no-pixel-border': 'error',
106
+ 'rdna/no-raw-line-height': 'error',
107
+ 'rdna/no-raw-font-family': 'error',
98
108
  // no-viewport-breakpoints-in-window-layout is intentionally excluded —
99
109
  // it is RadOS-specific and must be scoped via eslint.rdna.config.mjs
100
110
  },
@@ -0,0 +1,91 @@
1
+ /**
2
+ * rdna/no-raw-font-family
3
+ * Bans hardcoded font-family values in style props.
4
+ * Allows CSS variable references (var(--font-*)) and Tailwind font classes.
5
+ * Exempts files that import from @chenglou/pretext (canvas measurement needs literal names).
6
+ */
7
+ import {
8
+ getObjectPropertyKey,
9
+ getStaticStringValue,
10
+ getStyleObjectExpression,
11
+ isAllowedCssVar,
12
+ isDynamicTemplateLiteral,
13
+ } from '../utils.mjs';
14
+
15
+ // Valid Tailwind font-family utility classes (not checked here —
16
+ // className font-* classes are already semantic aliases and always valid).
17
+ // This rule focuses on style={{ fontFamily: ... }} enforcement.
18
+
19
+ const rule = {
20
+ meta: {
21
+ type: 'problem',
22
+ docs: {
23
+ description: 'Ban hardcoded font-family in style props; require RDNA font tokens',
24
+ },
25
+ messages: {
26
+ hardcodedFontFamily:
27
+ 'Hardcoded font-family in style prop. Use a CSS variable: var(--font-sans), var(--font-heading), var(--font-mono), var(--font-blackletter), var(--font-pixeloid).',
28
+ },
29
+ schema: [],
30
+ },
31
+
32
+ create(context) {
33
+ // Check if this file imports from @chenglou/pretext — if so, exempt it entirely.
34
+ // Pretext needs literal font names for canvas measurement.
35
+ let hasPretextImport = false;
36
+
37
+ return {
38
+ ImportDeclaration(node) {
39
+ if (
40
+ node.source &&
41
+ typeof node.source.value === 'string' &&
42
+ node.source.value.startsWith('@chenglou/pretext')
43
+ ) {
44
+ hasPretextImport = true;
45
+ }
46
+ },
47
+ CallExpression(node) {
48
+ // Also check dynamic require('@chenglou/pretext')
49
+ if (
50
+ node.callee.name === 'require' &&
51
+ node.arguments.length > 0 &&
52
+ node.arguments[0].type === 'Literal' &&
53
+ typeof node.arguments[0].value === 'string' &&
54
+ node.arguments[0].value.startsWith('@chenglou/pretext')
55
+ ) {
56
+ hasPretextImport = true;
57
+ }
58
+ },
59
+ JSXAttribute(node) {
60
+ if (hasPretextImport) return;
61
+ if (node.name.name === 'style') checkStyleObject(context, node.value);
62
+ },
63
+ };
64
+ },
65
+ };
66
+
67
+ function checkStyleObject(context, valueNode) {
68
+ const expr = getStyleObjectExpression(valueNode);
69
+ if (!expr) return;
70
+
71
+ for (const prop of expr.properties) {
72
+ const key = getObjectPropertyKey(prop);
73
+ if (key !== 'fontFamily') continue;
74
+
75
+ const val = prop.value;
76
+
77
+ const staticString = getStaticStringValue(val);
78
+ if (staticString !== null) {
79
+ // Allow any var(--font-*) reference
80
+ if (isAllowedCssVar(staticString, 'font-')) continue;
81
+ context.report({ node: val, messageId: 'hardcodedFontFamily' });
82
+ continue;
83
+ }
84
+
85
+ if (isDynamicTemplateLiteral(val)) {
86
+ context.report({ node: val, messageId: 'hardcodedFontFamily' });
87
+ }
88
+ }
89
+ }
90
+
91
+ export default rule;
@@ -0,0 +1,119 @@
1
+ /**
2
+ * rdna/no-raw-line-height
3
+ * Bans arbitrary line-height values in className and style props.
4
+ * Allows only RDNA token-mapped leading-* classes and CSS variable references.
5
+ */
6
+ import {
7
+ getClassNameStrings,
8
+ getObjectPropertyKey,
9
+ getStaticStringValue,
10
+ getStyleObjectExpression,
11
+ isAllowedCssVar,
12
+ isDynamicTemplateLiteral,
13
+ isInsideClassNameAttribute,
14
+ } from '../utils.mjs';
15
+
16
+ // Optional Tailwind modifier prefix: hover:, dark:, md:, focus:, etc. (stackable)
17
+ const MOD = '(?:[\\w-]+:)*';
18
+
19
+ // Matches arbitrary leading values: leading-[24px], leading-[1.4], leading-[1.4rem], etc.
20
+ const ARBITRARY_LEADING_CLASS = new RegExp(`${MOD}leading-\\[[^\\]]+\\]`, 'g');
21
+
22
+ // Token-mapped leading classes that are allowed
23
+ const VALID_LEADING_CLASSES = new Set([
24
+ 'leading-tight',
25
+ 'leading-heading',
26
+ 'leading-snug',
27
+ 'leading-normal',
28
+ 'leading-relaxed',
29
+ 'leading-none',
30
+ ]);
31
+
32
+ const validSuggestion = [...VALID_LEADING_CLASSES].join(', ');
33
+
34
+ /**
35
+ * Strip all Tailwind modifier prefixes (hover:, dark:, md:, etc.) from a class.
36
+ */
37
+ function stripModifiers(cls) {
38
+ return cls.replace(/^(?:[\w-]+:)+/, '');
39
+ }
40
+
41
+ const rule = {
42
+ meta: {
43
+ type: 'problem',
44
+ docs: {
45
+ description: 'Ban arbitrary line-height values; require RDNA leading tokens',
46
+ },
47
+ messages: {
48
+ arbitraryLeading:
49
+ `Arbitrary line-height "{{raw}}" in className. Use an RDNA leading token: ${validSuggestion}.`,
50
+ hardcodedLineHeightStyle:
51
+ `Hardcoded line-height in style prop. Use a CSS variable: var(--leading-*).`,
52
+ },
53
+ schema: [],
54
+ },
55
+
56
+ create(context) {
57
+ return {
58
+ JSXAttribute(node) {
59
+ if (node.name.name === 'className') checkClassName(context, node.value);
60
+ if (node.name.name === 'style') checkStyleObject(context, node.value);
61
+ },
62
+ CallExpression(node) {
63
+ if (!isInsideClassNameAttribute(node)) checkClassName(context, node);
64
+ },
65
+ };
66
+ },
67
+ };
68
+
69
+ function checkClassName(context, valueNode) {
70
+ if (!valueNode) return;
71
+
72
+ const strings = getClassNameStrings(valueNode);
73
+ for (const { value, node } of strings) {
74
+ ARBITRARY_LEADING_CLASS.lastIndex = 0;
75
+ let match;
76
+ while ((match = ARBITRARY_LEADING_CLASS.exec(value)) !== null) {
77
+ const bare = stripModifiers(match[0]);
78
+ // Allow if it's a valid token-mapped class (shouldn't match the regex, but defensive)
79
+ if (VALID_LEADING_CLASSES.has(bare)) continue;
80
+ context.report({
81
+ node,
82
+ messageId: 'arbitraryLeading',
83
+ data: { raw: match[0] },
84
+ });
85
+ }
86
+ }
87
+ }
88
+
89
+ function checkStyleObject(context, valueNode) {
90
+ const expr = getStyleObjectExpression(valueNode);
91
+ if (!expr) return;
92
+
93
+ for (const prop of expr.properties) {
94
+ const key = getObjectPropertyKey(prop);
95
+ if (key !== 'lineHeight') continue;
96
+
97
+ const val = prop.value;
98
+
99
+ // Allow numeric literals that are unitless (CSS lineHeight as number)
100
+ // — still flag them; RDNA wants token usage
101
+ if (val.type === 'Literal' && typeof val.value === 'number') {
102
+ context.report({ node: val, messageId: 'hardcodedLineHeightStyle' });
103
+ continue;
104
+ }
105
+
106
+ const staticString = getStaticStringValue(val);
107
+ if (staticString !== null) {
108
+ if (isAllowedCssVar(staticString, 'leading-')) continue;
109
+ context.report({ node: val, messageId: 'hardcodedLineHeightStyle' });
110
+ continue;
111
+ }
112
+
113
+ if (isDynamicTemplateLiteral(val)) {
114
+ context.report({ node: val, messageId: 'hardcodedLineHeightStyle' });
115
+ }
116
+ }
117
+ }
118
+
119
+ export default rule;
package/fonts-core.css ADDED
@@ -0,0 +1,70 @@
1
+ /* =============================================================================
2
+ fonts-core.css - Core token-system fonts (initial load)
3
+ Mondwest (body), Joystix (heading), PixelCode (mono)
4
+ ============================================================================= */
5
+
6
+ /* Mondwest - Body font (must be downloaded separately)
7
+ Get it at: https://pangrampangram.com/products/bitmap-mondwest
8
+ ----------------------------------------------------------------------------- */
9
+
10
+ @font-face {
11
+ font-family: 'Mondwest';
12
+ src: url('./fonts/Mondwest.woff2') format('woff2');
13
+ font-weight: 400;
14
+ font-style: normal;
15
+ font-display: swap;
16
+ }
17
+
18
+ @font-face {
19
+ font-family: 'Mondwest';
20
+ src: url('./fonts/Mondwest-Bold.woff2') format('woff2');
21
+ font-weight: 700;
22
+ font-style: normal;
23
+ font-display: swap;
24
+ }
25
+
26
+ /* Joystix - Heading font
27
+ ----------------------------------------------------------------------------- */
28
+
29
+ @font-face {
30
+ font-family: 'Joystix Monospace';
31
+ src: url('./fonts/Joystix.woff2') format('woff2');
32
+ font-weight: 400;
33
+ font-style: normal;
34
+ font-display: swap;
35
+ }
36
+
37
+ /* PixelCode - Monospace font
38
+ ----------------------------------------------------------------------------- */
39
+
40
+ @font-face {
41
+ font-family: 'PixelCode';
42
+ src: url('./fonts/PixelCode.woff2') format('woff2');
43
+ font-weight: 400;
44
+ font-style: normal;
45
+ font-display: swap;
46
+ }
47
+
48
+ @font-face {
49
+ font-family: 'PixelCode';
50
+ src: url('./fonts/PixelCode-Italic.woff2') format('woff2');
51
+ font-weight: 400;
52
+ font-style: italic;
53
+ font-display: swap;
54
+ }
55
+
56
+ @font-face {
57
+ font-family: 'PixelCode';
58
+ src: url('./fonts/PixelCode-Bold.woff2') format('woff2');
59
+ font-weight: 700;
60
+ font-style: normal;
61
+ font-display: swap;
62
+ }
63
+
64
+ @font-face {
65
+ font-family: 'PixelCode';
66
+ src: url('./fonts/PixelCode-Bold-Italic.woff2') format('woff2');
67
+ font-weight: 700;
68
+ font-style: italic;
69
+ font-display: swap;
70
+ }
@@ -0,0 +1,45 @@
1
+ /* =============================================================================
2
+ fonts-editorial.css - Display/editorial fonts (lazy load on app-window open)
3
+ Waves Blackletter CPC, Waves Tiny CPC, Pixeloid Sans
4
+ ============================================================================= */
5
+
6
+ /* Waves Blackletter CPC - Decorative blackletter
7
+ ----------------------------------------------------------------------------- */
8
+
9
+ @font-face {
10
+ font-family: 'Waves Blackletter CPC';
11
+ src: url('./fonts/WavesBlackletterCPC-Base.woff2') format('woff2');
12
+ font-weight: 400;
13
+ font-style: normal;
14
+ font-display: optional;
15
+ }
16
+
17
+ /* Waves Tiny CPC - Decorative pixel caption
18
+ ----------------------------------------------------------------------------- */
19
+
20
+ @font-face {
21
+ font-family: 'Waves Tiny CPC';
22
+ src: url('./fonts/WavesTinyCPC-Extended.woff2') format('woff2');
23
+ font-weight: 400;
24
+ font-style: normal;
25
+ font-display: optional;
26
+ }
27
+
28
+ /* Pixeloid Sans - Informational pixel text (bylines, labels)
29
+ ----------------------------------------------------------------------------- */
30
+
31
+ @font-face {
32
+ font-family: 'Pixeloid Sans';
33
+ src: url('./fonts/PixeloidSans.woff2') format('woff2');
34
+ font-weight: 400;
35
+ font-style: normal;
36
+ font-display: swap;
37
+ }
38
+
39
+ @font-face {
40
+ font-family: 'Pixeloid Sans';
41
+ src: url('./fonts/PixeloidSans-Bold.woff2') format('woff2');
42
+ font-weight: 700;
43
+ font-style: normal;
44
+ font-display: swap;
45
+ }