spec-layer 0.5.0 → 0.6.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.
Files changed (3) hide show
  1. package/README.md +100 -17
  2. package/dist/cli.js +1029 -276
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -559,7 +559,8 @@ var require_sha256 = __commonJS({
559
559
  import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
- import { join as join7 } from "node:path";
562
+ import { existsSync as existsSync8 } from "node:fs";
563
+ import { join as join7, resolve as resolve4 } from "node:path";
563
564
 
564
565
  // ../extractor/src/statesMatrix.ts
565
566
  var STATE_ORDER = [
@@ -1611,15 +1612,15 @@ function styleMember(p, property, scopes, path, name) {
1611
1612
  });
1612
1613
  return null;
1613
1614
  }
1614
- const converted = dtcgLiteral(property.resolved, scopes, p.options.values);
1615
- if ("omit" in converted) {
1616
- if (converted.omit === "unit_not_expressible") {
1615
+ const converted2 = dtcgLiteral(property.resolved, scopes, p.options.values);
1616
+ if ("omit" in converted2) {
1617
+ if (converted2.omit === "unit_not_expressible") {
1617
1618
  reportOnce(p, {
1618
1619
  code: "unit_not_expressible",
1619
1620
  severity: "info",
1620
1621
  path,
1621
1622
  message: `The ${name} unit is not a DTCG dimension unit; the value is kept under $extensions.`,
1622
- details: { property: name, ...converted.details }
1623
+ details: { property: name, ...converted2.details }
1623
1624
  });
1624
1625
  const d = property.resolved;
1625
1626
  return { extension: { value: d.number, unit: d.unit } };
@@ -1629,11 +1630,11 @@ function styleMember(p, property, scopes, path, name) {
1629
1630
  severity: "warning",
1630
1631
  path,
1631
1632
  message: `The ${name} property has a type DTCG cannot state and was omitted.`,
1632
- details: { property: name, ...converted.details }
1633
+ details: { property: name, ...converted2.details }
1633
1634
  });
1634
1635
  return null;
1635
1636
  }
1636
- return { value: converted.$value };
1637
+ return { value: converted2.$value };
1637
1638
  }
1638
1639
  var TYPOGRAPHY_MEMBERS = [
1639
1640
  ["font_family", "fontFamily", []],
@@ -1733,8 +1734,8 @@ function effectLeaf(p, style, path) {
1733
1734
  }
1734
1735
  const raw = effect[field];
1735
1736
  if (raw === void 0) continue;
1736
- const converted = dtcgLiteral(raw, [], p.options.values);
1737
- if (!("omit" in converted)) shadow[name] = converted.$value;
1737
+ const converted2 = dtcgLiteral(raw, [], p.options.values);
1738
+ if (!("omit" in converted2)) shadow[name] = converted2.$value;
1738
1739
  }
1739
1740
  shadow.inset = effect.type === "inner_shadow";
1740
1741
  shadows.push(shadow);
@@ -2000,7 +2001,7 @@ function tokenLeaf(p, token, collection, modeId) {
2000
2001
  }
2001
2002
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2002
2003
  }
2003
- const converted = projectedLiteral(p, token, value.value, (override) => {
2004
+ const converted2 = projectedLiteral(p, token, value.value, (override) => {
2004
2005
  reportOnce(p, {
2005
2006
  code: "unit_override_conflicts_with_scope",
2006
2007
  severity: "warning",
@@ -2009,18 +2010,18 @@ function tokenLeaf(p, token, collection, modeId) {
2009
2010
  details: { id: token.id, override, scopes: [...token.scopes] }
2010
2011
  });
2011
2012
  });
2012
- if ("omit" in converted) {
2013
+ if ("omit" in converted2) {
2013
2014
  reportOnce(p, {
2014
- code: converted.omit,
2015
+ code: converted2.omit,
2015
2016
  severity: "warning",
2016
2017
  path,
2017
2018
  mode,
2018
- message: converted.omit === "type_not_expressible" ? `DTCG has no ${String(converted.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted.details.unit)} value was omitted.`,
2019
- details: { id: token.id, ...converted.details }
2019
+ message: converted2.omit === "type_not_expressible" ? `DTCG has no ${String(converted2.details.type)} type; the value was omitted.` : `DTCG dimensions take px or rem; a ${String(converted2.details.unit)} value was omitted.`,
2020
+ details: { id: token.id, ...converted2.details }
2020
2021
  });
2021
2022
  return null;
2022
2023
  }
2023
- return { $type: converted.$type, $value: converted.$value, ...description };
2024
+ return { $type: converted2.$type, $value: converted2.$value, ...description };
2024
2025
  }
2025
2026
  function dtcgExportFiles(out) {
2026
2027
  const text = (v) => `${JSON.stringify(v, null, 2)}
@@ -2033,6 +2034,465 @@ function dtcgExportFiles(out) {
2033
2034
  return files;
2034
2035
  }
2035
2036
 
2037
+ // ../extractor/src/v5/outputs/naming.ts
2038
+ var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
2039
+ function splitWords(segment) {
2040
+ return segment.replace(new RegExp("(\\p{Ll})(\\p{Lu})", "gu"), "$1 $2").split(/[^\p{L}\p{N}]+/u).filter((w) => w.length > 0);
2041
+ }
2042
+ function pathWords(path) {
2043
+ return path.split(".").flatMap((seg) => {
2044
+ const words = splitWords(seg);
2045
+ return words.length > 0 ? words : ["_"];
2046
+ });
2047
+ }
2048
+ function joinWords(words, nameCase) {
2049
+ const lower = words.map((w) => w.toLowerCase());
2050
+ const cap = (w) => w.charAt(0).toUpperCase() + w.slice(1);
2051
+ switch (nameCase) {
2052
+ case "kebab":
2053
+ return lower.join("-");
2054
+ case "snake":
2055
+ return lower.join("_");
2056
+ case "constant":
2057
+ return lower.join("_").toUpperCase();
2058
+ case "camel":
2059
+ return lower.map((w, i) => i === 0 ? w : cap(w)).join("");
2060
+ case "pascal":
2061
+ return lower.map(cap).join("");
2062
+ default: {
2063
+ const exhaustive = nameCase;
2064
+ return exhaustive;
2065
+ }
2066
+ }
2067
+ }
2068
+ function deriveName(path, nameCase) {
2069
+ return joinWords(pathWords(path), nameCase);
2070
+ }
2071
+ function sortReport(entries) {
2072
+ return [...entries].sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
2073
+ }
2074
+ function resolveNames(paths, meta, rules) {
2075
+ const report2 = [];
2076
+ const candidates = /* @__PURE__ */ new Map();
2077
+ for (const path of [...paths].sort(compareCodeUnits)) {
2078
+ let name = null;
2079
+ let source = "derived";
2080
+ const declared = rules.codeSyntaxKey ? meta[path]?.code_syntax?.[rules.codeSyntaxKey] : void 0;
2081
+ if (declared !== void 0) {
2082
+ name = rules.acceptDeclared(declared);
2083
+ if (name !== null) {
2084
+ source = "code_syntax";
2085
+ } else {
2086
+ report2.push({
2087
+ code: "code_syntax_not_usable",
2088
+ severity: "info",
2089
+ path,
2090
+ message: `The declared ${rules.codeSyntaxKey} identifier "${declared}" is not a name this format can use; the name was derived instead.`,
2091
+ details: { declared, platform: rules.codeSyntaxKey ?? "" }
2092
+ });
2093
+ }
2094
+ }
2095
+ if (name === null) name = rules.affix(deriveName(path, rules.nameCase));
2096
+ const list = candidates.get(name) ?? [];
2097
+ list.push({ path, source });
2098
+ candidates.set(name, list);
2099
+ }
2100
+ const names = /* @__PURE__ */ new Map();
2101
+ const map = {};
2102
+ for (const [name, list] of candidates) {
2103
+ if (list.length > 1) {
2104
+ const collided = list.map((c) => c.path).sort(compareCodeUnits);
2105
+ for (const c of list) {
2106
+ report2.push({
2107
+ code: "name_collision",
2108
+ severity: "error",
2109
+ path: c.path,
2110
+ message: `${list.length} tokens would share the name ${name}; all were omitted.`,
2111
+ details: { name, paths: collided }
2112
+ });
2113
+ }
2114
+ continue;
2115
+ }
2116
+ names.set(list[0].path, name);
2117
+ map[list[0].path] = { name, source: list[0].source };
2118
+ }
2119
+ const sortedMap = Object.fromEntries(Object.entries(map).sort(([a], [b]) => compareCodeUnits(a, b)));
2120
+ return { names, map: sortedMap, report: sortReport(report2) };
2121
+ }
2122
+
2123
+ // ../extractor/src/v5/outputs/css.ts
2124
+ var CSS_DEFAULTS = {
2125
+ case: "kebab",
2126
+ root: ":root",
2127
+ modeSelector: '[data-theme="{mode}"]'
2128
+ };
2129
+ var CSS_HEADER_PREFIX = "/* Generated by spec-layer";
2130
+ var EXT = "com.spec-layer";
2131
+ var CUSTOM_PROPERTY = /^--[A-Za-z0-9_-]+$/;
2132
+ var BARE_IDENT = /^[A-Za-z_][A-Za-z0-9_-]*$/;
2133
+ function acceptCssDeclared(declared) {
2134
+ if (CUSTOM_PROPERTY.test(declared)) return declared;
2135
+ if (BARE_IDENT.test(declared)) return `--${declared}`;
2136
+ return null;
2137
+ }
2138
+ var asRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v) ? v : null;
2139
+ function collectLeaves(tree, prefix, out) {
2140
+ const node = asRecord(tree);
2141
+ if (!node) return;
2142
+ if ("$value" in node) {
2143
+ const ours = asRecord(asRecord(node.$extensions)?.[EXT]);
2144
+ out.push({
2145
+ path: prefix.join("."),
2146
+ type: typeof node.$type === "string" ? node.$type : "",
2147
+ value: node.$value,
2148
+ ext: ours
2149
+ });
2150
+ return;
2151
+ }
2152
+ for (const key of Object.keys(node)) {
2153
+ if (key.startsWith("$")) continue;
2154
+ collectLeaves(node[key], [...prefix, key], out);
2155
+ }
2156
+ }
2157
+ var unpointer = (s) => s.replace(/~1/g, "/").replace(/~0/g, "~");
2158
+ var refFile = (src) => {
2159
+ const r = asRecord(src);
2160
+ return r && typeof r.$ref === "string" ? r.$ref : null;
2161
+ };
2162
+ function sourcesOf(resolver) {
2163
+ const out = [];
2164
+ for (const { $ref } of resolver.resolutionOrder) {
2165
+ const set = /^#\/sets\/(.+)$/.exec($ref);
2166
+ const mod = /^#\/modifiers\/(.+)$/.exec($ref);
2167
+ if (set) {
2168
+ const label = unpointer(set[1]);
2169
+ for (const src of resolver.sets[label]?.sources ?? []) {
2170
+ const file = refFile(src);
2171
+ if (file) out.push({ collection: label, mode: null, file, isDefault: true });
2172
+ }
2173
+ } else if (mod) {
2174
+ const label = unpointer(mod[1]);
2175
+ const m = resolver.modifiers[label];
2176
+ if (!m) continue;
2177
+ for (const [context, srcs] of Object.entries(m.contexts)) {
2178
+ for (const src of srcs) {
2179
+ const file = refFile(src);
2180
+ if (file) out.push({ collection: label, mode: context, file, isDefault: context === m.default });
2181
+ }
2182
+ }
2183
+ }
2184
+ }
2185
+ return out;
2186
+ }
2187
+ var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
2188
+ var collectionSlug = (file) => file.split(".")[0];
2189
+ var REF = /^\{(.+)\}$/;
2190
+ function hexChannels(hex) {
2191
+ const at = (i) => parseInt(hex.slice(i, i + 2), 16);
2192
+ return [at(1), at(3), at(5)];
2193
+ }
2194
+ var quoteFamily = (f) => `"${f.replace(/["\\]/g, "\\$&")}"`;
2195
+ function report(ctx, entry2) {
2196
+ ctx.report.push({ ...entry2, path: ctx.path, ...ctx.mode !== void 0 ? { mode: ctx.mode } : {} });
2197
+ }
2198
+ function cssValue(ctx, type, value, property) {
2199
+ const member = property !== void 0 ? { property } : {};
2200
+ if (typeof value === "string") {
2201
+ const ref = REF.exec(value);
2202
+ if (ref) {
2203
+ const name = ctx.alive.has(ref[1]) ? ctx.names.get(ref[1]) : void 0;
2204
+ if (name !== void 0) return `var(${name})`;
2205
+ report(ctx, {
2206
+ code: "reference_target_omitted",
2207
+ severity: "warning",
2208
+ message: `References ${ref[1]}, which this output does not emit; the value was omitted.`,
2209
+ details: { target: ref[1], ...member }
2210
+ });
2211
+ return null;
2212
+ }
2213
+ }
2214
+ switch (type) {
2215
+ case "color": {
2216
+ if (typeof value === "string") return value;
2217
+ const c = asRecord(value);
2218
+ if (c && typeof c.hex === "string" && typeof c.alpha === "number") {
2219
+ if (c.alpha === 1) return c.hex;
2220
+ const [r, g, b] = hexChannels(c.hex);
2221
+ return `rgb(${r} ${g} ${b} / ${c.alpha})`;
2222
+ }
2223
+ break;
2224
+ }
2225
+ case "dimension":
2226
+ case "duration": {
2227
+ if (typeof value === "string") return value;
2228
+ const d = asRecord(value);
2229
+ if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
2230
+ break;
2231
+ }
2232
+ case "number":
2233
+ case "fontWeight":
2234
+ if (typeof value === "number") return String(value);
2235
+ break;
2236
+ case "cubicBezier":
2237
+ if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
2238
+ return `cubic-bezier(${value.join(", ")})`;
2239
+ }
2240
+ break;
2241
+ case "fontFamily":
2242
+ if (typeof value === "string") return quoteFamily(value);
2243
+ if (Array.isArray(value) && value.every((f) => typeof f === "string")) {
2244
+ return value.map(quoteFamily).join(", ");
2245
+ }
2246
+ break;
2247
+ default:
2248
+ break;
2249
+ }
2250
+ report(ctx, {
2251
+ code: "not_expressible",
2252
+ severity: "warning",
2253
+ message: `CSS has no form for this ${type || "untyped"} value; it was omitted.`,
2254
+ details: { type, value, ...member }
2255
+ });
2256
+ return null;
2257
+ }
2258
+ var TEXT_TRANSFORM = { original: "none", upper: "uppercase", lower: "lowercase", title: "capitalize" };
2259
+ var TEXT_DECORATION = { none: "none", underline: "underline", strikethrough: "line-through" };
2260
+ var TYPOGRAPHY_MEMBERS2 = [
2261
+ ["fontFamily", "fontFamily"],
2262
+ ["fontSize", "dimension"],
2263
+ ["fontWeight", "fontWeight"],
2264
+ ["lineHeight", "number"],
2265
+ ["letterSpacing", "dimension"]
2266
+ ];
2267
+ var TEXT_MEMBERS = [
2268
+ ["textTransform", "textCase", TEXT_TRANSFORM],
2269
+ ["textDecoration", "textDecoration", TEXT_DECORATION]
2270
+ ];
2271
+ function typographyMemberKeys(value, ext) {
2272
+ const keys = [];
2273
+ for (const [key] of TYPOGRAPHY_MEMBERS2) {
2274
+ if (key in value) keys.push(key);
2275
+ }
2276
+ for (const key of ["lineHeight", "letterSpacing"]) {
2277
+ if (key in value) continue;
2278
+ const d = asRecord(ext[key]);
2279
+ if (d && typeof d.value === "number" && typeof d.unit === "string") keys.push(key);
2280
+ }
2281
+ for (const [key, extKey] of TEXT_MEMBERS) {
2282
+ if (typeof ext[extKey] === "string") keys.push(key);
2283
+ }
2284
+ return keys;
2285
+ }
2286
+ function converted(ctx, property, from, to) {
2287
+ report(ctx, {
2288
+ code: "value_converted",
2289
+ severity: "info",
2290
+ message: `${property} was restated from ${String(from.value)}${String(from.unit)} as ${to}.`,
2291
+ details: { property, from, to }
2292
+ });
2293
+ }
2294
+ function extensionDimension(ctx, ext, key, name, percentTo) {
2295
+ const d = asRecord(ext[key]);
2296
+ if (!d || typeof d.value !== "number" || typeof d.unit !== "string") return null;
2297
+ if (d.unit === "%") {
2298
+ const to = percentTo(canonicalNumber(d.value / 100));
2299
+ converted(ctx, key, d, to);
2300
+ return `${name}: ${to};`;
2301
+ }
2302
+ return `${name}: ${d.value}${d.unit};`;
2303
+ }
2304
+ function typographyDecls(ctx, leaf, names) {
2305
+ const decls = [];
2306
+ const declaredPaths = [];
2307
+ const value = asRecord(leaf.value) ?? {};
2308
+ const ext = leaf.ext ?? {};
2309
+ const nameFor = (key) => names.get(`${leaf.path}.${key}`);
2310
+ for (const [key, type] of TYPOGRAPHY_MEMBERS2) {
2311
+ if (!(key in value)) continue;
2312
+ const name = nameFor(key);
2313
+ if (name === void 0) continue;
2314
+ const css = cssValue(ctx, type, value[key], key);
2315
+ if (css !== null) {
2316
+ decls.push(`${name}: ${css};`);
2317
+ declaredPaths.push(`${leaf.path}.${key}`);
2318
+ }
2319
+ }
2320
+ if (!("lineHeight" in value)) {
2321
+ const name = nameFor("lineHeight");
2322
+ if (name !== void 0) {
2323
+ const d = extensionDimension(ctx, ext, "lineHeight", name, (n) => String(n));
2324
+ if (d) {
2325
+ decls.push(d);
2326
+ declaredPaths.push(`${leaf.path}.lineHeight`);
2327
+ }
2328
+ }
2329
+ }
2330
+ if (!("letterSpacing" in value)) {
2331
+ const name = nameFor("letterSpacing");
2332
+ if (name !== void 0) {
2333
+ const d = extensionDimension(ctx, ext, "letterSpacing", name, (n) => `${n}em`);
2334
+ if (d) {
2335
+ decls.push(d);
2336
+ declaredPaths.push(`${leaf.path}.letterSpacing`);
2337
+ }
2338
+ }
2339
+ }
2340
+ for (const [key, extKey, table] of TEXT_MEMBERS) {
2341
+ const raw = ext[extKey];
2342
+ if (typeof raw !== "string") continue;
2343
+ const name = nameFor(key);
2344
+ if (name === void 0) continue;
2345
+ const css = table[raw];
2346
+ if (css !== void 0) {
2347
+ decls.push(`${name}: ${css};`);
2348
+ declaredPaths.push(`${leaf.path}.${key}`);
2349
+ } else {
2350
+ report(ctx, {
2351
+ code: "not_expressible",
2352
+ severity: "info",
2353
+ message: `CSS text properties have no form for ${extKey} "${raw}"; it was omitted.`,
2354
+ details: { property: extKey, value: raw }
2355
+ });
2356
+ }
2357
+ }
2358
+ return { decls, declaredPaths };
2359
+ }
2360
+ var SHADOW_MEMBERS = [
2361
+ ["offsetX", "dimension"],
2362
+ ["offsetY", "dimension"],
2363
+ ["blur", "dimension"],
2364
+ ["spread", "dimension"],
2365
+ ["color", "color"]
2366
+ ];
2367
+ function shadowDecl(ctx, leaf, name) {
2368
+ const layers = Array.isArray(leaf.value) ? leaf.value : [];
2369
+ const omit = (reason, message) => {
2370
+ report(ctx, { code: "not_expressible", severity: "warning", message, details: { reason } });
2371
+ return null;
2372
+ };
2373
+ if (layers.length === 0) return omit("no_visible_shadow", "The style has no visible shadow, so no box-shadow was written.");
2374
+ const parts = [];
2375
+ for (const layer of layers) {
2376
+ const l = asRecord(layer);
2377
+ if (!l) return omit("layer_not_an_object", "A shadow layer is not an object; the style was omitted.");
2378
+ const members = [];
2379
+ for (const [key, type] of SHADOW_MEMBERS) {
2380
+ if (!(key in l)) return omit(`missing_${key}`, `A shadow layer has no ${key}, which box-shadow needs; the style was omitted.`);
2381
+ const css = cssValue(ctx, type, l[key], key);
2382
+ if (css === null) return null;
2383
+ members.push(css);
2384
+ }
2385
+ parts.push(`${l.inset === true ? "inset " : ""}${members.join(" ")}`);
2386
+ }
2387
+ return `${name}: ${parts.join(", ")};`;
2388
+ }
2389
+ var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
2390
+ function headerText(header, nameCase) {
2391
+ return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
2392
+ Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
2393
+ }
2394
+ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2395
+ const entries = [];
2396
+ const declared = /* @__PURE__ */ new Set();
2397
+ const blocks = /* @__PURE__ */ new Map();
2398
+ for (const s of sources) {
2399
+ const perCollection = modes?.[s.collection];
2400
+ const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2401
+ const decls = [];
2402
+ for (const leaf of leavesByFile.get(s.file) ?? []) {
2403
+ const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2404
+ if (leaf.type === "typography") {
2405
+ const t = typographyDecls(ctx, leaf, names);
2406
+ decls.push(...t.decls);
2407
+ for (const p of t.declaredPaths) declared.add(p);
2408
+ } else {
2409
+ const name = names.get(leaf.path);
2410
+ if (name === void 0) continue;
2411
+ if (leaf.type === "shadow") {
2412
+ const d = shadowDecl(ctx, leaf, name);
2413
+ if (d !== null) {
2414
+ decls.push(d);
2415
+ declared.add(leaf.path);
2416
+ }
2417
+ } else {
2418
+ const v = cssValue(ctx, leaf.type, leaf.value);
2419
+ if (v !== null) {
2420
+ decls.push(`${name}: ${v};`);
2421
+ declared.add(leaf.path);
2422
+ }
2423
+ }
2424
+ }
2425
+ }
2426
+ if (decls.length === 0) continue;
2427
+ const lines = blocks.get(selector) ?? [];
2428
+ lines.push(` /* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`, ...decls.map((d) => ` ${d}`));
2429
+ blocks.set(selector, lines);
2430
+ }
2431
+ return { blocks, entries, declared };
2432
+ }
2433
+ function cssOutput(exp, header, options = {}) {
2434
+ const nameCase = options.case ?? CSS_DEFAULTS.case;
2435
+ const root = options.root ?? CSS_DEFAULTS.root;
2436
+ const template = options.modeSelector ?? CSS_DEFAULTS.modeSelector;
2437
+ const sources = sourcesOf(exp.resolver);
2438
+ const leavesByFile = /* @__PURE__ */ new Map();
2439
+ for (const s of sources) {
2440
+ if (!leavesByFile.has(s.file)) {
2441
+ const out = [];
2442
+ collectLeaves(exp.files[s.file] ?? {}, [], out);
2443
+ leavesByFile.set(s.file, out);
2444
+ }
2445
+ }
2446
+ const candidatePaths = /* @__PURE__ */ new Set();
2447
+ for (const leaves of leavesByFile.values()) {
2448
+ for (const leaf of leaves) {
2449
+ if (leaf.type === "typography") {
2450
+ const value = asRecord(leaf.value) ?? {};
2451
+ const ext = leaf.ext ?? {};
2452
+ for (const key of typographyMemberKeys(value, ext)) candidatePaths.add(`${leaf.path}.${key}`);
2453
+ } else {
2454
+ candidatePaths.add(leaf.path);
2455
+ }
2456
+ }
2457
+ }
2458
+ const resolved2 = resolveNames([...candidatePaths], exp.meta, {
2459
+ codeSyntaxKey: "WEB",
2460
+ acceptDeclared: acceptCssDeclared,
2461
+ affix: (body2) => `--${body2}`,
2462
+ nameCase
2463
+ });
2464
+ const names = resolved2.names;
2465
+ const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
2466
+ let alive = new Set(names.keys());
2467
+ let pass = emit(alive);
2468
+ for (; ; ) {
2469
+ if (pass.declared.size === alive.size) break;
2470
+ alive = pass.declared;
2471
+ pass = emit(alive);
2472
+ }
2473
+ const map = {};
2474
+ for (const [path, entry2] of Object.entries(resolved2.map)) {
2475
+ if (alive.has(path)) map[path] = entry2;
2476
+ }
2477
+ const entries = [...resolved2.report, ...pass.entries];
2478
+ const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
2479
+ if (shared.length > 1) {
2480
+ for (const collection of shared) {
2481
+ entries.push({
2482
+ code: "mode_selector_shared",
2483
+ severity: "warning",
2484
+ path: collection,
2485
+ message: `${shared.length} collections with modes share the selector template ${template}; one attribute cannot carry both axes. Declare a selector per collection under outputs[].modes.`,
2486
+ details: { modifiers: shared, selector: template }
2487
+ });
2488
+ }
2489
+ }
2490
+ const body = [...pass.blocks].flatMap(([selector, lines]) => [`${selector} {`, ...lines, "}", ""]);
2491
+ const text = `${[headerText(header, nameCase), "", ...body].join("\n").trimEnd()}
2492
+ `;
2493
+ return { text, map, report: sortReport(entries) };
2494
+ }
2495
+
2036
2496
  // ../extractor/src/v5/componentContext.ts
2037
2497
  var import_js_sha2563 = __toESM(require_sha256(), 1);
2038
2498
 
@@ -2101,6 +2561,9 @@ function parseLibraryBundle(input) {
2101
2561
  };
2102
2562
  }
2103
2563
 
2564
+ // ../extractor/src/libraryBundleHash.ts
2565
+ var import_js_sha2564 = __toESM(require_sha256(), 1);
2566
+
2104
2567
  // src/bundle.ts
2105
2568
  function parseBundle(raw) {
2106
2569
  try {
@@ -2121,8 +2584,8 @@ function parseBundle(raw) {
2121
2584
  }
2122
2585
 
2123
2586
  // src/config.ts
2124
- import { readFileSync as readFileSync2, writeFileSync as writeFileSync2, existsSync as existsSync2 } from "node:fs";
2125
- import { join as join2 } from "node:path";
2587
+ import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
2588
+ import { join as join3 } from "node:path";
2126
2589
 
2127
2590
  // src/credentials.ts
2128
2591
  import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
@@ -2155,6 +2618,268 @@ function writeCredentials(cwd, stored) {
2155
2618
  return { replaced };
2156
2619
  }
2157
2620
 
2621
+ // src/detect.ts
2622
+ import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
2623
+ import { join as join2 } from "node:path";
2624
+ var CODE_SYNTAX_KEY = {
2625
+ web: "WEB",
2626
+ ios: "iOS",
2627
+ android: "ANDROID",
2628
+ flutter: null
2629
+ };
2630
+ var PLATFORMS = ["web", "ios", "android", "flutter"];
2631
+ var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2632
+ var uniq = (xs) => [...new Set(xs)];
2633
+ function readPackageJson(cwd) {
2634
+ const path = join2(cwd, "package.json");
2635
+ if (!existsSync2(path)) return null;
2636
+ let parsed;
2637
+ try {
2638
+ parsed = JSON.parse(readFileSync2(path, "utf8"));
2639
+ } catch {
2640
+ return null;
2641
+ }
2642
+ if (typeof parsed !== "object" || parsed === null) return null;
2643
+ const record = parsed;
2644
+ const deps = {};
2645
+ for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2646
+ const block = record[field];
2647
+ if (typeof block !== "object" || block === null) continue;
2648
+ for (const [name, range] of Object.entries(block)) {
2649
+ if (typeof range === "string") deps[name] = range;
2650
+ }
2651
+ }
2652
+ return { deps };
2653
+ }
2654
+ function majorOf(range) {
2655
+ const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
2656
+ return m ? Number(m[1]) : null;
2657
+ }
2658
+ var DEP_SIGNALS = [
2659
+ { dep: "react", platform: "web", framework: "react" },
2660
+ { dep: "next", platform: "web", framework: "next" },
2661
+ { dep: "vue", platform: "web", framework: "vue" },
2662
+ { dep: "nuxt", platform: "web", framework: "nuxt" },
2663
+ { dep: "svelte", platform: "web", framework: "svelte" },
2664
+ { dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
2665
+ { dep: "@angular/core", platform: "web", framework: "angular" },
2666
+ { dep: "solid-js", platform: "web", framework: "solid" },
2667
+ { dep: "lit", platform: "web", framework: "lit" },
2668
+ { dep: "astro", platform: "web", framework: "astro" },
2669
+ { dep: "react-native", platform: "ios", framework: "react-native" },
2670
+ { dep: "expo", platform: "ios", framework: "expo" },
2671
+ { dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
2672
+ { dep: "styled-components", platform: "web", framework: "styled-components" },
2673
+ { dep: "@emotion/react", platform: "web", framework: "emotion" },
2674
+ { dep: "sass", platform: "web", framework: "sass" },
2675
+ { dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
2676
+ { dep: "@stitches/react", platform: "web", framework: "stitches" },
2677
+ { dep: "@pandacss/dev", platform: "web", framework: "panda" },
2678
+ { dep: "style-dictionary", tokenTool: "style-dictionary" },
2679
+ { dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
2680
+ { dep: "typescript", language: "typescript" }
2681
+ ];
2682
+ var FILE_SIGNALS = [
2683
+ { test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
2684
+ { test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
2685
+ { test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
2686
+ { test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
2687
+ { test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
2688
+ { test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
2689
+ { test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
2690
+ { test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
2691
+ { test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
2692
+ { test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
2693
+ { test: (n) => n === "go.mod", signal: "Go module", language: "go" },
2694
+ { test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
2695
+ { test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
2696
+ { test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
2697
+ { test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
2698
+ { test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
2699
+ { test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
2700
+ { test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
2701
+ { test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
2702
+ { test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
2703
+ { test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
2704
+ { test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
2705
+ { test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
2706
+ { test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
2707
+ ];
2708
+ function detectRepo(cwd) {
2709
+ const platforms = [];
2710
+ const languages = [];
2711
+ const frameworks = [];
2712
+ const tokenTools = [];
2713
+ const agents = [];
2714
+ const evidence = [];
2715
+ let styleDictionaryMajor = null;
2716
+ let names = [];
2717
+ try {
2718
+ names = readdirSync(cwd).sort();
2719
+ } catch {
2720
+ names = [];
2721
+ }
2722
+ for (const name of names) {
2723
+ for (const rule of FILE_SIGNALS) {
2724
+ if (!rule.test(name)) continue;
2725
+ evidence.push({ signal: rule.signal, file: name });
2726
+ if (rule.platform) platforms.push(rule.platform);
2727
+ if (rule.language) languages.push(rule.language);
2728
+ if (rule.framework) frameworks.push(rule.framework);
2729
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2730
+ if (rule.agent) agents.push(rule.agent);
2731
+ }
2732
+ }
2733
+ if (existsSync2(join2(cwd, ".github", "copilot-instructions.md")) || existsSync2(join2(cwd, ".github", "instructions"))) {
2734
+ evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
2735
+ agents.push("copilot");
2736
+ }
2737
+ const pkg = readPackageJson(cwd);
2738
+ if (pkg) {
2739
+ for (const rule of DEP_SIGNALS) {
2740
+ const range = pkg.deps[rule.dep];
2741
+ if (range === void 0) continue;
2742
+ evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
2743
+ if (rule.platform) platforms.push(rule.platform);
2744
+ if (rule.framework) frameworks.push(rule.framework);
2745
+ if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2746
+ if (rule.language) languages.push(rule.language);
2747
+ if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
2748
+ }
2749
+ if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
2750
+ }
2751
+ const order = (p) => PLATFORMS.indexOf(p);
2752
+ const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
2753
+ return {
2754
+ platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
2755
+ languages: uniq(languages).sort(),
2756
+ frameworks: uniq(frameworks).sort(),
2757
+ tokenTools: uniq(tokenTools).sort(),
2758
+ agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
2759
+ styleDictionaryMajor,
2760
+ evidence
2761
+ };
2762
+ }
2763
+ function isPlatform(value) {
2764
+ return PLATFORMS.includes(value);
2765
+ }
2766
+ function isAgentHost(value) {
2767
+ return AGENT_HOSTS.includes(value);
2768
+ }
2769
+
2770
+ // src/outputs.ts
2771
+ import {
2772
+ existsSync as existsSync3,
2773
+ mkdirSync,
2774
+ readFileSync as readFileSync3,
2775
+ renameSync,
2776
+ rmSync,
2777
+ writeFileSync as writeFileSync2
2778
+ } from "node:fs";
2779
+ import { dirname, isAbsolute, relative, resolve } from "node:path";
2780
+ var FORMATS = [
2781
+ { platform: "web", format: "css", defaultPath: "spec-layer/tokens.css", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2782
+ ];
2783
+ var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
2784
+ var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
2785
+ function defaultOutputs(platforms) {
2786
+ return FORMATS.filter((f) => f.defaultPath !== null && platforms.includes(f.platform)).map((f) => ({ platform: f.platform, format: f.format, path: f.defaultPath, case: f.defaultCase }));
2787
+ }
2788
+ function withDefaults(existing, platforms) {
2789
+ const covered = new Set(existing.map((o) => o.platform));
2790
+ return [...existing, ...defaultOutputs(platforms.filter((p) => !covered.has(p)))];
2791
+ }
2792
+ var outputId = (o) => `${o.platform}-${o.format}`;
2793
+ function parseOutput(value, index) {
2794
+ const at = `speclayer.json outputs[${index}]`;
2795
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${at} must be an object.`);
2796
+ const r = value;
2797
+ if (typeof r.platform !== "string" || typeof r.format !== "string") {
2798
+ throw new Error(`${at} needs "platform" and "format". Known: ${knownFormats()}.`);
2799
+ }
2800
+ const spec = specOf(r.platform, r.format);
2801
+ if (!spec) throw new Error(`${at}: unknown platform/format "${r.platform}/${r.format}". Known: ${knownFormats()}.`);
2802
+ const str = (key) => {
2803
+ if (r[key] !== void 0 && typeof r[key] !== "string") throw new Error(`${at} "${key}" must be a string.`);
2804
+ return r[key];
2805
+ };
2806
+ const path = str("path") ?? spec.defaultPath;
2807
+ if (path === null) throw new Error(`${at} needs "path": ${spec.platform} has no default location.`);
2808
+ const nameCase = str("case");
2809
+ if (nameCase !== void 0 && !NAME_CASES.includes(nameCase)) {
2810
+ throw new Error(`${at} "case" takes ${NAME_CASES.join(", ")}.`);
2811
+ }
2812
+ const root = str("root");
2813
+ const modeSelector = str("modeSelector");
2814
+ let modes;
2815
+ if (r.modes !== void 0) {
2816
+ const m = r.modes;
2817
+ if (typeof m !== "object" || m === null || Array.isArray(m) || !Object.values(m).every((v) => typeof v === "string")) {
2818
+ throw new Error(`${at} "modes" must map collection names to selector strings.`);
2819
+ }
2820
+ modes = m;
2821
+ }
2822
+ return {
2823
+ platform: spec.platform,
2824
+ format: spec.format,
2825
+ path,
2826
+ case: nameCase ?? spec.defaultCase,
2827
+ ...root !== void 0 ? { root } : {},
2828
+ ...modeSelector !== void 0 ? { modeSelector } : {},
2829
+ ...modes ? { modes } : {}
2830
+ };
2831
+ }
2832
+ function renderOutput(exp, o, header) {
2833
+ switch (o.format) {
2834
+ case "css":
2835
+ return cssOutput(exp, { ...header, platform: o.platform, format: o.format }, {
2836
+ case: o.case,
2837
+ ...o.root !== void 0 ? { root: o.root } : {},
2838
+ ...o.modeSelector !== void 0 ? { modeSelector: o.modeSelector } : {},
2839
+ ...o.modes ? { modes: o.modes } : {}
2840
+ });
2841
+ default: {
2842
+ const exhaustive = o.format;
2843
+ return exhaustive;
2844
+ }
2845
+ }
2846
+ }
2847
+ var inside = (parent, child) => {
2848
+ const rel = relative(parent, child);
2849
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
2850
+ };
2851
+ function outputPathProblem(cwd, outDir, o) {
2852
+ const root = resolve(cwd);
2853
+ const abs = resolve(cwd, o.path);
2854
+ if (!inside(root, abs) || abs === root) return `${o.path} is outside this directory. Choose a path inside the repository.`;
2855
+ if (inside(resolve(cwd, outDir), abs)) {
2856
+ return `${o.path} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
2857
+ }
2858
+ if (existsSync3(abs)) {
2859
+ const prefix = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
2860
+ let head;
2861
+ try {
2862
+ head = readFileSync3(abs, "utf8").slice(0, prefix.length);
2863
+ } catch {
2864
+ return `${o.path} exists and could not be read.`;
2865
+ }
2866
+ if (head !== prefix) return `${o.path} exists and was not written by spec-layer. Choose another path or remove the file.`;
2867
+ }
2868
+ return null;
2869
+ }
2870
+ function writeOutputFile(cwd, o, text) {
2871
+ const abs = resolve(cwd, o.path);
2872
+ mkdirSync(dirname(abs), { recursive: true });
2873
+ const partial = `${abs}.partial`;
2874
+ writeFileSync2(partial, text);
2875
+ try {
2876
+ renameSync(partial, abs);
2877
+ } catch (err) {
2878
+ rmSync(partial, { force: true });
2879
+ throw err;
2880
+ }
2881
+ }
2882
+
2158
2883
  // src/config.ts
2159
2884
  var DEFAULT_API = "https://api.spec-layer.com";
2160
2885
  var DEFAULT_OUT_DIR = ".speclayer";
@@ -2164,12 +2889,12 @@ function parseInclude(value) {
2164
2889
  if (typeof value !== "object" || value === null || Array.isArray(value)) throw invalidConfig();
2165
2890
  const record = value;
2166
2891
  if (record.foundation !== void 0 && typeof record.foundation !== "boolean") throw invalidConfig();
2167
- if (record.components !== void 0 && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
2892
+ if (record.components !== void 0 && record.components !== null && !(Array.isArray(record.components) && record.components.every((c) => typeof c === "string"))) {
2168
2893
  throw invalidConfig();
2169
2894
  }
2170
2895
  return {
2171
2896
  foundation: record.foundation === void 0 ? true : record.foundation,
2172
- components: record.components === void 0 ? null : record.components
2897
+ components: record.components ?? null
2173
2898
  };
2174
2899
  }
2175
2900
  function parseDtcg(value) {
@@ -2189,12 +2914,31 @@ function parseDtcg(value) {
2189
2914
  }
2190
2915
  return out;
2191
2916
  }
2917
+ function parsePlatforms(value) {
2918
+ if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
2919
+ throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
2920
+ }
2921
+ return [...new Set(value)];
2922
+ }
2923
+ function parseOutputs(value) {
2924
+ if (!Array.isArray(value)) throw new Error('speclayer.json "outputs" must be an array.');
2925
+ const outputs = value.map((v, i) => parseOutput(v, i));
2926
+ const seen = /* @__PURE__ */ new Set();
2927
+ for (const output of outputs) {
2928
+ const key = `${output.platform}/${output.format}`;
2929
+ if (seen.has(key)) {
2930
+ throw new Error(`speclayer.json "outputs" lists ${output.platform}/${output.format} more than once. Keep one entry per platform and format.`);
2931
+ }
2932
+ seen.add(key);
2933
+ }
2934
+ return outputs;
2935
+ }
2192
2936
  function readConfig(cwd) {
2193
- const path = join2(cwd, CONFIG_NAME);
2194
- if (!existsSync2(path)) return null;
2937
+ const path = join3(cwd, CONFIG_NAME);
2938
+ if (!existsSync4(path)) return null;
2195
2939
  let parsed;
2196
2940
  try {
2197
- parsed = JSON.parse(readFileSync2(path, "utf8"));
2941
+ parsed = JSON.parse(readFileSync4(path, "utf8"));
2198
2942
  } catch {
2199
2943
  throw invalidConfig();
2200
2944
  }
@@ -2204,7 +2948,9 @@ function readConfig(cwd) {
2204
2948
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
2205
2949
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
2206
2950
  ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2207
- ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {}
2951
+ ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
2952
+ ...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
2953
+ ...record.outputs !== void 0 ? { outputs: parseOutputs(record.outputs) } : {}
2208
2954
  };
2209
2955
  }
2210
2956
  function writeConfig(cwd, config) {
@@ -2212,15 +2958,17 @@ function writeConfig(cwd, config) {
2212
2958
  libraryId: config.libraryId,
2213
2959
  outDir: config.outDir,
2214
2960
  ...config.include ? { include: config.include } : {},
2215
- ...config.dtcg ? { dtcg: config.dtcg } : {}
2961
+ ...config.dtcg ? { dtcg: config.dtcg } : {},
2962
+ ...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
2963
+ ...config.outputs ? { outputs: config.outputs } : {}
2216
2964
  };
2217
- writeFileSync2(join2(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2965
+ writeFileSync3(join3(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2218
2966
  `);
2219
2967
  }
2220
2968
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
2221
2969
  const config = readConfig(cwd);
2222
2970
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
2223
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join2(cwd, outDir));
2971
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join3(cwd, outDir));
2224
2972
  const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2225
2973
  let storedKey = null;
2226
2974
  let storedKeyFor;
@@ -2239,6 +2987,8 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
2239
2987
  key: supplied ?? storedKey,
2240
2988
  ...config?.include ? { include: config.include } : {},
2241
2989
  ...config?.dtcg ? { dtcg: config.dtcg } : {},
2990
+ ...config?.platforms ? { platforms: config.platforms } : {},
2991
+ ...config?.outputs ? { outputs: config.outputs } : {},
2242
2992
  ...storedKeyFor ? { storedKeyFor } : {}
2243
2993
  };
2244
2994
  }
@@ -2277,8 +3027,8 @@ async function fetchBundle(opts) {
2277
3027
  }
2278
3028
 
2279
3029
  // src/files.ts
2280
- import { mkdirSync, writeFileSync as writeFileSync3, readFileSync as readFileSync3, readdirSync, rmSync, renameSync, existsSync as existsSync3 } from "node:fs";
2281
- import { join as join3, dirname, relative, resolve, isAbsolute } from "node:path";
3030
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync2, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3031
+ import { join as join4, dirname as dirname2, relative as relative2, resolve as resolve2, isAbsolute as isAbsolute2 } from "node:path";
2282
3032
 
2283
3033
  // src/selection.ts
2284
3034
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -2317,19 +3067,27 @@ function slugify(name) {
2317
3067
  return slug2 || "component";
2318
3068
  }
2319
3069
  function readManifest(outDir) {
2320
- const path = join3(outDir, "manifest.json");
2321
- if (!existsSync3(path)) return null;
3070
+ const path = join4(outDir, "manifest.json");
3071
+ if (!existsSync5(path)) return null;
2322
3072
  try {
2323
- return JSON.parse(readFileSync3(path, "utf8"));
3073
+ const parsed = JSON.parse(readFileSync5(path, "utf8"));
3074
+ parsed.artifacts = parsed.artifacts.map((artifact) => {
3075
+ const { aiPath, ...rest } = artifact;
3076
+ return {
3077
+ ...rest,
3078
+ path: rest.path ?? aiPath ?? null
3079
+ };
3080
+ });
3081
+ return parsed;
2324
3082
  } catch {
2325
3083
  return null;
2326
3084
  }
2327
3085
  }
2328
3086
  function readLocalBundle(outDir) {
2329
- const path = join3(outDir, "bundle.json");
2330
- if (!existsSync3(path)) return null;
3087
+ const path = join4(outDir, "bundle.json");
3088
+ if (!existsSync5(path)) return null;
2331
3089
  try {
2332
- return parseBundle(readFileSync3(path, "utf8"));
3090
+ return parseBundle(readFileSync5(path, "utf8"));
2333
3091
  } catch {
2334
3092
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
2335
3093
  }
@@ -2356,11 +3114,11 @@ function componentSlugs(bundle) {
2356
3114
  });
2357
3115
  }
2358
3116
  function assertReplaceable(outDir, cwd) {
2359
- const rel = relative(resolve(cwd), resolve(outDir));
2360
- if (rel === "" || rel.startsWith("..") || isAbsolute(rel)) {
3117
+ const rel = relative2(resolve2(cwd), resolve2(outDir));
3118
+ if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
2361
3119
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
2362
3120
  }
2363
- if (existsSync3(outDir) && !existsSync3(join3(outDir, "manifest.json")) && readdirSync(outDir).length > 0) {
3121
+ if (existsSync5(outDir) && !existsSync5(join4(outDir, "manifest.json")) && readdirSync2(outDir).length > 0) {
2364
3122
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
2365
3123
  }
2366
3124
  }
@@ -2369,44 +3127,62 @@ function writeBundleFiles(opts) {
2369
3127
  const selection = opts.selection ?? DEFAULT_SELECTION;
2370
3128
  const selected = selectComponents(opts.bundle, selection);
2371
3129
  const slugs = componentSlugs(opts.bundle);
3130
+ const outputs = opts.outputs ?? [];
3131
+ const willWriteFoundation = Boolean(opts.bundle.foundation) && selection.foundation;
3132
+ if (willWriteFoundation) {
3133
+ for (const o of outputs) {
3134
+ const problem = outputPathProblem(opts.cwd, opts.outDir, o);
3135
+ if (problem) throw new Error(problem);
3136
+ }
3137
+ }
2372
3138
  const staging = `${opts.outDir}.partial`;
2373
- rmSync(staging, { recursive: true, force: true });
3139
+ rmSync2(staging, { recursive: true, force: true });
2374
3140
  const written = [];
3141
+ const deliverables = [];
3142
+ const json = (v) => `${JSON.stringify(v, null, 2)}
3143
+ `;
2375
3144
  const put = (rel, content) => {
2376
- const path = join3(staging, rel);
2377
- mkdirSync(dirname(path), { recursive: true });
2378
- writeFileSync3(path, content);
3145
+ const path = join4(staging, rel);
3146
+ mkdirSync2(dirname2(path), { recursive: true });
3147
+ writeFileSync4(path, content);
2379
3148
  written.push(rel);
2380
3149
  };
2381
3150
  try {
2382
3151
  put("bundle.json", opts.raw);
2383
3152
  const artifacts = [];
2384
3153
  if (opts.bundle.foundation) {
2385
- let aiPath = null;
3154
+ let path = null;
2386
3155
  if (selection.foundation) {
2387
3156
  const artifact = opts.bundle.foundation.artifact;
2388
3157
  if (validateLevel1(artifact).some((d) => d.severity === "error")) {
2389
3158
  throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
2390
3159
  }
2391
- const files = dtcgExportFiles(foundationDtcg(artifact, opts.dtcg ?? {}));
2392
- for (const [name, text] of Object.entries(files)) put(`tokens/${name}`, text);
2393
- aiPath = "tokens/resolver.json";
3160
+ const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3161
+ for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3162
+ path = "tokens/resolver.json";
3163
+ const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
3164
+ for (const output of outputs) {
3165
+ const rendered = renderOutput(exp, output, header);
3166
+ put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
3167
+ put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
3168
+ deliverables.push({ output, text: rendered.text });
3169
+ }
2394
3170
  }
2395
3171
  artifacts.push({
2396
3172
  kind: "foundation",
2397
3173
  name: "foundation",
2398
3174
  contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash,
2399
- aiPath
3175
+ path
2400
3176
  });
2401
3177
  }
2402
3178
  opts.bundle.components.forEach((component, i) => {
2403
- const aiPath = selected[i] ? `ai/components/${slugs[i]}.yaml` : null;
2404
- if (aiPath) put(aiPath, component.ai);
3179
+ const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
3180
+ if (path) put(path, component.ai);
2405
3181
  artifacts.push({
2406
3182
  kind: "component",
2407
3183
  name: component.name,
2408
3184
  contentHash: component.artifact.spec_layer.export.content_hash,
2409
- aiPath
3185
+ path
2410
3186
  });
2411
3187
  });
2412
3188
  const manifest = {
@@ -2417,23 +3193,29 @@ function writeBundleFiles(opts) {
2417
3193
  extractorVersion: opts.bundle.extractorVersion,
2418
3194
  selection,
2419
3195
  artifacts,
2420
- ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {}
3196
+ ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
3197
+ ...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
3198
+ ...opts.outputs ? { outputs: opts.outputs } : {}
2421
3199
  };
2422
- put("manifest.json", `${JSON.stringify(manifest, null, 2)}
2423
- `);
3200
+ put("manifest.json", json(manifest));
2424
3201
  } catch (err) {
2425
- rmSync(staging, { recursive: true, force: true });
3202
+ rmSync2(staging, { recursive: true, force: true });
2426
3203
  throw err;
2427
3204
  }
2428
- rmSync(opts.outDir, { recursive: true, force: true });
2429
- renameSync(staging, opts.outDir);
2430
- return written;
3205
+ rmSync2(opts.outDir, { recursive: true, force: true });
3206
+ renameSync2(staging, opts.outDir);
3207
+ const outputPaths = [];
3208
+ for (const d of deliverables) {
3209
+ writeOutputFile(opts.cwd, d.output, d.text);
3210
+ outputPaths.push(d.output.path);
3211
+ }
3212
+ return { written, outputs: outputPaths };
2431
3213
  }
2432
3214
 
2433
3215
  // src/gitignore.ts
2434
- import { readFileSync as readFileSync4, writeFileSync as writeFileSync4, existsSync as existsSync4 } from "node:fs";
3216
+ import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
2435
3217
  import { spawnSync } from "node:child_process";
2436
- import { join as join4, dirname as dirname2, resolve as resolve2 } from "node:path";
3218
+ import { join as join5, dirname as dirname3, resolve as resolve3 } from "node:path";
2437
3219
  var COMMENT = "# Spec Layer pull key, not for committing";
2438
3220
  function git(cwd, args) {
2439
3221
  const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
@@ -2441,10 +3223,10 @@ function git(cwd, args) {
2441
3223
  return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
2442
3224
  }
2443
3225
  function insideWorkTreeWithoutGit(cwd) {
2444
- let dir = resolve2(cwd);
3226
+ let dir = resolve3(cwd);
2445
3227
  for (; ; ) {
2446
- if (existsSync4(join4(dir, ".git"))) return true;
2447
- const parent = dirname2(dir);
3228
+ if (existsSync6(join5(dir, ".git"))) return true;
3229
+ const parent = dirname3(dir);
2448
3230
  if (parent === dir) return false;
2449
3231
  dir = parent;
2450
3232
  }
@@ -2460,18 +3242,18 @@ function ensureIgnored(cwd, fileName) {
2460
3242
  if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
2461
3243
  const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
2462
3244
  if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
2463
- const path = join4(cwd, ".gitignore");
2464
- const existed = existsSync4(path);
3245
+ const path = join5(cwd, ".gitignore");
3246
+ const existed = existsSync6(path);
2465
3247
  try {
2466
3248
  if (!existed) {
2467
- writeFileSync4(path, `${COMMENT}
3249
+ writeFileSync5(path, `${COMMENT}
2468
3250
  ${fileName}
2469
3251
  `);
2470
3252
  } else {
2471
- const body = readFileSync4(path, "utf8");
3253
+ const body = readFileSync6(path, "utf8");
2472
3254
  if (!hasEntryLine(body, fileName)) {
2473
3255
  const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
2474
- writeFileSync4(path, `${body}${lead}${COMMENT}
3256
+ writeFileSync5(path, `${body}${lead}${COMMENT}
2475
3257
  ${fileName}
2476
3258
  `);
2477
3259
  }
@@ -2484,158 +3266,9 @@ ${fileName}
2484
3266
  return existed ? { kind: "added" } : { kind: "created" };
2485
3267
  }
2486
3268
 
2487
- // src/detect.ts
2488
- import { existsSync as existsSync5, readdirSync as readdirSync2, readFileSync as readFileSync5 } from "node:fs";
2489
- import { join as join5 } from "node:path";
2490
- var CODE_SYNTAX_KEY = {
2491
- web: "WEB",
2492
- ios: "iOS",
2493
- android: "ANDROID",
2494
- flutter: null
2495
- };
2496
- var PLATFORMS = ["web", "ios", "android", "flutter"];
2497
- var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2498
- var uniq = (xs) => [...new Set(xs)];
2499
- function readPackageJson(cwd) {
2500
- const path = join5(cwd, "package.json");
2501
- if (!existsSync5(path)) return null;
2502
- let parsed;
2503
- try {
2504
- parsed = JSON.parse(readFileSync5(path, "utf8"));
2505
- } catch {
2506
- return null;
2507
- }
2508
- if (typeof parsed !== "object" || parsed === null) return null;
2509
- const record = parsed;
2510
- const deps = {};
2511
- for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2512
- const block = record[field];
2513
- if (typeof block !== "object" || block === null) continue;
2514
- for (const [name, range] of Object.entries(block)) {
2515
- if (typeof range === "string") deps[name] = range;
2516
- }
2517
- }
2518
- return { deps };
2519
- }
2520
- function majorOf(range) {
2521
- const m = /^[\^~>=<\s]*v?(\d+)/.exec(range.trim());
2522
- return m ? Number(m[1]) : null;
2523
- }
2524
- var DEP_SIGNALS = [
2525
- { dep: "react", platform: "web", framework: "react" },
2526
- { dep: "next", platform: "web", framework: "next" },
2527
- { dep: "vue", platform: "web", framework: "vue" },
2528
- { dep: "nuxt", platform: "web", framework: "nuxt" },
2529
- { dep: "svelte", platform: "web", framework: "svelte" },
2530
- { dep: "@sveltejs/kit", platform: "web", framework: "sveltekit" },
2531
- { dep: "@angular/core", platform: "web", framework: "angular" },
2532
- { dep: "solid-js", platform: "web", framework: "solid" },
2533
- { dep: "lit", platform: "web", framework: "lit" },
2534
- { dep: "astro", platform: "web", framework: "astro" },
2535
- { dep: "react-native", platform: "ios", framework: "react-native" },
2536
- { dep: "expo", platform: "ios", framework: "expo" },
2537
- { dep: "tailwindcss", platform: "web", tokenTool: "tailwind" },
2538
- { dep: "styled-components", platform: "web", framework: "styled-components" },
2539
- { dep: "@emotion/react", platform: "web", framework: "emotion" },
2540
- { dep: "sass", platform: "web", framework: "sass" },
2541
- { dep: "@vanilla-extract/css", platform: "web", framework: "vanilla-extract" },
2542
- { dep: "@stitches/react", platform: "web", framework: "stitches" },
2543
- { dep: "@pandacss/dev", platform: "web", framework: "panda" },
2544
- { dep: "style-dictionary", tokenTool: "style-dictionary" },
2545
- { dep: "@tokens-studio/sd-transforms", tokenTool: "tokens-studio" },
2546
- { dep: "typescript", language: "typescript" }
2547
- ];
2548
- var FILE_SIGNALS = [
2549
- { test: (n) => n === "Package.swift", signal: "Swift package", platform: "ios", language: "swift" },
2550
- { test: (n) => n.endsWith(".xcodeproj") || n.endsWith(".xcworkspace"), signal: "Xcode project", platform: "ios", language: "swift" },
2551
- { test: (n) => n === "Podfile", signal: "CocoaPods", platform: "ios" },
2552
- { test: (n) => /^build\.gradle(\.kts)?$/.test(n) || /^settings\.gradle(\.kts)?$/.test(n), signal: "Gradle build", platform: "android", language: "kotlin" },
2553
- { test: (n) => n === "AndroidManifest.xml", signal: "Android manifest", platform: "android" },
2554
- { test: (n) => n === "pubspec.yaml", signal: "Flutter or Dart package", platform: "flutter", language: "dart" },
2555
- { test: (n) => n === "tsconfig.json", signal: "TypeScript config", language: "typescript" },
2556
- { test: (n) => n === "package.json", signal: "npm package", language: "javascript" },
2557
- { test: (n) => n === "deno.json" || n === "deno.jsonc", signal: "Deno config", language: "typescript" },
2558
- { test: (n) => n === "Cargo.toml", signal: "Cargo manifest", language: "rust" },
2559
- { test: (n) => n === "go.mod", signal: "Go module", language: "go" },
2560
- { test: (n) => n === "pyproject.toml" || n === "requirements.txt", signal: "Python project", language: "python" },
2561
- { test: (n) => n === "Gemfile", signal: "Ruby bundle", language: "ruby" },
2562
- { test: (n) => n === "composer.json", signal: "Composer package", language: "php" },
2563
- { test: (n) => n.endsWith(".csproj") || n.endsWith(".sln"), signal: ".NET project", language: "csharp" },
2564
- { test: (n) => n === "pom.xml", signal: "Maven build", language: "java" },
2565
- { test: (n) => /^tailwind\.config\.(js|cjs|mjs|ts)$/.test(n), signal: "Tailwind config", platform: "web", tokenTool: "tailwind" },
2566
- { test: (n) => /^(style-dictionary\.config|sd\.config)\.(js|cjs|mjs|ts|json)$/.test(n), signal: "Style Dictionary config", tokenTool: "style-dictionary" },
2567
- { test: (n) => n === "index.html" || n === "vite.config.ts" || n === "vite.config.js", signal: "web entry", platform: "web" },
2568
- { test: (n) => n === "CLAUDE.md" || n === ".claude", signal: "Claude Code", agent: "claude" },
2569
- { test: (n) => n === ".cursor" || n === ".cursorrules", signal: "Cursor", agent: "cursor" },
2570
- { test: (n) => n === ".windsurf" || n === ".windsurfrules", signal: "Windsurf", agent: "windsurf" },
2571
- { test: (n) => n === "GEMINI.md", signal: "Gemini CLI", agent: "gemini" },
2572
- { test: (n) => n === "AGENTS.md", signal: "AGENTS.md", agent: "agents-md" }
2573
- ];
2574
- function detectRepo(cwd) {
2575
- const platforms = [];
2576
- const languages = [];
2577
- const frameworks = [];
2578
- const tokenTools = [];
2579
- const agents = [];
2580
- const evidence = [];
2581
- let styleDictionaryMajor = null;
2582
- let names = [];
2583
- try {
2584
- names = readdirSync2(cwd).sort();
2585
- } catch {
2586
- names = [];
2587
- }
2588
- for (const name of names) {
2589
- for (const rule of FILE_SIGNALS) {
2590
- if (!rule.test(name)) continue;
2591
- evidence.push({ signal: rule.signal, file: name });
2592
- if (rule.platform) platforms.push(rule.platform);
2593
- if (rule.language) languages.push(rule.language);
2594
- if (rule.framework) frameworks.push(rule.framework);
2595
- if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2596
- if (rule.agent) agents.push(rule.agent);
2597
- }
2598
- }
2599
- if (existsSync5(join5(cwd, ".github", "copilot-instructions.md")) || existsSync5(join5(cwd, ".github", "instructions"))) {
2600
- evidence.push({ signal: "GitHub Copilot", file: ".github/copilot-instructions.md" });
2601
- agents.push("copilot");
2602
- }
2603
- const pkg = readPackageJson(cwd);
2604
- if (pkg) {
2605
- for (const rule of DEP_SIGNALS) {
2606
- const range = pkg.deps[rule.dep];
2607
- if (range === void 0) continue;
2608
- evidence.push({ signal: `${rule.dep} dependency`, file: "package.json" });
2609
- if (rule.platform) platforms.push(rule.platform);
2610
- if (rule.framework) frameworks.push(rule.framework);
2611
- if (rule.tokenTool) tokenTools.push(rule.tokenTool);
2612
- if (rule.language) languages.push(rule.language);
2613
- if (rule.dep === "style-dictionary") styleDictionaryMajor = majorOf(range);
2614
- }
2615
- if (pkg.deps["react-native"] !== void 0 || pkg.deps.expo !== void 0) platforms.push("android");
2616
- }
2617
- const order = (p) => PLATFORMS.indexOf(p);
2618
- const hostOrder = (a) => AGENT_HOSTS.indexOf(a);
2619
- return {
2620
- platforms: uniq(platforms).sort((a, b) => order(a) - order(b)),
2621
- languages: uniq(languages).sort(),
2622
- frameworks: uniq(frameworks).sort(),
2623
- tokenTools: uniq(tokenTools).sort(),
2624
- agents: uniq(agents).sort((a, b) => hostOrder(a) - hostOrder(b)),
2625
- styleDictionaryMajor,
2626
- evidence
2627
- };
2628
- }
2629
- function isPlatform(value) {
2630
- return PLATFORMS.includes(value);
2631
- }
2632
- function isAgentHost(value) {
2633
- return AGENT_HOSTS.includes(value);
2634
- }
2635
-
2636
3269
  // src/skill.ts
2637
- import { existsSync as existsSync6, mkdirSync as mkdirSync2, readdirSync as readdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync5 } from "node:fs";
2638
- import { dirname as dirname3, join as join6 } from "node:path";
3270
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync3, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3271
+ import { dirname as dirname4, join as join6 } from "node:path";
2639
3272
 
2640
3273
  // src/tools.ts
2641
3274
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
@@ -2643,18 +3276,24 @@ var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
2643
3276
  var TOOLS = [
2644
3277
  {
2645
3278
  name: "setup",
2646
- usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--only foundation|components] [--component NAME]...",
3279
+ usage: "spec-layer setup --id lib_... --key sl_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
2647
3280
  summary: "Records the library id, stores the pull key in a gitignored speclayer.local.json, then pulls.",
2648
3281
  when: "Once, with the command the plugin's Publish screen hands out. Re-run it after the key is rotated.",
2649
3282
  network: true,
2650
3283
  needsKey: true,
2651
- writes: ["speclayer.json", "speclayer.local.json", ".gitignore (one line, when inside a git repo)", "<outDir>/"],
3284
+ writes: [
3285
+ "speclayer.json",
3286
+ "speclayer.local.json",
3287
+ ".gitignore (one line, when inside a git repo)",
3288
+ "<outDir>/",
3289
+ "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"
3290
+ ],
2652
3291
  exits: OK_OR_ERROR
2653
3292
  },
2654
3293
  {
2655
3294
  name: "init",
2656
- usage: "spec-layer init --id lib_... [--out DIR] [--only foundation|components] [--component NAME]...",
2657
- summary: "Writes speclayer.json so later commands need no flags. Stores no key and reaches no server.",
3295
+ usage: "spec-layer init --id lib_... [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3296
+ summary: "Writes speclayer.json, with the platforms and default outputs, so later commands need no flags. Stores no key and reaches no server.",
2658
3297
  when: "A repo that supplies the key from SPEC_LAYER_KEY instead of a stored file.",
2659
3298
  network: false,
2660
3299
  needsKey: false,
@@ -2663,12 +3302,12 @@ var TOOLS = [
2663
3302
  },
2664
3303
  {
2665
3304
  name: "pull",
2666
- usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--only foundation|components] [--component NAME]...",
3305
+ usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
2667
3306
  summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
2668
- when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block.",
3307
+ when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block, or the outputs block.",
2669
3308
  network: true,
2670
3309
  needsKey: true,
2671
- writes: ["<outDir>/"],
3310
+ writes: ["<outDir>/", "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"],
2672
3311
  exits: OK_OR_ERROR
2673
3312
  },
2674
3313
  {
@@ -2713,7 +3352,7 @@ var TOOLS = [
2713
3352
  },
2714
3353
  {
2715
3354
  name: "skill",
2716
- usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter] [--json] [--out DIR]",
3355
+ usage: "spec-layer skill [--install] [--agent claude|cursor|copilot|windsurf|gemini|agents-md]... [--platform web|ios|android|flutter]... [--json] [--out DIR]",
2717
3356
  summary: "Prints a guide for a coding agent, adapted to this repository's stack and to the last pull; --install writes it where the agent reads instructions.",
2718
3357
  when: "Right after setup, and again after a pull that adds components or after the codebase changes stack.",
2719
3358
  network: false,
@@ -2768,9 +3407,9 @@ function countNumberTokens(tree) {
2768
3407
  return n;
2769
3408
  }
2770
3409
  function readJson(path) {
2771
- if (!existsSync6(path)) return null;
3410
+ if (!existsSync7(path)) return null;
2772
3411
  try {
2773
- return JSON.parse(readFileSync6(path, "utf8"));
3412
+ return JSON.parse(readFileSync7(path, "utf8"));
2774
3413
  } catch {
2775
3414
  return null;
2776
3415
  }
@@ -2778,13 +3417,13 @@ function readJson(path) {
2778
3417
  function summarizePull(cwd, outDir, manifest) {
2779
3418
  if (!manifest) return null;
2780
3419
  const absOut = join6(cwd, outDir);
2781
- const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.aiPath ? `${outDir}/${a.aiPath}` : null }));
3420
+ const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path ? `${outDir}/${a.path}` : null }));
2782
3421
  const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
2783
3422
  let foundation = null;
2784
3423
  if (foundationEntry) {
2785
3424
  const tokensDir = join6(absOut, "tokens");
2786
3425
  const resolver = readJson(join6(tokensDir, "resolver.json"));
2787
- const report = readJson(join6(tokensDir, "report.json"));
3426
+ const report2 = readJson(join6(tokensDir, "report.json"));
2788
3427
  let tokenFiles = [];
2789
3428
  try {
2790
3429
  tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
@@ -2797,13 +3436,13 @@ function summarizePull(cwd, outDir, manifest) {
2797
3436
  unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
2798
3437
  }
2799
3438
  const reportCounts = {};
2800
- if (Array.isArray(report)) {
2801
- for (const entry2 of report) {
3439
+ if (Array.isArray(report2)) {
3440
+ for (const entry2 of report2) {
2802
3441
  if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
2803
3442
  }
2804
3443
  }
2805
3444
  foundation = {
2806
- written: foundationEntry.aiPath !== null && resolver !== null,
3445
+ written: foundationEntry.path !== null && resolver !== null,
2807
3446
  sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
2808
3447
  modifiers: resolver ? Object.entries(resolver.modifiers ?? {}).map(([name, m]) => ({
2809
3448
  name,
@@ -2821,7 +3460,19 @@ function summarizePull(cwd, outDir, manifest) {
2821
3460
  publishedAt: manifest.publishedAt,
2822
3461
  pluginVersion: manifest.pluginVersion,
2823
3462
  components,
2824
- foundation
3463
+ foundation,
3464
+ outputs: (manifest.outputs ?? []).map((o) => ({
3465
+ platform: o.platform,
3466
+ format: o.format,
3467
+ path: o.path,
3468
+ case: o.case,
3469
+ modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
3470
+ modes: o.modes ?? {},
3471
+ // manifest.outputs records the configured list regardless of whether the
3472
+ // Foundation was written; the map file exists only when it was actually
3473
+ // rendered, so it is the on-disk proof a sentence can point to.
3474
+ written: existsSync7(join6(absOut, "outputs", `${o.platform}-${o.format}.map.json`))
3475
+ }))
2825
3476
  };
2826
3477
  }
2827
3478
  var code = (s) => `\`${s}\``;
@@ -2848,7 +3499,7 @@ function stackSection(input) {
2848
3499
  ""
2849
3500
  );
2850
3501
  } else {
2851
- const label = platformSource === "flag" ? "chosen with --platform" : "detected";
3502
+ const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
2852
3503
  lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
2853
3504
  }
2854
3505
  for (const platform of platforms) {
@@ -2856,10 +3507,41 @@ function stackSection(input) {
2856
3507
  const tokensDir = `${input.outDir}/tokens/`;
2857
3508
  if (platform === "web") {
2858
3509
  lines.push("### Web", "");
2859
- lines.push(
2860
- `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code("code_syntax.WEB")}, when the designer declared one in Figma. Use that identifier as the CSS custom property or theme key. When a token has no WEB entry, derive nothing: use the DTCG path as it appears in the token file (for example ${code("{Collection.group.name}")}) and say in your change that the code name is not declared in Figma.`
2861
- );
2862
- lines.push("");
3510
+ const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
3511
+ if (cssOut) {
3512
+ const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3513
+ lines.push(
3514
+ `The CSS custom property for every token is in ${code(mapPath)}: source "code_syntax" when the designer declared it in Figma, "derived" when the CLI built it from the DTCG path by the stated rule (${cssOut.case} case, collection root included). Use those names; never invent a third. ${code(`${tokensDir}spec-layer.meta.json`)} still holds the raw ${code("code_syntax.WEB")} the designer declared.`,
3515
+ ""
3516
+ );
3517
+ lines.push(
3518
+ `Import ${code(cssOut.path)} from the root stylesheet. It holds every set and every default mode at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)}. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). Wire it to prefers-color-scheme yourself if the OS should choose; the file never assumes that.`,
3519
+ ""
3520
+ );
3521
+ if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
3522
+ lines.push(
3523
+ `${code(cssOut.path)} is a projection of the same ${code("tokens/")} files, not a second source. Import one or the other.`,
3524
+ ""
3525
+ );
3526
+ }
3527
+ } else {
3528
+ lines.push(
3529
+ `Token identifiers for code live in ${code(`${tokensDir}spec-layer.meta.json`)} under each token's ${code(`code_syntax.${key}`)}, when the designer declared one in Figma. When a token has no WEB entry, use the DTCG path as it appears in the token file and say in your change that the code name is not declared in Figma.`,
3530
+ ""
3531
+ );
3532
+ const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
3533
+ if (configuredNotWritten) {
3534
+ lines.push(
3535
+ `A web/css output is configured at ${code(configuredNotWritten.path)} but was not written, because the last pull did not write the Foundation. Pull with the Foundation selected to write it.`,
3536
+ ""
3537
+ );
3538
+ } else if (pull?.foundation?.written) {
3539
+ lines.push(
3540
+ `No token file was written for web. Add \`"outputs"\` in \`speclayer.json\` (or run \`spec-layer pull --platform web\` once) and pull again; the default lands at ${code("spec-layer/tokens.css")}.`,
3541
+ ""
3542
+ );
3543
+ }
3544
+ }
2863
3545
  if (profile.tokenTools.includes("tailwind")) {
2864
3546
  lines.push(
2865
3547
  `Tailwind is present (${code("tailwindcss")}). Map DTCG ${code("color")} tokens to the theme's color scale and ${code("dimension")} tokens to spacing, radius, or font size by the collection and group they sit in. Keep the mapping in one place and reference token paths, not copied values, so a republish moves the code with it.`,
@@ -2937,7 +3619,10 @@ function pullSection(input) {
2937
3619
  } else {
2938
3620
  lines.push("- This library has no Foundation, so there is no tokens/ directory.");
2939
3621
  }
2940
- lines.push(`- ${code(`${outDir}/ai/components/`)}: one YAML per component.`);
3622
+ lines.push(`- ${code(`${outDir}/components/`)}: one YAML per component.`);
3623
+ for (const o of pull.outputs) {
3624
+ lines.push(o.written ? `- ${code(o.path)}: ${o.platform}/${o.format} token file, ${o.case} names, modes under ${code(o.modeSelector)}. Names and provenance: ${code(`${outDir}/outputs/${o.platform}-${o.format}.map.json`)}; what it could not express: ${code(`${outDir}/outputs/${o.platform}-${o.format}.report.json`)}.` : `- ${code(o.path)}: ${o.platform}/${o.format} token file, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
3625
+ }
2941
3626
  lines.push("");
2942
3627
  if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
2943
3628
  lines.push("### Token collections", "");
@@ -2993,11 +3678,13 @@ function buildSkillGuide(input) {
2993
3678
  );
2994
3679
  lines.push("## How to use it", "");
2995
3680
  lines.push(`1. Run ${code("npx spec-layer status")}. Exit 0 means the local copy is current; exit 2 means run ${code("npx spec-layer pull")} first.`);
2996
- lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/ai/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
3681
+ lines.push(`2. Building or changing a component: read its YAML under ${code(`${outDir}/components/`)}, or ${code("npx spec-layer show component NAME")}. ${code("api")} gives variants, states, booleans, and slots; ${code("anatomy")} names the parts; ${code("references.bindings")} says which token each part's property uses and under which ${code("when")} conditions; ${code("unbound")} lists values that are hardcoded in Figma.`);
2997
3682
  lines.push(`3. Working with colors, spacing, type, or effects: start at ${code(`${outDir}/tokens/resolver.json`)}, load the set and mode files it names, and look up ${code("code_syntax")} in ${code("spec-layer.meta.json")} for the name the designer declared for your platform.`);
2998
3683
  lines.push(`4. Reference tokens by name in code; never paste a resolved value where a token exists. A value the design system does not define is not a token: say so in your change rather than adding one.`);
2999
3684
  lines.push(`5. An ${code("unbound")} entry is design debt reported from Figma. Do not silently promote it to a token; keep the literal and note that Figma has no binding for it.`);
3000
- lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory. Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3685
+ const writtenOutputs = input.pull?.outputs.filter((o) => o.written) ?? [];
3686
+ const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(o.path)).join(", ")} either: pull replaces ${writtenOutputs.length === 1 ? "it" : "them"} in place.` : "";
3687
+ lines.push(`6. Never edit files under ${code(outDir + "/")}: the next pull replaces the whole directory.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3001
3688
  lines.push("");
3002
3689
  lines.push(...pullSection(input));
3003
3690
  lines.push(...stackSection(input));
@@ -3078,19 +3765,19 @@ ${BLOCK_END}
3078
3765
  function installSkill(cwd, host, guide) {
3079
3766
  const target = installTarget(host);
3080
3767
  const abs = join6(cwd, target.path);
3081
- const existing = existsSync6(abs) ? readFileSync6(abs, "utf8") : null;
3768
+ const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
3082
3769
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3083
3770
  if (existing === next) return { path: target.path, result: "unchanged" };
3084
- mkdirSync2(dirname3(abs), { recursive: true });
3085
- writeFileSync5(abs, next);
3771
+ mkdirSync3(dirname4(abs), { recursive: true });
3772
+ writeFileSync6(abs, next);
3086
3773
  return { path: target.path, result: existing === null ? "created" : "updated" };
3087
3774
  }
3088
3775
 
3089
3776
  // src/version.ts
3090
- import { readFileSync as readFileSync7 } from "node:fs";
3777
+ import { readFileSync as readFileSync8 } from "node:fs";
3091
3778
  function cliVersion() {
3092
3779
  try {
3093
- const parsed = JSON.parse(readFileSync7(new URL("../package.json", import.meta.url), "utf8"));
3780
+ const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
3094
3781
  return typeof parsed.version === "string" ? parsed.version : "unknown";
3095
3782
  } catch {
3096
3783
  return "unknown";
@@ -3108,8 +3795,8 @@ function manifestReader() {
3108
3795
  }
3109
3796
  function sameOutput(a, b) {
3110
3797
  const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
3111
- const dtcgKey = (d) => JSON.stringify(sortKeys(d ?? {}));
3112
- return selectionKey(a.selection) === selectionKey(b.selection) && dtcgKey(a.dtcg) === dtcgKey(b.dtcg);
3798
+ const key = (v) => JSON.stringify(sortKeys(v ?? {}));
3799
+ return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []);
3113
3800
  }
3114
3801
  function sortKeys(value) {
3115
3802
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -3118,6 +3805,35 @@ function sortKeys(value) {
3118
3805
  }
3119
3806
  return value;
3120
3807
  }
3808
+ function platformsFromFlags(flags, io2) {
3809
+ if (flags.platform === void 0 || flags.platform.length === 0) return void 0;
3810
+ const out = [];
3811
+ for (const value of flags.platform) {
3812
+ if (!isPlatform(value)) {
3813
+ io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${value}".`);
3814
+ return null;
3815
+ }
3816
+ if (!out.includes(value)) out.push(value);
3817
+ }
3818
+ return out;
3819
+ }
3820
+ function resolvePlatforms(cwd, fromFlags, config) {
3821
+ if (fromFlags) return { platforms: fromFlags, source: "flag" };
3822
+ if (config?.platforms && config.platforms.length > 0) return { platforms: config.platforms, source: "config" };
3823
+ const detected = detectRepo(cwd).platforms;
3824
+ return { platforms: detected, source: detected.length > 0 ? "detected" : "none" };
3825
+ }
3826
+ function outputsForRun(fromFlags, config, platforms) {
3827
+ if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
3828
+ return config?.outputs ?? defaultOutputs(platforms);
3829
+ }
3830
+ var NO_PLATFORM_NOTE = `No target platform detected, so no token file was written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
3831
+ function platformsMissingFormat(platforms) {
3832
+ return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
3833
+ }
3834
+ function missingFormatNote(platforms) {
3835
+ return `No token file exists yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3836
+ }
3121
3837
  var errorText = (err) => err instanceof Error ? err.message : String(err);
3122
3838
  function runInit(cwd, flags, io2) {
3123
3839
  if (!flags.id) {
@@ -3131,9 +3847,24 @@ function runInit(cwd, flags, io2) {
3131
3847
  io2.err(errorText(err));
3132
3848
  return 1;
3133
3849
  }
3850
+ const fromFlags = platformsFromFlags(flags, io2);
3851
+ if (fromFlags === null) return 1;
3852
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, null);
3853
+ const outputs = defaultOutputs(platforms);
3134
3854
  const outDir = flags.out ?? DEFAULT_OUT_DIR;
3135
- writeConfig(cwd, { libraryId: flags.id, outDir, ...include ? { include } : {} });
3136
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
3855
+ writeConfig(cwd, {
3856
+ libraryId: flags.id,
3857
+ outDir,
3858
+ ...include ? { include } : {},
3859
+ ...platforms.length > 0 ? { platforms } : {},
3860
+ ...outputs.length > 0 ? { outputs } : {}
3861
+ });
3862
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
3863
+ for (const o of outputs) io2.out(`Token file for ${o.platform}: ${o.path} (${o.format}, ${o.case} names), written by the next pull.`);
3864
+ if (source === "flag" || source === "detected") {
3865
+ const missing = platformsMissingFormat(platforms);
3866
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
3867
+ }
3137
3868
  io2.out(`The pull key is not stored here. Run spec-layer setup to store it in ${CREDENTIALS_NAME}, or set SPEC_LAYER_KEY.`);
3138
3869
  return 0;
3139
3870
  }
@@ -3193,16 +3924,22 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3193
3924
  } catch {
3194
3925
  existing = null;
3195
3926
  }
3927
+ const fromFlags = platformsFromFlags(flags, io2);
3928
+ if (fromFlags === null) return 1;
3196
3929
  const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
3197
3930
  const keptInclude = include ?? existing?.include ?? null;
3198
3931
  const keptDtcg = existing?.dtcg ?? null;
3932
+ const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
3933
+ const outputs = withDefaults(existing?.outputs ?? [], platforms);
3199
3934
  writeConfig(cwd, {
3200
3935
  libraryId: flags.id,
3201
3936
  outDir,
3202
3937
  ...keptInclude ? { include: keptInclude } : {},
3203
- ...keptDtcg ? { dtcg: keptDtcg } : {}
3938
+ ...keptDtcg ? { dtcg: keptDtcg } : {},
3939
+ ...platforms.length > 0 ? { platforms } : {},
3940
+ ...existing?.outputs !== void 0 || outputs.length > 0 ? { outputs } : {}
3204
3941
  });
3205
- io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}).`);
3942
+ io2.out(`Wrote speclayer.json (library ${flags.id}, output ${outDir}${platforms.length > 0 ? `, platforms ${platforms.join(", ")}` : ""}).`);
3206
3943
  const ignored = ensureIgnored(cwd, CREDENTIALS_NAME);
3207
3944
  switch (ignored.kind) {
3208
3945
  case "refused":
@@ -3260,11 +3997,17 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3260
3997
  io2.err(errorText(err));
3261
3998
  return 1;
3262
3999
  }
4000
+ const fromFlags = platformsFromFlags(flags, io2);
4001
+ if (fromFlags === null) return 1;
4002
+ const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
4003
+ const outputs = outputsForRun(fromFlags, opts, platforms);
3263
4004
  const manifest = manifestAt(join7(cwd, opts.outDir));
4005
+ const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4006
+ const willWriteFoundation = selection.foundation && foundationOnDisk;
3264
4007
  const etag = manifest && sameOutput(
3265
- { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg },
3266
- { selection, dtcg: opts.dtcg }
3267
- ) ? manifest.bundleHash : void 0;
4008
+ { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs },
4009
+ { selection, dtcg: opts.dtcg, outputs }
4010
+ ) && (!willWriteFoundation || outputs.every((o) => existsSync8(resolve4(cwd, o.path)) && existsSync8(join7(cwd, opts.outDir, "outputs", `${outputId(o)}.map.json`)))) ? manifest.bundleHash : void 0;
3268
4011
  const result = await fetchBundle({
3269
4012
  api: opts.api,
3270
4013
  libraryId: opts.libraryId,
@@ -3281,10 +4024,11 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3281
4024
  return 0;
3282
4025
  }
3283
4026
  let written;
4027
+ let outputPaths = [];
3284
4028
  try {
3285
4029
  const bundle = parseBundle(result.raw);
3286
4030
  const selected = selectComponents(bundle, selection);
3287
- written = writeBundleFiles({
4031
+ const writeResult = writeBundleFiles({
3288
4032
  outDir: join7(cwd, opts.outDir),
3289
4033
  cwd,
3290
4034
  raw: result.raw,
@@ -3293,8 +4037,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3293
4037
  libraryId: opts.libraryId,
3294
4038
  publishedAt: result.publishedAt,
3295
4039
  bundleHash: result.bundleHash,
3296
- dtcg: opts.dtcg
4040
+ dtcg: opts.dtcg,
4041
+ platforms,
4042
+ outputs
3297
4043
  });
4044
+ written = writeResult.written;
4045
+ outputPaths = writeResult.outputs;
3298
4046
  io2.out(
3299
4047
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
3300
4048
  );
@@ -3303,6 +4051,15 @@ async function runPull(cwd, flags, env, io2, fetcher) {
3303
4051
  return 1;
3304
4052
  }
3305
4053
  io2.out(`Wrote ${written.length} files under ${opts.outDir}/.`);
4054
+ for (const path of outputPaths) {
4055
+ const o = outputs.find((x) => x.path === path);
4056
+ if (o) io2.out(`Wrote ${path} (${o.platform}/${o.format}, ${o.case} names).`);
4057
+ }
4058
+ if (outputPaths.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4059
+ if (source === "flag" || source === "config") {
4060
+ const missing = platformsMissingFormat(platforms);
4061
+ if (missing.length > 0) io2.out(missingFormatNote(missing));
4062
+ }
3306
4063
  return 0;
3307
4064
  }
3308
4065
  async function runStatus(cwd, flags, env, io2, fetcher) {
@@ -3341,11 +4098,15 @@ function runList(cwd, flags, io2) {
3341
4098
  return 1;
3342
4099
  }
3343
4100
  io2.out(`Library ${manifest.libraryId}, published ${manifest.publishedAt}.`);
3344
- const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.aiPath ?? "not written", a.contentHash]);
4101
+ const rows = manifest.artifacts.map((a) => [a.kind, a.name, a.path ?? "not written", a.contentHash]);
3345
4102
  const widths = [0, 1, 2].map((i) => Math.max(...rows.map((r) => r[i].length)));
3346
4103
  for (const row of rows) {
3347
4104
  io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
3348
4105
  }
4106
+ for (const o of manifest.outputs ?? []) {
4107
+ const written = existsSync8(join7(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4108
+ io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
4109
+ }
3349
4110
  return 0;
3350
4111
  }
3351
4112
  var SHOW_USAGE = 'spec-layer show takes "foundation" or "component NAME".';
@@ -3410,19 +4171,9 @@ function collectSkillInput(cwd, flags, io2) {
3410
4171
  }
3411
4172
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
3412
4173
  const profile = detectRepo(cwd);
3413
- let platforms;
3414
- let platformSource;
3415
- if (flags.platform !== void 0) {
3416
- if (!isPlatform(flags.platform)) {
3417
- io2.err(`--platform takes ${PLATFORMS.join(", ")}, not "${flags.platform}".`);
3418
- return null;
3419
- }
3420
- platforms = [flags.platform];
3421
- platformSource = "flag";
3422
- } else {
3423
- platforms = profile.platforms;
3424
- platformSource = platforms.length > 0 ? "detected" : "none";
3425
- }
4174
+ const fromFlags = platformsFromFlags(flags, io2);
4175
+ if (fromFlags === null) return null;
4176
+ const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
3426
4177
  const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
3427
4178
  return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
3428
4179
  }
@@ -3484,17 +4235,18 @@ function runSkill(cwd, flags, io2) {
3484
4235
  var USAGE = `spec-layer <command>
3485
4236
 
3486
4237
  Commands:
3487
- setup --id lib_... --key sl_... [--out DIR] [selection]
4238
+ setup --id lib_... --key sl_... [--out DIR] [selection] [--platform P]...
3488
4239
  store the key, then pull
3489
- init --id lib_... [--out DIR] [selection] write speclayer.json
3490
- pull [--id lib_...] [--key sl_...] [selection]
4240
+ init --id lib_... [--out DIR] [selection] [--platform P]...
4241
+ write speclayer.json
4242
+ pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
3491
4243
  fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
3492
4244
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
3493
4245
  list list every artifact in the last pull
3494
4246
  show foundation | component NAME [--canonical]
3495
4247
  print one artifact (foundation: the DTCG document; component: its AI YAML; --canonical for JSON)
3496
4248
  tools [--json] list every command with what it reaches and writes
3497
- skill [--install] [--agent HOST]... [--platform P] [--json]
4249
+ skill [--install] [--agent HOST]... [--platform P]... [--json]
3498
4250
  print a guide for a coding agent, adapted to this repo and the last pull;
3499
4251
  --install writes it for claude, cursor, copilot, windsurf, gemini, or agents-md
3500
4252
 
@@ -3504,6 +4256,7 @@ Selection (setup, pull and init; flags replace the include block in speclayer.js
3504
4256
 
3505
4257
  Options:
3506
4258
  --api URL override the API origin (default https://api.spec-layer.com)
4259
+ --platform web|ios|android|flutter the target this repo builds for (repeatable); applies to setup, init, pull, and skill; setup and init store it, pull uses it for the run
3507
4260
  The pull key comes from --key, SPEC_LAYER_KEY, or speclayer.local.json written by setup.`;
3508
4261
  var io = {
3509
4262
  out: (l) => console.log(l),
@@ -3529,7 +4282,7 @@ async function main() {
3529
4282
  json: { type: "boolean" },
3530
4283
  install: { type: "boolean" },
3531
4284
  agent: { type: "string", multiple: true },
3532
- platform: { type: "string" }
4285
+ platform: { type: "string", multiple: true }
3533
4286
  }
3534
4287
  }));
3535
4288
  } catch {