@ericrisco/rsc 1.0.33 → 1.1.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.
@@ -0,0 +1,164 @@
1
+ #!/usr/bin/env python3
2
+ """Scaffold and validate an emitted style skill (design-dna Step 6).
3
+
4
+ python3 emit.py <slug> scaffold ~/.claude/skills/<slug>/ from templates
5
+ python3 emit.py <slug> --validate validate an existing one, exit non-zero on failure
6
+
7
+ Validation enforces the rules that are the whole point of the method, so a
8
+ half-finished capture cannot pass itself off as a skill:
9
+ - dna.json parses and satisfies design-dna.schema.json (required keys, bounds)
10
+ - signatures: 3 to 9 - bans: 5 or more (as absolutes)
11
+ - tests: 8 or more - reconstruction.attempted is true (Step 5 was run)
12
+ - PROMPT.md is at most 2048 bytes and ends with the self-check line
13
+ - reference/ is not empty (the image is mandatory)
14
+ """
15
+ import json, os, re, shutil, sys
16
+
17
+ HERE = os.path.dirname(os.path.abspath(__file__))
18
+ PARENT = os.path.dirname(HERE)
19
+ SKILLS = os.path.dirname(PARENT)
20
+ CAP = 2048
21
+ LAST_LINE = "Before returning any output, run every test in the self-check."
22
+
23
+ REQUIRED_TOP = ["meta", "soul", "palette", "type", "space", "signatures",
24
+ "weird_move", "bans", "tests", "reconstruction"]
25
+
26
+
27
+ def scaffold(slug):
28
+ dest = os.path.join(SKILLS, slug)
29
+ if os.path.exists(dest):
30
+ sys.exit(f"refusing to overwrite {dest} - one skill per style, and this one exists")
31
+ for sub in ("reference", "example", "scripts"):
32
+ os.makedirs(os.path.join(dest, sub))
33
+ for src, dst in (("templates/SKILL.md.tmpl", "SKILL.md"),
34
+ ("templates/PROMPT.md.tmpl", "PROMPT.md"),
35
+ ("templates/check.py.tmpl", "scripts/check.py")):
36
+ shutil.copy(os.path.join(PARENT, src), os.path.join(dest, dst))
37
+ os.chmod(os.path.join(dest, "scripts/check.py"), 0o755)
38
+ shutil.copy(os.path.join(PARENT, "design-dna.schema.json"),
39
+ os.path.join(dest, "design-dna.schema.json"))
40
+ print(f"scaffolded {dest}")
41
+ print("next: write dna.json, fill the __PLACEHOLDERS__, drop the reference image in reference/,")
42
+ print(f" build one worked output into example/, then: python3 {__file__} {slug} --validate")
43
+
44
+
45
+ def fail(errs, slug):
46
+ for e in errs:
47
+ print(f" FAIL {e}")
48
+ print(f"\n{len(errs)} problem(s). {slug} is not a finished skill yet.")
49
+ return 1
50
+
51
+
52
+ def validate(slug):
53
+ d = os.path.join(SKILLS, slug)
54
+ errs = []
55
+ if not os.path.isdir(d):
56
+ sys.exit(f"no such skill: {d}")
57
+
58
+ dna_path = os.path.join(d, "dna.json")
59
+ dna = None
60
+ if not os.path.isfile(dna_path):
61
+ errs.append("dna.json missing")
62
+ else:
63
+ try:
64
+ dna = json.load(open(dna_path))
65
+ except json.JSONDecodeError as e:
66
+ errs.append(f"dna.json does not parse: {e}")
67
+
68
+ if dna:
69
+ for k in REQUIRED_TOP:
70
+ if k not in dna:
71
+ errs.append(f"dna.json missing required key: {k}")
72
+ sigs = dna.get("signatures", [])
73
+ if not 3 <= len(sigs) <= 9:
74
+ errs.append(f"signatures must be 3-9, found {len(sigs)} (the cap forces a decision)")
75
+ for s in sigs:
76
+ for f in ("move", "how", "when", "never"):
77
+ if not s.get(f):
78
+ errs.append(f"signature {s.get('move', '?')!r} missing {f}")
79
+ bans = dna.get("bans", [])
80
+ if len(bans) < 5:
81
+ errs.append(f"bans must be 5 or more, found {len(bans)}")
82
+ if len(bans) < len(sigs):
83
+ errs.append("bans should outnumber positive style rules; they do the heavy lifting")
84
+ tests = dna.get("tests", [])
85
+ if len(tests) < 8:
86
+ errs.append(f"tests must be 8 or more, found {len(tests)}")
87
+ for t in tests:
88
+ for f in ("id", "check", "fail_looks_like"):
89
+ if not t.get(f):
90
+ errs.append(f"test {t.get('id', '?')!r} missing {f}")
91
+ if "auto" not in t:
92
+ errs.append(f"test {t.get('id', '?')!r} must declare auto: true|false")
93
+ vague = [t["id"] for t in tests if re.search(
94
+ r"\b(feels?|looks? (clean|premium|nice|good)|elegant|modern|polished)\b",
95
+ t.get("check", ""), re.I)]
96
+ if vague:
97
+ errs.append(f"not tests, two people could disagree: {vague}")
98
+ rec = dna.get("reconstruction", {})
99
+ if not rec.get("attempted"):
100
+ errs.append("reconstruction.attempted is false: Step 5 was skipped, so the spec is untested")
101
+ elif not rec.get("gaps_found"):
102
+ errs.append("reconstruction.gaps_found is empty: a first rebuild that found nothing means "
103
+ "you did not really close the reference")
104
+ cov = dna.get("palette", {}).get("coverage") or {}
105
+ if not cov:
106
+ errs.append("palette.coverage missing: the most-skipped and most-decisive field")
107
+ elif not 90 <= sum(float(v) for v in cov.values()) <= 110:
108
+ errs.append(f"palette.coverage sums to {sum(float(v) for v in cov.values())}, expected ~100")
109
+ for c in dna.get("palette", {}).get("colors", []):
110
+ if re.match(r"^[a-z]+-\d{2,3}$", str(c.get("name", ""))):
111
+ errs.append(f"colour name {c['name']!r} is systematic; image models cannot read token "
112
+ "names. Use a descriptive name.")
113
+ for f in dna.get("type", {}).get("families", []):
114
+ if not f.get("fallback"):
115
+ errs.append(f"type family {f.get('family', '?')!r} has no fallback: silent Arial "
116
+ "substitution is how a reproduction dies quietly")
117
+ sp = dna.get("space", {})
118
+ if isinstance(sp.get("margin_pct"), str) and "px" in sp["margin_pct"]:
119
+ errs.append("space uses pixels; percentages are what let one spec drive a carousel and a slide")
120
+
121
+ p = os.path.join(d, "PROMPT.md")
122
+ if not os.path.isfile(p):
123
+ errs.append("PROMPT.md missing")
124
+ else:
125
+ raw = open(p, "rb").read()
126
+ body = re.sub(rb"<!--.*?-->", b"", raw, flags=re.S).strip()
127
+ if len(body) > CAP:
128
+ errs.append(f"PROMPT.md is {len(body)}B of payload, cap is {CAP}B. Cut, do not negotiate.")
129
+ txt = body.decode("utf-8", "replace")
130
+ if LAST_LINE not in txt:
131
+ errs.append("PROMPT.md does not end with the mandatory self-check line")
132
+ if "__" in txt:
133
+ errs.append("PROMPT.md still contains __PLACEHOLDERS__")
134
+ if "dna.json" in txt:
135
+ errs.append("PROMPT.md references dna.json; the record never enters a prompt")
136
+
137
+ ref = os.path.join(d, "reference")
138
+ if not os.path.isdir(ref) or not os.listdir(ref):
139
+ errs.append("reference/ is empty. The image is mandatory and close to free.")
140
+ ex = os.path.join(d, "example")
141
+ if not os.path.isdir(ex) or not os.listdir(ex):
142
+ errs.append("example/ is empty. One worked output is the canonical proof.")
143
+ s = os.path.join(d, "SKILL.md")
144
+ if not os.path.isfile(s):
145
+ errs.append("SKILL.md missing")
146
+ elif "__" in open(s).read():
147
+ errs.append("SKILL.md still contains __PLACEHOLDERS__")
148
+
149
+ if errs:
150
+ return fail(errs, slug)
151
+ print(f" PASS {slug} is a finished style skill.")
152
+ print(f" signatures {len(dna['signatures'])}, bans {len(dna['bans'])}, "
153
+ f"tests {len(dna['tests'])}, reconstruction passes {dna['reconstruction'].get('passes', '?')}")
154
+ return 0
155
+
156
+
157
+ if __name__ == "__main__":
158
+ if len(sys.argv) < 2:
159
+ print(__doc__)
160
+ sys.exit(2)
161
+ slug = sys.argv[1]
162
+ if not re.fullmatch(r"[a-z0-9]+(-[a-z0-9]+)*", slug):
163
+ sys.exit("slug must be kebab-case")
164
+ sys.exit(validate(slug) if "--validate" in sys.argv else (scaffold(slug) or 0))
@@ -0,0 +1,42 @@
1
+ <!-- __NAME__ - the payload. HARD CAP 2KB of text.
2
+ Order is fixed: attention is strongest at both ends, weakest in the middle.
3
+ To add a tenth signature you must delete one. dna.json never goes in here. -->
4
+
5
+ # Style: __NAME__
6
+
7
+ **Reference image: `reference/__REF__` - attached. Match it before you match these words.**
8
+
9
+ **Soul.** __ONE_LINE__
10
+
11
+ ## The weird move (present exactly once, never twice)
12
+
13
+ __WEIRD_MOVE__
14
+
15
+ ## Signature moves (as ratios)
16
+
17
+ 1. __SIG_1__
18
+ 2. __SIG_2__
19
+ 3. __SIG_3__
20
+
21
+ ## Never
22
+
23
+ - __BAN_1__
24
+ - __BAN_2__
25
+ - __BAN_3__
26
+ - __BAN_4__
27
+ - __BAN_5__
28
+
29
+ ## Palette and type (roles and coverage only)
30
+
31
+ __PALETTE_LINE__
32
+ __TYPE_LINE__
33
+
34
+ ## Layouts
35
+
36
+ __ARCHETYPE_LINES__
37
+
38
+ ## Self-check
39
+
40
+ __TEST_LINES__
41
+
42
+ Before returning any output, run every test in the self-check. Name each test and its result. If any fails, repair the output and run them again. Never return output with a failing test and a note explaining it away.
@@ -0,0 +1,42 @@
1
+ ---
2
+ name: __SLUG__
3
+ description: "Use when building anything in the __NAME__ style: __ONE_LINE__ Fires on '__SLUG__', 'in the __NAME__ style', 'another one like <reference>', 'same look as before'. Captured from __MEDIUM__; drives any format. NOT inventing a new direction (that is `design`), NOT re-deriving this style (its record already exists in dna.json)."
4
+ tags: [style, design-dna, __SLUG__]
5
+ recommends: [design-dna, nextjs, marketing]
6
+ origin: design-dna
7
+ ---
8
+
9
+ # __NAME__
10
+
11
+ *__ONE_LINE__*
12
+
13
+ Captured __CAPTURED__ from __MEDIUM__. Reference kept in `reference/`. Worked proof in `example/`.
14
+
15
+ ## How to use this
16
+
17
+ 1. Load **`PROMPT.md` only**, and attach `reference/__REF__`. Never paste `dna.json` into a prompt: it is the record, not the payload, and its size is exactly what breaks compliance.
18
+ 2. Pick an archetype by name before writing a line. Structure first, or piece 8 will not sit beside piece 1 as a set.
19
+ 3. Build. Web build goes to `../nextjs/SKILL.md`, words to `../marketing/SKILL.md`; the identity stays here.
20
+ 4. Run the self-check. Name every test and its result:
21
+
22
+ python3 scripts/check.py <output files or a rendered PNG>
23
+
24
+ Non-zero exit means do not ship. Answer the MANUAL tests out loud.
25
+
26
+ ## What this style is, in one screen
27
+
28
+ __SUMMARY__
29
+
30
+ ## Do not
31
+
32
+ __BANS__
33
+
34
+ ## Known drift risks
35
+
36
+ __CONFIDENCE__
37
+
38
+ These are the values that were inferred rather than measured. If output starts feeling off, re-measure these first.
39
+
40
+ ## Maintenance
41
+
42
+ Re-capture only when the reference itself changes. To extend the style, edit `dna.json`, then recompile `PROMPT.md` under the 2KB cap: adding a signature means deleting one. Method and rationale: `../design-dna/SKILL.md`.
@@ -0,0 +1,143 @@
1
+ #!/usr/bin/env python3
2
+ """Auto tests for the __NAME__ style. Exits non-zero on any failure.
3
+
4
+ python3 scripts/check.py <output.html|output.css|output.png> [more files...]
5
+
6
+ Reads dna.json and runs every test marked "auto": true that carries a "probe".
7
+ Tests without a probe are reported as MANUAL and never silently pass.
8
+ A probe handed no artefact it can read is reported as SKIP with its reason -
9
+ never as PASS or FAIL.
10
+
11
+ Probe kinds
12
+ -----------
13
+ regex_absent {"kind":"regex_absent","pattern":"..."} fails if the pattern appears
14
+ regex_count {"kind":"regex_count","pattern":"...","exactly":1} or "min"/"max"
15
+ distinct_css {"kind":"distinct_css","property":"font-size","max":3} distinct declared values
16
+ css_ratio {"kind":"css_ratio","property":"font-size","min":6.0} largest / smallest declared
17
+ min_unit {"kind":"min_unit","property":"font-size","min_px":28} smallest declared value
18
+ color_coverage {"kind":"color_coverage","hex":"#ff4d00","max_pct":8,
19
+ "tolerance":24} needs an image file + Pillow
20
+ """
21
+ import json, os, re, sys
22
+
23
+ HERE = os.path.dirname(os.path.abspath(__file__))
24
+ DNA_PATH = os.path.join(HERE, "..", "dna.json")
25
+ if not os.path.isfile(DNA_PATH):
26
+ sys.exit("no dna.json beside this script, so there is nothing to check against.\n"
27
+ "This style skill was scaffolded and never finished. Fix:\n"
28
+ " write dna.json, then validate it with\n"
29
+ " python3 ../design-dna/scripts/emit.py <slug> --validate")
30
+ DNA = json.load(open(DNA_PATH))
31
+
32
+ TEXT_EXT = {".html", ".htm", ".css", ".scss", ".jsx", ".tsx", ".svg", ".md", ".js", ".ts"}
33
+ IMG_EXT = {".png", ".jpg", ".jpeg", ".webp"}
34
+
35
+
36
+ def load(paths):
37
+ text, images = "", []
38
+ for p in paths:
39
+ ext = os.path.splitext(p)[1].lower()
40
+ if ext in IMG_EXT:
41
+ images.append(p)
42
+ elif ext in TEXT_EXT or os.path.isfile(p):
43
+ text += "\n" + open(p, encoding="utf-8", errors="replace").read()
44
+ return text, images
45
+
46
+
47
+ def css_values(text, prop):
48
+ out = []
49
+ for raw in re.findall(rf"{re.escape(prop)}\s*:\s*([^;}}\"']+)", text, re.I):
50
+ m = re.match(r"\s*(-?[\d.]+)\s*(px|rem|em|pt|%)?", raw)
51
+ if not m:
52
+ continue
53
+ n = float(m.group(1))
54
+ unit = (m.group(2) or "px").lower()
55
+ out.append(n * 16 if unit in ("rem", "em") else n)
56
+ return out
57
+
58
+
59
+ def coverage(path, hex_target, tol):
60
+ try:
61
+ from PIL import Image
62
+ except ImportError:
63
+ return None, "Pillow not installed (pip install pillow)"
64
+ tr, tg, tb = (int(hex_target.lstrip("#")[i:i + 2], 16) for i in (0, 2, 4))
65
+ im = Image.open(path).convert("RGB")
66
+ im.thumbnail((400, 400))
67
+ px = list(im.get_flattened_data()) if hasattr(im, "get_flattened_data") else list(im.getdata())
68
+ hit = sum(1 for r, g, b in px if abs(r - tr) <= tol and abs(g - tg) <= tol and abs(b - tb) <= tol)
69
+ return 100.0 * hit / max(1, len(px)), None
70
+
71
+
72
+ # Probes that read declared values. Handed no text they must SKIP: a regex over an empty
73
+ # string is not a check, and calling it PASS or FAIL is the gate lying in one direction
74
+ # or the other.
75
+ TEXT_PROBES = ("regex_absent", "regex_count", "distinct_css", "css_ratio", "min_unit")
76
+
77
+
78
+ def run(test, text, images):
79
+ pr = test.get("probe")
80
+ if not pr:
81
+ return "MANUAL", "no probe declared"
82
+ k = pr.get("kind")
83
+ if k in TEXT_PROBES and not text.strip():
84
+ return "SKIP", "no text artefact passed; this probe reads declarations, not pixels"
85
+ if k == "regex_absent":
86
+ hits = re.findall(pr["pattern"], text, re.I)
87
+ return ("FAIL" if hits else "PASS"), f"{len(hits)} match(es)"
88
+ if k == "regex_count":
89
+ n = len(re.findall(pr["pattern"], text, re.I))
90
+ ok = (n == pr["exactly"]) if "exactly" in pr else (pr.get("min", 0) <= n <= pr.get("max", 10 ** 9))
91
+ return ("PASS" if ok else "FAIL"), f"found {n}"
92
+ if k == "distinct_css":
93
+ vals = sorted(set(css_values(text, pr["property"])))
94
+ return ("PASS" if len(vals) <= pr["max"] else "FAIL"), f"{len(vals)} distinct: {vals}"
95
+ if k == "css_ratio":
96
+ vals = [v for v in css_values(text, pr["property"]) if v > 0]
97
+ if not vals:
98
+ return "SKIP", f"no {pr['property']} declarations found"
99
+ r = max(vals) / min(vals)
100
+ return ("PASS" if r >= pr["min"] else "FAIL"), f"ratio {r:.2f}:1"
101
+ if k == "min_unit":
102
+ vals = [v for v in css_values(text, pr["property"]) if v > 0]
103
+ if not vals:
104
+ return "SKIP", f"no {pr['property']} declarations found"
105
+ return ("PASS" if min(vals) >= pr["min_px"] else "FAIL"), f"smallest {min(vals)}px"
106
+ if k == "color_coverage":
107
+ if not images:
108
+ return "SKIP", "no image passed; render the output to PNG to check coverage"
109
+ pct, err = coverage(images[0], pr["hex"], pr.get("tolerance", 24))
110
+ if err:
111
+ return "SKIP", err
112
+ lo, hi = pr.get("min_pct", 0), pr.get("max_pct", 100)
113
+ return ("PASS" if lo <= pct <= hi else "FAIL"), f"{pct:.1f}% of canvas"
114
+ return "SKIP", f"unknown probe kind {k!r}"
115
+
116
+
117
+ def main():
118
+ if len(sys.argv) < 2:
119
+ print(__doc__)
120
+ return 2
121
+ text, images = load(sys.argv[1:])
122
+ failed = manual = 0
123
+ print(f"design-dna check: {DNA['meta']['name']}\n")
124
+ for t in DNA.get("tests", []):
125
+ if not t.get("auto"):
126
+ status, detail = "MANUAL", "human judgement"
127
+ else:
128
+ status, detail = run(t, text, images)
129
+ failed += status == "FAIL"
130
+ manual += status == "MANUAL"
131
+ print(f" [{status:6}] {t['id']:28} {t['check']}")
132
+ if detail:
133
+ print(f" {detail}")
134
+ if status == "FAIL":
135
+ print(f" fail looks like: {t['fail_looks_like']}")
136
+ print(f"\n{failed} failed, {manual} need human judgement.")
137
+ if manual:
138
+ print("Answer the MANUAL tests out loud before you call this done.")
139
+ return 1 if failed else 0
140
+
141
+
142
+ if __name__ == "__main__":
143
+ sys.exit(main())