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.
This commit is contained in:
KeyInfo Bot committed 2026-10-26 00:03:15 +08:00
1 parent e1981742ba
commit 6a0a022257
17 files changed
+216 -81

No files matched your search

+8 -8
View File
@@ -114,8 +114,8 @@
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9", "commit": "9b68f7ed12b05e7efaff18e65c5a3fc2849fd0a4",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
}, },
{ {
"id": "taste-skill", "id": "taste-skill",
@@ -123,8 +123,8 @@
"repo": "https://github.com/Leonxlnx/taste-skill.git", "repo": "https://github.com/Leonxlnx/taste-skill.git",
"ref": "main", "ref": "main",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "6010f91a9935f1c167dfc500fb1a475681892e6b", "commit": "12af471f627f866411b4bd785b19646d3672784c",
"syncedAt": "2026-10-21T06:48:34Z" "syncedAt": "2026-10-25T16:00:00Z"
}, },
{ {
"id": "shadcn", "id": "shadcn",
@@ -132,8 +132,8 @@
"repo": "https://github.com/shadcn-ui/ui.git", "repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main", "ref": "main",
"adapter": "claude-skill", "adapter": "claude-skill",
"commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb", "commit": "481d523374745e77d0cf9c53a4115ab5dd7c8481",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
}, },
{ {
"id": "frontend-slides", "id": "frontend-slides",
@@ -258,8 +258,8 @@
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"adapter": "skill-collection", "adapter": "skill-collection",
"commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991", "commit": "cbf49db30a655ac17c92c2951885c4256db14b9e",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
}, },
{ {
"id": "shuorenhua", "id": "shuorenhua",
@@ -2,8 +2,8 @@
"sourceId": "caveman", "sourceId": "caveman",
"repo": "https://github.com/JuliusBrussee/caveman.git", "repo": "https://github.com/JuliusBrussee/caveman.git",
"ref": "main", "ref": "main",
"commit": "1f951fb4ad40ba63302c6e7f987147e034d599d9", "commit": "9b68f7ed12b05e7efaff18e65c5a3fc2849fd0a4",
"adapter": "codex-plugin", "adapter": "codex-plugin",
"sourcePath": "plugins/caveman", "sourcePath": "plugins/caveman",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
} }
@@ -28,7 +28,7 @@ if __package__ in (None, ""):
sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
__package__ = "scripts" __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 from .detect import detect_file_type, should_compress
@@ -48,6 +48,13 @@ def main():
filepath = filepath.resolve() 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 # Detect file type
file_type = detect_file_type(filepath) file_type = detect_file_type(filepath)
@@ -70,7 +77,8 @@ def main():
print(f"Original: {backup_path}") print(f"Original: {backup_path}")
sys.exit(0) sys.exit(0)
else: 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) sys.exit(2)
except KeyboardInterrupt: except KeyboardInterrupt:
@@ -72,6 +72,7 @@ SENSITIVE_BASENAME_REGEX = re.compile(
r"(?ix)^(" r"(?ix)^("
r"\.env(\..+)?" r"\.env(\..+)?"
r"|\.netrc" r"|\.netrc"
r"|\.(npmrc|pypirc|pgpass|s3cfg|boto)"
r"|credentials(\..+)?" r"|credentials(\..+)?"
r"|secrets?(\..+)?" r"|secrets?(\..+)?"
r"|passwords?(\..+)?" r"|passwords?(\..+)?"
@@ -105,8 +106,31 @@ def _state_base_dir(kind: str) -> Path:
def backup_dir_for(filepath: Path) -> 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.""" """Out-of-tree backup dir for filepath — kept outside the source tree so skill
return _state_base_dir("backups") / filepath.parent.name 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 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 URLs exactly
- Preserve ALL headings exactly - Preserve ALL headings exactly
- Preserve file paths and commands - 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. Only compress natural language prose. Never restructure, add formatting, or improve organization.
@@ -793,7 +817,7 @@ ORIGINAL (reference only):
COMPRESSED (fix this): COMPRESSED (fix this):
{compressed} {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 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 ---------- # ---------- 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.") print("❌ Refusing to compress: file is empty or whitespace-only.")
return False 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(): if backup_path.exists():
print(f"⚠️ Backup file already exists: {backup_path}") print(f"⚠️ Backup already exists: {backup_path}")
print("The original backup may contain important content.") print(f" It holds the uncompressed original of an earlier run on {filepath.name}.")
print("Aborting to prevent data loss. Please remove or rename the backup file if you want to proceed.") 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 return False
# Split YAML frontmatter off before compression. LLMs tend to strip or # 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}...") print(f"Compressing with {provider}...")
masked_body, code_blocks = mask_code_blocks(body) masked_body, code_blocks = mask_code_blocks(body)
masked_compressed = call_claude(build_compress_prompt(masked_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: try:
compressed_body = restore_code_blocks(masked_compressed, code_blocks) compressed_body = restore_code_blocks(masked_compressed, code_blocks)
except ValueError as error: except ValueError as error:
@@ -1046,7 +1129,7 @@ def _compress_file_locked(filepath: Path, run: dict) -> bool:
print(f"Fixing with {provider}...") print(f"Fixing with {provider}...")
fixed = call_claude( 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(): 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.") print(" Skipping this attempt.")
continue continue
# Guard against a prose preamble smuggled in ahead of the real fixed # Repairs use the same body-only contract as the initial compression.
# content (issue #588). Only enforced when the original starts with a # Mask code for structural checks so fenced YAML is never frontmatter.
# structural anchor (frontmatter `---` or a heading) — plain-prose try:
# first lines get legitimately rewritten by compression, and requiring masked_fixed, _ = mask_code_blocks(fixed)
# them verbatim would reject every valid fix. except ValueError as error:
anchor = first_nonblank_line(original_text) print(f"❌ Fix attempt aborted: {error}")
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.")
continue continue
body_error = _body_candidate_error(masked_body, masked_fixed)
# The repaired candidate is what gets written if it validates, so the if body_error:
# non-expansion invariant has to hold for it too — a repair that print(f"❌ Fix attempt aborted: {body_error}.")
# restores the structure validate() asked for by padding the prose back print(" Skipping this attempt.")
# out is exactly the "compression" #776 is about. `fixed` is a whole continue
# file (build_fix_prompt is given one, and the anchor check above fixed_body = fixed
# 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)
if not _is_smaller_than_body(fixed_body, body): if not _is_smaller_than_body(fixed_body, body):
print(" Skipping this attempt.") print(" Skipping this attempt.")
continue continue
# The fix prompt carries the whole file unmasked, so hold the repair to # The fix prompt carries the body unmasked; every protected block
# the first pass's guards: frontmatter and every masked block (code, # must still come back verbatim, including list-nested fences.
# including fences indented under list items, which validate() cannot
# see, and <!-- nocompress --> regions) must come back verbatim.
if fixed_frontmatter != frontmatter:
print("❌ Fix attempt aborted: output changed the YAML frontmatter.")
print(" Skipping this attempt.")
continue
if any(block.rstrip("\n") not in fixed_body for _, block in code_blocks): if any(block.rstrip("\n") not in fixed_body for _, block in code_blocks):
print("❌ Fix attempt aborted: output changed a preserved code or <!-- nocompress --> block.") print("❌ Fix attempt aborted: output changed a preserved code or <!-- nocompress --> block.")
print(" Skipping this attempt.") print(" Skipping this attempt.")
continue continue
compressed = fixed compressed_body = fixed_body
compressed = frontmatter + compressed_body
return False return False
@@ -222,12 +222,17 @@ def extract_fenced_spans(lines):
return spans 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): 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): 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): def count_bullets(text):
@@ -3,5 +3,5 @@
"name": "playwright浏览器自动化操作", "name": "playwright浏览器自动化操作",
"version": "20260605", "version": "20260605",
"keySource": "none", "keySource": "none",
"syncedAt": "2026-10-24T16:02:42Z" "syncedAt": "2026-10-25T16:03:14Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "next-skills", "sourceId": "next-skills",
"repo": "https://github.com/vercel/next.js.git", "repo": "https://github.com/vercel/next.js.git",
"ref": "canary", "ref": "canary",
"commit": "a831acd90b7bb80cd284d3e7727ab8e1a0756991", "commit": "cbf49db30a655ac17c92c2951885c4256db14b9e",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "shadcn", "sourceId": "shadcn",
"repo": "https://github.com/shadcn-ui/ui.git", "repo": "https://github.com/shadcn-ui/ui.git",
"ref": "main", "ref": "main",
"commit": "ffd225775bbb7955a673f08bb9be4a63600d9dfb", "commit": "481d523374745e77d0cf9c53a4115ab5dd7c8481",
"adapter": "claude-skill", "adapter": "claude-skill",
"sourcePath": "skills/shadcn", "sourcePath": "skills/shadcn",
"syncedAt": "2026-10-24T16:00:00Z" "syncedAt": "2026-10-25T16:00:00Z"
} }
@@ -2,8 +2,8 @@
"sourceId": "taste-skill", "sourceId": "taste-skill",
"repo": "https://github.com/Leonxlnx/taste-skill.git", "repo": "https://github.com/Leonxlnx/taste-skill.git",
"ref": "main", "ref": "main",
"commit": "6010f91a9935f1c167dfc500fb1a475681892e6b", "commit": "12af471f627f866411b4bd785b19646d3672784c",
"adapter": "skill-collection", "adapter": "skill-collection",
"sourcePath": "skills", "sourcePath": "skills",
"syncedAt": "2026-10-21T06:48:34Z" "syncedAt": "2026-10-25T16:00:00Z"
} }
@@ -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, - **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. good: the familiar layout is the one this skill exists to prevent.
- Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a - Redraw one slot (`--redraw hero | type | palette | photos | scenes | section:<role> | direction`) only for a
reason you can state: it contradicts a stated brand constraint, or needs an asset that does not reason you can state: it contradicts a stated brand constraint, needs an asset that does not exist,
exist. Never redraw to get something more comfortable. Record every redraw in your report. 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; - 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 it wants five quotations and there are none), do not bend the content to fit. List the others with
`--alternatives <role>`, pick the one whose assumptions match the content, and take it with `--alternatives <role>`, 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 wrong ("no clear title", "random empty space", "everything is pushed to one side"), and the
inspector measures each of them. inspector measures each of them.
9. **States and semantics.** Real links and buttons, visible focus, alt text, a phone layout that is 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 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. 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 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. 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 ### 8. Look
Passing the gate means nothing banned was measured. It does not mean the page is good. Open the 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 square, 23% pill; the 9–16px "soft" radius is the weak-site default). Most strong heroes have no
button at all, or one. button at all, or one.
- Navigation: wordmark, three to five plain links, one action. No bar chrome. - 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. - The footer is a designed close, not a sitemap: on most strong sites the name returns at full width.
## Special cases ## Special cases
@@ -37,6 +37,8 @@
const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; }; const titled = (parent, str) => { svg('title', {}, parent).textContent = str; return parent; };
// numbers follow the page's language (<html lang>), so separators match the rest of the screen // numbers follow the page's language (<html lang>), so separators match the rest of the screen
const nf = (o) => new Intl.NumberFormat(document.documentElement.lang || 'en', o); 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) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[c]);
const fmt = { const fmt = {
int: (v) => nf({ maximumFractionDigits: 0 }).format(v), int: (v) => nf({ maximumFractionDigits: 0 }).format(v),
dec: (d) => (v) => nf({ minimumFractionDigits: d, maximumFractionDigits: d }).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 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 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 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 = `<div style="opacity:.7">${labels[i]}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : format(sr.values[i])}</b> ${series.length > 1 ? sr.name : ''}</div>`).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 = `<div style="opacity:.7">${esc(labels[i])}</div>` + series.map((sr) => `<div><b style="font-weight:600">${sr.values[i] == null ? '–' : esc(format(sr.values[i]))}</b> ${series.length > 1 ? esc(sr.name) : ''}</div>`).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)); }); 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); 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)}`); 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: 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); 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'; 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 = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${label}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${sub}</div>` : ''}`; t.innerHTML = `<div style="font-size:${Math.round(size / 5.5)}px;font-weight:600;line-height:1">${esc(label)}</div>${sub ? `<div style="font-size:12px;opacity:.7;margin-top:4px">${esc(sub)}</div>` : ''}`;
wrap.appendChild(t); el.appendChild(wrap); 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 // 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 over = r.spent > r.planned;
const li = document.createElement('li'); const li = document.createElement('li');
const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px'; const top = document.createElement('div'); top.style.cssText = 'display:flex;justify-content:space-between;gap:12px;margin-bottom:6px';
top.innerHTML = `<span>${r.label}</span><span><b style="font-weight:500">${format(r.spent)}</b> <span style="color:var(--muted,#5f6368)">of ${format(r.planned)} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${note(r)}</span></span>`; top.innerHTML = `<span>${esc(r.label)}</span><span><b style="font-weight:500">${esc(format(r.spent))}</b> <span style="color:var(--muted,#5f6368)">of ${esc(format(r.planned))} · </span><span style="color:${over ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${esc(note(r))}</span></span>`;
// scale 'row': each track is full width and shows the share used (rows in a grid compare); 'plan': as long as its plan // 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 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) + '%'; 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) { for (const r of rows) {
const drift = r.value - r.target; 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'; 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 = `<span>${r.label}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${format(r.value)}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${format(Math.abs(drift))} vs ${format(r.target)}</span></span>`; li.innerHTML = `<span>${esc(r.label)}</span><span style="position:relative;height:10px;background:var(--rule,#eceeef);border-radius:2px"><span style="position:absolute;inset:0 auto 0 0;width:${(r.value / max) * 100}%;background:var(--chart-1,#2f6fdf);border-radius:2px"></span><span style="position:absolute;top:-4px;bottom:-4px;left:${(r.target / max) * 100}%;width:2px;margin-left:-1px;background:var(--text,#111)"></span></span><span><b style="font-weight:500">${esc(format(r.value))}</b> <span style="color:${Math.abs(drift) >= (r.tolerance ?? 0.02) ? 'var(--negative,#c4242a)' : 'var(--muted,#5f6368)'}">${drift >= 0 ? '+' : '−'}${esc(format(Math.abs(drift)))} vs ${esc(format(r.target))}</span></span>`;
li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`); li.setAttribute('aria-label', `${r.label}: ${format(r.value)}, target ${format(r.target)}`);
list.appendChild(li); list.appendChild(li);
} }
@@ -103,7 +103,8 @@
// fallback: words in inline-block spans, the full text kept for screen readers // fallback: words in inline-block spans, the full text kept for screen readers
if (!el.dataset.split) { if (!el.dataset.split) {
el.setAttribute('aria-label', el.textContent.trim()); el.setAttribute('aria-label', el.textContent.trim());
el.innerHTML = el.textContent.trim().split(/\s+/).map((w) => `<span aria-hidden="true" style="display:inline-block">${w}</span>`).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'; el.dataset.split = 'words';
} }
return [...el.children]; return [...el.children];
@@ -1486,7 +1486,7 @@
], ],
"italic": false, "italic": false,
"settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement", "settings": "weight 500 · tracking -0.025em · leading 0.95 · sentence case, or tight capitals at 32–40px for a single statement",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
}, },
"text": { "text": {
"family": "Erode", "family": "Erode",
@@ -1498,7 +1498,7 @@
], ],
"italic": true, "italic": true,
"settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0", "settings": "400 for body, 600 for emphasis, italic for titles of works · leading 1.5 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,500,600&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=erode@400,401,500,600&display=swap\" rel=\"stylesheet\">"
}, },
"standsInFor": [ "standsInFor": [
"Noe Text", "Noe Text",
@@ -1512,7 +1512,7 @@
"aww-colonia-zacamil" "aww-colonia-zacamil"
], ],
"links": [ "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": { "scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1534,7 +1534,7 @@
], ],
"italic": true, "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", "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": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
}, },
"text": { "text": {
"family": "Boska", "family": "Boska",
@@ -1546,7 +1546,7 @@
], ],
"italic": true, "italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em", "settings": "400 for body, 500 for emphasis · leading 1.5 · tracking 0.005em",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,500&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=boska@400,401,500&display=swap\" rel=\"stylesheet\">"
}, },
"standsInFor": [ "standsInFor": [
"Louize Display", "Louize Display",
@@ -1559,7 +1559,7 @@
"opl-sarah-matt-wedding" "opl-sarah-matt-wedding"
], ],
"links": [ "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": { "scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -1676,7 +1676,7 @@
], ],
"italic": false, "italic": false,
"settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements", "settings": "weight 300 · tracking -0.015em · leading 1.0 · sentence case; about 8vw for the headline, 40px for statements",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
}, },
"text": { "text": {
"family": "Sentient", "family": "Sentient",
@@ -1688,7 +1688,7 @@
], ],
"italic": true, "italic": true,
"settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0", "settings": "400 for body, 500 for emphasis · leading 1.55 · tracking 0",
"css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,500&display=swap\" rel=\"stylesheet\">" "css": "<link rel=\"preconnect\" href=\"https://api.fontshare.com\">\n<link rel=\"preconnect\" href=\"https://cdn.fontshare.com\" crossorigin>\n<link href=\"https://api.fontshare.com/v2/css?f[]=sentient@300,400,401,500&display=swap\" rel=\"stylesheet\">"
}, },
"standsInFor": [ "standsInFor": [
"Season Mix", "Season Mix",
@@ -1702,7 +1702,7 @@
"opl-le-petit-bleu" "opl-le-petit-bleu"
], ],
"links": [ "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": { "scale": {
"step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)", "step-small": "clamp(0.8125rem, 0.79rem + 0.1vw, 0.875rem)",
@@ -12,12 +12,14 @@
// //
// Exit code 0 = no FAIL findings. Exit code 1 = at least one FAIL (the page is not done). // 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+. // 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 { existsSync, mkdirSync, readFileSync, writeFileSync, statSync, createReadStream, readdirSync, unlinkSync } from 'node:fs';
import { resolve, join, relative, dirname, extname, sep } from 'node:path'; import { resolve, join, relative, dirname, extname, sep } from 'node:path';
import { createServer } from 'node:http'; import { createServer } from 'node:http';
import { Browser, sleep } from './lib/cdp.mjs'; import { Browser, sleep } from './lib/cdp.mjs';
import { probe, frame, layout, app, colourShare } from './lib/probe.mjs'; import { probe, frame, layout, app, colourShare } from './lib/probe.mjs';
import { judge, judgeLayout, judgeApp, APP_SKIP } from './lib/rules.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 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; }; 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('/')}`; 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 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 }); mkdirSync(outDir, { recursive: true });
// Screenshots from an earlier run would sit beside the new ones when the page got shorter. // 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 browser = await Browser.launch();
const helper = await browser.newPage({ width: 1600, height: 1000 }); const helper = await browser.newPage({ width: 1600, height: 1000 });
const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] }; const report = { url: server ? target : url, at: new Date().toISOString(), allow, viewports: [], findings: [], images: [] };
@@ -117,14 +118,17 @@ try {
for (const w of widths) { for (const w of widths) {
const h = HEIGHT[w] || Math.round(w * 0.625); const h = HEIGHT[w] || Math.round(w * 0.625);
const label = name(w); const label = name(w);
const imageLabel = screenshotLabel(w, widths);
const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 }); const page = await browser.newPage({ width: w, height: h, dsf: 1, mobile: w < 600 });
await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {}); await page.send('Page.addScriptToEvaluateOnNewDocument', { source: MOTION_RECORDER }).catch(() => {});
const how = await page.goto(url, { timeoutMs: 30000, settleMs: 600 }); 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? 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); 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 page.eval('document.fonts.ready.then(() => true)').catch(() => {});
await sleep(800); // let the load choreography finish before judging the first screen 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 }); await page.screenshot({ path: fold, quality: 82 });
const metrics = await page.eval(probe); const metrics = await page.eval(probe);
@@ -179,7 +183,7 @@ try {
lastY = y; lastY = y;
} }
for (let i = 0; i < shots.length; i += 24) { 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); await sheet(shots.slice(i, i + 24), 6, 320, 200, p);
filmSheets.push(p); filmSheets.push(p);
} }
@@ -191,11 +195,12 @@ try {
const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8; const perSheet = w >= 1100 ? 4 : w >= 600 ? 6 : 8;
const sheets = []; const sheets = [];
for (let i = 0; i < frames.length; i += perSheet) { 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); await sheet(frames.slice(i, i + perSheet), cols, cellW, cellH, p);
sheets.push(p); sheets.push(p);
} }
let findings = [...judge(m, { width: w, allow }), ...sceneFindings]; 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')) { if (dealtFonts.length && w >= 1100 && !allow.includes('font-not-dealt')) {
const norm = (f) => String(f).toLowerCase().replace(/["']/g, '').replace(/\s+variable$/, '').trim(); 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)); const used = m.type.families.filter((f) => f.share >= 0.1).map((f) => norm(f.family));
@@ -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);
@@ -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 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 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 = []; 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) // text, one box per text node (its line boxes merged)
const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange(); const walker = document.createTreeWalker(B, NodeFilter.SHOW_TEXT); const rg = document.createRange();
for (let n = walker.nextNode(); n && boxes.length < 6000; n = walker.nextNode()) { 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; 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; rg.selectNodeContents(n); const r = rg.getBoundingClientRect(); if (r.width < 2 || r.height < 2) continue;
if (!shown(el)) 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); 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 // 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')) { for (const el of B.querySelectorAll('img, video, canvas, picture, iframe, svg, input, select, textarea, button, [role="button"], a, div, section, figure, span, li')) {
@@ -240,4 +240,4 @@ export function judgeApp(A, m, { width = A.viewport.w, allow = [] } = {}) {
return out; 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'];