@tangle-network/brand 1.10.3 → 1.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/README.md +4 -1
- package/bin/drift-baseline.json +1395 -0
- package/bin/tangle-drift.mjs +456 -0
- package/package.json +9 -3
- package/src/styles/focus.test.ts +2 -2
- package/src/styles/ladders.css +144 -0
- package/src/styles/legacy-light.css +733 -29
- package/src/styles/named-themes.css +336 -6
- package/src/styles/named-themes.test.ts +374 -5
- package/src/styles/system.css +0 -18
- package/src/styles/tokens.css +229 -18
- package/src/styles/tokens.test.ts +41 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* tangle-drift: a ratchet on design-system drift across Tangle product surfaces.
|
|
4
|
+
*
|
|
5
|
+
* It counts raw Tailwind palette classes, hex literals, arbitrary color values,
|
|
6
|
+
* CSS custom-property definitions and locally defined primitives in a surface's
|
|
7
|
+
* source, reading a git tree with `git grep` (no checkout of the tree needed).
|
|
8
|
+
* The counts committed in drift-baseline.json beside this file may only fall.
|
|
9
|
+
*
|
|
10
|
+
* tangle-drift check --surface gtm --repo-dir . # consumer gate, reads HEAD
|
|
11
|
+
* tangle-drift check --surface gtm --repo-dir . --worktree # include uncommitted files
|
|
12
|
+
* tangle-drift scan --remote # every surface, default branches
|
|
13
|
+
* tangle-drift check --all --remote --update-baseline # lower the baseline
|
|
14
|
+
*
|
|
15
|
+
* Port of company wiki/evidence/ui-unification-2026-10-05/drift.py; the patterns
|
|
16
|
+
* and pathspecs are kept identical so counts stay comparable with that baseline.
|
|
17
|
+
*/
|
|
18
|
+
import { execFileSync } from "node:child_process";
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { join } from "node:path";
|
|
22
|
+
import { fileURLToPath } from "node:url";
|
|
23
|
+
|
|
24
|
+
export const DEFAULT_BASELINE = fileURLToPath(new URL("./drift-baseline.json", import.meta.url));
|
|
25
|
+
|
|
26
|
+
/** surface → GitHub repository and web roots. The default branch is resolved from the remote. */
|
|
27
|
+
export const SURFACES = {
|
|
28
|
+
website: { repo: "tangle-network/tangle-website", roots: [""] },
|
|
29
|
+
sandbox: { repo: "tangle-network/agent-dev-container", roots: ["products/sandbox/web"] },
|
|
30
|
+
platform: { repo: "tangle-network/agent-dev-container", roots: ["products/platform/web"] },
|
|
31
|
+
intelligence: { repo: "tangle-network/agent-dev-container", roots: ["products/intelligence/web"] },
|
|
32
|
+
gtm: { repo: "tangle-network/gtm-agent", roots: [""] },
|
|
33
|
+
tax: { repo: "tangle-network/tax-agent", roots: ["apps/web"] },
|
|
34
|
+
legal: { repo: "tangle-network/legal-agent", roots: [""] },
|
|
35
|
+
creative: { repo: "tangle-network/creative-agent", roots: [""] },
|
|
36
|
+
physim: { repo: "tangle-network/physim", roots: ["apps/web"] },
|
|
37
|
+
hospitality: { repo: "tangle-network/hospitality-agent", roots: [""] },
|
|
38
|
+
audits: { repo: "tangle-network/redteam", roots: ["packages/website"] },
|
|
39
|
+
browser: { repo: "tangle-network/bad-app", roots: [""] },
|
|
40
|
+
builder: { repo: "tangle-network/agent-builder", roots: [""] },
|
|
41
|
+
blueprint: { repo: "tangle-network/blueprint-agent", roots: ["apps/web"] },
|
|
42
|
+
// SUPER's interface is plain JavaScript and HTML, so its colors live in those files.
|
|
43
|
+
super: { repo: "tangle-network/super-agent", roots: ["public"], extraExt: ["*.js", "*.html"] },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const SRC_EXT = ["*.tsx", "*.ts", "*.jsx", "*.astro", "*.css", "*.vue", "*.svelte"];
|
|
47
|
+
const EXCLUDE = [":!*node_modules*", ":!*dist/*", ":!*.test.*", ":!*.spec.*", ":!*stories*", ":!*__tests__*",
|
|
48
|
+
":!*tests/*", ":!*e2e/*", ":!*fixtures*", ":!*.generated.*", ":!*proof*", ":!*evidence*", ":!*.d.ts"];
|
|
49
|
+
const PRIMS = "Button|IconButton|Badge|Card|Chip|Tag|Pill|Avatar|Dialog|Modal|Sheet|Drawer|Tooltip|Popover|DropdownMenu|Menu|Input|Textarea|Select|Combobox|Checkbox|Switch|Toggle|Tabs|Skeleton|Spinner|EmptyState|Callout|Alert|Toast|Table|Progress|Separator|Label|Field|StatusBadge|StatusDot|Kbd";
|
|
50
|
+
|
|
51
|
+
/** PCRE patterns, passed to `git grep -P`. */
|
|
52
|
+
export const PATTERNS = {
|
|
53
|
+
raw_palette: String.raw`\b(?:bg|text|border|ring|fill|stroke|from|to|via|outline|divide|decoration|accent|caret|shadow)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-[0-9]{2,3}\b`,
|
|
54
|
+
hex: String.raw`#[0-9a-fA-F]{6}\b|#[0-9a-fA-F]{3}\b`,
|
|
55
|
+
arbitrary_color: String.raw`-\[(?:#|rgb|hsl|oklch)`,
|
|
56
|
+
css_var_defs: String.raw`^\s*--[a-zA-Z0-9-]+\s*:`,
|
|
57
|
+
local_primitive_count: String.raw`export\s+(?:default\s+)?(?:function|const)\s+(?:${PRIMS})\b`,
|
|
58
|
+
};
|
|
59
|
+
const LOCAL_PRIM_NAME = new RegExp(String.raw`(?:function|const)\s+(${PRIMS})\b`);
|
|
60
|
+
const SHARED = ["@tangle-network/brand", "@tangle-network/ui", "@tangle-network/sandbox-ui", "@tangle-network/agent-app"];
|
|
61
|
+
|
|
62
|
+
/** The counts the gate ratchets. Everything else in a row is informational. */
|
|
63
|
+
export const GATED = Object.keys(PATTERNS);
|
|
64
|
+
|
|
65
|
+
function extensions(surface, metric) {
|
|
66
|
+
const extra = SURFACES[surface]?.extraExt ?? [];
|
|
67
|
+
switch (metric) {
|
|
68
|
+
case "raw_palette": return ["*.tsx", "*.jsx", "*.astro", "*.ts", ...extra];
|
|
69
|
+
case "hex": return ["*.tsx", "*.jsx", "*.astro", "*.css", ...extra];
|
|
70
|
+
case "arbitrary_color": return ["*.tsx", "*.jsx", "*.astro", "*.ts"];
|
|
71
|
+
case "css_var_defs": return ["*.css"];
|
|
72
|
+
case "local_primitive_count": return ["*.tsx", "*.jsx"];
|
|
73
|
+
case "imports": return ["*.tsx", "*.ts", "*.astro", "*.jsx"];
|
|
74
|
+
default: return SRC_EXT;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Repository-locating variables set by hook runners would override `-C`; user config such as
|
|
79
|
+
// grep.column or color.ui would change the output format the parser reads.
|
|
80
|
+
const GIT_ENV = Object.fromEntries(Object.entries(process.env).filter(([k]) => !["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"].includes(k)));
|
|
81
|
+
const GIT_CONFIG = ["-c", "grep.column=false", "-c", "color.ui=never", "-c", "color.grep=never"];
|
|
82
|
+
|
|
83
|
+
function git(dir, args, { allowExit1 = false } = {}) {
|
|
84
|
+
try {
|
|
85
|
+
return execFileSync("git", [...GIT_CONFIG, "-C", dir, ...args], { encoding: "utf8", env: GIT_ENV, maxBuffer: 1 << 30, stdio: ["ignore", "pipe", "pipe"] });
|
|
86
|
+
} catch (error) {
|
|
87
|
+
if (allowExit1 && error.status === 1) return error.stdout ?? "";
|
|
88
|
+
throw new Error(`git ${args.slice(0, 3).join(" ")} failed in ${dir}: ${(error.stderr || error.message).trim()}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A source to read: a git dir plus either a tree-ish (`rev`) or the working tree (`rev: null`,
|
|
94
|
+
* tracked and untracked files that .gitignore does not exclude).
|
|
95
|
+
*/
|
|
96
|
+
function pathspecs(roots, exts) {
|
|
97
|
+
// A plain pathspec "*" also matches "/", so `root/*.tsx` covers every depth under the root,
|
|
98
|
+
// including files directly inside it.
|
|
99
|
+
return roots.flatMap((root) => exts.map((e) => (root ? `${root}/${e}` : e)));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Every match of `pattern`: [{ path, line, match }]. */
|
|
103
|
+
export function grepMatches(source, roots, pattern, exts) {
|
|
104
|
+
const args = ["grep", "-I", "-P", "-n", "-o", "--null", "-e", pattern];
|
|
105
|
+
if (source.rev) args.push(source.rev);
|
|
106
|
+
else args.push("--untracked");
|
|
107
|
+
args.push("--", ...pathspecs(roots, exts), ...EXCLUDE);
|
|
108
|
+
const prefix = source.rev ? `${source.rev}:` : "";
|
|
109
|
+
const out = git(source.dir, args, { allowExit1: true });
|
|
110
|
+
const rows = [];
|
|
111
|
+
for (const record of out.split("\n")) {
|
|
112
|
+
if (!record) continue;
|
|
113
|
+
const [file, line, match] = record.split("\0");
|
|
114
|
+
rows.push({ path: file.startsWith(prefix) ? file.slice(prefix.length) : file, line: Number(line), match });
|
|
115
|
+
}
|
|
116
|
+
return rows;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Full text of matching lines in the named files, for failure reports. */
|
|
120
|
+
function grepLines(source, pattern, files) {
|
|
121
|
+
const args = ["grep", "-I", "-P", "-n", "--null", "-e", pattern];
|
|
122
|
+
if (source.rev) args.push(source.rev);
|
|
123
|
+
else args.push("--untracked");
|
|
124
|
+
args.push("--", ...files.map((f) => `:(literal)${f}`));
|
|
125
|
+
const prefix = source.rev ? `${source.rev}:` : "";
|
|
126
|
+
return git(source.dir, args, { allowExit1: true }).split("\n").filter(Boolean).map((record) => {
|
|
127
|
+
const [file, line, text] = record.split("\0");
|
|
128
|
+
return { path: file.startsWith(prefix) ? file.slice(prefix.length) : file, line: Number(line), text: text.trim() };
|
|
129
|
+
});
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function listFiles(source, roots) {
|
|
133
|
+
const scope = roots.map((r) => r || ".");
|
|
134
|
+
const out = source.rev
|
|
135
|
+
? git(source.dir, ["ls-tree", "-r", "--name-only", source.rev, "--", ...scope])
|
|
136
|
+
: git(source.dir, ["ls-files", "--cached", "--others", "--exclude-standard", "--", ...scope]);
|
|
137
|
+
return out.split("\n").filter(Boolean);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function readFile(source, path) {
|
|
141
|
+
if (!source.rev) {
|
|
142
|
+
const full = join(source.dir, path);
|
|
143
|
+
return existsSync(full) ? readFileSync(full, "utf8") : "";
|
|
144
|
+
}
|
|
145
|
+
try {
|
|
146
|
+
return execFileSync("git", ["-C", source.dir, "show", `${source.rev}:${path}`], { encoding: "utf8", env: GIT_ENV, maxBuffer: 1 << 30, stdio: ["ignore", "pipe", "ignore"] });
|
|
147
|
+
} catch {
|
|
148
|
+
return "";
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function sortedObject(entries) {
|
|
153
|
+
return Object.fromEntries([...entries].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)));
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function countBy(values) {
|
|
157
|
+
const counts = new Map();
|
|
158
|
+
for (const v of values) counts.set(v, (counts.get(v) ?? 0) + 1);
|
|
159
|
+
return counts;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function pkgVersions(source, roots) {
|
|
163
|
+
const found = new Map();
|
|
164
|
+
const add = (key, value) => found.set(key, new Set([...(found.get(key) ?? []), value]));
|
|
165
|
+
const paths = new Set(["package.json", ...roots.filter(Boolean).map((r) => `${r}/package.json`)]);
|
|
166
|
+
for (const p of paths) {
|
|
167
|
+
let pkg;
|
|
168
|
+
try { pkg = JSON.parse(readFile(source, p)); } catch { continue; }
|
|
169
|
+
for (const section of ["dependencies", "devDependencies", "peerDependencies"]) {
|
|
170
|
+
for (const [name, version] of Object.entries(pkg[section] ?? {})) {
|
|
171
|
+
if (SHARED.includes(name)) add(name.split("/")[1], version);
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
for (const [name, version] of Object.entries(pkg.pnpm?.overrides ?? {})) {
|
|
175
|
+
if (SHARED.some((s) => name.includes(s))) add(`override:${name.split("/").at(-1)}`, version);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return sortedObject([...found].map(([k, v]) => [k, [...v].sort()]));
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Measure one surface from `source`. `matches` holds the raw gated matches for reporting. */
|
|
182
|
+
export function measure(surface, source) {
|
|
183
|
+
const { roots, extraExt } = SURFACES[surface];
|
|
184
|
+
const files = listFiles(source, roots);
|
|
185
|
+
const srcRe = extraExt ? /\.(tsx|jsx|astro|vue|svelte|js|html)$/ : /\.(tsx|jsx|astro|vue|svelte)$/;
|
|
186
|
+
const tsxFiles = files.filter((f) => srcRe.test(f) && !/node_modules|test|spec|stories|fixtures|dist\//.test(f)).length;
|
|
187
|
+
const cssFiles = files.filter((f) => f.endsWith(".css") && !f.includes("node_modules") && !f.includes("dist/")).length;
|
|
188
|
+
|
|
189
|
+
const matches = {};
|
|
190
|
+
const row = { repo: SURFACES[surface].repo, roots, branch: source.branch ?? null, head: source.head ?? null,
|
|
191
|
+
tsx_files: tsxFiles, css_files: cssFiles, versions: pkgVersions(source, roots), importing_files: {} };
|
|
192
|
+
for (const pkg of SHARED) {
|
|
193
|
+
const escaped = pkg.replace(/[.*+?^${}()|[\]\\/-]/g, "\\$&");
|
|
194
|
+
const hits = grepMatches(source, roots, String.raw`from ['"]${escaped}[/'"]`, extensions(surface, "imports"));
|
|
195
|
+
row.importing_files[pkg.split("/")[1]] = new Set(hits.map((h) => h.path)).size;
|
|
196
|
+
}
|
|
197
|
+
row.by_file = {};
|
|
198
|
+
for (const metric of GATED) {
|
|
199
|
+
const hits = grepMatches(source, roots, PATTERNS[metric], extensions(surface, metric));
|
|
200
|
+
matches[metric] = hits;
|
|
201
|
+
row[metric] = hits.length;
|
|
202
|
+
row.by_file[metric] = sortedObject(countBy(hits.map((h) => h.path)));
|
|
203
|
+
}
|
|
204
|
+
row.palette_top = [...countBy(matches.raw_palette.map((m) => m.match))]
|
|
205
|
+
.sort(([a, x], [b, y]) => y - x || (a < b ? -1 : 1)).slice(0, 6);
|
|
206
|
+
row.local_primitives = sortedObject(countBy(matches.local_primitive_count.map((m) => m.match.match(LOCAL_PRIM_NAME)?.[1] ?? m.match)));
|
|
207
|
+
return { row, matches };
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Compare a measured row with its baseline row. Missing baseline metrics count as 0. */
|
|
211
|
+
export function compare(baseline, current) {
|
|
212
|
+
const rises = [];
|
|
213
|
+
const falls = [];
|
|
214
|
+
for (const metric of GATED) {
|
|
215
|
+
const base = baseline?.[metric] ?? 0;
|
|
216
|
+
const now = current[metric];
|
|
217
|
+
if (now > base) rises.push({ metric, base, now });
|
|
218
|
+
else if (now < base) falls.push({ metric, base, now });
|
|
219
|
+
}
|
|
220
|
+
return { rises, falls };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Files whose count for `metric` rose above their baseline count, worst first. */
|
|
224
|
+
export function risenFiles(baseline, current, metric) {
|
|
225
|
+
const before = baseline?.by_file?.[metric] ?? {};
|
|
226
|
+
return Object.entries(current.by_file[metric])
|
|
227
|
+
.map(([path, now]) => ({ path, base: before[path] ?? 0, now }))
|
|
228
|
+
.filter((f) => f.now > f.base)
|
|
229
|
+
.sort((a, b) => b.now - b.base - (a.now - a.base) || (a.path < b.path ? -1 : 1));
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Apply `rows` to `baseline`. Without allowRise, a row with any gated rise is refused and the
|
|
234
|
+
* baseline keeps its old row. Returns { next, refused }.
|
|
235
|
+
*/
|
|
236
|
+
export function updateBaseline(baseline, rows, { allowRise = false } = {}) {
|
|
237
|
+
const next = { ...baseline };
|
|
238
|
+
const refused = [];
|
|
239
|
+
for (const [surface, row] of Object.entries(rows)) {
|
|
240
|
+
const { rises } = compare(baseline[surface], row);
|
|
241
|
+
if (rises.length && !allowRise && baseline[surface]) { refused.push({ surface, rises }); continue; }
|
|
242
|
+
next[surface] = row;
|
|
243
|
+
}
|
|
244
|
+
return { next: sortedBySurface(next), refused };
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function sortedBySurface(baseline) {
|
|
248
|
+
const order = Object.keys(SURFACES);
|
|
249
|
+
return Object.fromEntries(Object.entries(baseline).sort(([a], [b]) => {
|
|
250
|
+
const ia = order.indexOf(a); const ib = order.indexOf(b);
|
|
251
|
+
return (ia < 0 ? 1e9 : ia) - (ib < 0 ? 1e9 : ib) || (a < b ? -1 : 1);
|
|
252
|
+
}));
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
export function readBaseline(path) {
|
|
256
|
+
return existsSync(path) ? JSON.parse(readFileSync(path, "utf8")) : {};
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
export function writeBaseline(path, baseline) {
|
|
260
|
+
writeFileSync(path, `${JSON.stringify(baseline, null, 1)}\n`);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function cacheRoot() {
|
|
264
|
+
return process.env.TANGLE_DRIFT_CACHE ?? join(process.env.XDG_CACHE_HOME ?? join(homedir(), ".cache"), "tangle-drift");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function remoteUrl(repo) {
|
|
268
|
+
return `${process.env.TANGLE_DRIFT_REMOTE_PREFIX ?? "https://github.com/"}${repo}.git`;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const fetched = new Map();
|
|
272
|
+
/**
|
|
273
|
+
* Shallow-fetch the default branch of `repo` into a bare partial clone in the cache. Blobs over
|
|
274
|
+
* 256 KiB (images, fonts, media) stay on the server; `git grep` fetches one on demand only if a
|
|
275
|
+
* source pathspec matches it.
|
|
276
|
+
*/
|
|
277
|
+
export function remoteSource(repo) {
|
|
278
|
+
if (fetched.has(repo)) return fetched.get(repo);
|
|
279
|
+
const url = remoteUrl(repo);
|
|
280
|
+
const dir = join(cacheRoot(), `${repo.replace("/", "__")}.git`);
|
|
281
|
+
// A directory left by an interrupted first run is re-initialised rather than letting `git -C`
|
|
282
|
+
// fall through to an enclosing repository.
|
|
283
|
+
if (!existsSync(join(dir, "HEAD"))) {
|
|
284
|
+
mkdirSync(dir, { recursive: true });
|
|
285
|
+
git(dir, ["init", "--bare", "--quiet"]);
|
|
286
|
+
}
|
|
287
|
+
git(dir, ["config", "remote.origin.url", url]);
|
|
288
|
+
git(dir, ["config", "remote.origin.promisor", "true"]);
|
|
289
|
+
git(dir, ["config", "remote.origin.partialclonefilter", "blob:limit=256k"]);
|
|
290
|
+
const symref = git(dir, ["ls-remote", "--symref", "origin", "HEAD"]);
|
|
291
|
+
const branch = symref.match(/^ref: refs\/heads\/(\S+)\s+HEAD/m)?.[1];
|
|
292
|
+
if (!branch) throw new Error(`cannot resolve the default branch of ${url}`);
|
|
293
|
+
const ref = `refs/remotes/origin/${branch}`;
|
|
294
|
+
git(dir, ["fetch", "--quiet", "--depth", "1", "--no-tags", "--filter=blob:limit=256k", "origin", `+refs/heads/${branch}:${ref}`]);
|
|
295
|
+
const source = { dir, rev: ref, branch, head: git(dir, ["rev-parse", ref]).trim().slice(0, 12) };
|
|
296
|
+
fetched.set(repo, source);
|
|
297
|
+
return source;
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Read a local checkout: a tree-ish (default HEAD) or, with rev null, the working tree. */
|
|
301
|
+
export function localSource(repoDir, rev = "HEAD") {
|
|
302
|
+
const dir = git(repoDir, ["rev-parse", "--show-toplevel"]).trim();
|
|
303
|
+
const headRev = rev ?? "HEAD";
|
|
304
|
+
const head = git(dir, ["rev-parse", "--verify", "--quiet", `${headRev}^{commit}`], { allowExit1: true }).trim().slice(0, 12) || null;
|
|
305
|
+
const name = git(dir, ["rev-parse", "--abbrev-ref", headRev], { allowExit1: true }).trim();
|
|
306
|
+
const branch = name && name !== "HEAD" && !/^[0-9a-f]{40}$/.test(name) ? name : null;
|
|
307
|
+
return { dir, rev, branch, head: rev ? head : head && `${head}+worktree` };
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function parseArgs(argv) {
|
|
311
|
+
if (argv.includes("-h") || argv.includes("--help")) usage();
|
|
312
|
+
const opts = { command: argv[0], surfaces: [], all: false, remote: false, repoDir: null, rev: "HEAD",
|
|
313
|
+
worktree: false, baseline: DEFAULT_BASELINE, update: false, allowRise: false, json: false, top: 20 };
|
|
314
|
+
for (let i = 1; i < argv.length; i++) {
|
|
315
|
+
const flag = argv[i];
|
|
316
|
+
const take = () => {
|
|
317
|
+
const v = argv[++i];
|
|
318
|
+
if (v === undefined) usage(`${flag} needs a value`);
|
|
319
|
+
return v;
|
|
320
|
+
};
|
|
321
|
+
switch (flag) {
|
|
322
|
+
case "--surface": opts.surfaces.push(take()); break;
|
|
323
|
+
case "--all": opts.all = true; break;
|
|
324
|
+
case "--remote": opts.remote = true; break;
|
|
325
|
+
case "--repo-dir": opts.repoDir = take(); break;
|
|
326
|
+
case "--rev": opts.rev = take(); break;
|
|
327
|
+
case "--worktree": opts.worktree = true; break;
|
|
328
|
+
case "--baseline": opts.baseline = take(); break;
|
|
329
|
+
case "--update-baseline": opts.update = true; break;
|
|
330
|
+
case "--allow-rise": opts.allowRise = true; break;
|
|
331
|
+
case "--json": opts.json = true; break;
|
|
332
|
+
case "--top": opts.top = Number(take()); break;
|
|
333
|
+
default: usage(`unknown argument: ${flag}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (!["check", "scan"].includes(opts.command)) usage(opts.command ? `unknown command: ${opts.command}` : "name a command: check or scan");
|
|
337
|
+
if (opts.all || (!opts.surfaces.length && opts.remote)) opts.surfaces = Object.keys(SURFACES);
|
|
338
|
+
for (const s of opts.surfaces) if (!SURFACES[s]) usage(`unknown surface: ${s} (known: ${Object.keys(SURFACES).join(", ")})`);
|
|
339
|
+
if (!opts.surfaces.length) usage("name a --surface, or pass --all");
|
|
340
|
+
if (!!opts.repoDir === opts.remote) usage("pass exactly one of --repo-dir <path> or --remote");
|
|
341
|
+
if (opts.repoDir && opts.surfaces.length > 1 && new Set(opts.surfaces.map((s) => SURFACES[s].repo)).size > 1) {
|
|
342
|
+
usage("--repo-dir reads one repository; name surfaces from that repository only");
|
|
343
|
+
}
|
|
344
|
+
if (opts.allowRise && !opts.update) usage("--allow-rise only applies with --update-baseline");
|
|
345
|
+
if (opts.update && opts.command !== "check") usage("--update-baseline only applies to check");
|
|
346
|
+
// The shipped baseline records default branches; a local checkout may be a feature branch,
|
|
347
|
+
// and writing into an installed package would silently relax that consumer's gate.
|
|
348
|
+
if (opts.update && !opts.remote && opts.baseline === DEFAULT_BASELINE) usage("--update-baseline writes the shipped baseline only from --remote; pass --baseline <file> to write another");
|
|
349
|
+
if (opts.update && opts.worktree) usage("--update-baseline cannot record uncommitted --worktree counts");
|
|
350
|
+
if (opts.worktree && opts.rev !== "HEAD") usage("pass --rev or --worktree, not both");
|
|
351
|
+
if (!Number.isInteger(opts.top) || opts.top < 0) usage("--top needs a non-negative integer");
|
|
352
|
+
return opts;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export class UsageError extends Error {}
|
|
356
|
+
|
|
357
|
+
function usage(error) {
|
|
358
|
+
const text = `Usage:
|
|
359
|
+
tangle-drift check --surface <name> --repo-dir <path> [--rev <tree-ish> | --worktree]
|
|
360
|
+
tangle-drift check --all --remote [--update-baseline [--allow-rise]]
|
|
361
|
+
tangle-drift scan (--surface <name>... | --all) (--repo-dir <path> | --remote) [--json]
|
|
362
|
+
|
|
363
|
+
check exit 1 when any gated count (${GATED.join(", ")}) is above the baseline
|
|
364
|
+
scan print the counts without comparing
|
|
365
|
+
--repo-dir read a local checkout at HEAD (or --rev), or its working tree with --worktree
|
|
366
|
+
--remote shallow-fetch each surface's default branch into ${cacheRoot()}
|
|
367
|
+
--baseline baseline file (default: the one shipped with @tangle-network/brand)
|
|
368
|
+
--update-baseline write measured rows; refuses rows that rise unless --allow-rise
|
|
369
|
+
Surfaces: ${Object.keys(SURFACES).join(", ")}
|
|
370
|
+
`;
|
|
371
|
+
if (error) throw new UsageError(`${error}\n${text}`);
|
|
372
|
+
throw Object.assign(new UsageError(text), { help: true });
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function sourceFor(surface, opts) {
|
|
376
|
+
if (opts.remote) return remoteSource(SURFACES[surface].repo);
|
|
377
|
+
return localSource(opts.repoDir, opts.worktree ? null : opts.rev);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function describe(surface, row) {
|
|
381
|
+
return `${surface} @ ${row.head ?? "?"}${row.branch ? ` (${row.branch})` : ""}: ` +
|
|
382
|
+
GATED.map((m) => `${m}=${row[m]}`).join(" ");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
export function main(argv = process.argv.slice(2), log = (s) => process.stdout.write(`${s}\n`)) {
|
|
386
|
+
const opts = parseArgs(argv);
|
|
387
|
+
const baseline = readBaseline(opts.baseline);
|
|
388
|
+
const rows = {};
|
|
389
|
+
const measured = {};
|
|
390
|
+
for (const surface of opts.surfaces) {
|
|
391
|
+
const source = sourceFor(surface, opts);
|
|
392
|
+
measured[surface] = { ...measure(surface, source), source };
|
|
393
|
+
rows[surface] = measured[surface].row;
|
|
394
|
+
}
|
|
395
|
+
if (opts.command === "scan") {
|
|
396
|
+
if (opts.json) log(JSON.stringify(rows, null, 1));
|
|
397
|
+
else for (const [s, row] of Object.entries(rows)) log(describe(s, row));
|
|
398
|
+
return 0;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
let failed = false;
|
|
402
|
+
for (const [surface, { row, source }] of Object.entries(measured)) {
|
|
403
|
+
const base = baseline[surface];
|
|
404
|
+
log(describe(surface, row));
|
|
405
|
+
if (!base) {
|
|
406
|
+
log(` no baseline row for ${surface} in ${opts.baseline}`);
|
|
407
|
+
if (!opts.update) failed = true;
|
|
408
|
+
continue;
|
|
409
|
+
}
|
|
410
|
+
const { rises, falls } = compare(base, row);
|
|
411
|
+
for (const { metric, base: b, now } of rises) {
|
|
412
|
+
log(` FAIL ${metric}: ${now} > baseline ${b} (+${now - b}; baseline read ${base.head})`);
|
|
413
|
+
const files = risenFiles(base, row, metric);
|
|
414
|
+
const shown = files.length ? files : Object.entries(row.by_file[metric]).map(([path, now]) => ({ path, base: 0, now }))
|
|
415
|
+
.sort((x, y) => y.now - x.now).slice(0, 10);
|
|
416
|
+
log(files.length ? " files above their baseline count:" : " no single file rose (moved or renamed?); largest files:");
|
|
417
|
+
for (const f of shown.slice(0, 10)) log(` ${f.path}: ${f.base} -> ${f.now}`);
|
|
418
|
+
const lines = grepLines(source, PATTERNS[metric], shown.slice(0, 10).map((f) => f.path));
|
|
419
|
+
for (const l of lines.slice(0, opts.top)) log(` ${l.path}:${l.line}: ${l.text.slice(0, 160)}`);
|
|
420
|
+
if (lines.length > opts.top) log(` ... ${lines.length - opts.top} more lines`);
|
|
421
|
+
}
|
|
422
|
+
if (rises.length) failed = true;
|
|
423
|
+
else if (falls.length) {
|
|
424
|
+
log(` ok, lower than baseline: ${falls.map((f) => `${f.metric} ${f.base} -> ${f.now}`).join(", ")}`);
|
|
425
|
+
log(" once this reaches the default branch, lower the baseline in tangle-network/brand:");
|
|
426
|
+
log(` node packages/brand/bin/tangle-drift.mjs check --surface ${surface} --remote --update-baseline`);
|
|
427
|
+
} else log(" ok, equal to baseline");
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
if (opts.update) {
|
|
431
|
+
const { next, refused } = updateBaseline(baseline, rows, { allowRise: opts.allowRise });
|
|
432
|
+
for (const r of refused) log(` refused to raise ${r.surface}: ${r.rises.map((x) => `${x.metric} ${x.base} -> ${x.now}`).join(", ")} (pass --allow-rise to accept)`);
|
|
433
|
+
writeBaseline(opts.baseline, next);
|
|
434
|
+
log(`wrote ${opts.baseline}`);
|
|
435
|
+
return refused.length ? 1 : 0;
|
|
436
|
+
}
|
|
437
|
+
return failed ? 1 : 0;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
function invokedDirectly() {
|
|
441
|
+
try {
|
|
442
|
+
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
|
|
443
|
+
} catch {
|
|
444
|
+
return false;
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
if (invokedDirectly()) {
|
|
449
|
+
try {
|
|
450
|
+
process.exitCode = main();
|
|
451
|
+
} catch (error) {
|
|
452
|
+
if (error.help) process.stdout.write(error.message);
|
|
453
|
+
else process.stderr.write(`tangle-drift: ${error.message}\n`);
|
|
454
|
+
process.exitCode = error.help ? 0 : 2;
|
|
455
|
+
}
|
|
456
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tangle-network/brand",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.0",
|
|
4
4
|
"description": "Tangle brand system — design tokens, logo, Tailwind v4 theme. Single source of truth for every Tangle app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -16,9 +16,14 @@
|
|
|
16
16
|
"./src/styles/*.css",
|
|
17
17
|
"./dist/styles/*.css"
|
|
18
18
|
],
|
|
19
|
+
"bin": {
|
|
20
|
+
"tangle-drift": "./bin/tangle-drift.mjs"
|
|
21
|
+
},
|
|
19
22
|
"files": [
|
|
20
23
|
"dist",
|
|
21
24
|
"src",
|
|
25
|
+
"bin/tangle-drift.mjs",
|
|
26
|
+
"bin/drift-baseline.json",
|
|
22
27
|
"README.md",
|
|
23
28
|
"CHANGELOG.md"
|
|
24
29
|
],
|
|
@@ -64,10 +69,11 @@
|
|
|
64
69
|
"tailwind"
|
|
65
70
|
],
|
|
66
71
|
"scripts": {
|
|
67
|
-
"build": "node scripts/gen-compat.mjs --check && tsdown",
|
|
72
|
+
"build": "node scripts/gen-ladders.mjs --check && node scripts/gen-compat.mjs --check && tsdown",
|
|
68
73
|
"gen:ladders": "node scripts/gen-ladders.mjs",
|
|
69
74
|
"gen:compat": "node scripts/gen-compat.mjs",
|
|
70
|
-
"check:compat": "node scripts/gen-compat.mjs --check && node --test scripts/gen-compat.test.mjs",
|
|
75
|
+
"check:compat": "node scripts/gen-ladders.mjs --check && node scripts/gen-compat.mjs --check && node --test scripts/gen-compat.test.mjs",
|
|
76
|
+
"check:drift": "node --test scripts/tangle-drift.test.mjs",
|
|
71
77
|
"dev": "tsdown --watch"
|
|
72
78
|
}
|
|
73
79
|
}
|
package/src/styles/focus.test.ts
CHANGED
|
@@ -59,11 +59,11 @@ const lightStatus = blocksIn(themes, '[data-theme="tangle-light"]').join("\n");
|
|
|
59
59
|
const scopes: Array<{ name: string; sources: string[] }> = [
|
|
60
60
|
{ name: "canonical dark", sources: [DARK] },
|
|
61
61
|
{ name: "canonical light", sources: [LIGHT] },
|
|
62
|
-
...["aubergine", "arena", "tangle-dark"].map((name) => ({
|
|
62
|
+
...["aubergine", "arena", "super", "tangle-dark", "agents"].map((name) => ({
|
|
63
63
|
name,
|
|
64
64
|
sources: [...blocksIn(themes, `[data-theme="${name}"]`), DARK],
|
|
65
65
|
})),
|
|
66
|
-
...["aubergine-light", "arena-light", "tangle-light"].map((name) => ({
|
|
66
|
+
...["aubergine-light", "arena-light", "super-light", "tangle-light", "agents-light"].map((name) => ({
|
|
67
67
|
name,
|
|
68
68
|
sources: [...blocksIn(themes, `[data-theme="${name}"]`), lightStatus, LIGHT, DARK],
|
|
69
69
|
})),
|
package/src/styles/ladders.css
CHANGED
|
@@ -159,6 +159,78 @@
|
|
|
159
159
|
--olive-10: #767d74;
|
|
160
160
|
--olive-11: #afb5ad;
|
|
161
161
|
--olive-12: #eceeec;
|
|
162
|
+
--purple-1: #18111b;
|
|
163
|
+
--purple-2: #1e1523;
|
|
164
|
+
--purple-3: #301c3b;
|
|
165
|
+
--purple-4: #3d224e;
|
|
166
|
+
--purple-5: #48295c;
|
|
167
|
+
--purple-6: #54346b;
|
|
168
|
+
--purple-7: #664282;
|
|
169
|
+
--purple-8: #8457aa;
|
|
170
|
+
--purple-9: #8e4ec6;
|
|
171
|
+
--purple-10: #9a5cd0;
|
|
172
|
+
--purple-11: #d19dff;
|
|
173
|
+
--purple-12: #ecd9fa;
|
|
174
|
+
--teal-1: #0d1514;
|
|
175
|
+
--teal-2: #111c1b;
|
|
176
|
+
--teal-3: #0d2d2a;
|
|
177
|
+
--teal-4: #023b37;
|
|
178
|
+
--teal-5: #084843;
|
|
179
|
+
--teal-6: #145750;
|
|
180
|
+
--teal-7: #1c6961;
|
|
181
|
+
--teal-8: #207e73;
|
|
182
|
+
--teal-9: #12a594;
|
|
183
|
+
--teal-10: #0eb39e;
|
|
184
|
+
--teal-11: #0bd8b6;
|
|
185
|
+
--teal-12: #adf0dd;
|
|
186
|
+
--pink-1: #191117;
|
|
187
|
+
--pink-2: #21121d;
|
|
188
|
+
--pink-3: #37172f;
|
|
189
|
+
--pink-4: #4b143d;
|
|
190
|
+
--pink-5: #591c47;
|
|
191
|
+
--pink-6: #692955;
|
|
192
|
+
--pink-7: #833869;
|
|
193
|
+
--pink-8: #a84885;
|
|
194
|
+
--pink-9: #d6409f;
|
|
195
|
+
--pink-10: #de51a8;
|
|
196
|
+
--pink-11: #ff8dcc;
|
|
197
|
+
--pink-12: #fdd1ea;
|
|
198
|
+
--brown-1: #12110f;
|
|
199
|
+
--brown-2: #1c1816;
|
|
200
|
+
--brown-3: #28211d;
|
|
201
|
+
--brown-4: #322922;
|
|
202
|
+
--brown-5: #3e3128;
|
|
203
|
+
--brown-6: #4d3c2f;
|
|
204
|
+
--brown-7: #614a39;
|
|
205
|
+
--brown-8: #7c5f46;
|
|
206
|
+
--brown-9: #ad7f58;
|
|
207
|
+
--brown-10: #b88c67;
|
|
208
|
+
--brown-11: #dbb594;
|
|
209
|
+
--brown-12: #f2e1ca;
|
|
210
|
+
--cyan-1: #0b161a;
|
|
211
|
+
--cyan-2: #101b20;
|
|
212
|
+
--cyan-3: #082c36;
|
|
213
|
+
--cyan-4: #003848;
|
|
214
|
+
--cyan-5: #004558;
|
|
215
|
+
--cyan-6: #045468;
|
|
216
|
+
--cyan-7: #12677e;
|
|
217
|
+
--cyan-8: #11809c;
|
|
218
|
+
--cyan-9: #00a2c7;
|
|
219
|
+
--cyan-10: #23afd0;
|
|
220
|
+
--cyan-11: #4ccce6;
|
|
221
|
+
--cyan-12: #b6ecf7;
|
|
222
|
+
--lime-1: #11130c;
|
|
223
|
+
--lime-2: #151a10;
|
|
224
|
+
--lime-3: #1f2917;
|
|
225
|
+
--lime-4: #29371d;
|
|
226
|
+
--lime-5: #334423;
|
|
227
|
+
--lime-6: #3d522a;
|
|
228
|
+
--lime-7: #496231;
|
|
229
|
+
--lime-8: #577538;
|
|
230
|
+
--lime-9: #bdee63;
|
|
231
|
+
--lime-10: #d4ff70;
|
|
232
|
+
--lime-11: #bde56c;
|
|
233
|
+
--lime-12: #e3f7ba;
|
|
162
234
|
--gray-1: var(--mauve-1);
|
|
163
235
|
--gray-2: var(--mauve-2);
|
|
164
236
|
--gray-3: var(--mauve-3);
|
|
@@ -379,6 +451,78 @@
|
|
|
379
451
|
--olive-10: #7f847d;
|
|
380
452
|
--olive-11: #60655f;
|
|
381
453
|
--olive-12: #1d211c;
|
|
454
|
+
--purple-1: #fefcfe;
|
|
455
|
+
--purple-2: #fbf7fe;
|
|
456
|
+
--purple-3: #f7edfe;
|
|
457
|
+
--purple-4: #f2e2fc;
|
|
458
|
+
--purple-5: #ead5f9;
|
|
459
|
+
--purple-6: #e0c4f4;
|
|
460
|
+
--purple-7: #d1afec;
|
|
461
|
+
--purple-8: #be93e4;
|
|
462
|
+
--purple-9: #8e4ec6;
|
|
463
|
+
--purple-10: #8347b9;
|
|
464
|
+
--purple-11: #8145b5;
|
|
465
|
+
--purple-12: #402060;
|
|
466
|
+
--teal-1: #fafefd;
|
|
467
|
+
--teal-2: #f3fbf9;
|
|
468
|
+
--teal-3: #e0f8f3;
|
|
469
|
+
--teal-4: #ccf3ea;
|
|
470
|
+
--teal-5: #b8eae0;
|
|
471
|
+
--teal-6: #a1ded2;
|
|
472
|
+
--teal-7: #83cdc1;
|
|
473
|
+
--teal-8: #53b9ab;
|
|
474
|
+
--teal-9: #12a594;
|
|
475
|
+
--teal-10: #0d9b8a;
|
|
476
|
+
--teal-11: #008573;
|
|
477
|
+
--teal-12: #0d3d38;
|
|
478
|
+
--pink-1: #fffcfe;
|
|
479
|
+
--pink-2: #fef7fb;
|
|
480
|
+
--pink-3: #fee9f5;
|
|
481
|
+
--pink-4: #fbdcef;
|
|
482
|
+
--pink-5: #f6cee7;
|
|
483
|
+
--pink-6: #efbfdd;
|
|
484
|
+
--pink-7: #e7acd0;
|
|
485
|
+
--pink-8: #dd93c2;
|
|
486
|
+
--pink-9: #d6409f;
|
|
487
|
+
--pink-10: #cf3897;
|
|
488
|
+
--pink-11: #c2298a;
|
|
489
|
+
--pink-12: #651249;
|
|
490
|
+
--brown-1: #fefdfc;
|
|
491
|
+
--brown-2: #fcf9f6;
|
|
492
|
+
--brown-3: #f6eee7;
|
|
493
|
+
--brown-4: #f0e4d9;
|
|
494
|
+
--brown-5: #ebdaca;
|
|
495
|
+
--brown-6: #e4cdb7;
|
|
496
|
+
--brown-7: #dcbc9f;
|
|
497
|
+
--brown-8: #cea37e;
|
|
498
|
+
--brown-9: #ad7f58;
|
|
499
|
+
--brown-10: #a07553;
|
|
500
|
+
--brown-11: #815e46;
|
|
501
|
+
--brown-12: #3e332e;
|
|
502
|
+
--cyan-1: #fafdfe;
|
|
503
|
+
--cyan-2: #f2fafb;
|
|
504
|
+
--cyan-3: #def7f9;
|
|
505
|
+
--cyan-4: #caf1f6;
|
|
506
|
+
--cyan-5: #b5e9f0;
|
|
507
|
+
--cyan-6: #9ddde7;
|
|
508
|
+
--cyan-7: #7dcedc;
|
|
509
|
+
--cyan-8: #3db9cf;
|
|
510
|
+
--cyan-9: #00a2c7;
|
|
511
|
+
--cyan-10: #0797b9;
|
|
512
|
+
--cyan-11: #107d98;
|
|
513
|
+
--cyan-12: #0d3c48;
|
|
514
|
+
--lime-1: #fcfdfa;
|
|
515
|
+
--lime-2: #f8faf3;
|
|
516
|
+
--lime-3: #eef6d6;
|
|
517
|
+
--lime-4: #e2f0bd;
|
|
518
|
+
--lime-5: #d3e7a6;
|
|
519
|
+
--lime-6: #c2da91;
|
|
520
|
+
--lime-7: #abc978;
|
|
521
|
+
--lime-8: #8db654;
|
|
522
|
+
--lime-9: #bdee63;
|
|
523
|
+
--lime-10: #b0e64c;
|
|
524
|
+
--lime-11: #5c7c2f;
|
|
525
|
+
--lime-12: #37401c;
|
|
382
526
|
--gray-1: var(--mauve-1);
|
|
383
527
|
--gray-2: var(--mauve-2);
|
|
384
528
|
--gray-3: var(--mauve-3);
|