@calibrate-ds/core 0.1.88 → 0.1.90

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.
@@ -0,0 +1,50 @@
1
+ import type { ComponentDefinition } from "@calibrate-ds/shared-types";
2
+ /**
3
+ * B-3: a compact, pre-resolved per-variant token/style map.
4
+ *
5
+ * The full per-variant data lives in `variantDiffs` (default-relative deltas) and
6
+ * `variantSnapshots` (absolute per-variant state) — both large and awkward to parse,
7
+ * which forced agents to script their way through a 300 KB+ JSON to reconstruct a
8
+ * per-variant token matrix. This projects `variantSnapshots` down to exactly what an
9
+ * agent needs to implement each variant: for every variant selection, the resolved
10
+ * token bindings (canonical `--ptb-*` cssVar + resolved value) and spacing for the
11
+ * nodes that carry them. No render tree, no diffs — just the build matrix.
12
+ */
13
+ /** One resolved style slot for a node within a variant. */
14
+ export type VariantTokenSlot = {
15
+ /** Node path within the component, e.g. "root" or "root/label". */
16
+ node: string;
17
+ /** The CSS-facing property this slot drives. */
18
+ property: "background" | "text-color" | "border-color" | "border-radius" | "padding" | "gap";
19
+ /** Canonical --ptb-* variable to emit (the design-system binding). Absent when unbound. */
20
+ cssVar?: string;
21
+ /** Resolved concrete value (hex or px) — use as the literal / var() fallback. */
22
+ value: string;
23
+ /** Human-readable token name when known (e.g. "surface/primary/light"). */
24
+ token?: string;
25
+ /** Stroke weight in px (border-color slots only). */
26
+ weight?: number;
27
+ /** Stroke alignment: inside | outside | center (border-color slots only). */
28
+ align?: string;
29
+ };
30
+ export type VariantTokenEntry = {
31
+ selection: Record<string, string>;
32
+ /** Visible text content per node, when the variant sets any (e.g. label text). */
33
+ text?: Record<string, string>;
34
+ slots: VariantTokenSlot[];
35
+ };
36
+ export type VariantTokenMap = {
37
+ component: string;
38
+ axes: string[];
39
+ variantCount: number;
40
+ variants: VariantTokenEntry[];
41
+ /** Present (with an explanation) when the component has no per-variant snapshot data. */
42
+ note?: string;
43
+ };
44
+ /**
45
+ * Builds the compact per-variant token/style map for a component. Returns an empty
46
+ * map with a `note` when the component has no variant snapshots (single components,
47
+ * or old-format snapshots predating variantSnapshots).
48
+ */
49
+ export declare function buildVariantTokenMap(component: ComponentDefinition): VariantTokenMap;
50
+ //# sourceMappingURL=variant-token-map.d.ts.map
@@ -0,0 +1,97 @@
1
+ function pxOrNum(n) {
2
+ return `${Math.round(n * 1000) / 1000}px`;
3
+ }
4
+ /** Extract the resolved token/style slots from one variant node's style. */
5
+ function slotsForNode(nodePath, style) {
6
+ const slots = [];
7
+ const isText = style.type === "TEXT";
8
+ const tokens = (style.tokens ?? {});
9
+ // Fill → background (containers) or text-color (TEXT nodes). textFill is the TEXT variant.
10
+ const fill = tokens.textFill ?? tokens.fill;
11
+ if (fill && typeof fill.color === "string") {
12
+ slots.push({
13
+ node: nodePath,
14
+ property: isText || tokens.textFill ? "text-color" : "background",
15
+ value: fill.color,
16
+ ...(fill.cssVar ? { cssVar: fill.cssVar } : {}),
17
+ ...(fill.token ? { token: fill.token } : {}),
18
+ });
19
+ }
20
+ // Stroke → border-color (carries weight + align — the strokeAlign inside/outside signal).
21
+ const stroke = tokens.stroke;
22
+ if (stroke && typeof stroke.color === "string") {
23
+ slots.push({
24
+ node: nodePath,
25
+ property: "border-color",
26
+ value: stroke.color,
27
+ ...(stroke.cssVar ? { cssVar: stroke.cssVar } : {}),
28
+ ...(stroke.token ? { token: stroke.token } : {}),
29
+ ...(typeof stroke.weight === "number" ? { weight: stroke.weight } : {}),
30
+ ...(stroke.align ? { align: stroke.align } : {}),
31
+ });
32
+ }
33
+ // Radius → border-radius (uniform value + optional binding).
34
+ if (style.radius && typeof style.radius.uniform === "number") {
35
+ slots.push({
36
+ node: nodePath,
37
+ property: "border-radius",
38
+ value: pxOrNum(style.radius.uniform),
39
+ ...(style.radius.cssVar ? { cssVar: style.radius.cssVar } : {}),
40
+ ...(style.radius.token ? { token: style.radius.token } : {}),
41
+ });
42
+ }
43
+ // Layout spacing (raw — Figma stores gap/padding as numbers, not bound vars here).
44
+ if (style.layout) {
45
+ if (typeof style.layout.gap === "number" && style.layout.gap > 0) {
46
+ slots.push({ node: nodePath, property: "gap", value: pxOrNum(style.layout.gap) });
47
+ }
48
+ const p = style.layout.padding;
49
+ if (p && (p.top || p.right || p.bottom || p.left)) {
50
+ const value = (p.top === p.bottom && p.left === p.right)
51
+ ? (p.top === p.left ? pxOrNum(p.top) : `${pxOrNum(p.top)} ${pxOrNum(p.right)}`)
52
+ : `${pxOrNum(p.top)} ${pxOrNum(p.right)} ${pxOrNum(p.bottom)} ${pxOrNum(p.left)}`;
53
+ slots.push({ node: nodePath, property: "padding", value });
54
+ }
55
+ }
56
+ return slots;
57
+ }
58
+ function entryForVariant(snapshot) {
59
+ const slots = [];
60
+ const text = {};
61
+ for (const [nodePath, style] of Object.entries(snapshot.nodes ?? {})) {
62
+ slots.push(...slotsForNode(nodePath, style));
63
+ if (typeof style.textContent === "string" && style.textContent.trim()) {
64
+ text[nodePath] = style.textContent;
65
+ }
66
+ }
67
+ return {
68
+ selection: snapshot.variantSelection ?? {},
69
+ ...(Object.keys(text).length > 0 ? { text } : {}),
70
+ slots,
71
+ };
72
+ }
73
+ /**
74
+ * Builds the compact per-variant token/style map for a component. Returns an empty
75
+ * map with a `note` when the component has no variant snapshots (single components,
76
+ * or old-format snapshots predating variantSnapshots).
77
+ */
78
+ export function buildVariantTokenMap(component) {
79
+ const axes = (component.variantAxes ?? []).map(a => a.name);
80
+ const snapshots = component.variantSnapshots ?? [];
81
+ if (snapshots.length === 0) {
82
+ return {
83
+ component: component.name,
84
+ axes,
85
+ variantCount: 0,
86
+ variants: [],
87
+ note: "No per-variant snapshots for this component (not a multi-variant component set, or scanned before variant snapshots were captured — re-run 'ptb scan').",
88
+ };
89
+ }
90
+ return {
91
+ component: component.name,
92
+ axes,
93
+ variantCount: snapshots.length,
94
+ variants: snapshots.map(entryForVariant),
95
+ };
96
+ }
97
+ //# sourceMappingURL=variant-token-map.js.map
@@ -15,6 +15,19 @@ export declare function detectPackageManager(dir: string): Promise<PackageManage
15
15
  * Walks up the directory tree so monorepos with hoisted node_modules are handled correctly. */
16
16
  export declare function checkStorybookReadiness(packageRoot: string): Promise<StorybookReadiness>;
17
17
  export declare function detectBundler(projectRoot: string): Promise<"vite" | "webpack">;
18
+ /**
19
+ * B-4: Storybook never loaded the design's fonts, so text rendered in a fallback
20
+ * (e.g. "Inter" → whatever the browser had), measuring a different width than the
21
+ * design and capping every text component's pixel-match score regardless of
22
+ * correctness. We generate a preview-head.html that loads the fonts the design
23
+ * actually uses (extracted from the generated typography.css) from Google Fonts —
24
+ * the standard, self-contained way to make a Storybook preview font-accurate.
25
+ * Non-Google fonts degrade gracefully (the link no-ops; same as before).
26
+ */
27
+ /** Extracts primary font-family names from a generated typography.css / tokens.css string. */
28
+ export declare function extractFontFamilies(css: string): string[];
29
+ /** Builds a Google Fonts <link> preview-head.html for the given family names. */
30
+ export declare function previewHead(fontFamilies: string[]): string;
18
31
  /**
19
32
  * Injects a tokens.css import at the top of an existing .storybook/preview.ts|js
20
33
  * without overwriting the rest of the file. No-ops if the import is already present.
@@ -137,6 +137,47 @@ const config: StorybookConfig = {
137
137
  export default config;
138
138
  `;
139
139
  }
140
+ /**
141
+ * B-4: Storybook never loaded the design's fonts, so text rendered in a fallback
142
+ * (e.g. "Inter" → whatever the browser had), measuring a different width than the
143
+ * design and capping every text component's pixel-match score regardless of
144
+ * correctness. We generate a preview-head.html that loads the fonts the design
145
+ * actually uses (extracted from the generated typography.css) from Google Fonts —
146
+ * the standard, self-contained way to make a Storybook preview font-accurate.
147
+ * Non-Google fonts degrade gracefully (the link no-ops; same as before).
148
+ */
149
+ /** Extracts primary font-family names from a generated typography.css / tokens.css string. */
150
+ export function extractFontFamilies(css) {
151
+ const families = new Set();
152
+ const re = /font-family:\s*([^;]+);/gi;
153
+ let m;
154
+ while ((m = re.exec(css)) !== null) {
155
+ // The primary family is the first entry; strip quotes/whitespace.
156
+ const primary = m[1].split(",")[0].trim().replace(/^["']|["']$/g, "");
157
+ // Skip CSS keywords and generic families — they need no @font-face.
158
+ const generic = ["inherit", "initial", "unset", "serif", "sans-serif", "monospace", "cursive", "fantasy", "system-ui", "-apple-system", "ui-monospace", "ui-sans-serif", "ui-serif"];
159
+ if (primary && !generic.includes(primary.toLowerCase()))
160
+ families.add(primary);
161
+ }
162
+ return [...families];
163
+ }
164
+ /** Builds a Google Fonts <link> preview-head.html for the given family names. */
165
+ export function previewHead(fontFamilies) {
166
+ if (fontFamilies.length === 0)
167
+ return "";
168
+ const familyParams = fontFamilies
169
+ .map(f => `family=${encodeURIComponent(f).replace(/%20/g, "+")}:wght@300;400;500;600;700`)
170
+ .join("&");
171
+ return `<!--
172
+ Auto-generated by Calibrate so Storybook renders your design system's fonts,
173
+ making 'ptb verify' pixel scores accurate. If a font is self-hosted or not on
174
+ Google Fonts, replace this link with your own @font-face / <link>.
175
+ -->
176
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
177
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
178
+ <link href="https://fonts.googleapis.com/css2?${familyParams}&display=swap" rel="stylesheet" />
179
+ `;
180
+ }
140
181
  function previewTs(tokensRelPath) {
141
182
  const tokenImport = tokensRelPath ? `import "${tokensRelPath}";\n` : "";
142
183
  return `import type { Preview } from "@storybook/react";
@@ -242,9 +283,22 @@ tokensDir) {
242
283
  tokensRelPath = rel.startsWith(".") ? rel : `./${rel}`;
243
284
  }
244
285
  }
286
+ // B-4: derive the design's fonts from the generated typography.css so the
287
+ // Storybook preview loads them (accurate pixel-diff). Best-effort — if the CSS
288
+ // isn't present yet, we simply skip preview-head.html.
289
+ let fontFamilies = [];
290
+ if (tokensDir) {
291
+ for (const cssName of ["typography.css", "tokens.css"]) {
292
+ const css = await fs.readFile(path.join(tokensDir, cssName), "utf-8").catch(() => "");
293
+ if (css)
294
+ fontFamilies.push(...extractFontFamilies(css));
295
+ }
296
+ fontFamilies = [...new Set(fontFamilies)];
297
+ }
245
298
  const files = [
246
299
  { name: "main.ts", content: mainTs(bundler, storiesGlob, frameworkPkg) },
247
300
  { name: "preview.ts", content: previewTs(tokensRelPath) },
301
+ ...(fontFamilies.length > 0 ? [{ name: "preview-head.html", content: previewHead(fontFamilies) }] : []),
248
302
  ];
249
303
  const created = [];
250
304
  const skipped = [];
package/dist/index.d.ts CHANGED
@@ -19,6 +19,7 @@ export * from "./tokens/index.js";
19
19
  export * from "./plan/index.js";
20
20
  export * from "./manifest/index.js";
21
21
  export * from "./context/get-component-context.js";
22
+ export * from "./context/variant-token-map.js";
22
23
  export * from "./context/hashing.js";
23
24
  export * from "./context/metadata-extraction.js";
24
25
  export * from "./context/export-context.js";
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ export * from "./tokens/index.js";
19
19
  export * from "./plan/index.js";
20
20
  export * from "./manifest/index.js";
21
21
  export * from "./context/get-component-context.js";
22
+ export * from "./context/variant-token-map.js";
22
23
  export * from "./context/hashing.js";
23
24
  export * from "./context/metadata-extraction.js";
24
25
  export * from "./context/export-context.js";
@@ -89,8 +89,18 @@ export type VerifyReport = {
89
89
  * components — it catches implementations that render wrong/placeholder content,
90
90
  * and is immune to font rendering and pixel-alignment differences.
91
91
  * Undefined when no design text tokens are found (e.g. icons, purely visual).
92
+ *
93
+ * NOTE: despite the name, this measures rendered TEXT content, NOT design-token
94
+ * (CSS variable) binding — that is `cssVarScore`. Prefer `textContentScore`, which
95
+ * carries the same value under the accurate name; `tokenScore` is kept as a
96
+ * deprecated alias so existing consumers do not break.
92
97
  */
93
98
  tokenScore?: number;
99
+ /**
100
+ * The correctly-named alias of `tokenScore`: the fraction of the design's TEXT
101
+ * content rendered by the implementation. Use this name going forward.
102
+ */
103
+ textContentScore?: number;
94
104
  /** Number of unique design text tokens extracted from the renderTree. */
95
105
  designTokenCount?: number;
96
106
  /** Number of design text tokens found in the rendered DOM. */
@@ -29,7 +29,7 @@ export type VerifyVerdictResult = {
29
29
  /** The score the verdict was computed from (0–1), or null when nothing comparable existed. */
30
30
  score: number | null;
31
31
  /** Which metric drove the verdict. */
32
- metric: "token" | "structure" | "pixel-content" | "pixel" | "none";
32
+ metric: "token" | "structure" | "pixel-content" | "pixel" | "css-var" | "none";
33
33
  };
34
34
  /**
35
35
  * Computes a single verdict from a VerifyReport.
@@ -49,5 +49,5 @@ export type VerifyVerdictResult = {
49
49
  * false-pass vector with no corroborating evidence behind it. Pixel rescue never
50
50
  * downgrades a result the old chain passed (if token was highest, it still wins).
51
51
  */
52
- export declare function computeVerifyVerdict(report: Pick<VerifyReport, "tokenScore" | "structureScore" | "pixelMatch" | "variantMeanScore">): VerifyVerdictResult;
52
+ export declare function computeVerifyVerdict(report: Pick<VerifyReport, "tokenScore" | "structureScore" | "pixelMatch" | "variantMeanScore" | "cssVarScore">): VerifyVerdictResult;
53
53
  //# sourceMappingURL=verdict.d.ts.map
@@ -41,6 +41,17 @@ export const CONTENT_COVERAGE_FLOOR = 0.35;
41
41
  * downgrades a result the old chain passed (if token was highest, it still wins).
42
42
  */
43
43
  export function computeVerifyVerdict(report) {
44
+ // B-1 (design-system binding gate): cssVarScore is the fraction of the design's
45
+ // bound --ptb-* variables actually present in the generated CSS. It is defined
46
+ // ONLY when the design has bound variables for this component. A component that
47
+ // should carry the design system's tokens but carries NONE (cssVarScore === 0) is
48
+ // token-blind — visually it may be perfect, but it is disconnected from the design
49
+ // system, which is the one property PTB uniquely guarantees. This is a hard fail
50
+ // that text/pixel evidence cannot rescue: tokenScore only measures rendered TEXT
51
+ // content, not token identity, so it must never pass a token-blind component.
52
+ if (report.cssVarScore === 0) {
53
+ return { verdict: "fail", score: 0, metric: "css-var" };
54
+ }
44
55
  let primary = null;
45
56
  if (report.tokenScore !== undefined) {
46
57
  primary = { score: report.tokenScore, metric: "token" };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@calibrate-ds/core",
3
- "version": "0.1.88",
3
+ "version": "0.1.90",
4
4
  "license": "FSL-1.1-MIT",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,8 +42,8 @@
42
42
  "pixelmatch": "^6.0.0",
43
43
  "pngjs": "^7.0.0",
44
44
  "zod": "^4.3.6",
45
- "@calibrate-ds/shared-types": "^0.1.88",
46
- "@calibrate-ds/figma-client": "^0.1.88"
45
+ "@calibrate-ds/shared-types": "^0.1.90",
46
+ "@calibrate-ds/figma-client": "^0.1.90"
47
47
  },
48
48
  "scripts": {
49
49
  "build": "tsc -p tsconfig.json",