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:
1 parent
e1981742ba
commit
6a0a022257
17 files changed
+216
-81
No files matched your search
@@ -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"
|
||||
}
|
||||
@@ -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:
|
||||
|
||||
@@ -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 <!-- nocompress --> 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 <!-- nocompress --> block.")
|
||||
print(" Skipping this attempt.")
|
||||
continue
|
||||
|
||||
compressed = fixed
|
||||
compressed_body = fixed_body
|
||||
compressed = frontmatter + compressed_body
|
||||
|
||||
return False
|
||||
@@ -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):
|
||||
|
||||
@@ -3,5 +3,5 @@
|
||||
"name": "playwright浏览器自动化操作",
|
||||
"version": "20260605",
|
||||
"keySource": "none",
|
||||
"syncedAt": "2026-10-24T16:02:42Z"
|
||||
"syncedAt": "2026-10-25T16:03:14Z"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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:<role> | 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 <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
|
||||
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
|
||||
|
||||
@@ -37,6 +37,8 @@
|
||||
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
|
||||
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 = `<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)); });
|
||||
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 = `<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);
|
||||
}
|
||||
// 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 = `<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
|
||||
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 = `<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)}`);
|
||||
list.appendChild(li);
|
||||
}
|
||||
|
||||
@@ -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) => `<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';
|
||||
}
|
||||
return [...el.children];
|
||||
|
||||
@@ -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": "<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": {
|
||||
"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": "<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": [
|
||||
"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": "<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": {
|
||||
"family": "Boska",
|
||||
@@ -1546,7 +1546,7 @@
|
||||
],
|
||||
"italic": true,
|
||||
"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": [
|
||||
"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": "<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": {
|
||||
"family": "Sentient",
|
||||
@@ -1688,7 +1688,7 @@
|
||||
],
|
||||
"italic": true,
|
||||
"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": [
|
||||
"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)",
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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 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')) {
|
||||
|
||||
@@ -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'];
|
||||
Reference in new issue
Block a user