spec-layer 0.6.0 → 0.8.2

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 +83 -42
  2. package/dist/cli.js +501 -151
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -560,7 +560,7 @@ import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
562
  import { existsSync as existsSync8 } from "node:fs";
563
- import { join as join7, resolve as resolve4 } from "node:path";
563
+ import { join as join8, resolve as resolve5 } from "node:path";
564
564
 
565
565
  // ../extractor/src/statesMatrix.ts
566
566
  var STATE_ORDER = [
@@ -816,6 +816,15 @@ var SUPPORTED_DURATION_UNITS = ["ms", "s"];
816
816
 
817
817
  // ../extractor/src/v5/canonical.ts
818
818
  var import_js_sha2562 = __toESM(require_sha256(), 1);
819
+ var SCHEMA_VERSION = "5.1.0";
820
+ function canonicalJson(value) {
821
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
822
+ if (value && typeof value === "object") {
823
+ const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => compareCodeUnits(a, b)).map(([k, v]) => `${JSON.stringify(k)}:${canonicalJson(v)}`);
824
+ return `{${entries.join(",")}}`;
825
+ }
826
+ return JSON.stringify(value);
827
+ }
819
828
 
820
829
  // ../extractor/src/v5/validate.ts
821
830
  var ROOT = "<artifact>";
@@ -1317,6 +1326,7 @@ function validateLevel1(artifact) {
1317
1326
  }
1318
1327
 
1319
1328
  // ../extractor/src/v5/dtcg.ts
1329
+ var import_js_sha2563 = __toESM(require_sha256(), 1);
1320
1330
  function dtcgSegments(name) {
1321
1331
  const segments = [];
1322
1332
  const notes = [];
@@ -1334,7 +1344,15 @@ function dtcgSegments(name) {
1334
1344
  }
1335
1345
  return { segments, notes };
1336
1346
  }
1337
- var slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "unnamed";
1347
+ function trimDashes(s) {
1348
+ let start = 0;
1349
+ let end = s.length;
1350
+ while (start < end && s[start] === "-") start += 1;
1351
+ while (end > start && s[end - 1] === "-") end -= 1;
1352
+ return s.slice(start, end);
1353
+ }
1354
+ var dtcgSlug = (s) => trimDashes(s.toLowerCase().replace(/[^a-z0-9]+/g, "-")) || "unnamed";
1355
+ var slug = dtcgSlug;
1338
1356
  var RESERVED_FILE_NAMES = [
1339
1357
  "styles.typography.json",
1340
1358
  "styles.effects.json",
@@ -1427,6 +1445,15 @@ function sortTree(value) {
1427
1445
  const keys = Object.keys(value).sort((a, b) => rank(a) - rank(b) || compareCodeUnits(a, b));
1428
1446
  return Object.fromEntries(keys.map((k) => [k, sortTree(value[k])]));
1429
1447
  }
1448
+ function recordFact(p, tokenId, mode, transform, resolved2) {
1449
+ let facts = p.factsById.get(tokenId);
1450
+ if (!facts) {
1451
+ facts = { transform: {}, resolved: {} };
1452
+ p.factsById.set(tokenId, facts);
1453
+ }
1454
+ facts.transform[mode] = transform;
1455
+ if (resolved2 !== void 0) facts.resolved[mode] = resolved2;
1456
+ }
1430
1457
  function reportOnce(p, entry2) {
1431
1458
  const key = JSON.stringify([entry2.code, entry2.path, entry2.mode ?? null, entry2.details]);
1432
1459
  if (p.reportKeys.has(key)) return;
@@ -1492,15 +1519,47 @@ function projectedLiteral(p, token, resolved2, onOverrideConflict) {
1492
1519
  const collection = p.collectionById.get(token.collection_id);
1493
1520
  const override = collection ? unitOverrideFor(p, token, collection) : void 0;
1494
1521
  let literal = resolved2;
1522
+ let overrode = false;
1495
1523
  if (override !== void 0 && literal.type === "number") {
1496
1524
  if (token.scopes.some((s) => STATED_NUMBER_SCOPES.includes(s))) onOverrideConflict?.(override);
1497
- else literal = { type: "dimension", number: literal.value, unit: override };
1525
+ else {
1526
+ literal = { type: "dimension", number: literal.value, unit: override };
1527
+ overrode = true;
1528
+ }
1529
+ }
1530
+ const converted2 = dtcgLiteral(literal, token.scopes, p.options.values);
1531
+ if ("omit" in converted2) return { converted: converted2, transform: null };
1532
+ return {
1533
+ converted: converted2,
1534
+ transform: overrode ? "number-unit-override" : literalTransform(literal, token.scopes)
1535
+ };
1536
+ }
1537
+ function literalTransform(value, scopes) {
1538
+ switch (value.type) {
1539
+ case "color":
1540
+ return "color";
1541
+ case "dimension":
1542
+ return "dimension";
1543
+ case "duration":
1544
+ return "duration";
1545
+ case "number":
1546
+ return scopes.includes("FONT_WEIGHT") ? "font-weight" : "number";
1547
+ case "cubic_bezier":
1548
+ return "cubic-bezier";
1549
+ case "font_family":
1550
+ return "font-family";
1551
+ case "string":
1552
+ case "boolean":
1553
+ return null;
1554
+ default: {
1555
+ const exhaustive = value;
1556
+ return exhaustive;
1557
+ }
1498
1558
  }
1499
- return dtcgLiteral(literal, token.scopes, p.options.values);
1500
1559
  }
1501
1560
  function aliasLeafType(p, token, chain, resolved2) {
1502
1561
  const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
1503
- return projectedLiteral(p, terminal ?? token, resolved2);
1562
+ return projectedLiteral(p, terminal ?? token, resolved2).converted;
1504
1563
  }
1505
1564
  function modeLabels(collection) {
1506
1565
  const counts = /* @__PURE__ */ new Map();
@@ -1539,6 +1598,17 @@ function reportCollectionNameCollisions(p) {
1539
1598
  function asJson(value) {
1540
1599
  return JSON.parse(JSON.stringify(value));
1541
1600
  }
1601
+ function transformField(p, token) {
1602
+ const facts = p.factsById.get(token.id);
1603
+ if (!facts) return {};
1604
+ const sorted = (source) => {
1605
+ const keys = Object.keys(source).sort(compareCodeUnits);
1606
+ return keys.length === 0 ? void 0 : Object.fromEntries(keys.map((k) => [k, source[k]]));
1607
+ };
1608
+ const transform = sorted(facts.transform);
1609
+ const resolved2 = sorted(facts.resolved);
1610
+ return { ...transform ? { transform } : {}, ...resolved2 ? { resolved: resolved2 } : {} };
1611
+ }
1542
1612
  function metaEntry(p, token, collection) {
1543
1613
  const labels = p.modeLabelsById.get(collection.id) ?? modeLabels(collection);
1544
1614
  const omitted = p.omittedIds.has(token.id);
@@ -1551,6 +1621,7 @@ function metaEntry(p, token, collection) {
1551
1621
  collection_id: token.collection_id,
1552
1622
  type: token.type,
1553
1623
  scopes: [...token.scopes],
1624
+ ...omitted ? {} : transformField(p, token),
1554
1625
  ...token.code_syntax ? { code_syntax: token.code_syntax } : {},
1555
1626
  ...token.publication ? { publication: token.publication } : {},
1556
1627
  ...omitted ? {
@@ -1849,6 +1920,64 @@ function annotateGroups(p, tree, collection) {
1849
1920
  }
1850
1921
  }
1851
1922
  }
1923
+ var newAccumulator = () => ({
1924
+ tokens: 0,
1925
+ types: /* @__PURE__ */ new Map(),
1926
+ present: 0,
1927
+ missing: 0,
1928
+ aliases: 0,
1929
+ literals: 0,
1930
+ scopes: /* @__PURE__ */ new Map(),
1931
+ codeSyntaxPresent: 0,
1932
+ codeSyntaxMissing: 0,
1933
+ published: 0,
1934
+ hiddenFromPublishing: 0,
1935
+ unstated: 0,
1936
+ omitted: 0,
1937
+ collided: 0
1938
+ });
1939
+ var bump = (counts, key) => {
1940
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1941
+ };
1942
+ var histogram = (counts) => Object.fromEntries([...counts.keys()].sort(compareCodeUnits).map((k) => [k, counts.get(k)]));
1943
+ function censusEntry(a) {
1944
+ return {
1945
+ tokens: a.tokens,
1946
+ types: histogram(a.types),
1947
+ descriptions: { present: a.present, missing: a.missing },
1948
+ aliases: a.aliases,
1949
+ literals: a.literals,
1950
+ scopes: histogram(a.scopes),
1951
+ code_syntax: { present: a.codeSyntaxPresent, missing: a.codeSyntaxMissing },
1952
+ publication: {
1953
+ published: a.published,
1954
+ hidden_from_publishing: a.hiddenFromPublishing,
1955
+ unstated: a.unstated
1956
+ },
1957
+ ...a.omitted > 0 ? { omitted: a.omitted } : {},
1958
+ ...a.collided > 0 ? { collided: a.collided } : {}
1959
+ };
1960
+ }
1961
+ function styleCensus(tree) {
1962
+ const a = newAccumulator();
1963
+ const walk = (node) => {
1964
+ if (typeof node !== "object" || node === null || Array.isArray(node)) return;
1965
+ const record = node;
1966
+ if ("$value" in record) {
1967
+ a.tokens += 1;
1968
+ bump(a.types, typeof record.$type === "string" ? record.$type : "unknown");
1969
+ if (typeof record.$description === "string" && record.$description.length > 0) a.present += 1;
1970
+ else a.missing += 1;
1971
+ return;
1972
+ }
1973
+ for (const [key, value] of Object.entries(record)) {
1974
+ if (key.startsWith("$")) continue;
1975
+ walk(value);
1976
+ }
1977
+ };
1978
+ walk(tree);
1979
+ return { tokens: a.tokens, types: histogram(a.types), descriptions: { present: a.present, missing: a.missing } };
1980
+ }
1852
1981
  function foundationDtcg(artifact, options = {}) {
1853
1982
  const p = {
1854
1983
  artifact,
@@ -1863,7 +1992,8 @@ function foundationDtcg(artifact, options = {}) {
1863
1992
  omittedIds: /* @__PURE__ */ new Set(),
1864
1993
  collidedIds: /* @__PURE__ */ new Set(),
1865
1994
  report: [],
1866
- reportKeys: /* @__PURE__ */ new Set()
1995
+ reportKeys: /* @__PURE__ */ new Set(),
1996
+ factsById: /* @__PURE__ */ new Map()
1867
1997
  };
1868
1998
  indexPaths(p);
1869
1999
  omitInexpressibleTypes(p);
@@ -1871,23 +2001,46 @@ function foundationDtcg(artifact, options = {}) {
1871
2001
  reportCollectionNameCollisions(p);
1872
2002
  const files = {};
1873
2003
  const plans = [];
2004
+ const census = {};
1874
2005
  const taken = new Set(RESERVED_FILE_NAMES);
1875
2006
  for (const collection of artifact.collections) {
1876
2007
  for (const mode of collection.modes) {
1877
2008
  const tree = {};
2009
+ const a = newAccumulator();
1878
2010
  for (const token of artifact.tokens) {
1879
- if (token.collection_id !== collection.id || p.omittedIds.has(token.id)) continue;
2011
+ if (token.collection_id !== collection.id) continue;
2012
+ if (p.omittedIds.has(token.id)) {
2013
+ a.omitted += 1;
2014
+ if (p.collidedIds.has(token.id)) a.collided += 1;
2015
+ continue;
2016
+ }
1880
2017
  const leaf = tokenLeaf(p, token, collection, mode.id);
1881
- if (leaf) setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
2018
+ if (!leaf) continue;
2019
+ setLeaf(tree, p.segmentsById.get(token.id) ?? [], leaf);
2020
+ a.tokens += 1;
2021
+ bump(a.types, typeof leaf.$type === "string" ? leaf.$type : "unknown");
2022
+ const modeLabel = modeLabelOf(p, collection, mode.id);
2023
+ if (p.factsById.get(token.id)?.transform[modeLabel] === "alias") a.aliases += 1;
2024
+ else a.literals += 1;
2025
+ if (token.description.length > 0) a.present += 1;
2026
+ else a.missing += 1;
2027
+ for (const scope of token.scopes) bump(a.scopes, scope);
2028
+ if (token.code_syntax) a.codeSyntaxPresent += 1;
2029
+ else a.codeSyntaxMissing += 1;
2030
+ if (token.publication?.published) a.published += 1;
2031
+ if (token.publication?.hidden_from_publishing) a.hiddenFromPublishing += 1;
2032
+ if (!token.publication) a.unstated += 1;
1882
2033
  }
1883
2034
  annotateGroups(p, tree, collection);
1884
2035
  const file = fileNameFor(collection, mode, taken);
1885
2036
  plans.push({ collection, modeId: mode.id, file });
1886
2037
  files[file] = sortTree(tree);
2038
+ census[file] = censusEntry(a);
1887
2039
  }
1888
2040
  }
1889
2041
  const styles = styleFiles(p);
1890
2042
  Object.assign(files, styles);
2043
+ for (const [file, tree] of Object.entries(styles)) census[file] = styleCensus(tree);
1891
2044
  const resolver = buildResolver(p, plans, Object.keys(styles).sort(compareCodeUnits));
1892
2045
  p.report.sort((a, b) => compareCodeUnits(a.path, b.path) || compareCodeUnits(a.code, b.code) || compareCodeUnits(a.mode ?? "", b.mode ?? ""));
1893
2046
  const meta = {};
@@ -1898,7 +2051,25 @@ function foundationDtcg(artifact, options = {}) {
1898
2051
  meta[p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path] = metaEntry(p, token, collection);
1899
2052
  }
1900
2053
  const sortedMeta = Object.fromEntries(Object.entries(meta).sort(([a], [b]) => compareCodeUnits(a, b)));
1901
- return { files, resolver, meta: sortedMeta, report: p.report };
2054
+ const codeSyntax = {};
2055
+ for (const [path, entry2] of Object.entries(sortedMeta)) {
2056
+ if (entry2.code_syntax) codeSyntax[path] = entry2.code_syntax;
2057
+ }
2058
+ const sourceFileName = artifact.spec_layer.source.file_name;
2059
+ const extension = {
2060
+ schema_version: SCHEMA_VERSION,
2061
+ content_hash: artifact.spec_layer.export.content_hash,
2062
+ config_hash: `sha256:${(0, import_js_sha2563.sha256)(canonicalJson(p.options))}`,
2063
+ source: {
2064
+ provider: "figma",
2065
+ ...typeof sourceFileName === "string" && sourceFileName.length > 0 ? { file_name: sourceFileName } : {}
2066
+ },
2067
+ completeness: artifact.completeness,
2068
+ code_syntax: codeSyntax,
2069
+ census: Object.fromEntries(Object.keys(census).sort(compareCodeUnits).map((k) => [k, census[k]])),
2070
+ report: p.report
2071
+ };
2072
+ return { files, resolver, meta: sortedMeta, report: p.report, extension };
1902
2073
  }
1903
2074
  function omitInexpressibleTypes(p) {
1904
2075
  for (const token of p.artifact.tokens) {
@@ -1999,9 +2170,10 @@ function tokenLeaf(p, token, collection, modeId) {
1999
2170
  });
2000
2171
  return null;
2001
2172
  }
2173
+ recordFact(p, token.id, mode, "alias", typed.$value);
2002
2174
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2003
2175
  }
2004
- const converted2 = projectedLiteral(p, token, value.value, (override) => {
2176
+ const projected = projectedLiteral(p, token, value.value, (override) => {
2005
2177
  reportOnce(p, {
2006
2178
  code: "unit_override_conflicts_with_scope",
2007
2179
  severity: "warning",
@@ -2010,6 +2182,7 @@ function tokenLeaf(p, token, collection, modeId) {
2010
2182
  details: { id: token.id, override, scopes: [...token.scopes] }
2011
2183
  });
2012
2184
  });
2185
+ const converted2 = projected.converted;
2013
2186
  if ("omit" in converted2) {
2014
2187
  reportOnce(p, {
2015
2188
  code: converted2.omit,
@@ -2021,6 +2194,7 @@ function tokenLeaf(p, token, collection, modeId) {
2021
2194
  });
2022
2195
  return null;
2023
2196
  }
2197
+ if (projected.transform !== null) recordFact(p, token.id, mode, projected.transform);
2024
2198
  return { $type: converted2.$type, $value: converted2.$value, ...description };
2025
2199
  }
2026
2200
  function dtcgExportFiles(out) {
@@ -2028,7 +2202,10 @@ function dtcgExportFiles(out) {
2028
2202
  `;
2029
2203
  const files = {};
2030
2204
  for (const name of Object.keys(out.files).sort(compareCodeUnits)) files[name] = text(out.files[name]);
2031
- files["resolver.json"] = text(out.resolver);
2205
+ files["resolver.json"] = text({
2206
+ ...out.resolver,
2207
+ $extensions: { "com.spec-layer": out.extension }
2208
+ });
2032
2209
  files["spec-layer.meta.json"] = text(out.meta);
2033
2210
  files["report.json"] = text(out.report);
2034
2211
  return files;
@@ -2121,6 +2298,7 @@ function resolveNames(paths, meta, rules) {
2121
2298
  }
2122
2299
 
2123
2300
  // ../extractor/src/v5/outputs/css.ts
2301
+ var CSS_INDEX_FILE = "index.css";
2124
2302
  var CSS_DEFAULTS = {
2125
2303
  case: "kebab",
2126
2304
  root: ":root",
@@ -2186,6 +2364,23 @@ function sourcesOf(resolver) {
2186
2364
  }
2187
2365
  var modeSlug = (file) => file.replace(/\.json$/, "").split(".").slice(1).join(".");
2188
2366
  var collectionSlug = (file) => file.split(".")[0];
2367
+ function cssFileNames(sources) {
2368
+ const taken = /* @__PURE__ */ new Set([CSS_INDEX_FILE]);
2369
+ const out = /* @__PURE__ */ new Map();
2370
+ for (const s of sources) {
2371
+ if (out.has(s.file)) continue;
2372
+ const base = s.mode === null ? dtcgSlug(s.collection) : `${dtcgSlug(s.collection)}.${modeSlug(s.file)}`;
2373
+ let candidate = `${base}.css`;
2374
+ let n = 1;
2375
+ while (taken.has(candidate)) {
2376
+ n += 1;
2377
+ candidate = `${base}-${n}.css`;
2378
+ }
2379
+ taken.add(candidate);
2380
+ out.set(s.file, candidate);
2381
+ }
2382
+ return out;
2383
+ }
2189
2384
  var REF = /^\{(.+)\}$/;
2190
2385
  function hexChannels(hex) {
2191
2386
  const at = (i) => parseInt(hex.slice(i, i + 2), 16);
@@ -2394,17 +2589,22 @@ function headerText(header, nameCase) {
2394
2589
  function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2395
2590
  const entries = [];
2396
2591
  const declared = /* @__PURE__ */ new Set();
2592
+ const firstFile = /* @__PURE__ */ new Map();
2397
2593
  const blocks = /* @__PURE__ */ new Map();
2398
2594
  for (const s of sources) {
2399
2595
  const perCollection = modes?.[s.collection];
2400
2596
  const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2401
2597
  const decls = [];
2598
+ const declaredHere = [];
2402
2599
  for (const leaf of leavesByFile.get(s.file) ?? []) {
2403
2600
  const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2404
2601
  if (leaf.type === "typography") {
2405
2602
  const t = typographyDecls(ctx, leaf, names);
2406
2603
  decls.push(...t.decls);
2407
- for (const p of t.declaredPaths) declared.add(p);
2604
+ for (const p of t.declaredPaths) {
2605
+ declared.add(p);
2606
+ declaredHere.push(p);
2607
+ }
2408
2608
  } else {
2409
2609
  const name = names.get(leaf.path);
2410
2610
  if (name === void 0) continue;
@@ -2413,22 +2613,26 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2413
2613
  if (d !== null) {
2414
2614
  decls.push(d);
2415
2615
  declared.add(leaf.path);
2616
+ declaredHere.push(leaf.path);
2416
2617
  }
2417
2618
  } else {
2418
2619
  const v = cssValue(ctx, leaf.type, leaf.value);
2419
2620
  if (v !== null) {
2420
2621
  decls.push(`${name}: ${v};`);
2421
2622
  declared.add(leaf.path);
2623
+ declaredHere.push(leaf.path);
2422
2624
  }
2423
2625
  }
2424
2626
  }
2425
2627
  }
2426
2628
  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);
2629
+ for (const p of declaredHere) if (!firstFile.has(p)) firstFile.set(p, s.file);
2630
+ const comment = `/* ${commentSafe(`${s.collection}${s.mode !== null ? `, ${s.mode}` : ""}`)} */`;
2631
+ const existing = blocks.get(s.file);
2632
+ if (existing) existing.decls.push(...decls);
2633
+ else blocks.set(s.file, { selector, comment, decls });
2430
2634
  }
2431
- return { blocks, entries, declared };
2635
+ return { blocks, entries, declared, firstFile };
2432
2636
  }
2433
2637
  function cssOutput(exp, header, options = {}) {
2434
2638
  const nameCase = options.case ?? CSS_DEFAULTS.case;
@@ -2458,7 +2662,7 @@ function cssOutput(exp, header, options = {}) {
2458
2662
  const resolved2 = resolveNames([...candidatePaths], exp.meta, {
2459
2663
  codeSyntaxKey: "WEB",
2460
2664
  acceptDeclared: acceptCssDeclared,
2461
- affix: (body2) => `--${body2}`,
2665
+ affix: (body) => `--${body}`,
2462
2666
  nameCase
2463
2667
  });
2464
2668
  const names = resolved2.names;
@@ -2470,9 +2674,11 @@ function cssOutput(exp, header, options = {}) {
2470
2674
  alive = pass.declared;
2471
2675
  pass = emit(alive);
2472
2676
  }
2677
+ const fileNames = cssFileNames(sources);
2473
2678
  const map = {};
2474
2679
  for (const [path, entry2] of Object.entries(resolved2.map)) {
2475
- if (alive.has(path)) map[path] = entry2;
2680
+ const from = pass.firstFile.get(path);
2681
+ if (alive.has(path) && from !== void 0) map[path] = { ...entry2, file: fileNames.get(from) };
2476
2682
  }
2477
2683
  const entries = [...resolved2.report, ...pass.entries];
2478
2684
  const shared = [...new Set(sources.filter((s) => !s.isDefault && options.modes?.[s.collection] === void 0).map((s) => s.collection))].sort(compareCodeUnits);
@@ -2487,14 +2693,29 @@ function cssOutput(exp, header, options = {}) {
2487
2693
  });
2488
2694
  }
2489
2695
  }
2490
- const body = [...pass.blocks].flatMap(([selector, lines]) => [`${selector} {`, ...lines, "}", ""]);
2491
- const text = `${[headerText(header, nameCase), "", ...body].join("\n").trimEnd()}
2696
+ const head = headerText(header, nameCase);
2697
+ const files = {};
2698
+ const imports = [];
2699
+ for (const [source, block] of pass.blocks) {
2700
+ const name = fileNames.get(source);
2701
+ files[name] = `${head}
2702
+
2703
+ ${block.selector} {
2704
+ ${block.comment}
2705
+ ${block.decls.map((d) => ` ${d}`).join("\n")}
2706
+ }
2492
2707
  `;
2493
- return { text, map, report: sortReport(entries) };
2708
+ imports.push(block.comment, `@import "./${name}";`);
2709
+ }
2710
+ if (imports.length > 0) files[CSS_INDEX_FILE] = `${head}
2711
+
2712
+ ${imports.join("\n")}
2713
+ `;
2714
+ return { files, map, report: sortReport(entries) };
2494
2715
  }
2495
2716
 
2496
2717
  // ../extractor/src/v5/componentContext.ts
2497
- var import_js_sha2563 = __toESM(require_sha256(), 1);
2718
+ var import_js_sha2564 = __toESM(require_sha256(), 1);
2498
2719
 
2499
2720
  // ../extractor/src/libraryBundle.ts
2500
2721
  var LIBRARY_BUNDLE_SCHEMA = "spec-layer-library-bundle";
@@ -2562,7 +2783,7 @@ function parseLibraryBundle(input) {
2562
2783
  }
2563
2784
 
2564
2785
  // ../extractor/src/libraryBundleHash.ts
2565
- var import_js_sha2564 = __toESM(require_sha256(), 1);
2786
+ var import_js_sha2565 = __toESM(require_sha256(), 1);
2566
2787
 
2567
2788
  // src/bundle.ts
2568
2789
  function parseBundle(raw) {
@@ -2585,7 +2806,7 @@ function parseBundle(raw) {
2585
2806
 
2586
2807
  // src/config.ts
2587
2808
  import { readFileSync as readFileSync4, writeFileSync as writeFileSync3, existsSync as existsSync4 } from "node:fs";
2588
- import { join as join3 } from "node:path";
2809
+ import { join as join4 } from "node:path";
2589
2810
 
2590
2811
  // src/credentials.ts
2591
2812
  import { readFileSync, writeFileSync, existsSync, chmodSync } from "node:fs";
@@ -2768,17 +2989,93 @@ function isAgentHost(value) {
2768
2989
  }
2769
2990
 
2770
2991
  // src/outputs.ts
2992
+ import { readFileSync as readFileSync3 } from "node:fs";
2993
+ import { resolve as resolve2 } from "node:path";
2994
+
2995
+ // src/visibleDir.ts
2771
2996
  import {
2997
+ closeSync,
2772
2998
  existsSync as existsSync3,
2999
+ lstatSync,
2773
3000
  mkdirSync,
2774
- readFileSync as readFileSync3,
3001
+ openSync,
3002
+ readSync,
3003
+ readdirSync as readdirSync2,
2775
3004
  renameSync,
2776
3005
  rmSync,
2777
3006
  writeFileSync as writeFileSync2
2778
3007
  } from "node:fs";
2779
- import { dirname, isAbsolute, relative, resolve } from "node:path";
3008
+ import { isAbsolute, join as join3, relative, resolve } from "node:path";
3009
+ var inside = (parent, child) => {
3010
+ const rel = relative(parent, child);
3011
+ return rel === "" || !rel.startsWith("..") && !isAbsolute(rel);
3012
+ };
3013
+ var isDotfile = (name) => name.startsWith(".");
3014
+ function carriesMarker(abs, marker) {
3015
+ const newlines = (marker.match(/\n/g) ?? []).length;
3016
+ const wantBytes = Buffer.byteLength(marker, "utf8") + newlines;
3017
+ const fd = openSync(abs, "r");
3018
+ try {
3019
+ const buf = Buffer.alloc(wantBytes);
3020
+ const read = readSync(fd, buf, 0, wantBytes, 0);
3021
+ const text = buf.subarray(0, read).toString("utf8").replace(/\r\n/g, "\n");
3022
+ return text.startsWith(marker);
3023
+ } finally {
3024
+ closeSync(fd);
3025
+ }
3026
+ }
3027
+ function visibleDirProblem(cwd, outDir, dir, marker, others, configKey) {
3028
+ const root = resolve(cwd);
3029
+ const abs = resolve(cwd, dir);
3030
+ if (!inside(root, abs) || abs === root) return `${dir} is outside this directory. Choose a path inside the repository.`;
3031
+ if (inside(resolve(cwd, outDir), abs)) return `${dir} is inside ${outDir}, which pull replaces wholesale. Choose a path outside it.`;
3032
+ for (const other of others) {
3033
+ const otherAbs = resolve(cwd, other);
3034
+ if (inside(abs, otherAbs) || inside(otherAbs, abs)) return `${dir} and ${other} overlap. Give each output its own directory.`;
3035
+ }
3036
+ if (!existsSync3(abs)) return null;
3037
+ if (!lstatSync(abs).isDirectory()) return `${dir} exists and is not a directory. Choose another path or remove the file.`;
3038
+ const foreign = `${dir} holds files spec-layer did not write. Set ${configKey} in speclayer.json to another path, or move them.`;
3039
+ for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
3040
+ if (isDotfile(entry2.name)) continue;
3041
+ if (!entry2.isFile()) return foreign;
3042
+ try {
3043
+ if (!carriesMarker(join3(abs, entry2.name), marker)) return foreign;
3044
+ } catch {
3045
+ return `${dir}/${entry2.name} could not be read.`;
3046
+ }
3047
+ }
3048
+ return null;
3049
+ }
3050
+ function writeAtomically(abs, text) {
3051
+ const partial = `${abs}.partial`;
3052
+ writeFileSync2(partial, text);
3053
+ try {
3054
+ renameSync(partial, abs);
3055
+ } catch (err) {
3056
+ rmSync(partial, { force: true });
3057
+ throw err;
3058
+ }
3059
+ }
3060
+ function writeVisibleDir(cwd, dir, marker, files, last) {
3061
+ const abs = resolve(cwd, dir);
3062
+ const names = Object.keys(files).filter((n) => n !== last);
3063
+ if (last !== void 0 && last in files) names.push(last);
3064
+ if (names.length === 0 && !existsSync3(abs)) return [];
3065
+ mkdirSync(abs, { recursive: true });
3066
+ for (const name of names) writeAtomically(join3(abs, name), files[name]);
3067
+ const keep = new Set(names);
3068
+ for (const entry2 of readdirSync2(abs, { withFileTypes: true })) {
3069
+ if (isDotfile(entry2.name) || keep.has(entry2.name) || !entry2.isFile()) continue;
3070
+ const path = join3(abs, entry2.name);
3071
+ if (carriesMarker(path, marker)) rmSync(path);
3072
+ }
3073
+ return names;
3074
+ }
3075
+
3076
+ // src/outputs.ts
2780
3077
  var FORMATS = [
2781
- { platform: "web", format: "css", defaultPath: "spec-layer/tokens.css", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
3078
+ { platform: "web", format: "css", defaultPath: "tokens", defaultCase: "kebab", headerPrefix: CSS_HEADER_PREFIX }
2782
3079
  ];
2783
3080
  var knownFormats = () => FORMATS.map((f) => `${f.platform}/${f.format}`).join(", ");
2784
3081
  var specOf = (platform, format) => FORMATS.find((f) => f.platform === platform && f.format === format) ?? null;
@@ -2844,45 +3141,27 @@ function renderOutput(exp, o, header) {
2844
3141
  }
2845
3142
  }
2846
3143
  }
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);
3144
+ function readIndexImports(cwd, o) {
3145
+ const path = resolve2(cwd, o.path, CSS_INDEX_FILE);
3146
+ let text;
2875
3147
  try {
2876
- renameSync(partial, abs);
2877
- } catch (err) {
2878
- rmSync(partial, { force: true });
2879
- throw err;
3148
+ text = readFileSync3(path, "utf8");
3149
+ } catch {
3150
+ return null;
2880
3151
  }
3152
+ return [...text.matchAll(/^@import "\.\/([^"\n]+)";$/gm)].map((m) => m[1]);
3153
+ }
3154
+ var LEGACY_CSS_PATH_NOTE = (path) => `${path} names a file; spec-layer 0.7.0 writes a directory. Set outputs[].path to a directory, for example "tokens", delete the old file, and pull again.`;
3155
+ function outputPathProblem(cwd, outDir, o, others = []) {
3156
+ if (/\.css$/i.test(o.path)) return LEGACY_CSS_PATH_NOTE(o.path);
3157
+ const marker = specOf(o.platform, o.format)?.headerPrefix ?? CSS_HEADER_PREFIX;
3158
+ return visibleDirProblem(cwd, outDir, o.path, marker, others, "outputs[].path");
2881
3159
  }
2882
3160
 
2883
3161
  // src/config.ts
2884
3162
  var DEFAULT_API = "https://api.spec-layer.com";
2885
3163
  var DEFAULT_OUT_DIR = ".speclayer";
3164
+ var DEFAULT_COMPONENT_SPECS_DIR = "component-specs";
2886
3165
  var CONFIG_NAME = "speclayer.json";
2887
3166
  var invalidConfig = () => new Error(`${CONFIG_NAME} is not valid JSON. Fix or delete it, then retry.`);
2888
3167
  function parseInclude(value) {
@@ -2914,6 +3193,14 @@ function parseDtcg(value) {
2914
3193
  }
2915
3194
  return out;
2916
3195
  }
3196
+ function parseComponentSpecsDir(value) {
3197
+ if (typeof value !== "string" || value.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
3198
+ let dir = value.replace(/\\/g, "/");
3199
+ if (dir.startsWith("./")) dir = dir.slice(2);
3200
+ dir = dir.replace(/\/+$/, "");
3201
+ if (dir.length === 0) throw new Error('speclayer.json "componentSpecsDir" must be a non-empty string.');
3202
+ return dir;
3203
+ }
2917
3204
  function parsePlatforms(value) {
2918
3205
  if (!Array.isArray(value) || !value.every((p) => typeof p === "string" && isPlatform(p))) {
2919
3206
  throw new Error(`speclayer.json "platforms" must be an array of ${PLATFORMS.join(", ")}.`);
@@ -2934,7 +3221,7 @@ function parseOutputs(value) {
2934
3221
  return outputs;
2935
3222
  }
2936
3223
  function readConfig(cwd) {
2937
- const path = join3(cwd, CONFIG_NAME);
3224
+ const path = join4(cwd, CONFIG_NAME);
2938
3225
  if (!existsSync4(path)) return null;
2939
3226
  let parsed;
2940
3227
  try {
@@ -2947,6 +3234,7 @@ function readConfig(cwd) {
2947
3234
  return {
2948
3235
  ...typeof record.libraryId === "string" ? { libraryId: record.libraryId } : {},
2949
3236
  ...typeof record.outDir === "string" ? { outDir: record.outDir } : {},
3237
+ ...record.componentSpecsDir !== void 0 ? { componentSpecsDir: parseComponentSpecsDir(record.componentSpecsDir) } : {},
2950
3238
  ...record.include !== void 0 ? { include: parseInclude(record.include) } : {},
2951
3239
  ...record.dtcg !== void 0 ? { dtcg: parseDtcg(record.dtcg) } : {},
2952
3240
  ...record.platforms !== void 0 ? { platforms: parsePlatforms(record.platforms) } : {},
@@ -2957,18 +3245,19 @@ function writeConfig(cwd, config) {
2957
3245
  const body = {
2958
3246
  libraryId: config.libraryId,
2959
3247
  outDir: config.outDir,
3248
+ ...config.componentSpecsDir ? { componentSpecsDir: config.componentSpecsDir } : {},
2960
3249
  ...config.include ? { include: config.include } : {},
2961
3250
  ...config.dtcg ? { dtcg: config.dtcg } : {},
2962
3251
  ...config.platforms && config.platforms.length > 0 ? { platforms: config.platforms } : {},
2963
3252
  ...config.outputs ? { outputs: config.outputs } : {}
2964
3253
  };
2965
- writeFileSync3(join3(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
3254
+ writeFileSync3(join4(cwd, CONFIG_NAME), `${JSON.stringify(body, null, 2)}
2966
3255
  `);
2967
3256
  }
2968
3257
  function resolveOptions(cwd, flags, env, manifestLibraryId) {
2969
3258
  const config = readConfig(cwd);
2970
3259
  const outDir = flags.out ?? config?.outDir ?? DEFAULT_OUT_DIR;
2971
- const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join3(cwd, outDir));
3260
+ const libraryId = flags.id ?? config?.libraryId ?? manifestLibraryId(join4(cwd, outDir));
2972
3261
  const supplied = flags.key || env.SPEC_LAYER_KEY || null;
2973
3262
  let storedKey = null;
2974
3263
  let storedKeyFor;
@@ -2982,6 +3271,7 @@ function resolveOptions(cwd, flags, env, manifestLibraryId) {
2982
3271
  return {
2983
3272
  libraryId,
2984
3273
  outDir,
3274
+ componentSpecsDir: config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
2985
3275
  // A trailing slash would build "//v1/..." paths the proxy router 404s on.
2986
3276
  api: (flags.api ?? env.SPEC_LAYER_API ?? DEFAULT_API).replace(/\/+$/, ""),
2987
3277
  key: supplied ?? storedKey,
@@ -3027,8 +3317,8 @@ async function fetchBundle(opts) {
3027
3317
  }
3028
3318
 
3029
3319
  // src/files.ts
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";
3320
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync5, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3321
+ import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
3032
3322
 
3033
3323
  // src/selection.ts
3034
3324
  var DEFAULT_SELECTION = { foundation: true, components: null };
@@ -3066,8 +3356,9 @@ function slugify(name) {
3066
3356
  const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
3067
3357
  return slug2 || "component";
3068
3358
  }
3359
+ var COMPONENT_SPEC_MARKER = "spec_layer:\n kind: component";
3069
3360
  function readManifest(outDir) {
3070
- const path = join4(outDir, "manifest.json");
3361
+ const path = join5(outDir, "manifest.json");
3071
3362
  if (!existsSync5(path)) return null;
3072
3363
  try {
3073
3364
  const parsed = JSON.parse(readFileSync5(path, "utf8"));
@@ -3084,7 +3375,7 @@ function readManifest(outDir) {
3084
3375
  }
3085
3376
  }
3086
3377
  function readLocalBundle(outDir) {
3087
- const path = join4(outDir, "bundle.json");
3378
+ const path = join5(outDir, "bundle.json");
3088
3379
  if (!existsSync5(path)) return null;
3089
3380
  try {
3090
3381
  return parseBundle(readFileSync5(path, "utf8"));
@@ -3114,11 +3405,11 @@ function componentSlugs(bundle) {
3114
3405
  });
3115
3406
  }
3116
3407
  function assertReplaceable(outDir, cwd) {
3117
- const rel = relative2(resolve2(cwd), resolve2(outDir));
3408
+ const rel = relative2(resolve3(cwd), resolve3(outDir));
3118
3409
  if (rel === "" || rel.startsWith("..") || isAbsolute2(rel)) {
3119
3410
  throw new Error('The output directory must sit inside the current directory, not be "." or a parent of it.');
3120
3411
  }
3121
- if (existsSync5(outDir) && !existsSync5(join4(outDir, "manifest.json")) && readdirSync2(outDir).length > 0) {
3412
+ if (existsSync5(outDir) && !existsSync5(join5(outDir, "manifest.json")) && readdirSync3(outDir).length > 0) {
3122
3413
  throw new Error(`${outDir} exists and was not written by spec-layer pull. Choose an empty or new directory.`);
3123
3414
  }
3124
3415
  }
@@ -3128,13 +3419,23 @@ function writeBundleFiles(opts) {
3128
3419
  const selected = selectComponents(opts.bundle, selection);
3129
3420
  const slugs = componentSlugs(opts.bundle);
3130
3421
  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);
3422
+ const componentSpecsDir = opts.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3423
+ const outDirRel = relative2(resolve3(opts.cwd), resolve3(opts.outDir)).split(sep).join("/");
3424
+ const outputPaths = outputs.map((o) => o.path);
3425
+ const specsProblem = visibleDirProblem(opts.cwd, outDirRel, componentSpecsDir, COMPONENT_SPEC_MARKER, outputPaths, "componentSpecsDir");
3426
+ if (specsProblem) throw new Error(specsProblem);
3427
+ for (const o of outputs) {
3428
+ const problem = outputPathProblem(opts.cwd, outDirRel, o, [componentSpecsDir, ...outputPaths.filter((p) => p !== o.path)]);
3429
+ if (problem) throw new Error(problem);
3430
+ }
3431
+ const briefs = {};
3432
+ opts.bundle.components.forEach((component, i) => {
3433
+ if (!selected[i]) return;
3434
+ if (!component.ai.startsWith(COMPONENT_SPEC_MARKER)) {
3435
+ throw new Error(`The published brief for ${component.name} does not begin with the Spec Layer marker. Republish from the plugin, then pull again.`);
3136
3436
  }
3137
- }
3437
+ briefs[`${slugs[i]}.yaml`] = component.ai;
3438
+ });
3138
3439
  const staging = `${opts.outDir}.partial`;
3139
3440
  rmSync2(staging, { recursive: true, force: true });
3140
3441
  const written = [];
@@ -3142,8 +3443,8 @@ function writeBundleFiles(opts) {
3142
3443
  const json = (v) => `${JSON.stringify(v, null, 2)}
3143
3444
  `;
3144
3445
  const put = (rel, content) => {
3145
- const path = join4(staging, rel);
3146
- mkdirSync2(dirname2(path), { recursive: true });
3446
+ const path = join5(staging, rel);
3447
+ mkdirSync2(dirname(path), { recursive: true });
3147
3448
  writeFileSync4(path, content);
3148
3449
  written.push(rel);
3149
3450
  };
@@ -3159,13 +3460,13 @@ function writeBundleFiles(opts) {
3159
3460
  }
3160
3461
  const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3161
3462
  for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3162
- path = "tokens/resolver.json";
3463
+ path = `${outDirRel}/tokens/resolver.json`;
3163
3464
  const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
3164
3465
  for (const output of outputs) {
3165
3466
  const rendered = renderOutput(exp, output, header);
3166
3467
  put(`outputs/${outputId(output)}.map.json`, json(rendered.map));
3167
3468
  put(`outputs/${outputId(output)}.report.json`, json(rendered.report));
3168
- deliverables.push({ output, text: rendered.text });
3469
+ deliverables.push({ output, files: rendered.files });
3169
3470
  }
3170
3471
  }
3171
3472
  artifacts.push({
@@ -3176,13 +3477,11 @@ function writeBundleFiles(opts) {
3176
3477
  });
3177
3478
  }
3178
3479
  opts.bundle.components.forEach((component, i) => {
3179
- const path = selected[i] ? `components/${slugs[i]}.yaml` : null;
3180
- if (path) put(path, component.ai);
3181
3480
  artifacts.push({
3182
3481
  kind: "component",
3183
3482
  name: component.name,
3184
3483
  contentHash: component.artifact.spec_layer.export.content_hash,
3185
- path
3484
+ path: selected[i] ? `${componentSpecsDir}/${slugs[i]}.yaml` : null
3186
3485
  });
3187
3486
  });
3188
3487
  const manifest = {
@@ -3192,6 +3491,7 @@ function writeBundleFiles(opts) {
3192
3491
  pluginVersion: opts.bundle.pluginVersion,
3193
3492
  extractorVersion: opts.bundle.extractorVersion,
3194
3493
  selection,
3494
+ componentSpecsDir,
3195
3495
  artifacts,
3196
3496
  ...opts.dtcg && Object.keys(opts.dtcg).length > 0 ? { dtcg: opts.dtcg } : {},
3197
3497
  ...opts.platforms && opts.platforms.length > 0 ? { platforms: opts.platforms } : {},
@@ -3204,18 +3504,18 @@ function writeBundleFiles(opts) {
3204
3504
  }
3205
3505
  rmSync2(opts.outDir, { recursive: true, force: true });
3206
3506
  renameSync2(staging, opts.outDir);
3207
- const outputPaths = [];
3507
+ const componentSpecs = { path: componentSpecsDir, files: writeVisibleDir(opts.cwd, componentSpecsDir, COMPONENT_SPEC_MARKER, briefs) };
3508
+ const outputResults = [];
3208
3509
  for (const d of deliverables) {
3209
- writeOutputFile(opts.cwd, d.output, d.text);
3210
- outputPaths.push(d.output.path);
3510
+ outputResults.push({ path: d.output.path, files: writeVisibleDir(opts.cwd, d.output.path, CSS_HEADER_PREFIX, d.files, CSS_INDEX_FILE) });
3211
3511
  }
3212
- return { written, outputs: outputPaths };
3512
+ return { written, componentSpecs, outputs: outputResults };
3213
3513
  }
3214
3514
 
3215
3515
  // src/gitignore.ts
3216
3516
  import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3217
3517
  import { spawnSync } from "node:child_process";
3218
- import { join as join5, dirname as dirname3, resolve as resolve3 } from "node:path";
3518
+ import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
3219
3519
  var COMMENT = "# Spec Layer pull key, not for committing";
3220
3520
  function git(cwd, args) {
3221
3521
  const res = spawnSync("git", args, { cwd, stdio: ["ignore", "pipe", "ignore"], encoding: "utf8" });
@@ -3223,10 +3523,10 @@ function git(cwd, args) {
3223
3523
  return { ranGit: true, status: res.status, stdout: res.stdout ?? "" };
3224
3524
  }
3225
3525
  function insideWorkTreeWithoutGit(cwd) {
3226
- let dir = resolve3(cwd);
3526
+ let dir = resolve4(cwd);
3227
3527
  for (; ; ) {
3228
- if (existsSync6(join5(dir, ".git"))) return true;
3229
- const parent = dirname3(dir);
3528
+ if (existsSync6(join6(dir, ".git"))) return true;
3529
+ const parent = dirname2(dir);
3230
3530
  if (parent === dir) return false;
3231
3531
  dir = parent;
3232
3532
  }
@@ -3242,7 +3542,7 @@ function ensureIgnored(cwd, fileName) {
3242
3542
  if (inWorkTree.status !== 0 || inWorkTree.stdout.trim() !== "true") return { kind: "not-a-repo" };
3243
3543
  const checkIgnore = git(cwd, ["check-ignore", "-q", fileName]);
3244
3544
  if (checkIgnore.ranGit && checkIgnore.status === 0) return { kind: "already" };
3245
- const path = join5(cwd, ".gitignore");
3545
+ const path = join6(cwd, ".gitignore");
3246
3546
  const existed = existsSync6(path);
3247
3547
  try {
3248
3548
  if (!existed) {
@@ -3267,8 +3567,8 @@ ${fileName}
3267
3567
  }
3268
3568
 
3269
3569
  // src/skill.ts
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";
3570
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3571
+ import { dirname as dirname3, join as join7 } from "node:path";
3272
3572
 
3273
3573
  // src/tools.ts
3274
3574
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
@@ -3286,7 +3586,8 @@ var TOOLS = [
3286
3586
  "speclayer.local.json",
3287
3587
  ".gitignore (one line, when inside a git repo)",
3288
3588
  "<outDir>/",
3289
- "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"
3589
+ "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3590
+ "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3290
3591
  ],
3291
3592
  exits: OK_OR_ERROR
3292
3593
  },
@@ -3303,11 +3604,15 @@ var TOOLS = [
3303
3604
  {
3304
3605
  name: "pull",
3305
3606
  usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
3306
- summary: "Fetches the published library and writes it under the output directory (default .speclayer/).",
3307
- when: "After setup, whenever status says the local copy is behind, or after changing the include or dtcg block, or the outputs block.",
3607
+ summary: "Fetches the published library and writes the record under the output directory (default .speclayer/), the briefs under componentSpecsDir, and the token files under outputs[].path.",
3608
+ when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks.",
3308
3609
  network: true,
3309
3610
  needsKey: true,
3310
- writes: ["<outDir>/", "outputs[].path from speclayer.json (default spec-layer/tokens.css for web), written in place"],
3611
+ writes: [
3612
+ "<outDir>/",
3613
+ "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3614
+ "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3615
+ ],
3311
3616
  exits: OK_OR_ERROR
3312
3617
  },
3313
3618
  {
@@ -3416,24 +3721,24 @@ function readJson(path) {
3416
3721
  }
3417
3722
  function summarizePull(cwd, outDir, manifest) {
3418
3723
  if (!manifest) return null;
3419
- const absOut = join6(cwd, outDir);
3420
- const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path ? `${outDir}/${a.path}` : null }));
3724
+ const absOut = join7(cwd, outDir);
3725
+ const components = manifest.artifacts.filter((a) => a.kind === "component").map((a) => ({ name: a.name, path: a.path }));
3421
3726
  const foundationEntry = manifest.artifacts.find((a) => a.kind === "foundation") ?? null;
3422
3727
  let foundation = null;
3423
3728
  if (foundationEntry) {
3424
- const tokensDir = join6(absOut, "tokens");
3425
- const resolver = readJson(join6(tokensDir, "resolver.json"));
3426
- const report2 = readJson(join6(tokensDir, "report.json"));
3729
+ const tokensDir = join7(absOut, "tokens");
3730
+ const resolver = readJson(join7(tokensDir, "resolver.json"));
3731
+ const report2 = readJson(join7(tokensDir, "report.json"));
3427
3732
  let tokenFiles = [];
3428
3733
  try {
3429
- tokenFiles = readdirSync3(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
3734
+ tokenFiles = readdirSync4(tokensDir).filter((f) => f.endsWith(".json") && !RESERVED.has(f)).sort();
3430
3735
  } catch {
3431
3736
  tokenFiles = [];
3432
3737
  }
3433
3738
  let unitlessNumbers = 0;
3434
3739
  for (const file of tokenFiles) {
3435
3740
  if (file.startsWith("styles.")) continue;
3436
- unitlessNumbers += countNumberTokens(readJson(join6(tokensDir, file)));
3741
+ unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
3437
3742
  }
3438
3743
  const reportCounts = {};
3439
3744
  if (Array.isArray(report2)) {
@@ -3459,20 +3764,33 @@ function summarizePull(cwd, outDir, manifest) {
3459
3764
  libraryId: manifest.libraryId,
3460
3765
  publishedAt: manifest.publishedAt,
3461
3766
  pluginVersion: manifest.pluginVersion,
3767
+ componentSpecsDir: manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR,
3462
3768
  components,
3463
3769
  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
- }))
3770
+ outputs: (manifest.outputs ?? []).map((o) => {
3771
+ const mapPath = join7(absOut, "outputs", `${o.platform}-${o.format}.map.json`);
3772
+ const map = readJson(mapPath);
3773
+ const imports = map ? readIndexImports(cwd, o) : null;
3774
+ const files = imports !== null ? [...imports, CSS_INDEX_FILE] : [];
3775
+ return {
3776
+ platform: o.platform,
3777
+ format: o.format,
3778
+ path: o.path,
3779
+ case: o.case,
3780
+ modeSelector: o.modeSelector ?? '[data-theme="{mode}"]',
3781
+ modes: o.modes ?? {},
3782
+ // index.css must be readable, not just the map, or a deleted index.css
3783
+ // (with the map still on disk from an interrupted pull) would report
3784
+ // written with an empty file list, a sentence that claims files exist.
3785
+ written: map !== null && imports !== null,
3786
+ // Distinguishes "the map is on disk but index.css is gone" from "the
3787
+ // map itself never existed" (the Foundation was excluded), so the
3788
+ // guide can name the actual cause instead of always blaming the
3789
+ // Foundation.
3790
+ indexMissing: map !== null && imports === null,
3791
+ files
3792
+ };
3793
+ })
3476
3794
  };
3477
3795
  }
3478
3796
  var code = (s) => `\`${s}\``;
@@ -3511,16 +3829,17 @@ function stackSection(input) {
3511
3829
  if (cssOut) {
3512
3830
  const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3513
3831
  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.`,
3832
+ `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). Each entry names the file that declares the property. 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
3833
  ""
3516
3834
  );
3835
+ const partFiles = cssOut.files.filter((f) => f !== CSS_INDEX_FILE);
3517
3836
  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.`,
3837
+ `Import ${code(`${cssOut.path}/index.css`)} from the root stylesheet. It imports one file per collection and mode: ${partFiles.join(", ")}. Sets and default modes are at ${code(":root")}; every other mode is a block under ${code(cssOut.modeSelector)} in its own file, so a mode can also be imported alone. To switch, set ${code("data-theme")} on ${code("<html>")} (or whatever the selector names). To let the OS choose, set that collection's selector to ${code(":root")} under ${code("outputs[].modes")} and import the mode's file yourself under ${code("@media (prefers-color-scheme: dark)")}; the CLI never assumes that.`,
3519
3838
  ""
3520
3839
  );
3521
3840
  if (profile.tokenTools.includes("style-dictionary") || profile.tokenTools.includes("tokens-studio")) {
3522
3841
  lines.push(
3523
- `${code(cssOut.path)} is a projection of the same ${code("tokens/")} files, not a second source. Import one or the other.`,
3842
+ `${code(`${cssOut.path}/`)} is a projection of the same ${code(tokensDir)} files, not a second source. Import one or the other.`,
3524
3843
  ""
3525
3844
  );
3526
3845
  }
@@ -3530,14 +3849,19 @@ function stackSection(input) {
3530
3849
  ""
3531
3850
  );
3532
3851
  const configuredNotWritten = pull?.outputs.find((o) => o.platform === "web" && !o.written) ?? null;
3533
- if (configuredNotWritten) {
3852
+ if (configuredNotWritten?.indexMissing) {
3853
+ lines.push(
3854
+ `A web/css output is configured at ${code(`${configuredNotWritten.path}/`)} but its ${code("index.css")} is missing, so the file list is unknown. Run ${code("npx spec-layer pull")} to write it again.`,
3855
+ ""
3856
+ );
3857
+ } else if (configuredNotWritten) {
3534
3858
  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.`,
3859
+ `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
3860
  ""
3537
3861
  );
3538
3862
  } else if (pull?.foundation?.written) {
3539
3863
  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")}.`,
3864
+ `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("tokens/")}.`,
3541
3865
  ""
3542
3866
  );
3543
3867
  }
@@ -3619,9 +3943,9 @@ function pullSection(input) {
3619
3943
  } else {
3620
3944
  lines.push("- This library has no Foundation, so there is no tokens/ directory.");
3621
3945
  }
3622
- lines.push(`- ${code(`${outDir}/components/`)}: one YAML per component.`);
3946
+ lines.push(`- ${code(`${pull.componentSpecsDir}/`)}: one YAML per component.`);
3623
3947
  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.`);
3948
+ lines.push(o.written ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, ${o.case} names: ${o.files.join(", ")}. Non-default modes are under ${code(o.modeSelector)}, each in its own file. 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`)}.` : o.indexMissing ? `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, but ${code("index.css")} is missing; run ${code("npx spec-layer pull")} to restore the directory.` : `- ${code(`${o.path}/`)}: ${o.platform}/${o.format} token files, configured but not written by the last pull (the Foundation was not written). Nothing is on disk at that path from Spec Layer.`);
3625
3949
  }
3626
3950
  lines.push("");
3627
3951
  if (pull.foundation && (pull.foundation.sets.length || pull.foundation.modifiers.length)) {
@@ -3676,20 +4000,21 @@ function buildSkillGuide(input) {
3676
4000
  `The Spec Layer Figma plugin publishes a design system's components, variables, and styles as data. The ${code("spec-layer")} CLI (version ${input.version}) pulls that data into this repository under ${code(outDir + "/")}. Everything in those files is extracted deterministically from Figma and validated against a published schema; no model wrote any of it. Treat it as the source of truth for what the design system contains, and treat anything it does not state as unknown rather than as something to infer.`,
3677
4001
  ""
3678
4002
  );
4003
+ const componentSpecsDir = input.pull?.componentSpecsDir ?? input.config?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3679
4004
  lines.push("## How to use it", "");
3680
4005
  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.`);
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.`);
4006
+ lines.push(`2. Building or changing a component: read its YAML under ${code(`${componentSpecsDir}/`)}, 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.`);
3682
4007
  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.`);
3683
4008
  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.`);
3684
4009
  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.`);
3685
4010
  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.`);
4011
+ const outputNote = writtenOutputs.length ? ` Never edit ${writtenOutputs.map((o) => code(`${o.path}/`)).join(", ")} either: pull replaces or removes files there.` : "";
4012
+ lines.push(`6. Never edit files under ${code(outDir + "/")} or ${code(componentSpecsDir + "/")}: the next pull replaces or removes them.${outputNote} Configuration lives in ${code("speclayer.json")}. Never commit ${code(CREDENTIALS_NAME)}, and never print or copy the pull key.`);
3688
4013
  lines.push("");
3689
4014
  lines.push(...pullSection(input));
3690
4015
  lines.push(...stackSection(input));
3691
4016
  lines.push(...commandsSection());
3692
- lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components or when the codebase changes stack; the file is replaced, not appended.`);
4017
+ lines.push(`Generated by ${code("spec-layer skill")}. Re-run ${code("npx spec-layer skill --install")} after a pull that adds components, after changing ${code("outputs")} or ${code("componentSpecsDir")}, or when the codebase changes stack; the file is replaced, not appended.`);
3693
4018
  return `${lines.join("\n")}
3694
4019
  `;
3695
4020
  }
@@ -3759,16 +4084,16 @@ ${BLOCK_END}
3759
4084
  const after = existing.slice(end + BLOCK_END.length).replace(/^\n/, "");
3760
4085
  return `${existing.slice(0, begin)}${block}${after}`;
3761
4086
  }
3762
- const sep = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
3763
- return `${existing}${sep}${block}`;
4087
+ const sep2 = existing.length === 0 ? "" : existing.endsWith("\n\n") ? "" : existing.endsWith("\n") ? "\n" : "\n\n";
4088
+ return `${existing}${sep2}${block}`;
3764
4089
  }
3765
4090
  function installSkill(cwd, host, guide) {
3766
4091
  const target = installTarget(host);
3767
- const abs = join6(cwd, target.path);
4092
+ const abs = join7(cwd, target.path);
3768
4093
  const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
3769
4094
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
3770
4095
  if (existing === next) return { path: target.path, result: "unchanged" };
3771
- mkdirSync3(dirname4(abs), { recursive: true });
4096
+ mkdirSync3(dirname3(abs), { recursive: true });
3772
4097
  writeFileSync6(abs, next);
3773
4098
  return { path: target.path, result: existing === null ? "created" : "updated" };
3774
4099
  }
@@ -3796,7 +4121,7 @@ function manifestReader() {
3796
4121
  function sameOutput(a, b) {
3797
4122
  const selectionKey = (s) => JSON.stringify([s.foundation, s.components === null ? null : [...new Set(s.components.map(slugify))].sort()]);
3798
4123
  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 ?? []);
4124
+ return selectionKey(a.selection) === selectionKey(b.selection) && key(a.dtcg) === key(b.dtcg) && key(a.outputs ?? []) === key(b.outputs ?? []) && (a.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR) === (b.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR);
3800
4125
  }
3801
4126
  function sortKeys(value) {
3802
4127
  if (Array.isArray(value)) return value.map(sortKeys);
@@ -3827,12 +4152,12 @@ function outputsForRun(fromFlags, config, platforms) {
3827
4152
  if (fromFlags) return withDefaults(config?.outputs ?? [], fromFlags);
3828
4153
  return config?.outputs ?? defaultOutputs(platforms);
3829
4154
  }
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.`;
4155
+ var NO_PLATFORM_NOTE = `No target platform detected, so no token files were written for your code. Pass --platform ${PLATFORMS.join("|")}, or add outputs to speclayer.json.`;
3831
4156
  function platformsMissingFormat(platforms) {
3832
4157
  return platforms.filter((p) => !FORMATS.some((f) => f.platform === p));
3833
4158
  }
3834
4159
  function missingFormatNote(platforms) {
3835
- return `No token file exists yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
4160
+ return `No token files exist yet for ${platforms.join(", ")}: no output format is available for that platform. Web has css.`;
3836
4161
  }
3837
4162
  var errorText = (err) => err instanceof Error ? err.message : String(err);
3838
4163
  function runInit(cwd, flags, io2) {
@@ -3855,12 +4180,13 @@ function runInit(cwd, flags, io2) {
3855
4180
  writeConfig(cwd, {
3856
4181
  libraryId: flags.id,
3857
4182
  outDir,
4183
+ componentSpecsDir: DEFAULT_COMPONENT_SPECS_DIR,
3858
4184
  ...include ? { include } : {},
3859
4185
  ...platforms.length > 0 ? { platforms } : {},
3860
4186
  ...outputs.length > 0 ? { outputs } : {}
3861
4187
  });
3862
4188
  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.`);
4189
+ for (const o of outputs) io2.out(`Token files for ${o.platform}: ${o.path}/ (${o.format}, ${o.case} names), written by the next pull.`);
3864
4190
  if (source === "flag" || source === "detected") {
3865
4191
  const missing = platformsMissingFormat(platforms);
3866
4192
  if (missing.length > 0) io2.out(missingFormatNote(missing));
@@ -3888,7 +4214,7 @@ function resolved(cwd, flags, env, io2, manifestAt) {
3888
4214
  }
3889
4215
  function resolvedOutDir(cwd, flags, io2) {
3890
4216
  try {
3891
- return join7(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
4217
+ return join8(cwd, flags.out ?? readConfig(cwd)?.outDir ?? DEFAULT_OUT_DIR);
3892
4218
  } catch (err) {
3893
4219
  io2.err(errorText(err));
3894
4220
  return null;
@@ -3927,6 +4253,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3927
4253
  const fromFlags = platformsFromFlags(flags, io2);
3928
4254
  if (fromFlags === null) return 1;
3929
4255
  const outDir = flags.out ?? existing?.outDir ?? DEFAULT_OUT_DIR;
4256
+ const componentSpecsDir = existing?.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR;
3930
4257
  const keptInclude = include ?? existing?.include ?? null;
3931
4258
  const keptDtcg = existing?.dtcg ?? null;
3932
4259
  const { platforms } = resolvePlatforms(cwd, fromFlags, existing);
@@ -3934,6 +4261,7 @@ async function runSetup(cwd, flags, env, io2, fetcher) {
3934
4261
  writeConfig(cwd, {
3935
4262
  libraryId: flags.id,
3936
4263
  outDir,
4264
+ componentSpecsDir,
3937
4265
  ...keptInclude ? { include: keptInclude } : {},
3938
4266
  ...keptDtcg ? { dtcg: keptDtcg } : {},
3939
4267
  ...platforms.length > 0 ? { platforms } : {},
@@ -3986,6 +4314,12 @@ git rm --cached ${ignored.line}`);
3986
4314
  io2.out("spec-layer skill prints the same guide; spec-layer tools lists every command.");
3987
4315
  return 0;
3988
4316
  }
4317
+ function outputFilesOnDisk(cwd, outDir, o) {
4318
+ const mapPath = join8(cwd, outDir, "outputs", `${outputId(o)}.map.json`);
4319
+ if (!existsSync8(mapPath)) return false;
4320
+ const imports = readIndexImports(cwd, o);
4321
+ return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
4322
+ }
3989
4323
  async function runPull(cwd, flags, env, io2, fetcher) {
3990
4324
  const manifestAt = manifestReader();
3991
4325
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -4001,13 +4335,14 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4001
4335
  if (fromFlags === null) return 1;
4002
4336
  const { platforms, source } = resolvePlatforms(cwd, fromFlags, opts);
4003
4337
  const outputs = outputsForRun(fromFlags, opts, platforms);
4004
- const manifest = manifestAt(join7(cwd, opts.outDir));
4338
+ const manifest = manifestAt(join8(cwd, opts.outDir));
4005
4339
  const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4006
4340
  const willWriteFoundation = selection.foundation && foundationOnDisk;
4341
+ const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
4007
4342
  const etag = manifest && sameOutput(
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;
4343
+ { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
4344
+ { selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
4345
+ ) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
4011
4346
  const result = await fetchBundle({
4012
4347
  api: opts.api,
4013
4348
  libraryId: opts.libraryId,
@@ -4024,12 +4359,13 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4024
4359
  return 0;
4025
4360
  }
4026
4361
  let written;
4027
- let outputPaths = [];
4362
+ let componentSpecs;
4363
+ let outputResults;
4028
4364
  try {
4029
4365
  const bundle = parseBundle(result.raw);
4030
4366
  const selected = selectComponents(bundle, selection);
4031
4367
  const writeResult = writeBundleFiles({
4032
- outDir: join7(cwd, opts.outDir),
4368
+ outDir: join8(cwd, opts.outDir),
4033
4369
  cwd,
4034
4370
  raw: result.raw,
4035
4371
  bundle,
@@ -4039,10 +4375,12 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4039
4375
  bundleHash: result.bundleHash,
4040
4376
  dtcg: opts.dtcg,
4041
4377
  platforms,
4042
- outputs
4378
+ outputs,
4379
+ componentSpecsDir: opts.componentSpecsDir
4043
4380
  });
4044
4381
  written = writeResult.written;
4045
- outputPaths = writeResult.outputs;
4382
+ componentSpecs = writeResult.componentSpecs;
4383
+ outputResults = writeResult.outputs;
4046
4384
  io2.out(
4047
4385
  `Pulled ${bundle.fileName ?? opts.libraryId}: ${describePull(bundle, selection, selected)} (published ${result.publishedAt}).`
4048
4386
  );
@@ -4050,12 +4388,24 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4050
4388
  io2.err(errorText(err));
4051
4389
  return 1;
4052
4390
  }
4391
+ const count = (n) => `${n} file${n === 1 ? "" : "s"}`;
4053
4392
  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).`);
4393
+ if (componentSpecs.files.length > 0) io2.out(`Wrote ${componentSpecs.path}/ (${count(componentSpecs.files.length)}).`);
4394
+ for (const r of outputResults) {
4395
+ const o = outputs.find((x) => x.path === r.path);
4396
+ if (o) io2.out(`Wrote ${r.path}/ (${count(r.files.length)}, ${o.platform}/${o.format}, ${o.case} names).`);
4397
+ }
4398
+ const staleDirNote = (previous, current) => {
4399
+ if (previous !== current && existsSync8(resolve5(cwd, previous))) {
4400
+ io2.out(`The previous pull wrote ${previous}/; this one wrote ${current}/. Delete ${previous}/ if nothing else uses it.`);
4401
+ }
4402
+ };
4403
+ if (manifest) staleDirNote(manifest.componentSpecsDir ?? DEFAULT_COMPONENT_SPECS_DIR, opts.componentSpecsDir);
4404
+ for (const prev of manifest?.outputs ?? []) {
4405
+ const current = outputs.find((o) => outputId(o) === outputId(prev));
4406
+ if (current) staleDirNote(prev.path, current.path);
4057
4407
  }
4058
- if (outputPaths.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4408
+ if (outputResults.length === 0 && selection.foundation && source === "none" && opts.outputs === void 0) io2.out(NO_PLATFORM_NOTE);
4059
4409
  if (source === "flag" || source === "config") {
4060
4410
  const missing = platformsMissingFormat(platforms);
4061
4411
  if (missing.length > 0) io2.out(missingFormatNote(missing));
@@ -4066,7 +4416,7 @@ async function runStatus(cwd, flags, env, io2, fetcher) {
4066
4416
  const manifestAt = manifestReader();
4067
4417
  const opts = resolved(cwd, flags, env, io2, manifestAt);
4068
4418
  if (!opts) return 1;
4069
- const manifest = manifestAt(join7(cwd, opts.outDir));
4419
+ const manifest = manifestAt(join8(cwd, opts.outDir));
4070
4420
  if (!manifest) {
4071
4421
  io2.err(NO_LOCAL_PULL);
4072
4422
  return 2;
@@ -4104,7 +4454,7 @@ function runList(cwd, flags, io2) {
4104
4454
  io2.out(row.map((cell, i) => i < 3 ? cell.padEnd(widths[i]) : cell).join(" "));
4105
4455
  }
4106
4456
  for (const o of manifest.outputs ?? []) {
4107
- const written = existsSync8(join7(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4457
+ const written = existsSync8(join8(outDir, "outputs", `${o.platform}-${o.format}.map.json`));
4108
4458
  io2.out(["output".padEnd(widths[0]), `${o.platform}/${o.format}`.padEnd(widths[1]), written ? o.path : "not written"].join(" "));
4109
4459
  }
4110
4460
  return 0;
@@ -4174,7 +4524,7 @@ function collectSkillInput(cwd, flags, io2) {
4174
4524
  const fromFlags = platformsFromFlags(flags, io2);
4175
4525
  if (fromFlags === null) return null;
4176
4526
  const { platforms, source: platformSource } = resolvePlatforms(cwd, fromFlags, config);
4177
- const pull = summarizePull(cwd, outDir, readManifest(join7(cwd, outDir)));
4527
+ const pull = summarizePull(cwd, outDir, readManifest(join8(cwd, outDir)));
4178
4528
  return { profile, platforms, platformSource, outDir, config, pull, version: cliVersion() };
4179
4529
  }
4180
4530
  function skillHosts(flags, input, io2) {