lucent-ui 0.2.0 → 0.4.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/dist/index.cjs +106 -19
- package/dist/index.d.ts +156 -1
- package/dist/index.js +2936 -1029
- package/dist-cli/cli/figma.js +41 -0
- package/dist-cli/cli/index.js +120 -0
- package/dist-cli/cli/mapper.js +162 -0
- package/dist-cli/cli/template.manifest.json +83 -0
- package/dist-cli/cli/types.js +1 -0
- package/dist-cli/src/tokens/types.js +1 -0
- package/dist-server/src/components/molecules/CommandPalette/CommandPalette.manifest.js +89 -0
- package/dist-server/src/components/molecules/DataTable/DataTable.manifest.js +97 -0
- package/dist-server/src/components/molecules/DatePicker/DatePicker.manifest.js +91 -0
- package/dist-server/src/components/molecules/DateRangePicker/DateRangePicker.manifest.js +85 -0
- package/dist-server/src/components/molecules/FileUpload/FileUpload.manifest.js +104 -0
- package/dist-server/src/components/molecules/MultiSelect/MultiSelect.manifest.js +100 -0
- package/dist-server/src/components/molecules/Timeline/Timeline.manifest.js +60 -0
- package/dist-server/src/manifest/examples/button.manifest.js +1 -1
- package/dist-server/src/manifest/index.js +1 -1
- package/package.json +6 -3
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// ─── Figma Variables API types ────────────────────────────────────────────────
|
|
2
|
+
// ─── API client ───────────────────────────────────────────────────────────────
|
|
3
|
+
export async function fetchFigmaVariables(figmaToken, fileKey) {
|
|
4
|
+
const url = `https://api.figma.com/v1/files/${fileKey}/variables/local`;
|
|
5
|
+
const res = await fetch(url, {
|
|
6
|
+
headers: {
|
|
7
|
+
'X-Figma-Token': figmaToken,
|
|
8
|
+
},
|
|
9
|
+
});
|
|
10
|
+
if (!res.ok) {
|
|
11
|
+
const body = await res.text().catch(() => '');
|
|
12
|
+
throw new Error(`Figma API error ${res.status}: ${res.statusText}${body ? `\n${body}` : ''}`);
|
|
13
|
+
}
|
|
14
|
+
return res.json();
|
|
15
|
+
}
|
|
16
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
17
|
+
/** Convert Figma RGBA (0–1 channels) to a CSS hex string. */
|
|
18
|
+
export function figmaColorToHex(color) {
|
|
19
|
+
const toHex = (n) => Math.round(Math.min(1, Math.max(0, n)) * 255)
|
|
20
|
+
.toString(16)
|
|
21
|
+
.padStart(2, '0');
|
|
22
|
+
const rgb = `#${toHex(color.r)}${toHex(color.g)}${toHex(color.b)}`;
|
|
23
|
+
// Include alpha channel only when not fully opaque
|
|
24
|
+
if (color.a < 0.9999) {
|
|
25
|
+
return `${rgb}${toHex(color.a)}`;
|
|
26
|
+
}
|
|
27
|
+
return rgb;
|
|
28
|
+
}
|
|
29
|
+
/** True if value is a FigmaColor object (has r/g/b/a number fields). */
|
|
30
|
+
export function isFigmaColor(v) {
|
|
31
|
+
return (typeof v === 'object' &&
|
|
32
|
+
v !== null &&
|
|
33
|
+
'r' in v &&
|
|
34
|
+
typeof v.r === 'number');
|
|
35
|
+
}
|
|
36
|
+
/** True if value is a VARIABLE_ALIAS reference. */
|
|
37
|
+
export function isAlias(v) {
|
|
38
|
+
return (typeof v === 'object' &&
|
|
39
|
+
v !== null &&
|
|
40
|
+
v.type === 'VARIABLE_ALIAS');
|
|
41
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lucent-manifest init
|
|
4
|
+
*
|
|
5
|
+
* Usage:
|
|
6
|
+
* npx lucent-manifest init --figma-token <token> --file-key <key> [options]
|
|
7
|
+
* npx lucent-manifest init --template (manual fallback)
|
|
8
|
+
*
|
|
9
|
+
* Options:
|
|
10
|
+
* --figma-token <token> Figma personal access token
|
|
11
|
+
* --file-key <key> Figma file key (from the file URL)
|
|
12
|
+
* --light-mode <name> Mode name to treat as light theme (default: "light")
|
|
13
|
+
* --dark-mode <name> Mode name to treat as dark theme (default: "dark")
|
|
14
|
+
* --name <name> Design system name written into the manifest
|
|
15
|
+
* --out <path> Output file path (default: lucent.manifest.json)
|
|
16
|
+
* --template Write an empty JSON template instead of fetching Figma
|
|
17
|
+
*/
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fetchFigmaVariables } from './figma.js';
|
|
21
|
+
import { mapFigmaToTokens } from './mapper.js';
|
|
22
|
+
// ─── Arg parsing ──────────────────────────────────────────────────────────────
|
|
23
|
+
function parseArgs(argv) {
|
|
24
|
+
const args = {};
|
|
25
|
+
for (let i = 0; i < argv.length; i++) {
|
|
26
|
+
const arg = argv[i];
|
|
27
|
+
if (arg.startsWith('--')) {
|
|
28
|
+
const key = arg.slice(2);
|
|
29
|
+
const next = argv[i + 1];
|
|
30
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
31
|
+
args[key] = next;
|
|
32
|
+
i++;
|
|
33
|
+
}
|
|
34
|
+
else {
|
|
35
|
+
args[key] = true;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return args;
|
|
40
|
+
}
|
|
41
|
+
function required(args, key) {
|
|
42
|
+
const v = args[key];
|
|
43
|
+
if (!v || v === true) {
|
|
44
|
+
console.error(`Error: --${key} is required.`);
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
return v;
|
|
48
|
+
}
|
|
49
|
+
function optional(args, key, fallback) {
|
|
50
|
+
const v = args[key];
|
|
51
|
+
return typeof v === 'string' ? v : fallback;
|
|
52
|
+
}
|
|
53
|
+
// ─── Template fallback ────────────────────────────────────────────────────────
|
|
54
|
+
function writeTemplate(outPath) {
|
|
55
|
+
const templateSrc = new URL('./template.manifest.json', import.meta.url);
|
|
56
|
+
fs.copyFileSync(templateSrc, outPath);
|
|
57
|
+
console.log(`Template written to ${outPath}`);
|
|
58
|
+
console.log('Fill in the token values and load it with LucentProvider.');
|
|
59
|
+
}
|
|
60
|
+
// ─── Main ─────────────────────────────────────────────────────────────────────
|
|
61
|
+
async function main() {
|
|
62
|
+
const args = parseArgs(process.argv.slice(2));
|
|
63
|
+
const outPath = path.resolve(optional(args, 'out', 'lucent.manifest.json'));
|
|
64
|
+
if (args['template']) {
|
|
65
|
+
writeTemplate(outPath);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const figmaToken = required(args, 'figma-token');
|
|
69
|
+
const fileKey = required(args, 'file-key');
|
|
70
|
+
const lightModeName = optional(args, 'light-mode', 'light');
|
|
71
|
+
const darkModeName = optional(args, 'dark-mode', 'dark');
|
|
72
|
+
const systemName = optional(args, 'name', 'My Design System');
|
|
73
|
+
console.log(`Fetching Figma variables for file ${fileKey}…`);
|
|
74
|
+
const response = await fetchFigmaVariables(figmaToken, fileKey);
|
|
75
|
+
const manifest = {
|
|
76
|
+
version: '1.0',
|
|
77
|
+
name: systemName,
|
|
78
|
+
tokens: {},
|
|
79
|
+
};
|
|
80
|
+
// Light theme
|
|
81
|
+
try {
|
|
82
|
+
const { tokens: lightTokens, unmapped: lightUnmapped } = mapFigmaToTokens(response, lightModeName);
|
|
83
|
+
manifest.tokens.light = lightTokens;
|
|
84
|
+
console.log(`Light theme: mapped ${Object.keys(lightTokens).length} token(s).`);
|
|
85
|
+
if (lightUnmapped.length > 0) {
|
|
86
|
+
console.warn(` ${lightUnmapped.length} variable(s) didn't match a Lucent token key and were skipped:`);
|
|
87
|
+
for (const u of lightUnmapped) {
|
|
88
|
+
console.warn(` "${u.figmaName}" → candidate "${u.candidateKey}" (value: ${u.value})`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
catch (err) {
|
|
93
|
+
console.warn(`Skipping light theme: ${err.message}`);
|
|
94
|
+
}
|
|
95
|
+
// Dark theme
|
|
96
|
+
try {
|
|
97
|
+
const { tokens: darkTokens, unmapped: darkUnmapped } = mapFigmaToTokens(response, darkModeName);
|
|
98
|
+
manifest.tokens.dark = darkTokens;
|
|
99
|
+
console.log(`Dark theme: mapped ${Object.keys(darkTokens).length} token(s).`);
|
|
100
|
+
if (darkUnmapped.length > 0) {
|
|
101
|
+
console.warn(` ${darkUnmapped.length} variable(s) didn't match a Lucent token key and were skipped:`);
|
|
102
|
+
for (const u of darkUnmapped) {
|
|
103
|
+
console.warn(` "${u.figmaName}" → candidate "${u.candidateKey}" (value: ${u.value})`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
console.warn(`Skipping dark theme: ${err.message}`);
|
|
109
|
+
}
|
|
110
|
+
if (!manifest.tokens.light && !manifest.tokens.dark) {
|
|
111
|
+
console.error('No tokens were mapped. Check your --light-mode and --dark-mode names match the Figma file.');
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
fs.writeFileSync(outPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
|
115
|
+
console.log(`\nManifest written to ${outPath}`);
|
|
116
|
+
}
|
|
117
|
+
main().catch(err => {
|
|
118
|
+
console.error(err instanceof Error ? err.message : String(err));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { figmaColorToHex, isFigmaColor, isAlias, } from './figma.js';
|
|
2
|
+
// ─── Token key registry ───────────────────────────────────────────────────────
|
|
3
|
+
/**
|
|
4
|
+
* Every valid key in LucentTokens — used to validate candidate names produced
|
|
5
|
+
* by the name normaliser before writing them into the output manifest.
|
|
6
|
+
*/
|
|
7
|
+
const LUCENT_TOKEN_KEYS = new Set([
|
|
8
|
+
// SemanticColorTokens
|
|
9
|
+
'bgBase', 'bgSubtle', 'bgMuted', 'bgOverlay',
|
|
10
|
+
'surfaceDefault', 'surfaceRaised', 'surfaceOverlay',
|
|
11
|
+
'borderDefault', 'borderSubtle', 'borderStrong',
|
|
12
|
+
'textPrimary', 'textSecondary', 'textDisabled', 'textInverse', 'textOnAccent',
|
|
13
|
+
'accentDefault', 'accentHover', 'accentActive', 'accentSubtle',
|
|
14
|
+
'successDefault', 'successSubtle', 'successText',
|
|
15
|
+
'warningDefault', 'warningSubtle', 'warningText',
|
|
16
|
+
'dangerDefault', 'dangerHover', 'dangerSubtle', 'dangerText',
|
|
17
|
+
'infoDefault', 'infoSubtle', 'infoText',
|
|
18
|
+
'focusRing',
|
|
19
|
+
// TypographyTokens
|
|
20
|
+
'fontFamilyBase', 'fontFamilyMono', 'fontFamilyDisplay',
|
|
21
|
+
'fontSizeXs', 'fontSizeSm', 'fontSizeMd', 'fontSizeLg',
|
|
22
|
+
'fontSizeXl', 'fontSize2xl', 'fontSize3xl',
|
|
23
|
+
'fontWeightRegular', 'fontWeightMedium', 'fontWeightSemibold', 'fontWeightBold',
|
|
24
|
+
'lineHeightTight', 'lineHeightBase', 'lineHeightRelaxed',
|
|
25
|
+
'letterSpacingTight', 'letterSpacingBase', 'letterSpacingWide',
|
|
26
|
+
// SpacingTokens
|
|
27
|
+
'space0', 'space1', 'space2', 'space3', 'space4', 'space5',
|
|
28
|
+
'space6', 'space8', 'space10', 'space12', 'space16', 'space20', 'space24',
|
|
29
|
+
// RadiusTokens
|
|
30
|
+
'radiusNone', 'radiusSm', 'radiusMd', 'radiusLg', 'radiusXl', 'radiusFull',
|
|
31
|
+
// ShadowTokens
|
|
32
|
+
'shadowNone', 'shadowSm', 'shadowMd', 'shadowLg', 'shadowXl',
|
|
33
|
+
// MotionTokens
|
|
34
|
+
'durationFast', 'durationBase', 'durationSlow',
|
|
35
|
+
'easingDefault', 'easingEmphasized', 'easingDecelerate',
|
|
36
|
+
]);
|
|
37
|
+
// ─── Name normalisation ───────────────────────────────────────────────────────
|
|
38
|
+
/**
|
|
39
|
+
* Top-level Figma collection category prefixes that carry no semantic meaning
|
|
40
|
+
* in the Lucent token schema and should be dropped before camelCasing.
|
|
41
|
+
*/
|
|
42
|
+
const CATEGORY_PREFIXES = new Set([
|
|
43
|
+
'color', 'colour', 'typography', 'type', 'spacing', 'space',
|
|
44
|
+
'radius', 'shadow', 'motion', 'animation',
|
|
45
|
+
]);
|
|
46
|
+
/**
|
|
47
|
+
* Convert a Figma variable name (slash-separated path, may include hyphens)
|
|
48
|
+
* into a camelCase candidate token key.
|
|
49
|
+
*
|
|
50
|
+
* Examples:
|
|
51
|
+
* "color/bg/base" → "bgBase"
|
|
52
|
+
* "color/text/primary" → "textPrimary"
|
|
53
|
+
* "typography/font-size/xl" → "fontSizeXl"
|
|
54
|
+
* "spacing/space-4" → "space4"
|
|
55
|
+
* "radius/radius-md" → "radiusMd"
|
|
56
|
+
* "accentDefault" → "accentDefault" (no change needed)
|
|
57
|
+
*/
|
|
58
|
+
export function normalizeName(figmaName) {
|
|
59
|
+
// Split path segments on /
|
|
60
|
+
const segments = figmaName.split('/').map(s => s.trim()).filter(Boolean);
|
|
61
|
+
// Drop a leading category prefix if present
|
|
62
|
+
if (segments.length > 1 && CATEGORY_PREFIXES.has(segments[0].toLowerCase())) {
|
|
63
|
+
segments.shift();
|
|
64
|
+
}
|
|
65
|
+
// Further split each segment on hyphens
|
|
66
|
+
const parts = segments.flatMap(s => s.split('-').filter(Boolean));
|
|
67
|
+
if (parts.length === 0)
|
|
68
|
+
return figmaName;
|
|
69
|
+
// camelCase: lowercase first part, title-case the rest
|
|
70
|
+
return parts
|
|
71
|
+
.map((p, i) => i === 0 ? p.toLowerCase() : p.charAt(0).toUpperCase() + p.slice(1).toLowerCase())
|
|
72
|
+
.join('');
|
|
73
|
+
}
|
|
74
|
+
// ─── Alias resolution ─────────────────────────────────────────────────────────
|
|
75
|
+
/**
|
|
76
|
+
* Resolve a variable value for a given mode, following VARIABLE_ALIAS chains.
|
|
77
|
+
* Returns `undefined` if the chain cannot be resolved (dangling alias, missing
|
|
78
|
+
* mode, non-string/non-color resolved type).
|
|
79
|
+
*/
|
|
80
|
+
function resolveValue(variable, modeId, allVariables, visited = new Set()) {
|
|
81
|
+
if (visited.has(variable.id))
|
|
82
|
+
return undefined; // circular alias guard
|
|
83
|
+
visited.add(variable.id);
|
|
84
|
+
const value = variable.valuesByMode[modeId];
|
|
85
|
+
if (value === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
if (isAlias(value)) {
|
|
88
|
+
const target = allVariables[value.id];
|
|
89
|
+
if (!target)
|
|
90
|
+
return undefined;
|
|
91
|
+
// Aliases preserve their own modeId in the target collection — use the
|
|
92
|
+
// same modeId; if absent the target may use its defaultModeId but we
|
|
93
|
+
// can't determine that here without the collections map, so fall back to
|
|
94
|
+
// returning undefined rather than guessing.
|
|
95
|
+
return resolveValue(target, modeId, allVariables, visited);
|
|
96
|
+
}
|
|
97
|
+
return value;
|
|
98
|
+
}
|
|
99
|
+
// ─── Value → CSS string ───────────────────────────────────────────────────────
|
|
100
|
+
function toCssValue(value, resolvedType) {
|
|
101
|
+
if (resolvedType === 'COLOR' && isFigmaColor(value)) {
|
|
102
|
+
return figmaColorToHex(value);
|
|
103
|
+
}
|
|
104
|
+
if (resolvedType === 'FLOAT' && typeof value === 'number') {
|
|
105
|
+
// Figma stores spacing/radius in px without units; emit as "Npx"
|
|
106
|
+
return `${value}px`;
|
|
107
|
+
}
|
|
108
|
+
if (resolvedType === 'STRING' && typeof value === 'string') {
|
|
109
|
+
return value;
|
|
110
|
+
}
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Map a Figma Variables API response for a single named mode (e.g. "light" or
|
|
115
|
+
* "dark") into a `Partial<LucentTokens>` token override object.
|
|
116
|
+
*
|
|
117
|
+
* @param response Full response from `fetchFigmaVariables`
|
|
118
|
+
* @param modeName Case-insensitive mode name to extract (e.g. "Light", "dark")
|
|
119
|
+
*/
|
|
120
|
+
export function mapFigmaToTokens(response, modeName) {
|
|
121
|
+
const { variables, variableCollections } = response.meta;
|
|
122
|
+
// Find the modeId matching the requested name across all collections
|
|
123
|
+
const targetModeName = modeName.toLowerCase();
|
|
124
|
+
const modeIdByCollection = new Map();
|
|
125
|
+
for (const collection of Object.values(variableCollections)) {
|
|
126
|
+
const match = collection.modes.find(m => m.name.toLowerCase() === targetModeName);
|
|
127
|
+
if (match) {
|
|
128
|
+
modeIdByCollection.set(collection.id, match.modeId);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
if (modeIdByCollection.size === 0) {
|
|
132
|
+
const available = Object.values(variableCollections)
|
|
133
|
+
.flatMap(c => c.modes.map(m => m.name))
|
|
134
|
+
.filter((v, i, a) => a.indexOf(v) === i)
|
|
135
|
+
.join(', ');
|
|
136
|
+
throw new Error(`No mode named "${modeName}" found in Figma file.\nAvailable modes: ${available || '(none)'}`);
|
|
137
|
+
}
|
|
138
|
+
const tokens = {};
|
|
139
|
+
const unmapped = [];
|
|
140
|
+
for (const variable of Object.values(variables)) {
|
|
141
|
+
// Skip booleans — no LucentTokens field uses boolean values
|
|
142
|
+
if (variable.resolvedType === 'BOOLEAN')
|
|
143
|
+
continue;
|
|
144
|
+
const modeId = modeIdByCollection.get(variable.variableCollectionId);
|
|
145
|
+
if (modeId === undefined)
|
|
146
|
+
continue;
|
|
147
|
+
const raw = resolveValue(variable, modeId, variables);
|
|
148
|
+
if (raw === undefined)
|
|
149
|
+
continue;
|
|
150
|
+
const cssValue = toCssValue(raw, variable.resolvedType);
|
|
151
|
+
if (cssValue === undefined)
|
|
152
|
+
continue;
|
|
153
|
+
const candidate = normalizeName(variable.name);
|
|
154
|
+
if (LUCENT_TOKEN_KEYS.has(candidate)) {
|
|
155
|
+
tokens[candidate] = cssValue;
|
|
156
|
+
}
|
|
157
|
+
else {
|
|
158
|
+
unmapped.push({ figmaName: variable.name, candidateKey: candidate, value: cssValue });
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return { tokens, unmapped };
|
|
162
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": "1.0",
|
|
3
|
+
"name": "My Design System",
|
|
4
|
+
"description": "Optional description of your design system",
|
|
5
|
+
"tokens": {
|
|
6
|
+
"light": {
|
|
7
|
+
"bgBase": "#ffffff",
|
|
8
|
+
"bgSubtle": "#f9fafb",
|
|
9
|
+
"bgMuted": "#f3f4f6",
|
|
10
|
+
"bgOverlay": "rgb(0 0 0 / 0.4)",
|
|
11
|
+
"surfaceDefault": "#ffffff",
|
|
12
|
+
"surfaceRaised": "#ffffff",
|
|
13
|
+
"surfaceOverlay": "#ffffff",
|
|
14
|
+
"borderDefault": "#e5e7eb",
|
|
15
|
+
"borderSubtle": "#f3f4f6",
|
|
16
|
+
"borderStrong": "#9ca3af",
|
|
17
|
+
"textPrimary": "#111827",
|
|
18
|
+
"textSecondary": "#6b7280",
|
|
19
|
+
"textDisabled": "#9ca3af",
|
|
20
|
+
"textInverse": "#ffffff",
|
|
21
|
+
"textOnAccent": "#ffffff",
|
|
22
|
+
"accentDefault": "#111827",
|
|
23
|
+
"accentHover": "#1f2937",
|
|
24
|
+
"accentActive": "#374151",
|
|
25
|
+
"accentSubtle": "#f3f4f6",
|
|
26
|
+
"successDefault": "#16a34a",
|
|
27
|
+
"successSubtle": "#f0fdf4",
|
|
28
|
+
"successText": "#15803d",
|
|
29
|
+
"warningDefault": "#d97706",
|
|
30
|
+
"warningSubtle": "#fffbeb",
|
|
31
|
+
"warningText": "#b45309",
|
|
32
|
+
"dangerDefault": "#dc2626",
|
|
33
|
+
"dangerHover": "#b91c1c",
|
|
34
|
+
"dangerSubtle": "#fef2f2",
|
|
35
|
+
"dangerText": "#b91c1c",
|
|
36
|
+
"infoDefault": "#2563eb",
|
|
37
|
+
"infoSubtle": "#eff6ff",
|
|
38
|
+
"infoText": "#1d4ed8",
|
|
39
|
+
"focusRing": "#111827",
|
|
40
|
+
"fontFamilyBase": "\"Inter\", sans-serif",
|
|
41
|
+
"fontFamilyMono": "\"Fira Code\", monospace",
|
|
42
|
+
"fontFamilyDisplay": "\"Inter\", sans-serif"
|
|
43
|
+
},
|
|
44
|
+
"dark": {
|
|
45
|
+
"bgBase": "#0f172a",
|
|
46
|
+
"bgSubtle": "#1e293b",
|
|
47
|
+
"bgMuted": "#334155",
|
|
48
|
+
"bgOverlay": "rgb(0 0 0 / 0.6)",
|
|
49
|
+
"surfaceDefault": "#1e293b",
|
|
50
|
+
"surfaceRaised": "#334155",
|
|
51
|
+
"surfaceOverlay": "#1e293b",
|
|
52
|
+
"borderDefault": "#334155",
|
|
53
|
+
"borderSubtle": "#1e293b",
|
|
54
|
+
"borderStrong": "#64748b",
|
|
55
|
+
"textPrimary": "#f8fafc",
|
|
56
|
+
"textSecondary": "#94a3b8",
|
|
57
|
+
"textDisabled": "#475569",
|
|
58
|
+
"textInverse": "#0f172a",
|
|
59
|
+
"textOnAccent": "#0f172a",
|
|
60
|
+
"accentDefault": "#f8fafc",
|
|
61
|
+
"accentHover": "#e2e8f0",
|
|
62
|
+
"accentActive": "#cbd5e1",
|
|
63
|
+
"accentSubtle": "#1e293b",
|
|
64
|
+
"successDefault": "#22c55e",
|
|
65
|
+
"successSubtle": "#052e16",
|
|
66
|
+
"successText": "#4ade80",
|
|
67
|
+
"warningDefault": "#f59e0b",
|
|
68
|
+
"warningSubtle": "#431407",
|
|
69
|
+
"warningText": "#fbbf24",
|
|
70
|
+
"dangerDefault": "#ef4444",
|
|
71
|
+
"dangerHover": "#f87171",
|
|
72
|
+
"dangerSubtle": "#450a0a",
|
|
73
|
+
"dangerText": "#f87171",
|
|
74
|
+
"infoDefault": "#3b82f6",
|
|
75
|
+
"infoSubtle": "#0f1f47",
|
|
76
|
+
"infoText": "#60a5fa",
|
|
77
|
+
"focusRing": "#f8fafc",
|
|
78
|
+
"fontFamilyBase": "\"Inter\", sans-serif",
|
|
79
|
+
"fontFamilyMono": "\"Fira Code\", monospace",
|
|
80
|
+
"fontFamilyDisplay": "\"Inter\", sans-serif"
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
export const COMPONENT_MANIFEST = {
|
|
2
|
+
id: 'command-palette',
|
|
3
|
+
name: 'CommandPalette',
|
|
4
|
+
tier: 'overlay',
|
|
5
|
+
domain: 'neutral',
|
|
6
|
+
specVersion: '1.0',
|
|
7
|
+
description: 'A keyboard-driven modal command palette with fuzzy search, grouped results, and a built-in ⌘K / Ctrl+K global shortcut.',
|
|
8
|
+
designIntent: 'CommandPalette renders as a portal-style overlay (fixed, full-viewport) with a centred panel that animates in with a subtle scale+fade. ' +
|
|
9
|
+
'The ⌘K shortcut is registered as a global window listener so it works regardless of focus — consumers can override the key via shortcutKey prop. ' +
|
|
10
|
+
'Clicking the backdrop dismisses the palette; Escape also closes it. ' +
|
|
11
|
+
'Results are filtered client-side against label and description using simple substring match — no fuzzy library needed. ' +
|
|
12
|
+
'Navigation is purely keyboard-driven: ↑↓ move the active index, Enter selects, mouse hover syncs the active index so mouse and keyboard stay in sync. ' +
|
|
13
|
+
'Groups are derived from the group field on each CommandItem; the order of groups follows the order they first appear in the commands array. ' +
|
|
14
|
+
'The component is controlled-or-uncontrolled: pass open + onOpenChange for controlled use, or omit both to use internal state.',
|
|
15
|
+
props: [
|
|
16
|
+
{
|
|
17
|
+
name: 'commands',
|
|
18
|
+
type: 'array',
|
|
19
|
+
required: true,
|
|
20
|
+
description: 'Array of CommandItem objects. Each has id, label, onSelect, and optional description, icon, group, disabled.',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
name: 'placeholder',
|
|
24
|
+
type: 'string',
|
|
25
|
+
required: false,
|
|
26
|
+
default: '"Search commands…"',
|
|
27
|
+
description: 'Placeholder text for the search input.',
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
name: 'shortcutKey',
|
|
31
|
+
type: 'string',
|
|
32
|
+
required: false,
|
|
33
|
+
default: '"k"',
|
|
34
|
+
description: 'Key that opens the palette when pressed with Meta (Mac) or Ctrl (Windows). Defaults to "k" for ⌘K.',
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'open',
|
|
38
|
+
type: 'boolean',
|
|
39
|
+
required: false,
|
|
40
|
+
description: 'Controlled open state. When provided, the component is fully controlled.',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
name: 'onOpenChange',
|
|
44
|
+
type: 'function',
|
|
45
|
+
required: false,
|
|
46
|
+
description: 'Called when the palette requests an open/close state change.',
|
|
47
|
+
},
|
|
48
|
+
{
|
|
49
|
+
name: 'style',
|
|
50
|
+
type: 'object',
|
|
51
|
+
required: false,
|
|
52
|
+
description: 'Inline style overrides for the backdrop element.',
|
|
53
|
+
},
|
|
54
|
+
],
|
|
55
|
+
usageExamples: [
|
|
56
|
+
{
|
|
57
|
+
title: 'Uncontrolled with ⌘K',
|
|
58
|
+
code: `<CommandPalette
|
|
59
|
+
commands={[
|
|
60
|
+
{ id: 'new', label: 'New document', icon: <PlusIcon />, onSelect: () => router.push('/new') },
|
|
61
|
+
{ id: 'settings', label: 'Settings', description: 'Open app settings', onSelect: () => router.push('/settings') },
|
|
62
|
+
{ id: 'logout', label: 'Log out', group: 'Account', onSelect: handleLogout },
|
|
63
|
+
]}
|
|
64
|
+
/>`,
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
title: 'Controlled with custom shortcut',
|
|
68
|
+
code: `const [open, setOpen] = useState(false);
|
|
69
|
+
<>
|
|
70
|
+
<Button onClick={() => setOpen(true)}>Open palette</Button>
|
|
71
|
+
<CommandPalette
|
|
72
|
+
commands={commands}
|
|
73
|
+
open={open}
|
|
74
|
+
onOpenChange={setOpen}
|
|
75
|
+
shortcutKey="p"
|
|
76
|
+
/>
|
|
77
|
+
</>`,
|
|
78
|
+
},
|
|
79
|
+
],
|
|
80
|
+
compositionGraph: [
|
|
81
|
+
{ componentId: 'text', componentName: 'Text', role: 'Group labels, item labels, descriptions, and empty state', required: true },
|
|
82
|
+
],
|
|
83
|
+
accessibility: {
|
|
84
|
+
role: 'dialog',
|
|
85
|
+
ariaAttributes: ['aria-label', 'aria-modal', 'aria-expanded', 'aria-selected', 'aria-disabled', 'aria-controls', 'aria-autocomplete'],
|
|
86
|
+
keyboardInteractions: ['⌘K / Ctrl+K to open', '↑↓ to navigate', 'Enter to select', 'Escape to close'],
|
|
87
|
+
notes: 'The backdrop and panel use role="dialog" with aria-modal="true". The input is role="searchbox". The result list is role="listbox" with role="option" items. Focus is moved to the search input on open.',
|
|
88
|
+
},
|
|
89
|
+
};
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
export const COMPONENT_MANIFEST = {
|
|
2
|
+
id: 'data-table',
|
|
3
|
+
name: 'DataTable',
|
|
4
|
+
tier: 'molecule',
|
|
5
|
+
domain: 'neutral',
|
|
6
|
+
specVersion: '1.0',
|
|
7
|
+
description: 'A sortable, paginated data table with configurable columns, custom cell renderers, and keyboard-accessible pagination controls.',
|
|
8
|
+
designIntent: 'DataTable is generic over row type T so TypeScript consumers get full type safety on column keys and renderers. ' +
|
|
9
|
+
'Sorting is client-side and composable — each column opts in via sortable:true; clicking a sorted column cycles asc → desc → unsorted. ' +
|
|
10
|
+
'Pagination is either controlled (page prop + onPageChange) or uncontrolled (internal state). ' +
|
|
11
|
+
'A pageSize of 0 disables pagination entirely, useful when the parent manages windowing. ' +
|
|
12
|
+
'Column filtering is intentionally excluded here (see DataTable Filter issue #52) to keep the API focused. ' +
|
|
13
|
+
'Row hover uses bg-subtle, not a border change, so the visual weight stays low for dense data views.',
|
|
14
|
+
props: [
|
|
15
|
+
{
|
|
16
|
+
name: 'columns',
|
|
17
|
+
type: 'array',
|
|
18
|
+
required: true,
|
|
19
|
+
description: 'Column definitions. Each column has a key, header, optional render function, optional sortable flag, optional width, and optional text align.',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
name: 'rows',
|
|
23
|
+
type: 'array',
|
|
24
|
+
required: true,
|
|
25
|
+
description: 'Array of data objects to display. The generic type T is inferred from this prop.',
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
name: 'pageSize',
|
|
29
|
+
type: 'number',
|
|
30
|
+
required: false,
|
|
31
|
+
default: '10',
|
|
32
|
+
description: 'Number of rows per page. Set to 0 to disable pagination.',
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
name: 'page',
|
|
36
|
+
type: 'number',
|
|
37
|
+
required: false,
|
|
38
|
+
description: 'Controlled current page (0-indexed). When provided, the component is fully controlled.',
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
name: 'onPageChange',
|
|
42
|
+
type: 'function',
|
|
43
|
+
required: false,
|
|
44
|
+
description: 'Called with the new page index whenever the page changes (from pagination controls or after a sort reset).',
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
name: 'emptyState',
|
|
48
|
+
type: 'ReactNode',
|
|
49
|
+
required: false,
|
|
50
|
+
description: 'Content to render when rows is empty. Defaults to a "No data" text.',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
name: 'style',
|
|
54
|
+
type: 'object',
|
|
55
|
+
required: false,
|
|
56
|
+
description: 'Inline style overrides for the outer wrapper.',
|
|
57
|
+
},
|
|
58
|
+
],
|
|
59
|
+
usageExamples: [
|
|
60
|
+
{
|
|
61
|
+
title: 'Basic sortable table',
|
|
62
|
+
code: `<DataTable
|
|
63
|
+
columns={[
|
|
64
|
+
{ key: 'name', header: 'Name', sortable: true },
|
|
65
|
+
{ key: 'role', header: 'Role', sortable: true },
|
|
66
|
+
{ key: 'status', header: 'Status', render: (row) => <Badge>{row.status}</Badge> },
|
|
67
|
+
]}
|
|
68
|
+
rows={users}
|
|
69
|
+
/>`,
|
|
70
|
+
},
|
|
71
|
+
{
|
|
72
|
+
title: 'Controlled pagination',
|
|
73
|
+
code: `const [page, setPage] = useState(0);
|
|
74
|
+
<DataTable
|
|
75
|
+
columns={columns}
|
|
76
|
+
rows={rows}
|
|
77
|
+
pageSize={20}
|
|
78
|
+
page={page}
|
|
79
|
+
onPageChange={setPage}
|
|
80
|
+
/>`,
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
title: 'No pagination',
|
|
84
|
+
code: `<DataTable columns={columns} rows={rows} pageSize={0} />`,
|
|
85
|
+
},
|
|
86
|
+
],
|
|
87
|
+
compositionGraph: [
|
|
88
|
+
{ componentId: 'text', componentName: 'Text', role: 'Row count and empty state labels', required: false },
|
|
89
|
+
{ componentId: 'badge', componentName: 'Badge', role: 'Typical cell content for status columns', required: false },
|
|
90
|
+
],
|
|
91
|
+
accessibility: {
|
|
92
|
+
role: 'table',
|
|
93
|
+
ariaAttributes: ['aria-label', 'aria-sort', 'aria-current'],
|
|
94
|
+
keyboardInteractions: ['Tab to pagination controls', 'Enter/Space to activate buttons'],
|
|
95
|
+
notes: 'Column headers with sortable:true are interactive buttons with aria-sort reflecting the current sort direction. Pagination buttons include aria-label and aria-current="page" for the active page.',
|
|
96
|
+
},
|
|
97
|
+
};
|