jig-ui 0.10.0 → 0.12.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 +61 -0
- package/README.md +20 -8
- package/dist/index.js +207 -56
- package/package.json +2 -2
- package/rules/05-copy.md +8 -0
- package/rules.index.json +7 -0
- package/templates/COMMAND.md.tmpl +10 -2
- package/templates/command-metadata.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,66 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.12.0
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- **`I-118` Em dashes in interface text.** Use a full stop, a comma, a colon
|
|
8
|
+
or a second element. The mark is ambiguous on its own: it stands in for
|
|
9
|
+
four different ones, and the reader only learns which after reading past
|
|
10
|
+
it. It is also the clearest tell of machine-written copy, and copy that
|
|
11
|
+
reads as generated is copy the reader trusts less. The `em-dash` detector
|
|
12
|
+
reads element text and the attributes a reader hears or sees; scripts,
|
|
13
|
+
styles, comments, class names and documentation are not interface text.
|
|
14
|
+
The en dash keeps its one job, a range read as "to" (`2-10 seats`).
|
|
15
|
+
|
|
16
|
+
## 0.11.1
|
|
17
|
+
|
|
18
|
+
Two holes in the gate, both found by running the loop twice on 0.11.0 —
|
|
19
|
+
once on Haiku, once on Sonnet.
|
|
20
|
+
|
|
21
|
+
### Fixed
|
|
22
|
+
|
|
23
|
+
- **The Stop hook's block budget is per failure, not per session.** A
|
|
24
|
+
headless session keeps one session id across every `/jig` step, so a run
|
|
25
|
+
that spent its three blocks on `init`, `spec` and `make` reached
|
|
26
|
+
`critique` with none left: it reported "complete and approved, zero
|
|
27
|
+
findings" having written no verdict files, and the gate that would have
|
|
28
|
+
caught it had already let go. Fixing a failure now returns its attempts,
|
|
29
|
+
a new failure starts fresh, and an agent that cannot fix a given failure
|
|
30
|
+
still gets out after three tries.
|
|
31
|
+
- **`nav: horizontal bar (no menu)` is no longer read as a menu button**
|
|
32
|
+
at a width where the links fit. A false positive is how a gate gets
|
|
33
|
+
ignored.
|
|
34
|
+
|
|
35
|
+
## 0.11.0
|
|
36
|
+
|
|
37
|
+
One release, one lesson: a measurement an agent can type is not a
|
|
38
|
+
measurement. Everything here came from two live runs at the capability
|
|
39
|
+
floor the day 0.10.0 shipped.
|
|
40
|
+
|
|
41
|
+
### Added
|
|
42
|
+
|
|
43
|
+
- **`jig probe --save <surface>`.** The CLI writes the probe file now. It
|
|
44
|
+
reads what the probe returned on stdin, checks it is probe output for a
|
|
45
|
+
page inside the project, and stamps it with that page's checksum and the
|
|
46
|
+
time. A run had written its own probe file by hand — five links and an
|
|
47
|
+
Escape key that closed a menu with thirteen links and no Escape handler —
|
|
48
|
+
and its review passed on those numbers.
|
|
49
|
+
- **`install --hook` / `--no-hook`.** The Stop hook is opt-in. An
|
|
50
|
+
interactive install offers it once and takes silence as no; `--yes` never
|
|
51
|
+
adds it, because that is the path an agent takes and nobody is there to
|
|
52
|
+
consent. `update` moves an existing hook and never adds one.
|
|
53
|
+
|
|
54
|
+
### Changed
|
|
55
|
+
|
|
56
|
+
- **Probe files are version 2.** `jig verdicts` rejects a probe with no
|
|
57
|
+
stamp — nothing measured it — and one whose page changed after it was
|
|
58
|
+
taken. Version 1 files no longer validate.
|
|
59
|
+
- **README and `init` say which directory to commit, and why.** The tokens
|
|
60
|
+
live in `<css dir>/jig/`; `.jig/` holds `state.json`, specs, mockups and
|
|
61
|
+
critique verdicts. Both are warned about separately, each with what
|
|
62
|
+
ignoring it costs. Since 0.7.0 both had said `.jig/` holds the tokens.
|
|
63
|
+
|
|
3
64
|
## 0.10.0
|
|
4
65
|
|
|
5
66
|
Mobile-first, and a design loop that a weak model cannot skip. Everything here
|
package/README.md
CHANGED
|
@@ -12,13 +12,13 @@ Installed as `npx jig-ui` — the bare name was taken on npm.
|
|
|
12
12
|
Jig is **a skill your coding agent reads**, and **a CLI you can run yourself**.
|
|
13
13
|
They are two halves of the same thing, and the split is not arbitrary:
|
|
14
14
|
|
|
15
|
-
- Of the
|
|
15
|
+
- Of the 114 rules, **17 can be decided by a machine** — a hard-coded colour, a
|
|
16
16
|
contrast ratio below the floor, a removed focus ring. The CLI decides those.
|
|
17
17
|
- The other **97 are judgment** — whether an empty state says anything useful,
|
|
18
18
|
whether a label reads as an instruction, whether motion earns its place. No
|
|
19
19
|
regex settles those. An agent reads the rules and applies them.
|
|
20
20
|
|
|
21
|
-
Running only the CLI gets you the
|
|
21
|
+
Running only the CLI gets you the 17. Running only the agent gets you the 97 with
|
|
22
22
|
no verification. **A clean `jig check` is not a clean review**, and the skill
|
|
23
23
|
says so to every agent that reads it.
|
|
24
24
|
|
|
@@ -149,13 +149,23 @@ Set `brand` in `jig.config.json` to put it somewhere else. Projects set up
|
|
|
149
149
|
before 0.7.0 keep their `.jig/tokens/` layout; `update` does not move them, and
|
|
150
150
|
`init` offers to.
|
|
151
151
|
|
|
152
|
-
The mode file is the one
|
|
153
|
-
edge in a build graph and has to resolve locally, on
|
|
152
|
+
The mode file is the one token file copied verbatim from the package: a
|
|
153
|
+
stylesheet `@import` is an edge in a build graph and has to resolve locally, on
|
|
154
|
+
every machine that builds. The brand file and the barrel are generated for your
|
|
155
|
+
project, not copied.
|
|
154
156
|
|
|
155
|
-
**Commit
|
|
156
|
-
|
|
157
|
-
run `init` themselves
|
|
158
|
-
check` sees no token layer at all.
|
|
157
|
+
**Commit the token directory** — `<css dir>/jig/`. It holds the files your
|
|
158
|
+
stylesheet `@import`s, so ignoring it means the design system does not exist for
|
|
159
|
+
anyone who did not run `init` themselves: their build breaks on a missing import,
|
|
160
|
+
and CI's `jig check` sees no token layer at all.
|
|
161
|
+
|
|
162
|
+
**Commit `.jig/` too.** It is not the tokens (it was, before 0.7.0). It holds
|
|
163
|
+
`state.json`, which records the version and checksum of every file `init` wrote
|
|
164
|
+
and is what `update` reads to know what it may refresh, plus the durable record of
|
|
165
|
+
the design loop: your specs, approved mockups and critique verdicts. Ignored, a
|
|
166
|
+
teammate's `update` cannot tell a file you edited from one it wrote, and every
|
|
167
|
+
spec the project agreed is invisible to the next agent. `init` warns if it finds
|
|
168
|
+
either directory ignored.
|
|
159
169
|
|
|
160
170
|
Add `--yes` to accept every derived default non-interactively — the mode CI and
|
|
161
171
|
agents run in. It states the mode it chose and where to change it, because
|
|
@@ -370,6 +380,8 @@ treatment.
|
|
|
370
380
|
| `<css dir>/jig/brand.*.css` | Identity. One per project. |
|
|
371
381
|
| `<css dir>/jig/mode.*.css` | Density, scale, rhythm, motion |
|
|
372
382
|
| `<css dir>/jig/theme.css` | The barrel — brand + mode. This is what you import. |
|
|
383
|
+
| `.jig/state.json` | What `init` wrote, with checksums. `update` reads it to leave your edits alone. |
|
|
384
|
+
| `.jig/specs/`, `.jig/mockups/`, `.jig/critique/` | The design loop's record: what was agreed, what was drawn, what the review found. |
|
|
373
385
|
|
|
374
386
|
`rules/*` and `rules.index.json` live beside your installed skill file, not
|
|
375
387
|
in the project — see above.
|
package/dist/index.js
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { Command } from "commander";
|
|
5
|
-
import { readFileSync as
|
|
5
|
+
import { readFileSync as readFileSync28 } from "fs";
|
|
6
6
|
import { homedir } from "os";
|
|
7
|
-
import { join as
|
|
7
|
+
import { join as join34 } from "path";
|
|
8
8
|
|
|
9
9
|
// src/paths.ts
|
|
10
10
|
import { existsSync } from "fs";
|
|
@@ -3299,6 +3299,59 @@ var undeclaredToken = {
|
|
|
3299
3299
|
}
|
|
3300
3300
|
};
|
|
3301
3301
|
|
|
3302
|
+
// src/check/detectors/em-dash.ts
|
|
3303
|
+
var MARKUP = [".html", ".htm", ".vue", ".svelte", ".astro", ".jsx", ".tsx", ".php", ".erb", ".twig", ".hbs"];
|
|
3304
|
+
var TEXT_ATTRS = /\b(aria-label|aria-description|aria-placeholder|title|placeholder|alt|label|value|content)\s*=\s*("([^"]*)"|'([^']*)')/gi;
|
|
3305
|
+
var EM_DASH = /—/;
|
|
3306
|
+
function maskNonProse(source) {
|
|
3307
|
+
const blank3 = (m) => " ".repeat(m.length);
|
|
3308
|
+
return source.replace(/<script\b[\s\S]*?<\/script\s*>/gi, blank3).replace(/<style\b[\s\S]*?<\/style\s*>/gi, blank3).replace(/<!--[\s\S]*?-->/g, blank3).replace(/\/\*[\s\S]*?\*\//g, blank3).replace(/(^|[^:])\/\/[^\n]*/g, (m, p1) => p1 + " ".repeat(m.length - p1.length));
|
|
3309
|
+
}
|
|
3310
|
+
function proseSpans(masked) {
|
|
3311
|
+
const spans = [];
|
|
3312
|
+
let last = 0;
|
|
3313
|
+
for (const tag of masked.matchAll(/<[^>]*>/g)) {
|
|
3314
|
+
const between = masked.slice(last, tag.index);
|
|
3315
|
+
if (between.trim()) spans.push({ index: last, text: between });
|
|
3316
|
+
for (const attr of tag[0].matchAll(TEXT_ATTRS)) {
|
|
3317
|
+
const value = attr[3] ?? attr[4] ?? "";
|
|
3318
|
+
spans.push({ index: tag.index + attr.index + attr[0].indexOf(value), text: value });
|
|
3319
|
+
}
|
|
3320
|
+
last = tag.index + tag[0].length;
|
|
3321
|
+
}
|
|
3322
|
+
const tail = masked.slice(last);
|
|
3323
|
+
if (tail.trim()) spans.push({ index: last, text: tail });
|
|
3324
|
+
return spans;
|
|
3325
|
+
}
|
|
3326
|
+
var emDash = {
|
|
3327
|
+
name: "em-dash",
|
|
3328
|
+
appliesTo: (file) => hasExtension(file, MARKUP),
|
|
3329
|
+
run(_source, file, ctx) {
|
|
3330
|
+
const masked = maskNonProse(ctx.raw);
|
|
3331
|
+
const starts = buildLineIndex(ctx.raw);
|
|
3332
|
+
const findings = [];
|
|
3333
|
+
const seen = /* @__PURE__ */ new Set();
|
|
3334
|
+
for (const span of proseSpans(masked)) {
|
|
3335
|
+
if (!EM_DASH.test(span.text)) continue;
|
|
3336
|
+
const offset = span.index + span.text.indexOf("\u2014");
|
|
3337
|
+
const line = lineForOffset(starts, offset);
|
|
3338
|
+
if (seen.has(line)) continue;
|
|
3339
|
+
seen.add(line);
|
|
3340
|
+
findings.push(
|
|
3341
|
+
mkFinding(
|
|
3342
|
+
ctx,
|
|
3343
|
+
"em-dash",
|
|
3344
|
+
file,
|
|
3345
|
+
line,
|
|
3346
|
+
"an em dash in interface text: use a full stop, a comma, a colon, or a second element",
|
|
3347
|
+
sourceLine(ctx.raw, line)
|
|
3348
|
+
)
|
|
3349
|
+
);
|
|
3350
|
+
}
|
|
3351
|
+
return findings;
|
|
3352
|
+
}
|
|
3353
|
+
};
|
|
3354
|
+
|
|
3302
3355
|
// src/check/registry.ts
|
|
3303
3356
|
var DETECTORS = [
|
|
3304
3357
|
gradientText,
|
|
@@ -3316,7 +3369,8 @@ var DETECTORS = [
|
|
|
3316
3369
|
inputZoom,
|
|
3317
3370
|
safeArea,
|
|
3318
3371
|
menuState,
|
|
3319
|
-
undeclaredToken
|
|
3372
|
+
undeclaredToken,
|
|
3373
|
+
emDash
|
|
3320
3374
|
];
|
|
3321
3375
|
function getDetector(name) {
|
|
3322
3376
|
return DETECTORS.find((d) => d.name === name);
|
|
@@ -4847,9 +4901,16 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4847
4901
|
log(` ${snippet.split("\n").join("\n ")}`);
|
|
4848
4902
|
}
|
|
4849
4903
|
}
|
|
4904
|
+
const tokenDirRel = tokensRelDir.join("/");
|
|
4905
|
+
if (isIgnored(opts.projectRoot, tokenDirRel)) {
|
|
4906
|
+
log(
|
|
4907
|
+
`
|
|
4908
|
+
WARNING: ${tokenDirRel}/ is gitignored, but it holds this project's tokens \u2014 the files your stylesheet @imports. Committed, teammates and CI get the same design system; ignored, their builds break on a missing import. Remove the ${tokenDirRel}/ rule from .gitignore, or commit the directory explicitly.`
|
|
4909
|
+
);
|
|
4910
|
+
}
|
|
4850
4911
|
if (isIgnored(opts.projectRoot, ".jig")) {
|
|
4851
4912
|
log(
|
|
4852
|
-
"\n WARNING: .jig/ is gitignored
|
|
4913
|
+
"\n WARNING: .jig/ is gitignored. It holds state.json \u2014 which `update` reads to leave your edited files alone \u2014 and the specs, mockups and critique verdicts this project agreed. Ignored, update cannot tell your edits from its own writes, and every spec is invisible to the next agent. Commit it."
|
|
4853
4914
|
);
|
|
4854
4915
|
}
|
|
4855
4916
|
const baselineResult = check({ projectRoot: opts.projectRoot, homeDir: opts.homeDir, version: opts.version, all: true, ci: false });
|
|
@@ -4873,8 +4934,8 @@ Could not write to ${wireTarget}: ${err.message}`);
|
|
|
4873
4934
|
}
|
|
4874
4935
|
|
|
4875
4936
|
// src/commands/verdicts.ts
|
|
4876
|
-
import { existsSync as
|
|
4877
|
-
import { join as
|
|
4937
|
+
import { existsSync as existsSync18, readFileSync as readFileSync25 } from "fs";
|
|
4938
|
+
import { join as join31, resolve as resolve4 } from "path";
|
|
4878
4939
|
|
|
4879
4940
|
// src/rules/citations.ts
|
|
4880
4941
|
import { join as join28 } from "path";
|
|
@@ -4888,11 +4949,16 @@ function citableIds(packageRoot2) {
|
|
|
4888
4949
|
}
|
|
4889
4950
|
|
|
4890
4951
|
// src/probe/check.ts
|
|
4891
|
-
import { existsSync as
|
|
4892
|
-
import { join as
|
|
4952
|
+
import { existsSync as existsSync17, readdirSync as readdirSync11, readFileSync as readFileSync24 } from "fs";
|
|
4953
|
+
import { join as join30 } from "path";
|
|
4954
|
+
|
|
4955
|
+
// src/probe/save.ts
|
|
4956
|
+
import { existsSync as existsSync16, mkdirSync as mkdirSync7, readFileSync as readFileSync23, writeFileSync as writeFileSync6 } from "fs";
|
|
4957
|
+
import { fileURLToPath as fileURLToPath2 } from "url";
|
|
4958
|
+
import { join as join29, relative as relative3, resolve as resolve3 } from "path";
|
|
4893
4959
|
|
|
4894
4960
|
// src/probe/script.ts
|
|
4895
|
-
var PROBE_VERSION =
|
|
4961
|
+
var PROBE_VERSION = 2;
|
|
4896
4962
|
var PROBE_SCRIPT = `(async () => {
|
|
4897
4963
|
const doc = document.documentElement;
|
|
4898
4964
|
const vis = (e) => { const r = e.getBoundingClientRect(); const s = getComputedStyle(e); return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none'; };
|
|
@@ -4962,17 +5028,79 @@ var PROBE_SCRIPT = `(async () => {
|
|
|
4962
5028
|
});
|
|
4963
5029
|
})()`;
|
|
4964
5030
|
|
|
5031
|
+
// src/probe/save.ts
|
|
5032
|
+
function saveProbe(opts) {
|
|
5033
|
+
let probe;
|
|
5034
|
+
try {
|
|
5035
|
+
probe = JSON.parse(opts.json);
|
|
5036
|
+
} catch (e) {
|
|
5037
|
+
throw new Error(`That is not the probe's output: ${e.message}. Evaluate \`jig probe\` in the browser and pipe what it returns straight in.`);
|
|
5038
|
+
}
|
|
5039
|
+
if (probe.jigProbe !== PROBE_VERSION) {
|
|
5040
|
+
throw new Error(`That JSON is not version ${PROBE_VERSION} probe output (jigProbe: ${JSON.stringify(probe.jigProbe)}). Re-read \`jig probe\` and evaluate it; do not write the file yourself.`);
|
|
5041
|
+
}
|
|
5042
|
+
const width = typeof probe.width === "number" ? probe.width : NaN;
|
|
5043
|
+
if (!Number.isFinite(width)) throw new Error("The probe output has no width. Evaluate it in a browser, at the width you are judging.");
|
|
5044
|
+
const url = typeof probe.url === "string" ? probe.url : "";
|
|
5045
|
+
const page = pageFile(opts.projectRoot, url);
|
|
5046
|
+
if (!page) {
|
|
5047
|
+
throw new Error(`The probe ran at ${url || "no url"}, which is not a file in this project. Open the page you built \u2014 a file:// URL under the project \u2014 and probe that.`);
|
|
5048
|
+
}
|
|
5049
|
+
probe.pageFile = relative3(opts.projectRoot, page).split("\\").join("/");
|
|
5050
|
+
probe.pageChecksum = checksum(readFileSync23(page, "utf8"));
|
|
5051
|
+
probe.recordedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
5052
|
+
const dir = join29(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5053
|
+
mkdirSync7(dir, { recursive: true });
|
|
5054
|
+
const path = join29(dir, `probe-${width}.json`);
|
|
5055
|
+
writeFileSync6(path, JSON.stringify(probe), "utf8");
|
|
5056
|
+
return { path: `.jig/critique/${opts.surface}/probe-${width}.json`, width, page: probe.pageFile };
|
|
5057
|
+
}
|
|
5058
|
+
function pageFile(projectRoot, url) {
|
|
5059
|
+
if (!url.startsWith("file://")) return void 0;
|
|
5060
|
+
let path;
|
|
5061
|
+
try {
|
|
5062
|
+
path = fileURLToPath2(url.split("#")[0].split("?")[0]);
|
|
5063
|
+
} catch {
|
|
5064
|
+
return void 0;
|
|
5065
|
+
}
|
|
5066
|
+
const abs = resolve3(path);
|
|
5067
|
+
if (!abs.startsWith(resolve3(projectRoot))) return void 0;
|
|
5068
|
+
return existsSync16(abs) ? abs : void 0;
|
|
5069
|
+
}
|
|
5070
|
+
|
|
4965
5071
|
// src/probe/check.ts
|
|
4966
|
-
function
|
|
4967
|
-
if (!
|
|
5072
|
+
function stampProblem(projectRoot, file, p) {
|
|
5073
|
+
if (!p.pageChecksum || !p.pageFile) {
|
|
5074
|
+
return `${file} was not written by \`jig probe --save\`, so nothing measured it. Evaluate \`jig probe\` in the browser and pipe its output into \`jig probe --save <surface>\`.`;
|
|
5075
|
+
}
|
|
5076
|
+
const page = pageFile(projectRoot, p.url ?? "");
|
|
5077
|
+
if (!page) return `${file} names a page outside this project (${p.url ?? "no url"}).`;
|
|
5078
|
+
let current;
|
|
5079
|
+
try {
|
|
5080
|
+
current = readFileSync24(page, "utf8");
|
|
5081
|
+
} catch {
|
|
5082
|
+
return `${file} was taken on ${p.pageFile}, which no longer exists.`;
|
|
5083
|
+
}
|
|
5084
|
+
if (checksum(current) !== p.pageChecksum) {
|
|
5085
|
+
return `${file} was taken on an older ${p.pageFile} \u2014 the page changed after it. Render and probe again at that width.`;
|
|
5086
|
+
}
|
|
5087
|
+
return void 0;
|
|
5088
|
+
}
|
|
5089
|
+
function readProbes(projectRoot, dir, errors) {
|
|
5090
|
+
if (!existsSync17(dir)) return [];
|
|
4968
5091
|
const probes = [];
|
|
4969
5092
|
for (const f of readdirSync11(dir).filter((n) => /^probe-\d+\.json$/.test(n)).sort()) {
|
|
4970
5093
|
try {
|
|
4971
|
-
const p = JSON.parse(
|
|
5094
|
+
const p = JSON.parse(readFileSync24(join30(dir, f), "utf8"));
|
|
4972
5095
|
if (p.jigProbe !== PROBE_VERSION || typeof p.width !== "number") {
|
|
4973
5096
|
errors.push(`${f} is not output of \`jig probe\` (version ${PROBE_VERSION}). Re-run the probe; do not write it by hand.`);
|
|
4974
5097
|
continue;
|
|
4975
5098
|
}
|
|
5099
|
+
const stale = stampProblem(projectRoot, f, p);
|
|
5100
|
+
if (stale) {
|
|
5101
|
+
errors.push(stale);
|
|
5102
|
+
continue;
|
|
5103
|
+
}
|
|
4976
5104
|
probes.push(p);
|
|
4977
5105
|
} catch (e) {
|
|
4978
5106
|
errors.push(`${f}: not valid JSON (${e.message}).`);
|
|
@@ -5033,18 +5161,18 @@ function probeContradictions(probes, verdictOf) {
|
|
|
5033
5161
|
var VERDICTS = ["ok", "finding", "n/a"];
|
|
5034
5162
|
var ABSENCE = /\b(rule (not found|does not exist)|context unavailable|cannot (find|read|access) (the )?rule|not in (the )?(accessible )?corpus)\b/i;
|
|
5035
5163
|
function readJson(path, errors) {
|
|
5036
|
-
if (!
|
|
5164
|
+
if (!existsSync18(path)) return null;
|
|
5037
5165
|
try {
|
|
5038
|
-
return JSON.parse(
|
|
5166
|
+
return JSON.parse(readFileSync25(path, "utf8"));
|
|
5039
5167
|
} catch (e) {
|
|
5040
5168
|
errors.push(`${path}: not valid JSON (${e.message})`);
|
|
5041
5169
|
return {};
|
|
5042
5170
|
}
|
|
5043
5171
|
}
|
|
5044
5172
|
function specNeedsNav(projectRoot, surface) {
|
|
5045
|
-
const path =
|
|
5046
|
-
if (!
|
|
5047
|
-
const front =
|
|
5173
|
+
const path = join31(projectRoot, ".jig", "specs", `${surface}.spec.md`);
|
|
5174
|
+
if (!existsSync18(path)) return false;
|
|
5175
|
+
const front = readFileSync25(path, "utf8").split(/^---\s*$/m)[1] ?? "";
|
|
5048
5176
|
const navField = [...front.matchAll(/^\s*nav:\s*(.+)$/gim)].some((m) => !/^\s*(none|n\/a|-)\b/i.test(m[1]));
|
|
5049
5177
|
const navRegion = /^\s*-\s*(nav|navigation)\s*:/im.test(front);
|
|
5050
5178
|
return navField || navRegion;
|
|
@@ -5095,7 +5223,7 @@ function checkArm(name, file, required, otherPass, extraAllowed, extraRequired,
|
|
|
5095
5223
|
function verifyVerdicts(opts) {
|
|
5096
5224
|
const root = opts.packageRoot ?? assetRoot();
|
|
5097
5225
|
const errors = [];
|
|
5098
|
-
const index = JSON.parse(
|
|
5226
|
+
const index = JSON.parse(readFileSync25(join31(root, "rules.index.json"), "utf8"));
|
|
5099
5227
|
const judgment = index.filter((r) => r.bucket === "judgment");
|
|
5100
5228
|
const screenIds = judgment.filter((r) => r.pass === "screen").map((r) => r.id);
|
|
5101
5229
|
const codeIds = judgment.filter((r) => r.pass === "code").map((r) => r.id);
|
|
@@ -5104,21 +5232,21 @@ function verifyVerdicts(opts) {
|
|
|
5104
5232
|
...judgment.map((r) => [r.id, r.pass ?? ""])
|
|
5105
5233
|
]);
|
|
5106
5234
|
const specIds = new Set(citableIds(root).filter((id) => /^[PMLRT]-\d+$/.test(id)));
|
|
5107
|
-
const dir =
|
|
5108
|
-
const screenFile = readJson(
|
|
5109
|
-
const codeFile = readJson(
|
|
5235
|
+
const dir = join31(opts.projectRoot, ".jig", "critique", opts.surface);
|
|
5236
|
+
const screenFile = readJson(join31(dir, "screen.json"), errors);
|
|
5237
|
+
const codeFile = readJson(join31(dir, "code.json"), errors);
|
|
5110
5238
|
const screenExtraRequired = specNeedsNav(opts.projectRoot, opts.surface) ? ["P-14"] : [];
|
|
5111
5239
|
const screen = checkArm("screen", screenFile, screenIds, passOf, specIds, screenExtraRequired, errors);
|
|
5112
5240
|
const code = checkArm("code", codeFile, codeIds, passOf, specIds, [], errors);
|
|
5113
5241
|
let rendered = false;
|
|
5114
5242
|
if (screenFile && screenFile.rendered === true) {
|
|
5115
5243
|
const artefacts = Array.isArray(screenFile.artefacts) ? screenFile.artefacts.filter((a) => typeof a === "string") : [];
|
|
5116
|
-
const absent = artefacts.filter((a) => !
|
|
5244
|
+
const absent = artefacts.filter((a) => !existsSync18(resolve4(opts.projectRoot, a)));
|
|
5117
5245
|
if (artefacts.length === 0) errors.push("screen.json says rendered: true but lists no artefacts. A render leaves a screenshot.");
|
|
5118
5246
|
else if (absent.length) errors.push(`screen.json says rendered: true, but these artefacts do not exist: ${absent.join(", ")}.`);
|
|
5119
5247
|
else rendered = true;
|
|
5120
5248
|
}
|
|
5121
|
-
const probes = readProbes(dir, errors);
|
|
5249
|
+
const probes = readProbes(opts.projectRoot, dir, errors);
|
|
5122
5250
|
if (screenFile && screenFile.rendered === true) {
|
|
5123
5251
|
const widths = new Set(probes.map((p) => p.width));
|
|
5124
5252
|
const missing = [360, 768, 1280].filter((w) => !widths.has(w));
|
|
@@ -5140,19 +5268,20 @@ function verifyVerdicts(opts) {
|
|
|
5140
5268
|
}
|
|
5141
5269
|
|
|
5142
5270
|
// src/commands/gate.ts
|
|
5143
|
-
import { existsSync as
|
|
5144
|
-
import {
|
|
5271
|
+
import { existsSync as existsSync20, mkdirSync as mkdirSync8, readdirSync as readdirSync13, readFileSync as readFileSync27, writeFileSync as writeFileSync7 } from "fs";
|
|
5272
|
+
import { createHash as createHash2 } from "crypto";
|
|
5273
|
+
import { join as join33 } from "path";
|
|
5145
5274
|
|
|
5146
5275
|
// src/check/spec-shape.ts
|
|
5147
|
-
import { existsSync as
|
|
5148
|
-
import { join as
|
|
5276
|
+
import { existsSync as existsSync19, readFileSync as readFileSync26, readdirSync as readdirSync12, statSync as statSync2 } from "fs";
|
|
5277
|
+
import { join as join32 } from "path";
|
|
5149
5278
|
function newestSpec(projectRoot) {
|
|
5150
|
-
const dir =
|
|
5151
|
-
if (!
|
|
5279
|
+
const dir = join32(projectRoot, ".jig", "specs");
|
|
5280
|
+
if (!existsSync19(dir)) return void 0;
|
|
5152
5281
|
const files = readdirSync12(dir).filter((f) => f.endsWith(".md"));
|
|
5153
5282
|
if (files.length === 0) return void 0;
|
|
5154
|
-
const newest = files.map((f) => ({ f, at: statSync2(
|
|
5155
|
-
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body:
|
|
5283
|
+
const newest = files.map((f) => ({ f, at: statSync2(join32(dir, f)).mtimeMs })).sort((a, b) => b.at - a.at)[0].f;
|
|
5284
|
+
return { path: `.jig/specs/${newest}`, slug: newest.replace(/\.spec\.md$|\.md$/, ""), body: readFileSync26(join32(dir, newest), "utf8") };
|
|
5156
5285
|
}
|
|
5157
5286
|
function specProblems(spec) {
|
|
5158
5287
|
const parts = spec.body.split(/^---\s*$/m);
|
|
@@ -5201,7 +5330,7 @@ function sizeBlock(sizes, size) {
|
|
|
5201
5330
|
}
|
|
5202
5331
|
return lines.join("\n");
|
|
5203
5332
|
}
|
|
5204
|
-
var MENU_RE =
|
|
5333
|
+
var MENU_RE = /(?<!\b(?:no|not|without|never)\s)(?<!\bno\s\w{1,12}\s)\b(menu|hamburger|drawer|burger)\b/i;
|
|
5205
5334
|
var NONE_RE = /^\s*(none|no navigation|n\/a|-)\b/i;
|
|
5206
5335
|
function navProblems(spec) {
|
|
5207
5336
|
const front = spec.body.split(/^---\s*$/m)[1] ?? "";
|
|
@@ -5221,10 +5350,10 @@ function navProblems(spec) {
|
|
|
5221
5350
|
// src/commands/gate.ts
|
|
5222
5351
|
var MAX_BLOCKS = 3;
|
|
5223
5352
|
function lastJigCommand(transcriptPath) {
|
|
5224
|
-
if (!transcriptPath || !
|
|
5353
|
+
if (!transcriptPath || !existsSync20(transcriptPath)) return void 0;
|
|
5225
5354
|
let text;
|
|
5226
5355
|
try {
|
|
5227
|
-
text =
|
|
5356
|
+
text = readFileSync27(transcriptPath, "utf8");
|
|
5228
5357
|
} catch {
|
|
5229
5358
|
return void 0;
|
|
5230
5359
|
}
|
|
@@ -5242,10 +5371,10 @@ function commandProblems(root, command) {
|
|
|
5242
5371
|
const problems = [];
|
|
5243
5372
|
const spec = newestSpec(root);
|
|
5244
5373
|
if (command === "decide") {
|
|
5245
|
-
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) =>
|
|
5374
|
+
const file = ["jig/DECISIONS.md", "DECISIONS.md", ".jig/DECISIONS.md"].map((p) => join33(root, p)).find((p) => existsSync20(p));
|
|
5246
5375
|
if (!file) problems.push("decide wrote no DECISIONS.md beside the token layer.");
|
|
5247
5376
|
else {
|
|
5248
|
-
const body =
|
|
5377
|
+
const body = readFileSync27(file, "utf8");
|
|
5249
5378
|
if (!/^##\s+Unresolved\s*$/im.test(body)) {
|
|
5250
5379
|
problems.push("DECISIONS.md has no `## Unresolved` section. Round 3 asks by name what is still undecided; write what the owner named, or `None named by the owner.`");
|
|
5251
5380
|
}
|
|
@@ -5266,13 +5395,13 @@ function commandProblems(root, command) {
|
|
|
5266
5395
|
const at = /^\s*mockup_at\s*:\s*(.+)$/im.exec(front)?.[1]?.trim().replace(/^["']|["']$/g, "") ?? "";
|
|
5267
5396
|
if (/^approved/i.test(mockup)) {
|
|
5268
5397
|
if (!at) problems.push(`${spec.path}: \`mockup_at:\` is empty. Record where the approved drawing is.`);
|
|
5269
|
-
else if (!/^https?:/i.test(at) && !
|
|
5398
|
+
else if (!/^https?:/i.test(at) && !existsSync20(join33(root, at))) problems.push(`${spec.path}: \`mockup_at: ${at}\` does not exist.`);
|
|
5270
5399
|
else if (!/^https?:/i.test(at) && !at.startsWith(".jig/mockups/")) problems.push(`The drawing is at ${at}. A mockup lives in .jig/mockups/, outside what check scans and outside what ships.`);
|
|
5271
5400
|
}
|
|
5272
5401
|
}
|
|
5273
5402
|
if (command === "critique") {
|
|
5274
|
-
const dir =
|
|
5275
|
-
const surfaces =
|
|
5403
|
+
const dir = join33(root, ".jig", "critique");
|
|
5404
|
+
const surfaces = existsSync20(dir) ? readdirSync13(dir).filter((s) => existsSync20(join33(dir, s, "screen.json")) || existsSync20(join33(dir, s, "code.json"))) : [];
|
|
5276
5405
|
for (const surface of surfaces) {
|
|
5277
5406
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5278
5407
|
if (v.ok && v.screen.state === "skipped") {
|
|
@@ -5287,7 +5416,7 @@ function commandProblems(root, command) {
|
|
|
5287
5416
|
}
|
|
5288
5417
|
function gate(opts) {
|
|
5289
5418
|
const root = opts.projectRoot;
|
|
5290
|
-
if (!
|
|
5419
|
+
if (!existsSync20(join33(root, "jig.config.json")) && !existsSync20(join33(root, ".jig"))) {
|
|
5291
5420
|
return { block: false, reason: "" };
|
|
5292
5421
|
}
|
|
5293
5422
|
const command = lastJigCommand(opts.input.transcript_path);
|
|
@@ -5306,11 +5435,11 @@ function gate(opts) {
|
|
|
5306
5435
|
);
|
|
5307
5436
|
}
|
|
5308
5437
|
}
|
|
5309
|
-
const critiqueDir =
|
|
5310
|
-
if (
|
|
5438
|
+
const critiqueDir = join33(root, ".jig", "critique");
|
|
5439
|
+
if (existsSync20(critiqueDir)) {
|
|
5311
5440
|
for (const surface of readdirSync13(critiqueDir)) {
|
|
5312
|
-
const dir =
|
|
5313
|
-
if (!
|
|
5441
|
+
const dir = join33(critiqueDir, surface);
|
|
5442
|
+
if (!existsSync20(join33(dir, "screen.json")) && !existsSync20(join33(dir, "code.json"))) continue;
|
|
5314
5443
|
const v = verifyVerdicts({ projectRoot: root, surface });
|
|
5315
5444
|
if (!v.ok) {
|
|
5316
5445
|
const shown = v.errors.slice(0, 6).map((e) => ` ${e}`);
|
|
@@ -5323,20 +5452,22 @@ function gate(opts) {
|
|
|
5323
5452
|
}
|
|
5324
5453
|
}
|
|
5325
5454
|
const session = opts.input.session_id ?? "unknown";
|
|
5326
|
-
const
|
|
5455
|
+
const key = `${session}:${createHash2("sha256").update(problems.join("\n")).digest("hex").slice(0, 12)}`;
|
|
5456
|
+
const stateFile = join33(root, ".jig", "gate.json");
|
|
5327
5457
|
let state = {};
|
|
5328
5458
|
try {
|
|
5329
|
-
state = JSON.parse(
|
|
5459
|
+
state = JSON.parse(readFileSync27(stateFile, "utf8"));
|
|
5330
5460
|
} catch {
|
|
5331
5461
|
}
|
|
5332
5462
|
if (problems.length === 0) {
|
|
5333
|
-
|
|
5334
|
-
|
|
5463
|
+
const mine = Object.keys(state).filter((k) => k.startsWith(`${session}:`));
|
|
5464
|
+
if (mine.length) {
|
|
5465
|
+
for (const k of mine) delete state[k];
|
|
5335
5466
|
save(stateFile, state);
|
|
5336
5467
|
}
|
|
5337
5468
|
return { block: false, reason: "" };
|
|
5338
5469
|
}
|
|
5339
|
-
const count = (state[
|
|
5470
|
+
const count = (state[key] ?? 0) + 1;
|
|
5340
5471
|
if (count > MAX_BLOCKS) {
|
|
5341
5472
|
return {
|
|
5342
5473
|
block: false,
|
|
@@ -5344,7 +5475,7 @@ function gate(opts) {
|
|
|
5344
5475
|
${problems.join("\n\n")}`
|
|
5345
5476
|
};
|
|
5346
5477
|
}
|
|
5347
|
-
state[
|
|
5478
|
+
state[key] = count;
|
|
5348
5479
|
save(stateFile, state);
|
|
5349
5480
|
return {
|
|
5350
5481
|
block: true,
|
|
@@ -5355,15 +5486,15 @@ ${problems.join("\n\n")}`
|
|
|
5355
5486
|
}
|
|
5356
5487
|
function save(file, state) {
|
|
5357
5488
|
try {
|
|
5358
|
-
|
|
5359
|
-
|
|
5489
|
+
mkdirSync8(join33(file, ".."), { recursive: true });
|
|
5490
|
+
writeFileSync7(file, JSON.stringify(state), "utf8");
|
|
5360
5491
|
} catch {
|
|
5361
5492
|
}
|
|
5362
5493
|
}
|
|
5363
5494
|
|
|
5364
5495
|
// src/index.ts
|
|
5365
5496
|
var packageRoot = getPackageRoot();
|
|
5366
|
-
var { version } = JSON.parse(
|
|
5497
|
+
var { version } = JSON.parse(readFileSync28(join34(packageRoot, "package.json"), "utf8"));
|
|
5367
5498
|
function warnIfUnpublishedPin() {
|
|
5368
5499
|
if (isPublishedBuild(packageRoot)) return;
|
|
5369
5500
|
console.warn(
|
|
@@ -5489,13 +5620,33 @@ program.command("check").description("Check the repo against Jig's mechanical +
|
|
|
5489
5620
|
process.exit(1);
|
|
5490
5621
|
}
|
|
5491
5622
|
});
|
|
5492
|
-
program.command("probe").description("Print the render probe
|
|
5493
|
-
|
|
5623
|
+
program.command("probe").description("Print the render probe. With --save, read what it returned on stdin and record it for `jig verdicts`.").option("--save <surface>", "record the probe's output (piped in) under .jig/critique/<surface>/").action(async (opts) => {
|
|
5624
|
+
if (!opts.save) {
|
|
5625
|
+
console.log(PROBE_SCRIPT);
|
|
5626
|
+
return;
|
|
5627
|
+
}
|
|
5628
|
+
const projectRoot = findProjectRoot(process.cwd());
|
|
5629
|
+
try {
|
|
5630
|
+
const json = process.stdin.isTTY ? "" : await new Promise((done, fail) => {
|
|
5631
|
+
let text = "";
|
|
5632
|
+
process.stdin.setEncoding("utf8");
|
|
5633
|
+
process.stdin.on("data", (chunk) => {
|
|
5634
|
+
text += chunk;
|
|
5635
|
+
});
|
|
5636
|
+
process.stdin.on("end", () => done(text));
|
|
5637
|
+
process.stdin.on("error", fail);
|
|
5638
|
+
});
|
|
5639
|
+
const result = saveProbe({ projectRoot, surface: opts.save, json });
|
|
5640
|
+
console.log(` Recorded ${result.path} \u2014 ${result.page} at ${result.width}px.`);
|
|
5641
|
+
} catch (err) {
|
|
5642
|
+
console.error(` \u2717 ${err.message}`);
|
|
5643
|
+
process.exit(1);
|
|
5644
|
+
}
|
|
5494
5645
|
});
|
|
5495
5646
|
program.command("gate").description("Run by the Claude Code Stop hook: block stopping while check or a critique fails.").action(() => {
|
|
5496
5647
|
let input = {};
|
|
5497
5648
|
try {
|
|
5498
|
-
if (!process.stdin.isTTY) input = JSON.parse(
|
|
5649
|
+
if (!process.stdin.isTTY) input = JSON.parse(readFileSync28(0, "utf8") || "{}");
|
|
5499
5650
|
} catch {
|
|
5500
5651
|
}
|
|
5501
5652
|
const cwd = input.cwd ?? process.cwd();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jig-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "A design system for coding agents.
|
|
3
|
+
"version": "0.12.0",
|
|
4
|
+
"description": "A design system for coding agents. 114 numbered UI rules, brand x mode design tokens, and an installer for Claude Code, Codex, Cursor and opencode.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/rules/05-copy.md
CHANGED
|
@@ -83,6 +83,14 @@ The one legitimate use is a **short label** distinguishing itself from nearby te
|
|
|
83
83
|
✅ Most interface text is too short to need them. Use a full stop only where the text is a complete sentence containing commas.
|
|
84
84
|
Whichever you choose, be consistent across sibling elements — a list where three items end in a stop and two do not looks like a mistake, because it is one.
|
|
85
85
|
|
|
86
|
+
### I-118 Em dashes in interface text
|
|
87
|
+
❌ "Your plan — including every seat — renews monthly", "Free — forever", "Deleted — this cannot be undone"
|
|
88
|
+
✅ A full stop, a comma, a colon or a new element. "Your plan renews monthly, including every seat." "Free forever." "Deleted. This cannot be undone."
|
|
89
|
+
Interface text is read in fragments, at a glance, in a space someone else's content has to fit too. An em dash is a pause the reader has to interpret: it stands in for a comma, a colon, a bracket or a full stop, and which one it is only becomes clear after reading past it. The punctuation that says exactly one thing is faster.
|
|
90
|
+
It is also the clearest tell of machine-written copy. Generated text reaches for the em dash far more often than a person does, and readers have learned to notice. Copy that reads as generated is copy the reader trusts less, whatever it says.
|
|
91
|
+
This is about interface strings — labels, buttons, headings, errors, empty states, help text, and the prose a page ships. It is not a rule about your documentation, your commit messages or this file.
|
|
92
|
+
The en dash keeps its one job: ranges, where it is read as "to" (`2–10 seats`, `Mon–Fri`). That is not a pause, and it is not affected.
|
|
93
|
+
|
|
86
94
|
### I-87 Inconsistent vocabulary
|
|
87
95
|
❌ "Add to cart" beside a "Bag" icon; "Sign up" on the page and "Register" in the nav
|
|
88
96
|
✅ One word per concept, everywhere. Keep a term list in the project and follow it.
|
package/rules.index.json
CHANGED
|
@@ -853,10 +853,18 @@ probe in the browser and save what it returns beside the verdicts:
|
|
|
853
853
|
|
|
854
854
|
```
|
|
855
855
|
{{scripts_path}} probe > .jig/probe.js
|
|
856
|
-
# at each width: set the viewport, open the page, evaluate .jig/probe.js,
|
|
857
|
-
#
|
|
856
|
+
# at each width: set the viewport, open the page, evaluate .jig/probe.js, and pipe
|
|
857
|
+
# exactly what it returned into:
|
|
858
|
+
{{scripts_path}} probe --save <surface>
|
|
858
859
|
```
|
|
859
860
|
|
|
861
|
+
**The CLI writes the probe file, you do not.** `--save` reads the probe's output,
|
|
862
|
+
stamps it with the page's checksum, and stores it. That is what makes the file a
|
|
863
|
+
measurement: `verdicts` recomputes the checksum, so a file written by hand, or one
|
|
864
|
+
taken before the page's last edit, is rejected. A live run wrote a probe file itself
|
|
865
|
+
— five links and Escape closing a menu that has thirteen links and no Escape handler
|
|
866
|
+
— and its review passed on those numbers.
|
|
867
|
+
|
|
860
868
|
The probe opens the phone menu, presses Escape, measures sideways scroll, and reads
|
|
861
869
|
whether the styles and tokens actually applied. `verdicts` refuses `rendered: true`
|
|
862
870
|
without a probe at each width, and refuses an `ok` the probe contradicts — a `P-14`
|
|
@@ -30,8 +30,8 @@
|
|
|
30
30
|
"status": "available"
|
|
31
31
|
},
|
|
32
32
|
"probe": {
|
|
33
|
-
"description": "Print the render probe: a script the critique runs in a browser at 360, 768 and 1280px.
|
|
34
|
-
"argumentHint": "",
|
|
33
|
+
"description": "Print the render probe: a script the critique runs in a browser at 360, 768 and 1280px. With --save it records what the probe returned, stamped with the page's checksum, so `verdicts` can tell a measurement from a claim.",
|
|
34
|
+
"argumentHint": "[--save <surface>]",
|
|
35
35
|
"status": "available"
|
|
36
36
|
},
|
|
37
37
|
"gate": {
|