diff --git a/CHANGELOG.md b/CHANGELOG.md index 3335ebc..af911f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo ### Added +- The pre-#61 override compatibility test now uses a minimal synthetic legacy art-direction fixture (`tests/fixtures/content/ART_DIRECTION.synthetic-legacy.md`, placeholder names and neutral colours, no production brand data) instead of a copy of the previous bundled template; the original stays in git history. +- `content icon prompt` takes `--seed N` to request an image seed. The prompt states it only when asked, and the markdown provenance line `Requested: 1 image; seed: ...` records `N` or `not supported` (#63). An invalid or missing glyph `size_class` stays `needs-writer` and the result (`note`, `invalid`) lists the allowed values WIDE, TALL, SQUARE and DIAGONAL with a one-line meaning each; the values are listed in `SKILL.md`, `references/content.md` and `references/art-direction.md` (#62). - `content icon normalize` auto mode keys any uniform ground to transparent unless it is a dark night tile (luminance < 0.2 and not within dE 12 of the card colour), so a flat pale ground on a dark card no longer needs `--ground transparent`; `--ground keep|transparent` are unchanged. New `--ground-tolerance DE` (default 3.0) widens the corner match for slightly vignetted grounds (e.g. `--ground-tolerance 5`); above it `icon-ground` names the measured corner dE and the flag. A raised tolerance widens the keying threshold only above 3.0 and flattens a kept night tile so QA accepts it. Auto falls back to keying when a kept night tile would exceed the 300 KB budget; non-finite or negative `--ground-tolerance` is a usage error, and `--ground-tolerance 0` accepts identical corners as one ground (a positive tolerance keeps the strict boundary). The option A icon prompt (bundled and estate-format templates) states the exact flat `card_fill` ground with no vignette, texture, gradient, shadow or glow; the estate BACKGROUND heading now says one flat ground (not the avatar night tile), and the exact ground clause is now appended whenever a BACKGROUND replacement removed it (checked after all replacements). The soft 3 dE key alpha ramp applies only with a raised `--ground-tolerance`; the default transparent key is unchanged (original 0/255 step). - `content icon normalize` flattens a vignetted kept night tile with a feathered ramp (6 dE) instead of a hard threshold, and the transparent key uses a soft alpha ramp, so a glow around a solid glyph keeps its continuity with no hard outer ring; the glyph edge stays hard. `--ground-tolerance` is covered through the CLI (a raised value is applied; `nan` and negative values exit 2), and the estate-template numbered BACKGROUND paragraph is replaced by the flat-ground wording, including explicit colour or radial-gradient text. - `content icon check` and `content icon normalize` take `--ocr` (opt-in, off by default) to read the icon with `tesseract` on `PATH` for text: words of 2 or more alphanumerics warn (fail with `--strict`); without the tool the check is skipped with `needs-input:ocr-unavailable` and `no-text` stays a manual review, so the result and exit code do not change. An unreadable or undecodable PNG (bad structure, or valid structure with corrupt compressed data) fails the icon as a structured `png` check, so `content icon check` then prints a JSON result with a `logs` list and exits 1 (bad arguments still exit 2); `logs` names the unreadable image as `unreadable-image:`, with and without `--ocr`; the command never ends in a traceback. `content icon normalize` handles an unreadable or undecodable `--in` candidate the same way (exit 1, no file written, with and without `--ocr`; `logs` names the `--in` source as `unreadable-image:` plus `art-fail:icon-qa`); a missing `--in` file stays a usage error (exit 2). @@ -15,6 +17,11 @@ All notable changes to `github-showcase-skill` are recorded here. Versions follo - `org labels ORG --repos-from-gh` plans labels across accessible repositories without the private estate registry, with paginated read-only GitHub capture, optional verified local checkouts, unchanged reference guards, and a documented org-default-forms-first workflow. Registry-backed plans still require the org registry row, and checkout repository names match GitHub case-insensitively, including resolved pending renames. Registry-free runs also isolate lazy brand lookups from estate decision files, and held reference rewrites retain their file/line guidance with an incomplete result. Workflow/labeler/release/Renovate references remain final skips rather than unresolved input requests. Missing Windows device-named checkouts fall back to captured metadata instead of aborting the org plan. Pending transfers check the repository's current owner's default issue forms and hold renames when that evidence is unavailable. Sparse or partially missing checkouts retain reference holds based on captured paths and the Git index, while unrelated omitted files remain allowed. - `references/art-direction.md` documents the Codex image-generation recipe for the icon pipeline (the `codex exec` command, reading the image path from the run output, and the retry and attempt limits). Image-folder and sandbox notes are labelled as observed runtime behaviour, not OpenAI-documented, and the exact returned path is the rule. Each distinct returned path is one candidate, and the attempt cap is per candidate slot (four slots, 3 empty attempts each); unreadable-only output counts as an empty attempt and ends in `art-fail` after 3. Documentation and static wording tests only; no behaviour change. +### Changed + +- `content icon check` and `content icon normalize` measure an explicitly configured contrast floor (`--min-contrast` or the family key `icon_min_contrast`) on the most common actual ink colour, not the 16-level bucket midpoint, which falsely failed grey #707070 on #F8FAFC (really 4.73:1) and falsely passed #808080 on #032530 (really 4.06:1) at 4.5; the unconfigured default keeps the legacy midpoint measurement. `--ground-tolerance` must be a finite number from 0 to 12; NaN, a negative, 12.5 or 1000 is a usage error (exit 2) instead of keying the whole glyph away (`icon-empty`). The `references/art-direction.md` recipe selects the first readable path an invocation reports for its single slot, so a run that reports an unreadable sandbox copy and a readable `generated_images` file fills the slot; only a run with no readable path is an empty run (#61, ported from the epic review fixes; #64 privacy and retry limits unchanged). +- `content icon prompt` now asks the image tool for ONE image (`Produce one image.`) instead of four candidates, writes the output path without the `-c{1..4}` suffix, and no longer asserts the family seed it used to infer; the provenance template line `Candidates: 4 or 8` became `Images requested`. The `references/art-direction.md` recipe and pipeline rule 7 now run the four-candidate selection round as four single-image invocations (then four more once), each slot with the existing per-slot cap of 3 attempts and the exact returned path; the #64 privacy, retrieval and retry semantics are unchanged. CLI help only documents flags: only a returned readable image path proves that an image was generated (#61, #63). + ### Fixed - `readme --bootstrap --write` no longer regenerates a marked footer that holds authored content. When the edited-template upgrade is refused by the never-drop-a-line check and the marked footer carries a partner or foreign image, the command now exits with a check failure (`readme-shrink`) and leaves `README.md` unchanged instead of falling back to a header refresh that rewrote the footer; a plain old footer copy still takes the fallback. The same refusal now applies in every bootstrap state (pristine and unmarked as well as edited), checked before any write, so a pristine or unmarked template can no longer silently replace a partner or foreign footer image. diff --git a/SKILL.md b/SKILL.md index dd6a777..f6a94ba 100644 --- a/SKILL.md +++ b/SKILL.md @@ -140,8 +140,11 @@ The four command groups follow the same rules as the repository commands: read-o `content icon` defaults to the installed skill's `references/art-direction.md`, including a repository glyph template for option A and the illustration template -for option B. Palette values come from `references/brand.yaml`; documented family -seeds are used when available, otherwise the prompt uses the tool's default seed. Use +for option B. Palette values come from `references/brand.yaml`. The prompt asks for +one image and asserts no seed; pass `--seed N` to request one (recorded in the prompt and +its provenance line, otherwise "not supported"). The glyph `size_class` is WIDE, TALL, +SQUARE or DIAGONAL (case-insensitive); anything else stays `needs-writer` and the result +lists the four values with their meanings. Use `--art-direction FILE` to select another file; relative paths use the current directory, and missing files report `needs-input:art-direction` with the selected path. Older custom configs without `icon_ink`/`icon_spark` keep normalization and checks @@ -222,7 +225,7 @@ project readme --capture FILE --owner OWNER [--out FILE] project estate-plan [--copied FILE] [--user-id ID] [--source OWNER/N] [--jobs FILE] content social [--out PATH] [--title TEXT] [--icon PATH] [--job] [--allow-unmeasured] [--allow-hold] content icon prompt|normalize|check [--option A|B] [--glyph FILE] [--art-direction FILE] [--in PNG] - [--ground auto|transparent|keep] [--ground-tolerance DE] [--out PATH] [--preview PATH] + [--ground auto|transparent|keep] [--ground-tolerance DE (0 to 12)] [--seed N] [--out PATH] [--preview PATH] [--strict] [--min-contrast RATIO] [--ocr] content links [FILE...] [--readme-path PATH] [--base-ref REF] [--online] [--cache FILE] [--strict] [--diff] content linguist [--min-share 0.02] [--language NAME] diff --git a/references/art-direction.md b/references/art-direction.md index 380837b..4adc8d7 100644 --- a/references/art-direction.md +++ b/references/art-direction.md @@ -9,10 +9,10 @@ Condensed from `ct-std-v1` §9 with decisions 12, 13, 19 and 21 applied. It cove 1. **The image model draws only illustration layers.** Never text, letters, numbers, logos or brand marks. 2. **Marks come only from committed files.** Never crop, mask, recolour or redraw a mark. The only crops ever made are the one-time `mark-dark.png`, `bulb-dark.png` and `jev-reference.png`, made by the estate planner on the chosen master (decision 19: re-measure on the new master; never reuse old pixel coordinates). 3. **All text is typeset by the compositor** with real fonts: Poppins and JetBrains Mono for CompleteTech (both OFL); T2 families use their own fonts. -4. **One style lock per family**, verbatim. Use the same model and version for a family run; record the seed if the tool supports seeds (CompleteTech `1025`, Jev-Engineering `3001`, jevplay-games `3101`, LibreEvolve `4001`, TouchedByAnAgent `5001`, romgenie `6001`). +4. **One style lock per family**, verbatim. Use the same model and version for a family run; record a seed only when one was requested (`content icon prompt --seed N`; otherwise "not supported"). Family seeds, for runs that choose to request one: CompleteTech `1025`, Jev-Engineering `3001`, jevplay-games `3101`, LibreEvolve `4001`, TouchedByAnAgent `5001`, romgenie `6001`. 5. **Pilot first, per family.** The user approves the first banner and card of each family before the rest are generated; they become the family's golden references. 6. **Every in-scope repository gets its own icon** (decision 12), `assets/icon.png`; watching-org forks are the only exception. T3 art waits for co-owner sign-off. -7. **Selection:** generate 4 candidates, reject any that fails the QA checklist, select the first remaining one in generation order with a one-line reason. If none passes, generate 4 more once; then log `art-fail`. +7. **Selection:** run the one-image prompt as four single-image invocations (each invocation is one candidate), reject any that fails the QA checklist, select the first remaining one in generation order with a one-line reason. If none passes, run four more single-image invocations once; then log `art-fail`. 8. **Mascot:** every jevplay-games prompt uses exactly `jevplay-games/.github/profile/assets/jev-reference.png`. 9. **No third-party IP:** no Pokémon creatures or Poké Balls, Among Us crewmates, Discord, Zoho or NetScaler logos, Hasbro board designs, real people, or anything sexual or suggestive. 10. **One dark design** (decision 21): banners, cards, tiles and intros ship one dark file used on both GitHub themes. @@ -90,6 +90,8 @@ codex exec --skip-git-repo-check --json "$(cat art/prompts/ORG/REPO/icon.md)" > - `--sandbox MODE` selects the sandbox policy for model-generated shell commands. - `--output-last-message FILE` also writes the final message to FILE. - A prompt of `-` is read from standard input. +- Each invocation runs one single-image prompt (`content icon prompt` asks for one image) and fills at most one candidate slot, but its output may report more than one path (for example an unreadable sandbox copy and a readable `generated_images` file). The candidate for the slot is the first readable path the run reported, see item 5 below. +- `codex exec --help` and CLI help only document flags; they do not prove that an image is generated. Only a returned readable image path proves a generation. Use the same model and version for a whole family run (pipeline rule 4). Record the tool, model id and version in `PROMPTS.md` exactly as the run reports them; if the run does not expose a model id, write `not exposed` in the `TOOL / MODEL ID AND VERSION` field. Never invent a model id. Flag names follow codex-cli 0.166.0 at the time of writing; if one is rejected, run `codex exec --help` and change nothing else in the recipe. @@ -97,18 +99,17 @@ This recipe does not rely on a documented image path: OpenAI documentation cover ### Read the image path from the run output -1. After the run, search the `run.jsonl` events (or the final message) for every image file path Codex reports for the generated images, and use exactly those paths (see item 5). +1. After the run, search the `run.jsonl` events (or the final message) for the image file path Codex reports for the generated image, and use exactly that path (see item 5). 2. Never pick the newest file in the generated-images folder. Parallel sessions write into the same place, so the newest file can belong to another run and the wrong image gets normalized. 3. If the run output names no readable image path, treat the attempt as an empty run (see Retry and attempt limits). Do not fall back to the newest file or to a directory listing. 4. Copy the chosen image to scratch (see Sandbox (d)) and pass that copy to content icon normalize --in PATH, not the generated_images path. In the `PROMPTS.md` provenance block, record the Codex session id and only the image file name, or the path written relative to `$CODEX_HOME` (for example `$CODEX_HOME/generated_images/`), in the prose beside the record. Never record the absolute path: it contains the local user name. The Provenance record fenced template itself does not change. -5. One invocation may report between zero and four image paths. Every DISTINCT path the run output reports is one candidate; use exactly the reported paths, never a directory listing or the newest file. A run that reports no readable path is an empty run: either it reports no path, or every path it reports is unreadable (unreadable-only output). An unreadable path fills no slot, and readable paths in the same run still fill their slots. Parallel sessions write into the same place, so do not run candidates for several repositories in the same working directory at once unless you can tell their outputs apart. +5. Each invocation asks for ONE image and fills ONE slot, but its output may report more than one path (for example an unreadable sandbox copy and a readable `generated_images` file). Use only paths reported by that same invocation, never a directory listing or the newest file. Select the first readable path in the order the run reported them: that single path is the candidate for the slot (one accepted candidate per slot, even when several paths are reported), and the remaining reported paths are ignored. A run that reports no readable path is an empty run: either it reports no path, or every reported path is unreadable (unreadable-only output). An unreadable path fills no slot and does not stop the run from using another readable path it reported. Parallel sessions write into the same place, so do not run candidates for several repositories in the same working directory at once unless you can tell their outputs apart. ### Retry and attempt limits -- The limit applies per candidate, not per candidate set. Pipeline rule 7 asks for four candidates (the option A template also says so), so there are four candidate slots, and the cap applies to each slot. A run that returns k distinct readable paths fills k slots (up to the unfilled ones); make further runs, with the same unchanged prompt, only for unfilled slots. -- An empty run (no path, or unreadable-only output) is retried as a NEW run with the same unchanged prompt, at most 2 retries (3 attempts total) for each slot that is still unfilled. Each empty run counts as one attempt against every slot that is still unfilled. Parallel runs can finish without a path, which is why a retry is allowed. Retries never alter the prompt. -- Successful candidates never count toward the cap, so the four-candidate round of pipeline rule 7 (then 4 more once, then log `art-fail`) is still reachable. Only empty runs count toward the 3 attempts, and they are not candidates. An unreadable path is not a successful candidate, so unreadable-only output is counted as an empty run and cannot loop forever. -- The second round of rule 7 ("4 more once") opens four new slots with the same per-slot cap of 3 attempts (2 retries each); slots from the first round do not carry over their attempt counts. +- The limit applies per candidate slot. Pipeline rule 7 asks for four single-image invocations (the option A template asks for one image each), so there are four candidate slots and the cap applies to each slot. The first readable path an invocation reports fills the slot that invocation was for; an empty run (no reported path is readable) is retried as a NEW run with the same unchanged prompt, at most 2 retries (3 attempts total) for that slot. Retries never alter the prompt. Each slot is an independent invocation, so one slot's empty run does not count against another slot. +- Successful candidates never count toward the cap, so the four-invocation round of pipeline rule 7 (then four more once, then log `art-fail`) is still reachable. Only empty runs count toward the 3 attempts, and they are not candidates. An unreadable path is not a successful candidate, so unreadable-only output is counted as an empty run and cannot loop forever. A run that reports one unreadable path and one readable path is NOT unreadable-only: it fills the slot with the readable path and is not an empty run. +- The second round of rule 7 (four more single-image invocations once) opens four new slots with the same per-slot cap of 3 attempts (2 retries each); slots from the first round do not carry over their attempt counts. - After 3 empty attempts for any one slot, including attempts that returned only unreadable paths, stop and log `art-fail` with a one-line reason. Do not loop. ### Privacy: only public facts go into prompts @@ -125,7 +126,7 @@ These are OBSERVED runtime notes from issue #64 and the Pick Your AI evidence, n - (a) Observed: Codex stored generated images in its `generated_images` folder inside `$CODEX_HOME` (default: the `.codex` folder in the user's home, `~`). Read the image from the path named in the run output, which points there. - (b) Observed: files that Codex writes into its own sandbox, for example a copy it saves into the working directory it was given, can be locked against the user account (access denied). Do not rely on them, and do not try to chmod or take ownership of them. -- (c) If a path is unreadable, copy nothing from the sandbox and do not retry the same path. Treat that path as returning nothing: if the same run reports another readable path in the generated-images folder, use that; otherwise the run is an empty run and counts as one attempt under the retry limit above, ending in `art-fail` after 3 empty attempts for the slot. +- (c) If a path is unreadable, copy nothing from the sandbox and do not retry the same path. Skip it and use the first other readable path the same run reported, if there is one. Only when no reported path is readable does the run return nothing: it is then an empty run and counts as one attempt under the retry limit above, ending in `art-fail` after 3 empty attempts for the slot. - (d) Copy the chosen image to a scratch location outside the repository before `content icon normalize --in`, and commit only the normalized `assets/icon.png`. Never commit the raw candidate. Do not write any real absolute local path or user name; use `$CODEX_HOME` and `~` only. ## Repository glyph prompt template @@ -134,9 +135,13 @@ These are OBSERVED runtime notes from issue #64 and the Pick Your AI evidence, n palette slots and flat render inks come from the selected family's `icon_ink` and `icon_spark` tokens in `brand.yaml`. Both are chosen to meet the icon QA's 3:1 contrast threshold on that family's card, with a distinct outline and spark. -The writer supplies the four glyph fields with `--glyph`. Documented family seeds -above are used when available; otherwise the prompt explicitly uses the tool's -default seed. It does +The writer supplies the four glyph fields with `--glyph`. `size_class` accepts +WIDE (the glyph spans close to the full 688 px width and is about 430 to 560 px tall), +TALL (it spans close to the full 688 px height and is about 400 to 600 px wide), +SQUARE (a squarish glyph about 600 to 660 px on each side) and DIAGONAL (the mass runs +corner to corner inside a 656 × 656 px box). The prompt asks for one image and asserts +no seed; pass `--seed N` to request one, which the prompt and its provenance block then +record (otherwise the seed is recorded as "not supported"). It does not require the estate's avatar tables or reference images. An explicit `--art-direction FILE` may instead supply the estate-format `(b) Family palette` and `(d) Shared prompt template` sections. @@ -157,7 +162,7 @@ glyph outline in {LINE_HEX} so the spark remains distinct at small sizes. No text, letters, numbers, words, logos, brand marks, human figures or third-party IP. {EXTRA_NEGATIVES} -Produce four candidates. {SEED_INSTRUCTION} +Produce one image.{SEED_INSTRUCTION} The lock pass renders assets/icon.png as a flat glyph in the family's light-surface inks, without a tile or glow. Do not render the repository name in the image. ``` @@ -209,7 +214,7 @@ No text, letters, numbers, symbols, words, labels, signatures, watermarks, logos - Seed: SEED or "not supported" - Reference images: golden reference path, jev-reference.png, or "none" - Prompt: (verbatim, fenced) -- Candidates: 4 or 8; selected: N (reason: one line) +- Images requested: as asked in the prompt (`content icon prompt` asks for 1); selected: N (reason: one line) - Post-processing: showcase.py VERSION; output sha256 HASH; size KB ``` diff --git a/references/content.md b/references/content.md index ee00840..84f928a 100644 --- a/references/content.md +++ b/references/content.md @@ -11,9 +11,9 @@ Run from inside the target checkout, or pass the global `--repo-dir DIR` and `-- ```bash S=/scripts/showcase.py python3 $S --meta meta.json content social [--out assets/social-preview.png] [--job] # alias of social-preview -python3 $S --meta meta.json content icon prompt [--glyph glyph.yaml] [--option A|B] [--out art/prompts/ORG/REPO/icon.md] -python3 $S --meta meta.json content icon normalize --in candidate.png [--ground auto|transparent|keep] [--ground-tolerance DE] [--out assets/icon.png] [--preview icon-48.png] -python3 $S --meta meta.json content icon check [--out assets/icon.png] +python3 $S --meta meta.json content icon prompt [--glyph glyph.yaml] [--option A|B] [--seed N] [--art-direction FILE] [--out art/prompts/ORG/REPO/icon.md] +python3 $S --meta meta.json content icon normalize --in candidate.png [--ground auto|transparent|keep] [--ground-tolerance DE (0 to 12)] [--strict] [--min-contrast RATIO] [--ocr] [--out assets/icon.png] [--preview icon-48.png] +python3 $S --meta meta.json content icon check [--strict] [--min-contrast RATIO] [--ocr] [--out assets/icon.png] python3 $S content links [FILE ...] [--base-ref origin/main] [--online [--cache links.json]] [--strict] [--diff] python3 $S --meta meta.json content linguist [--min-share 0.02] [--language Python] python3 $S --meta meta.json content actions [--runs runs.json] [--workflows workflows.json] [--pages pages.json] [--approval WORKFLOW=ID] @@ -26,7 +26,7 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append | Action | Gap | Does | Writes | |---|---|---|---| | `social` | R10 | The 1280×640 repository social preview (the contract name; the top-level `social-preview` is a hidden, deprecated alias with the same options and file), plus with `--job` the `browser` upload job (Settings → Social preview → Edit → Upload, then a read-only `usesCustomOpenGraphImage` check). Public T1 and T2 repositories only, the personal-profile capstone and backups included; forks, archived, `.github` and Pages repositories get no job. The image's footer reads `github.com//`, the slug the job uploads to (after an approved org rename, never the former login); when the rendered footer and the job's slug differ, the job is withheld (`needs-input:social-login`, exit 1). | `--out` | -| `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | +| `icon prompt` | RT-04 | Fills the repository-icon prompt from the art direction (option A: the Neon Night glyph; option B: the isometric illustration) and the repository's facts. The glyph itself is the writer's: `needs-writer` (exit 1) until `--glyph` supplies `glyph_name`, `glyph_description`, `spark_placement` and `size_class`; nothing is written before then. `size_class` is one of WIDE (the glyph spans close to the full 688 px width and is about 430 to 560 px tall), TALL (spans close to the full 688 px height and is about 400 to 600 px wide), SQUARE (a squarish glyph about 600 to 660 px on each side) or DIAGONAL (the mass runs corner to corner inside a 656 × 656 px box), case-insensitive; a missing or unrecognised value stays `needs-writer` (exit 1, nothing written) and the result's `note` and `invalid` fields list the four values with these meanings. Both options ask for ONE image per run (each run is one candidate slot of pipeline rule 7) and never promise a candidate count or a seed. `--seed N` requests a seed, which the prompt then states; without it the prompt asserts no seed, and the markdown provenance line `Requested: 1 image; seed: not supported` (or `seed: N`) records what was actually requested. Option B appends the family addition (the trademark negatives in `references/art-direction.md`, such as the TCG one) found under any login of the org (CQ-03) or its display name, so a re-keyed `brand.yaml` row keeps it; when the org's addition is not in the art direction, the prompt is refused (`needs-input:family-additions`) rather than written without it. | `--out` | | `icon normalize` | RT-04 | Keys a returned candidate to transparency (or keeps an opaque night tile full-bleed), trims and fits it to the 688 px live area of a 1024 px RGBA PNG, then runs the QA. A failing QA writes nothing (`art-fail:icon-qa`, exit 1). | `--out` (default `assets/icon.png` in the checkout), `--preview`, `--strict`, `--min-contrast`, `--ocr` | | `icon check` | RT-04 | Automated icon QA: size, byte budget, alpha, live area, coverage, contrast on the card (floor set by `--min-contrast`, default 3:1), family inks, not the org glyph, one file. OCR is opt-in with `--ocr`; the 48 px set sheet stays manual. | `--strict`, `--min-contrast`, `--ocr` | | `links` | CQ-04, RP-09 | Every relative link, anchor and image target in the displayed README (GitHub's `.github/`, root, `docs/` precedence, or `--readme-path`), `docs/**`, `.github/*.md` and the org profile. With `--base-ref`, only breakage the change introduces fails; `--strict` fails any. `--online` adds cached, polite remote checks (HEAD first, one request per host per `--delay` seconds, at most `--max-remote`) and `gh` `full_name` checks for GitHub links, so a renamed org or repository is proposed under its current name. Offline, a link to this repository under its former slug (a transfer left its badges, release and clone URLs on the old org) is `moved` with a rewrite proposal only when an approved repository `rename` or `transfer` action in `decisions/estate-actions.json` records that exact move (the resolver's `repo_before`, CQ-03); a repository of the same name under another org is a different repository and is never rewritten (every org has its own `.github`). Every URL `references/brand.yaml` holds (the decision 18 footer's href and logo URLs, the brand and hub sites) is fixed content: it is still checked, but no org rename, transfer or online `full_name` answer proposes rewriting it. Rewrites match whole targets, so `…/tests.yml` never half-rewrites `…/tests.yml/badge.svg`. Broken links carry proposals; `--diff` prints them as a unified diff that is never applied. | `--cache` | @@ -37,7 +37,7 @@ Every action also takes `--report FILE` (the JSON result), `--jobs FILE` (append ### Icon ground rule (`icon normalize`, `--ground auto`) -- Transparent corners: keyed as is. Four opaque corners closer than `--ground-tolerance` dE to each other (default 3.0; a finite number >= 0; identical corners always count as one ground at 0) are one ground: a dark one (relative luminance < 0.2) that is not within dE 12 of the card colour is kept full-bleed as a night tile (unless the kept PNG would exceed 300 KB, then it is keyed: `auto_reason` budget-fallback); any other uniform ground, pale or card-coloured, is keyed to transparent (the PNG stays under 300 KB). +- Transparent corners: keyed as is. Four opaque corners closer than `--ground-tolerance` dE to each other (default 3.0; a finite number from 0 to 12, the icon content dE; any other value, including NaN, a negative, 12.5 or 1000, is a usage error, exit 2, before any image is read; identical corners always count as one ground at 0) are one ground: a dark one (relative luminance < 0.2) that is not within dE 12 of the card colour is kept full-bleed as a night tile (unless the kept PNG would exceed 300 KB, then it is keyed: `auto_reason` budget-fallback); any other uniform ground, pale or card-coloured, is keyed to transparent (the PNG stays under 300 KB). - A slightly vignetted ground with corner dE above 3.0 fails with `icon-ground` and a message naming the measured dE and `--ground-tolerance`; rerun with e.g. `--ground-tolerance 5` for corner dE 3.1-4.9. Larger spreads still fail, so ask for a flat ground (the option A prompt states the exact `card_fill` hex with no vignette, texture, gradient, shadow or glow). - A raised `--ground-tolerance` widens the key threshold only above the default 3.0 (explicit modes stay byte-identical at the default), and a kept night tile with such a vignette is flattened to one ground so `content icon check` passes it. The flatten is feathered: pixels within the corner spread of the ground become the flat ground, and a soft ramp of 6 dE blends back to the original, so a smooth glow or bloom around the glyph stays smooth (no hard ring) while the glyph edge stays hard. The transparent key uses a soft 3 dE alpha ramp only when --ground-tolerance is raised above 3.0; at the default the key stays the original 0/255 step. - `--ground keep|transparent` behave as before; `--ground-tolerance` also sets how alike the corners must be for them. @@ -52,7 +52,7 @@ None by default. `links --online` and `images --online` make cached, rate-limite `--strict` (on `icon check` and `icon normalize`, also set by the family key `icon_strict_family_ink: true` in `references/brand.yaml`, which is off by default) makes the family-ink check fail instead of warn when the icon's dominant ink is not a family ink. Without the flag it stays a warning and the exit code is unchanged. With no family palette (`icon_ink` and `icon_spark` absent) the family-ink check is not emitted, so `--strict` has no effect there and `needs-input:icon-palette` is still logged. -`--min-contrast` sets the contrast floor of the icon's dominant ink on the card plate (on `icon normalize` and `icon check`). The precedence is the CLI flag, then the family key `icon_min_contrast` in `references/brand.yaml`, then the default 3.0. The default stays 3:1 because a higher floor would change results that already pass. The recommended floor for the card plate is 4.5:1 (WCAG AA body text); set it with `--min-contrast 4.5` or `icon_min_contrast: 4.5` for a family. The valid range is 1 to 21; any other value, or a non-number, is a usage error (exit 2). A failing contrast check fails the icon (exit 1), as the other failing icon checks do. +`--min-contrast` sets the contrast floor of the icon's dominant ink on the card plate (on `icon normalize` and `icon check`). The precedence is the CLI flag, then the family key `icon_min_contrast` in `references/brand.yaml`, then the default 3.0. The default stays 3:1 because a higher floor would change results that already pass. The recommended floor for the card plate is 4.5:1 (WCAG AA body text); set it with `--min-contrast 4.5` or `icon_min_contrast: 4.5` for a family. The valid range is 1 to 21; any other value, or a non-number, is a usage error (exit 2). A failing contrast check fails the icon (exit 1), as the other failing icon checks do. An explicitly configured floor (the flag or the family key) is measured on the most common actual ink colour of the dominant bucket, not the 16-level bucket midpoint, which can be 8 levels per channel off (grey #707070 on #F8FAFC is really 4.73:1, not the 4.22:1 of its midpoint); with no configured floor the legacy midpoint measurement and the 3.0 default are unchanged. `--ocr` (on `icon check` and `icon normalize`, off by default) adds an optional text check: when `tesseract` is on `PATH`, the icon is composited on the configured family card colour (card_fill) and read with it, and words of 2 or more alphanumeric characters count as text. Found text warns (`no-text`, detail `OCR found text: ...`) and fails the icon with `--strict`; no text passes. Single characters and punctuation are ignored. Without `--ocr`, or without `tesseract`, nothing changes: `no-text` stays a manual review, and an absent tool logs `needs-input:ocr-unavailable` without changing the result or the exit code (a tool that fails to run logs `needs-input:ocr-failed`). The tool is never installed by this package. An unreadable or undecodable PNG (bad structure, or valid structure with corrupt compressed data) fails the `png` check and returns a `logs` list naming the image (`unreadable-image:`), with and without `--ocr`. On `icon normalize`, an unreadable or undecodable `--in` candidate also fails the `png` check (exit 1, `out` null, nothing written; `logs` names the `--in` source as `unreadable-image:` plus `art-fail:icon-qa`), while a missing `--in` file stays a usage error (exit 2). The 48 px set sheet stays manual. diff --git a/scripts/ctshowcase/content.py b/scripts/ctshowcase/content.py index 4766487..7ec47a5 100644 --- a/scripts/ctshowcase/content.py +++ b/scripts/ctshowcase/content.py @@ -20,7 +20,6 @@ import html import io import json -import math import re import shutil import subprocess @@ -1764,6 +1763,14 @@ def sidebar_plan(meta: dict, facts: dict, *, resolver: Resolver | None = None, p "TALL": "spans close to the full 688 px height and is about 400 to 600 px wide", "SQUARE": "a squarish glyph about 600 to 660 px on each side", "DIAGONAL": "the mass runs corner to corner inside a 656 × 656 px box"} + + +def size_class_help() -> str: + """The allowed size_class values with their one-line meanings, for the invalid-value error and the note.""" + return "size_class must be one of: " + "; ".join(f"{k} ({v})" for k, v in SIZE_CLASSES.items()) + \ + ". Names are case-insensitive." + + HEX = r"`(#[0-9A-Fa-f]{6})`" @@ -1882,11 +1889,17 @@ def ground_clause(card: str) -> str: def icon_prompt(ctx, resolver: Resolver, art_text: str, glyph: dict | None = None, option: str = "A", - language: str | None = None, skill_art: str | None = None) -> dict: + language: str | None = None, skill_art: str | None = None, seed: int | None = None) -> dict: """The Codex prompt for a repository icon (f.3 option A: the (d) template with "repo icon" for "avatar"; option B: the illustration template on the light card). The tool fills every mechanical slot from the art direction and the repository's facts; the glyph itself is the writer's (needs-writer until `glyph` supplies it).""" glyph = {k.upper(): v for k, v in (glyph or {}).items() if v} + invalid: dict = {} + # one image per request; a seed is only ever asserted when the caller asked for one (--seed), never inferred from + # the family's documented seed, because the image tool may not support seeds at all + seed_text = str(seed) if seed is not None else "not supported" + seed_instruction = f" Use seed {seed}." if seed is not None else "" + requested = {"images": 1, "seed": seed_text} owner, repo = ctx.owner, ctx.repo org = org_row(resolver, ctx.brand, owner, require=True) org["canonical"], org["current"] = org["brand_key"], current_login(resolver, owner) @@ -1928,6 +1941,7 @@ def icon_prompt(ctx, resolver: Resolver, art_text: str, glyph: dict | None = Non card = ctx.style["tokens"]["card_fill"] prompt = tpl.replace("FAMILY STYLE LOCK, VERBATIM", lock or "{STYLE_LOCK}") prompt = prompt.replace("Square 1024x1024 | Wide 1536x1024", "Square 1024x1024") + prompt = prompt.rstrip() + "\n\nProduce one image." + seed_instruction prompt = prompt.replace("untextured BG background", f"untextured {card} background") if family == "completetech" and pal: prompt = prompt.replace("PALETTE. HIGHLIGHT LINE", f"{pal.group(1)}. {pal.group(2)}") @@ -1943,15 +1957,13 @@ def icon_prompt(ctx, resolver: Resolver, art_text: str, glyph: dict | None = Non if not tpl: raise NeedsInput("art-direction", "selected art direction has no (d) Shared prompt template " "or Repository glyph prompt template") - seed = seed_for(art_text, logins, family) slots = {k: colours.get(k) for k in ("LINE_NAME", "LINE_HEX", "CORE_HEX", "GLOW_HEX", "SPARK_NAME", "SPARK_HEX", "SPARK_CORE_HEX", "SPARK_BLOOM_HEX")} slots.update(ACCOUNT=f"{org['current']}/{repo}", KIND="repository", CIRCLE_CLAUSE="", CROP="repository icon, shown inside the light card of the README hero banner, the repo card and the " "social preview", SECONDARY_LIGHT="", SECONDARY_LINE="", SPARK_D="94", SPARK_CORE_D="37", TEXT_RULE="Any text, letters, numbers or words", - SEED=seed or "not set", - SEED_INSTRUCTION=f"Use seed {seed} when supported." if seed else "Use the tool's default seed.", + SEED=seed_text, SEED_INSTRUCTION=seed_instruction, STYLE_REFERENCES="the three approved pilot renders from art/golden/neon-night/.", BRAND_REFERENCE=f"Org avatar art/reference/avatars/{org['canonical']}.png for family consistency " "only: do not copy its glyph; this repository's silhouette must differ from it at " @@ -1965,6 +1977,8 @@ def icon_prompt(ctx, resolver: Resolver, art_text: str, glyph: dict | None = Non size = str(glyph.get("SIZE_CLASS") or "").upper() if size in SIZE_CLASSES: slots["SIZE_CLASS"] = f"{size}: {SIZE_CLASSES[size]}" + elif size: + invalid["SIZE_CLASS"] = f"{glyph['SIZE_CLASS']!r} is not a valid size_class. " + size_class_help() for k in ("GLYPH_NAME", "GLYPH_DESCRIPTION", "SPARK_PLACEMENT"): if glyph.get(k): slots[k] = glyph[k] @@ -1973,8 +1987,12 @@ def icon_prompt(ctx, resolver: Resolver, art_text: str, glyph: dict | None = Non prompt = re.sub(r"\(a\) Geometry:.*?(?= \(b\) Style:)", "(a) Geometry: none yet; this glyph is new, so section 7 is the geometry and the lock pass " "traces the chosen candidate into the compositor.", tpl, flags=re.S) - prompt = re.sub(r"output art/out/avatars/\S+", f"output art/out/icons/{org['current']}/{repo}-1024-c{{1..4}}.png", + prompt = re.sub(r"output art/out/avatars/\S+", f"output art/out/icons/{org['current']}/{repo}-1024.png", prompt) + prompt = prompt.replace("seed {SEED} if the tool supports seeds", "seed: {SEED}") + # legacy "Use seed {SEED} when supported." would assert a seed (or "not supported") unconditionally + prompt = re.sub(r"\bUse seed \{SEED\}(?: (?:when|if) (?:supported|the tool supports seeds))?\.", + "{SEED_INSTRUCTION}", prompt, flags=re.I) prompt = re.sub(r"The pipeline downsamples your image[^.]*\.", "The lock pass traces your image into the compositor, which renders the flat 1024 × 1024 px " "assets/icon.png in the family light-surface inks.", prompt) @@ -2004,9 +2022,19 @@ def _ground_paragraph(m): # the whole numbered BACKGROUND paragraph, up to the prompt = prompt.replace("{" + k + "}", str(v)) if clause not in prompt: # the exact ground is appended whenever no paragraph replacement stated it prompt = prompt.rstrip() + "\n\n" + clause + # older art-direction files (including the previous bundled one, via --art-direction) say "Produce four + # candidates" / "4 candidates"; the request is one image, so the prompt and the provenance must agree + prompt = re.sub(r"\bProduce (?:four|4) candidates\.", "Produce one image.", prompt, flags=re.I) + prompt = re.sub(r"\b(?:four|4) candidates\b", "1 image", prompt, flags=re.I) + if seed is not None and not re.search(rf"\bseed:?\s+{seed}\b", prompt, re.I): + # a custom template without a seed placeholder: the requested seed must still be asked for, once + prompt = prompt.rstrip() + "\n\n" + seed_instruction.strip() missing = sorted(set(re.findall(r"\{([A-Z_]+)\}", prompt))) + if option.upper() == "A" and not str(glyph.get("SIZE_CLASS") or "").strip() and "SIZE_CLASS" not in missing: + missing = sorted(missing + ["SIZE_CLASS"]) # a literal-size custom template must not bypass the size_class check ink = colours.get("INK") or [] - return {"prompt": prompt, "missing": missing, "facts": facts, "option": option.upper(), + return {"prompt": prompt, "missing": missing, "invalid": invalid, "facts": facts, "option": option.upper(), + "requested": requested, "render": {"ink": ink[0] if ink else None, "spark": ink[1] if len(ink) > 1 else None, "size": [ICON_PX, ICON_PX], "live_area": [LIVE_MARGIN, LIVE_MARGIN, ICON_PX - LIVE_MARGIN, ICON_PX - LIVE_MARGIN], "budget": ICON_BUDGET}} @@ -2023,6 +2051,7 @@ def prompt_markdown(res: dict) -> str: f"- Render (lock pass): flat body in the light-surface ink {r['ink'] or '(see ART_DIRECTION (b))'}, spark in " f"{r['spark'] or '(see ART_DIRECTION (b))'}, no tile and no glow, centred in the live area " f"{r['live_area']}.\n" + f"- Requested: {res['requested']['images']} image; seed: {res['requested']['seed']}.\n" "- Then: `showcase.py content icon normalize --in ` and `showcase.py content icon check`.\n\n" "## Repository facts\n\n| Fact | Value |\n|---|---|\n" + rows + "\n\n## Prompt\n\n```text\n" + res["prompt"] + "\n```\n") @@ -2063,9 +2092,7 @@ def normalize_icon(img, ground: str = "auto", card: str | None = None, pixel_art from .color import rgb as hexrgb from .iconbox import DARK_PLATE, GROUND_DE, corner_ground, corner_spread, native_pixels from .color import luminance - tol = GROUND_DE if tolerance is None else float(tolerance) - if not math.isfinite(tol) or tol < 0: - raise ShowcaseError(f"--ground-tolerance must be a finite number >= 0 (got {tolerance!r})", log="usage") + tol = GROUND_DE if tolerance is None else icon_ground_tolerance(tolerance) im = img.convert("RGBA") info: dict = {"source_px": list(im.size)} corners = [im.getpixel(p) for p in ((0, 0), (im.width - 1, 0), (0, im.height - 1), (im.width - 1, im.height - 1))] @@ -2231,6 +2258,16 @@ def icon_contrast_floor(cli_value, family_value) -> float: return float(value) +def icon_ground_tolerance(value) -> float: + """`--ground-tolerance`: a finite corner dE from 0 to CONTENT_DE (12). NaN would make every `spread > tol` test + false (any four opaque corners become one averaged ground) and a huge value widens the key (KEY_DE + tol) until it + keys the whole image, so both are refused as usage errors, as `--min-contrast` is (review A, epic #51). Above 12 + the corners may differ by more than what icon_qa counts as content, so they are no longer one ground.""" + if isinstance(value, bool) or not isinstance(value, (int, float)) or not 0 <= value <= CONTENT_DE: + raise ShowcaseError(f"--ground-tolerance must be a dE between 0 and {CONTENT_DE:g}, got {value}", log="usage") + return float(value) + + def _tesseract() -> str | None: """The `tesseract` executable on PATH, or None. OCR is optional: this tool never installs it.""" return shutil.which("tesseract") @@ -2272,14 +2309,19 @@ def _ocr_words(png: bytes, exe: str) -> list[str] | None: def icon_qa(data: bytes, *, card: str, ink: list[str] | None = None, org_mark: Path | None = None, siblings: list[str] | None = None, strict: bool = False, min_contrast: float = 3.0, - ocr: bool = False, source: str | None = None) -> dict: + ocr: bool = False, source: str | None = None, exact_contrast: bool = False) -> dict: """Automated icon checks (f.3, art QA): size, budget, alpha, live area, coverage, contrast on the card, family inks, not the org glyph, one file. The 48 px set sheet stays manual. `strict` makes a failing family-ink check fail the icon (default: warn); with no `ink` the family-ink check is not emitted at all. `min_contrast` is - the contrast floor of the dominant ink on the card (default 3.0; see icon_contrast_floor). `ocr` (opt-in, off by - default) reads the icon with tesseract on PATH: words of 2+ alphanumerics are text, warn (fail with `strict`); - without tesseract, or when it fails, no-text stays manual and `logs` names needs-input:ocr-unavailable or - needs-input:ocr-failed. A `logs` key is returned with `ocr`, and always on a png failure, where it is + the contrast floor of the dominant ink on the card (default 3.0; see icon_contrast_floor). The dominant ink is the + most common 16-level colour bucket; by default its contrast is measured at the bucket midpoint (legacy). With + `exact_contrast` (set when a floor was explicitly configured) it is measured at the most common actual RGB in that + bucket, because the midpoint can be up to 8 levels per channel off: grey 128 on a dark teal card is really 4.06:1 + but its midpoint (grey 136) measures 4.52:1, a false pass at 4.5; grey 112 on a pale card is 4.73:1 but its + midpoint (grey 120) measures 4.22:1, a false fail. + `ocr` (opt-in, off by default) reads the icon with tesseract on PATH: words of 2+ alphanumerics are text, warn (fail + with `strict`); without tesseract, or when it fails, no-text stays manual and `logs` names needs-input:ocr-unavailable + or needs-input:ocr-failed. A `logs` key is returned with `ocr`, and always on a png failure, where it is ["unreadable-image:"] naming the unreadable image (`source` is the file path when known, else ).""" from PIL import Image from .color import contrast, hexstr, rgb as hexrgb @@ -2341,10 +2383,18 @@ def add(name, ok, detail="", level="fail"): for p in ink_px: key = (p[0] // 16 * 16 + 8, p[1] // 16 * 16 + 8, p[2] // 16 * 16 + 8) counts[key] = counts.get(key, 0) + 1 - dom = hexstr(max(counts, key=counts.get)) - cr = contrast(dom, card) + bucket = max(counts, key=counts.get) + dom = hexstr(bucket) + measured = dom + if exact_contrast: + exact: dict = {} + for p in ink_px: + if (p[0] // 16 * 16 + 8, p[1] // 16 * 16 + 8, p[2] // 16 * 16 + 8) == bucket: + exact[p[:3]] = exact.get(p[:3], 0) + 1 + measured = hexstr(max(exact, key=exact.get)) + cr = contrast(measured, card) floor = float(min_contrast) - add("contrast-on-card", cr >= floor, f"dominant ink {dom} on {card}: {cr:.2f}:1 (>= {floor:g}:1)") + add("contrast-on-card", cr >= floor, f"dominant ink {measured} on {card}: {cr:.2f}:1 (>= {floor:g}:1)") if ink: near = min(_de(lab(hexrgb(dom)), lab(hexrgb(x))) for x in ink) add("family-ink", near <= 12, f"dominant ink {dom}, nearest family ink ΔE {near:.1f}", @@ -2427,12 +2477,15 @@ def add_parser(sub, cli=None): a.add_argument("icon_action", choices=("prompt", "normalize", "check")) a.add_argument("--option", choices=("A", "B"), default="A", help="q59 option: A Neon Night glyph, B isometric") a.add_argument("--glyph", help="YAML/JSON with the writer's glyph_name, glyph_description, spark_placement, " - "size_class (option B: subject, objects)") + "size_class (WIDE|TALL|SQUARE|DIAGONAL; option B: subject, objects)") a.add_argument("--art-direction", help="art direction file (default: the skill's references/art-direction.md)") + a.add_argument("--seed", type=int, help="prompt: ask the image tool for this seed (recorded in the prompt and " + "provenance; default: not supported, no seed is requested)") a.add_argument("--in", dest="src", help="normalize: the returned candidate PNG") a.add_argument("--ground", choices=("auto", "transparent", "keep"), default="auto") a.add_argument("--ground-tolerance", type=float, default=3.0, metavar="DE", - help="normalize: corners within this dE are one ground (default 3.0; try 5 for a vignetted ground)") + help="normalize: corners within this dE are one ground (default 3.0; try 5 for a vignetted ground; " + "0 to 12)") a.add_argument("--out", help="prompt: the prompt file (art/prompts///icon.md); normalize: the icon " "(default assets/icon.png in the checkout); check: the icon to check") a.add_argument("--preview", help="normalize: also write a 48 px preview on the card colour") @@ -2745,21 +2798,32 @@ def _cmd_icon(args, cli, resolver, head): except NeedsInput: lang = None skill_art = SKILL_ART_DIRECTION.read_text(encoding="utf-8") if args.option == "B" else None - res = icon_prompt(ctx, resolver, art, glyph, args.option, lang, skill_art) + res = icon_prompt(ctx, resolver, art, glyph, args.option, lang, skill_art, args.seed) md = prompt_markdown(res) - result = "needs-writer" if res["missing"] else "ok" + result = "needs-writer" if (res["missing"] or res["invalid"]) else "ok" out = None - if args.out and not res["missing"]: + if args.out and not res["missing"] and not res["invalid"]: out = Path(args.out) out.parent.mkdir(parents=True, exist_ok=True) out.write_text(md, encoding="utf-8") logs = ctx.logs + ([f"needs-writer:{s}" for s in res["missing"]]) - obj = dict(head, result=result, out=str(out) if out else None, missing=res["missing"], facts=res["facts"], - render=res["render"], prompt=res["prompt"], markdown=None if out else md, logs=logs, - note="The glyph is the writer's: fill glyph_name, glyph_description, spark_placement and size_class " - "(--glyph FILE) and re-run until the result is ok; nothing is written before then.") + invalid = res["invalid"] + if invalid: + logs = logs + ["needs-writer:SIZE_CLASS:invalid"] + note = ("The glyph is the writer's: fill glyph_name, glyph_description, spark_placement and size_class " + "(--glyph FILE) and re-run until the result is ok; nothing is written before then.") + if invalid: + note += " " + invalid["SIZE_CLASS"] + elif "SIZE_CLASS" in res["missing"]: + note += " " + size_class_help() + obj = dict(head, result=result, out=str(out) if out else None, missing=res["missing"], invalid=invalid, + facts=res["facts"], render=res["render"], prompt=res["prompt"], markdown=None if out else md, + logs=logs, note=note) return obj, _code(result) floor = icon_contrast_floor(getattr(args, "min_contrast", None), ctx.style.get("icon_min_contrast")) + # an explicitly configured floor is measured on the actual ink, not the bucket midpoint (review B, epic #51); the + # unconfigured default keeps the legacy midpoint measurement so existing pass/fail results do not move + exact = getattr(args, "min_contrast", None) is not None or ctx.style.get("icon_min_contrast") is not None ink = [] try: art = load_art_direction(args.art_direction) @@ -2771,6 +2835,7 @@ def _cmd_icon(args, cli, resolver, head): if args.icon_action == "normalize": if not args.src: raise ShowcaseError("content icon normalize needs --in CANDIDATE.png", log="usage") + tolerance = icon_ground_tolerance(args.ground_tolerance) try: raw = Path(args.src).read_bytes() except OSError as exc: @@ -2781,7 +2846,7 @@ def _cmd_icon(args, cli, resolver, head): except (OSError, ValueError, SyntaxError, IndexError, Image.DecompressionBombError): # readable but undecodable source: the structured png failure from icon_qa, nothing written qa = icon_qa(raw, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, - min_contrast=floor, ocr=ocr, source=str(args.src)) + min_contrast=floor, ocr=ocr, source=str(args.src), exact_contrast=exact) if qa["result"] != "fail": qa["result"] = "fail" qa["checks"].append({"check": "png", "result": "fail", "detail": "undecodable image data"}) @@ -2793,10 +2858,10 @@ def _cmd_icon(args, cli, resolver, head): logs=ctx.logs + qa_logs + ["art-fail:icon-qa"], note="nothing written"), CHECK_FAILED img, info = normalize_icon(src, args.ground, card, pixel_art=bool(ctx.style.get("icon_pixel_art")), - tolerance=args.ground_tolerance) + tolerance=tolerance) data, qlogs = icon_bytes(img) qa = icon_qa(data, card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, min_contrast=floor, - ocr=ocr, source=str(args.src)) + ocr=ocr, source=str(args.src), exact_contrast=exact) out = Path(args.out) if args.out else cli.default_out(args, "assets/icon.png") if qa["result"] == "fail": return dict(head, result="fail", out=None, info=info, qa=qa, @@ -2815,7 +2880,7 @@ def _cmd_icon(args, cli, resolver, head): return dict(head, result="fail", file=str(path) if path else None, logs=ctx.logs + ["needs-input:repo-icon"]), \ CHECK_FAILED qa = icon_qa(path.read_bytes(), card=card, ink=ink, org_mark=mark, siblings=siblings, strict=strict, - min_contrast=floor, ocr=ocr, source=str(path)) + min_contrast=floor, ocr=ocr, source=str(path), exact_contrast=exact) return dict(head, result=qa["result"], file=str(path), qa=qa, logs=ctx.logs + qa.get("logs", [])), _code(qa["result"]) diff --git a/tests/fixtures/content/ART_DIRECTION.synthetic-legacy.md b/tests/fixtures/content/ART_DIRECTION.synthetic-legacy.md new file mode 100644 index 0000000..ae8b5c9 --- /dev/null +++ b/tests/fixtures/content/ART_DIRECTION.synthetic-legacy.md @@ -0,0 +1,50 @@ +# Synthetic legacy art direction + +This is a synthetic test fixture that imitates the pre-#61 template syntax: it asked for four candidates and used a seed placeholder. It contains no production brand data. The original lives in git history (commits baa3c02 and 71ffd4a). + +## Repository glyph prompt template + +```text +SYNTHETIC LEGACY REPO ICON · {ACCOUNT} + +Create one square 1024 × 1024 px image of a single repository glyph. +Keep the background empty and the glyph inside the central 688 × 688 px square. +Size class {SIZE_CLASS}. + +GLYPH: {GLYPH_NAME}. {GLYPH_DESCRIPTION} +SPARK PLACEMENT: {SPARK_PLACEMENT} +PALETTE: outline {LINE_HEX}, glow {GLOW_HEX}; one spark in {SPARK_HEX}. + +No text, letters, numbers, words, logos, brand marks, human figures or third-party IP. +{EXTRA_NEGATIVES} + +Produce four candidates. {SEED_INSTRUCTION} +``` + +## Prompt template for illustrations + +```text +[STYLE LOCK — FAMILY] +FAMILY STYLE LOCK, VERBATIM + +[SUBJECT] +One sentence: the single hero object or scene that represents the repository or org's function. + +[OBJECTS] +3–5 objects. Only objects from a synthetic placeholder library. + +[COMPOSITION] +Isolated platform and objects on a perfectly flat, untextured BG background; 10% empty margin on all sides. Square 1024x1024 | Wide 1536x1024. + +[PALETTE] +Use only these colors: PALETTE. HIGHLIGHT LINE + +[EXCLUSIONS] +No text, letters, numbers, symbols, words, labels, logos or brand marks. FAMILY ADDITIONS +``` + +| Family | Style lock (verbatim) | +|---|---| +| completetech | Synthetic placeholder style lock: flat neutral shapes on a plain ground. | + +- Family additions: none (synthetic fixture). diff --git a/tests/test_art_direction_recipe.py b/tests/test_art_direction_recipe.py index e7cfbe2..2cba2fd 100644 --- a/tests/test_art_direction_recipe.py +++ b/tests/test_art_direction_recipe.py @@ -75,13 +75,16 @@ | TouchedByAnAgent | Glossy 3D product illustration on deep maroon: smooth pink-peach robotics, warm amber glow, soft magenta signal ripples, tasteful and non-suggestive, studio lighting. | | romgenie | Glossy illustrated 3D still life in a warm vault: brass-bound objects, soft lamp light from above, dark teal ambient background, storybook-premium finish. |""".splitlines() +# The provenance template outside the recipe. Issue #61 (one image per prompt run) deliberately +# replaced the former "Candidates: 4 or 8" line with "Images requested", because each run now +# asks for one image; the recipe (#64) must not change anything else in this block. BASE_PROVENANCE = """## assets/icon.png - Date: YYYY-MM-DD - Tool and model: TOOL / MODEL ID AND VERSION - Seed: SEED or "not supported" - Reference images: golden reference path, jev-reference.png, or "none" - Prompt: (verbatim, fenced) -- Candidates: 4 or 8; selected: N (reason: one line) +- Images requested: as asked in the prompt (`content icon prompt` asks for 1); selected: N (reason: one line) - Post-processing: showcase.py VERSION; output sha256 HASH; size KB """ @@ -264,15 +267,16 @@ def test_recipe_provenance_never_records_absolute_path(): assert "record the codex session id and the path" not in step.lower() -def test_recipe_attempt_cap_is_per_candidate(): - # Successful runs are candidates and must not exhaust the cap, otherwise the - # four-candidate round (pipeline rule 7) could never complete. +def test_recipe_attempt_cap_is_per_slot(): + # Successful runs fill a slot and must not exhaust the cap, otherwise the + # four-invocation round (pipeline rule 7) could never complete. section = _recipe(_text()) lowered = section.lower() - assert "per candidate, not per candidate set" in lowered - assert "per candidate set" not in lowered.replace("per candidate, not per candidate set", "") + assert "the limit applies per candidate slot" in lowered + assert "per candidate set" not in lowered assert "successful candidates never count toward the cap" in lowered assert "only empty runs count toward the 3 attempts" in lowered + assert "one slot's empty run does not count against another slot" in lowered def test_recipe_labels_sandbox_facts_as_observed_and_uses_exact_returned_path(): @@ -296,17 +300,30 @@ def test_recipe_does_not_cite_unverified_url_for_image_path(): assert "http" not in _recipe(_text()).lower() -def test_recipe_candidate_contract_is_consistent_with_four_candidate_template(): - # Static wording test: the fixed generator template line stays byte-identical and unique, - # and the recipe handles every returned path instead of one run per candidate. +def test_recipe_candidate_contract_is_single_image(): + # Static wording test (#61, replacing the #64 four-candidate pins): the generator template asks for + # ONE image, exactly once, and rule 7 is four single-image invocations. text = _text() - assert text.count("Produce four candidates. {SEED_INSTRUCTION}") == 1 - assert text.count("Produce four candidates.") == 1 + assert text.count("Produce one image.{SEED_INSTRUCTION}") == 1 + assert "Produce four candidates" not in text + assert "four candidates. {SEED_INSTRUCTION}" not in text section = _recipe(text) lowered = section.lower() assert "one run per candidate" not in lowered - assert "every distinct path the run output reports is one candidate" in lowered - assert "between zero and four image paths" in lowered + assert "between zero and four image paths" not in lowered + assert "every distinct path the run output reports is one candidate" not in lowered + assert "asks for one image each" in lowered + assert "item 5" in lowered + assert "each invocation asks for one image and fills one slot" in lowered + assert "select the first readable path in the order the run reported them" in lowered + assert "one accepted candidate per slot, even when several paths are reported" in lowered + assert "the remaining reported paths are ignored" in lowered + assert "asks for one image, so a run reports at most one image path" not in lowered + assert "at most one image path" not in lowered + assert "one run reports at most one" not in lowered + assert "fills at most one candidate slot, but its output may report more than one path" in lowered + assert "first readable path the run reported" in lowered + assert "an extra path, if a run ever reports one" not in lowered assert "empty run" in lowered assert "four candidate slots" in lowered assert "the cap applies to each slot" in lowered @@ -317,6 +334,17 @@ def test_recipe_candidate_contract_is_consistent_with_four_candidate_template(): assert "art-fail" in lowered +def test_pipeline_rules_4_and_7_match_the_single_image_prompt(): + rules = _text().split("## Pipeline rules (MUST)", 1)[1] + rule4 = next(line for line in rules.splitlines() if line.startswith("4. ")) + rule7 = next(line for line in rules.splitlines() if line.startswith("7. ")) + assert "record a seed only when one was requested" in rule4.lower() + assert "content icon prompt --seed n" in rule4.lower() and '"not supported"' in rule4 + assert "four single-image invocations" in rule7 + assert "four more single-image invocations" in rule7 and "art-fail" in rule7 + assert "generate 4 candidates" not in rule7.lower() + + def test_recipe_unreadable_only_output_counts_against_slot_budget(): # Static wording test (mock evidence, not an image-generation run): a run whose only # reported paths are unreadable must fill no slot and count as an empty run, so the @@ -326,7 +354,7 @@ def test_recipe_unreadable_only_output_counts_against_slot_budget(): assert "a run that reports no readable path is an empty run" in lowered assert "unreadable-only" in lowered assert "an unreadable path fills no slot" in lowered - assert "counts as one attempt against every slot that is still unfilled" in lowered + assert "an empty run (no reported path is readable) is retried as a new run with the same unchanged prompt, at most 2 retries (3 attempts total) for that slot" in lowered assert "unreadable-only output is counted as an empty run" in lowered assert "after 3 empty attempts for any one slot, including attempts that returned only unreadable paths" in lowered assert "art-fail" in lowered @@ -342,18 +370,38 @@ def test_recipe_unreadable_only_output_counts_against_slot_budget(): assert "retry the run under the retry limit" not in sandbox_c[0] -def test_recipe_opus_p3_fixes_plural_paths_rule7_slots_and_scratch_copy(): - # Static wording test (mock evidence, not an image-generation run): the four accepted P3 findings. +def test_recipe_distinguishes_one_accepted_candidate_from_one_reported_path(): + # Static wording test (mock evidence, not an image-generation run): #61 review P2. A single-image run may report + # an unreadable sandbox copy and a readable generated_images file; the slot is filled by the readable one and the + # run is neither empty nor unreadable-only (it must not exhaust the 3-attempt budget despite a good generation). + section = _recipe(_text()) + lowered = section.lower() + assert "the first readable path an invocation reports fills the slot that invocation was for" in lowered + assert "one unreadable path and one readable path is not unreadable-only" in lowered + assert "it fills the slot with the readable path and is not an empty run" in lowered + assert "an empty run (no reported path is readable)" in lowered + sandbox_c = [line.strip().lower() for line in section.splitlines() + if line.strip().lower().startswith("- (c) if a path is unreadable")] + assert len(sandbox_c) == 1 + assert "use the first other readable path the same run reported" in sandbox_c[0] + assert "only when no reported path is readable" in sandbox_c[0] + # sandbox (c) no longer declares the run empty unconditionally + assert "skip it" in sandbox_c[0] and not sandbox_c[0].split("only when no reported path is readable")[0].count("empty run") + + +def test_recipe_opus_p3_fixes_single_path_rule7_slots_and_scratch_copy(): + # Static wording test (mock evidence, not an image-generation run): the accepted #64 P3 findings, + # restated for the single-image contract of #61. text = _text() - assert text.count("Produce four candidates.") == 1 + assert text.count("Produce one image.") == 1 and "Produce four candidates" not in text lowered = _recipe(text).lower() - # (1) item 1 reads every image path the run reports, not a single path - assert "every image file path codex reports" in lowered - assert "use exactly those paths (see item 5)" in lowered - assert "use exactly that path" not in lowered + # (1) item 1 reads the one image path the run reports + assert "for the image file path codex reports for the generated image" in lowered + assert "use exactly that path (see item 5)" in lowered + assert "use exactly those paths" not in lowered # (2) the first retry bullet distinguishes pipeline rule 7 and the option A template - assert "pipeline rule 7 asks for four candidates" in lowered - assert "the option a template also says so" in lowered + assert "pipeline rule 7 asks for four single-image invocations" in lowered + assert "the option a template asks for one image each" in lowered assert "the fixed prompt asks for four candidates" not in lowered assert "four candidate slots" in lowered # (3) the second round of rule 7 opens four new slots with the same per-slot cap @@ -362,3 +410,5 @@ def test_recipe_opus_p3_fixes_plural_paths_rule7_slots_and_scratch_copy(): # (4) item 4 normalizes a scratch copy of the chosen image, not the generated path assert "copy the chosen image to scratch (see sandbox (d)) and pass that copy to content icon normalize --in path" in lowered assert "pass the chosen path to `content icon normalize" not in lowered + # (5) CLI help is not image generation (#61) + assert "only a returned readable image path proves a generation" in lowered diff --git a/tests/test_content.py b/tests/test_content.py index 57b70ba..c1c8fdd 100644 --- a/tests/test_content.py +++ b/tests/test_content.py @@ -612,9 +612,10 @@ def test_icon_prompt_needs_writer_then_ok(brand, icon_ctx): p = res["prompt"] assert res["missing"] == [] and p.startswith("NEON NIGHT REPO ICON · STYLE LOCK v1.2 · Northwind-Trading-Co/") assert "#46B8FF" in p and "#CBEBFF" in p and "#0081ED" in p and "Bulb Gold #FFC300" in p - assert "Size class WIDE:" in p and "seed 1025" in p and "GitHub avatar" not in p and "28 avatars in" not in p + assert "Size class WIDE:" in p and "1025" not in p and "seed: not supported" in p and "GitHub avatar" not in p and "28 avatars in" not in p assert "art/reference/avatars/Northwind-Trading-Co.png" not in p.split("(b) Style")[0] - assert "art/out/icons/Northwind-Trading-Co/support-triage-agent-1024-c{1..4}.png" in p + assert "art/out/icons/Northwind-Trading-Co/support-triage-agent-1024.png" in p + assert "-c{1..4}" not in p and "candidates" not in p.lower() assert "Do not draw the Northwind-Trading-Co org glyph (Compass rose" in p assert res["render"]["ink"] == "#005ADD" and res["render"]["live_area"] == [168, 168, 856, 856] md = C.prompt_markdown(res) @@ -783,7 +784,7 @@ def test_icon_noisy_dark_ground_falls_back_to_keying_inside_the_budget(): assert len(C.icon_bytes(out)[0]) <= 300_000 -@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -1.0]) +@pytest.mark.parametrize("bad", [float("nan"), float("inf"), -1.0, 12.5]) def test_icon_ground_tolerance_rejects_non_finite_and_negative(bad): from ctshowcase.errors import ShowcaseError img = _candidate("#F8FAFC", size=256, box=(60, 60, 190, 190)) @@ -1124,7 +1125,7 @@ def test_icon_cli_ground_tolerance_flag_wired(capsys, tmp_path, brand): assert not out.exists() -@pytest.mark.parametrize("bad", ["nan", "-1", "inf"]) +@pytest.mark.parametrize("bad", ["nan", "-1", "inf", "1000", "12.5"]) def test_icon_cli_ground_tolerance_rejects_bad_values(capsys, tmp_path, brand, bad): """A non-finite or negative --ground-tolerance is a usage error (exit 2, log 'usage'); nothing is written.""" card = brand.style(NW)["tokens"]["card_fill"] @@ -1400,6 +1401,91 @@ def test_icon_min_contrast_cli(capsys, tmp_path, brand): assert code == 2, res +def _solid_icon(ink): + from PIL import Image, ImageDraw + im = Image.new("RGBA", (1024, 1024), (0, 0, 0, 0)) + ImageDraw.Draw(im).rectangle((300, 300, 724, 724), fill=tuple(ink) + (255,)) + return C.icon_bytes(im)[0] + + +def _contrast_detail(qa): + return next(c["detail"] for c in qa["checks"] if c["check"] == "contrast-on-card") + + +def test_icon_explicit_floor_measures_actual_ink_not_bucket_midpoint(): + """Epic #51 review B, reproduced on 8f27ef1 with min_contrast=4.5: #707070 on #F8FAFC (actual 4.73:1) failed as + the bucket midpoint #787878 at 4.22:1, and #808080 on #032530 (actual 4.06:1) passed as #888888 at 4.52:1. An + explicitly configured floor measures the most common actual RGB; the unconfigured default keeps the legacy + midpoint so existing results do not move.""" + from ctshowcase.color import contrast + assert contrast("#808080", "#032530") < 4.5 < contrast("#888888", "#032530") + assert contrast("#787878", "#F8FAFC") < 4.5 < contrast("#707070", "#F8FAFC") + false_pass = C.icon_qa(_solid_icon((0x80,) * 3), card="#032530", min_contrast=4.5, exact_contrast=True) + assert _check_result(false_pass, "contrast-on-card") == "fail", false_pass + assert "#808080" in _contrast_detail(false_pass) + false_fail = C.icon_qa(_solid_icon((0x70,) * 3), card="#F8FAFC", min_contrast=4.5, exact_contrast=True) + assert _check_result(false_fail, "contrast-on-card") == "pass", false_fail + assert "#707070" in _contrast_detail(false_fail) + # legacy default (no exact_contrast): the midpoint measurement is unchanged + legacy = C.icon_qa(_solid_icon((0x70,) * 3), card="#F8FAFC", min_contrast=4.5) + assert _check_result(legacy, "contrast-on-card") == "fail" and legacy["dominant_ink"] == "#787878" + assert "#787878" in _contrast_detail(legacy) + + +def test_icon_min_contrast_cli_measures_actual_ink(capsys, tmp_path, brand): + """The CLI turns on the exact measurement only when a floor is configured (--min-contrast or the family key).""" + card = brand.style(NW)["tokens"]["card_fill"] + assert card.upper() == "#F8FAFC" + out = write(tmp_path, {"repo/assets/icon.png": _solid_icon((0x70,) * 3)}) / "repo" / "assets" / "icon.png" + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + check = ("--meta", meta, "--no-repo-dir", "content", "icon", "check", "--out", out, + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + code, res = run_content(capsys, *check, "--min-contrast", "4.5") + assert _check_result(res["qa"], "contrast-on-card") == "pass", res + assert "#707070" in _contrast_detail(res["qa"]) + # converse: no --min-contrast and no icon_min_contrast keeps the legacy bucket midpoint + code, res = run_content(capsys, *check) + assert "#787878" in _contrast_detail(res["qa"]), res + # a family icon_min_contrast alone also turns the exact measurement on + import yaml + raw = yaml.safe_load((Path(__file__).resolve().parents[1] / "references" / "brand.yaml").read_text(encoding="utf-8")) + raw["families"]["completetech"]["icon_min_contrast"] = 4.5 + cfg = tmp_path / "brand-floor.yaml" + cfg.write_text(yaml.safe_dump(raw, sort_keys=False, allow_unicode=True), encoding="utf-8") + code, res = run_content(capsys, "--config", cfg, *check) + assert _check_result(res["qa"], "contrast-on-card") == "pass", res + assert "#707070" in _contrast_detail(res["qa"]) and "(>= 4.5:1)" in _contrast_detail(res["qa"]), res + + +def test_icon_ground_tolerance_is_range_checked(capsys, tmp_path, brand): + """Epic #51 review A, reproduced on 8f27ef1: NaN made every `spread > tol` false and 12.5 or 1000 widened the key + (KEY_DE + tol) until the whole glyph was removed (art-fail icon-empty, exit 3). All are usage errors (exit 2).""" + from ctshowcase.errors import ShowcaseError + assert C.icon_ground_tolerance(0) == 0.0 and C.icon_ground_tolerance(5) == 5.0 and C.icon_ground_tolerance(12) == 12.0 + for bad in (float("nan"), float("inf"), -1, 12.5, 1000, True, "x"): + with pytest.raises(ShowcaseError) as exc: + C.icon_ground_tolerance(bad) + assert exc.value.log == "usage" and exc.value.code == 2, bad + src = tmp_path / "cand.png" + _candidate(brand.style(NW)["tokens"]["card_fill"]).save(src) + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + for bad in ("nan", "-1", "1000"): + out = tmp_path / f"icon-{bad}.png" + code, res = run_content(capsys, "--meta", meta, "--no-repo-dir", "content", "icon", "normalize", "--in", src, + "--out", out, "--ground-tolerance", bad, "--art-direction", + FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + assert code == 2 and not out.exists(), (bad, res) + # an opaque candidate with --ground transparent and a huge tolerance is a usage error, not icon-empty + out = tmp_path / "icon-transparent.png" + code, res = run_content(capsys, "--meta", meta, "--no-repo-dir", "content", "icon", "normalize", "--in", src, + "--out", out, "--ground", "transparent", "--ground-tolerance", "1000", + "--art-direction", FIX / "ART_DIRECTION.min.md", *NO_ESTATE) + assert code == 2 and res["result"] == "usage" and "icon-empty" not in json.dumps(res), res + assert not out.exists() + + def test_schema_icon_min_contrast(brand): import copy from ctshowcase.config import schema_errors @@ -1783,3 +1869,174 @@ def chunk(kind, body): good_out.unlink(missing_ok=True) code, res = run_content(capsys, *base, "normalize", "--in", good, "--out", good_out, *extra, *art, *NO_ESTATE) assert code == 0 and res["result"] in ("pass", "warn") and good_out.is_file(), (extra, res) + + +# --------------------------------------------------------------------------------------------------- +# icon prompt: unrecognised size_class names the allowed values (#62); one image and honest seed (#63) +# --------------------------------------------------------------------------------------------------- +MEANINGS = {"WIDE": "full 688 px width", "TALL": "full 688 px height", "SQUARE": "squarish glyph", + "DIAGONAL": "corner to corner"} + + +def _glyph_with_size(size): + return {"glyph_name": "Inbox Tray", "glyph_description": "An open tray seen front-on.", + "spark_placement": "held inside the tray", "size_class": size} + + +def test_icon_prompt_invalid_size_class_lists_allowed_values_and_meanings(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size("medium")) + assert res["missing"] == ["SIZE_CLASS"] + text = res["invalid"]["SIZE_CLASS"] + assert "'medium'" in text + for key, meaning in MEANINGS.items(): + assert key in text and meaning in text + + +def test_icon_prompt_valid_size_class_has_no_invalid_and_is_case_insensitive(brand, icon_ctx): + for size in ("wide", "Tall", "SQUARE", "diagonal"): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size(size)) + assert res["invalid"] == {} and res["missing"] == [], size + assert f"Size class {size.upper()}:" in res["prompt"] + + +def test_size_class_help_lists_every_key_with_its_meaning(): + text = C.size_class_help() + assert text.startswith("size_class must be one of: ") and text.endswith("Names are case-insensitive.") + assert set(C.SIZE_CLASSES) == set(MEANINGS) + for key, meaning in MEANINGS.items(): + assert f"{key} (" in text and meaning in text + + +def _cli_prompt(monkeypatch, tmp_path, capsys, glyph_data, art_text=None, extra=()): + monkeypatch.chdir(tmp_path) + meta = tmp_path / "meta.json" + meta.write_text(json.dumps(meta_dict()), encoding="utf-8") + glyph = tmp_path / "glyph.json" + glyph.write_text(json.dumps(glyph_data), encoding="utf-8") + out = tmp_path / "prompt.md" + args = ["--meta", str(meta), "--no-repo-dir", "content", "icon", "prompt", "--option", "A", + "--glyph", str(glyph), "--out", str(out)] + if art_text is not None: + art_file = tmp_path / "art.md" + art_file.write_text(art_text, encoding="utf-8") + args += ["--art-direction", str(art_file)] + code = C.main(args + list(extra)) + return code, json.loads(capsys.readouterr().out), out + + +_NO_SIZE = {"glyph_name": "Inbox", "glyph_description": "An open tray.", "spark_placement": "inside the tray"} + + +def test_icon_prompt_cli_invalid_size_class_is_needs_writer_with_the_values(monkeypatch, tmp_path, capsys): + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, dict(_NO_SIZE, size_class="medium")) + assert code == 1 and result["result"] == "needs-writer" + assert "SIZE_CLASS" in result["invalid"] and "needs-writer:SIZE_CLASS:invalid" in result["logs"] + for key in MEANINGS: + assert key in result["note"] + assert not out.exists() + + +def test_icon_prompt_cli_invalid_size_class_blocks_out_with_a_literal_size_template(monkeypatch, tmp_path, capsys): + art = C.load_art_direction().replace("{SIZE_CLASS}", "WIDE") + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, dict(_NO_SIZE, size_class="medium"), art_text=art) + assert code == 1 and result["result"] == "needs-writer" and "SIZE_CLASS" in result["invalid"] + assert not out.exists() + + +def test_icon_prompt_cli_missing_size_class_names_allowed_values(monkeypatch, tmp_path, capsys): + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, _NO_SIZE) + assert code == 1 and result["result"] == "needs-writer" + assert result["missing"] == ["SIZE_CLASS"] and result["invalid"] == {} + for key in MEANINGS: + assert key in result["note"] + assert not out.exists() + + +def test_icon_prompt_cli_missing_size_class_with_literal_size_template(monkeypatch, tmp_path, capsys): + art = C.load_art_direction().replace("{SIZE_CLASS}", "WIDE") + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, _NO_SIZE, art_text=art) + assert code == 1 and result["result"] == "needs-writer" and "SIZE_CLASS" in result["missing"] + for key in MEANINGS: + assert key in result["note"] + assert not out.exists() + + +CUSTOM_TEMPLATE = """## Repository glyph prompt template + +```text +Draw {GLYPH_NAME}: {GLYPH_DESCRIPTION}. Size class {SIZE_CLASS}. Spark {SPARK_PLACEMENT}. +output art/out/avatars/x.png +``` +""" + + +@pytest.mark.parametrize("option", ["A", "B"]) +def test_icon_prompt_has_no_candidate_wording_for_bundled_options(brand, icon_ctx, option): + glyph = dict(_glyph_with_size("wide"), subject="an inbox tray", objects="a tray and a card") + skill_art = C.SKILL_ART_DIRECTION.read_text(encoding="utf-8") if option == "B" else None + res = C.icon_prompt(icon_ctx, resolver(brand), ART, glyph, option, skill_art=skill_art) + low = res["prompt"].lower() + assert "candidates" not in low and "four" not in low and "-c{1..4}" not in low + assert res["requested"] == {"images": 1, "seed": "not supported"} + + +def test_icon_prompt_has_no_candidate_wording_for_a_custom_template(brand, icon_ctx): + custom = CUSTOM_TEMPLATE.replace("output art", "Produce four candidates. 4 candidates.\noutput art") + res = C.icon_prompt(icon_ctx, resolver(brand), custom, _glyph_with_size("wide")) + low = res["prompt"].lower() + assert "candidates" not in low and "four" not in low + assert "Produce one image." in res["prompt"] + + +def test_icon_prompt_without_seed_asserts_none_and_records_not_supported(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size("wide")) + # the estate-format fixture states the run setting; it never asserts a seed or "Use seed" + assert "seed: not supported" in res["prompt"] and "use seed" not in res["prompt"].lower() + assert not re.search(r"seed:? *[0-9]", res["prompt"], re.I) + assert res["requested"]["seed"] == "not supported" + assert "- Requested: 1 image; seed: not supported." in C.prompt_markdown(res) + + +def test_icon_prompt_family_seed_is_never_inferred(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size("wide")) + assert "1025" not in res["prompt"] + + +def test_icon_prompt_seed_is_in_prompt_and_provenance(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size("wide"), seed=1234) + assert "seed: 1234" in res["prompt"] and "not supported" not in res["prompt"] + assert res["requested"] == {"images": 1, "seed": "1234"} + assert "- Requested: 1 image; seed: 1234." in C.prompt_markdown(res) + + +def test_icon_prompt_legacy_seed_placeholder_is_rewritten(brand, icon_ctx): + art = ART.replace("seed {SEED} if the tool supports seeds", "Use seed {SEED} when supported.") + assert "Use seed {SEED} when supported." in art + plain = C.icon_prompt(icon_ctx, resolver(brand), art, _glyph_with_size("wide")) + assert "when supported" not in plain["prompt"] and "not supported" not in plain["prompt"] + assert "use seed" not in plain["prompt"].lower() + seeded = C.icon_prompt(icon_ctx, resolver(brand), art, _glyph_with_size("wide"), seed=1234) + assert "Use seed 1234." in seeded["prompt"] and "when supported" not in seeded["prompt"] + + +def test_icon_prompt_custom_template_without_seed_placeholder_still_gets_the_seed(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), CUSTOM_TEMPLATE, _glyph_with_size("wide"), seed=1234) + assert res["prompt"].lower().count("seed 1234") == 1 + + +def test_icon_prompt_output_path_has_no_candidate_suffix(brand, icon_ctx): + res = C.icon_prompt(icon_ctx, resolver(brand), ART, _glyph_with_size("wide")) + assert "support-triage-agent-1024.png" in res["prompt"] and "-c{1..4}" not in res["prompt"] + + +def test_icon_prompt_cli_parses_seed(monkeypatch, tmp_path, capsys): + data = dict(_NO_SIZE, size_class="wide") + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, data, extra=["--seed", "1234"]) + assert code == 0 and result["result"] == "ok" + assert "Use seed 1234." in result["prompt"] + assert "seed: 1234." in out.read_text(encoding="utf-8") + code, result, out = _cli_prompt(monkeypatch, tmp_path, capsys, data) + assert "seed: not supported." in out.read_text(encoding="utf-8") + assert "seed" not in result["prompt"].lower() + with pytest.raises(SystemExit): + _cli_prompt(monkeypatch, tmp_path, capsys, data, extra=["--seed", "abc"]) diff --git a/tests/test_icon_art_direction.py b/tests/test_icon_art_direction.py index d2319b2..052a6d4 100644 --- a/tests/test_icon_art_direction.py +++ b/tests/test_icon_art_direction.py @@ -1,5 +1,7 @@ """The installed skill's icon defaults work without the private estate bundle.""" import json +import re +from pathlib import Path import copy import os import subprocess @@ -114,17 +116,36 @@ def test_explicit_missing_template_reports_the_required_sections(brand, make_ctx C.icon_prompt(ctx, Resolver(brand, {}, {}), "# Empty custom art direction\n") -@pytest.mark.parametrize(("owner", "seed"), [ - ("CompleteTech-Skills", "1025"), ("AI-Ascension", None), ("Jev-Engineering", "3001"), - ("jevplay-games", "3101"), ("LibreEvolve", "4001"), ("TouchedByAnAgent", "5001"), - ("romgenie-archive-repostiory", "6001")]) -def test_bundled_prompt_has_a_valid_seed_instruction(owner, seed, brand, make_ctx): +GLYPH = {"glyph_name": "Inbox Tray", "glyph_description": "An open tray seen front-on.", + "spark_placement": "held inside the tray", "size_class": "wide", "subject": "an inbox tray", + "objects": "a tray and a card"} + + +@pytest.mark.parametrize("option", ["A", "B"]) +@pytest.mark.parametrize("owner", ["CompleteTech-Skills", "AI-Ascension", "Jev-Engineering", "romgenie-archive-repostiory"]) +def test_prompt_asks_for_one_image_and_asserts_no_seed_by_default(option, owner, brand, make_ctx): + from ctshowcase.resolver import Resolver + + art = (C.SKILL_ART_DIRECTION.read_text(encoding="utf-8") if option == "B" else None) + result = C.icon_prompt(make_ctx(owner=owner), Resolver(brand, {}, {}), C.load_art_direction(), GLYPH, option, + skill_art=art) + p = result["prompt"] + assert "Produce one image." in p + assert "four candidates" not in p.lower() and "when supported" not in p and "seed" not in p.lower() + assert result["requested"] == {"images": 1, "seed": "not supported"} + md = C.prompt_markdown(result) + assert "- Requested: 1 image; seed: not supported." in md + + +@pytest.mark.parametrize("option", ["A", "B"]) +def test_requested_seed_is_in_the_prompt_and_provenance(option, brand, make_ctx): from ctshowcase.resolver import Resolver - result = C.icon_prompt(make_ctx(owner=owner), Resolver(brand, {}, {}), C.load_art_direction()) - expected = f"Use seed {seed} when supported." if seed else "Use the tool's default seed." - assert expected in result["prompt"] - assert "seed not set" not in result["prompt"] + art = (C.SKILL_ART_DIRECTION.read_text(encoding="utf-8") if option == "B" else None) + result = C.icon_prompt(make_ctx(owner="CompleteTech-Skills"), Resolver(brand, {}, {}), C.load_art_direction(), + GLYPH, option, skill_art=art, seed=4242) + assert "Produce one image. Use seed 4242." in result["prompt"] + assert "- Requested: 1 image; seed: 4242." in C.prompt_markdown(result) @pytest.mark.parametrize("missing", [("icon_ink",), ("icon_spark",), ("icon_ink", "icon_spark")]) @@ -186,3 +207,51 @@ def test_legacy_config_option_b_does_not_require_glyph_inks(explicit_art_directi result = json.loads(proc.stdout.decode("utf-8")) assert result["result"] == "ok" and result["missing"] == [] assert "A tray holding an envelope." in output.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("option", ["A", "B"]) +def test_synthetic_legacy_template_via_override_still_asks_for_one_image(option, brand, make_ctx): + """The fixture is a minimal synthetic legacy template that imitates the pre-#61 'Produce four candidates.' syntax + and is not a production snapshot. As an explicit --art-direction override it must not leave the prompt requesting + four while provenance records one image.""" + from ctshowcase.resolver import Resolver + + legacy = (Path(__file__).parent / "fixtures" / "content" / "ART_DIRECTION.synthetic-legacy.md").read_text(encoding="utf-8") + assert "Produce four candidates." in legacy + assert len(legacy.splitlines()) < 80 + assert legacy.count("Produce four candidates.") == 1 + assert not any(token in legacy for token in ("jev-reference", "jevplay", "AI Ascension", "Jev-Engineering", "#00A7FD", "1025")), \ + "the fixture must stay synthetic" + result = C.icon_prompt(make_ctx(owner="CompleteTech-Skills"), Resolver(brand, {}, {}), legacy, GLYPH, option, + skill_art=(legacy if option == "B" else None)) + assert "four candidates" not in result["prompt"].lower() + assert "Produce one image." in result["prompt"] + assert "- Requested: 1 image; seed: not supported." in C.prompt_markdown(result) + + +def test_explicit_seed_is_requested_even_when_a_custom_template_has_no_seed_placeholder(brand, make_ctx): + """A custom --art-direction without {SEED}/{SEED_INSTRUCTION} must still carry a seed that provenance records.""" + from ctshowcase.resolver import Resolver + + art = C.load_art_direction() + art = re.sub(r"[^\n]*\{SEED(?:_INSTRUCTION)?\}[^\n]*", "", art) + result = C.icon_prompt(make_ctx(owner="CompleteTech-Skills"), Resolver(brand, {}, {}), art, GLYPH, "A", seed=4242) + assert "seed 4242" in result["prompt"].lower() + assert "- Requested: 1 image; seed: 4242." in C.prompt_markdown(result) + plain = C.icon_prompt(make_ctx(owner="CompleteTech-Skills"), Resolver(brand, {}, {}), art, GLYPH, "A") + assert "seed" not in plain["prompt"].lower() + + +def test_legacy_conditional_seed_clause_in_a_custom_template_is_normalised(brand, make_ctx): + """'Use seed {SEED} when supported.' must not assert a seed (or 'not supported') the caller never requested.""" + from ctshowcase.resolver import Resolver + + art = C.load_art_direction() + art = re.sub(r"[^\n]*\{SEED(?:_INSTRUCTION)?\}[^\n]*", "", art) + art = art.rstrip() + "\n\nUse seed {SEED} when supported.\n" + ctx = make_ctx(owner="CompleteTech-Skills") + plain = C.icon_prompt(ctx, Resolver(brand, {}, {}), art, GLYPH, "A") + assert "seed" not in plain["prompt"].lower() + seeded = C.icon_prompt(ctx, Resolver(brand, {}, {}), art, GLYPH, "A", seed=4242) + assert "Use seed 4242." in seeded["prompt"] + assert "when supported" not in seeded["prompt"]