spec-layer 0.8.2 → 0.9.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 +1 -1
  2. package/dist/cli.js +602 -85
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -64,7 +64,7 @@ Module 2025.10 files, rather than `ai/foundation.yaml`, needs 0.4.0 or later.
64
64
  `componentSpecsDir`, cwd-relative manifest paths, and the `tokens/` directory
65
65
  need 0.7.0 or later. The `census` and `config_hash` blocks inside
66
66
  `resolver.json`, and the `transform` and `resolved` fields in
67
- `spec-layer.meta.json`, need 0.8.0 or later; an earlier version pulls the same
67
+ `spec-layer.meta.json`, need 0.8.2 or later; an earlier version pulls the same
68
68
  files without those fields.
69
69
 
70
70
  ## Commands
package/dist/cli.js CHANGED
@@ -559,7 +559,7 @@ var require_sha256 = __commonJS({
559
559
  import { parseArgs } from "node:util";
560
560
 
561
561
  // src/commands.ts
562
- import { existsSync as existsSync8 } from "node:fs";
562
+ import { existsSync as existsSync8, readFileSync as readFileSync9 } from "node:fs";
563
563
  import { join as join8, resolve as resolve5 } from "node:path";
564
564
 
565
565
  // ../extractor/src/statesMatrix.ts
@@ -814,6 +814,26 @@ var SUPPORTED_TOKEN_TYPES = [
814
814
  var SUPPORTED_VALUE_KINDS = ["literal", "alias", "missing"];
815
815
  var SUPPORTED_DURATION_UNITS = ["ms", "s"];
816
816
 
817
+ // ../extractor/src/v5/units.ts
818
+ var UNIT_BY_SCOPE = {
819
+ WIDTH_HEIGHT: "px",
820
+ CORNER_RADIUS: "px",
821
+ GAP: "px",
822
+ FONT_SIZE: "px",
823
+ STROKE_FLOAT: "px",
824
+ PARAGRAPH_SPACING: "px",
825
+ PARAGRAPH_INDENT: "px",
826
+ EFFECT_FLOAT: "px",
827
+ FONT_WEIGHT: "number",
828
+ OPACITY: "number"
829
+ };
830
+ function scopesStateUnit(scopes) {
831
+ return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] !== void 0);
832
+ }
833
+ function scopesStateNumber(scopes) {
834
+ return (scopes ?? []).some((s) => UNIT_BY_SCOPE[s] === "number");
835
+ }
836
+
817
837
  // ../extractor/src/v5/canonical.ts
818
838
  var import_js_sha2562 = __toESM(require_sha256(), 1);
819
839
  var SCHEMA_VERSION = "5.1.0";
@@ -1325,6 +1345,25 @@ function validateLevel1(artifact) {
1325
1345
  }
1326
1346
  }
1327
1347
 
1348
+ // ../extractor/src/v5/fonts.ts
1349
+ function fontRequirements(artifact) {
1350
+ const byFamily = /* @__PURE__ */ new Map();
1351
+ for (const style of artifact.styles.typography) {
1352
+ const family = style.properties.font_family.resolved;
1353
+ if (family === null || family.type !== "font_family") continue;
1354
+ const bucket = byFamily.get(family.value) ?? { weights: /* @__PURE__ */ new Set(), used: /* @__PURE__ */ new Set() };
1355
+ const weight = style.properties.font_weight.resolved;
1356
+ if (weight !== null && weight.type === "number") bucket.weights.add(weight.value);
1357
+ bucket.used.add(style.name);
1358
+ byFamily.set(family.value, bucket);
1359
+ }
1360
+ return [...byFamily.entries()].sort((a, b) => compareCodeUnits(a[0], b[0])).map(([family, bucket]) => ({
1361
+ family,
1362
+ weights: [...bucket.weights].sort((a, b) => a - b),
1363
+ used_by: [...bucket.used].sort(compareCodeUnits)
1364
+ }));
1365
+ }
1366
+
1328
1367
  // ../extractor/src/v5/dtcg.ts
1329
1368
  var import_js_sha2563 = __toESM(require_sha256(), 1);
1330
1369
  function dtcgSegments(name) {
@@ -1344,6 +1383,9 @@ function dtcgSegments(name) {
1344
1383
  }
1345
1384
  return { segments, notes };
1346
1385
  }
1386
+ function dtcgPathOf(collectionName, tokenName) {
1387
+ return [...dtcgSegments(collectionName).segments, ...dtcgSegments(tokenName).segments].join(".");
1388
+ }
1347
1389
  function trimDashes(s) {
1348
1390
  let start = 0;
1349
1391
  let end = s.length;
@@ -1514,25 +1556,73 @@ function unitOverrideFor(p, token, collection) {
1514
1556
  }
1515
1557
  return void 0;
1516
1558
  }
1517
- var STATED_NUMBER_SCOPES = ["FONT_WEIGHT", "OPACITY"];
1518
- function projectedLiteral(p, token, resolved2, onOverrideConflict) {
1559
+ function reportPathOf(p, token) {
1560
+ const path = p.pathById.get(token.id) ?? p.segmentsById.get(token.id)?.join(".") ?? token.name;
1561
+ return p.collidedIds.has(token.id) ? `${path} [${token.id}]` : path;
1562
+ }
1563
+ function ownerFor(p, token) {
1564
+ const path = reportPathOf(p, token);
1565
+ return {
1566
+ overrideConflict: (override) => {
1567
+ reportOnce(p, {
1568
+ code: "unit_override_conflicts_with_scope",
1569
+ severity: "warning",
1570
+ path,
1571
+ message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
1572
+ details: { id: token.id, override, scopes: [...token.scopes] }
1573
+ });
1574
+ },
1575
+ // Reported without a mode, like the override conflict above: the evidence
1576
+ // is a fact about the token, not about one of its values, so a token in
1577
+ // three modes earns one entry rather than three.
1578
+ derivedUnit: (evidence) => {
1579
+ reportOnce(p, {
1580
+ code: "unit_derived_from_usage",
1581
+ severity: "info",
1582
+ path,
1583
+ // "its own variable", not "no scope": for `via: 'alias-scope'` a scope
1584
+ // is exactly what stated the unit, and this same sentence goes on to
1585
+ // name it. What is true of both kinds of evidence is that the token's
1586
+ // OWN variable states nothing. The CSS header that points a reader at
1587
+ // this entry says it the same way, for the same reason.
1588
+ message: `This token's own variable states no unit, so ${evidence.unit} was taken from how the library uses it: ${evidence.source} ${evidence.via === "binding" ? "binds it to" : "is scoped"} ${evidence.reason}.`,
1589
+ details: {
1590
+ id: token.id,
1591
+ unit: evidence.unit,
1592
+ via: evidence.via,
1593
+ source: evidence.source,
1594
+ reason: evidence.reason
1595
+ }
1596
+ });
1597
+ }
1598
+ };
1599
+ }
1600
+ function projectedLiteral(p, token, resolved2, owner) {
1519
1601
  const collection = p.collectionById.get(token.collection_id);
1520
1602
  const override = collection ? unitOverrideFor(p, token, collection) : void 0;
1521
1603
  let literal = resolved2;
1522
1604
  let overrode = false;
1605
+ let derived;
1523
1606
  if (override !== void 0 && literal.type === "number") {
1524
- if (token.scopes.some((s) => STATED_NUMBER_SCOPES.includes(s))) onOverrideConflict?.(override);
1607
+ if (scopesStateNumber(token.scopes)) owner?.overrideConflict(override);
1525
1608
  else {
1526
1609
  literal = { type: "dimension", number: literal.value, unit: override };
1527
1610
  overrode = true;
1528
1611
  }
1612
+ } else if (literal.type === "number" && !scopesStateUnit(token.scopes)) {
1613
+ const evidence = p.derivedUnits.get(token.id);
1614
+ if (evidence !== void 0) {
1615
+ literal = { type: "dimension", number: literal.value, unit: evidence.unit };
1616
+ derived = evidence;
1617
+ owner?.derivedUnit(evidence);
1618
+ }
1529
1619
  }
1530
1620
  const converted2 = dtcgLiteral(literal, token.scopes, p.options.values);
1531
1621
  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
- };
1622
+ let transform = literalTransform(literal, token.scopes);
1623
+ if (overrode) transform = "number-unit-override";
1624
+ else if (derived !== void 0) transform = "number-unit-usage";
1625
+ return { converted: converted2, transform };
1536
1626
  }
1537
1627
  function literalTransform(value, scopes) {
1538
1628
  switch (value.type) {
@@ -1559,7 +1649,24 @@ function literalTransform(value, scopes) {
1559
1649
  }
1560
1650
  function aliasLeafType(p, token, chain, resolved2) {
1561
1651
  const terminal = chain.length > 0 ? p.tokenById.get(chain[chain.length - 1].token_id) : void 0;
1562
- return projectedLiteral(p, terminal ?? token, resolved2).converted;
1652
+ const subject = terminal ?? token;
1653
+ return projectedLiteral(p, subject, resolved2, ownerFor(p, subject)).converted;
1654
+ }
1655
+ function terminalOwnType(p, chain) {
1656
+ const hop = chain.length > 0 ? chain[chain.length - 1] : void 0;
1657
+ const terminal = hop ? p.tokenById.get(hop.token_id) : void 0;
1658
+ const value = terminal && hop ? terminal.values[hop.mode_id] : void 0;
1659
+ if (!terminal || !value || value.kind !== "literal") return void 0;
1660
+ return projectedLiteral(p, terminal, value.value, ownerFor(p, terminal)).converted;
1661
+ }
1662
+ function reportAliasTypeMismatch(p, path, targetPath, ownType, targetType) {
1663
+ reportOnce(p, {
1664
+ code: "alias_type_mismatch",
1665
+ severity: "error",
1666
+ path,
1667
+ message: `This token is "${ownType}" but its alias target ${targetPath} is "${targetType}"; a consumer reading the declared type gets a value the target cannot carry.`,
1668
+ details: { target: targetPath, own_type: ownType, target_type: targetType }
1669
+ });
1563
1670
  }
1564
1671
  function modeLabels(collection) {
1565
1672
  const counts = /* @__PURE__ */ new Map();
@@ -1978,10 +2085,11 @@ function styleCensus(tree) {
1978
2085
  walk(tree);
1979
2086
  return { tokens: a.tokens, types: histogram(a.types), descriptions: { present: a.present, missing: a.missing } };
1980
2087
  }
1981
- function foundationDtcg(artifact, options = {}) {
2088
+ function foundationDtcg(artifact, options = {}, derivedUnits) {
1982
2089
  const p = {
1983
2090
  artifact,
1984
2091
  options: { values: options.values ?? "standard", ...options.units ? { units: options.units } : {} },
2092
+ derivedUnits: derivedUnits ?? /* @__PURE__ */ new Map(),
1985
2093
  tokenById: new Map(artifact.tokens.map((t) => [t.id, t])),
1986
2094
  tokenIds: new Set(artifact.tokens.map((t) => t.id)),
1987
2095
  collectionById: new Map(artifact.collections.map((c) => [c.id, c])),
@@ -2170,18 +2278,17 @@ function tokenLeaf(p, token, collection, modeId) {
2170
2278
  });
2171
2279
  return null;
2172
2280
  }
2281
+ const terminalType = terminalOwnType(p, value.resolved.chain);
2282
+ if (terminalType && !("omit" in terminalType) && terminalType.$type !== typed.$type) {
2283
+ reportAliasTypeMismatch(p, path, targetPath, typed.$type, terminalType.$type);
2284
+ const transform = literalTransform(value.resolved.value, token.scopes);
2285
+ if (transform !== null) recordFact(p, token.id, mode, transform);
2286
+ return { $type: typed.$type, $value: typed.$value, ...description };
2287
+ }
2173
2288
  recordFact(p, token.id, mode, "alias", typed.$value);
2174
2289
  return { $type: typed.$type, $value: `{${targetPath}}`, ...description };
2175
2290
  }
2176
- const projected = projectedLiteral(p, token, value.value, (override) => {
2177
- reportOnce(p, {
2178
- code: "unit_override_conflicts_with_scope",
2179
- severity: "warning",
2180
- path,
2181
- message: "A unit override names this token but its scopes state a unitless number; the override was ignored.",
2182
- details: { id: token.id, override, scopes: [...token.scopes] }
2183
- });
2184
- });
2291
+ const projected = projectedLiteral(p, token, value.value, ownerFor(p, token));
2185
2292
  const converted2 = projected.converted;
2186
2293
  if ("omit" in converted2) {
2187
2294
  reportOnce(p, {
@@ -2211,6 +2318,124 @@ function dtcgExportFiles(out) {
2211
2318
  return files;
2212
2319
  }
2213
2320
 
2321
+ // ../extractor/src/v5/usageUnits.ts
2322
+ var LENGTH_SCOPES = ["CORNER_RADIUS", "WIDTH_HEIGHT", "GAP", "FONT_SIZE", "STROKE_FLOAT"];
2323
+ var LENGTH_PROPERTIES = [
2324
+ "border-radius",
2325
+ "border-top-left-radius",
2326
+ "border-top-right-radius",
2327
+ "border-bottom-left-radius",
2328
+ "border-bottom-right-radius",
2329
+ "gap",
2330
+ "height",
2331
+ "padding-x",
2332
+ "padding-y",
2333
+ "width"
2334
+ ];
2335
+ var MAX_ALIAS_DEPTH = 16;
2336
+ var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2337
+ function foundationOf(bundle) {
2338
+ const artifact = bundle.foundation?.artifact;
2339
+ if (!isRecord2(artifact)) return null;
2340
+ if (!Array.isArray(artifact.tokens) || !Array.isArray(artifact.collections)) return null;
2341
+ return artifact;
2342
+ }
2343
+ function precedes(a, b) {
2344
+ return (compareCodeUnits(a.via, b.via) || compareCodeUnits(a.source, b.source) || compareCodeUnits(a.reason, b.reason)) < 0;
2345
+ }
2346
+ function bindingsOf(artifact) {
2347
+ if (!isRecord2(artifact) || !isRecord2(artifact.references)) return [];
2348
+ const bindings = artifact.references.bindings;
2349
+ if (!Array.isArray(bindings)) return [];
2350
+ return bindings.filter((b) => isRecord2(b) && b.kind === "variable" && typeof b.source_id === "string" && typeof b.property === "string");
2351
+ }
2352
+ function aliasTargets(token) {
2353
+ const out = [];
2354
+ for (const value of Object.values(token.values)) {
2355
+ if (value.kind !== "alias" || value.reference.external) continue;
2356
+ const id = value.reference.target_id;
2357
+ if (id !== null && !out.includes(id)) out.push(id);
2358
+ }
2359
+ return out;
2360
+ }
2361
+ function usageUnits(bundle) {
2362
+ const evidence = /* @__PURE__ */ new Map();
2363
+ const artifact = foundationOf(bundle);
2364
+ if (artifact === null) return evidence;
2365
+ const tokenById = new Map(artifact.tokens.map((t) => [t.id, t]));
2366
+ const collectionById = new Map(artifact.collections.map((c) => [c.id, c]));
2367
+ const lengthUse = /* @__PURE__ */ new Map();
2368
+ const nonLengthUse = [];
2369
+ for (const component of bundle.components) {
2370
+ for (const binding of bindingsOf(component.artifact)) {
2371
+ if (!LENGTH_PROPERTIES.includes(binding.property)) {
2372
+ nonLengthUse.push(binding.source_id);
2373
+ continue;
2374
+ }
2375
+ const found = {
2376
+ unit: "px",
2377
+ via: "binding",
2378
+ source: component.name,
2379
+ reason: binding.property
2380
+ };
2381
+ const prior = lengthUse.get(binding.source_id);
2382
+ if (prior === void 0 || precedes(found, prior)) lengthUse.set(binding.source_id, found);
2383
+ }
2384
+ }
2385
+ const vetoedIds = /* @__PURE__ */ new Set();
2386
+ const walkChain = (startId, includeStart, visit) => {
2387
+ const seen = /* @__PURE__ */ new Set([startId]);
2388
+ let frontier = [startId];
2389
+ for (let depth = 0; depth <= MAX_ALIAS_DEPTH && frontier.length > 0; depth += 1) {
2390
+ const next = [];
2391
+ for (const id of frontier) {
2392
+ const token = tokenById.get(id);
2393
+ if (id !== startId && token !== void 0 && scopesStateUnit(token.scopes)) continue;
2394
+ if (id !== startId || includeStart) visit(id);
2395
+ if (token === void 0) continue;
2396
+ for (const target of aliasTargets(token)) {
2397
+ if (seen.has(target)) continue;
2398
+ seen.add(target);
2399
+ next.push(target);
2400
+ }
2401
+ }
2402
+ frontier = next;
2403
+ }
2404
+ };
2405
+ for (const id of nonLengthUse) walkChain(id, true, (reached) => vetoedIds.add(reached));
2406
+ for (const token of artifact.tokens) {
2407
+ if (!scopesStateNumber(token.scopes)) continue;
2408
+ walkChain(token.id, false, (reached) => vetoedIds.add(reached));
2409
+ }
2410
+ const isCandidate = (id) => {
2411
+ const token = tokenById.get(id);
2412
+ return token !== void 0 && token.type === "number" && !scopesStateUnit(token.scopes);
2413
+ };
2414
+ const record = (id, found) => {
2415
+ if (!isCandidate(id) || vetoedIds.has(id)) return;
2416
+ const prior = evidence.get(id);
2417
+ if (prior === void 0 || precedes(found, prior)) evidence.set(id, found);
2418
+ };
2419
+ const pin = (startId, found, includeStart) => walkChain(startId, includeStart, (id) => record(id, found));
2420
+ for (const token of artifact.tokens) {
2421
+ const scopes = token.scopes.filter((s) => LENGTH_SCOPES.includes(s)).sort(compareCodeUnits);
2422
+ if (scopes.length === 0) continue;
2423
+ const collection = collectionById.get(token.collection_id);
2424
+ if (collection === void 0) continue;
2425
+ pin(token.id, {
2426
+ unit: "px",
2427
+ via: "alias-scope",
2428
+ source: dtcgPathOf(collection.name, token.name),
2429
+ reason: scopes[0]
2430
+ }, false);
2431
+ }
2432
+ for (const token of artifact.tokens) {
2433
+ const found = lengthUse.get(token.id);
2434
+ if (found !== void 0) pin(token.id, found, true);
2435
+ }
2436
+ return evidence;
2437
+ }
2438
+
2214
2439
  // ../extractor/src/v5/outputs/naming.ts
2215
2440
  var NAME_CASES = ["kebab", "camel", "pascal", "snake", "constant"];
2216
2441
  function splitWords(segment) {
@@ -2424,10 +2649,22 @@ function cssValue(ctx, type, value, property) {
2424
2649
  if (d && typeof d.value === "number" && typeof d.unit === "string") return `${d.value}${d.unit}`;
2425
2650
  break;
2426
2651
  }
2427
- case "number":
2428
2652
  case "fontWeight":
2429
2653
  if (typeof value === "number") return String(value);
2430
2654
  break;
2655
+ case "number":
2656
+ if (typeof value === "number") {
2657
+ if (property !== "lineHeight") {
2658
+ report(ctx, {
2659
+ code: "unitless_number",
2660
+ severity: "warning",
2661
+ message: ctx.statesNumber ? "This token has no unit, because its Figma scopes state it is a unitless number. CSS reads it as a number, not a length, which is what those scopes ask for. Do not use it where a length is expected." : "This token has no unit, because its Figma variable states none. CSS reads it as a number, not a length. Narrow the variable's scopes in Figma and pull again.",
2662
+ details: { value, ...member }
2663
+ });
2664
+ }
2665
+ return String(value);
2666
+ }
2667
+ break;
2431
2668
  case "cubicBezier":
2432
2669
  if (Array.isArray(value) && value.length === 4 && value.every((n) => typeof n === "number")) {
2433
2670
  return `cubic-bezier(${value.join(", ")})`;
@@ -2582,22 +2819,59 @@ function shadowDecl(ctx, leaf, name) {
2582
2819
  return `${name}: ${parts.join(", ")};`;
2583
2820
  }
2584
2821
  var commentSafe = (text) => text.replace(/\*\//g, "* /").replace(/[\r\n]+/g, " ");
2585
- function headerText(header, nameCase) {
2586
- return `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.
2587
- Do not edit. Change the design in Figma, republish, and run spec-layer pull. */`;
2822
+ function headerText(header, nameCase, unitlessCount = 0, derivedCount = 0) {
2823
+ const lines = [
2824
+ `${CSS_HEADER_PREFIX} from library ${commentSafe(header.libraryId)}, foundation ${header.contentHash}, ${header.platform}/${header.format}/${nameCase}.`,
2825
+ " Do not edit. Change the design in Figma, republish, and run spec-layer pull."
2826
+ ];
2827
+ if (unitlessCount > 0) {
2828
+ const reportFile = `${header.platform}-${header.format}.report.json`;
2829
+ if (unitlessCount === 1) {
2830
+ lines.push(
2831
+ " 1 property in this file has no unit, because its Figma variable states none.",
2832
+ ` CSS reads it as a number, not a length. See outputs/${reportFile} under your pull's output directory, or narrow the variable's scopes in Figma.`
2833
+ );
2834
+ } else {
2835
+ lines.push(
2836
+ ` ${unitlessCount} properties in this file have no unit, because their Figma variables state none.`,
2837
+ ` CSS reads them as numbers, not lengths. See outputs/${reportFile} under your pull's output directory, or narrow the variables' scopes in Figma.`
2838
+ );
2839
+ }
2840
+ }
2841
+ if (derivedCount > 0) {
2842
+ lines.push(
2843
+ derivedCount === 1 ? " 1 property in this file has a unit its own Figma variable does not state, taken from how the library uses the token." : ` ${derivedCount} properties in this file have a unit their own Figma variables do not state, taken from how the library uses those tokens.`,
2844
+ ` See tokens/report.json under your pull's output directory for what pinned ${derivedCount === 1 ? "it" : "each one"}.`
2845
+ );
2846
+ }
2847
+ return `${lines.join("\n")} */`;
2588
2848
  }
2589
- function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2849
+ function emitPass(sources, leavesByFile, names, alive, root, template, modes, facts) {
2590
2850
  const entries = [];
2591
2851
  const declared = /* @__PURE__ */ new Set();
2592
2852
  const firstFile = /* @__PURE__ */ new Map();
2593
2853
  const blocks = /* @__PURE__ */ new Map();
2854
+ const unitlessByFile = /* @__PURE__ */ new Map();
2855
+ const derivedByFile = /* @__PURE__ */ new Map();
2856
+ const note = (by, file, path) => {
2857
+ const set = by.get(file) ?? /* @__PURE__ */ new Set();
2858
+ set.add(path);
2859
+ by.set(file, set);
2860
+ };
2594
2861
  for (const s of sources) {
2595
2862
  const perCollection = modes?.[s.collection];
2596
2863
  const selector = s.isDefault ? root : (perCollection ?? template).replace(/\{mode\}/g, modeSlug(s.file)).replace(/\{collection\}/g, collectionSlug(s.file));
2597
2864
  const decls = [];
2598
2865
  const declaredHere = [];
2599
2866
  for (const leaf of leavesByFile.get(s.file) ?? []) {
2600
- const ctx = { names, alive, report: entries, path: leaf.path, ...s.mode !== null ? { mode: s.mode } : {} };
2867
+ const ctx = {
2868
+ names,
2869
+ alive,
2870
+ report: entries,
2871
+ path: leaf.path,
2872
+ statesNumber: facts.statesNumber.has(leaf.path),
2873
+ ...s.mode !== null ? { mode: s.mode } : {}
2874
+ };
2601
2875
  if (leaf.type === "typography") {
2602
2876
  const t = typographyDecls(ctx, leaf, names);
2603
2877
  decls.push(...t.decls);
@@ -2616,12 +2890,17 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2616
2890
  declaredHere.push(leaf.path);
2617
2891
  }
2618
2892
  } else {
2893
+ const before = entries.length;
2619
2894
  const v = cssValue(ctx, leaf.type, leaf.value);
2620
2895
  if (v !== null) {
2621
2896
  decls.push(`${name}: ${v};`);
2622
2897
  declared.add(leaf.path);
2623
2898
  declaredHere.push(leaf.path);
2624
2899
  }
2900
+ if (entries.length > before && entries[entries.length - 1].code === "unitless_number" && !ctx.statesNumber) {
2901
+ note(unitlessByFile, s.file, leaf.path);
2902
+ }
2903
+ if (v !== null && facts.derived.has(leaf.path)) note(derivedByFile, s.file, leaf.path);
2625
2904
  }
2626
2905
  }
2627
2906
  }
@@ -2632,7 +2911,7 @@ function emitPass(sources, leavesByFile, names, alive, root, template, modes) {
2632
2911
  if (existing) existing.decls.push(...decls);
2633
2912
  else blocks.set(s.file, { selector, comment, decls });
2634
2913
  }
2635
- return { blocks, entries, declared, firstFile };
2914
+ return { blocks, entries, declared, firstFile, unitlessByFile, derivedByFile };
2636
2915
  }
2637
2916
  function cssOutput(exp, header, options = {}) {
2638
2917
  const nameCase = options.case ?? CSS_DEFAULTS.case;
@@ -2666,7 +2945,11 @@ function cssOutput(exp, header, options = {}) {
2666
2945
  nameCase
2667
2946
  });
2668
2947
  const names = resolved2.names;
2669
- const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes);
2948
+ const facts = {
2949
+ statesNumber: new Set(Object.entries(exp.meta).filter(([, entry2]) => scopesStateNumber(entry2.scopes)).map(([path]) => path)),
2950
+ derived: new Set(exp.report.filter((entry2) => entry2.code === "unit_derived_from_usage").map((entry2) => entry2.path))
2951
+ };
2952
+ const emit = (alive2) => emitPass(sources, leavesByFile, names, alive2, root, template, options.modes, facts);
2670
2953
  let alive = new Set(names.keys());
2671
2954
  let pass = emit(alive);
2672
2955
  for (; ; ) {
@@ -2693,11 +2976,16 @@ function cssOutput(exp, header, options = {}) {
2693
2976
  });
2694
2977
  }
2695
2978
  }
2696
- const head = headerText(header, nameCase);
2697
2979
  const files = {};
2698
2980
  const imports = [];
2699
2981
  for (const [source, block] of pass.blocks) {
2700
2982
  const name = fileNames.get(source);
2983
+ const head = headerText(
2984
+ header,
2985
+ nameCase,
2986
+ pass.unitlessByFile.get(source)?.size ?? 0,
2987
+ pass.derivedByFile.get(source)?.size ?? 0
2988
+ );
2701
2989
  files[name] = `${head}
2702
2990
 
2703
2991
  ${block.selector} {
@@ -2707,7 +2995,7 @@ ${block.decls.map((d) => ` ${d}`).join("\n")}
2707
2995
  `;
2708
2996
  imports.push(block.comment, `@import "./${name}";`);
2709
2997
  }
2710
- if (imports.length > 0) files[CSS_INDEX_FILE] = `${head}
2998
+ if (imports.length > 0) files[CSS_INDEX_FILE] = `${headerText(header, nameCase)}
2711
2999
 
2712
3000
  ${imports.join("\n")}
2713
3001
  `;
@@ -2726,14 +3014,14 @@ var LibraryBundleError = class extends Error {
2726
3014
  this.code = code2;
2727
3015
  }
2728
3016
  };
2729
- var isRecord2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
3017
+ var isRecord3 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
2730
3018
  function hasContentHash(artifact) {
2731
- if (!isRecord2(artifact) || !isRecord2(artifact.spec_layer)) return false;
3019
+ if (!isRecord3(artifact) || !isRecord3(artifact.spec_layer)) return false;
2732
3020
  const exp = artifact.spec_layer.export;
2733
- return isRecord2(exp) && typeof exp.content_hash === "string";
3021
+ return isRecord3(exp) && typeof exp.content_hash === "string";
2734
3022
  }
2735
3023
  function entry(v, where) {
2736
- if (!isRecord2(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
3024
+ if (!isRecord3(v) || typeof v.name !== "string" || typeof v.ai !== "string" || !hasContentHash(v.artifact)) {
2737
3025
  throw new LibraryBundleError("malformed", `The ${where} entry in this bundle is malformed.`);
2738
3026
  }
2739
3027
  return { name: v.name, ai: v.ai, artifact: v.artifact };
@@ -2750,7 +3038,7 @@ function parseLibraryBundle(input) {
2750
3038
  throw new LibraryBundleError("not_json", "This is not valid JSON.");
2751
3039
  }
2752
3040
  }
2753
- if (!isRecord2(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
3041
+ if (!isRecord3(parsed) || parsed.schema !== LIBRARY_BUNDLE_SCHEMA) {
2754
3042
  throw new LibraryBundleError("not_bundle", "This is not a Spec Layer library bundle.");
2755
3043
  }
2756
3044
  if (!supportedVersion(parsed.version)) {
@@ -2766,7 +3054,7 @@ function parseLibraryBundle(input) {
2766
3054
  const foundationRaw = parsed.foundation ?? null;
2767
3055
  let foundation = null;
2768
3056
  if (foundationRaw !== null) {
2769
- if (!isRecord2(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
3057
+ if (!isRecord3(foundationRaw) || typeof foundationRaw.ai !== "string" || !hasContentHash(foundationRaw.artifact)) {
2770
3058
  throw new LibraryBundleError("malformed", "The foundation entry in this bundle is malformed.");
2771
3059
  }
2772
3060
  foundation = { ai: foundationRaw.ai, artifact: foundationRaw.artifact };
@@ -2851,8 +3139,7 @@ var CODE_SYNTAX_KEY = {
2851
3139
  var PLATFORMS = ["web", "ios", "android", "flutter"];
2852
3140
  var AGENT_HOSTS = ["claude", "cursor", "copilot", "windsurf", "gemini", "agents-md"];
2853
3141
  var uniq = (xs) => [...new Set(xs)];
2854
- function readPackageJson(cwd) {
2855
- const path = join2(cwd, "package.json");
3142
+ function readJsonObject(path) {
2856
3143
  if (!existsSync2(path)) return null;
2857
3144
  let parsed;
2858
3145
  try {
@@ -2861,7 +3148,11 @@ function readPackageJson(cwd) {
2861
3148
  return null;
2862
3149
  }
2863
3150
  if (typeof parsed !== "object" || parsed === null) return null;
2864
- const record = parsed;
3151
+ return parsed;
3152
+ }
3153
+ function readPackageJson(cwd) {
3154
+ const record = readJsonObject(join2(cwd, "package.json"));
3155
+ if (!record) return null;
2865
3156
  const deps = {};
2866
3157
  for (const field of ["dependencies", "devDependencies", "peerDependencies"]) {
2867
3158
  const block = record[field];
@@ -2987,6 +3278,97 @@ function isPlatform(value) {
2987
3278
  function isAgentHost(value) {
2988
3279
  return AGENT_HOSTS.includes(value);
2989
3280
  }
3281
+ function fontPackageSlug(family) {
3282
+ return family.toLowerCase().split(" ").join("-");
3283
+ }
3284
+ function dependencyNamesFamily(dependencyName, slug2) {
3285
+ const lower = dependencyName.toLowerCase();
3286
+ return lower === slug2 || lower.endsWith(`/${slug2}`);
3287
+ }
3288
+ function cssNamesFamily(cssText, family) {
3289
+ if (!cssText.includes("@font-face")) return false;
3290
+ return cssText.includes(`"${family}"`) || cssText.includes(`'${family}'`);
3291
+ }
3292
+ function googleFontsLinkNamesFamily(htmlText, family) {
3293
+ for (const quoted of htmlText.split('"')) {
3294
+ for (const fragment of quoted.split("'")) {
3295
+ const trimmed = fragment.trim();
3296
+ const candidate = trimmed.startsWith("//") ? `https:${trimmed}` : trimmed;
3297
+ if (!candidate.startsWith("http")) continue;
3298
+ let url;
3299
+ try {
3300
+ url = new URL(candidate);
3301
+ } catch {
3302
+ continue;
3303
+ }
3304
+ if (url.hostname !== "fonts.googleapis.com") continue;
3305
+ for (const value of url.searchParams.getAll("family")) {
3306
+ if (value === family || value.startsWith(`${family}:`)) return true;
3307
+ }
3308
+ }
3309
+ }
3310
+ return false;
3311
+ }
3312
+ function missingFontSources(families, repo) {
3313
+ const dependencyNames = Object.keys({ ...repo.packageJson.dependencies, ...repo.packageJson.devDependencies });
3314
+ return families.filter((family) => {
3315
+ const slug2 = fontPackageSlug(family);
3316
+ if (dependencyNames.some((name) => dependencyNamesFamily(name, slug2))) return false;
3317
+ if (cssNamesFamily(repo.cssText, family)) return false;
3318
+ if (googleFontsLinkNamesFamily(repo.htmlText, family)) return false;
3319
+ return true;
3320
+ });
3321
+ }
3322
+ function dependencyField(record, field) {
3323
+ const block = record?.[field];
3324
+ if (typeof block !== "object" || block === null) return {};
3325
+ const out = {};
3326
+ for (const [name, range] of Object.entries(block)) {
3327
+ if (typeof range === "string") out[name] = range;
3328
+ }
3329
+ return out;
3330
+ }
3331
+ function readRootCssText(cwd) {
3332
+ let names = [];
3333
+ try {
3334
+ names = readdirSync(cwd);
3335
+ } catch {
3336
+ names = [];
3337
+ }
3338
+ const chunks = [];
3339
+ for (const name of names) {
3340
+ if (!name.endsWith(".css")) continue;
3341
+ try {
3342
+ chunks.push(readFileSync2(join2(cwd, name), "utf8"));
3343
+ } catch {
3344
+ }
3345
+ }
3346
+ return chunks.join("\n");
3347
+ }
3348
+ function readEntryHtmlText(cwd) {
3349
+ const chunks = [];
3350
+ for (const path of [join2(cwd, "index.html"), join2(cwd, "public", "index.html")]) {
3351
+ try {
3352
+ if (existsSync2(path)) chunks.push(readFileSync2(path, "utf8"));
3353
+ } catch {
3354
+ }
3355
+ }
3356
+ return chunks.join("\n");
3357
+ }
3358
+ function readFontRepoSignals(cwd) {
3359
+ const record = readJsonObject(join2(cwd, "package.json"));
3360
+ return {
3361
+ packageJson: {
3362
+ dependencies: dependencyField(record, "dependencies"),
3363
+ devDependencies: dependencyField(record, "devDependencies")
3364
+ },
3365
+ cssText: readRootCssText(cwd),
3366
+ htmlText: readEntryHtmlText(cwd)
3367
+ };
3368
+ }
3369
+ function missingFontSourcesInRepo(families, cwd) {
3370
+ return missingFontSources(families, readFontRepoSignals(cwd));
3371
+ }
2990
3372
 
2991
3373
  // src/outputs.ts
2992
3374
  import { readFileSync as readFileSync3 } from "node:fs";
@@ -3317,7 +3699,7 @@ async function fetchBundle(opts) {
3317
3699
  }
3318
3700
 
3319
3701
  // src/files.ts
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";
3702
+ import { mkdirSync as mkdirSync2, writeFileSync as writeFileSync4, readFileSync as readFileSync6, readdirSync as readdirSync3, rmSync as rmSync2, renameSync as renameSync2, existsSync as existsSync5 } from "node:fs";
3321
3703
  import { join as join5, dirname, relative as relative2, resolve as resolve3, isAbsolute as isAbsolute2, sep } from "node:path";
3322
3704
 
3323
3705
  // src/selection.ts
@@ -3351,6 +3733,17 @@ Available: ${available || "none"}.`
3351
3733
  return bundle.components.map((c) => wanted.some((name) => matchesName(name, c.name)));
3352
3734
  }
3353
3735
 
3736
+ // src/version.ts
3737
+ import { readFileSync as readFileSync5 } from "node:fs";
3738
+ function cliVersion() {
3739
+ try {
3740
+ const parsed = JSON.parse(readFileSync5(new URL("../package.json", import.meta.url), "utf8"));
3741
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
3742
+ } catch {
3743
+ return "unknown";
3744
+ }
3745
+ }
3746
+
3354
3747
  // src/files.ts
3355
3748
  function slugify(name) {
3356
3749
  const slug2 = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
@@ -3361,7 +3754,7 @@ function readManifest(outDir) {
3361
3754
  const path = join5(outDir, "manifest.json");
3362
3755
  if (!existsSync5(path)) return null;
3363
3756
  try {
3364
- const parsed = JSON.parse(readFileSync5(path, "utf8"));
3757
+ const parsed = JSON.parse(readFileSync6(path, "utf8"));
3365
3758
  parsed.artifacts = parsed.artifacts.map((artifact) => {
3366
3759
  const { aiPath, ...rest } = artifact;
3367
3760
  return {
@@ -3378,7 +3771,7 @@ function readLocalBundle(outDir) {
3378
3771
  const path = join5(outDir, "bundle.json");
3379
3772
  if (!existsSync5(path)) return null;
3380
3773
  try {
3381
- return parseBundle(readFileSync5(path, "utf8"));
3774
+ return parseBundle(readFileSync6(path, "utf8"));
3382
3775
  } catch {
3383
3776
  throw new Error(`${path} could not be read as a library bundle. Run spec-layer pull again.`);
3384
3777
  }
@@ -3458,7 +3851,12 @@ function writeBundleFiles(opts) {
3458
3851
  if (validateLevel1(artifact).some((d) => d.severity === "error")) {
3459
3852
  throw new Error("The published Foundation context did not pass schema validation. Republish from the plugin, then pull again.");
3460
3853
  }
3461
- const exp = foundationDtcg(artifact, opts.dtcg ?? {});
3854
+ put("fonts.json", json(fontRequirements(artifact)));
3855
+ const exp = foundationDtcg(
3856
+ artifact,
3857
+ opts.dtcg ?? {},
3858
+ usageUnits(opts.bundle)
3859
+ );
3462
3860
  for (const [name, text] of Object.entries(dtcgExportFiles(exp))) put(`tokens/${name}`, text);
3463
3861
  path = `${outDirRel}/tokens/resolver.json`;
3464
3862
  const header = { libraryId: opts.libraryId, contentHash: opts.bundle.foundation.artifact.spec_layer.export.content_hash };
@@ -3490,6 +3888,7 @@ function writeBundleFiles(opts) {
3490
3888
  bundleHash: opts.bundleHash,
3491
3889
  pluginVersion: opts.bundle.pluginVersion,
3492
3890
  extractorVersion: opts.bundle.extractorVersion,
3891
+ cliVersion: cliVersion(),
3493
3892
  selection,
3494
3893
  componentSpecsDir,
3495
3894
  artifacts,
@@ -3513,7 +3912,7 @@ function writeBundleFiles(opts) {
3513
3912
  }
3514
3913
 
3515
3914
  // src/gitignore.ts
3516
- import { readFileSync as readFileSync6, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3915
+ import { readFileSync as readFileSync7, writeFileSync as writeFileSync5, existsSync as existsSync6 } from "node:fs";
3517
3916
  import { spawnSync } from "node:child_process";
3518
3917
  import { join as join6, dirname as dirname2, resolve as resolve4 } from "node:path";
3519
3918
  var COMMENT = "# Spec Layer pull key, not for committing";
@@ -3550,7 +3949,7 @@ function ensureIgnored(cwd, fileName) {
3550
3949
  ${fileName}
3551
3950
  `);
3552
3951
  } else {
3553
- const body = readFileSync6(path, "utf8");
3952
+ const body = readFileSync7(path, "utf8");
3554
3953
  if (!hasEntryLine(body, fileName)) {
3555
3954
  const lead = body.length === 0 || body.endsWith("\n") ? "" : "\n";
3556
3955
  writeFileSync5(path, `${body}${lead}${COMMENT}
@@ -3567,12 +3966,16 @@ ${fileName}
3567
3966
  }
3568
3967
 
3569
3968
  // src/skill.ts
3570
- import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "node:fs";
3969
+ import { existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync8, writeFileSync as writeFileSync6 } from "node:fs";
3571
3970
  import { dirname as dirname3, join as join7 } from "node:path";
3572
3971
 
3573
3972
  // src/tools.ts
3574
3973
  var OK_OR_ERROR = { "0": "success", "1": "usage error, bad key or id, or a network or server failure" };
3575
3974
  var LOCAL_ONLY = { "0": "success", "1": "no local pull, or a usage error" };
3975
+ var PULL_EXITS = {
3976
+ "0": "success, even when tokens/report.json or an outputs/*.report.json holds an error-severity entry (pass --strict to fail on that instead)",
3977
+ "1": "usage error, bad key or id, a network or server failure, or --strict with an error-severity entry in tokens/report.json or an outputs/*.report.json, including on a cached (304) pull"
3978
+ };
3576
3979
  var TOOLS = [
3577
3980
  {
3578
3981
  name: "setup",
@@ -3603,9 +4006,9 @@ var TOOLS = [
3603
4006
  },
3604
4007
  {
3605
4008
  name: "pull",
3606
- usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]...",
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.",
4009
+ usage: "spec-layer pull [--id lib_...] [--key sl_...] [--out DIR] [--platform web|ios|android|flutter]... [--only foundation|components] [--component NAME]... [--strict]",
4010
+ 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. Prints a severity summary to stderr, even on a cached pull, when tokens/report.json or an outputs/*.report.json holds an error or warning.",
4011
+ when: "After setup, whenever status says the local copy is behind, or after changing the include, dtcg, outputs, or componentSpecsDir blocks. Add --strict in CI to fail the build on an error-severity report entry.",
3609
4012
  network: true,
3610
4013
  needsKey: true,
3611
4014
  writes: [
@@ -3613,7 +4016,7 @@ var TOOLS = [
3613
4016
  "outputs[].path from speclayer.json (default tokens/ for web), a directory written in place",
3614
4017
  "componentSpecsDir from speclayer.json (default component-specs/), written in place"
3615
4018
  ],
3616
- exits: OK_OR_ERROR
4019
+ exits: PULL_EXITS
3617
4020
  },
3618
4021
  {
3619
4022
  name: "status",
@@ -3700,25 +4103,43 @@ function toolsJson(version) {
3700
4103
 
3701
4104
  // src/skill.ts
3702
4105
  var RESERVED = /* @__PURE__ */ new Set(["resolver.json", "spec-layer.meta.json", "report.json"]);
3703
- function countNumberTokens(tree) {
3704
- if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return 0;
4106
+ function collectNumberTokenPaths(tree, path, legitimatelyUnitless, out) {
4107
+ if (typeof tree !== "object" || tree === null || Array.isArray(tree)) return;
3705
4108
  const record = tree;
3706
- if (record.$type === "number" && "$value" in record) return 1;
3707
- let n = 0;
4109
+ if (record.$type === "number" && "$value" in record) {
4110
+ const dotted = path.join(".");
4111
+ if (!legitimatelyUnitless.has(dotted)) out.add(dotted);
4112
+ return;
4113
+ }
3708
4114
  for (const [key, value] of Object.entries(record)) {
3709
4115
  if (key.startsWith("$")) continue;
3710
- n += countNumberTokens(value);
4116
+ collectNumberTokenPaths(value, [...path, key], legitimatelyUnitless, out);
3711
4117
  }
3712
- return n;
3713
4118
  }
3714
4119
  function readJson(path) {
3715
4120
  if (!existsSync7(path)) return null;
3716
4121
  try {
3717
- return JSON.parse(readFileSync7(path, "utf8"));
4122
+ return JSON.parse(readFileSync8(path, "utf8"));
3718
4123
  } catch {
3719
4124
  return null;
3720
4125
  }
3721
4126
  }
4127
+ function unitlessScopedPaths(tokensDir) {
4128
+ const meta = readJson(join7(tokensDir, "spec-layer.meta.json"));
4129
+ const out = /* @__PURE__ */ new Set();
4130
+ if (typeof meta !== "object" || meta === null || Array.isArray(meta)) return out;
4131
+ for (const [path, entry2] of Object.entries(meta)) {
4132
+ if (typeof entry2 !== "object" || entry2 === null) continue;
4133
+ const { type, scopes } = entry2;
4134
+ if (type === "number" && Array.isArray(scopes) && scopesStateNumber(scopes)) out.add(path);
4135
+ }
4136
+ return out;
4137
+ }
4138
+ function isFontRequirement(v) {
4139
+ if (typeof v !== "object" || v === null) return false;
4140
+ const r = v;
4141
+ return typeof r.family === "string" && Array.isArray(r.weights) && r.weights.every((w) => typeof w === "number") && Array.isArray(r.used_by) && r.used_by.every((u) => typeof u === "string");
4142
+ }
3722
4143
  function summarizePull(cwd, outDir, manifest) {
3723
4144
  if (!manifest) return null;
3724
4145
  const absOut = join7(cwd, outDir);
@@ -3735,17 +4156,37 @@ function summarizePull(cwd, outDir, manifest) {
3735
4156
  } catch {
3736
4157
  tokenFiles = [];
3737
4158
  }
3738
- let unitlessNumbers = 0;
4159
+ const legitimatelyUnitless = unitlessScopedPaths(tokensDir);
4160
+ const unitlessPaths = /* @__PURE__ */ new Set();
3739
4161
  for (const file of tokenFiles) {
3740
4162
  if (file.startsWith("styles.")) continue;
3741
- unitlessNumbers += countNumberTokens(readJson(join7(tokensDir, file)));
4163
+ collectNumberTokenPaths(readJson(join7(tokensDir, file)), [], legitimatelyUnitless, unitlessPaths);
3742
4164
  }
4165
+ const unitlessNumbers = unitlessPaths.size;
3743
4166
  const reportCounts = {};
3744
4167
  if (Array.isArray(report2)) {
3745
4168
  for (const entry2 of report2) {
3746
4169
  if (typeof entry2?.code === "string") reportCounts[entry2.code] = (reportCounts[entry2.code] ?? 0) + 1;
3747
4170
  }
3748
4171
  }
4172
+ const fontsPath = join7(absOut, "fonts.json");
4173
+ let fontsStatus = "missing";
4174
+ let fonts = [];
4175
+ if (existsSync7(fontsPath)) {
4176
+ let parsed;
4177
+ try {
4178
+ parsed = JSON.parse(readFileSync8(fontsPath, "utf8"));
4179
+ } catch {
4180
+ parsed = void 0;
4181
+ }
4182
+ if (Array.isArray(parsed)) {
4183
+ fontsStatus = "ok";
4184
+ fonts = parsed.filter(isFontRequirement);
4185
+ } else {
4186
+ fontsStatus = "unreadable";
4187
+ }
4188
+ }
4189
+ const missingFontFamilies = fontsStatus === "ok" && fonts.length > 0 ? missingFontSourcesInRepo(fonts.map((f) => f.family), cwd) : [];
3749
4190
  foundation = {
3750
4191
  written: foundationEntry.path !== null && resolver !== null,
3751
4192
  sets: resolver ? Object.keys(resolver.sets ?? {}) : [],
@@ -3756,7 +4197,10 @@ function summarizePull(cwd, outDir, manifest) {
3756
4197
  })) : [],
3757
4198
  tokenFiles,
3758
4199
  unitlessNumbers,
3759
- reportCounts
4200
+ reportCounts,
4201
+ fontsStatus,
4202
+ fonts,
4203
+ missingFontFamilies
3760
4204
  };
3761
4205
  }
3762
4206
  return {
@@ -3820,12 +4264,49 @@ function stackSection(input) {
3820
4264
  const label = platformSource === "flag" ? "chosen with --platform" : platformSource === "config" ? "set in speclayer.json" : "detected";
3821
4265
  lines.push(`Target platform${platforms.length > 1 ? "s" : ""} (${label}): ${platforms.join(", ")}.`, "");
3822
4266
  }
4267
+ const tokensDir = `${input.outDir}/tokens/`;
4268
+ if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
4269
+ const n = pull.foundation.unitlessNumbers;
4270
+ const cssReport = pull.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
4271
+ lines.push(
4272
+ `**${n} token${n === 1 ? " has" : "s have"} no unit.** ${n === 1 ? "Its own Figma variable states" : "Their own Figma variables state"} none at all, not even that ${n === 1 ? "it is" : "they are"} a unitless number the way an opacity or a font weight would. Those are already excluded from this count, because Figma states that for them. So ${n === 1 ? "it is" : "they are"} written as ${code('$type: "number"')}: a bare number, not usable as a CSS length. ${code("height: 36")} is invalid CSS and the browser drops the declaration. ${n === 1 ? "Narrow the variable's" : "Narrow each variable's"} scope in Figma to a length (${code("CORNER_RADIUS")}, ${code("GAP")}, ${code("WIDTH_HEIGHT")}, ${code("FONT_SIZE")}, or ${code("STROKE_FLOAT")}), or to ${code("OPACITY")}/${code("FONT_WEIGHT")} if it genuinely carries none, and pull again. Only add ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')} in ${code("speclayer.json")} once you already know the token is a length: the override applies to anything the glob matches that Figma has not already scoped as unitless, so a glob that is too broad can turn a genuine opacity or font weight into a fake length. Nothing is inferred from a name; ${code(`${tokensDir}spec-layer.meta.json`)} names each token's own Figma scopes` + (cssReport ? `, and ${code(`${input.outDir}/outputs/${cssReport.platform}-${cssReport.format}.report.json`)} names every one under ${code("unitless_number")}. Do not read that file's entry count as this number: it carries one entry per mode a token appears in, and it also names the tokens Figma does scope as a unitless number, which this count leaves out.` : "."),
4273
+ ""
4274
+ );
4275
+ }
3823
4276
  for (const platform of platforms) {
3824
4277
  const key = CODE_SYNTAX_KEY[platform];
3825
- const tokensDir = `${input.outDir}/tokens/`;
3826
4278
  if (platform === "web") {
3827
4279
  lines.push("### Web", "");
3828
4280
  const cssOut = pull?.outputs.find((o) => o.platform === "web" && o.format === "css" && o.written) ?? null;
4281
+ if (pull?.foundation?.written) {
4282
+ const { fontsStatus, fonts, missingFontFamilies } = pull.foundation;
4283
+ if (fontsStatus === "ok" && fonts.length > 0) {
4284
+ const fontList = fonts.map((f) => `${f.family} at ${f.weights.join(", ")}`).join("; ");
4285
+ lines.push(
4286
+ `**Fonts.** This library's type is ${fontList}. Load every weight listed; a missing weight renders as a synthesised bold that matches nothing in the design.`
4287
+ );
4288
+ for (const family of missingFontFamilies) {
4289
+ lines.push(
4290
+ `${family}: nothing in this repository loads it. Add a font source before building UI, or every component that uses it renders in the browser default.`
4291
+ );
4292
+ }
4293
+ const fallbackTarget = cssOut ? `${cssOut.path}/` : `${input.outDir}/`;
4294
+ lines.push(
4295
+ `The token holds a family name and no fallback stack. Never write ${code("font-family")} from a token without appending a generic fallback, and never keep that fallback inside ${code(fallbackTarget)}: the next pull replaces it.`,
4296
+ ""
4297
+ );
4298
+ } else if (fontsStatus === "ok") {
4299
+ lines.push(
4300
+ `${code("fonts.json")} names no font family. Either this library's typography styles reference none, or a reference failed to resolve one; ${code("npx spec-layer show foundation")} prints the styles themselves.`,
4301
+ ""
4302
+ );
4303
+ } else {
4304
+ lines.push(
4305
+ fontsStatus === "missing" ? `${code("fonts.json")} is missing, so the font requirement for this library is unknown here. Run ${code("npx spec-layer pull")} again: every pull that includes the Foundation writes this file, and a pull that finds it gone re-projects instead of reporting no change.` : `${code("fonts.json")} is not valid JSON, so the font requirement for this library is unknown here. Delete ${code(`${input.outDir}/fonts.json`)} and run ${code("npx spec-layer pull")} again, which rewrites it.`,
4306
+ ""
4307
+ );
4308
+ }
4309
+ }
3829
4310
  if (cssOut) {
3830
4311
  const mapPath = `${input.outDir}/outputs/web-css.map.json`;
3831
4312
  lines.push(
@@ -3905,13 +4386,6 @@ function stackSection(input) {
3905
4386
  );
3906
4387
  }
3907
4388
  }
3908
- if (pull?.foundation && pull.foundation.unitlessNumbers > 0) {
3909
- const n = pull.foundation.unitlessNumbers;
3910
- lines.push(
3911
- `${n} token${n === 1 ? " is" : "s are"} exported as ${code('$type: "number"')} because the Figma scopes state no unit. If your code needs them as px or rem, declare it in ${code("speclayer.json")}: ${code('"dtcg": { "units": { "<Collection>/<name glob>": "px" } }')}, then run ${code("spec-layer pull")}. Nothing is inferred from a name; an override that contradicts a stated scope is ignored and listed in ${code("report.json")}.`,
3912
- ""
3913
- );
3914
- }
3915
4389
  return lines;
3916
4390
  }
3917
4391
  function pullSection(input) {
@@ -4090,7 +4564,7 @@ ${BLOCK_END}
4090
4564
  function installSkill(cwd, host, guide) {
4091
4565
  const target = installTarget(host);
4092
4566
  const abs = join7(cwd, target.path);
4093
- const existing = existsSync7(abs) ? readFileSync7(abs, "utf8") : null;
4567
+ const existing = existsSync7(abs) ? readFileSync8(abs, "utf8") : null;
4094
4568
  const next = target.mode === "file" ? renderForHost(host, guide) : upsertBlock(existing, renderForHost(host, guide));
4095
4569
  if (existing === next) return { path: target.path, result: "unchanged" };
4096
4570
  mkdirSync3(dirname3(abs), { recursive: true });
@@ -4098,17 +4572,6 @@ function installSkill(cwd, host, guide) {
4098
4572
  return { path: target.path, result: existing === null ? "created" : "updated" };
4099
4573
  }
4100
4574
 
4101
- // src/version.ts
4102
- import { readFileSync as readFileSync8 } from "node:fs";
4103
- function cliVersion() {
4104
- try {
4105
- const parsed = JSON.parse(readFileSync8(new URL("../package.json", import.meta.url), "utf8"));
4106
- return typeof parsed.version === "string" ? parsed.version : "unknown";
4107
- } catch {
4108
- return "unknown";
4109
- }
4110
- }
4111
-
4112
4575
  // src/commands.ts
4113
4576
  var NO_LOCAL_PULL = "No local pull found. Run spec-layer pull.";
4114
4577
  function manifestReader() {
@@ -4315,11 +4778,59 @@ git rm --cached ${ignored.line}`);
4315
4778
  return 0;
4316
4779
  }
4317
4780
  function outputFilesOnDisk(cwd, outDir, o) {
4318
- const mapPath = join8(cwd, outDir, "outputs", `${outputId(o)}.map.json`);
4319
- if (!existsSync8(mapPath)) return false;
4781
+ const id = outputId(o);
4782
+ for (const rel of [`${id}.map.json`, `${id}.report.json`]) {
4783
+ if (!existsSync8(join8(cwd, outDir, "outputs", rel))) return false;
4784
+ }
4320
4785
  const imports = readIndexImports(cwd, o);
4321
4786
  return imports !== null && imports.every((f) => existsSync8(resolve5(cwd, o.path, f)));
4322
4787
  }
4788
+ function foundationFilesOnDisk(cwd, outDir) {
4789
+ return ["fonts.json", join8("tokens", "report.json"), join8("tokens", "resolver.json")].every((rel) => existsSync8(join8(cwd, outDir, rel)));
4790
+ }
4791
+ function readJsonArray(path) {
4792
+ if (!existsSync8(path)) return [];
4793
+ try {
4794
+ const parsed = JSON.parse(readFileSync9(path, "utf8"));
4795
+ return Array.isArray(parsed) ? parsed : [];
4796
+ } catch {
4797
+ return [];
4798
+ }
4799
+ }
4800
+ function readReportSeverities(path) {
4801
+ return readJsonArray(path).map((entry2) => entry2.severity).filter((s) => s === "error" || s === "warning" || s === "info");
4802
+ }
4803
+ function readFontFamilies(cwd, outDir) {
4804
+ return readJsonArray(join8(cwd, outDir, "fonts.json")).map((entry2) => entry2.family).filter((f) => typeof f === "string");
4805
+ }
4806
+ function plural(n, word) {
4807
+ return `${n} ${word}${n === 1 ? "" : "s"}`;
4808
+ }
4809
+ function printReportSummary(cwd, outDir, outputs, io2) {
4810
+ const reportPaths = [];
4811
+ let errors = 0;
4812
+ let warnings = 0;
4813
+ const add = (severities, path) => {
4814
+ if (severities.length === 0) return;
4815
+ errors += severities.filter((s) => s === "error").length;
4816
+ warnings += severities.filter((s) => s === "warning").length;
4817
+ reportPaths.push(path);
4818
+ };
4819
+ add(readReportSeverities(join8(cwd, outDir, "tokens", "report.json")), `${outDir}/tokens/report.json`);
4820
+ for (const o of outputs) {
4821
+ add(readReportSeverities(join8(cwd, outDir, "outputs", `${outputId(o)}.report.json`)), `${outDir}/outputs/${outputId(o)}.report.json`);
4822
+ }
4823
+ if (errors > 0 || warnings > 0) {
4824
+ io2.err(`${plural(errors, "error")}, ${plural(warnings, "warning")} in the token output. See ${reportPaths.join(", ")}.`);
4825
+ }
4826
+ const fontFamilies = readFontFamilies(cwd, outDir);
4827
+ if (fontFamilies.length > 0) {
4828
+ for (const family of missingFontSourcesInRepo(fontFamilies, cwd)) {
4829
+ io2.err(`This library needs ${family}, and nothing in this repository loads it. See fonts.json.`);
4830
+ }
4831
+ }
4832
+ return errors;
4833
+ }
4323
4834
  async function runPull(cwd, flags, env, io2, fetcher) {
4324
4835
  const manifestAt = manifestReader();
4325
4836
  const opts = resolved(cwd, flags, env, io2, manifestAt);
@@ -4339,10 +4850,10 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4339
4850
  const foundationOnDisk = Boolean(manifest?.artifacts.find((a) => a.kind === "foundation")?.path);
4340
4851
  const willWriteFoundation = selection.foundation && foundationOnDisk;
4341
4852
  const briefsOnDisk = (manifest?.artifacts ?? []).filter((a) => a.kind === "component" && a.path !== null).every((a) => existsSync8(resolve5(cwd, a.path)));
4342
- const etag = manifest && sameOutput(
4853
+ const etag = manifest && manifest.cliVersion === cliVersion() && sameOutput(
4343
4854
  { selection: manifest.selection ?? DEFAULT_SELECTION, dtcg: manifest.dtcg, outputs: manifest.outputs, componentSpecsDir: manifest.componentSpecsDir },
4344
4855
  { selection, dtcg: opts.dtcg, outputs, componentSpecsDir: opts.componentSpecsDir }
4345
- ) && briefsOnDisk && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
4856
+ ) && briefsOnDisk && (!willWriteFoundation || foundationFilesOnDisk(cwd, opts.outDir)) && (!willWriteFoundation || outputs.every((o) => outputFilesOnDisk(cwd, opts.outDir, o))) ? manifest.bundleHash : void 0;
4346
4857
  const result = await fetchBundle({
4347
4858
  api: opts.api,
4348
4859
  libraryId: opts.libraryId,
@@ -4356,6 +4867,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4356
4867
  }
4357
4868
  if (result.kind === "not_modified") {
4358
4869
  io2.out(`Already up to date (published ${manifest?.publishedAt ?? "unknown"}).`);
4870
+ const cachedErrors = printReportSummary(cwd, opts.outDir, outputs, io2);
4871
+ if (flags.strict && cachedErrors > 0) return 1;
4359
4872
  return 0;
4360
4873
  }
4361
4874
  let written;
@@ -4410,6 +4923,8 @@ async function runPull(cwd, flags, env, io2, fetcher) {
4410
4923
  const missing = platformsMissingFormat(platforms);
4411
4924
  if (missing.length > 0) io2.out(missingFormatNote(missing));
4412
4925
  }
4926
+ const errors = printReportSummary(cwd, opts.outDir, outputs, io2);
4927
+ if (flags.strict && errors > 0) return 1;
4413
4928
  return 0;
4414
4929
  }
4415
4930
  async function runStatus(cwd, flags, env, io2, fetcher) {
@@ -4589,8 +5104,9 @@ Commands:
4589
5104
  store the key, then pull
4590
5105
  init --id lib_... [--out DIR] [selection] [--platform P]...
4591
5106
  write speclayer.json
4592
- pull [--id lib_...] [--key sl_...] [selection] [--platform P]...
4593
- fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/
5107
+ pull [--id lib_...] [--key sl_...] [selection] [--platform P]... [--strict]
5108
+ fetch the library into DIR (default .speclayer); the foundation lands as DTCG under DIR/tokens/;
5109
+ --strict exits 1 when tokens/report.json or an outputs/*.report.json holds an error-severity entry, even on a cached pull (default exit stays 0)
4594
5110
  status [--id lib_...] [--key sl_...] check freshness; exits 2 when behind
4595
5111
  list list every artifact in the last pull
4596
5112
  show foundation | component NAME [--canonical]
@@ -4631,6 +5147,7 @@ async function main() {
4631
5147
  canonical: { type: "boolean" },
4632
5148
  json: { type: "boolean" },
4633
5149
  install: { type: "boolean" },
5150
+ strict: { type: "boolean" },
4634
5151
  agent: { type: "string", multiple: true },
4635
5152
  platform: { type: "string", multiple: true }
4636
5153
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "spec-layer",
3
- "version": "0.8.2",
3
+ "version": "0.9.0",
4
4
  "description": "Pull design-system context published by the Spec Layer Figma plugin",
5
5
  "license": "MIT",
6
6
  "type": "module",