diff --git a/README.md b/README.md index 137c6e6..a06ac32 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,7 @@ flowchart LR | Scoped guidance | `*/AGENTS.md.template` | Folder-specific rules for frontend, backend, tests, ops, and more | | Project config | [`.codex/config.toml.template`](.codex/config.toml.template) | Safe, repo-scoped Codex defaults | | Automatic setup | [`skills/bootstrap-project/`](skills/bootstrap-project/) | Inspect, prepare, validate, then continue the original task | +| Design Genome | [`skills/frontend-divergence/`](skills/frontend-divergence/) | Explore distinct art directions before frontend implementation | | Model strategy | [`CODEX_COST_AND_SAFETY_POLICY.md`](CODEX_COST_AND_SAFETY_POLICY.md) | Match capability and reasoning to task value | | Task workflows | [`CODEX_TASK_PROMPT_LIBRARY.md`](CODEX_TASK_PROMPT_LIBRARY.md) | Reusable implementation, debugging, research, and review prompts | | Quality gates | [`CODEX_PR_REVIEW_GUIDELINES.md`](CODEX_PR_REVIEW_GUIDELINES.md) | Review behavior and evidence requirements | @@ -60,13 +61,14 @@ cd codex-flightdeck python scripts/validate.py ``` -### 2. Install the bootstrap skill +### 2. Install the skills macOS/Linux: ```bash mkdir -p ~/.codex/skills cp -R skills/bootstrap-project ~/.codex/skills/bootstrap-project +cp -R skills/frontend-divergence ~/.codex/skills/frontend-divergence ``` PowerShell: @@ -74,10 +76,17 @@ PowerShell: ```powershell New-Item -ItemType Directory -Force "$HOME\.codex\skills" | Out-Null Copy-Item -Recurse .\skills\bootstrap-project "$HOME\.codex\skills\bootstrap-project" +Copy-Item -Recurse .\skills\frontend-divergence "$HOME\.codex\skills\frontend-divergence" ``` Restart Codex or start a new task so the skill list refreshes. +`frontend-divergence` triggers for greenfield websites and visually significant +redesigns. It generates structurally different directions, selects one design +contract, and then hands that contract to the applicable frontend or taste +skill. Invoke `$frontend-divergence` explicitly when a brief needs unusually +high creative range. + ### 3. Merge personal guidance Review [`GLOBAL_AGENTS.md.template`](GLOBAL_AGENTS.md.template), then merge the sections you want into `~/.codex/AGENTS.md`. Do not blindly replace an existing personal file. diff --git a/scripts/validate.py b/scripts/validate.py index 12496a8..77e1408 100644 --- a/scripts/validate.py +++ b/scripts/validate.py @@ -22,6 +22,10 @@ ".codex/config.toml.template", "skills/bootstrap-project/SKILL.md", "skills/bootstrap-project/agents/openai.yaml", + "skills/frontend-divergence/SKILL.md", + "skills/frontend-divergence/agents/openai.yaml", + "skills/frontend-divergence/scripts/direction_lab.py", + "skills/frontend-divergence/references/creative-mechanics.md", ) PUBLIC_RISK_PATTERNS = { @@ -39,7 +43,11 @@ def text_files() -> list[Path]: return [ path for path in ROOT.rglob("*") - if path.is_file() and ".git" not in path.parts and path.name != "LICENSE" + if path.is_file() + and ".git" not in path.parts + and "__pycache__" not in path.parts + and path.suffix != ".pyc" + and path.name != "LICENSE" ] @@ -76,16 +84,28 @@ def validate_toml(errors: list[str]) -> None: errors.append(f"invalid TOML in {path.relative_to(ROOT)}: {exc}") -def validate_skill(errors: list[str]) -> None: - path = ROOT / "skills" / "bootstrap-project" / "SKILL.md" - if not path.is_file(): - return - content = path.read_text(encoding="utf-8") - if not content.startswith("---\n"): - errors.append("bootstrap skill is missing YAML frontmatter") - for field in ("name:", "description:"): - if field not in content.split("---", 2)[1]: - errors.append(f"bootstrap skill frontmatter is missing {field}") +def validate_skills(errors: list[str]) -> None: + for directory in sorted((ROOT / "skills").iterdir()): + if not directory.is_dir(): + continue + path = directory / "SKILL.md" + if not path.is_file(): + errors.append(f"skill is missing SKILL.md: {directory.name}") + continue + content = path.read_text(encoding="utf-8") + if not content.startswith("---\n") or content.count("---") < 2: + errors.append(f"{directory.name} skill is missing YAML frontmatter") + continue + frontmatter = content.split("---", 2)[1] + for field in ("name:", "description:"): + if field not in frontmatter: + errors.append(f"{directory.name} skill frontmatter is missing {field}") + if f"name: {directory.name}\n" not in frontmatter: + errors.append(f"skill name does not match folder: {directory.name}") + if re.search(r"\[TODO|\bTODO:", content): + errors.append(f"unfinished TODO in skill: {directory.name}") + if not (directory / "agents" / "openai.yaml").is_file(): + errors.append(f"skill is missing agents/openai.yaml: {directory.name}") def validate_markdown_links(errors: list[str]) -> None: @@ -112,7 +132,7 @@ def main() -> int: validate_required(errors) validate_utf8_and_public_content(errors) validate_toml(errors) - validate_skill(errors) + validate_skills(errors) validate_markdown_links(errors) if errors: diff --git a/skills/frontend-divergence/SKILL.md b/skills/frontend-divergence/SKILL.md new file mode 100644 index 0000000..ea5171a --- /dev/null +++ b/skills/frontend-divergence/SKILL.md @@ -0,0 +1,123 @@ +--- +name: frontend-divergence +description: Use before implementing a greenfield website, landing page, campaign page, portfolio, brand site, or visually significant redesign when Codex must avoid repeating familiar AI layouts and create a distinctive, brief-fit art direction. Generate and compare structurally different concepts, select one design contract, then hand it to the implementation or taste skill. Do not use for exact Figma/screenshot reproduction, routine product UI maintenance, or small component edits. +--- + +# Frontend Divergence: Design Genome + +Create difference before polishing quality. Existing frontend and taste skills +may converge on good execution; this skill owns exploration and selection. + +## 1. Establish The Design Problem + +Extract these inputs from the brief and repository without asking unless a +material decision is genuinely missing: + +- audience, desired action, and emotional effect; +- content shape and strongest real asset; +- brand constraints and existing visual equity; +- accessibility, performance, framework, and delivery constraints; +- designs from this repo or prior context that must not be repeated. + +Write a compact `Design premise` containing the user tension, visual +opportunity, and one non-negotiable. Do not name a trendy style yet. + +## 2. Generate A Direction Slate + +Run the bundled engine before writing production UI: + +```bash +python /scripts/direction_lab.py generate \ + --project "" \ + --brief "" \ + --count 4 +``` + +The engine uses a morphological matrix and past fingerprints to maximize +structural distance. It is a provocation tool, not an aesthetic authority. +Read `references/creative-mechanics.md` when adjusting the matrix, judging +novelty, or explaining why this process exists. + +Turn each generated fingerprint into a short concept with: + +- thesis: the single visual idea; +- first viewport: concrete composition, hierarchy, and media behavior; +- system: type, color, surface, spatial rhythm, and motion grammar; +- signature moment: one interaction or composition specific to this project; +- risk: what could become gimmicky, inaccessible, or off-brief; +- anti-reference: which familiar solution this direction refuses to become. + +Do not produce four recolors of one wireframe. Concepts must differ in at least +five matrix axes, including composition and one of media or interaction. + +When web or image search is available and visual originality is important, +triangulate only after generating the slate: inspect one same-domain reference, +one adjacent-domain reference, and one far-domain reference. Extract a property +from each (rhythm, crop, type behavior, material, or interaction), state what +must not be copied, and contextualize it against the actual brief. + +## 3. Select, Do Not Average + +Score every concept from 1-5 on: + +- brief fit; +- distinctiveness from recent work; +- content truthfulness; +- implementation feasibility; +- accessibility and responsive resilience. + +Choose one direction and state why. You may transplant one strong trait from a +runner-up only when it strengthens the thesis. Never average all directions +into a generic compromise. + +If the top two are close and the choice materially changes the product, show +the two concise options to the user. Otherwise choose autonomously. + +## 4. Freeze A Design Contract + +Before implementation, write a `Design contract` with exact decisions: + +- selected fingerprint and concept name; +- layout grammar and responsive transformation; +- type roles and scale behavior; +- color/material rules; +- image or illustration logic; +- motion grammar and reduced-motion behavior; +- three forbidden defaults for this project; +- the signature moment and its fallback; +- quality checks that prove the concept survived implementation. + +The contract overrides aesthetic defaults from other skills when they conflict. +It never overrides accessibility, semantics, performance, existing brand +requirements, or explicit user constraints. + +## 5. Implement And Challenge The Result + +Hand the contract to the applicable frontend, taste, image-generation, or UI +skill. Preserve the chosen structure instead of letting implementation drift +back to a centered hero, card grid, pills, generic gradient, or familiar +fade-up sequence. + +At the first complete viewport and again before delivery, run a divergence +check: + +1. Compare the result with the contract, not only with code quality rules. +2. Identify the three most template-like decisions still visible. +3. Replace at least one if it weakens the project-specific thesis. +4. Verify mobile, keyboard, contrast, reduced motion, and content realism. +5. Confirm that removing the logo would not make the page interchangeable with + an unrelated recent project. + +## 6. Record The Selected Fingerprint + +After the direction is selected, record the generated code shown in the slate: + +```bash +python /scripts/direction_lab.py record \ + --project "" \ + --direction "" \ + --code "" +``` + +The default user-level history stores a hash of the project identifier, not the +project name. Use `--history ` when isolation or team sharing is needed. diff --git a/skills/frontend-divergence/agents/openai.yaml b/skills/frontend-divergence/agents/openai.yaml new file mode 100644 index 0000000..0769888 --- /dev/null +++ b/skills/frontend-divergence/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Design Genome" + short_description: "Break repeated frontend design defaults" + default_prompt: "Use $frontend-divergence to explore genuinely different design directions before implementing this website." diff --git a/skills/frontend-divergence/references/creative-mechanics.md b/skills/frontend-divergence/references/creative-mechanics.md new file mode 100644 index 0000000..e06baea --- /dev/null +++ b/skills/frontend-divergence/references/creative-mechanics.md @@ -0,0 +1,51 @@ +# Creative Mechanics + +## Why This Skill Adds Friction + +Generative systems tend to reproduce dominant conventions. Microsoft Research +describes productive friction as a mitigation for homogenization in web vibe +coding. Experimental work on generative-AI-assisted ideation has also found +greater fixation on initial examples and lower idea variety in some settings. + +This skill therefore separates divergence from implementation. It delays code, +decomposes art direction into independent axes, creates distant combinations, +and requires an explicit selection before quality-oriented skills converge. + +## Morphological Exploration + +The direction engine combines independent choices for composition, hero +behavior, typography, media, color, surface, navigation, motion, and signature +detail. Morphological charts are established tools for widening a design +solution space, but combinations are prompts for judgment rather than answers. + +Use the engine to expose different regions of the space. Then judge each region +against the real audience, content, brand, accessibility, and delivery context. +Do not mistake random difference for useful originality. + +## Example Hygiene + +Examples can inspire or fixate. Research comparing example interfaces found +that contextualizing examples in the problem space supported exploration better +than presenting a flat list. Therefore: + +- analyze references by transferable property, not by copying whole pages; +- mix near and far references from different domains; +- state what each reference teaches and what must not be copied; +- introduce screenshots after the design premise, not before it; +- compare concepts in the context of the actual content and viewport. + +## Divergence Threshold + +Treat directions as distinct only when they differ on at least five axes. A +palette swap, font swap, or reordered card grid is not a new direction. +Composition plus either media logic or interaction grammar must change. + +Novelty is bounded by truthfulness: the direction must still communicate the +right action, support real content, work responsively, and remain accessible. + +## Sources + +- [Interrogating Design Homogenization in Web Vibe Coding](https://www.microsoft.com/en-us/research/publication/interrogating-design-homogenization-in-web-vibe-coding/) +- [The Effects of Generative AI on Design Fixation and Divergent Thinking](https://arxiv.org/abs/2403.11164) +- [Formulating or Fixating: Effects of Examples on Problem Solving](https://arxiv.org/abs/2401.11022) +- [Design Divergence Using the Morphological Chart](https://openjournals.ljmu.ac.uk/DesignTechnologyEducation/article/view/1474) diff --git a/skills/frontend-divergence/scripts/direction_lab.py b/skills/frontend-divergence/scripts/direction_lab.py new file mode 100644 index 0000000..557a515 --- /dev/null +++ b/skills/frontend-divergence/scripts/direction_lab.py @@ -0,0 +1,282 @@ +#!/usr/bin/env python3 +"""Generate and remember structurally diverse frontend art directions.""" + +from __future__ import annotations + +import argparse +import base64 +import hashlib +import json +import os +import random +from datetime import datetime, timezone +from pathlib import Path + + +AXES = { + "composition": [ + ("centered-stack", "disciplined centered stack with one dominant plane", {"calm", "product"}), + ("asymmetric-grid", "asymmetric grid with deliberate negative-space pressure", {"editorial", "cultural"}), + ("poster-field", "poster-like field where type and image share one canvas", {"brand", "campaign"}), + ("chapter-scroll", "cinematic chapters with changing spatial relationships", {"story", "luxury"}), + ("index-canvas", "index or catalogue structure that reveals depth through browsing", {"editorial", "commerce"}), + ("spatial-object", "one spatial object organizes the page around itself", {"technical", "product"}), + ], + "hero": [ + ("headline-first", "type-led opening with controlled supporting media", {"editorial", "service"}), + ("documentary-crop", "documentary image crop with content anchored to real context", {"local", "human"}), + ("interactive-object", "interactive product or symbolic object as the first action", {"product", "technical"}), + ("narrative-scene", "scene-setting opening that starts a visual story", {"story", "culture"}), + ("utility-first", "the useful product surface is visible immediately", {"data", "product"}), + ("dashboard-composite", "composed product evidence rather than decorative abstraction", {"product", "data"}), + ], + "typography": [ + ("expressive-serif", "expressive serif for voice with quiet supporting sans", {"editorial", "luxury"}), + ("condensed-display", "condensed display type creating vertical energy", {"campaign", "culture"}), + ("mono-humanist", "monospace precision softened by humanist body text", {"technical", "craft"}), + ("variable-scale", "variable type whose scale and width carry hierarchy", {"brand", "experimental"}), + ("soft-grotesk", "warm grotesk with restrained scale contrasts", {"human", "wellness"}), + ("neutral-grotesk", "neutral grotesk used only when other axes provide identity", {"product", "data"}), + ], + "media": [ + ("documentary-photo", "in-situ documentary photography with imperfect human detail", {"human", "local"}), + ("macro-material", "macro material studies and tactile crops", {"luxury", "craft"}), + ("diagram-language", "diagrams and annotated systems as the visual language", {"technical", "data"}), + ("illustration-world", "coherent illustration world with reusable visual rules", {"playful", "culture"}), + ("type-as-image", "typography itself supplies the main imagery", {"editorial", "campaign"}), + ("abstract-gradient", "abstract gradient used sparingly and tied to a real concept", {"product", "brand"}), + ], + "color": [ + ("near-monochrome", "near-monochrome system with one semantic interruption", {"calm", "luxury"}), + ("paper-and-ink", "warm paper, ink, and one print-like spot color", {"editorial", "craft"}), + ("mineral-muted", "muted mineral palette with tonal depth", {"wellness", "luxury"}), + ("saturated-duotone", "saturated duotone with strict contrast roles", {"campaign", "playful"}), + ("spectral-accent", "dark or neutral field with a narrow spectral accent", {"technical", "product"}), + ("material-native", "palette sampled from the product, place, or source imagery", {"local", "human"}), + ], + "surface": [ + ("flat-print", "flat print logic: rules, blocks, overprint, no ornamental depth", {"editorial", "campaign"}), + ("layered-paper", "overlapping paper-like planes with purposeful occlusion", {"craft", "culture"}), + ("light-glass", "limited translucent depth around real interactive layers", {"technical", "product"}), + ("raw-grid", "visible structural grid and honest edges", {"technical", "editorial"}), + ("soft-volume", "soft volumetric surfaces with large calm fields", {"wellness", "human"}), + ("rounded-cards", "rounded containers only where grouping or interaction requires them", {"product", "commerce"}), + ], + "navigation": [ + ("minimal-anchor", "minimal anchors that keep the composition dominant", {"brand", "campaign"}), + ("editorial-index", "editorial index exposing the site's content shape", {"editorial", "culture"}), + ("side-rail", "persistent side rail with vertical reading rhythm", {"data", "technical"}), + ("context-dock", "contextual dock that changes with the active chapter", {"story", "product"}), + ("command-led", "search or command-led navigation for expert intent", {"technical", "data"}), + ("top-bar", "conventional top bar kept visually subordinate", {"product", "service"}), + ], + "motion": [ + ("almost-still", "near-static confidence with one meaningful transition", {"calm", "luxury"}), + ("cut-and-snap", "editorial cuts and decisive snapping transitions", {"campaign", "editorial"}), + ("slow-depth", "slow depth shifts tied to narrative hierarchy", {"story", "luxury"}), + ("kinetic-type", "type changes width, scale, or position to carry meaning", {"brand", "campaign"}), + ("object-physics", "one object responds with restrained physical behavior", {"product", "playful"}), + ("fade-rise", "familiar fade and rise used only as supporting motion", {"product", "service"}), + ], + "signature": [ + ("edge-type", "type intentionally meets or exits a viewport edge", {"editorial", "campaign"}), + ("responsive-recompose", "mobile recomposes the visual idea instead of merely stacking", {"product", "brand"}), + ("annotated-detail", "small annotations reveal provenance or mechanism", {"technical", "craft"}), + ("image-window", "a changing image window controls pace and focus", {"story", "human"}), + ("living-rule", "one line or rule becomes navigation, chart, or transition", {"data", "editorial"}), + ("pill-labels", "pills appear only for real filters or state", {"product", "commerce"}), + ], +} + +GENERIC = { + "composition": "centered-stack", + "hero": "dashboard-composite", + "typography": "neutral-grotesk", + "media": "abstract-gradient", + "surface": "rounded-cards", + "navigation": "top-bar", + "motion": "fade-rise", + "signature": "pill-labels", +} + +KEYWORDS = { + "editorial": ("editorial", "magazine", "journal", "publishing", "magasin", "tidning"), + "luxury": ("luxury", "premium", "exclusive", "lyx", "high-end"), + "technical": ("developer", "technical", "engineering", "api", "teknisk", "kod"), + "product": ("software", "saas", "app", "platform", "product", "produkt"), + "data": ("data", "analytics", "dashboard", "metrics", "trading", "statistik"), + "human": ("community", "people", "care", "human", "människ", "social"), + "local": ("local", "place", "venue", "restaurant", "lokal", "plats", "stad"), + "playful": ("playful", "game", "kids", "lekfull", "spel"), + "campaign": ("campaign", "launch", "event", "kampanj", "lansering"), + "wellness": ("health", "wellness", "calm", "hälsa", "lugn"), + "craft": ("craft", "studio", "maker", "hantverk", "ateljé"), + "story": ("story", "film", "journey", "berätt", "resa"), + "culture": ("culture", "music", "art", "museum", "kultur", "musik", "konst"), + "commerce": ("shop", "store", "commerce", "retail", "butik", "handel"), +} + + +def history_path(value: str | None) -> Path: + if value: + return Path(value).expanduser() + home = Path(os.environ.get("CODEX_HOME", Path.home() / ".codex")) + return home / "frontend-divergence" / "history.json" + + +def load_history(path: Path) -> list[dict]: + if not path.exists(): + return [] + data = json.loads(path.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + + +def distance(left: dict[str, str], right: dict[str, str]) -> float: + axes = set(left) | set(right) + return sum(left.get(axis) != right.get(axis) for axis in axes) / max(1, len(axes)) + + +def brief_tags(brief: str) -> set[str]: + lowered = brief.lower() + return {tag for tag, words in KEYWORDS.items() if any(word in lowered for word in words)} + + +def option(axis: str, key: str) -> tuple[str, set[str]]: + for candidate, description, tags in AXES[axis]: + if candidate == key: + return description, tags + raise KeyError(f"Unknown {axis} option: {key}") + + +def fit_score(fingerprint: dict[str, str], target: set[str]) -> float: + if not target: + return 0.5 + matches = sum(bool(option(axis, key)[1] & target) for axis, key in fingerprint.items()) + return matches / len(fingerprint) + + +def generic_count(fingerprint: dict[str, str]) -> int: + return sum(fingerprint.get(axis) == value for axis, value in GENERIC.items()) + + +def encode(fingerprint: dict[str, str]) -> str: + raw = json.dumps(fingerprint, sort_keys=True, separators=(",", ":")).encode() + return base64.urlsafe_b64encode(raw).decode().rstrip("=") + + +def decode(code: str) -> dict[str, str]: + raw = base64.urlsafe_b64decode(code + "=" * (-len(code) % 4)) + value = json.loads(raw.decode()) + if set(value) != set(AXES): + raise ValueError("Record code does not contain the expected design axes") + return value + + +def generate(args: argparse.Namespace) -> int: + path = history_path(args.history) + history = [item.get("fingerprint", {}) for item in load_history(path)] + seed_text = args.seed or f"{args.project}|{args.brief}" + rng = random.Random(int(hashlib.sha256(seed_text.encode()).hexdigest(), 16)) + target = brief_tags(args.brief) + + pool: list[dict[str, str]] = [] + seen: set[str] = set() + while len(pool) < 800: + fingerprint = {axis: rng.choice(values)[0] for axis, values in AXES.items()} + code = encode(fingerprint) + if code in seen or generic_count(fingerprint) > 2: + continue + if target and fit_score(fingerprint, target) < 2 / len(AXES): + continue + seen.add(code) + pool.append(fingerprint) + + selected: list[dict[str, str]] = [] + for index in range(args.count): + def score(candidate: dict[str, str]) -> float: + fit = fit_score(candidate, target) + old_distance = min((distance(candidate, old) for old in history), default=1.0) + new_distance = min((distance(candidate, old) for old in selected), default=1.0) + weights = ((2.2, 0.8, 0.5), (1.4, 1.0, 2.0), (0.9, 1.6, 2.2), (1.0, 2.2, 2.4)) + fit_w, history_w, selected_w = weights[min(index, len(weights) - 1)] + return fit * fit_w + old_distance * history_w + new_distance * selected_w + rng.random() * 0.05 + + eligible = ( + candidate + for candidate in pool + if candidate not in selected + and all(distance(candidate, prior) >= 5 / len(AXES) for prior in selected) + ) + choice = max(eligible, key=score) + selected.append(choice) + + lines = [ + "# Frontend Direction Slate", + "", + f"Project hash: `{hashlib.sha256(args.project.encode()).hexdigest()[:12]}`", + f"Brief tags: `{', '.join(sorted(target)) or 'none inferred'}`", + "", + "Choose one direction; do not average the slate.", + ] + for index, fingerprint in enumerate(selected, 1): + old_distance = min((distance(fingerprint, old) for old in history), default=1.0) + lines.extend([ + "", + f"## Direction {index}", + "", + f"- Brief fit: `{fit_score(fingerprint, target):.2f}`", + f"- Distance from recorded work: `{old_distance:.2f}`", + f"- Generic-default matches: `{generic_count(fingerprint)}`", + ]) + for axis, key in fingerprint.items(): + description, _ = option(axis, key) + lines.append(f"- **{axis} / {key}:** {description}") + lines.append(f"- Record code: `{encode(fingerprint)}`") + + output = "\n".join(lines) + "\n" + if args.output: + Path(args.output).write_text(output, encoding="utf-8") + else: + print(output, end="") + return 0 + + +def record(args: argparse.Namespace) -> int: + path = history_path(args.history) + fingerprint = decode(args.code) + entries = load_history(path) + entries.append({ + "project_hash": hashlib.sha256(args.project.encode()).hexdigest()[:16], + "direction": args.direction, + "fingerprint": fingerprint, + "recorded_at": datetime.now(timezone.utc).isoformat(), + }) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(entries[-100:], indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Recorded fingerprint in {path}") + return 0 + + +def parser() -> argparse.ArgumentParser: + root = argparse.ArgumentParser(description=__doc__) + commands = root.add_subparsers(dest="command", required=True) + make = commands.add_parser("generate", help="generate a diverse direction slate") + make.add_argument("--project", required=True) + make.add_argument("--brief", required=True) + make.add_argument("--count", type=int, default=4, choices=range(3, 7)) + make.add_argument("--seed") + make.add_argument("--history") + make.add_argument("--output") + make.set_defaults(func=generate) + save = commands.add_parser("record", help="record the selected fingerprint") + save.add_argument("--project", required=True) + save.add_argument("--direction", required=True) + save.add_argument("--code", required=True) + save.add_argument("--history") + save.set_defaults(func=record) + return root + + +if __name__ == "__main__": + arguments = parser().parse_args() + raise SystemExit(arguments.func(arguments)) diff --git a/skills/frontend-divergence/scripts/test_direction_lab.py b/skills/frontend-divergence/scripts/test_direction_lab.py new file mode 100644 index 0000000..58c3a4b --- /dev/null +++ b/skills/frontend-divergence/scripts/test_direction_lab.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import importlib.util +import re +import subprocess +import sys +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("direction_lab.py") +SPEC = importlib.util.spec_from_file_location("direction_lab", SCRIPT) +assert SPEC and SPEC.loader +LAB = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(LAB) + + +class DirectionLabTests(unittest.TestCase): + def test_round_trip_code(self) -> None: + fingerprint = {axis: values[0][0] for axis, values in LAB.AXES.items()} + self.assertEqual(LAB.decode(LAB.encode(fingerprint)), fingerprint) + + def test_generate_is_deterministic_and_diverse(self) -> None: + command = [ + sys.executable, + str(SCRIPT), + "generate", + "--project", + "test-project", + "--brief", + "An editorial cultural archive with a strong local story", + "--count", + "4", + ] + first = subprocess.run(command, check=True, capture_output=True, text=True).stdout + second = subprocess.run(command, check=True, capture_output=True, text=True).stdout + self.assertEqual(first, second) + self.assertEqual(first.count("## Direction"), 4) + self.assertNotIn("Generic-default matches: `3`", first) + self.assertNotIn("Brief fit: `0.00`", first) + codes = re.findall(r"Record code: `([^`]+)`", first) + fingerprints = [LAB.decode(code) for code in codes] + for index, left in enumerate(fingerprints): + for right in fingerprints[index + 1 :]: + self.assertGreaterEqual(LAB.distance(left, right), 5 / len(LAB.AXES)) + + +if __name__ == "__main__": + unittest.main()