@scattered-light/loomiere 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/README.md +6 -1
- package/bin/loomiere.mjs +24 -0
- package/build/build-tokens.mjs +93 -49
- package/exporters/README.md +1 -1
- package/exporters/ag-grid-settings.mjs +7 -7
- package/exporters/ag-grid.mjs +6 -6
- package/exporters/chakra.mjs +43 -43
- package/exporters/component-styles.mjs +4 -4
- package/exporters/design-system.mjs +74 -10
- package/exporters/figma.mjs +774 -68
- package/exporters/mui.mjs +40 -40
- package/exporters/shadcn.mjs +5 -13
- package/exporters/tailwind.mjs +14 -5
- package/figma/README.md +135 -0
- package/figma/build-plugin.mjs +25 -0
- package/figma/engine.js +730 -0
- package/figma/plugin/code.js +746 -0
- package/figma/plugin/manifest.json +14 -0
- package/figma/plugin/ui.html +237 -0
- package/figma/plugin-main.js +14 -0
- package/generator/brand.mjs +16 -14
- package/generator/brand.schema.json +60 -0
- package/generator/generate.mjs +2 -2
- package/generator/pairs.mjs +6 -1
- package/generator/roles.mjs +55 -15
- package/generator/vision.mjs +7 -2
- package/lib/config.mjs +2 -0
- package/lib/contrast.mjs +8 -3
- package/lib/figma.mjs +338 -0
- package/lib/studio.mjs +4 -0
- package/lib/tool-server.mjs +7 -4
- package/package.json +5 -1
- package/tokens/README.md +10 -5
- package/tokens/component/avatar.json +41 -6
- package/tokens/component/popover.json +3 -3
- package/tokens/semantic/color.json +1 -1
- package/tokens/semantic/elevation.json +8 -43
- package/tokens/semantic/typography.json +61 -94
- package/tools/brand-builder/builder.js +33 -4
package/lib/contrast.mjs
CHANGED
|
@@ -18,12 +18,15 @@ export const MODES = ['light', 'dark', 'contrast-more', 'dark-contrast-more'];
|
|
|
18
18
|
const fallback = { dark: 'light', 'contrast-more': 'light', 'dark-contrast-more': 'dark' };
|
|
19
19
|
|
|
20
20
|
/**
|
|
21
|
-
* A token's value in a mode. Dark + contrast falls back to dark, then light. A plain alias
|
|
21
|
+
* A token's value in a mode ('vision:<theme>' for a color vision theme). Dark + contrast falls back to dark, then light. A plain alias
|
|
22
22
|
* ({other.token}) follows the other token into the same mode, the way var() does in CSS.
|
|
23
23
|
*/
|
|
24
24
|
function valueIn(byPath, tokenPath, mode) {
|
|
25
25
|
const t = byPath.get(tokenPath);
|
|
26
26
|
if (!t) throw new Error(`Unknown token ${tokenPath}`);
|
|
27
|
+
// A color vision theme is light with the brand's colors swapped for its own.
|
|
28
|
+
const vision = mode.startsWith('vision:') && byPath.get(`vision.${mode.slice(7)}.${tokenPath}`);
|
|
29
|
+
if (vision) return vision.value;
|
|
27
30
|
for (let m = mode; m && m !== 'light'; m = fallback[m]) {
|
|
28
31
|
if (t.modes?.[m] === undefined) continue;
|
|
29
32
|
const modeAlias = typeof t.modeReferences?.[m] === 'string' && t.modeReferences[m].match(/^\{([^}]+)\}$/);
|
|
@@ -82,7 +85,9 @@ const { tokens } = JSON.parse(fs.readFileSync(path.join(outDir, 'tokens.json'),
|
|
|
82
85
|
const byPath = new Map(tokens.map((t) => [t.path, t]));
|
|
83
86
|
const failures = [];
|
|
84
87
|
const rows = [];
|
|
85
|
-
|
|
88
|
+
const visions = [...new Set(tokens.filter((t) => t.path.startsWith('vision.')).map((t) => t.path.split('.')[1]))];
|
|
89
|
+
const modes = [...MODES, ...visions.map((v) => `vision:${v}`)];
|
|
90
|
+
for (const mode of modes) {
|
|
86
91
|
for (const [fg, bg, min, kind] of PAIRS) {
|
|
87
92
|
const a = valueIn(byPath, fg, mode);
|
|
88
93
|
const b = valueIn(byPath, bg, mode);
|
|
@@ -129,6 +134,6 @@ export function reportContrast({ rows, failures }) {
|
|
|
129
134
|
}
|
|
130
135
|
return false;
|
|
131
136
|
}
|
|
132
|
-
console.log(`Contrast check passed: ${rows.length} pairs across ${
|
|
137
|
+
console.log(`Contrast check passed: ${rows.length} pairs across ${new Set(rows.map((r) => r.mode)).size} modes, brand pairs included.`);
|
|
133
138
|
return true;
|
|
134
139
|
}
|
package/lib/figma.mjs
ADDED
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* loomiere figma: sends the built theme (exports/figma.json) to a Figma file.
|
|
3
|
+
*
|
|
4
|
+
* loomiere figma push build, then push by the first way that works here:
|
|
5
|
+
* --via claude Claude Code with a Figma connector runs the push scripts
|
|
6
|
+
* --via api Figma's REST API with FIGMA_TOKEN (Enterprise plans only;
|
|
7
|
+
* variables only, the API can't write text styles)
|
|
8
|
+
* --via plugin serve the build for the Loomiere plugin's Pull from project
|
|
9
|
+
* --via scripts write the push scripts and stop (what Claude runs in chat)
|
|
10
|
+
* loomiere figma serve serve the build for the plugin, without pushing
|
|
11
|
+
*
|
|
12
|
+
* Options: --file <Figma URL or key> (or "figma": { "file": … } in loomiere.config.json),
|
|
13
|
+
* --since <snapshot.json> (scripts: only what changed), --remove (delete what the build no
|
|
14
|
+
* longer has), --dry-run (report only), --port <n>.
|
|
15
|
+
*
|
|
16
|
+
* Every way runs the same engine (figma/engine.js), so they leave the file the same.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync, spawn } from 'node:child_process';
|
|
19
|
+
import fs from 'node:fs';
|
|
20
|
+
import http from 'node:http';
|
|
21
|
+
import path from 'node:path';
|
|
22
|
+
import vm from 'node:vm';
|
|
23
|
+
|
|
24
|
+
import { engineRoot } from './tool-server.mjs';
|
|
25
|
+
|
|
26
|
+
const ENGINE_FILE = path.join(engineRoot, 'figma', 'engine.js');
|
|
27
|
+
/** use_figma takes up to 50,000 characters of code; leave room for the call around the engine. */
|
|
28
|
+
const PART_LIMIT = 48000;
|
|
29
|
+
export const DEFAULT_PORT = 4322;
|
|
30
|
+
|
|
31
|
+
const engineText = () => fs.readFileSync(ENGINE_FILE, 'utf8');
|
|
32
|
+
/** The engine without its comments, so more of each part is data. */
|
|
33
|
+
const engineCompact = () =>
|
|
34
|
+
engineText()
|
|
35
|
+
.replace(/\/\*[\s\S]*?\*\//g, '')
|
|
36
|
+
.replace(/^\s*\/\/.*$/gm, '')
|
|
37
|
+
.replace(/\n\s*\n/g, '\n')
|
|
38
|
+
.trim();
|
|
39
|
+
|
|
40
|
+
/** The engine's own functions, for Node: fingerprints and layouts match the file side exactly. */
|
|
41
|
+
export function engine() {
|
|
42
|
+
const context = {};
|
|
43
|
+
vm.runInNewContext(`${engineText()}\nthis.api = { loomiereFingerprint, loomiereLayout, loomiereGroupOf, loomiereGroupHash, loomiereHash, loomiereText };`, context);
|
|
44
|
+
return context.api;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const figmaJsonPath = (config) => path.join(config.outDir, 'exports', 'figma.json');
|
|
48
|
+
|
|
49
|
+
export function readFigmaJson(config) {
|
|
50
|
+
const file = figmaJsonPath(config);
|
|
51
|
+
if (!fs.existsSync(file)) throw new Error(`No ${path.relative(config.cwd, file)} yet. Run loomiere build (and keep "figma" in "exports" if you list them).`);
|
|
52
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** "https://www.figma.com/design/KEY/Name?…" or "KEY" → "KEY". */
|
|
56
|
+
export function fileKeyOf(value) {
|
|
57
|
+
if (!value) return null;
|
|
58
|
+
const m = String(value).match(/figma\.com\/(?:design|file)\/([0-9a-zA-Z]{22,128})(?:\/branch\/([0-9a-zA-Z]{22,128}))?/);
|
|
59
|
+
if (m) return m[2] ?? m[1];
|
|
60
|
+
return /^[0-9a-zA-Z]{22,128}$/.test(value) ? value : null;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const call = (data, options) => `\nreturn await loomiereSync(${JSON.stringify(data)}, ${JSON.stringify(options)});\n`;
|
|
64
|
+
|
|
65
|
+
const SNAPSHOT_NEEDS = ['LOOMIERE_FLAT_NAMES', 'LOOMIERE_MODE_NAMES', 'loomiereRgba', 'loomiereHex8', 'loomiereCanon', 'loomiereText', 'loomiereHash', 'loomiereModeLimit', 'loomiereReadFile', 'loomiereFileFingerprint', 'loomiereGroupOf', 'loomiereGroupHash', 'loomiereSnapshot'];
|
|
66
|
+
|
|
67
|
+
/** The script that reads what the file has, run first in every scripted push: just the parts it needs. */
|
|
68
|
+
export function snapshotScript() {
|
|
69
|
+
const blocks = engineCompact().split(/\n(?=(?:var|function|async function) )/);
|
|
70
|
+
const needed = blocks.filter((b) => SNAPSHOT_NEEDS.includes(b.match(/^(?:var|function|async function) (\w+)/)?.[1]));
|
|
71
|
+
return `${needed.join('\n')}\nreturn await loomiereSnapshot();\n`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The push as use_figma scripts, each under the size limit. `snapshot` is what
|
|
76
|
+
* snapshotScript() returned: the plan's mode limit and a hash per group of variables, so only
|
|
77
|
+
* groups that differ are sent, each whole in one part.
|
|
78
|
+
*/
|
|
79
|
+
export function pushScripts(data, snapshot, { remove = false, dryRun = false } = {}) {
|
|
80
|
+
const { loomiereFingerprint, loomiereLayout, loomiereGroupOf, loomiereGroupHash } = engine();
|
|
81
|
+
const limit = snapshot.modeLimit ?? 1;
|
|
82
|
+
const warnings = [];
|
|
83
|
+
const layout = loomiereLayout(data, limit, 'auto', warnings);
|
|
84
|
+
const head = engineCompact();
|
|
85
|
+
const room = PART_LIMIT - head.length - 400;
|
|
86
|
+
const base = { format: data.format, version: data.version, brand: data.brand };
|
|
87
|
+
const options = { layout: 'modes', modeLimit: limit, dryRun, partial: true, remove };
|
|
88
|
+
const headers = layout.collections.map((c) => ({ name: c.name, modes: c.modes, ...(c.hidden && { hidden: true }) }));
|
|
89
|
+
|
|
90
|
+
// The build's groups, the same way the snapshot groups the file.
|
|
91
|
+
const groups = new Map();
|
|
92
|
+
for (const c of layout.collections) {
|
|
93
|
+
for (const v of c.variables) {
|
|
94
|
+
const g = `${c.name}:${loomiereGroupOf(v.name)}`;
|
|
95
|
+
if (!groups.has(g)) groups.set(g, { collection: c.name, variables: [], entries: [] });
|
|
96
|
+
groups.get(g).variables.push(v);
|
|
97
|
+
groups.get(g).entries.push([v.name, loomiereFingerprint(c, v)]);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
const have = snapshot.groups ?? {};
|
|
101
|
+
const changed = [...groups].filter(([g, { entries }]) => have[g] !== loomiereGroupHash(entries));
|
|
102
|
+
const ours = layout.collections.map((c) => c.name);
|
|
103
|
+
const removeGroups = Object.keys(have).filter((g) => ours.includes(g.slice(0, g.indexOf(':'))) && !groups.has(g));
|
|
104
|
+
const removeCollections = (snapshot.collections ?? []).filter((n) => !ours.includes(n));
|
|
105
|
+
|
|
106
|
+
const parts = [];
|
|
107
|
+
let current = [];
|
|
108
|
+
let size = 0;
|
|
109
|
+
const flush = (extra = {}) => {
|
|
110
|
+
const collections = headers.map((h) => ({ ...h, variables: current.filter(([, g]) => g.collection === h.name).flatMap(([, g]) => g.variables) }));
|
|
111
|
+
parts.push(head + call({ ...base, collections, ...extra.data }, { ...options, complete: current.map(([key]) => key), textStyles: false, ...extra.options }));
|
|
112
|
+
current = [];
|
|
113
|
+
size = 0;
|
|
114
|
+
};
|
|
115
|
+
for (const entry of changed) {
|
|
116
|
+
const n = JSON.stringify(entry[1].variables).length + entry[0].length + 4;
|
|
117
|
+
if (size && size + n > room) flush();
|
|
118
|
+
current.push(entry);
|
|
119
|
+
size += n;
|
|
120
|
+
}
|
|
121
|
+
if (current.length) flush();
|
|
122
|
+
// Last: text and effect styles, and the groups and collections the build no longer has.
|
|
123
|
+
flush({ data: { textStyles: data.textStyles, effectStyles: data.effectStyles }, options: { textStyles: true, removeGroups, removeCollections } });
|
|
124
|
+
const tooBig = parts.findIndex((p) => p.length > 50000);
|
|
125
|
+
if (tooBig !== -1) throw new Error(`Push part ${tooBig + 1} is over use_figma's 50,000 characters; a variable group is too large to send in one part.`);
|
|
126
|
+
const variables = changed.reduce((n, [, g]) => n + g.variables.length, 0);
|
|
127
|
+
return { layout: layout.name, limit, changed: variables, groups: changed.length, parts, warnings };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Writes the scripts to <out>/figma-push/ and returns their paths. */
|
|
131
|
+
function writeScripts(config, scripts) {
|
|
132
|
+
const dir = path.join(config.outDir, 'figma-push');
|
|
133
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
135
|
+
return scripts.map((text, i) => {
|
|
136
|
+
const file = path.join(dir, `${String(i + 1).padStart(2, '0')}.js`);
|
|
137
|
+
fs.writeFileSync(file, text);
|
|
138
|
+
return file;
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// --- via plugin --------------------------------------------------------------
|
|
143
|
+
|
|
144
|
+
/** Serves exports/figma.json for the plugin's Pull from project, read fresh on each request. */
|
|
145
|
+
export function serve(config, { port = DEFAULT_PORT } = {}) {
|
|
146
|
+
const server = http.createServer((req, res) => {
|
|
147
|
+
const headers = { 'access-control-allow-origin': '*', 'access-control-allow-private-network': 'true', 'cache-control': 'no-store' };
|
|
148
|
+
if (req.method === 'OPTIONS') return res.writeHead(204, { ...headers, 'access-control-allow-methods': 'GET' }), res.end();
|
|
149
|
+
if (req.method !== 'GET' || new URL(req.url, 'http://localhost').pathname !== '/figma.json') return res.writeHead(404, headers), res.end();
|
|
150
|
+
try {
|
|
151
|
+
res.writeHead(200, { ...headers, 'content-type': 'application/json' });
|
|
152
|
+
res.end(fs.readFileSync(figmaJsonPath(config)));
|
|
153
|
+
} catch (err) {
|
|
154
|
+
res.writeHead(500, headers);
|
|
155
|
+
res.end(err.message);
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
server.listen(port, '127.0.0.1', () => {
|
|
159
|
+
console.log(`Serving ${path.relative(config.cwd, figmaJsonPath(config))} at http://localhost:${port}/figma.json`);
|
|
160
|
+
console.log('In Figma: Plugins › Development › Loomiere › Pull from project. Ctrl+C to stop.');
|
|
161
|
+
});
|
|
162
|
+
return server;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// --- via claude --------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
const hasClaude = () => {
|
|
168
|
+
try {
|
|
169
|
+
execFileSync('claude', ['--version'], { stdio: 'ignore' });
|
|
170
|
+
return true;
|
|
171
|
+
} catch {
|
|
172
|
+
return false;
|
|
173
|
+
}
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
const FIGMA_TOOLS = ['mcp__figma__use_figma', 'mcp__Figma__use_figma', 'mcp__claude_ai_Figma__use_figma', 'mcp__figma-remote-mcp__use_figma'];
|
|
177
|
+
|
|
178
|
+
function viaClaude(config, fileKey, { remove, dryRun }) {
|
|
179
|
+
const dir = path.join(config.outDir, 'figma-push');
|
|
180
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
181
|
+
const snap = path.join(dir, 'snapshot.js');
|
|
182
|
+
fs.writeFileSync(snap, snapshotScript());
|
|
183
|
+
const rel = (p) => path.relative(config.cwd, p);
|
|
184
|
+
const flags = [remove && '--remove', dryRun && '--dry-run'].filter(Boolean).join(' ');
|
|
185
|
+
const prompt = [
|
|
186
|
+
`Push this project's Loomiere theme to the Figma file ${fileKey} with the Figma connector's use_figma tool.`,
|
|
187
|
+
`1. Run the code in ${rel(snap)} with use_figma on that file, exactly as written. Save the JSON it returns to ${rel(path.join(dir, 'snapshot.json'))}.`,
|
|
188
|
+
`2. Run: npx loomiere figma push --via scripts --since ${rel(path.join(dir, 'snapshot.json'))} ${flags}`.trim(),
|
|
189
|
+
` It writes numbered scripts to ${rel(dir)}/.`,
|
|
190
|
+
'3. Run each numbered script with use_figma on the file, one at a time, in order, exactly as written. Do not edit them.',
|
|
191
|
+
'4. Report what the last script returned: what was created, updated and removed, and any warnings.',
|
|
192
|
+
].join('\n');
|
|
193
|
+
console.log(`Pushing to Figma through Claude Code (file ${fileKey})…`);
|
|
194
|
+
const child = spawn('claude', ['-p', prompt, '--allowedTools', ['Read', 'Write', 'Bash(npx loomiere figma push:*)', ...FIGMA_TOOLS].join(',')], {
|
|
195
|
+
cwd: config.cwd,
|
|
196
|
+
stdio: 'inherit',
|
|
197
|
+
});
|
|
198
|
+
return new Promise((resolve) => child.on('exit', (code) => resolve(code === 0)));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// --- via api (Figma Enterprise) ---------------------------------------------
|
|
202
|
+
|
|
203
|
+
async function figmaApi(token, method, url, body) {
|
|
204
|
+
const res = await fetch(`https://api.figma.com${url}`, {
|
|
205
|
+
method,
|
|
206
|
+
headers: { 'X-Figma-Token': token, 'content-type': 'application/json' },
|
|
207
|
+
body: body && JSON.stringify(body),
|
|
208
|
+
});
|
|
209
|
+
const json = await res.json().catch(() => ({}));
|
|
210
|
+
if (!res.ok) throw new Error(`Figma API ${res.status}: ${json.message ?? json.err ?? res.statusText}${res.status === 403 ? ' (writing variables through the API needs an Enterprise plan and a token with file_variables:write)' : ''}`);
|
|
211
|
+
return json;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const rgba = (hex) => {
|
|
215
|
+
const h = hex.replace('#', '');
|
|
216
|
+
const n = (i) => parseInt(h.slice(i, i + 2), 16) / 255;
|
|
217
|
+
return { r: n(0), g: n(2), b: n(4), a: h.length === 8 ? n(6) : 1 };
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Variables through POST /v1/files/:key/variables. Enterprise allows modes in every
|
|
222
|
+
* collection, so this sends the modes layout. Matched by name like the engine; only changed
|
|
223
|
+
* variables are sent. Text styles can't go through the API.
|
|
224
|
+
*/
|
|
225
|
+
async function viaApi(config, fileKey, token, { remove, dryRun }) {
|
|
226
|
+
const { loomiereFingerprint, loomiereHash, loomiereText } = engine();
|
|
227
|
+
const data = readFigmaJson(config);
|
|
228
|
+
const { meta } = await figmaApi(token, 'GET', `/v1/files/${fileKey}/variables/local`);
|
|
229
|
+
const cols = Object.values(meta.variableCollections).filter((c) => !c.remote);
|
|
230
|
+
const vars = Object.values(meta.variables).filter((v) => !v.remote);
|
|
231
|
+
const colByName = new Map(cols.map((c) => [c.name, c]));
|
|
232
|
+
const keyOf = (v) => `${meta.variableCollections[v.variableCollectionId]?.name}:${v.name}`;
|
|
233
|
+
const varByKey = new Map(vars.map((v) => [keyOf(v), v]));
|
|
234
|
+
const idOf = (key) => varByKey.get(key)?.id ?? `tmp:${key}`;
|
|
235
|
+
const canon = (type, raw) => {
|
|
236
|
+
if (raw?.type === 'VARIABLE_ALIAS') return `@${keyOf(meta.variables[raw.id] ?? {})}`;
|
|
237
|
+
if (type === 'COLOR') return `#${['r', 'g', 'b', 'a'].map((k) => `0${Math.round((raw[k] ?? 1) * 255).toString(16)}`.slice(-2)).join('')}`;
|
|
238
|
+
if (type === 'FLOAT') return String(Math.round(raw * 1000) / 1000);
|
|
239
|
+
return String(raw);
|
|
240
|
+
};
|
|
241
|
+
const body = { variableCollections: [], variableModes: [], variables: [], variableModeValues: [] };
|
|
242
|
+
const modeId = {};
|
|
243
|
+
for (const c of data.collections) {
|
|
244
|
+
const col = colByName.get(c.name);
|
|
245
|
+
const colId = col?.id ?? `tmp:${c.name}`;
|
|
246
|
+
if (!col) body.variableCollections.push({ action: 'CREATE', id: colId, name: c.name, initialModeId: `tmp:${c.name}:${c.modes[0]}`, hiddenFromPublishing: !!c.hidden });
|
|
247
|
+
c.modes.forEach((m, i) => {
|
|
248
|
+
const found = col?.modes.find((x) => x.name === m);
|
|
249
|
+
modeId[`${c.name}:${m}`] = found?.modeId ?? `tmp:${c.name}:${m}`;
|
|
250
|
+
if (!found && (col || i > 0)) body.variableModes.push({ action: 'CREATE', id: `tmp:${c.name}:${m}`, name: m, variableCollectionId: colId });
|
|
251
|
+
if (!col && i === 0) body.variableModes.push({ action: 'UPDATE', id: `tmp:${c.name}:${m}`, name: m, variableCollectionId: colId });
|
|
252
|
+
});
|
|
253
|
+
for (const v of c.variables) {
|
|
254
|
+
const key = `${c.name}:${v.name}`;
|
|
255
|
+
const existing = varByKey.get(key);
|
|
256
|
+
if (existing && col) {
|
|
257
|
+
const parts = [existing.resolvedType, ...c.modes.slice().sort().map((m) => {
|
|
258
|
+
const raw = existing.valuesByMode[col.modes.find((x) => x.name === m)?.modeId];
|
|
259
|
+
return `${m}=${raw === undefined ? '' : canon(existing.resolvedType, raw)}`;
|
|
260
|
+
}), existing.scopes.slice().sort().join(','), existing.codeSyntax?.WEB ?? '', loomiereText(existing.description), col.hiddenFromPublishing ? 'hidden' : ''];
|
|
261
|
+
if (loomiereHash(parts.join('|')) === loomiereFingerprint(c, v)) continue;
|
|
262
|
+
}
|
|
263
|
+
body.variables.push({
|
|
264
|
+
action: existing ? 'UPDATE' : 'CREATE',
|
|
265
|
+
id: idOf(key),
|
|
266
|
+
...(!existing && { name: v.name, variableCollectionId: colId, resolvedType: v.type }),
|
|
267
|
+
scopes: v.scopes,
|
|
268
|
+
...(v.code && { codeSyntax: { WEB: v.code } }),
|
|
269
|
+
description: v.description ?? '',
|
|
270
|
+
});
|
|
271
|
+
for (const m of c.modes) {
|
|
272
|
+
const value = v.values[m];
|
|
273
|
+
if (value === undefined || value === null) continue;
|
|
274
|
+
body.variableModeValues.push({
|
|
275
|
+
variableId: idOf(key),
|
|
276
|
+
modeId: modeId[`${c.name}:${m}`],
|
|
277
|
+
value: value.alias ? { type: 'VARIABLE_ALIAS', id: idOf(value.alias) } : v.type === 'COLOR' ? rgba(value) : value,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
const wanted = new Set(data.collections.flatMap((c) => c.variables.map((v) => `${c.name}:${v.name}`)));
|
|
283
|
+
const gone = vars.filter((v) => data.collections.some((c) => c.name === meta.variableCollections[v.variableCollectionId]?.name) && !wanted.has(keyOf(v)));
|
|
284
|
+
if (remove) for (const v of gone) body.variables.push({ action: 'DELETE', id: v.id });
|
|
285
|
+
console.log(`${body.variables.filter((v) => v.action === 'CREATE').length} to create, ${body.variables.filter((v) => v.action === 'UPDATE').length} to update, ${gone.length} no longer in the build${remove ? ' (deleting)' : ''}.`);
|
|
286
|
+
if (dryRun) return true;
|
|
287
|
+
// The API takes up to 5,000 changes a request, and temporary ids only last one request: send
|
|
288
|
+
// collections, modes and variables first, then the values in batches with the real ids.
|
|
289
|
+
const { variableModeValues, ...structure } = body;
|
|
290
|
+
const first = await figmaApi(token, 'POST', `/v1/files/${fileKey}/variables`, structure);
|
|
291
|
+
const real = (id) => first.meta?.tempIdToRealId?.[id] ?? id;
|
|
292
|
+
for (let i = 0; i < variableModeValues.length; i += 4000) {
|
|
293
|
+
const batch = variableModeValues.slice(i, i + 4000).map((v) => ({
|
|
294
|
+
...v,
|
|
295
|
+
variableId: real(v.variableId),
|
|
296
|
+
modeId: real(v.modeId),
|
|
297
|
+
value: v.value?.type === 'VARIABLE_ALIAS' ? { ...v.value, id: real(v.value.id) } : v.value,
|
|
298
|
+
}));
|
|
299
|
+
await figmaApi(token, 'POST', `/v1/files/${fileKey}/variables`, { variableModeValues: batch });
|
|
300
|
+
}
|
|
301
|
+
console.log('Variables pushed. Text and effect styles can only be written inside Figma: ask Claude to sync, or use the plugin.');
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// --- the command -------------------------------------------------------------
|
|
306
|
+
|
|
307
|
+
export async function figmaCommand(config, sub, { via, file, since, remove = false, dryRun = false, port } = {}) {
|
|
308
|
+
if (sub === 'serve') return serve(config, { port });
|
|
309
|
+
if (sub !== 'push') throw new Error('Use loomiere figma push or loomiere figma serve.');
|
|
310
|
+
const fileKey = fileKeyOf(file ?? config.figma?.file ?? config.figma);
|
|
311
|
+
|
|
312
|
+
if (via === 'scripts') {
|
|
313
|
+
if (!since) {
|
|
314
|
+
const [snap] = writeScripts(config, [snapshotScript()]);
|
|
315
|
+
console.log(`Wrote ${path.relative(config.cwd, snap)}. Run it with use_figma, save what it returns, then: loomiere figma push --via scripts --since <that file>`);
|
|
316
|
+
return true;
|
|
317
|
+
}
|
|
318
|
+
const snapshot = JSON.parse(fs.readFileSync(path.resolve(config.cwd, since), 'utf8'));
|
|
319
|
+
const { layout, limit, changed, groups, parts, warnings } = pushScripts(readFigmaJson(config), snapshot, { remove, dryRun });
|
|
320
|
+
const files = writeScripts(config, parts);
|
|
321
|
+
for (const w of warnings) console.log(`Note: ${w}`);
|
|
322
|
+
console.log(`${changed} variables in ${groups} groups differ from the file (${layout} layout, ${limit} modes per collection allowed).`);
|
|
323
|
+
console.log(`Wrote ${files.length} scripts to ${path.relative(config.cwd, path.dirname(files[0]))}/: run them in order with use_figma.`);
|
|
324
|
+
return true;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const token = process.env.FIGMA_TOKEN;
|
|
328
|
+
const chosen = via ?? (token && fileKey ? 'api' : hasClaude() && fileKey ? 'claude' : 'plugin');
|
|
329
|
+
if (chosen !== 'plugin' && !fileKey) throw new Error('Which Figma file? Pass --file <url> or set "figma": { "file": "<url>" } in loomiere.config.json.');
|
|
330
|
+
if (chosen === 'api') {
|
|
331
|
+
if (!token) throw new Error('--via api needs FIGMA_TOKEN (a Figma personal access token with file_variables:write).');
|
|
332
|
+
return viaApi(config, fileKey, token, { remove, dryRun });
|
|
333
|
+
}
|
|
334
|
+
if (chosen === 'claude') return viaClaude(config, fileKey, { remove, dryRun });
|
|
335
|
+
if (!via) console.log('No FIGMA_TOKEN (Enterprise) or Claude Code here, so serving the build for the Loomiere plugin instead.');
|
|
336
|
+
serve(config, { port });
|
|
337
|
+
return true;
|
|
338
|
+
}
|
package/lib/studio.mjs
CHANGED
|
@@ -15,6 +15,7 @@ import path from 'node:path';
|
|
|
15
15
|
|
|
16
16
|
import { formatBrand } from '../generator/format-brand.mjs';
|
|
17
17
|
import { brandFiles, schemaPathFor } from './config.mjs';
|
|
18
|
+
import { readFigmaJson } from './figma.mjs';
|
|
18
19
|
import { engineRoot, readJson, saveAndRebuild, serveTool } from './tool-server.mjs';
|
|
19
20
|
|
|
20
21
|
const toolDir = path.join(engineRoot, 'tools', 'brand-builder');
|
|
@@ -92,7 +93,10 @@ export function studio(config, { port = 4322, exportFile } = {}) {
|
|
|
92
93
|
routes: {
|
|
93
94
|
'GET /api/brand': (_, url) => load(url.searchParams.get('name') || brandName),
|
|
94
95
|
'POST /api/brand': save,
|
|
96
|
+
// The built theme for the Loomiere Figma plugin's Pull from project.
|
|
97
|
+
'GET /figma.json': () => readFigmaJson(config),
|
|
95
98
|
},
|
|
99
|
+
open: ['/figma.json'],
|
|
96
100
|
onListen: () => {
|
|
97
101
|
console.log(`Brand studio for "${brandName}": http://localhost:${port}`);
|
|
98
102
|
console.log(`Save writes ${path.relative(config.cwd, brandDir) || '.'}/<name>.json; saving "${brandName}" also rebuilds the theme. Ctrl+C to stop.`);
|
package/lib/tool-server.mjs
CHANGED
|
@@ -53,16 +53,19 @@ const TYPES = {
|
|
|
53
53
|
/**
|
|
54
54
|
* Serves `dir` at / plus any `mounts` ({ '/prefix/': directory }), and JSON `routes`
|
|
55
55
|
* ({ 'GET /data.json': () => data, 'POST /save': (body) => result }). A route that throws
|
|
56
|
-
* answers 400 with { ok: false, error }.
|
|
56
|
+
* answers 400 with { ok: false, error }. Paths in `open` can be read from other origins (the
|
|
57
|
+
* Figma plugin reads /figma.json).
|
|
57
58
|
*/
|
|
58
|
-
export function serveTool({ dir, mounts = {}, routes, port, onListen }) {
|
|
59
|
+
export function serveTool({ dir, mounts = {}, routes, port, onListen, open = [] }) {
|
|
59
60
|
const server = http.createServer(async (req, res) => {
|
|
61
|
+
const url = new URL(req.url, 'http://localhost');
|
|
62
|
+
const cors = open.includes(url.pathname) ? { 'access-control-allow-origin': '*', 'access-control-allow-private-network': 'true' } : {};
|
|
60
63
|
const send = (status, body, type = TYPES['.json']) => {
|
|
61
|
-
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store' });
|
|
64
|
+
res.writeHead(status, { 'content-type': type, 'cache-control': 'no-store', ...cors });
|
|
62
65
|
res.end(typeof body === 'string' ? body : JSON.stringify(body));
|
|
63
66
|
};
|
|
64
67
|
try {
|
|
65
|
-
|
|
68
|
+
if (req.method === 'OPTIONS' && open.includes(url.pathname)) return res.writeHead(204, { ...cors, 'access-control-allow-methods': 'GET' }), res.end();
|
|
66
69
|
const route = routes[`${req.method} ${url.pathname}`];
|
|
67
70
|
if (route) {
|
|
68
71
|
let body;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@scattered-light/loomiere",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Turns a small brand file into a full design token theme: OKLCH color ramps, a type scale, light, dark and high-contrast modes, plus Tailwind, MUI, Chakra, AG Grid and Figma exports.",
|
|
5
5
|
"keywords": ["design-tokens", "theme", "style-dictionary", "dtcg", "oklch", "base-ui", "ag-grid", "tailwind"],
|
|
6
6
|
"repository": {
|
|
@@ -25,12 +25,16 @@
|
|
|
25
25
|
"bin",
|
|
26
26
|
"build",
|
|
27
27
|
"exporters",
|
|
28
|
+
"figma",
|
|
28
29
|
"generator",
|
|
29
30
|
"lib",
|
|
30
31
|
"templates",
|
|
31
32
|
"tokens",
|
|
32
33
|
"tools"
|
|
33
34
|
],
|
|
35
|
+
"scripts": {
|
|
36
|
+
"build": "node figma/build-plugin.mjs"
|
|
37
|
+
},
|
|
34
38
|
"publishConfig": {
|
|
35
39
|
"access": "public",
|
|
36
40
|
"provenance": false
|
package/tokens/README.md
CHANGED
|
@@ -26,18 +26,21 @@ Light, dark and high-contrast colors are decided in one place: the slot table in
|
|
|
26
26
|
| `dark` | `prefers-color-scheme: dark` (unless `data-theme="light"`), or `data-theme="dark"` |
|
|
27
27
|
| `contrast-more` | `prefers-contrast: more` |
|
|
28
28
|
| `dark-contrast-more` | Both of the above |
|
|
29
|
-
| `reduced-motion` | `prefers-reduced-motion: reduce` (
|
|
29
|
+
| `reduced-motion` | `prefers-reduced-motion: reduce` (durations drop to 1ms, so things change at once instead of moving) |
|
|
30
30
|
|
|
31
31
|
Palettes never change between modes. Dark mode picks different palette steps, so `--sl-color-neutral-100` is the same color everywhere and `--sl-scope-neutral-overlay` (and through it `--sl-surface-overlay`) is what flips. Use semantic tokens in app code. Set `data-theme` on the root element: the variables that point at slots resolve there.
|
|
32
32
|
|
|
33
33
|
### Color vision themes
|
|
34
34
|
|
|
35
|
-
There's no media query for color blindness, so
|
|
35
|
+
Color vision themes are themes of their own, next to light, dark and the two high-contrast themes. There's no media query for color blindness, so they're opt-in: set `data-theme` on the root element to `deuteranopia` (green-blind, the most common), `protanopia` (red-blind), `tritanopia` (blue-blind) or `achromatopsia` (no color vision, which also suits grayscale screens). Each is a light theme that re-declares the spot palettes (`--sl-color-primary-*` through `quaternary` and `success`, `warning`, `error`, `info`, with their on-colors) and the data colors (`--sl-color-data-*`) with versions generated for that kind of color vision; everything that reads them follows. The system's dark mode and contrast setting don't change them. Set the attribute on the root element only: variables declared at `:root` resolve there.
|
|
36
|
+
|
|
37
|
+
The full list of `data-theme` values: `light`, `dark`, `light-high-contrast`, `dark-high-contrast`, `deuteranopia`, `protanopia`, `tritanopia`, `achromatopsia`. With no `data-theme`, the system picks light or dark and high contrast.
|
|
36
38
|
|
|
37
39
|
The generator (`generator/vision.mjs`) designs each theme from the brand's own alert and data colors every build, so any brand file gets its own four themes. Nothing is a fixed, shared palette:
|
|
38
40
|
|
|
39
41
|
- **Palettes.** Every spot palette (brand colors and alerts; the neutral stays) moves until colors that look different with typical vision don't blur together with that vision. Colors that look alike to everyone, like an ember brand color and an error red, aren't pushed apart. For the three color blindnesses palettes turn hue (alerts at most 40°, so a green success never turns yellow; brand colors cost four times as much to move, since they carry identity). For achromatopsia, where hue means nothing, they darken instead (never lighten, so none loses contrast). Each ramp is rebuilt on the same contrast table, so every step keeps its contrast, and the theme re-declares every step and on-color.
|
|
40
42
|
- **Data colors** are nudged, in series order, until each is at least ΔE 15 from the two before and after it as seen with that vision. None loses contrast on paper.
|
|
43
|
+
- **Order.** `color.vision.tritanopia.dataOrder = ["coral", "navy"]` puts the data series in that theme's own order (names left out follow in the brand's order), so the first few series can be the ones that stay apart for that vision. Data colors are tokens by name (`--sl-color-data-coral`); the series slots `--sl-color-data-1`… point at a name, and each theme re-points them.
|
|
41
44
|
- **Pins.** `color.vision.deuteranopia.keys.primary = "#3a5bd9"` fixes the color that stands for a palette (its most colorful key; the whole ramp moves with it), and `color.vision.deuteranopia.data.coral = "#d55e00"` fixes one data color. The others move to make room. The brand builder's **Color vision** section shows the brand and each theme as cards; open a theme to see every color as designed and as seen, and pin any of them.
|
|
42
45
|
|
|
43
46
|
For low vision, the `contrast-more` modes above already raise contrast when the system asks for it.
|
|
@@ -65,7 +68,9 @@ The accent can be any brand color; a red brand might keep red for errors and pic
|
|
|
65
68
|
|
|
66
69
|
**Concept picks.** `roles.concepts` in the brand file replaces a role's default step for one concept, in light, dark or both: `"chrome.fg": { "light": "primary.1000" }` sets body text to the brand's text color without touching any palette. A pick is `palette.step`, `white` (paper), `black` (ink) or a hex. The Concepts section of `loomiere studio` shows every concept in light and dark with its contrast and what uses it, and Reset drops a pick. Spot palettes share a contrast table, so `solid` has the same contrast in every hue; that is why any palette can stand in for another. `--sl-scope-<slot>` is the scope in effect: the accent by default, any palette under `data-color="secondary"` (or `error`, `chrome`…).
|
|
67
70
|
|
|
68
|
-
**Emphasis.** Styles are `solid`, `soft`, `outline` and `ghost`. `data-emphasis="subtle"` sets `--sl-e-bg`, `-bg-hover`, `-bg-active`, `-fg`, `-border` and `-border-hover` from the level's style. Accent levels follow the scope around them, so a `data-color` on a section re-colors them; chrome and named-palette levels stay put unless the element sets its own `data-color`. In React: `<Button emphasis="bold">`, `<Button emphasis="subtle" palette="error">`.
|
|
71
|
+
**Emphasis.** Styles are `solid`, `soft`, `outline` and `ghost`. `data-emphasis="subtle"` sets `--sl-e-bg`, `-bg-hover`, `-bg-active`, `-fg`, `-border` and `-border-hover` from the level's style. Accent levels follow the scope around them, so a `data-color` on a section re-colors them; chrome and named-palette levels stay put unless the element sets its own `data-color`. In React: `<Button emphasis="bold">`, `<Button emphasis="subtle" palette="error">`. Emphasis isn't only for buttons: each component that has a loudness choice starts at a level of its own (Button bold, Toggle's pressed state standard, Avatar initials bold) and paints with `--sl-e-*`, so `data-emphasis` on it picks another level, and on a Toggle Group sets every toggle in it. A new component joins by reading `--sl-e-*` and getting a default level in `DEFAULT_LEVELS` (build/build-tokens.mjs).
|
|
72
|
+
|
|
73
|
+
**Statuses.** `data-status="info|success|warning|error"` on any element gives every emphasis level inside it that status's version: by default the level's style in the status's palette, chrome levels included, so `<div data-status="warning" data-emphasis="subtle">` is a soft warning banner and a bold button inside it is solid warning. A toast's type is its status. `roles.status` in the brand file lets a brand pair stand in for a level, per status: `{ "default": { "bold": "mirage-deep" }, "warning": { "subtle": "dune-soft" } }`. The pair paints the element: `bg` and `fg`, with `bg-hover` and `bg-active` mixed from them (`pair.<name>.bg-hover`, kept at AA). Levels left out stay automatic. The tokens are `status-level.<status>.<level>.<part>` (`tokens/semantic/status.json`, generated); `emphasis.<level>.<part>` is the default status's row.
|
|
69
74
|
|
|
70
75
|
## Exports for other frameworks
|
|
71
76
|
|
|
@@ -88,7 +93,7 @@ The universal outputs are the CSS variables (`tokens.css`, works with any framew
|
|
|
88
93
|
|
|
89
94
|
## Text styles and utilities
|
|
90
95
|
|
|
91
|
-
Each text style is one `typography` token in `semantic/typography.json`, named
|
|
96
|
+
Each text style is one `typography` token in `semantic/typography.json`, named role first, then tier: `body-base`, `label-base`, `trigger-condense`, `eyebrow-condense`, `title-magnify`. The role is the job (body, label, trigger, selected, link, subtitle, eyebrow, title, code); the tier sets the size (minimize, condense, base, magnify, amplify, maximize). Titles use the display family: `title-amplify` for dialogs and sections, `title-maximize` for the page head. A style can be another style (`"link-base": "{font.body-base}"`) when only color sets it apart. The build expands every style into the CSS variables components use, `--sl-font-<style>-font` (shorthand), `-font-family`, `-font-size`, `-font-weight`, `-line-height`, `-letter-spacing` and, for uppercase styles, `-text-transform`.
|
|
92
97
|
|
|
93
98
|
`loomiere build` also writes the utility classes into `<theme>/utilities.css`, so they always match the tokens: `.text-style-<style>` for every text style, `.icon-<tier>` for every icon size, `.focus-ring` and `.focus-ring-inset`, and `.hide-on-mobile|tablet|desktop` from the breakpoints.
|
|
94
99
|
|
|
@@ -110,7 +115,7 @@ Some looks belong to a family of components, not to one. Those are concepts in t
|
|
|
110
115
|
- Tooltip, Preview card and Navigation menu are built from existing concepts. Tooltip uses `surface.inverse` with `text.on-inverse-surface`, which has its own Concepts row. Preview card uses `popup.*`. Navigation menu uses `bar.*` for the strip, `popup.*` for the panel, and item hover for links. Each keeps only size tokens.
|
|
111
116
|
- `notice.*` (`semantic/notice.json`): toasts, on the popup surface. A toast whose type is success, warning, error or info takes that palette's soft fill, edge and text (the build points the scope at it); `data-color` picks any other palette. Toast keeps only the stack's width and corner margins.
|
|
112
117
|
- `gauge.*` (`semantic/gauge.json`): Meter and Progress share one bar. The track is `fill.track` (a Concepts row, near the page in dark mode so every palette's fill reads 3:1 on it); the fill is the scope's solid color, so `data-color` on the root sets a zone or status. Neither has component tokens. Slider is a gauge you can drag: its track, fill and label row are `gauge.*` (thin size), the knob is field-colored and ringed in the scope's solid color, and it keeps only its knob size and vertical length.
|
|
113
|
-
- Avatar, Separator and Scroll area are built from existing tokens. Avatar initials
|
|
118
|
+
- Avatar, Separator and Scroll area are built from existing tokens. Avatar initials paint with the bold emphasis level (data-emphasis picks another), its ring is the page color, and it keeps only its five sizes. Separator is `border.default`; Scroll area is `content.scrollbar-*`. Neither has component tokens. Badge, Card and Dropdown token files were removed: no style read them (Base UI has no Badge or Card; menus and selects use `popup.*` and `item.*`).
|
|
114
119
|
- `item.*` (`semantic/item.json`): rows inside menus and listboxes, and the check or dot that marks a chosen row. `item.padding-block-dense` is for menus.
|
|
115
120
|
- `icon-button.*` (`semantic/icon-button.json`): small icon-only buttons, such as the X that dismisses a popover, dialog or toast, and a field's clear and open buttons.
|
|
116
121
|
- `check.*` (`semantic/check.json`): the box or circle you tick. Checkbox, Radio and the Switch track share it, and AG Grid's checkboxes point at it.
|
|
@@ -1,10 +1,45 @@
|
|
|
1
1
|
{
|
|
2
2
|
"avatar": {
|
|
3
|
-
"$description": "Avatar tokens:
|
|
4
|
-
"size-xs": {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
"size-
|
|
3
|
+
"$description": "Avatar tokens: the five sizes and the initials that fit each. The ring is the page color so overlapping avatars cut out of each other; initials are the scope's solid fill and its text (bold emphasis), so data-color re-colors them.",
|
|
4
|
+
"size-xs": {
|
|
5
|
+
"$value": "{space.8}",
|
|
6
|
+
"$description": "32px, inline with text and in dense lists."
|
|
7
|
+
},
|
|
8
|
+
"size-sm": {
|
|
9
|
+
"$value": "{space.10}",
|
|
10
|
+
"$description": "40px, in list rows and comments."
|
|
11
|
+
},
|
|
12
|
+
"size-md": {
|
|
13
|
+
"$value": "{space.12}",
|
|
14
|
+
"$description": "48px, the default."
|
|
15
|
+
},
|
|
16
|
+
"size-lg": {
|
|
17
|
+
"$value": "{space.16}",
|
|
18
|
+
"$description": "64px, in cards and headers."
|
|
19
|
+
},
|
|
20
|
+
"size-xl": {
|
|
21
|
+
"$value": "{space.24}",
|
|
22
|
+
"$description": "96px, on a profile page."
|
|
23
|
+
},
|
|
24
|
+
"initials-font-size-xs": {
|
|
25
|
+
"$value": "{font-size.xs}",
|
|
26
|
+
"$description": "Sized to the avatar, not a text style: initials have to fit inside the circle at each size. The rest of the type is title-base."
|
|
27
|
+
},
|
|
28
|
+
"initials-font-size-sm": {
|
|
29
|
+
"$value": "{font-size.sm}",
|
|
30
|
+
"$description": "Sized to the avatar, not a text style: initials have to fit inside the circle at each size. The rest of the type is title-base."
|
|
31
|
+
},
|
|
32
|
+
"initials-font-size-md": {
|
|
33
|
+
"$value": "{font-size.md}",
|
|
34
|
+
"$description": "Sized to the avatar, not a text style: initials have to fit inside the circle at each size. The rest of the type is title-base."
|
|
35
|
+
},
|
|
36
|
+
"initials-font-size-lg": {
|
|
37
|
+
"$value": "{font-size.lg}",
|
|
38
|
+
"$description": "Sized to the avatar, not a text style: initials have to fit inside the circle at each size. The rest of the type is title-base."
|
|
39
|
+
},
|
|
40
|
+
"initials-font-size-xl": {
|
|
41
|
+
"$value": "{font-size.2xl}",
|
|
42
|
+
"$description": "Sized to the avatar, not a text style: initials have to fit inside the circle at each size. The rest of the type is title-base."
|
|
43
|
+
}
|
|
9
44
|
}
|
|
10
45
|
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"popover": {
|
|
3
3
|
"$description": "Popover's own sizes. Everything else is the popup and close concepts, text styles and CSS.",
|
|
4
|
-
"
|
|
5
|
-
"$value": "
|
|
6
|
-
"$description": "Keeps
|
|
4
|
+
"text-max-width": {
|
|
5
|
+
"$value": "{measure.narrow}",
|
|
6
|
+
"$description": "Keeps the title and description to a readable line length. The popup itself has no fixed cap, so wider content such as a calendar or a form fits; it stops at the space Base UI reports beside the trigger."
|
|
7
7
|
},
|
|
8
8
|
"popup-min-width": {
|
|
9
9
|
"$value": "{popup.min-width}",
|
|
@@ -185,7 +185,7 @@
|
|
|
185
185
|
"$description": "Accent text such as active tabs. One step darker than the interactive color so it stays AA as text."
|
|
186
186
|
},
|
|
187
187
|
"disabled": {
|
|
188
|
-
"$value": "{scope.chrome.
|
|
188
|
+
"$value": "{scope.chrome.fg-disabled}",
|
|
189
189
|
"$type": "color",
|
|
190
190
|
"$description": "Text in disabled controls. Deliberately below AA, which WCAG allows for disabled UI."
|
|
191
191
|
},
|