@litfamily/litopencode 1.0.31 → 1.0.33
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +38 -0
- package/README-Ko-KR.md +6 -6
- package/README.md +6 -6
- package/TRADEMARK.md +12 -0
- package/dist/activation-primary-prompts.js +5 -1
- package/dist/activation-probe.js +1 -0
- package/dist/activation-prompt-utils.d.ts +1 -1
- package/dist/activation-reentry.d.ts +20 -0
- package/dist/activation-reentry.js +110 -1
- package/dist/activation-routing.d.ts +2 -1
- package/dist/activation-routing.js +100 -11
- package/dist/activation-workflow-prompts.d.ts +4 -0
- package/dist/activation-workflow-prompts.js +30 -0
- package/dist/activation.d.ts +1 -1
- package/dist/activation.js +44 -5
- package/dist/adhd-next.d.ts +51 -0
- package/dist/adhd-next.js +150 -0
- package/dist/agents/defaults.js +1 -1
- package/dist/cli/managed-skill-assets.d.ts +1 -1
- package/dist/cli/managed-skill-assets.js +7 -1
- package/dist/cli/native-skill-integrity.js +1 -1
- package/dist/cli.js +7 -1
- package/dist/commands.d.ts +28 -1
- package/dist/commands.js +58 -5
- package/dist/durable-plan.d.ts +5 -1
- package/dist/durable-plan.js +14 -2
- package/dist/features.d.ts +80 -1
- package/dist/features.js +105 -1
- package/dist/hooks.js +3 -0
- package/dist/ledger.js +12 -1
- package/dist/plan-scope-roots.d.ts +23 -0
- package/dist/plan-scope-roots.js +116 -0
- package/dist/restart-notice.d.ts +7 -0
- package/dist/restart-notice.js +40 -0
- package/dist/secret-shapes.js +5 -5
- package/dist/server.js +4 -0
- package/dist/session-liveness.d.ts +20 -0
- package/dist/session-liveness.js +99 -0
- package/dist/skills.d.ts +22 -1
- package/dist/skills.js +36 -0
- package/dist/start-work-typed.d.ts +34 -0
- package/dist/start-work-typed.js +167 -0
- package/dist/state.d.ts +3 -0
- package/dist/state.js +3 -0
- package/dist/tools.js +22 -2
- package/dist/tui/mission-band.d.ts +22 -1
- package/dist/tui/mission-band.js +75 -16
- package/dist/tui/mission-looks.d.ts +38 -0
- package/dist/tui/mission-looks.js +73 -0
- package/dist/tui/mission-model.d.ts +2 -0
- package/dist/tui/mission-model.js +3 -1
- package/dist/tui/mission-rainbow.d.ts +33 -0
- package/dist/tui/mission-rainbow.js +90 -0
- package/dist/tui/mission-source.d.ts +49 -0
- package/dist/tui/mission-source.js +217 -2
- package/dist/tui/mission-text.d.ts +2 -0
- package/dist/tui/mission-text.js +9 -1
- package/dist/tui/mission-threads.d.ts +58 -0
- package/dist/tui/mission-threads.js +128 -0
- package/dist/tui.js +112 -7
- package/dist/uiux-visual-catalog.js +8 -1
- package/dist/v2/events.js +3 -0
- package/dist/v2/session-hooks.js +38 -5
- package/dist/workflow-stack.d.ts +53 -0
- package/dist/workflow-stack.js +196 -0
- package/docs/assets/readme/badge-version.svg +1 -1
- package/docs/assets/skills/lit-schematic.webp +0 -0
- package/docs/reference-Ko-KR.md +48 -3
- package/docs/reference.md +54 -3
- package/package.json +1 -1
- package/skills/frontend-ui-ux/SKILL.md +3 -2
- package/skills/frontend-ui-ux/kit/components.css +414 -0
- package/skills/frontend-ui-ux/kit/fonts.json +22 -0
- package/skills/frontend-ui-ux/kit/layouts/landing.html +164 -0
- package/skills/frontend-ui-ux/kit/layouts/settings.html +114 -0
- package/skills/frontend-ui-ux/kit/layouts/workspace.html +115 -0
- package/skills/frontend-ui-ux/kit/styles/computational-brutalism.json +227 -0
- package/skills/frontend-ui-ux/kit/styles/field-notes.json +205 -0
- package/skills/frontend-ui-ux/kit/styles/graphite.json +200 -0
- package/skills/frontend-ui-ux/kit/styles/meadow.json +199 -0
- package/skills/frontend-ui-ux/kit/styles/neo-brutalism.json +222 -0
- package/skills/frontend-ui-ux/kit/ui.js +226 -0
- package/skills/frontend-ui-ux/references/brand-and-imagery.md +2 -2
- package/skills/frontend-ui-ux/references/complete-contract.md +2 -1
- package/skills/frontend-ui-ux/references/craft-floor.md +6 -2
- package/skills/frontend-ui-ux/references/default-editorial-pixel.json +3 -0
- package/skills/frontend-ui-ux/references/house-styles.md +236 -0
- package/skills/frontend-ui-ux/references/motion-guide.md +2 -2
- package/skills/frontend-ui-ux/references/production.md +3 -1
- package/skills/frontend-ui-ux/references/slop-register.md +4 -4
- package/skills/frontend-ui-ux/scripts/design-lock.mjs +400 -0
- package/skills/frontend-ui-ux/scripts/pixel-art.mjs +60 -0
- package/skills/frontend-ui-ux/scripts/probe-lock.mjs +235 -0
- package/skills/frontend-ui-ux/scripts/probe-page.mjs +34 -12
- package/skills/frontend-ui-ux/scripts/probe-static.mjs +16 -3
- package/skills/frontend-ui-ux/scripts/probe-thresholds.json +8 -2
- package/skills/frontend-ui-ux/scripts/probe.mjs +35 -7
- package/skills/lit-adhd/SKILL.md +209 -0
- package/skills/lit-adhd/references/brief-format.md +46 -0
- package/skills/lit-adhd/references/card-examples.md +103 -0
- package/skills/lit-adhd/references/status-taxonomy.md +34 -0
- package/skills/lit-adhd/scripts/rewind.mjs +933 -0
- package/skills/lit-diagram-drawer/SKILL.md +4 -3
- package/skills/lit-diagram-drawer/references/style-guide.md +20 -11
- package/skills/lit-diagram-drawer/references/type-architecture.md +3 -3
- package/skills/lit-diagram-drawer/references/type-dependency.md +1 -1
- package/skills/lit-diagram-drawer/references/type-tree.md +3 -3
- package/skills/lit-diagram-drawer/references/type-wardley.md +1 -1
- package/skills/lit-diagram-drawer/references/verifier-guide.md +1 -0
- package/skills/lit-diagram-drawer/scripts/craft-thresholds.json +1 -2
- package/skills/lit-diagram-drawer/scripts/visual-quality.mjs +1 -2
- package/skills/lit-docx/SKILL.md +2 -1
- package/skills/lit-handoff/SKILL.md +2 -0
- package/skills/lit-hijack/SKILL.md +422 -0
- package/skills/lit-hijack/references/capture-and-provenance.md +87 -0
- package/skills/lit-hijack/references/clean-room-build.md +111 -0
- package/skills/lit-hijack/references/distil-and-licence.md +100 -0
- package/skills/lit-hijack/references/gap-and-port-design.md +126 -0
- package/skills/lit-hijack/references/parity-and-report.md +110 -0
- package/skills/lit-hijack/scripts/hijack.mjs +993 -0
- package/skills/lit-plan/SKILL.md +8 -0
- package/skills/lit-pptx/SKILL.md +27 -11
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Bold.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/JetBrainsMono-Regular.ttf +0 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/OFL.txt +93 -0
- package/skills/lit-pptx/fonts/jetbrains-mono/SOURCE.md +17 -0
- package/skills/lit-pptx/references/density-and-fill.md +9 -3
- package/skills/lit-pptx/references/direction-step.md +21 -6
- package/skills/lit-pptx/references/examples/04-lecture-chalk-ko.md +3 -6
- package/skills/lit-pptx/references/examples/05-research-talk-paper-en.md +1 -5
- package/skills/lit-pptx/references/examples/08-reliability-keynote-night-en.md +0 -4
- package/skills/lit-pptx/references/examples/09-status-update-ledger-en.md +2 -2
- package/skills/lit-pptx/references/examples/11-forecast-review-night-ko.md +4 -7
- package/skills/lit-pptx/references/examples/13-engineering-review-computational-brutalism-ko.md +173 -0
- package/skills/lit-pptx/references/examples/14-launch-pitch-neo-brutalism-ko.md +154 -0
- package/skills/lit-pptx/references/examples/15-design-lecture-bauhaus-ko.md +139 -0
- package/skills/lit-pptx/references/examples/16-community-programme-riso-ko.md +158 -0
- package/skills/lit-pptx/references/examples/17-public-data-briefing-newsroom-ko.md +179 -0
- package/skills/lit-pptx/references/layout-families.md +65 -47
- package/skills/lit-pptx/references/title-treatments.md +83 -134
- package/skills/lit-pptx/references/tonalities/atlas.md +3 -3
- package/skills/lit-pptx/references/tonalities/bauhaus.md +140 -0
- package/skills/lit-pptx/references/tonalities/chalk.md +7 -6
- package/skills/lit-pptx/references/tonalities/computational-brutalism.md +150 -0
- package/skills/lit-pptx/references/tonalities/gazette.md +1 -1
- package/skills/lit-pptx/references/tonalities/ledger.md +1 -1
- package/skills/lit-pptx/references/tonalities/neo-brutalism.md +161 -0
- package/skills/lit-pptx/references/tonalities/newsroom.md +144 -0
- package/skills/lit-pptx/references/tonalities/night.md +1 -1
- package/skills/lit-pptx/references/tonalities/paper.md +2 -2
- package/skills/lit-pptx/references/tonalities/riso.md +137 -0
- package/skills/lit-pptx/references/tonalities/signal.md +3 -3
- package/skills/lit-pptx/references/tonalities/studio.md +7 -6
- package/skills/lit-pptx/run.mjs +2 -1
- package/skills/lit-pptx/scripts/compile-deck.js +1 -1
- package/skills/lit-pptx/scripts/craft-thresholds.json +4 -8
- package/skills/lit-pptx/scripts/craft_extras.py +56 -3
- package/skills/lit-pptx/scripts/deck_output.py +75 -90
- package/skills/lit-pptx/scripts/embed_fonts.py +13 -1
- package/skills/lit-pptx/scripts/layout_inventory.py +12 -5
- package/skills/lit-pptx/scripts/lib/grid-resolver.js +101 -110
- package/skills/lit-pptx/scripts/lib/layout-resolver.js +9 -15
- package/skills/lit-pptx/scripts/lib/render-pack.js +373 -566
- package/skills/lit-pptx/scripts/lib/slide-grid.js +104 -0
- package/skills/lit-pptx/scripts/lib/template-registry.js +57 -6
- package/skills/lit-pptx/scripts/qa_deck.py +10 -11
- package/skills/lit-pptx/scripts/render_pages.py +118 -0
- package/skills/lit-pptx/templates/tonalities/atlas/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/bauhaus/pack.yaml +98 -0
- package/skills/lit-pptx/templates/tonalities/chalk/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/computational-brutalism/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/gazette/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/ledger/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/neo-brutalism/pack.yaml +107 -0
- package/skills/lit-pptx/templates/tonalities/newsroom/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/night/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/paper/pack.yaml +1 -1
- package/skills/lit-pptx/templates/tonalities/riso/pack.yaml +99 -0
- package/skills/lit-pptx/templates/tonalities/signal/pack.yaml +2 -2
- package/skills/lit-pptx/templates/tonalities/studio/pack.yaml +1 -1
- package/skills/lit-recap/SKILL.md +4 -0
- package/skills/lit-schematic/SKILL.md +118 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/build.mjs +38 -0
- package/skills/lit-schematic/examples/iso-heat-exchanger/scene.mjs +133 -0
- package/skills/lit-schematic/examples/journal-adsorption-rig/build.mjs +87 -0
- package/skills/lit-schematic/examples/pid-dosing-skid/build.mjs +85 -0
- package/skills/lit-schematic/references/cutaway-outputs.md +70 -0
- package/skills/lit-schematic/references/isometric-craft.md +101 -0
- package/skills/lit-schematic/references/journal-figures.md +72 -0
- package/skills/lit-schematic/references/kit-api.md +134 -0
- package/skills/lit-schematic/references/pfd-and-layout.md +53 -0
- package/skills/lit-schematic/references/pid-drafting.md +103 -0
- package/skills/lit-schematic/references/pid-symbols.md +163 -0
- package/skills/lit-schematic/references/verification.md +100 -0
- package/skills/lit-schematic/scripts/doctor.mjs +57 -0
- package/skills/lit-schematic/scripts/lib/figure.mjs +81 -0
- package/skills/lit-schematic/scripts/lib/fonts.mjs +25 -0
- package/skills/lit-schematic/scripts/lib/geom.mjs +113 -0
- package/skills/lit-schematic/scripts/lib/interactive.mjs +292 -0
- package/skills/lit-schematic/scripts/lib/isa.mjs +115 -0
- package/skills/lit-schematic/scripts/lib/iso.mjs +719 -0
- package/skills/lit-schematic/scripts/lib/legend-sheet.mjs +162 -0
- package/skills/lit-schematic/scripts/lib/pid.mjs +937 -0
- package/skills/lit-schematic/scripts/lib/png.mjs +132 -0
- package/skills/lit-schematic/scripts/lib/qa-core.mjs +658 -0
- package/skills/lit-schematic/scripts/lib/route.mjs +218 -0
- package/skills/lit-schematic/scripts/lib/sheet.mjs +890 -0
- package/skills/lit-schematic/scripts/lib/svg.mjs +82 -0
- package/skills/lit-schematic/scripts/lib/theme.mjs +115 -0
- package/skills/lit-schematic/scripts/motion.mjs +129 -0
- package/skills/lit-schematic/scripts/qa.mjs +92 -0
- package/skills/lit-schematic/scripts/render.mjs +233 -0
- package/skills/lit-schematic/scripts/scaffold.mjs +60 -0
- package/skills/lit-scientific-visualization/SKILL.md +27 -4
- package/skills/lit-typographic-motion/SKILL.md +19 -17
- package/skills/lit-typographic-motion/THIRD_PARTY_NOTICES +9 -0
- package/skills/lit-typographic-motion/engine/accents.mjs +6 -0
- package/skills/lit-typographic-motion/engine/assets.mjs +282 -0
- package/skills/lit-typographic-motion/engine/blender/runner.py +228 -0
- package/skills/lit-typographic-motion/engine/blender.mjs +126 -0
- package/skills/lit-typographic-motion/engine/board.mjs +138 -0
- package/skills/lit-typographic-motion/engine/cover/assets.mjs +11 -8
- package/skills/lit-typographic-motion/engine/cover/capture.mjs +6 -6
- package/skills/lit-typographic-motion/engine/cover/render.mjs +20 -7
- package/skills/lit-typographic-motion/engine/cover/treatment.mjs +30 -3
- package/skills/lit-typographic-motion/engine/fill.mjs +54 -0
- package/skills/lit-typographic-motion/engine/gallery.mjs +118 -0
- package/skills/lit-typographic-motion/engine/house.mjs +42 -0
- package/skills/lit-typographic-motion/engine/look.mjs +65 -8
- package/skills/lit-typographic-motion/engine/loudness.mjs +101 -0
- package/skills/lit-typographic-motion/engine/scaffold.mjs +219 -0
- package/skills/lit-typographic-motion/engine/score.mjs +594 -0
- package/skills/lit-typographic-motion/engine/sound.mjs +9 -57
- package/skills/lit-typographic-motion/engine/stage/capture.mjs +23 -9
- package/skills/lit-typographic-motion/engine/stage/clock.js +8 -2
- package/skills/lit-typographic-motion/engine/stage/cover-init.js +12 -1
- package/skills/lit-typographic-motion/engine/stage/frame-worker.mjs +15 -7
- package/skills/lit-typographic-motion/engine/stage/gate.mjs +48 -4
- package/skills/lit-typographic-motion/engine/stage/qa-page.js +16 -5
- package/skills/lit-typographic-motion/engine/stage/qa.mjs +148 -7
- package/skills/lit-typographic-motion/engine/stage/render.mjs +69 -7
- package/skills/lit-typographic-motion/engine/stage/scan.mjs +20 -1
- package/skills/lit-typographic-motion/engine/stage/stage-kit.js +85 -4
- package/skills/lit-typographic-motion/engine/stills.mjs +1 -1
- package/skills/lit-typographic-motion/engine/styles.mjs +221 -0
- package/skills/lit-typographic-motion/engine/text.mjs +1 -1
- package/skills/lit-typographic-motion/engine/treatment.mjs +190 -6
- package/skills/lit-typographic-motion/engine/vendor/three/LICENSE +21 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/environments/RoomEnvironment.js +144 -0
- package/skills/lit-typographic-motion/engine/vendor/three/addons/geometries/RoundedBoxGeometry.js +155 -0
- package/skills/lit-typographic-motion/engine/vendor/three/three.module.js +54571 -0
- package/skills/lit-typographic-motion/gate.mjs +18 -2
- package/skills/lit-typographic-motion/probe.mjs +13 -1
- package/skills/lit-typographic-motion/recipes/hero-3d/expected.json +64 -0
- package/skills/lit-typographic-motion/recipes/hero-3d/index.html +132 -0
- package/skills/lit-typographic-motion/recipes/orbit-loop/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/painted-light/expected.json +2 -0
- package/skills/lit-typographic-motion/recipes/weight-tide/expected.json +2 -0
- package/skills/lit-typographic-motion/references/cover.md +56 -13
- package/skills/lit-typographic-motion/references/craft-loop.md +20 -7
- package/skills/lit-typographic-motion/references/depth.md +112 -0
- package/skills/lit-typographic-motion/references/runtime.md +21 -6
- package/skills/lit-typographic-motion/references/sound.md +76 -0
- package/skills/lit-typographic-motion/references/stage.md +50 -7
- package/skills/lit-typographic-motion/references/studio.md +162 -0
- package/skills/lit-typographic-motion/references/style-bibles.md +18 -0
- package/skills/lit-typographic-motion/references/treatment.md +108 -9
- package/skills/lit-typographic-motion/render.mjs +60 -3
- package/skills/lit-typographic-motion/styles/paper.json +185 -0
- package/skills/lit-typographic-motion/styles/swiss-signal.json +188 -0
- package/skills/lit-typographic-motion/styles/terminalcore.json +182 -0
- package/skills/lit-typographic-motion/styles/tidal.json +187 -0
- package/skills/litresearch/SKILL.md +2 -0
- package/skills/managed-skill-manifest.json +517 -88
- package/skills/readme-studio/SKILL.md +1 -1
- package/skills/review-work/SKILL.md +2 -0
- package/skills/start-work/SKILL.md +8 -0
- package/skills/workflow-loop/SKILL.md +4 -0
- package/tools/check-pack-payload.mjs +5 -2
- package/tools/payload-substance-parity.json +4 -3
- package/tools/qa-cover-recipes.mjs +4 -3
- package/tools/version-manifests.json +2 -2
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
// Design-lock conformance for the interface probe (DS-001..DS-008). The rule table and the lock
|
|
2
|
+
// lookup run in Node; measureLock runs in the page. probe.mjs serialises measureLock into the
|
|
3
|
+
// browser, so that one function may not import anything or close over module scope: every
|
|
4
|
+
// threshold comes from DS_RULES through `options.rules`, and the lock arrives as `options.lock`.
|
|
5
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
6
|
+
import { dirname, join, resolve } from "node:path";
|
|
7
|
+
|
|
8
|
+
export const LOCK_SCHEMA = "litopencode.design-lock/v1";
|
|
9
|
+
export const LOCK_FILE = "design.lock.json";
|
|
10
|
+
export const LOCK_LIMITS = Object.freeze({ findingsPerRule: 12, nodes: 3000, textChars: 4000 });
|
|
11
|
+
|
|
12
|
+
// These run only when the page's directory holds a design.lock.json written by
|
|
13
|
+
// scripts/design-lock.mjs, or when --lock names one. The lock is the run's own declaration, so
|
|
14
|
+
// drifting from it is a defect the build introduced.
|
|
15
|
+
export const DS_RULES = Object.freeze({
|
|
16
|
+
"DS-001": { title: "Design lock not applied", severity: "HIGH", tier: "measured", threshold: { property: "--ui-lock" }, fix: "Link ui/tokens.css (written by scripts/design-lock.mjs) before any other stylesheet so the page renders the locked style." },
|
|
17
|
+
"DS-002": { title: "Font family outside the lock", severity: "HIGH", tier: "measured", threshold: null, fix: "Use var(--font-text), var(--font-display) or var(--font-mono); a new family needs a new lock, not a page rule." },
|
|
18
|
+
"DS-003": { title: "Colour outside the locked palette", severity: "HIGH", tier: "measured", threshold: { channelTolerance: 8, minAlpha: 0.05, highCount: 4, lowSeverity: "MEDIUM" }, fix: "Replace the literal colour with the var(--c-*) role that means the same thing; a colour with no role is a decoration to delete." },
|
|
19
|
+
"DS-004": { title: "Font size off the locked scale", severity: "MEDIUM", tier: "measured", threshold: { tolerancePx: 0.5 }, fix: "Use a var(--fs-*) step, or var(--fs-display) for the one display title." },
|
|
20
|
+
"DS-005": { title: "Corner radius off the lock", severity: "LOW", tier: "measured", threshold: { tolerancePx: 0.5 }, fix: "Use a var(--radius-*) value (inner radius one step below its container) or make it fully round." },
|
|
21
|
+
"DS-006": { title: "Starter slot left in the page", severity: "HIGH", tier: "measured", threshold: { pattern: "\\{\\{(?! )[^{}\\n]{1,80}(?<! )\\}\\}" }, fix: "Replace every {{slot}} from the kit layout with real content, or delete the element that holds it." },
|
|
22
|
+
"DS-007": { title: "Elevation outside the lock grammar", severity: "MEDIUM", tier: "measured", threshold: { hairlinePx: 1 }, fix: "Use var(--shadow-raised), var(--shadow-overlay) or var(--shadow-control): a flat lock takes no blurred or offset shadow, an offset lock takes no blurred one." },
|
|
23
|
+
"DS-008": { title: "Signature moment missing, buried or generic", severity: "HIGH", tier: "measured", threshold: { attribute: "data-ui-moment", boldMinAreaPx: 40000, smallMinAreaPx: 4800, smallMaxViewportShare: 0.5, foldMinViewportPx: 1024, foldMinVisiblePx: 120, phoneScreens: 1.5, coverShare: 0.5, needs: { "hero-composition": "media", "type-set-piece": "display-type", "data-moment": "data", diagram: "media", "motion-detail": "motion", "interactive-object": "control", chart: "data", "empty-state": null, "header-detail": null }, media: "svg, img, canvas, video, picture, figure, table, [role=\"img\"], .ui-pixel, .ui-frame, .ui-readout, .ui-panel, .ui-chart, .ui-meter, .ui-sticker, .ui-bignum", data: "svg, canvas, table, [role=\"img\"], .ui-chart, .ui-meter, .ui-readout, .ui-bignum", control: "input, select, textarea, details, [role=\"slider\"], [role=\"switch\"], [role=\"tab\"], [aria-pressed], [aria-expanded]" }, fix: "Record the page's one signature moment with design-lock.mjs (--moment or the moment command), build it from this page's content, mark that element with data-ui-moment=\"<kind>\", and keep a read-once page's moment in the first screen." },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
// The design lock beside the page (or named by --lock) turns the DS-* rules on. A lock that cannot
|
|
27
|
+
// be read is reported, never silently skipped.
|
|
28
|
+
export function findLock(entry, source, explicit) {
|
|
29
|
+
const dirs = [entry && dirname(entry), source && (existsSync(source) && statSync(source).isFile() ? dirname(source) : source)].filter(Boolean);
|
|
30
|
+
const candidates = explicit ? [resolve(explicit)] : dirs.map((dir) => join(dir, LOCK_FILE));
|
|
31
|
+
const path = candidates.find((candidate) => existsSync(candidate));
|
|
32
|
+
if (!path) return { lock: null, path: null, error: explicit ? `lock not found: ${explicit}` : null };
|
|
33
|
+
try {
|
|
34
|
+
const lock = JSON.parse(readFileSync(path, "utf8"));
|
|
35
|
+
if (lock.schema_id !== LOCK_SCHEMA || typeof lock.style !== "string") return { lock: null, path, error: `${LOCK_FILE} is not a ${LOCK_SCHEMA} file` };
|
|
36
|
+
return { lock, path, error: null };
|
|
37
|
+
} catch (error) {
|
|
38
|
+
return { lock: null, path, error: `${LOCK_FILE} unreadable: ${error.message}` };
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// In-page conformance. Keep everything below this line self-contained.
|
|
43
|
+
export function measureLock(options) {
|
|
44
|
+
const { rules, limits, lock } = options;
|
|
45
|
+
const findings = [];
|
|
46
|
+
const notVerified = [];
|
|
47
|
+
const push = (rule, selector, value, threshold, severity) => {
|
|
48
|
+
if (findings.filter((f) => f.rule === rule).length >= limits.findingsPerRule) return;
|
|
49
|
+
findings.push({ rule, severity: severity ?? rules[rule].severity, tier: rules[rule].tier, selector, value, threshold });
|
|
50
|
+
};
|
|
51
|
+
const describe = (el) => {
|
|
52
|
+
if (el.id) return `#${el.id}`;
|
|
53
|
+
const cls = [...el.classList].slice(0, 2).map((c) => `.${c}`).join("");
|
|
54
|
+
return `${el.tagName.toLowerCase()}${cls}`;
|
|
55
|
+
};
|
|
56
|
+
const exempt = (el) => Boolean(el.closest("[data-lock-exempt], img, video, canvas, iframe, picture, object"));
|
|
57
|
+
const visible = (el) => {
|
|
58
|
+
if (typeof el.checkVisibility === "function" && !el.checkVisibility({ checkOpacity: false, checkVisibilityCSS: true })) return false;
|
|
59
|
+
const box = el.getBoundingClientRect();
|
|
60
|
+
return box.width >= 1 && box.height >= 1;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// DS-001: the generated tokens.css carries the lock id on :root.
|
|
64
|
+
const applied = getComputedStyle(document.documentElement).getPropertyValue(rules["DS-001"].threshold.property).trim().replace(/^["']|["']$/gu, "");
|
|
65
|
+
if (applied !== lock.style) {
|
|
66
|
+
push("DS-001", ":root", applied ? `${rules["DS-001"].threshold.property}: ${applied}` : `${rules["DS-001"].threshold.property} missing`, `"${lock.style}" from design.lock.json`);
|
|
67
|
+
return { findings, notVerified };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const parse = (value) => {
|
|
71
|
+
let match = /^rgba?\(([\d.]+),\s*([\d.]+),\s*([\d.]+)(?:,\s*([\d.]+))?\)$/u.exec(value);
|
|
72
|
+
if (match) return { rgb: [Number(match[1]), Number(match[2]), Number(match[3])], alpha: match[4] === undefined ? 1 : Number(match[4]) };
|
|
73
|
+
match = /^color\(srgb\s+([-\d.e]+)\s+([-\d.e]+)\s+([-\d.e]+)(?:\s*\/\s*([\d.]+))?\)$/u.exec(value);
|
|
74
|
+
if (match) return { rgb: [match[1], match[2], match[3]].map((n) => Math.round(Number(n) * 255)), alpha: match[4] === undefined ? 1 : Number(match[4]) };
|
|
75
|
+
return null;
|
|
76
|
+
};
|
|
77
|
+
const hex = (h) => [1, 3, 5].map((i) => parseInt(h.slice(i, i + 2), 16));
|
|
78
|
+
const palette = [...Object.values(lock.colors.light), ...Object.values(lock.colors.dark)].map(hex);
|
|
79
|
+
const tolerance = rules["DS-003"].threshold.channelTolerance;
|
|
80
|
+
const onPalette = (rgb) => palette.some((p) => Math.abs(p[0] - rgb[0]) <= tolerance && Math.abs(p[1] - rgb[1]) <= tolerance && Math.abs(p[2] - rgb[2]) <= tolerance);
|
|
81
|
+
const toHex = (rgb) => `#${rgb.map((n) => Math.max(0, Math.min(255, n)).toString(16).padStart(2, "0")).join("")}`;
|
|
82
|
+
const families = new Set(lock.fonts.map((f) => f.toLowerCase()));
|
|
83
|
+
const sizes = lock.type.scale_px;
|
|
84
|
+
const display = lock.type.display_px;
|
|
85
|
+
// Inside the declared signature moment type may run past the display range up to the lock's ceiling.
|
|
86
|
+
const momentMax = lock.type.moment_px?.max ?? display.max;
|
|
87
|
+
const momentAttr = rules["DS-008"].threshold.attribute;
|
|
88
|
+
const sizeTolerance = rules["DS-004"].threshold.tolerancePx;
|
|
89
|
+
const radii = lock.radius_px;
|
|
90
|
+
const radiusTolerance = rules["DS-005"].threshold.tolerancePx;
|
|
91
|
+
|
|
92
|
+
const offColours = new Map();
|
|
93
|
+
const offFamilies = new Map();
|
|
94
|
+
const offSizes = new Map();
|
|
95
|
+
const offRadii = new Map();
|
|
96
|
+
const offShadows = new Map();
|
|
97
|
+
let unparsed = 0;
|
|
98
|
+
// DS-007: the lock names an elevation grammar. "flat" allows only rings (no blur, no offset past a
|
|
99
|
+
// hairline); "offset" allows hard offsets but no blur; "soft" is left to CF-403. Insets never count.
|
|
100
|
+
const elevation = lock.elevation ?? "soft";
|
|
101
|
+
const hairline = rules["DS-007"].threshold.hairlinePx;
|
|
102
|
+
const shadowLengths = (value) => (value === "none" ? [] : value.split(/,(?![^()]*\))/u)).filter((part) => !/\binset\b/u.test(part))
|
|
103
|
+
.map((part) => part.replace(/\b(?:rgba?|color|hsla?|oklch|lab|lch)\([^)]*\)/gu, "").match(/-?[\d.]+px/gu)?.map(Number.parseFloat) ?? []);
|
|
104
|
+
const noteColour = (value, el, where) => {
|
|
105
|
+
const colour = parse(value);
|
|
106
|
+
if (!colour) { if (value !== "transparent" && value !== "none") unparsed += 1; return; }
|
|
107
|
+
if (colour.alpha < rules["DS-003"].threshold.minAlpha || onPalette(colour.rgb)) return;
|
|
108
|
+
const key = toHex(colour.rgb);
|
|
109
|
+
if (!offColours.has(key)) offColours.set(key, `${describe(el)} ${where}`);
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const elements = [...document.body.querySelectorAll("*")].slice(0, limits.nodes);
|
|
113
|
+
for (const el of elements) {
|
|
114
|
+
if (exempt(el) || !visible(el)) continue;
|
|
115
|
+
const style = getComputedStyle(el);
|
|
116
|
+
const isSvgShape = el instanceof SVGElement && !(el instanceof SVGSVGElement);
|
|
117
|
+
if (isSvgShape) {
|
|
118
|
+
if (["rect", "path", "circle", "ellipse", "polygon", "polyline", "line"].includes(el.tagName.toLowerCase())) {
|
|
119
|
+
if (style.fill !== "none") noteColour(style.fill, el, "fill");
|
|
120
|
+
if (style.stroke !== "none") noteColour(style.stroke, el, "stroke");
|
|
121
|
+
}
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
const ownText = [...el.childNodes].some((node) => node.nodeType === Node.TEXT_NODE && node.textContent.trim());
|
|
125
|
+
if (ownText) {
|
|
126
|
+
noteColour(style.color, el, "text");
|
|
127
|
+
const family = style.fontFamily.split(",")[0].trim().replace(/^["']|["']$/gu, "").toLowerCase();
|
|
128
|
+
if (!families.has(family) && !offFamilies.has(family)) offFamilies.set(family, describe(el));
|
|
129
|
+
const px = Number.parseFloat(style.fontSize);
|
|
130
|
+
const ceiling = el.closest(`[${momentAttr}]`) ? momentMax : display.max;
|
|
131
|
+
const onScale = sizes.some((s) => Math.abs(s - px) <= sizeTolerance) || (px >= display.min - sizeTolerance && px <= ceiling + sizeTolerance);
|
|
132
|
+
if (!onScale && !offSizes.has(px)) offSizes.set(px, describe(el));
|
|
133
|
+
}
|
|
134
|
+
noteColour(style.backgroundColor, el, "background");
|
|
135
|
+
for (const side of ["Top", "Right", "Bottom", "Left"]) {
|
|
136
|
+
if (Number.parseFloat(style[`border${side}Width`]) > 0 && style[`border${side}Style`] !== "none") noteColour(style[`border${side}Color`], el, "border");
|
|
137
|
+
}
|
|
138
|
+
if (elevation !== "soft") {
|
|
139
|
+
for (const [x = 0, y = 0, blur = 0] of shadowLengths(style.boxShadow)) {
|
|
140
|
+
const offset = Math.abs(x) > hairline || Math.abs(y) > hairline;
|
|
141
|
+
if ((blur > 0 || (elevation === "flat" && offset)) && !offShadows.has(describe(el))) offShadows.set(describe(el), `${x}px ${y}px ${blur}px`);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const box = el.getBoundingClientRect();
|
|
145
|
+
for (const corner of ["TopLeft", "TopRight", "BottomRight", "BottomLeft"]) {
|
|
146
|
+
const raw = style[`border${corner}Radius`];
|
|
147
|
+
if (!raw || raw.includes(" ") || raw.includes("%")) continue;
|
|
148
|
+
const r = Number.parseFloat(raw);
|
|
149
|
+
if (!(r > 0)) continue;
|
|
150
|
+
const round = r >= Math.min(box.width, box.height) / 2 - radiusTolerance;
|
|
151
|
+
if (!round && !radii.some((v) => Math.abs(v - r) <= radiusTolerance) && !offRadii.has(r)) offRadii.set(r, describe(el));
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
for (const [family, where] of offFamilies) push("DS-002", where, `font-family "${family}"`, `one of ${lock.fonts.join(", ")}`);
|
|
156
|
+
if (offColours.size) {
|
|
157
|
+
const list = [...offColours.entries()];
|
|
158
|
+
const severity = list.length >= rules["DS-003"].threshold.highCount ? "HIGH" : rules["DS-003"].threshold.lowSeverity;
|
|
159
|
+
push("DS-003", list.slice(0, 4).map(([, where]) => where).join(" | "), `${list.length} off-palette colour(s): ${list.slice(0, 8).map(([c]) => c).join(", ")}`, `the ${lock.style} palette (light and dark roles)`, severity);
|
|
160
|
+
}
|
|
161
|
+
if (offSizes.size) push("DS-004", [...offSizes.values()].slice(0, 3).join(" | "), `${offSizes.size} size(s) off scale: ${[...offSizes.keys()].slice(0, 8).map((n) => `${n}px`).join(", ")}`, `${sizes.join("/")}px or display ${display.min}–${display.max}px`);
|
|
162
|
+
if (offRadii.size) push("DS-005", [...offRadii.values()].slice(0, 3).join(" | "), `${offRadii.size} radius value(s) off lock: ${[...offRadii.keys()].slice(0, 6).map((n) => `${n}px`).join(", ")}`, `${radii.join("/")}px or fully round`);
|
|
163
|
+
if (offShadows.size) push("DS-007", [...offShadows.keys()].slice(0, 3).join(" | "), `${offShadows.size} element(s) with ${[...offShadows.values()].slice(0, 3).join(", ")} shadows`, elevation === "flat" ? "flat lock: rings only, no blur or offset" : "offset lock: zero-blur offsets only");
|
|
164
|
+
if (unparsed) notVerified.push({ rule: "DS-003", reason: `${unparsed} colour value(s) in a format the probe cannot compare` });
|
|
165
|
+
|
|
166
|
+
// DS-006: a starter-layout slot such as {{Product}} left in visible text or a labelling attribute.
|
|
167
|
+
const slot = new RegExp(rules["DS-006"].threshold.pattern, "u");
|
|
168
|
+
const textHit = slot.exec(document.body.innerText.slice(0, limits.textChars * 10));
|
|
169
|
+
if (textHit) push("DS-006", "page text", textHit[0], "no {{slot}} from the kit layouts");
|
|
170
|
+
for (const el of document.querySelectorAll("[title], [aria-label], [alt], [placeholder]")) {
|
|
171
|
+
for (const attr of ["title", "aria-label", "alt", "placeholder"]) {
|
|
172
|
+
const value = el.getAttribute(attr);
|
|
173
|
+
const hit = value && slot.exec(value);
|
|
174
|
+
if (hit) push("DS-006", `${describe(el)}[${attr}]`, hit[0], "no {{slot}} from the kit layouts");
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const title = slot.exec(document.title);
|
|
178
|
+
if (title) push("DS-006", "title", title[0], "no {{slot}} from the kit layouts");
|
|
179
|
+
|
|
180
|
+
// DS-008: the one signature moment the lock declares. It must be declared, marked, big enough to
|
|
181
|
+
// read, in the first screen on a read-once page, hold what its kind promises, and not be the
|
|
182
|
+
// generic gradient-and-centred-text hero.
|
|
183
|
+
const m = rules["DS-008"].threshold;
|
|
184
|
+
const declared = lock.moment;
|
|
185
|
+
if (!declared) {
|
|
186
|
+
push("DS-008", "design.lock.json", "the lock predates signature moments and records none", "a moment recorded with design-lock.mjs moment");
|
|
187
|
+
return { findings, notVerified };
|
|
188
|
+
}
|
|
189
|
+
if (!declared.kind) {
|
|
190
|
+
push("DS-008", "design.lock.json", "the lock declares no signature moment", `a ${declared.scale} moment recorded with design-lock.mjs moment`);
|
|
191
|
+
return { findings, notVerified };
|
|
192
|
+
}
|
|
193
|
+
const marked = [...document.querySelectorAll(`[${m.attribute}]`)].filter(visible);
|
|
194
|
+
if (!marked.length) {
|
|
195
|
+
push("DS-008", "page", `no [${m.attribute}] element for the declared ${declared.kind}`, `one element marked ${m.attribute}="${declared.kind}"`);
|
|
196
|
+
return { findings, notVerified };
|
|
197
|
+
}
|
|
198
|
+
if (marked.length > 1) push("DS-008", marked.slice(0, 3).map(describe).join(" | "), `${marked.length} elements claim the signature moment`, "one moment per page", "MEDIUM");
|
|
199
|
+
const el = marked.find((node) => node.getAttribute(m.attribute) === declared.kind) ?? marked[0];
|
|
200
|
+
const where = describe(el);
|
|
201
|
+
const kind = el.getAttribute(m.attribute);
|
|
202
|
+
if (kind !== declared.kind) push("DS-008", where, `marked "${kind}" but the lock declares "${declared.kind}"`, "the declared kind");
|
|
203
|
+
const box = el.getBoundingClientRect();
|
|
204
|
+
const area = box.width * box.height;
|
|
205
|
+
const minArea = declared.scale === "bold" ? m.boldMinAreaPx : m.smallMinAreaPx;
|
|
206
|
+
if (area < minArea) push("DS-008", where, `${Math.round(box.width)}×${Math.round(box.height)} px moment is too small to read as one`, `≥ ${minArea} px²`);
|
|
207
|
+
const base = options.pass === "base";
|
|
208
|
+
if (declared.scale === "small" && base && innerWidth >= m.foldMinViewportPx && area > m.smallMaxViewportShare * innerWidth * innerHeight) push("DS-008", where, `a work-surface moment covering ${Math.round((100 * area) / (innerWidth * innerHeight))}% of the screen`, `≤ ${m.smallMaxViewportShare * 100}%: work surfaces stay calm`, "MEDIUM");
|
|
209
|
+
if (declared.above_fold && base) {
|
|
210
|
+
const top = box.top + scrollY;
|
|
211
|
+
const wide = innerWidth >= m.foldMinViewportPx;
|
|
212
|
+
const limit = wide ? innerHeight - m.foldMinVisiblePx : innerHeight * m.phoneScreens;
|
|
213
|
+
if (top > limit) push("DS-008", where, `moment starts ${Math.round(top)} px down, below the fold`, wide ? `starts by ${Math.round(limit)} px (first screen)` : `starts within ${m.phoneScreens} screens (${Math.round(limit)} px)`);
|
|
214
|
+
}
|
|
215
|
+
const has = (selector) => el.matches(selector) || [...el.querySelectorAll(selector)].some(visible);
|
|
216
|
+
const media = has(m.media);
|
|
217
|
+
const inside = [el, ...el.querySelectorAll("*")].filter(visible);
|
|
218
|
+
const texts = inside.filter((node) => [...node.childNodes].some((child) => child.nodeType === Node.TEXT_NODE && child.textContent.trim()));
|
|
219
|
+
const need = m.needs[declared.kind];
|
|
220
|
+
const wants = {
|
|
221
|
+
media: ["an image, drawing, figure, frame or product view", () => media],
|
|
222
|
+
data: ["chart, table, figure or readout", () => has(m.data)],
|
|
223
|
+
control: ["control a reader can operate", () => has(m.control)],
|
|
224
|
+
"display-type": ["type at display size or above", () => texts.some((node) => Number.parseFloat(getComputedStyle(node).fontSize) >= display.min - sizeTolerance)],
|
|
225
|
+
motion: ["animation or transition", () => options.pass === "reduced-motion" || inside.some((node) => { const cs = getComputedStyle(node); return cs.animationName !== "none" || cs.transitionDuration.split(",").some((d) => Number.parseFloat(d) > 0); })],
|
|
226
|
+
}[need];
|
|
227
|
+
if (wants && !wants[1]()) push("DS-008", where, `declared ${declared.kind} but it holds no ${wants[0]}`, `a ${declared.kind} built from the page's content`);
|
|
228
|
+
const gradient = (node) => /gradient\(/u.test(getComputedStyle(node).backgroundImage) && !node.matches(".ui-scan, .ui-ruler");
|
|
229
|
+
const chain = [];
|
|
230
|
+
for (let node = el; node && node !== document.body && node !== document.documentElement; node = node.parentElement) chain.push(node);
|
|
231
|
+
const behind = chain.some(gradient) || inside.some((node) => { const b = node.getBoundingClientRect(); return gradient(node) && b.width * b.height >= m.coverShare * area; });
|
|
232
|
+
const biggest = texts.reduce((best, node) => (Number.parseFloat(getComputedStyle(node).fontSize) > (best ? Number.parseFloat(getComputedStyle(best).fontSize) : 0) ? node : best), null);
|
|
233
|
+
if (behind && !media && biggest && getComputedStyle(biggest).textAlign === "center") push("DS-008", where, "a generic gradient-and-centred-text hero", "a moment built from this page's content (composition, set piece, data, diagram, motion or object)");
|
|
234
|
+
return { findings, notVerified };
|
|
235
|
+
}
|
|
@@ -19,7 +19,9 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
19
19
|
const color = (value) => {
|
|
20
20
|
const hex = value?.match(/^#([\da-f]{3}|[\da-f]{6})$/iu);
|
|
21
21
|
if (hex) { const raw = hex[1].length === 3 ? [...hex[1]].map((digit) => digit + digit).join('') : hex[1]; return { c: [0, 2, 4].map((index) => Number.parseInt(raw.slice(index, index + 2), 16)), a: 1 }; }
|
|
22
|
-
|
|
22
|
+
// color-mix() and relative colours compute to color(srgb r g b / a) with 0 to 1 channels; other spaces are not sampled.
|
|
23
|
+
if (/^(?:color\((?!srgb[ )])|lab\(|lch\(|oklab\(|oklch\()/iu.test(value ?? '')) return null;
|
|
24
|
+
const n = value?.match(/[\d.]+/gu)?.map(Number), unit = /^color\(srgb /iu.test(value ?? '') ? 255 : 1; return n?.length >= 3 ? { c: n.slice(0, 3).map((v) => v * unit), a: n[3] ?? 1 } : null;
|
|
23
25
|
};
|
|
24
26
|
const luminance = (channels) => { const c = channels.map((n) => { const v = n / 255; return v <= .04045 ? v / 12.92 : ((v + .055) / 1.055) ** 2.4; }); return .2126 * c[0] + .7152 * c[1] + .0722 * c[2]; };
|
|
25
27
|
const contrast = (a, b) => (Math.max(luminance(a), luminance(b)) + .05) / (Math.min(luminance(a), luminance(b)) + .05);
|
|
@@ -38,7 +40,7 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
38
40
|
const gradientStops = (el) => {
|
|
39
41
|
for (let node = el; node; node = node.parentElement) {
|
|
40
42
|
const css = getComputedStyle(node), paint = color(css.backgroundColor);
|
|
41
|
-
if (/gradient\(/iu.test(css.backgroundImage)) return [...css.backgroundImage.matchAll(/rgba?\([^)]*\)|#[\da-f]{3,6}\b/giu)].map((match) => color(match[0])).filter(
|
|
43
|
+
if (/gradient\(/iu.test(css.backgroundImage)) return [...css.backgroundImage.matchAll(/rgba?\([^)]*\)|#[\da-f]{3,6}\b/giu)].map((match) => color(match[0])).filter((stop) => stop && stop.a >= .5);
|
|
42
44
|
if (css.backgroundImage !== 'none' || paint && paint.a * Number(css.opacity) >= .99) break;
|
|
43
45
|
}
|
|
44
46
|
return [];
|
|
@@ -63,7 +65,7 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
63
65
|
if (state.zoomEmulation) add('RS-004', 'HIGH', 'derived', null, overflow, t.pageOverflowTolerancePx);
|
|
64
66
|
}
|
|
65
67
|
const elements = [...document.querySelectorAll('body *')].slice(0, 3000).filter(visible);
|
|
66
|
-
const textElements = [], hues = [], accentSamples = [], targets = [], spacing = [], fontFamilies = new Set(), roles = new Set(), eyebrows = [], glows = [], stripes = [], tileGrids = [];
|
|
68
|
+
const textElements = [], gradientText = [], hues = [], accentSamples = [], targets = [], spacing = [], fontFamilies = new Set(), roles = new Set(), eyebrows = [], glows = [], stripes = [], tileGrids = [];
|
|
67
69
|
const destructive = /\b(?:delete|remove|destroy|erase|discard|삭제|제거|폐기)\b/iu;
|
|
68
70
|
const isTarget = (el) => el.matches('button,a[href],input,select,textarea,[role="button"]');
|
|
69
71
|
const firstFilled = elements.find((el) => isTarget(el) && el.getBoundingClientRect().top < innerHeight && el.matches('button,a,[role="button"]') && color(getComputedStyle(el).backgroundColor)?.a > .5);
|
|
@@ -85,8 +87,10 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
85
87
|
if (!/mono/u.test(family) && !/tabular-nums/u.test(css.fontVariantNumeric) && !/["'](?:tnum|lnum)["']\s*1/u.test(css.fontFeatureSettings)) add('CF-106', 'LOW', 'measured', el, css.fontVariantNumeric, 'tabular numerals');
|
|
86
88
|
}
|
|
87
89
|
const rects = textRects(el);
|
|
90
|
+
// A strip that scrolls sideways inside its own box (a tab or nav row) keeps its overflow reachable.
|
|
91
|
+
const inScroller = (() => { for (let p = el.parentElement; p && p !== document.body && p !== document.documentElement; p = p.parentElement) if (/(auto|scroll)/u.test(getComputedStyle(p).overflowX) && p.scrollWidth > p.clientWidth) return true; return false; })();
|
|
88
92
|
const clipped = rects.some((r) => {
|
|
89
|
-
if (r.left < -t.clippedTextTolerancePx || r.right > innerWidth + t.clippedTextTolerancePx) return true;
|
|
93
|
+
if (!inScroller && (r.left < -t.clippedTextTolerancePx || r.right > innerWidth + t.clippedTextTolerancePx)) return true;
|
|
90
94
|
for (let parent = el; parent; parent = parent.parentElement) {
|
|
91
95
|
const pc = getComputedStyle(parent);
|
|
92
96
|
if (!/(hidden|clip)/u.test(`${pc.overflowX} ${pc.overflowY}`) || pc.textOverflow === 'ellipsis' || Number.parseInt(pc.webkitLineClamp, 10) > 0 || Number.parseInt(pc.lineClamp, 10) > 0) continue;
|
|
@@ -107,7 +111,8 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
107
111
|
const size = Number.parseFloat(css.fontSize), large = size >= t.largeTextPx || (size >= t.boldLargeTextPx && Number.parseFloat(css.fontWeight) >= 700);
|
|
108
112
|
const floor = large ? t.largeTextContrast : t.smallTextContrast;
|
|
109
113
|
if (value < floor) add('CF-201', large ? 'MEDIUM' : 'HIGH', 'derived', el, Number(value.toFixed(2)), floor);
|
|
110
|
-
} else if (!el.closest(':disabled,[aria-disabled="true"]')) {
|
|
114
|
+
} else if (!el.closest(':disabled,[aria-disabled="true"]') && !((css.backgroundClip === 'text' || css.webkitBackgroundClip === 'text') && /gradient\(/iu.test(css.backgroundImage))) {
|
|
115
|
+
// Gradient text paints its own gradient; SLOP-009 checks the stops against the surface.
|
|
111
116
|
const stops = !obscured && fg ? gradientStops(el) : [];
|
|
112
117
|
if (stops.length) {
|
|
113
118
|
const size = Number.parseFloat(css.fontSize), large = size >= t.largeTextPx || size >= t.boldLargeTextPx && Number.parseFloat(css.fontWeight) >= 700;
|
|
@@ -134,7 +139,7 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
134
139
|
if (flourishDashes) add('SLOP-040', 'MEDIUM', 'measured', el, flourishDashes.length, 0);
|
|
135
140
|
if (/\b(?:unleash|revolutionize|transformative platform|seamless experience|next.gen)\b/iu.test(text)) add('SLOP-036', 'MEDIUM', 'measured', el, text.slice(0, 120), 'specific claim');
|
|
136
141
|
if (/\b(?:lorem ipsum|\[placeholder\]|\[todo\]|todo:)\b/iu.test(text)) add('SLOP-060', 'LOW', 'measured', el, text.slice(0, 120), 'finished copy');
|
|
137
|
-
if (el.matches('button,a,label,[role="button"],nav *') && /^\p{Lu}[\p{L}]+(?:\s+\p{Lu}[\p{L}]
|
|
142
|
+
if (el.matches('button,a,label,[role="button"],nav *') && !el.matches('[class*="brand"],[class*="logo"]') && /^\p{Lu}[\p{L}]+(?:\s+\p{Lu}\p{Ll}[\p{L}]*)+$/u.test(text) && !/\bExample\b/u.test(text)) add('CF-107', 'LOW', 'derived', el, text, 'sentence case');
|
|
138
143
|
if (el.nextElementSibling?.matches('h1,h2,h3') && css.textTransform === 'uppercase' && Number.parseFloat(css.letterSpacing) > 0 && Number.parseFloat(css.fontSize) < Number.parseFloat(getComputedStyle(el.nextElementSibling).fontSize) * .65) eyebrows.push(el);
|
|
139
144
|
if (el.matches('button,a,[role="button"]') && /^[\p{Extended_Pictographic}\uFE0F\s]+$/u.test(text) && box.width <= 32) add('SLOP-053', 'LOW', 'derived', el, text, 'named icon set');
|
|
140
145
|
}
|
|
@@ -145,7 +150,9 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
145
150
|
const fg = color(getComputedStyle(el, '::placeholder').color), bg = background(el);
|
|
146
151
|
if (fg && bg) { const value = contrast(fg.c, bg); if (value < t.smallTextContrast) add('CF-807', 'MEDIUM', 'derived', el, Number(value.toFixed(2)), t.smallTextContrast); }
|
|
147
152
|
}
|
|
148
|
-
|
|
153
|
+
// A bare "#" or a javascript: URL goes nowhere; an in-page "#section" is real when the target exists.
|
|
154
|
+
const href = el.matches('a[href]') ? el.getAttribute('href').trim() : '', fragment = href.length > 1 && href.startsWith('#') ? href.slice(1) : null;
|
|
155
|
+
if (el.matches('a[href]') && (/^(#|javascript:)/iu.test(href) && fragment === null || fragment !== null && !document.getElementById(fragment) && !document.getElementsByName(fragment).length)) add('SLOP-058', el.closest('nav') || el.classList.contains('primary') ? 'HIGH' : 'MEDIUM', 'derived', el, el.getAttribute('href'), 'real destination');
|
|
149
156
|
if (el.matches('button:not([type])') && el.form && [...el.form.querySelectorAll('button,input[type="submit"],input[type="image"]'), ...document.querySelectorAll(`button[form="${CSS.escape(el.form.id)}"]`)].filter((button) => button.form === el.form && (button.matches('button:not([type]),button[type="submit"],input[type="submit"],input[type="image"]'))).length >= 2) add('SLOP-059', 'HIGH', 'derived', el, 'implicit submit among submit-capable controls', 'explicit button or submit');
|
|
150
157
|
if (el.matches('img')) {
|
|
151
158
|
const source = el.getAttribute('src') || el.getAttribute('srcset');
|
|
@@ -168,7 +175,7 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
168
175
|
const accessibleName = el.getAttribute('aria-label')?.trim() || labelledBy || el.getAttribute('title')?.trim() || el.innerText?.trim() || el.querySelector('img[alt]')?.getAttribute('alt')?.trim() || el.labels?.[0]?.textContent?.trim() || el.getAttribute('value')?.trim();
|
|
169
176
|
if (!accessibleName && !el.matches('input[type="hidden"]')) add('CF-603', 'HIGH', 'derived', el, 'no accessible name', 'accessible name');
|
|
170
177
|
}
|
|
171
|
-
if ((css.backgroundClip === 'text' || css.webkitBackgroundClip === 'text') && /gradient\(/iu.test(css.backgroundImage))
|
|
178
|
+
if ((css.backgroundClip === 'text' || css.webkitBackgroundClip === 'text') && /gradient\(/iu.test(css.backgroundImage)) gradientText.push({ el, css });
|
|
172
179
|
if (/repeating-(?:linear|radial)-gradient/iu.test(css.backgroundImage) && !el.closest('figure,[role="img"]') && !el.querySelector('canvas,svg,table')) {
|
|
173
180
|
const section = el.closest('section') ?? el;
|
|
174
181
|
if (section.querySelector('[class*="blob"],[class*="halo"]') || /(?:rgb|hsl)a?\([^)]*(?:22\d|2[3-7]\d)/iu.test(css.backgroundImage)) add('SLOP-012', 'MEDIUM', 'derived', el, css.backgroundImage.slice(0, 100), 'data-linked grid');
|
|
@@ -208,8 +215,17 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
208
215
|
}
|
|
209
216
|
if (fontFamilies.size > t.fontFamilyMax) add('SLOP-019', 'MEDIUM', 'derived', null, [...fontFamilies], t.fontFamilyMax);
|
|
210
217
|
if (viewport === '390' && roles.size > t.typeRoleMax) add('CF-108', 'MEDIUM', 'derived', null, [...roles], t.typeRoleMax);
|
|
211
|
-
if (glows.length >= t.glowRepeatMin) add('SLOP-010', 'MEDIUM', 'derived', glows[0], glows.length,
|
|
212
|
-
if (stripes.
|
|
218
|
+
if (glows.length >= t.glowRepeatMin) add('SLOP-010', 'MEDIUM', 'derived', glows[0], glows.length, `fewer than ${t.glowRepeatMin} repeated halos`);
|
|
219
|
+
if (stripes.filter((row) => row.inset).length >= t.stripeInsetRepeatMin || stripes.length >= t.stripeRepeatMin) add('SLOP-015', 'MEDIUM', 'derived', stripes[0].el, stripes.length, `fewer than ${t.stripeRepeatMin} accent stripes (inset form: ${t.stripeInsetRepeatMin})`);
|
|
220
|
+
// One gradient headline in the palette's own hues is craft. The reflex is a violet stop, the effect repeated across the page, or a stop the text cannot be read on.
|
|
221
|
+
for (const { el, css } of gradientText) {
|
|
222
|
+
const surface = background(el.parentElement ?? el), stops = [...css.backgroundImage.matchAll(/rgba?\([^)]*\)|#[\da-f]{3,6}\b/giu)].map((match) => color(match[0])).filter((stop) => stop && stop.a >= .1);
|
|
223
|
+
const floor = Number.parseFloat(css.fontSize) >= t.largeTextPx ? t.gradientTextLargeStopContrast : t.gradientTextStopContrast;
|
|
224
|
+
const weakest = surface && stops.length ? Math.min(...stops.map((stop) => contrast(stop.c.map((value, index) => stop.a * value + (1 - stop.a) * surface[index]), surface))) : null;
|
|
225
|
+
const violet = stops.some((stop) => Math.max(...stop.c) - Math.min(...stop.c) >= t.bannedAccentSpreadMin && hsl(stop.c).h >= t.bannedAccentHueMin && hsl(stop.c).h <= t.bannedAccentHueMax);
|
|
226
|
+
const reason = violet ? 'a violet stop' : gradientText.length > t.gradientTextMaxElements ? `${gradientText.length} gradient-filled text elements` : weakest !== null && weakest < floor ? `a stop at ${weakest.toFixed(2)}:1` : null;
|
|
227
|
+
if (reason) add('SLOP-009', 'MEDIUM', 'measured', el, `${css.backgroundImage.slice(0, 80)} (${reason})`, `at most ${t.gradientTextMaxElements} gradient headline, palette hues, every stop at least ${floor}:1`);
|
|
228
|
+
}
|
|
213
229
|
const sections = elements.filter((el) => el.tagName === 'SECTION');
|
|
214
230
|
for (const heading of elements.filter((el) => /^H[1-6]$/u.test(el.tagName))) {
|
|
215
231
|
let anchor = heading, previous = null;
|
|
@@ -262,7 +278,11 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
262
278
|
const ca = getComputedStyle(a), cb = getComputedStyle(b);
|
|
263
279
|
const painted = [ca, cb].some((css) => css.backgroundColor !== 'rgba(0, 0, 0, 0)' || Number.parseFloat(css.borderTopWidth) > 0);
|
|
264
280
|
const floor = painted ? t.borderedBlockGapPx : t.borderlessBlockGapPx;
|
|
265
|
-
|
|
281
|
+
// Full-bleed section bands carry their own padding, so a flush stack of them is the page rhythm, not a cramped pair.
|
|
282
|
+
const bleed = Math.min(ra.width, rb.width) >= innerWidth * .95 && /^(SECTION|HEADER|FOOTER)$/u.test(a.tagName) && /^(SECTION|HEADER|FOOTER)$/u.test(b.tagName);
|
|
283
|
+
// A heading and what it introduces, or the rows of a table, belong together; the spacing rule is for separate blocks.
|
|
284
|
+
const related = /^H[1-6]$/u.test(a.tagName) || /table/u.test(getComputedStyle(parent).display) || /^T(HEAD|BODY|FOOT)$/u.test(b.tagName);
|
|
285
|
+
if (gap < floor && !bleed && !related && !a.contains(b) && !b.contains(a)) add('CF-303', 'MEDIUM', 'measured', b, Number(gap.toFixed(1)), floor);
|
|
266
286
|
}
|
|
267
287
|
if (gaps.length >= 3) {
|
|
268
288
|
const sorted = [...gaps].sort((a, b) => a - b), middle = sorted[Math.floor(sorted.length / 2)];
|
|
@@ -285,7 +305,9 @@ export function inspectPage(viewport, t, state = {}) {
|
|
|
285
305
|
}
|
|
286
306
|
const clusters = [];
|
|
287
307
|
for (const hue of hues) if (!clusters.some((v) => Math.min(Math.abs(hue - v), 360 - Math.abs(hue - v)) <= t.accentHueToleranceDegrees)) clusters.push(hue);
|
|
288
|
-
|
|
308
|
+
// Rich is the default register; a lock recorded as calm keeps the single-accent limit.
|
|
309
|
+
const clusterLimit = state.density === 'calm' ? t.accentClusterCalmMax : t.accentClusterMax;
|
|
310
|
+
if (clusters.length > clusterLimit) add('CF-205', 'MEDIUM', 'derived', null, clusters.length, clusterLimit, 'review separate status colours');
|
|
289
311
|
const banned = accentSamples.filter((sample) => sample.hue >= t.bannedAccentHueMin && sample.hue <= t.bannedAccentHueMax);
|
|
290
312
|
if (banned.length && new Set(banned.map((sample) => sample.el.closest('section') ?? sample.el.closest('main') ?? sample.el)).size < 2) add('SLOP-008', 'MEDIUM', 'derived', banned[0].el, Number(banned[0].hue.toFixed(1)), 'brand-consistent hue or alternate accent');
|
|
291
313
|
for (let i = 0; i < Math.min(textElements.length, 150); i++) for (let j = i + 1; j < Math.min(textElements.length, 150); j++) {
|
|
@@ -17,7 +17,7 @@ export function staticScan(root, viewport = "static") {
|
|
|
17
17
|
const findings = [];
|
|
18
18
|
const base = root && existsSync(root) && statSync(root).isFile() ? dirname(root) : root;
|
|
19
19
|
const signatures = [
|
|
20
|
-
["SLOP-058", /href\s*=\s*["'](?:#|javascript:
|
|
20
|
+
["SLOP-058", /href\s*=\s*["'](?:#|javascript:[^"']*)["']/giu, "MEDIUM", "real destination"],
|
|
21
21
|
["SLOP-057", /<img\b[^>]*src\s*=\s*["'](?:|#|undefined)["']/giu, "HIGH", "decodable source"],
|
|
22
22
|
["SLOP-009", /(?:background-clip|webkit-background-clip)\s*:\s*text[^}]{0,300}gradient\(/giu, "MEDIUM", "solid text"],
|
|
23
23
|
["SLOP-060", /lorem ipsum|\[placeholder\]|\[todo\]/giu, "LOW", "finished copy"],
|
|
@@ -29,14 +29,27 @@ export function staticScan(root, viewport = "static") {
|
|
|
29
29
|
["CF-507", /will-change\s*:\s*(?:all|width|height|left|top|margin|padding)/giu, "LOW", "transform, opacity or filter"],
|
|
30
30
|
["CF-508", /transition(?:-property)?\s*:[^;]*(?:width|height|padding|margin|top|left)/giu, "MEDIUM", "transform or opacity"],
|
|
31
31
|
];
|
|
32
|
+
const gradientText = [];
|
|
32
33
|
for (const file of sourceFiles(root)) {
|
|
33
34
|
const body = readFileSync(file, "utf8");
|
|
35
|
+
// A kit starter slot left in a page. Framework templates use {{ }} on purpose, so only HTML is read.
|
|
36
|
+
if ([".html", ".htm"].includes(extname(file))) {
|
|
37
|
+
for (const match of body.matchAll(/\{\{(?! )[^{}\n]{1,80}(?<! )\}\}/gu)) {
|
|
38
|
+
if (findings.filter((entry) => entry.rule === "DS-006").length >= 30) break;
|
|
39
|
+
findings.push({ rule: "DS-006", severity: "HIGH", tier: "derived", viewport, selector: `${relative(base, file)}:${body.slice(0, match.index).split("\n").length}`, value: match[0], threshold: "no {{slot}} from the kit layouts", note: "source signature; render not observed" });
|
|
40
|
+
}
|
|
41
|
+
}
|
|
34
42
|
for (const [rule, pattern, severity, threshold] of signatures) {
|
|
35
43
|
for (const match of body.matchAll(pattern)) {
|
|
36
|
-
|
|
37
|
-
|
|
44
|
+
const row = { rule, severity, tier: "derived", viewport, selector: `${relative(base, file)}:${body.slice(0, match.index).split("\n").length}`, value: match[0], threshold, note: "source signature; render not observed" };
|
|
45
|
+
if (rule === "SLOP-009") { gradientText.push(row); continue; }
|
|
46
|
+
if (findings.filter((entry) => entry.rule === rule).length >= 30) break;
|
|
47
|
+
findings.push(row);
|
|
38
48
|
}
|
|
39
49
|
}
|
|
40
50
|
}
|
|
51
|
+
// Source alone cannot weigh contrast, so a static match is the violet form or a repeated one.
|
|
52
|
+
const violet = (row) => (row.value.match(/#[\da-f]{6}\b/giu) ?? []).some((hex) => { const [r, g, b] = [1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)); const hi = Math.max(r, g, b), lo = Math.min(r, g, b); if (hi - lo < 50) return false; const d = hi - lo; const h = ((hi === r ? ((g - b) / d) % 6 : hi === g ? (b - r) / d + 2 : (r - g) / d + 4) * 60 + 360) % 360; return h >= 260 && h <= 310; });
|
|
53
|
+
findings.push(...gradientText.filter((row) => gradientText.length > 1 || violet(row)).slice(0, 30));
|
|
41
54
|
return { findings, notVerified: renderedRules.map((rule) => ({ rule, viewport, reason: "static fallback: needs a rendered DOM" })) };
|
|
42
55
|
}
|
|
@@ -61,13 +61,19 @@
|
|
|
61
61
|
"fontFamilyMax": 3,
|
|
62
62
|
"focusOutlineMinPx": 2,
|
|
63
63
|
"focusContrastMin": 3,
|
|
64
|
-
"glowRepeatMin":
|
|
65
|
-
"stripeRepeatMin":
|
|
64
|
+
"glowRepeatMin": 3,
|
|
65
|
+
"stripeRepeatMin": 3,
|
|
66
|
+
"stripeInsetRepeatMin": 2,
|
|
66
67
|
"tileBoxTolerancePx": 4,
|
|
67
68
|
"tileCountMin": 4,
|
|
68
69
|
"tileGridRepeatMin": 1,
|
|
69
70
|
"statSiblingMin": 4,
|
|
70
71
|
"statSiblingMarketingMin": 3,
|
|
72
|
+
"accentClusterMax": 4,
|
|
73
|
+
"accentClusterCalmMax": 1,
|
|
74
|
+
"gradientTextMaxElements": 1,
|
|
75
|
+
"gradientTextStopContrast": 4.5,
|
|
76
|
+
"gradientTextLargeStopContrast": 3,
|
|
71
77
|
"bannedAccentHueMin": 260,
|
|
72
78
|
"bannedAccentHueMax": 310,
|
|
73
79
|
"bannedAccentSpreadMin": 50,
|
|
@@ -6,17 +6,28 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { inspectPage } from "./probe-page.mjs";
|
|
8
8
|
import { staticScan } from "./probe-static.mjs";
|
|
9
|
+
import { DS_RULES, LOCK_LIMITS, findLock, measureLock } from "./probe-lock.mjs";
|
|
9
10
|
|
|
10
11
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
11
12
|
const args = process.argv.slice(2);
|
|
12
13
|
const option = (name) => { const i = args.indexOf(name); return i < 0 ? null : args[i + 1]; };
|
|
13
14
|
const input = option("--path") ?? option("--url");
|
|
14
15
|
const output = option("--out") ? resolve(option("--out")) : null;
|
|
15
|
-
if (!input || !output) { process.stderr.write("Usage: probe.mjs --path FILE|DIR or --url LOOPBACK --out DIR\n"); process.exit(2); }
|
|
16
|
+
if (!input || !output) { process.stderr.write("Usage: probe.mjs --path FILE|DIR or --url LOOPBACK --out DIR [--lock design.lock.json] [--viewports ids]\n"); process.exit(2); }
|
|
16
17
|
const spec = JSON.parse(readFileSync(join(here, "probe-thresholds.json"), "utf8"));
|
|
17
18
|
const session = `uiux-${process.pid}-${Date.now()}`;
|
|
18
19
|
const browserEnv = { ...process.env, AGENT_BROWSER_HIDE_SCROLLBARS: "true", AGENT_BROWSER_IDLE_TIMEOUT_MS: "120000" };
|
|
19
20
|
delete browserEnv.AGENT_BROWSER_ALLOW_FILE_ACCESS;
|
|
21
|
+
// The fixed look rubric printed under every rendered run: concrete questions a screenshot answers.
|
|
22
|
+
const LOOK_RUBRIC = [
|
|
23
|
+
["LOOK-1", "One focal point: the signature moment (data-ui-moment) is where the eye lands first at 1440 and at 390; nothing else competes with it."],
|
|
24
|
+
["LOOK-2", "Shared edges: section content starts on the same left edge at 1440; no block floats on a width of its own."],
|
|
25
|
+
["LOOK-3", "Grouping by space: gaps between groups are visibly larger than gaps inside them; no empty band separates nothing."],
|
|
26
|
+
["LOOK-4", "Kit, not costume: every block is a kit part or a page rule on the locked tokens; nothing decorative without a job."],
|
|
27
|
+
["LOOK-5", "Composed mobile: at 390 and 320 the title breaks well, navigation and tables stay usable, nothing is a squeezed desktop."],
|
|
28
|
+
["LOOK-6", "Specific content: names, numbers and copy belong to this brief; no filler and no leftover slot."],
|
|
29
|
+
];
|
|
30
|
+
const subset = option("--viewports") ? new Set(option("--viewports").split(",").map((id) => id.trim()).filter(Boolean)) : null;
|
|
20
31
|
const blockedRules = ["RS-001", "RS-002", "RS-003", "RS-004", "RS-006", "RS-007", "CF-201", "CF-202", "CF-701"];
|
|
21
32
|
|
|
22
33
|
function entryFor(pathname) {
|
|
@@ -81,15 +92,18 @@ function writeReport(manifest, findings) {
|
|
|
81
92
|
const rows = ["| Severity | Rule | Where | Measured | Fix |", "| --- | --- | --- | --- | --- |"];
|
|
82
93
|
for (const f of findings) rows.push(`| ${f.severity} | ${f.rule} | ${String(f.selector ?? "document").replaceAll("|", "\\|")} @ ${f.viewport} | ${f.tier}: ${JSON.stringify(f.value).replaceAll("|", "\\|")} | ${JSON.stringify(f.threshold).replaceAll("|", "\\|")} |`);
|
|
83
94
|
const missing = manifest.not_verified.map((item) => `- ${item.rule} @ ${item.viewport ?? "all"}: ${item.reason}`);
|
|
84
|
-
|
|
95
|
+
const shots = manifest.screenshots.filter(({ viewport }) => ["1440", "390", "320"].includes(viewport));
|
|
96
|
+
const look = shots.length ? ["", "Look before calling it done (open these screenshots; fix the two worst answers, re-run the probe, two look rounds at most):", ...shots.map((shot) => `- ${shot.viewport}: ${shot.path}`), ...LOOK_RUBRIC.map(([id, text]) => `${id} ${text}`)].join("\n") : "";
|
|
97
|
+
writeFileSync(join(output, "review.md"), `# Interface probe\n\n${manifest.exit_code === 2 ? "Blocked" : manifest.exit_code === 1 ? "Block" : "Approve"}\n\n${rows.join("\n")}\n\n## Not verified\n\n${missing.join("\n") || "None"}\n${look ? `\n## Look\n${look}\n` : ""}`);
|
|
85
98
|
if (manifest.exit_code === 2) process.stdout.write(`BLOCKED: ${manifest.blocked_reason}\n`);
|
|
86
99
|
else process.stdout.write(`${findings.length} findings; ${manifest.exit_code === 1 ? "Block" : "Approve"}\n`);
|
|
100
|
+
if (look) process.stdout.write(`${look.trimStart()}\n`);
|
|
87
101
|
process.exitCode = manifest.exit_code;
|
|
88
102
|
}
|
|
89
103
|
|
|
90
104
|
const source = option("--source") ?? (option("--path") ? resolve(option("--path")) : null);
|
|
91
105
|
const fallback = staticScan(source);
|
|
92
|
-
const manifest = { url: null, viewports_run: [], browser_version: null, zoom_emulation: "viewport-halved", not_verified: [], screenshots: [], exit_code: 2, blocked_reason: null };
|
|
106
|
+
const manifest = { url: null, viewports_run: [], browser_version: null, zoom_emulation: "viewport-halved", not_verified: [], screenshots: [], exit_code: 2, blocked_reason: null, design_lock: null };
|
|
93
107
|
const findings = [];
|
|
94
108
|
const block = (reason) => {
|
|
95
109
|
manifest.blocked_reason = reason;
|
|
@@ -97,9 +111,13 @@ const block = (reason) => {
|
|
|
97
111
|
findings.push(...fallback.findings);
|
|
98
112
|
};
|
|
99
113
|
|
|
100
|
-
let server = null, command = null, browserAttempted = false, socketDir = null;
|
|
114
|
+
let server = null, command = null, browserAttempted = false, socketDir = null, lock = null;
|
|
101
115
|
try {
|
|
102
116
|
const entry = option("--path") ? entryFor(option("--path")) : null;
|
|
117
|
+
const lockState = findLock(entry, source, option("--lock"));
|
|
118
|
+
lock = lockState.lock;
|
|
119
|
+
if (lock) manifest.design_lock = { path: lockState.path, style: lock.style, family: lock.family ?? null, density: lock.density ?? null };
|
|
120
|
+
else manifest.not_verified.push({ rule: "DS-001", reason: lockState.error ?? "no design.lock.json beside the page; lock conformance not checked" });
|
|
103
121
|
if (option("--path") && !entry) { block("no entry page found"); }
|
|
104
122
|
else if (option("--url") && !localUrl(option("--url"))) { block("browser unavailable"); }
|
|
105
123
|
else if (args.includes("--static-only")) { block("browser unavailable"); }
|
|
@@ -118,7 +136,7 @@ try {
|
|
|
118
136
|
manifest.url = `http://127.0.0.1:${server.port}/${encodeURIComponent(basename(entry))}`;
|
|
119
137
|
} else manifest.url = option("--url");
|
|
120
138
|
const started = Date.now();
|
|
121
|
-
for (const probeCase of spec.matrix) {
|
|
139
|
+
for (const probeCase of spec.matrix.filter((entry) => !subset || subset.has(entry.name))) {
|
|
122
140
|
if (Date.now() - started > spec.values.runTimeBudgetMs) {
|
|
123
141
|
manifest.not_verified.push(...blockedRules.map((rule) => ({ rule, viewport: probeCase.name, reason: "time budget exceeded" })));
|
|
124
142
|
manifest.blocked_reason = "matrix incomplete";
|
|
@@ -138,10 +156,20 @@ try {
|
|
|
138
156
|
manifest.blocked_reason = "matrix incomplete";
|
|
139
157
|
break;
|
|
140
158
|
}
|
|
141
|
-
const state = { scheme: probeCase.scheme, reducedMotion: !!probeCase.reducedMotion, zoomEmulation: probeCase.zoomEmulation };
|
|
159
|
+
const state = { scheme: probeCase.scheme, reducedMotion: !!probeCase.reducedMotion, zoomEmulation: probeCase.zoomEmulation, density: lock?.density ?? null };
|
|
142
160
|
const inspected = parseEval(await browser(command, ["eval", "--stdin"], `JSON.stringify((${inspectPage.toString()})(${JSON.stringify(probeCase.name)},${JSON.stringify(spec.values)},${JSON.stringify(state)}))`));
|
|
143
161
|
findings.push(...inspected.findings);
|
|
144
162
|
manifest.not_verified.push(...inspected.notVerified);
|
|
163
|
+
if (lock) {
|
|
164
|
+
const pass = probeCase.reducedMotion ? "reduced-motion" : probeCase.zoomEmulation ? "zoom" : probeCase.scheme === "dark" ? "dark" : "base";
|
|
165
|
+
try {
|
|
166
|
+
const locked = parseEval(await browser(command, ["eval", "--stdin"], `JSON.stringify((${measureLock.toString()})(${JSON.stringify({ rules: DS_RULES, limits: LOCK_LIMITS, lock, pass })}))`));
|
|
167
|
+
findings.push(...locked.findings.map((finding) => ({ ...finding, viewport: probeCase.name })));
|
|
168
|
+
manifest.not_verified.push(...locked.notVerified.map((item) => ({ ...item, viewport: probeCase.name })));
|
|
169
|
+
} catch (error) {
|
|
170
|
+
manifest.not_verified.push({ rule: "DS-001", viewport: probeCase.name, reason: `design-lock pass failed: ${error.message.slice(0, 120)}` });
|
|
171
|
+
}
|
|
172
|
+
}
|
|
145
173
|
for (const selector of inspected.hoverSelectors.slice(0, 5)) {
|
|
146
174
|
await browser(command, ["hover", selector]);
|
|
147
175
|
const duration = parseEval(await browser(command, ["eval", "--stdin"], `JSON.stringify((() => { const el = document.querySelector(${JSON.stringify(selector)}); return el ? Math.max(...getComputedStyle(el).transitionDuration.split(',').map((value) => value.trim().endsWith('ms') ? Number.parseFloat(value) : Number.parseFloat(value) * 1000)) : null; })())`));
|
|
@@ -157,7 +185,7 @@ try {
|
|
|
157
185
|
if (errors && !/no errors|0 errors/iu.test(errors)) writeFileSync(join(output, `${probeCase.name}-errors.txt`), `${errors}\n`);
|
|
158
186
|
}
|
|
159
187
|
const missing = spec.requiredViewports.filter((name) => !manifest.viewports_run.includes(name));
|
|
160
|
-
if (missing.length) manifest.blocked_reason = `matrix incomplete (${missing.join(', ')})`;
|
|
188
|
+
if (missing.length) manifest.blocked_reason = subset && !manifest.blocked_reason ? `partial matrix (--viewports ${[...subset].join(",")}); only the full matrix can approve` : `matrix incomplete (${missing.join(', ')})`;
|
|
161
189
|
manifest.exit_code = findings.some((row) => row.severity === "HIGH" && ["measured", "derived"].includes(row.tier)) ? 1 : manifest.blocked_reason ? 2 : 0;
|
|
162
190
|
}
|
|
163
191
|
}
|