From 6a0a022257d2ffd9e2ce0f33d6ae4a83736a6074 Mon Sep 17 00:00:00 2001 From: KeyInfo Bot Date: Mon, 26 Oct 2026 00:03:15 +0800 Subject: [PATCH] Sync third-party and MCP marketplace plugins Constraint: Public skills are published only by explicit administrator action unless they are tracked third-party market sources. Confidence: high Scope-risk: narrow Directive: Keep private/internal skills out of the public marketplace and preserve normal incremental market Git history. Tested: Marketplace validation passed. --- config/external-sources.lock.json | 16 +- .../plugins/caveman/THIRD_PARTY_SOURCE.json | 4 +- .../skills/caveman-compress/scripts/cli.py | 12 +- .../caveman-compress/scripts/compress.py | 144 +++++++++++++----- .../caveman-compress/scripts/validate.py | 9 +- .../plugins/mcp-playwright/MCP_SOURCE.json | 2 +- .../next-skills/THIRD_PARTY_SOURCE.json | 4 +- .../plugins/shadcn/THIRD_PARTY_SOURCE.json | 4 +- .../taste-skill/THIRD_PARTY_SOURCE.json | 4 +- .../taste-skill/skills/taste-skill/SKILL.md | 19 ++- .../skills/taste-skill/assets/charts.js | 10 +- .../skills/taste-skill/assets/scenes/stage.js | 3 +- .../skills/taste-skill/library/type.json | 18 +-- .../skills/taste-skill/scripts/inspect.mjs | 15 +- .../scripts/lib/inspection-files.mjs | 9 ++ .../skills/taste-skill/scripts/lib/probe.mjs | 22 ++- .../skills/taste-skill/scripts/lib/rules.mjs | 2 +- 17 files changed, 216 insertions(+), 81 deletions(-) create mode 100644 plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/inspection-files.mjs diff --git a/config/external-sources.lock.json b/config/external-sources.lock.json index b0277a2a..3b7ef055 100644 --- a/config/external-sources.lock.json +++ b/config/external-sources.lock.json @@ -114,8 +114,8 @@ "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", "adapter": "codex-plugin", - "commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9", - "syncedAt": "2026-10-24T16:00:00Z" + "commit": "9b68f7ed12b05e7efaff18e65c5a3fc2849fd0a4", + "syncedAt": "2026-10-25T16:00:00Z" }, { "id": "taste-skill", @@ -123,8 +123,8 @@ "repo": "https://github.com/Leonxlnx/taste-skill.git", "ref": "main", "adapter": "skill-collection", - "commit": "6010f91a9935f1c167dfc500fb1a475681892e6b", - "syncedAt": "2026-10-21T06:48:34Z" + "commit": "12af471f627f866411b4bd785b19646d3672784c", + "syncedAt": "2026-10-25T16:00:00Z" }, { "id": "shadcn", @@ -132,8 +132,8 @@ "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", "adapter": "claude-skill", - "commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb", - "syncedAt": "2026-10-24T16:00:00Z" + "commit": "481d523374745e77d0cf9c53a4115ab5dd7c8481", + "syncedAt": "2026-10-25T16:00:00Z" }, { "id": "frontend-slides", @@ -258,8 +258,8 @@ "repo": "https://github.com/vercel/next.js.git", "ref": "canary", "adapter": "skill-collection", - "commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991", - "syncedAt": "2026-10-24T16:00:00Z" + "commit": "cbf49db30a655ac17c92c2951885c4256db14b9e", + "syncedAt": "2026-10-25T16:00:00Z" }, { "id": "shuorenhua", diff --git a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json index 7c77a66b..ec4b75ee 100644 --- a/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/caveman/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "caveman", "repo": "https://github.com/JuliusBrussee/caveman.git", "ref": "main", - "commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9", + "commit": "9b68f7ed12b05e7efaff18e65c5a3fc2849fd0a4", "adapter": "codex-plugin", "sourcePath": "plugins/caveman", - "syncedAt": "2026-10-24T16:00:00Z" + "syncedAt": "2026-10-25T16:00:00Z" } diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py index cc23b142..330410bc 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/cli.py @@ -28,7 +28,7 @@ if __package__ in (None, ""): sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) __package__ = "scripts" -from .compress import backup_dir_for, compress_file +from .compress import backup_dir_for, compress_file, is_sensitive_path from .detect import detect_file_type, should_compress @@ -48,6 +48,13 @@ def main(): filepath = filepath.resolve() + # Refuse before detect_file_type(), which reads extensionless files to sniff them. + if is_sensitive_path(filepath): + print(f"❌ Refusing to compress {filepath}: filename looks sensitive " + "(credentials, keys, secrets, or known private paths). " + "Rename the file if this is a false positive.") + sys.exit(1) + # Detect file type file_type = detect_file_type(filepath) @@ -70,7 +77,8 @@ def main(): print(f"Original: {backup_path}") sys.exit(0) else: - print("\n❌ Compression failed after retries") + # Also reached when no attempt ran (backup exists, empty file, ...). + print("\n❌ Compression did not complete (see above). Original file is untouched.") sys.exit(2) except KeyboardInterrupt: diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py index 0a8a66c6..e34a975d 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/compress.py @@ -72,6 +72,7 @@ SENSITIVE_BASENAME_REGEX = re.compile( r"(?ix)^(" r"\.env(\..+)?" r"|\.netrc" + r"|\.(npmrc|pypirc|pgpass|s3cfg|boto)" r"|credentials(\..+)?" r"|secrets?(\..+)?" r"|passwords?(\..+)?" @@ -105,8 +106,31 @@ def _state_base_dir(kind: str) -> Path: def backup_dir_for(filepath: Path) -> Path: - """Out-of-tree backup dir for filepath, keyed by its parent dir name — kept outside the source tree so skill auto-loaders don't re-ingest `.original.md` backups as live files.""" - return _state_base_dir("backups") / filepath.parent.name + """Out-of-tree backup dir for filepath — kept outside the source tree so skill + auto-loaders don't re-ingest `.original.md` backups as live files. + + Keyed on the file's RESOLVED PARENT PATH, not just the parent's name. Keying + on the name alone meant every project's `.claude/CLAUDE.md` shared one slot + (issue #1351): the second project aborted on the first project's backup, and + the abort's own advice — remove the backup and rerun — deleted another + project's only uncompressed original. The readable parent name is kept as a + prefix so the directory is still recognisable by eye; the digest is what + makes it unique. Resolving here (rather than trusting callers) keeps two + spellings of one file in a single slot. + """ + parent = Path(filepath).resolve().parent + digest = hashlib.sha256(str(parent).encode("utf-8")).hexdigest()[:12] + # A file directly under the filesystem root has an empty parent name. + return _state_base_dir("backups") / f"{parent.name or 'root'}-{digest}" + + +def legacy_backup_dir_for(filepath: Path) -> Path: + """The pre-#1351 backup dir (parent name only). Read-only: `_compress_file_locked` + still refuses to run when a backup sits here, so an install that compressed a + file before the key changed cannot silently re-backup its own compressed output + as the new "original". Never written to, and never deleted — the file it holds + may belong to a different project entirely, which is the bug that produced it.""" + return _state_base_dir("backups") / Path(filepath).resolve().parent.name LOCK_WAIT_SECONDS = 900 # must outlast a legitimate holder's worst-case run (up to MAX_RETRIES+1 Claude calls against the 500KB size cap) or a healthy wait misreads as a stuck lock @@ -759,7 +783,7 @@ STRICT RULES: - Preserve ALL URLs exactly - Preserve ALL headings exactly - Preserve file paths and commands -- Return ONLY the compressed markdown body — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. +- Return ONLY the compressed markdown body, with no preface, commentary, or YAML frontmatter — do NOT wrap the entire output in a ```markdown fence or any other fence. Inner code blocks from the original stay as-is; do not add a new outer fence around the whole file. Only compress natural language prose. Never restructure, add formatting, or improve organization. @@ -793,7 +817,7 @@ ORIGINAL (reference only): COMPRESSED (fix this): {compressed} -Return ONLY the fixed compressed file. No explanation. +Return ONLY the fixed compressed markdown body. No preface, commentary, YAML frontmatter, or explanation. """ @@ -883,6 +907,32 @@ def restore_code_blocks(text: str, blocks: List[Tuple[str, str]]) -> str: return restored +def _frontmatter_block_count(masked_body: str) -> int: + """Count mapping-shaped delimiter blocks outside preserved code.""" + lines = masked_body.splitlines() + delimiters = [i for i, line in enumerate(lines) if line == "---"] + count = 0 + for start, end in zip(delimiters, delimiters[1:]): + content = [line for line in lines[start + 1:end] if line.strip()] + key_value = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]*:(\s.*)?$") + if content and key_value.match(content[0]) and all( + key_value.match(line) or line[0].isspace() or line.startswith("- ") + for line in content[1:] + ): + count += 1 + return count + + +def _body_candidate_error(original_masked: str, candidate_masked: str) -> str: + """Use the same body-only structural guards for compression and repair.""" + anchor = first_nonblank_line(original_masked) + if anchor.startswith(("#", CODE_MARKER_PREFIX)) and first_nonblank_line(candidate_masked) != anchor: + return "output does not start with the original body's first line (possible preamble leak)" + if _frontmatter_block_count(candidate_masked) > _frontmatter_block_count(original_masked): + return "output added YAML frontmatter to the markdown body" + return "" + + # ---------- Core Logic ---------- @@ -948,11 +998,35 @@ def _compress_file_locked(filepath: Path, run: dict) -> bool: print("❌ Refusing to compress: file is empty or whitespace-only.") return False - # Check if backup already exists to prevent accidental overwriting + # Refuse when a backup already exists, so a rerun cannot capture this file's + # already-compressed text as the new "original". if backup_path.exists(): - print(f"⚠️ Backup file already exists: {backup_path}") - print("The original backup may contain important content.") - print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") + print(f"⚠️ Backup already exists: {backup_path}") + print(f" It holds the uncompressed original of an earlier run on {filepath.name}.") + print("Aborting to prevent data loss.") + print(f" To restore it: cp '{backup_path}' '{filepath}'") + print(f" To start over: mv '{backup_path}' '{backup_path}.kept'") + print(" Do not just delete it — rerunning would then store the ALREADY") + print(" COMPRESSED text as the new original, losing the uncompressed version.") + return False + + # The pre-#1351 location is checked too: an install that compressed this file + # before the backup key changed still has its only original there. That path + # was keyed on the parent directory's NAME alone, so it was shared by every + # same-named file on the machine — this backup may well belong to a DIFFERENT + # project, which is exactly the bug that produced it. So: refuse, name the + # ambiguity, and never read, move or delete it on the user's behalf. + legacy_backup = legacy_backup_dir_for(filepath) / (filepath.stem + ".original.md") + if legacy_backup.exists(): + print(f"⚠️ A backup from an older caveman-compress exists: {legacy_backup}") + print(" Older versions keyed backups on the parent directory's name only,") + print(f" so that file is the original of SOME {filepath.parent.name}/{filepath.name}") + print(" on this machine — possibly this one, possibly another project's.") + print("Aborting to prevent data loss.") + print(" Check its contents, then move it out of the way to proceed:") + print(f" mv '{legacy_backup}' '{legacy_backup}.kept'") + print(" Backups written from now on are keyed on the file's full path and") + print(f" will land in {backup_path.parent}") return False # Split YAML frontmatter off before compression. LLMs tend to strip or @@ -971,6 +1045,15 @@ def _compress_file_locked(filepath: Path, run: dict) -> bool: print(f"Compressing with {provider}...") masked_body, code_blocks = mask_code_blocks(body) masked_compressed = call_claude(build_compress_prompt(masked_body)) + if masked_compressed is None or not masked_compressed.strip(): + print(f"❌ Compression aborted: {provider} returned an empty response.") + print(" Original file is untouched (no backup created).") + return False + body_error = _body_candidate_error(masked_body, masked_compressed) + if body_error: + print(f"❌ Compression aborted: {body_error}.") + print(" Original file is untouched (no backup created).") + return False try: compressed_body = restore_code_blocks(masked_compressed, code_blocks) except ValueError as error: @@ -1046,7 +1129,7 @@ def _compress_file_locked(filepath: Path, run: dict) -> bool: print(f"Fixing with {provider}...") fixed = call_claude( - build_fix_prompt(original_text, compressed, result.errors) + build_fix_prompt(body, compressed_body, result.errors) ) if fixed is None or not fixed.strip(): @@ -1054,42 +1137,31 @@ def _compress_file_locked(filepath: Path, run: dict) -> bool: print(" Skipping this attempt.") continue - # Guard against a prose preamble smuggled in ahead of the real fixed - # content (issue #588). Only enforced when the original starts with a - # structural anchor (frontmatter `---` or a heading) — plain-prose - # first lines get legitimately rewritten by compression, and requiring - # them verbatim would reject every valid fix. - anchor = first_nonblank_line(original_text) - if anchor.startswith(("---", "#")) and first_nonblank_line(fixed) != anchor: - print("❌ Fix attempt aborted: output does not start with the original's first line.") - print(" Possible preamble leak. Skipping this attempt.") + # Repairs use the same body-only contract as the initial compression. + # Mask code for structural checks so fenced YAML is never frontmatter. + try: + masked_fixed, _ = mask_code_blocks(fixed) + except ValueError as error: + print(f"❌ Fix attempt aborted: {error}") continue - - # The repaired candidate is what gets written if it validates, so the - # non-expansion invariant has to hold for it too — a repair that - # restores the structure validate() asked for by padding the prose back - # out is exactly the "compression" #776 is about. `fixed` is a whole - # file (build_fix_prompt is given one, and the anchor check above - # requires it to start with the original's first line), so its - # frontmatter is split off to compare like against like. - fixed_frontmatter, fixed_body = split_frontmatter(fixed) + body_error = _body_candidate_error(masked_body, masked_fixed) + if body_error: + print(f"❌ Fix attempt aborted: {body_error}.") + print(" Skipping this attempt.") + continue + fixed_body = fixed if not _is_smaller_than_body(fixed_body, body): print(" Skipping this attempt.") continue - # The fix prompt carries the whole file unmasked, so hold the repair to - # the first pass's guards: frontmatter and every masked block (code, - # including fences indented under list items, which validate() cannot - # see, and regions) must come back verbatim. - if fixed_frontmatter != frontmatter: - print("❌ Fix attempt aborted: output changed the YAML frontmatter.") - print(" Skipping this attempt.") - continue + # The fix prompt carries the body unmasked; every protected block + # must still come back verbatim, including list-nested fences. if any(block.rstrip("\n") not in fixed_body for _, block in code_blocks): print("❌ Fix attempt aborted: output changed a preserved code or block.") print(" Skipping this attempt.") continue - compressed = fixed + compressed_body = fixed_body + compressed = frontmatter + compressed_body return False diff --git a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py index d68935fc..53eba1e2 100644 --- a/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py +++ b/plugins/codex/plugins/caveman/skills/caveman-compress/scripts/validate.py @@ -222,12 +222,17 @@ def extract_fenced_spans(lines): return spans +# Sentence punctuation right after a URL or path ("see docs/a.md.") is matched +# by both regexes but is not part of it; compression often moves it. +TRAILING_PUNCTUATION = ".,;:!?" + + def extract_urls(text): - return set(URL_REGEX.findall(text)) + return {url.rstrip(TRAILING_PUNCTUATION) for url in URL_REGEX.findall(text)} def extract_paths(text): - return set(PATH_REGEX.findall(text)) + return {path.rstrip(TRAILING_PUNCTUATION) for path in PATH_REGEX.findall(text)} def count_bullets(text): diff --git a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json index 93ba465e..4dc054d6 100644 --- a/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json +++ b/plugins/codex/plugins/mcp-playwright/MCP_SOURCE.json @@ -3,5 +3,5 @@ "name": "playwright浏览器自动化操作", "version": "20260605", "keySource": "none", - "syncedAt": "2026-10-24T16:02:42Z" + "syncedAt": "2026-10-25T16:03:14Z" } diff --git a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json index eeed608e..8c8e3c76 100644 --- a/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/next-skills/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "next-skills", "repo": "https://github.com/vercel/next.js.git", "ref": "canary", - "commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991", + "commit": "cbf49db30a655ac17c92c2951885c4256db14b9e", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-24T16:00:00Z" + "syncedAt": "2026-10-25T16:00:00Z" } diff --git a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json index 7fd7a7e9..01dc3b9f 100644 --- a/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/shadcn/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "shadcn", "repo": "https://github.com/shadcn-ui/ui.git", "ref": "main", - "commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb", + "commit": "481d523374745e77d0cf9c53a4115ab5dd7c8481", "adapter": "claude-skill", "sourcePath": "skills/shadcn", - "syncedAt": "2026-10-24T16:00:00Z" + "syncedAt": "2026-10-25T16:00:00Z" } diff --git a/plugins/codex/plugins/taste-skill/THIRD_PARTY_SOURCE.json b/plugins/codex/plugins/taste-skill/THIRD_PARTY_SOURCE.json index 23b5cc66..d2717363 100644 --- a/plugins/codex/plugins/taste-skill/THIRD_PARTY_SOURCE.json +++ b/plugins/codex/plugins/taste-skill/THIRD_PARTY_SOURCE.json @@ -2,8 +2,8 @@ "sourceId": "taste-skill", "repo": "https://github.com/Leonxlnx/taste-skill.git", "ref": "main", - "commit": "6010f91a9935f1c167dfc500fb1a475681892e6b", + "commit": "12af471f627f866411b4bd785b19646d3672784c", "adapter": "skill-collection", "sourcePath": "skills", - "syncedAt": "2026-10-21T06:48:34Z" + "syncedAt": "2026-10-25T16:00:00Z" } diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/SKILL.md b/plugins/codex/plugins/taste-skill/skills/taste-skill/SKILL.md index 9341b783..e5f61ae2 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/SKILL.md +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/SKILL.md @@ -131,8 +131,9 @@ The command prints a **direction sheet**. It is the art direction for this build - **Adopt it.** Do not blend it with the layout you would have made anyway. If it feels unfamiliar, good: the familiar layout is the one this skill exists to prevent. - Redraw one slot (`--redraw hero | type | palette | photos | scenes | section: | direction`) only for a - reason you can state: it contradicts a stated brand constraint, or needs an asset that does not - exist. Never redraw to get something more comfortable. Record every redraw in your report. + reason you can state: it contradicts a stated brand constraint, needs an asset that does not exist, + or is a section that cannot hold the real content (next point). Never redraw to get something more + comfortable. Record every redraw in your report. - When a dealt section cannot hold the real content (it compares three plans and there is one price; it wants five quotations and there are none), do not bend the content to fit. List the others with `--alternatives `, pick the one whose assumptions match the content, and take it with @@ -286,7 +287,9 @@ not finished. Order of work: wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the inspector measures each of them. 9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is - its own composition. + its own composition. Every control, the live thing included, works from the keyboard alone in + reading order and has an accessible name: its visible label, or `aria-label` when it shows only an + icon. Read `references/type.md`, `references/colour.md` and `references/layout.md` when you need the detail behind a decision; the laws below are the short form. @@ -312,6 +315,11 @@ designer in one sentence. Re-run until there are no `FAIL`s. Do not argue with t hide things from it. The inspector serves local files over a throwaway local server; if the page must also open straight from the file system, open `index.html` that way once before you finish. +Inspect what the visitor receives. With a build step, run the last gate on the production build +(`vite build && vite preview`, `next build && next start`, or the built `index.html`), not only on the +dev server: minifiers and bundlers change what ships (one dropped a `backdrop-filter` that the dev +server showed). The inspector warns when it is looking at a dev server. + ### 8. Look Passing the gate means nothing banned was measured. It does not mean the page is good. Open the @@ -448,6 +456,11 @@ Short form. Numbers are from the study unless marked as house style. square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no button at all, or one. - Navigation: wordmark, three to five plain links, one action. No bar chrome. +- A control explains how to use it by its form, its label and its place. When it seems to need a + sentence on how to operate it, redesign the control instead of adding the sentence. What a field + accepts is different. First remove the limit where you can: a date picker, or a parser that takes + any common format. A limit that remains, such as password rules or file type and size, stays visible + beside the field and is tied to it with `aria-describedby`. - The footer is a designed close, not a sitemap: on most strong sites the name returns at full width. ## Special cases diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/charts.js b/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/charts.js index fa0f4594..8060404d 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/charts.js +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/charts.js @@ -37,6 +37,8 @@ const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; }; // numbers follow the page's language (), so separators match the rest of the screen const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o); + // every text value that reaches innerHTML goes through esc: a label from a database is text, never markup + const esc = (v) => String(v).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]); const fmt = { int: (v) => nf({ maximumFractionDigits: 0 }).format(v), dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).format(v), @@ -299,7 +301,7 @@ const tip = tooltip(el), rule = svg('line', { y1: padT, y2: h - padB, stroke: css(el, '--axis-ink', '#6b6b6b'), 'stroke-width': 1, opacity: 0 }, g); const dots = series.map((sr, k) => svg('circle', { r: 3.5, fill: css(el, '--surface', '#fff'), stroke: colourOf(sr, k), 'stroke-width': 2, opacity: 0 }, g)); const hit = svg('rect', { x: padL, y: padT, width: w - padL, height: h - padT - padB, fill: 'transparent' }, s); - const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `
${labels[i]}
` + series.map((sr) => `
${sr.values[i] == null ? '–' : format(sr.values[i])} ${series.length > 1 ? sr.name : ''}
`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; }; + const move = (ev) => { const r = s.getBoundingClientRect(); const px = ((ev.clientX - r.left) / r.width) * w; const i = Math.max(0, Math.min(labels.length - 1, Math.round(((px - padL) / (w - padL - padR)) * (labels.length - 1)))); rule.setAttribute('x1', x(i)); rule.setAttribute('x2', x(i)); rule.setAttribute('opacity', 0.35); series.forEach((sr, k) => { const v = sr.values[i]; dots[k].setAttribute('cx', x(i)); dots[k].setAttribute('cy', v == null ? -99 : y(Math.min(v, yt.at(-1)))); dots[k].setAttribute('opacity', v == null ? 0 : 1); }); tip.innerHTML = `
${esc(labels[i])}
` + series.map((sr) => `
${sr.values[i] == null ? '–' : esc(format(sr.values[i]))} ${series.length > 1 ? esc(sr.name) : ''}
`).join(''); const left = Math.min((x(i) / w) * r.width + 12, r.width - tip.offsetWidth - 4); tip.style.left = `${Math.max(0, left)}px`; tip.style.top = '0px'; tip.style.opacity = 1; }; hit.addEventListener('pointermove', move); hit.addEventListener('pointerleave', () => { tip.style.opacity = 0; rule.setAttribute('opacity', 0); dots.forEach((d) => d.setAttribute('opacity', 0)); }); const last = series[0].values.filter((v) => v != null).at(-1); s.setAttribute('aria-label', `${series.map((x) => x.name).join(', ')}, ${labels[0]} to ${labels.at(-1)}; latest ${last == null ? 'no data' : format(last)}`); @@ -381,7 +383,7 @@ svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: css(el, '--grid', '#e8e8e8'), 'stroke-width': stroke }, s); svg('circle', { cx: size / 2, cy: size / 2, r, fill: 'none', stroke: colour || css(el, '--chart-1', '#2f6fdf'), 'stroke-width': stroke, 'stroke-dasharray': `${c * v} ${c}`, transform: `rotate(-90 ${size / 2} ${size / 2})` }, s); const t = document.createElement('div'); t.style.cssText = 'position:absolute;inset:0;display:grid;place-content:center;text-align:center;font-variant-numeric:tabular-nums'; - t.innerHTML = `
${label}
${sub ? `
${sub}
` : ''}`; + t.innerHTML = `
${esc(label)}
${sub ? `
${esc(sub)}
` : ''}`; wrap.appendChild(t); el.appendChild(wrap); } // A ranked list. By default the row's fill is the bar (a one-figure list); thin: true puts a 2px grey bar under each @@ -418,7 +420,7 @@ const over = r.spent > r.planned; const li = document.createElement('li'); const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px'; - top.innerHTML = `${r.label}${format(r.spent)} of ${format(r.planned)} · ${note(r)}`; + top.innerHTML = `${esc(r.label)}${esc(format(r.spent))} of ${esc(format(r.planned))} · ${esc(note(r))}`; // scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan const room = over ? 100 / (1 + Math.max(0.06, (r.spent - r.planned) / r.planned)) : 100; const track = document.createElement('div'); track.style.cssText = 'position:relative;height:8px;border-radius:999px;background:var(--rule,#eceeef);overflow:visible;width:' + (scale === 'plan' ? (r.planned / max) * room : room) + '%'; @@ -440,7 +442,7 @@ for (const r of rows) { const drift = r.value - r.target; const li = document.createElement('li'); li.style.cssText = 'display:grid;grid-template-columns:minmax(90px,30%) 1fr auto;align-items:center;gap:12px'; - li.innerHTML = `${r.label}${format(r.value)} ${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}`; + li.innerHTML = `${esc(r.label)}${esc(format(r.value))} ${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}`; li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`); list.appendChild(li); } diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/scenes/stage.js b/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/scenes/stage.js index fd7ba541..315b1d9d 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/scenes/stage.js +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/assets/scenes/stage.js @@ -103,7 +103,8 @@ // fallback: words in inline-block spans, the full text kept for screen readers if (!el.dataset.split) { el.setAttribute('aria-label', el.textContent.trim()); - el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => ``).join(' '); + const words = el.textContent.trim().split(/\s+/).map((w) => { const span = document.createElement('span'); span.setAttribute('aria-hidden', 'true'); span.style.display = 'inline-block'; span.textContent = w; return span; }); + el.replaceChildren(...words.flatMap((span, i) => (i ? [' ', span] : [span]))); el.dataset.split = 'words'; } return [...el.children]; diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/library/type.json b/plugins/codex/plugins/taste-skill/skills/taste-skill/library/type.json index 72c1c1e4..f2a9e6ee 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/library/type.json +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/library/type.json @@ -1486,7 +1486,7 @@ ], "italic": false, "settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement", - "css": "\n\n" + "css": "\n\n" }, "text": { "family": "Erode", @@ -1498,7 +1498,7 @@ ], "italic": true, "settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0", - "css": "\n\n" + "css": "\n\n" }, "standsInFor": [ "Noe Text", @@ -1512,7 +1512,7 @@ "aww-colonia-zacamil" ], "links": [ - "https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap" + "https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap" ], "scale": { "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", @@ -1534,7 +1534,7 @@ ], "italic": true, "settings": "weight 400 · tracking -0.02em · leading 0.95 · roman capitals for the main words with lowercase italic for the connecting words at the same size, or sentence case throughout; centred lines work", - "css": "\n\n" + "css": "\n\n" }, "text": { "family": "Boska", @@ -1546,7 +1546,7 @@ ], "italic": true, "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em", - "css": "\n\n" + "css": "\n\n" }, "standsInFor": [ "Louize Display", @@ -1559,7 +1559,7 @@ "opl-sarah-matt-wedding" ], "links": [ - "https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap" + "https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap" ], "scale": { "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", @@ -1676,7 +1676,7 @@ ], "italic": false, "settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements", - "css": "\n\n" + "css": "\n\n" }, "text": { "family": "Sentient", @@ -1688,7 +1688,7 @@ ], "italic": true, "settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0", - "css": "\n\n" + "css": "\n\n" }, "standsInFor": [ "Season Mix", @@ -1702,7 +1702,7 @@ "opl-le-petit-bleu" ], "links": [ - "https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap" + "https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap" ], "scale": { "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/inspect.mjs b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/inspect.mjs index 105b0e5e..f1790ca0 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/inspect.mjs +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/inspect.mjs @@ -12,12 +12,14 @@ // // Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done). // Zero dependencies: drives an installed Chrome/Edge/Chromium. Node 22+. +// Widths sharing a viewport class receive width-qualified screenshot filenames. import { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs'; import { resolve, join, relative, dirname, extname, sep } from 'node:path'; import { createServer } from 'node:http'; import { Browser, sleep } from './lib/cdp.mjs'; import { probe, frame, layout, app, colourShare } from './lib/probe.mjs'; import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.mjs'; +import { viewportClass as name, screenshotLabel, isInspectionScreenshot } from './lib/inspection-files.mjs'; const args = process.argv.slice(2); const flag = (name, def) => { const i = args.indexOf('--' + name); if (i === -1) return def; const v = args[i + 1]; args.splice(i, 2); return v; }; @@ -61,11 +63,10 @@ if (!/^https?:\/\//.test(target)) { url = `http://127.0.0.1:${server.address().port}/${relative(root, file).split(sep).map(encodeURIComponent).join('/')}`; } const HEIGHT = { 1440: 900, 1280: 800, 1024: 768, 768: 1024, 390: 844, 375: 812 }; -const name = (w) => (w >= 1100 ? 'desktop' : w >= 600 ? 'tablet' : 'mobile'); mkdirSync(outDir, { recursive: true }); // Screenshots from an earlier run would sit beside the new ones when the page got shorter. -for (const f of readdirSync(outDir)) if (/^(desktop|tablet|mobile)-(fold|page-\d+|film-\d+)\.jpg$/.test(f)) unlinkSync(join(outDir, f)); +for (const f of readdirSync(outDir)) if (isInspectionScreenshot(f)) unlinkSync(join(outDir, f)); const browser = await Browser.launch(); const helper = await browser.newPage({ width: 1600, height: 1000 }); const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] }; @@ -117,14 +118,17 @@ try { for (const w of widths) { const h = HEIGHT[w] || Math.round(w * 0.625); const label = name(w); + const imageLabel = screenshotLabel(w, widths); const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 }); await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {}); const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 }); await sleep(600); // about 1.2s after the load event: is the title there to read yet? const early = await page.eval(TITLE_SEEN).catch(() => null); + // a dev server is not what ships: bundlers and minifiers change the CSS a visitor receives + const devServer = await page.eval(`(() => { const s = [...document.scripts].map((x) => x.src || '').join(' '); if (/@vite\\/client|webpack-dev-server|\\/_next\\/static\\/development\\/|react-refresh|webpack-hmr/.test(s)) return true; try { return !!(window.__NEXT_DATA__ && window.__NEXT_DATA__.buildId === 'development'); } catch { return false; } })()`).catch(() => false); await page.eval('document.fonts.ready.then(() => true)').catch(() => {}); await sleep(800); // let the load choreography finish before judging the first screen - const fold = join(outDir, `${label}-fold.jpg`); + const fold = join(outDir, `${imageLabel}-fold.jpg`); await page.screenshot({ path: fold, quality: 82 }); const metrics = await page.eval(probe); @@ -179,7 +183,7 @@ try { lastY = y; } for (let i = 0; i < shots.length; i += 24) { - const p = join(outDir, `${label}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`); + const p = join(outDir, `${imageLabel}-film-${String(i / 24 + 1).padStart(2, '0')}.jpg`); await sheet(shots.slice(i, i + 24), 6, 320, 200, p); filmSheets.push(p); } @@ -191,11 +195,12 @@ try { const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8; const sheets = []; for (let i = 0; i < frames.length; i += perSheet) { - const p = join(outDir, `${label}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`); + const p = join(outDir, `${imageLabel}-page-${String(i / perSheet + 1).padStart(2, '0')}.jpg`); await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p); sheets.push(p); } let findings = [...judge(m, { width: w, allow }), ...sceneFindings]; + if (devServer && !allow.includes('dev-server')) findings.push({ level: 'WARN', id: 'dev-server', width: w, message: 'This page is served by a development server (Vite, webpack or Next in dev mode).', fix: 'Run the last gate on the production build too (vite build && vite preview, next build && next start, or the built index.html): minifiers and bundlers change the CSS a visitor receives.' }); if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) { const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim(); const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family)); diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/inspection-files.mjs b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/inspection-files.mjs new file mode 100644 index 00000000..d375fcb6 --- /dev/null +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/inspection-files.mjs @@ -0,0 +1,9 @@ +export const viewportClass = (width) => (width >= 1100 ? 'desktop' : width >= 600 ? 'tablet' : 'mobile'); + +export function screenshotLabel(width, widths) { + const label = viewportClass(width); + const sameClass = new Set(widths.filter((candidate) => viewportClass(candidate) === label)); + return sameClass.size > 1 ? `${label}-${width}` : label; +} + +export const isInspectionScreenshot = (file) => /^(desktop|tablet|mobile)(?:-\d+)?-(fold|page-\d+|film-\d+)\.jpg$/.test(file); diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/probe.mjs b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/probe.mjs index 911094e5..d45372bc 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/probe.mjs +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/probe.mjs @@ -566,6 +566,22 @@ export function layout() { const layerOf = (el) => { for (let p = el; p && p !== B; p = p.parentElement) { const pos = getComputedStyle(p).position; if (pos === 'fixed') return 'fixed'; if (pos === 'sticky') return 'sticky'; } return null; }; const sel = (el) => (el.tagName.toLowerCase() + (el.id ? '#' + el.id : '') + (typeof el.className === 'string' && el.className.trim() ? '.' + el.className.trim().split(/\s+/).slice(0, 2).join('.') : '')).slice(0, 60); const boxes = []; + // A range is layout geometry: an ellipsised or scrolled line reaches past what the reader sees. Clip + // each text box to the window its element's overflow allows (its own box and every clipping ancestor). + const clipMemo = new Map(); + const clipBox = (el) => { + if (!el || el === B || el === document.documentElement) return null; + if (clipMemo.has(el)) return clipMemo.get(el); + const cs = getComputedStyle(el); + let box = cs.position === 'fixed' ? null : clipBox(el.parentElement); + if (cs.overflowX !== 'visible' || cs.overflowY !== 'visible') { + const r = el.getBoundingClientRect(); + const own = { l: cs.overflowX !== 'visible' ? r.left : -Infinity, r: cs.overflowX !== 'visible' ? r.right : Infinity, t: cs.overflowY !== 'visible' ? r.top : -Infinity, b: cs.overflowY !== 'visible' ? r.bottom : Infinity }; + box = box ? { l: Math.max(box.l, own.l), r: Math.min(box.r, own.r), t: Math.max(box.t, own.t), b: Math.min(box.b, own.b) } : own; + } + clipMemo.set(el, box); + return box; + }; // text, one box per text node (its line boxes merged) const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange(); for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) { @@ -573,8 +589,12 @@ export function layout() { const el = n.parentElement; if (!el || /^(SCRIPT|STYLE|NOSCRIPT|TEMPLATE|OPTION)$/.test(el.tagName) || el.closest('svg')) continue; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue; if (!shown(el)) continue; + const c = clipBox(el); + const l = c ? Math.max(r.left, c.l) : r.left, rr = c ? Math.min(r.right, c.r) : r.right; + const tp = c ? Math.max(r.top, c.t) : r.top, bt = c ? Math.min(r.bottom, c.b) : r.bottom; + if (rr - l < 2 || bt - tp < 2) continue; const cs = getComputedStyle(el); - boxes.push({ kind: 'text', el, l: r.left, r: r.right, t: r.top + SY, b: r.bottom + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName }); + boxes.push({ kind: 'text', el, l, r: rr, t: tp + SY, b: bt + SY, chars: t.length, text: t.slice(0, 50), size: parseFloat(cs.fontSize), align: cs.textAlign, layer: layerOf(el), tag: el.tagName }); } // pictures, drawings and controls for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) { diff --git a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/rules.mjs b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/rules.mjs index 558fcccf..daf22a9c 100644 --- a/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/rules.mjs +++ b/plugins/codex/plugins/taste-skill/skills/taste-skill/scripts/lib/rules.mjs @@ -240,4 +240,4 @@ export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) { return out; } -export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy']; +export const RULE_IDS = ['headline-lines', 'statement-length', 'focal-point', 'hero-words', 'mixed-font-headline', 'eyebrow', 'mono', 'numbering', 'micro-label', 'emoji', 'gradient', 'gradient-text', 'shadow', 'text-shadow', 'button-fx', 'hairline', 'outlined-box', 'dashed', 'glass', 'icon-cards', 'card-rows', 'pill-nav', 'palette-claude', 'palette-terracotta', 'palette-cream', 'palette-purple', 'default-link', 'contrast', 'font-count', 'font-fallback', 'size-count', 'body-size', 'measure', 'small-text', 'all-centred', 'ui-wrapper', 'overflow', 'broken-image', 'no-hero', 'first-action', 'grey-body', 'blend-text', 'covered-text', 'clipped-text', 'pinned-over-picture', 'empty-screens', 'long-pin', 'side-scroll', 'few-pictures', 'no-real-pictures', 'no-footage', 'still-page', 'off-centre', 'lopsided', 'blank-gap', 'no-title', 'small-title', 'many-h1', 'name-only-title', 'empty-middle', 'untitled-section', 'edge-text', 'ragged-edges', 'sidebar-phone', 'unused-canvas', 'heavy-shadow', 'heavy-borders', 'radius-mix', 'icon-chips', 'numeric-align', 'tabular', 'rainbow-chart', 'placeholder-data', 'tiny-text', 'colour-sprawl', 'ragged-row', 'below-fold', 'app-title-size', 'kpi-size', 'icon-tiles', 'late-title', 'reduced-hidden', 'no-poster', 'hero-void', 'one-sided-bleed', 'font-not-dealt', 'panel-void', 'no-data-hue', 'scene-not-started', 'scene-not-live', 'many-scenes', 'scene-heavy', 'dev-server'];