@scalepad/ui 0.1.0 → 0.2.0
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.
- package/README.md +226 -28
- package/package.json +150 -38
- package/scripts/verify-consumption.mjs +242 -0
- package/src/ThemeProvider.tsx +53 -5
- package/src/components/Anchor/Anchor.css.ts +163 -0
- package/src/components/Anchor/Anchor.figma.tsx +57 -0
- package/src/components/Anchor/Anchor.tsx +114 -13
- package/src/components/Anchor/index.ts +1 -1
- package/src/components/FilterMenu/FilterSubMenuTypes/SearchableFilterSubmenu.tsx +8 -5
- package/src/components/FilterMenu/helpers.ts +5 -2
- package/src/components/IconButton/IconButton.tsx +51 -0
- package/src/components/SubNavigation/SubNavigation.css.ts +20 -0
- package/src/components/Typography/Text.tsx +2 -4
- package/src/components/Typography/Title.tsx +2 -4
- package/src/index.ts +3 -1
- package/src/inter-font.ts +21 -0
- package/src/mantine.ts +2 -0
- package/src/theme/themeContract.css.ts +27 -3
- package/src/tokens/color-types.ts +28 -5
- package/src/tokens/colors.ts +52 -10
- package/src/tokens/semantic-colors.ts +177 -73
- package/src/tokens/semantic-tokens-css.ts +34 -12
- package/src/tokens/shadows.ts +30 -6
- package/src/utils/typography-props.ts +19 -0
- package/src/vite.d.ts +37 -0
- package/src/vite.js +79 -0
- package/src/geist-fonts.ts +0 -48
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* verify-consumption
|
|
4
|
+
*
|
|
5
|
+
* Smoke test that catches the class of bug a colleague hit when consuming
|
|
6
|
+
* `@scalepad/ui` from a fresh project:
|
|
7
|
+
*
|
|
8
|
+
* - Missing transitive peer dependencies (e.g. @tiptap/core, @tiptap/extensions)
|
|
9
|
+
* - Vite setup gaps (vanilla-extract plugin not wired up, optimizeDeps not configured)
|
|
10
|
+
*
|
|
11
|
+
* What it does:
|
|
12
|
+
* 1. `pnpm pack` the local `@scalepad/ui` and `@scalepad/ui-utils` workspaces.
|
|
13
|
+
* 2. Create a temp Vite + React 19 project in the OS temp dir.
|
|
14
|
+
* 3. Install the local tarballs plus the required peers from the README.
|
|
15
|
+
* 4. Write a minimal `App.tsx` that imports `ThemeProvider`, `Button`,
|
|
16
|
+
* `showToast`, and `SlashRichTextEditor` (so all the optional-peer
|
|
17
|
+
* code paths are exercised).
|
|
18
|
+
* 5. Run `vite build`. If exit code is non-zero, this script fails.
|
|
19
|
+
*
|
|
20
|
+
* Intended use:
|
|
21
|
+
* `pnpm --filter @scalepad/ui run verify:consumption` before publishing
|
|
22
|
+
* a new version. Not wired into CI yet; we'll iterate on speed first.
|
|
23
|
+
*/
|
|
24
|
+
import { spawnSync } from 'node:child_process';
|
|
25
|
+
import { existsSync, mkdirSync, mkdtempSync, readdirSync, writeFileSync } from 'node:fs';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { dirname, join, resolve } from 'node:path';
|
|
28
|
+
import { fileURLToPath } from 'node:url';
|
|
29
|
+
|
|
30
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const pkgRoot = resolve(__dirname, '..');
|
|
32
|
+
const monorepoRoot = resolve(pkgRoot, '..', '..');
|
|
33
|
+
const utilsRoot = resolve(monorepoRoot, 'packages', 'utils');
|
|
34
|
+
|
|
35
|
+
const log = (msg) => console.log(`[verify-consumption] ${msg}`);
|
|
36
|
+
const die = (msg) => {
|
|
37
|
+
console.error(`[verify-consumption] FAIL: ${msg}`);
|
|
38
|
+
process.exit(1);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
function run(cmd, args, opts = {}) {
|
|
42
|
+
const display = `${cmd} ${args.join(' ')}`;
|
|
43
|
+
log(`$ ${display} (cwd=${opts.cwd ?? process.cwd()})`);
|
|
44
|
+
const result = spawnSync(cmd, args, {
|
|
45
|
+
stdio: 'inherit',
|
|
46
|
+
encoding: 'utf8',
|
|
47
|
+
...opts,
|
|
48
|
+
});
|
|
49
|
+
if (result.status !== 0) {
|
|
50
|
+
die(`${display} exited with status ${result.status}`);
|
|
51
|
+
}
|
|
52
|
+
return result;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function findTarball(dir, prefix) {
|
|
56
|
+
const matches = readdirSync(dir).filter(
|
|
57
|
+
(f) => f.startsWith(prefix) && f.endsWith('.tgz'),
|
|
58
|
+
);
|
|
59
|
+
if (matches.length === 0) {
|
|
60
|
+
die(`no ${prefix}*.tgz produced in ${dir}`);
|
|
61
|
+
}
|
|
62
|
+
if (matches.length > 1) {
|
|
63
|
+
log(`warning: multiple ${prefix}*.tgz found, using newest`);
|
|
64
|
+
}
|
|
65
|
+
// sort by mtime via spawnSync(ls -t) would be heavier; for two same-run packs,
|
|
66
|
+
// alphabetical reverse-sort gets the higher version number.
|
|
67
|
+
matches.sort().reverse();
|
|
68
|
+
return join(dir, matches[0]);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 1. Pack both workspaces.
|
|
72
|
+
log('packing @scalepad/ui and @scalepad/ui-utils...');
|
|
73
|
+
run('pnpm', ['pack', '--pack-destination', pkgRoot], { cwd: pkgRoot });
|
|
74
|
+
run('pnpm', ['pack', '--pack-destination', utilsRoot], { cwd: utilsRoot });
|
|
75
|
+
|
|
76
|
+
const uiTarball = findTarball(pkgRoot, 'scalepad-ui-');
|
|
77
|
+
const utilsTarball = findTarball(utilsRoot, 'scalepad-ui-utils-');
|
|
78
|
+
log(`ui tarball = ${uiTarball}`);
|
|
79
|
+
log(`utils tarball= ${utilsTarball}`);
|
|
80
|
+
|
|
81
|
+
// 2. Make a temp project.
|
|
82
|
+
const projectDir = mkdtempSync(join(tmpdir(), 'scalepad-ui-verify-'));
|
|
83
|
+
log(`temp project = ${projectDir}`);
|
|
84
|
+
mkdirSync(join(projectDir, 'src'), { recursive: true });
|
|
85
|
+
|
|
86
|
+
// 3. Write package.json with the README's required + optional peers.
|
|
87
|
+
const projectPkg = {
|
|
88
|
+
name: 'scalepad-ui-verify',
|
|
89
|
+
private: true,
|
|
90
|
+
version: '0.0.0',
|
|
91
|
+
type: 'module',
|
|
92
|
+
scripts: {
|
|
93
|
+
build: 'vite build',
|
|
94
|
+
},
|
|
95
|
+
// Required peers only. Mantine, recharts, clsx, react-intersection-observer
|
|
96
|
+
// come in as runtime deps of @scalepad/ui — consumers do NOT install them.
|
|
97
|
+
// Tiptap is an optional peer; this smoke test exercises the rich-text editor
|
|
98
|
+
// entry points, so we install the full Tiptap set here.
|
|
99
|
+
dependencies: {
|
|
100
|
+
'@scalepad/ui': `file:${uiTarball}`,
|
|
101
|
+
'@scalepad/ui-utils': `file:${utilsTarball}`,
|
|
102
|
+
'@tanstack/react-query': '^5.0.0',
|
|
103
|
+
'@tanstack/react-table': '^8.21.3',
|
|
104
|
+
'@vanilla-extract/css': '^1.16.2',
|
|
105
|
+
dayjs: '^1.11.19',
|
|
106
|
+
'lucide-react': '^0.469.0',
|
|
107
|
+
react: '^19.0.0',
|
|
108
|
+
'react-dom': '^19.0.0',
|
|
109
|
+
'@tiptap/core': '^3.22.3',
|
|
110
|
+
'@tiptap/extensions': '^3.22.3',
|
|
111
|
+
'@tiptap/pm': '^3.22.3',
|
|
112
|
+
'@tiptap/react': '^3.22.3',
|
|
113
|
+
'@tiptap/starter-kit': '^3.22.3',
|
|
114
|
+
'@tiptap/suggestion': '^3.22.3',
|
|
115
|
+
'@tiptap/extension-blockquote': '^3.22.3',
|
|
116
|
+
'@tiptap/extension-bold': '^3.22.3',
|
|
117
|
+
'@tiptap/extension-bubble-menu': '^3.22.3',
|
|
118
|
+
'@tiptap/extension-bullet-list': '^3.22.3',
|
|
119
|
+
'@tiptap/extension-code': '^3.22.3',
|
|
120
|
+
'@tiptap/extension-code-block': '^3.22.3',
|
|
121
|
+
'@tiptap/extension-document': '^3.22.3',
|
|
122
|
+
'@tiptap/extension-dropcursor': '^3.22.3',
|
|
123
|
+
'@tiptap/extension-gapcursor': '^3.22.3',
|
|
124
|
+
'@tiptap/extension-hard-break': '^3.22.3',
|
|
125
|
+
'@tiptap/extension-heading': '^3.22.3',
|
|
126
|
+
'@tiptap/extension-horizontal-rule': '^3.22.3',
|
|
127
|
+
'@tiptap/extension-image': '^3.22.3',
|
|
128
|
+
'@tiptap/extension-italic': '^3.22.3',
|
|
129
|
+
'@tiptap/extension-link': '^3.22.3',
|
|
130
|
+
'@tiptap/extension-list': '^3.22.3',
|
|
131
|
+
'@tiptap/extension-list-item': '^3.22.3',
|
|
132
|
+
'@tiptap/extension-list-keymap': '^3.22.3',
|
|
133
|
+
'@tiptap/extension-ordered-list': '^3.22.3',
|
|
134
|
+
'@tiptap/extension-paragraph': '^3.22.3',
|
|
135
|
+
'@tiptap/extension-placeholder': '^3.22.3',
|
|
136
|
+
'@tiptap/extension-strike': '^3.22.3',
|
|
137
|
+
'@tiptap/extension-text': '^3.22.3',
|
|
138
|
+
'@tiptap/extension-underline': '^3.22.3',
|
|
139
|
+
},
|
|
140
|
+
devDependencies: {
|
|
141
|
+
'@vitejs/plugin-react': '^5.0.4',
|
|
142
|
+
'@types/react': '^19.2.2',
|
|
143
|
+
'@types/react-dom': '^19.2.1',
|
|
144
|
+
typescript: '^6.0.3',
|
|
145
|
+
vite: '^7.1.9',
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
writeFileSync(
|
|
149
|
+
join(projectDir, 'package.json'),
|
|
150
|
+
JSON.stringify(projectPkg, null, 2),
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
writeFileSync(
|
|
154
|
+
join(projectDir, 'tsconfig.json'),
|
|
155
|
+
JSON.stringify(
|
|
156
|
+
{
|
|
157
|
+
compilerOptions: {
|
|
158
|
+
target: 'ES2022',
|
|
159
|
+
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
|
160
|
+
jsx: 'react-jsx',
|
|
161
|
+
module: 'ESNext',
|
|
162
|
+
moduleResolution: 'bundler',
|
|
163
|
+
strict: true,
|
|
164
|
+
esModuleInterop: true,
|
|
165
|
+
skipLibCheck: true,
|
|
166
|
+
noEmit: true,
|
|
167
|
+
resolveJsonModule: true,
|
|
168
|
+
isolatedModules: true,
|
|
169
|
+
},
|
|
170
|
+
include: ['src'],
|
|
171
|
+
},
|
|
172
|
+
null,
|
|
173
|
+
2,
|
|
174
|
+
),
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
writeFileSync(
|
|
178
|
+
join(projectDir, 'vite.config.ts'),
|
|
179
|
+
`import react from '@vitejs/plugin-react';
|
|
180
|
+
import { defineConfig } from 'vite';
|
|
181
|
+
import { scalepadUi } from '@scalepad/ui/vite';
|
|
182
|
+
|
|
183
|
+
export default defineConfig({
|
|
184
|
+
plugins: [react(), scalepadUi()],
|
|
185
|
+
});
|
|
186
|
+
`,
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
writeFileSync(
|
|
190
|
+
join(projectDir, 'index.html'),
|
|
191
|
+
`<!doctype html>
|
|
192
|
+
<html>
|
|
193
|
+
<head><meta charset="utf-8" /><title>verify</title></head>
|
|
194
|
+
<body>
|
|
195
|
+
<div id="root"></div>
|
|
196
|
+
<script type="module" src="/src/main.tsx"></script>
|
|
197
|
+
</body>
|
|
198
|
+
</html>
|
|
199
|
+
`,
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
writeFileSync(
|
|
203
|
+
join(projectDir, 'src', 'main.tsx'),
|
|
204
|
+
`import '@mantine/core/styles.css';
|
|
205
|
+
import { createRoot } from 'react-dom/client';
|
|
206
|
+
import {
|
|
207
|
+
Button,
|
|
208
|
+
Notifications,
|
|
209
|
+
showSuccessToast,
|
|
210
|
+
SlashRichTextEditor,
|
|
211
|
+
ThemeProvider,
|
|
212
|
+
} from '@scalepad/ui';
|
|
213
|
+
|
|
214
|
+
function App() {
|
|
215
|
+
return (
|
|
216
|
+
<ThemeProvider>
|
|
217
|
+
<Notifications />
|
|
218
|
+
<Button onClick={() => showSuccessToast({ title: 'hi', message: 'world' })}>
|
|
219
|
+
Click
|
|
220
|
+
</Button>
|
|
221
|
+
<SlashRichTextEditor commands={[]} placeholder="type" />
|
|
222
|
+
</ThemeProvider>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
createRoot(document.getElementById('root')!).render(<App />);
|
|
227
|
+
`,
|
|
228
|
+
);
|
|
229
|
+
|
|
230
|
+
// 4. Install. Use npm so we don't depend on the user's pnpm workspace config;
|
|
231
|
+
// file: deps work cleanly under npm and don't leak the monorepo's hoisting.
|
|
232
|
+
log('installing in temp project (this is slow on cold cache)...');
|
|
233
|
+
run('npm', ['install', '--no-audit', '--no-fund', '--legacy-peer-deps=false'], {
|
|
234
|
+
cwd: projectDir,
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
// 5. Build.
|
|
238
|
+
log('building...');
|
|
239
|
+
run('npx', ['vite', 'build'], { cwd: projectDir });
|
|
240
|
+
|
|
241
|
+
log('OK — built successfully.');
|
|
242
|
+
log(`(temp project left at ${projectDir} for inspection; delete when done)`);
|
package/src/ThemeProvider.tsx
CHANGED
|
@@ -7,11 +7,16 @@ import '@mantine/notifications/styles.css';
|
|
|
7
7
|
// oxlint-disable-next-line import/no-unassigned-import
|
|
8
8
|
import '@mantine/schedule/styles.css';
|
|
9
9
|
// oxlint-disable-next-line import/no-unassigned-import
|
|
10
|
-
import './
|
|
10
|
+
import './inter-font';
|
|
11
11
|
|
|
12
|
-
import type
|
|
12
|
+
import { useMemo, type ReactNode } from 'react';
|
|
13
13
|
|
|
14
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
localStorageColorSchemeManager,
|
|
16
|
+
MantineProvider,
|
|
17
|
+
type MantineColorScheme,
|
|
18
|
+
type MantineThemeOverride,
|
|
19
|
+
} from '@mantine/core';
|
|
15
20
|
|
|
16
21
|
import { mantineTheme } from './theme';
|
|
17
22
|
import { semanticColorsCss } from './tokens/semantic-colors';
|
|
@@ -20,15 +25,58 @@ import { semanticTokensCss } from './tokens/semantic-tokens-css';
|
|
|
20
25
|
export interface ThemeProviderProps {
|
|
21
26
|
children: ReactNode;
|
|
22
27
|
theme?: MantineThemeOverride;
|
|
28
|
+
/**
|
|
29
|
+
* Initial color scheme used when no persisted value is found. Defaults to
|
|
30
|
+
* `'auto'` so a fresh visitor follows their OS appearance preference.
|
|
31
|
+
*/
|
|
32
|
+
defaultColorScheme?: MantineColorScheme;
|
|
33
|
+
/**
|
|
34
|
+
* localStorage key used to persist the user's color scheme choice. Apps that
|
|
35
|
+
* embed ThemeProvider can namespace this to keep multiple apps from sharing
|
|
36
|
+
* a key on the same origin.
|
|
37
|
+
*/
|
|
38
|
+
colorSchemeStorageKey?: string;
|
|
23
39
|
}
|
|
24
40
|
|
|
25
|
-
export function ThemeProvider({
|
|
41
|
+
export function ThemeProvider({
|
|
42
|
+
children,
|
|
43
|
+
theme,
|
|
44
|
+
defaultColorScheme = 'auto',
|
|
45
|
+
colorSchemeStorageKey = 'scalepad-ui.color-scheme',
|
|
46
|
+
}: ThemeProviderProps) {
|
|
47
|
+
const colorSchemeManager = useMemo(
|
|
48
|
+
() => localStorageColorSchemeManager({ key: colorSchemeStorageKey }),
|
|
49
|
+
[colorSchemeStorageKey],
|
|
50
|
+
);
|
|
51
|
+
|
|
26
52
|
return (
|
|
27
|
-
<MantineProvider
|
|
53
|
+
<MantineProvider
|
|
54
|
+
theme={theme || mantineTheme}
|
|
55
|
+
defaultColorScheme={defaultColorScheme}
|
|
56
|
+
colorSchemeManager={colorSchemeManager}
|
|
57
|
+
>
|
|
28
58
|
{/* Inject Figma semantic color tokens as CSS custom properties */}
|
|
29
59
|
<style dangerouslySetInnerHTML={{ __html: semanticColorsCss }} />
|
|
30
60
|
{/* Inject semantic radius, spacing, shadows, z-index, font-family */}
|
|
31
61
|
<style dangerouslySetInnerHTML={{ __html: semanticTokensCss }} />
|
|
62
|
+
{/*
|
|
63
|
+
Global font / glyph rendering. macOS defaults to subpixel
|
|
64
|
+
antialiasing which makes light text on dark surfaces look
|
|
65
|
+
bolded/blurry, and Mantine's reset sometimes overrides body-level
|
|
66
|
+
inline styles. Forcing grayscale antialiasing + optimizeLegibility
|
|
67
|
+
keeps text crisp in both light and dark mode.
|
|
68
|
+
*/}
|
|
69
|
+
<style
|
|
70
|
+
dangerouslySetInnerHTML={{
|
|
71
|
+
__html: `
|
|
72
|
+
html, body {
|
|
73
|
+
-webkit-font-smoothing: antialiased;
|
|
74
|
+
-moz-osx-font-smoothing: grayscale;
|
|
75
|
+
text-rendering: optimizeLegibility;
|
|
76
|
+
}
|
|
77
|
+
`,
|
|
78
|
+
}}
|
|
79
|
+
/>
|
|
32
80
|
{/* Override Mantine Table hover to use semantic primary light */}
|
|
33
81
|
<style
|
|
34
82
|
dangerouslySetInnerHTML={{
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anchor component styles – vanilla-extract with semantic design tokens.
|
|
3
|
+
*
|
|
4
|
+
* Anchor is a polymorphic component that renders as `<a>` (default) or
|
|
5
|
+
* `<button>`. It covers two related use cases with the same visual model:
|
|
6
|
+
* inline links inside body copy, and text-only "buttons" (no fill, no
|
|
7
|
+
* border) like the mockup's "Generate" / "Regenerate".
|
|
8
|
+
*
|
|
9
|
+
* Typography is driven entirely by `variant` (BodyVariant) — one class per
|
|
10
|
+
* variant generated from `textStyleVariants`. There is no inline `fz`/`fw`
|
|
11
|
+
* forwarding; the variant class is the only path that sets typography.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { style, styleVariants } from '@vanilla-extract/css';
|
|
15
|
+
|
|
16
|
+
import { textStyleVariants, type BodyVariant } from '../../tokens/text-styles';
|
|
17
|
+
import { mantineVars } from '../../theme/mantineVars';
|
|
18
|
+
import { tokens } from '../../theme/themeContract.css';
|
|
19
|
+
|
|
20
|
+
const focusRing = {
|
|
21
|
+
outline: `2px solid ${tokens.color.stroke.focusStrong}`,
|
|
22
|
+
outlineOffset: 2,
|
|
23
|
+
borderRadius: tokens.radius.sm,
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Base styles applied to every Anchor: clear underline at rest (the design
|
|
28
|
+
* system's links underline only on interaction), reset background/border so
|
|
29
|
+
* `component="button"` looks identical to `<a>`, and apply the focus ring
|
|
30
|
+
* + hover underline.
|
|
31
|
+
*/
|
|
32
|
+
export const root = style({
|
|
33
|
+
backgroundColor: 'transparent',
|
|
34
|
+
border: 'none',
|
|
35
|
+
padding: 0,
|
|
36
|
+
margin: 0,
|
|
37
|
+
cursor: 'pointer',
|
|
38
|
+
textDecoration: 'none',
|
|
39
|
+
textDecorationThickness: '1px',
|
|
40
|
+
textUnderlineOffset: '2px',
|
|
41
|
+
transition: 'color 150ms ease, text-decoration-color 150ms ease',
|
|
42
|
+
selectors: {
|
|
43
|
+
'&:hover': {
|
|
44
|
+
textDecoration: 'underline',
|
|
45
|
+
},
|
|
46
|
+
'&:focus-visible': focusRing,
|
|
47
|
+
},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* One class per BodyVariant — typography is generated from the same
|
|
52
|
+
* `textStyleVariants` source that `Text` and `Title` use, so they cannot
|
|
53
|
+
* drift.
|
|
54
|
+
*/
|
|
55
|
+
export const variant = styleVariants(textStyleVariants, styles => ({
|
|
56
|
+
fontFamily: styles.fontFamily,
|
|
57
|
+
fontWeight: styles.fontWeight,
|
|
58
|
+
fontSize: styles.fontSize,
|
|
59
|
+
lineHeight: styles.lineHeight,
|
|
60
|
+
letterSpacing: styles.letterSpacing,
|
|
61
|
+
textTransform: 'textTransform' in styles ? styles.textTransform : undefined,
|
|
62
|
+
})) satisfies Record<BodyVariant, string>;
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* One class per AnchorTone. Color at rest + hover. The `:hover` underline
|
|
66
|
+
* comes from `root`; tone classes only set `color`. Dark-mode overrides
|
|
67
|
+
* pin to the same semantic tokens (which already swap per theme via the
|
|
68
|
+
* theme contract).
|
|
69
|
+
*/
|
|
70
|
+
export const tone = styleVariants({
|
|
71
|
+
default: {
|
|
72
|
+
color: tokens.color.text.default,
|
|
73
|
+
selectors: {
|
|
74
|
+
'&:hover': { color: tokens.color.text.primaryDefault },
|
|
75
|
+
'&:focus-visible': { color: tokens.color.text.primaryDefault },
|
|
76
|
+
[`${mantineVars.darkSelector} &`]: { color: tokens.color.text.default },
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
primary: {
|
|
80
|
+
color: tokens.color.text.primaryDefault,
|
|
81
|
+
selectors: {
|
|
82
|
+
'&:hover': { color: tokens.color.text.primaryLight },
|
|
83
|
+
'&:focus-visible': { color: tokens.color.text.primaryLight },
|
|
84
|
+
[`${mantineVars.darkSelector} &`]: {
|
|
85
|
+
color: tokens.color.text.primaryDefault,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
danger: {
|
|
90
|
+
color: tokens.color.text.dangerDefault,
|
|
91
|
+
selectors: {
|
|
92
|
+
'&:hover': { color: tokens.color.text.dangerStrong },
|
|
93
|
+
'&:focus-visible': { color: tokens.color.text.dangerStrong },
|
|
94
|
+
[`${mantineVars.darkSelector} &`]: {
|
|
95
|
+
color: tokens.color.text.dangerDefault,
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
subdued: {
|
|
100
|
+
color: tokens.color.text.subduedStrong,
|
|
101
|
+
selectors: {
|
|
102
|
+
'&:hover': { color: tokens.color.text.default },
|
|
103
|
+
'&:focus-visible': { color: tokens.color.text.default },
|
|
104
|
+
[`${mantineVars.darkSelector} &`]: {
|
|
105
|
+
color: tokens.color.text.subduedStrong,
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Layout for the case where `leftSection` and/or `rightSection` is set.
|
|
113
|
+
* Without icons we leave the root as plain inline text so it flows
|
|
114
|
+
* naturally inside a paragraph.
|
|
115
|
+
*/
|
|
116
|
+
export const withIcons = style({
|
|
117
|
+
display: 'inline-flex',
|
|
118
|
+
alignItems: 'center',
|
|
119
|
+
verticalAlign: 'baseline',
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
/** Gap between icon section(s) and label, sized to match Figma `Anchor`. */
|
|
123
|
+
export const gap = styleVariants({
|
|
124
|
+
xs: { gap: 4 },
|
|
125
|
+
sm: { gap: 4 },
|
|
126
|
+
md: { gap: 6 },
|
|
127
|
+
lg: { gap: 6 },
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Wrapper around left / right icon nodes. `display: inline-flex` keeps the
|
|
132
|
+
* icon visually centered with the label without forcing the caller to wrap
|
|
133
|
+
* their lucide icon themselves.
|
|
134
|
+
*/
|
|
135
|
+
export const iconSection = style({
|
|
136
|
+
display: 'inline-flex',
|
|
137
|
+
alignItems: 'center',
|
|
138
|
+
flexShrink: 0,
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Disabled state — used by both `<a aria-disabled>` and `<button disabled>`.
|
|
143
|
+
* `pointer-events: none` neutralises hover/click without removing focus
|
|
144
|
+
* ability when needed (e.g. screenreader can still navigate to the
|
|
145
|
+
* `aria-disabled` link).
|
|
146
|
+
*/
|
|
147
|
+
export const disabled = style({
|
|
148
|
+
color: tokens.color.text.disabledDefault,
|
|
149
|
+
cursor: 'not-allowed',
|
|
150
|
+
pointerEvents: 'none',
|
|
151
|
+
selectors: {
|
|
152
|
+
'&:hover': {
|
|
153
|
+
textDecoration: 'none',
|
|
154
|
+
color: tokens.color.text.disabledDefault,
|
|
155
|
+
},
|
|
156
|
+
'&:focus-visible': {
|
|
157
|
+
color: tokens.color.text.disabledDefault,
|
|
158
|
+
},
|
|
159
|
+
[`${mantineVars.darkSelector} &`]: {
|
|
160
|
+
color: tokens.color.text.disabledDefault,
|
|
161
|
+
},
|
|
162
|
+
},
|
|
163
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import figma from '@figma/code-connect';
|
|
2
|
+
|
|
3
|
+
import { Anchor } from './Anchor';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Code Connect mapping for the LM Design System `Anchor` component_set
|
|
7
|
+
* (formerly `Link Button`, renamed to keep design + code names aligned).
|
|
8
|
+
*
|
|
9
|
+
* The component_set lives on page `Anchor` (node `842:44446`) in
|
|
10
|
+
* `LM Design System` (`VCLfybgU3OaUUPrQdBaVmP`). Variants exposed:
|
|
11
|
+
*
|
|
12
|
+
* - `Variant`: Default | Primary | Danger | Subdued — color tone.
|
|
13
|
+
* - `Size`: Mini | Small | Default | Large — icon-gap scale.
|
|
14
|
+
* - `Roundness`, `State` (Default | Hover & Active | Focus | Disabled):
|
|
15
|
+
* not surfaced as code props. Hover / focus / disabled are interaction
|
|
16
|
+
* state; `Roundness` only changes the focus-ring shape.
|
|
17
|
+
* - Boolean-gated icon swaps `Show left icon` + `⮑ Left icon` and
|
|
18
|
+
* `Show right icon` + `⮑ Right icon` map to `leftSection` / `rightSection`.
|
|
19
|
+
*/
|
|
20
|
+
figma.connect(
|
|
21
|
+
Anchor,
|
|
22
|
+
'https://www.figma.com/design/VCLfybgU3OaUUPrQdBaVmP/LM-Design-System?node-id=842-44446',
|
|
23
|
+
{
|
|
24
|
+
props: {
|
|
25
|
+
tone: figma.enum('Variant', {
|
|
26
|
+
Default: 'default',
|
|
27
|
+
Primary: 'primary',
|
|
28
|
+
Danger: 'danger',
|
|
29
|
+
Subdued: 'subdued',
|
|
30
|
+
}),
|
|
31
|
+
size: figma.enum('Size', {
|
|
32
|
+
Default: 'md',
|
|
33
|
+
Large: 'lg',
|
|
34
|
+
Small: 'sm',
|
|
35
|
+
Mini: 'xs',
|
|
36
|
+
}),
|
|
37
|
+
leftSection: figma.boolean('Show left icon', {
|
|
38
|
+
true: figma.instance('⮑ Left icon'),
|
|
39
|
+
false: undefined,
|
|
40
|
+
}),
|
|
41
|
+
rightSection: figma.boolean('Show right icon', {
|
|
42
|
+
true: figma.instance('⮑ Right icon'),
|
|
43
|
+
false: undefined,
|
|
44
|
+
}),
|
|
45
|
+
},
|
|
46
|
+
example: props => (
|
|
47
|
+
<Anchor
|
|
48
|
+
tone={props.tone}
|
|
49
|
+
size={props.size}
|
|
50
|
+
leftSection={props.leftSection}
|
|
51
|
+
rightSection={props.rightSection}
|
|
52
|
+
>
|
|
53
|
+
Label
|
|
54
|
+
</Anchor>
|
|
55
|
+
),
|
|
56
|
+
},
|
|
57
|
+
);
|
|
@@ -1,3 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design System Anchor Component
|
|
3
|
+
*
|
|
4
|
+
* Polymorphic — renders as `<a>` by default and `component="button"` for
|
|
5
|
+
* action triggers (e.g. inline "Generate" / "Regenerate" actions). Covers
|
|
6
|
+
* both inline links and text-only "buttons" with one component.
|
|
7
|
+
*
|
|
8
|
+
* Typography is locked to the design-system body variants (no `fz`/`fw`/
|
|
9
|
+
* `lh`/etc. forwarded). Color is controlled via `tone`; use `c` only for
|
|
10
|
+
* one-off escapes (e.g. `c="inherit"` to flow color from parent).
|
|
11
|
+
*
|
|
12
|
+
* @example Inline link
|
|
13
|
+
* ```tsx
|
|
14
|
+
* <Anchor href="/clients/123">Open client</Anchor>
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @example Text-only button (mockup pattern)
|
|
18
|
+
* ```tsx
|
|
19
|
+
* <Anchor
|
|
20
|
+
* component="button"
|
|
21
|
+
* type="button"
|
|
22
|
+
* tone="primary"
|
|
23
|
+
* leftSection={<RefreshCcw size={14} />}
|
|
24
|
+
* onClick={onRegenerate}
|
|
25
|
+
* >
|
|
26
|
+
* Regenerate link
|
|
27
|
+
* </Anchor>
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
|
|
1
31
|
import { forwardRef, type ReactNode } from 'react';
|
|
2
32
|
|
|
3
33
|
import {
|
|
@@ -5,39 +35,110 @@ import {
|
|
|
5
35
|
createPolymorphicComponent,
|
|
6
36
|
type AnchorProps as MantineAnchorProps,
|
|
7
37
|
} from '@mantine/core';
|
|
38
|
+
import { clsx } from 'clsx';
|
|
8
39
|
|
|
9
|
-
import { textStyleVariants, type BodyVariant } from '../../tokens/text-styles';
|
|
10
40
|
import { resolveColorToken } from '../../utils/color-props';
|
|
41
|
+
import type { TypographyStyleProp } from '../../utils/typography-props';
|
|
42
|
+
|
|
43
|
+
import * as classes from './Anchor.css';
|
|
44
|
+
|
|
45
|
+
import type { BodyVariant, TextColor } from '../../tokens';
|
|
11
46
|
|
|
12
|
-
|
|
47
|
+
export type AnchorTone = 'default' | 'primary' | 'danger' | 'subdued';
|
|
13
48
|
|
|
14
|
-
|
|
49
|
+
/**
|
|
50
|
+
* Box size — controls the icon-to-label gap. Typography is independent and
|
|
51
|
+
* comes from `variant`. Maps to Figma `Anchor`'s `Size` property
|
|
52
|
+
* (Mini/Small/Default/Large).
|
|
53
|
+
*/
|
|
54
|
+
export type AnchorSize = 'xs' | 'sm' | 'md' | 'lg';
|
|
15
55
|
|
|
16
56
|
export type AnchorProps = Omit<
|
|
17
57
|
MantineAnchorProps,
|
|
18
|
-
|
|
58
|
+
TypographyStyleProp | 'c' | 'children' | 'variant' | 'color'
|
|
19
59
|
> & {
|
|
60
|
+
/** Typography variant from the design system. Default `body1`. */
|
|
20
61
|
variant?: BodyVariant;
|
|
62
|
+
/**
|
|
63
|
+
* Color tone. Maps to the Figma `Anchor` component's `Variant` property.
|
|
64
|
+
* Default `primary` (matches Mantine's link-green default and existing
|
|
65
|
+
* `<Anchor component="button">` usage).
|
|
66
|
+
*/
|
|
67
|
+
tone?: AnchorTone;
|
|
68
|
+
/** Box size — controls icon gap. Default `md`. */
|
|
69
|
+
size?: AnchorSize;
|
|
70
|
+
/** Optional icon node rendered before the label. */
|
|
71
|
+
leftSection?: ReactNode;
|
|
72
|
+
/** Optional icon node rendered after the label. */
|
|
73
|
+
rightSection?: ReactNode;
|
|
74
|
+
/**
|
|
75
|
+
* Disabled state. For `<a>` renders `aria-disabled` and removes hover;
|
|
76
|
+
* for `<button>` adds the native `disabled` attribute.
|
|
77
|
+
*/
|
|
78
|
+
disabled?: boolean;
|
|
79
|
+
/**
|
|
80
|
+
* Color override. Use sparingly — `tone` should cover semantic cases.
|
|
81
|
+
* `inherit` flows the color from the parent (e.g. inside a tinted `<Text>`).
|
|
82
|
+
*/
|
|
21
83
|
c?: TextColor | 'inherit';
|
|
22
84
|
children?: ReactNode;
|
|
23
85
|
};
|
|
24
86
|
|
|
25
87
|
const AnchorBase = forwardRef<HTMLAnchorElement, AnchorProps>(
|
|
26
|
-
(
|
|
27
|
-
|
|
28
|
-
|
|
88
|
+
(
|
|
89
|
+
{
|
|
90
|
+
variant = 'body1',
|
|
91
|
+
tone = 'primary',
|
|
92
|
+
size = 'md',
|
|
93
|
+
leftSection,
|
|
94
|
+
rightSection,
|
|
95
|
+
disabled,
|
|
96
|
+
className,
|
|
97
|
+
children,
|
|
98
|
+
c,
|
|
99
|
+
...rest
|
|
100
|
+
},
|
|
101
|
+
ref,
|
|
102
|
+
) => {
|
|
103
|
+
const hasIcons = leftSection != null || rightSection != null;
|
|
104
|
+
// Mantine forwards arbitrary `component` via polymorphic typing.
|
|
105
|
+
// Narrow at runtime so we can apply the right disabled semantics
|
|
106
|
+
// (native `disabled` for `<button>`, `aria-disabled` for `<a>`).
|
|
107
|
+
const isButton = (rest as { component?: unknown }).component === 'button';
|
|
108
|
+
|
|
109
|
+
const cls = clsx(
|
|
110
|
+
classes.root,
|
|
111
|
+
classes.variant[variant],
|
|
112
|
+
classes.tone[tone],
|
|
113
|
+
hasIcons && classes.withIcons,
|
|
114
|
+
hasIcons && classes.gap[size],
|
|
115
|
+
disabled && classes.disabled,
|
|
116
|
+
className,
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const resolvedC = c !== undefined ? resolveColorToken(c) : undefined;
|
|
29
120
|
|
|
30
121
|
return (
|
|
31
122
|
<MantineAnchor
|
|
32
123
|
ref={ref}
|
|
33
|
-
|
|
34
|
-
fz={variantStyles.fontSize}
|
|
35
|
-
lh={variantStyles.lineHeight}
|
|
36
|
-
lts={variantStyles.letterSpacing}
|
|
37
|
-
ff={variantStyles.fontFamily}
|
|
124
|
+
className={cls}
|
|
38
125
|
c={resolvedC}
|
|
126
|
+
aria-disabled={!isButton && disabled ? true : undefined}
|
|
127
|
+
{...(isButton && disabled ? { disabled: true } : {})}
|
|
39
128
|
{...rest}
|
|
40
|
-
|
|
129
|
+
>
|
|
130
|
+
{leftSection != null && (
|
|
131
|
+
<span className={classes.iconSection} aria-hidden>
|
|
132
|
+
{leftSection}
|
|
133
|
+
</span>
|
|
134
|
+
)}
|
|
135
|
+
{children}
|
|
136
|
+
{rightSection != null && (
|
|
137
|
+
<span className={classes.iconSection} aria-hidden>
|
|
138
|
+
{rightSection}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</MantineAnchor>
|
|
41
142
|
);
|
|
42
143
|
},
|
|
43
144
|
);
|