Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,9 @@
"product:truth": "node scripts/check-product-truth.mjs",
"mobbin:run": "node scripts/run-design-system-proving-lab.mjs",
"mobbin:check": "node scripts/run-design-system-proving-lab.mjs --dry-run",
"ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-footer-targets.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check",
"ci": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check",
"check": "npm test",
"test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-footer-targets.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check"
"test": "node scripts/test-service-engine.mjs && node scripts/test-sales-intake-contract.mjs && node scripts/test-active-offer-projection.mjs && node scripts/test-active-operator-surfaces.mjs && node scripts/test-direction-proof-gate.mjs && node scripts/test-client-readiness-contract.mjs && node scripts/test-validated-service-client.mjs && node scripts/test-client-acceptance-gates.mjs && node scripts/check-product-truth.mjs && node scripts/check-human-service-kit.mjs && node scripts/test-design-system-proving-lab.mjs && node scripts/check-retention-automation.mjs && node scripts/test-retention-automation.mjs && node scripts/check-agency-defaults.mjs && node scripts/check-outbound-claim-safety.mjs && node scripts/check-outbound-send-readiness.mjs && node scripts/test-outbound-send-readiness.mjs && node scripts/test-public-conversion-signal.mjs && node scripts/test-public-structured-data.mjs && node scripts/test-public-heading-hierarchy.mjs && node scripts/test-public-link-targets.mjs && find scripts -name '*.mjs' -print0 | xargs -0 -n1 node --check"
},
"engines": {
"node": ">=20"
Expand Down
18 changes: 18 additions & 0 deletions public/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -147,6 +147,9 @@ img {
font-size: 0.95rem;
font-weight: 500;
letter-spacing: 0;
display: inline-block;
min-height: 24px;
padding: 4px 0;
Comment on lines +150 to +152

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Enforce the 24px inline dimension.

These rules only enforce target height. A short anchor can remain narrower than 24px and still pass scripts/test-public-link-targets.mjs, because that validator does not check width.

Add min-width: 24px or min-inline-size: 24px to each selector. Extend the validator to require the same property.

Also applies to: 325-329, 414-416, 698-702

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@public/styles.css` around lines 150 - 152, Update each affected anchor
selector in public/styles.css, including the blocks near the existing min-height
rules, to enforce a minimum inline dimension of 24px by adding min-width or
min-inline-size. Extend scripts/test-public-link-targets.mjs to validate that
each matching selector declares the same 24px minimum-width requirement while
preserving the existing height checks.

transition: color 160ms ease;
}

Expand Down Expand Up @@ -319,6 +322,12 @@ h4 {
letter-spacing: 0;
}

.rail-item strong a {
display: inline-block;
min-height: 24px;
padding: 4px 0;
}

.rail-item p {
margin: 8px 0 0;
}
Expand Down Expand Up @@ -402,6 +411,9 @@ h4 {
text-decoration: none;
font-size: 0.92rem;
font-weight: 500;
display: inline-block;
min-height: 24px;
padding: 4px 0;
}

.team-feature {
Expand Down Expand Up @@ -683,6 +695,12 @@ h4 {
line-height: 1.45;
}

.plain-list a {
display: inline-block;
min-height: 24px;
padding: 4px 0;
}

.page-card.dark .plain-list li {
color: rgba(255, 255, 255, 0.82);
}
Expand Down
89 changes: 0 additions & 89 deletions scripts/test-public-footer-targets.mjs

This file was deleted.

179 changes: 179 additions & 0 deletions scripts/test-public-link-targets.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
import { readFileSync } from "node:fs"
import { fileURLToPath } from "node:url"
import { dirname, join } from "node:path"

const ROOT = join(dirname(fileURLToPath(import.meta.url)), "..")
const read = (p) => readFileSync(join(ROOT, p), "utf8")

let failures = 0
let checks = 0
const ok = (cond, msg) => {
checks++
if (cond) console.log(` ok ${msg}`)
else {
failures++
console.error(` FAIL ${msg}`)
}
}

// Every public page that carries the shared header/footer.
const PUBLIC_PAGES = [
"public/index.html",
"public/contact/index.html",
"public/promptly/index.html",
"public/promptly/support/index.html",
"public/promptly/privacy/index.html",
"public/drishti/index.html",
"public/drishti/support/index.html",
"public/drishti/privacy/index.html",
"public/support/index.html",
"public/privacy/index.html",
"public/privacy-choices/index.html",
"public/terms/index.html"
]

const beforeFooter = (html) => {
const end = html.indexOf("<footer")
return end === -1 ? html : html.slice(0, end)
}

const countLinks = (block) => (block.match(/<a\b/gi) ?? []).length

// Anchors whose own class already guarantees a >= 24px target via the
// .button/.text-link (48px), .ghost-button (48px), .skip-link (48px), or
// .brand (42px brand-mark) rules. Header chrome and CTAs, not plain content.
const CHROME_OR_CTA_CLASSES = /class="(skip-link|brand|button|ghost-button|text-link)"/

const countChromeOrCtaLinks = (block) =>
(block.match(/<a\b[^>]*class="[^"]*"[^>]*>/gi) ?? []).filter((tag) =>
CHROME_OR_CTA_CLASSES.test(tag)
).length

const linksInside = (block, container) =>
(block.match(container) ?? []).reduce((sum, part) => sum + countLinks(part), 0)

const TOP_NAV = /<nav\b[^>]*class="top-nav"[^>]*>[\s\S]*?<\/nav>/gi
const PLAIN_LIST = /<ul\b[^>]*class="plain-list"[^>]*>[\s\S]*?<\/ul>/gi
const PRODUCT_LINKS = /<div\b[^>]*class="product-links"[^>]*>[\s\S]*?<\/div>/gi
const RAIL_ITEM = /<article\b[^>]*class="[^"]*rail-item[^"]*"[^>]*>([\s\S]*?)<\/article>/gi

// The containers whose links styles.css must raise to a >= 24px target.
const IN_CONTENT_SELECTORS = [
".top-nav a",
".plain-list a",
".product-links a",
".rail-item strong a"
]

// Links inside a rail-item <strong> (the hero rail "link as heading" anchors
// such as inish.in and The Website Correction).
const railItemStrongLinks = (html) =>
(html.match(RAIL_ITEM) ?? [])
.flatMap((article) => [...article.matchAll(/<strong\b[^>]*>([\s\S]*?)<\/strong>/gi)])
.reduce((sum, m) => sum + countLinks(m[1]), 0)

const footerBlockOf = (html) => {
const start = html.indexOf("<footer")
const end = html.indexOf("</footer>")
if (start === -1 || end === -1) return ""
return html.slice(start, end)
}

const countFooterLinks = (block) =>
(block.match(/<ul\s+class="footer-links"[^>]*>[\s\S]*?<\/ul>/gi) ?? []).reduce(
(sum, list) => sum + countLinks(list),
0
)

console.log("test-public-link-targets: every interactive link on the public pages is a WCAG 2.2 24px tap target")

console.log("A. every in-content link on every public page is covered by a >= 24px container or CTA class")
for (const page of PUBLIC_PAGES) {
const content = beforeFooter(read(page))
const total = countLinks(content)
const covered =
countChromeOrCtaLinks(content) +
linksInside(content, TOP_NAV) +
linksInside(content, PLAIN_LIST) +
linksInside(content, PRODUCT_LINKS) +
railItemStrongLinks(content)
ok(
total === covered && covered > 0,
`${page} has no uncovered in-content links (${covered}/${total} in styled containers or CTA classes)`
Comment on lines +94 to +102

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

De-duplicate covered anchors before comparison.

A CTA link inside .top-nav counts once in countChromeOrCtaLinks() and again in linksInside(content, TOP_NAV). An uncovered anchor can offset that duplicate, so total === covered passes even though the page has an uncovered link.

Track each anchor by its source position and compare the unique covered-anchor set with the complete anchor set.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@scripts/test-public-link-targets.mjs` around lines 94 - 102, Update the
coverage calculation around countChromeOrCtaLinks(), linksInside(), and
railItemStrongLinks() to track covered anchors by source position, deduplicating
overlaps across selectors. Compare the resulting unique covered-anchor set
against the complete anchor set, while preserving the existing requirement that
coverage is nonzero and reporting accurate unique counts.

)
}

console.log("B. styles.css gives every in-content container a >= 24px target")
const css = read("public/styles.css")

const targetRuleOf = (selector) => {
const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
const pattern = selector
.split(",")
.map((s) => esc(s.trim()))
.join("\\s*,\\s*")
const match = css.match(new RegExp(`${pattern}\\s*\\{([^}]*)\\}`))
return match ? match[1] : null
}

const assert24pxRule = (selector, rule) => {
ok(rule !== null, `styles.css has a ${selector} rule`)
if (rule) {
ok(/display:\s*(inline-block|inline-flex|block)/.test(rule), `${selector} links are block-level boxes (hit area covers the line box)`)
ok(!/display:\s*inline\s*;/.test(rule), `${selector} links are not plain inline boxes`)
ok(/min-height:\s*24px/.test(rule), `${selector} links declare min-height: 24px`)
const paddingMatch = rule.match(/padding:\s*([^;]+)/)
ok(paddingMatch !== null, `${selector} links declare vertical padding`)
if (paddingMatch) {
const vertical = parseFloat(paddingMatch[1].trim().split(/\s+/)[0])
ok(vertical >= 4, `${selector} vertical padding is at least 4px (${vertical}px), so 16px text + padding >= 24px`)
}
}
}

for (const selector of IN_CONTENT_SELECTORS) {
assert24pxRule(selector, targetRuleOf(selector))
}
assert24pxRule(".footer-links a", targetRuleOf(".footer-links a"))

console.log("C. styles.css still keeps CTA and header links above the minimum")
const buttonRule = targetRuleOf(".button, .text-link")
ok(buttonRule !== null, "styles.css has a .button, .text-link rule")
if (buttonRule) {
const minHeight = buttonRule.match(/min-height:\s*(\d+)px/)
ok(minHeight !== null && parseFloat(minHeight[1]) >= 24, `.button and .text-link declare min-height >= 24px (${minHeight ? minHeight[1] : "none"}px)`)
}
const ghostRule = targetRuleOf(".ghost-button")
ok(ghostRule !== null, "styles.css has a .ghost-button rule")
if (ghostRule) {
const minHeight = ghostRule.match(/min-height:\s*(\d+)px/)
ok(minHeight !== null && parseFloat(minHeight[1]) >= 24, `.ghost-button declares min-height >= 24px (${minHeight ? minHeight[1] : "none"}px)`)
}
const brandRule = targetRuleOf(".brand")
ok(brandRule !== null, "styles.css has a .brand rule")
if (brandRule) {
ok(/display:\s*(inline-flex|flex|inline-block|block)/.test(brandRule), ".brand is a block-level box")
}

console.log("D. every footer link on every public page is a .footer-links link")
for (const page of PUBLIC_PAGES) {
const block = footerBlockOf(read(page))
const total = countLinks(block)
const inLists = countFooterLinks(block)
ok(total > 0, `${page} has a footer with links`)
ok(inLists === total && inLists > 0, `${page} keeps every footer link inside .footer-links (${inLists}/${total})`)
}

console.log("E. npm test/ci wiring")
const pkg = JSON.parse(read("package.json"))
ok(
pkg.scripts.test.includes("test-public-link-targets.mjs"),
"npm test runs the public link target test"
)
ok(
pkg.scripts.ci.includes("test-public-link-targets.mjs"),
"npm run ci runs the public link target test"
)

console.log(`\n${checks} checks, ${failures} failures`)
process.exit(failures === 0 ? 0 : 1)