From 8c5565944739ba21134fbd8961d8fff93ddd700a Mon Sep 17 00:00:00 2001 From: nish3451 <257724087+nish3451@users.noreply.github.com> Date: Sun, 9 Aug 2026 05:57:55 +0530 Subject: [PATCH] fix(public): bring in-content links up to WCAG 2.2 24px tap targets PR #22 fixed only .footer-links; the trust-critical in-content links on the app and privacy pages - mailto support emails, plain-list navigation, top-nav, product links, and the homepage hero-rail links - stayed plain inline anchors with ~17px hit areas, below the WCAG 2.2 24px minimum on every tinystudio.in page at mobile widths. Give .top-nav a, .product-links a, .plain-list a, and .rail-item strong a the same treatment as the footer: inline-block with min-height 24px and 4px vertical padding (rendered ~31-43px), so the whole row is tappable. Renames scripts/test-public-footer-targets.mjs to scripts/test-public-link-targets.mjs and extends it (wired into npm test and npm run ci) to assert every in-content link on all 12 public pages lives inside a styled container or CTA class, that each container rule enforces the 24px minimum, and that footer links stay covered. verify: /home/nish/.local/bin/test-gate npm test; /home/nish/.local/bin/test-gate npm run ci; git diff --check; node --check scripts/test-public-link-targets.mjs; Playwright 390x844 audit on /promptly/privacy/, /drishti/privacy/, /privacy-choices/, /promptly/, /, /contact/, /terms/ shows zero in-content links below 24x24 (pre-fix: 17px) Co-authored-by: Claude --- package.json | 4 +- public/styles.css | 18 +++ scripts/test-public-footer-targets.mjs | 89 ------------ scripts/test-public-link-targets.mjs | 179 +++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 91 deletions(-) delete mode 100644 scripts/test-public-footer-targets.mjs create mode 100644 scripts/test-public-link-targets.mjs diff --git a/package.json b/package.json index a6bf7045..ea6d9cb4 100644 --- a/package.json +++ b/package.json @@ -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" diff --git a/public/styles.css b/public/styles.css index deb94222..5bf61682 100644 --- a/public/styles.css +++ b/public/styles.css @@ -147,6 +147,9 @@ img { font-size: 0.95rem; font-weight: 500; letter-spacing: 0; + display: inline-block; + min-height: 24px; + padding: 4px 0; transition: color 160ms ease; } @@ -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; } @@ -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 { @@ -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); } diff --git a/scripts/test-public-footer-targets.mjs b/scripts/test-public-footer-targets.mjs deleted file mode 100644 index ae957491..00000000 --- a/scripts/test-public-footer-targets.mjs +++ /dev/null @@ -1,89 +0,0 @@ -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 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 footerBlockOf = (html) => { - const start = html.indexOf("") - if (start === -1 || end === -1) return "" - return html.slice(start, end) -} - -const countLinks = (block) => (block.match(/ - (block.match(/]*>[\s\S]*?<\/ul>/gi) ?? []).reduce( - (sum, list) => sum + countLinks(list), - 0 - ) - -console.log("test-public-footer-targets: footer links are WCAG 2.2 24px tap targets on every public page") - -console.log("A. 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("B. styles.css gives .footer-links a an at-least-24px target") -const css = read("public/styles.css") -const ruleMatch = css.match(/\.footer-links a\s*\{([^}]*)\}/) -ok(ruleMatch !== null, "styles.css has a .footer-links a rule") -if (ruleMatch) { - const rule = ruleMatch[1] - ok(/display:\s*(inline-block|inline-flex|block)/.test(rule), "links are block-level boxes (hit area covers the line box)") - ok(!/display:\s*inline\s*;/.test(rule), "links are not plain inline boxes") - ok(/min-height:\s*24px/.test(rule), "links declare min-height: 24px") - const paddingMatch = rule.match(/padding:\s*([^;]+)/) - ok(paddingMatch !== null, "links declare vertical padding") - if (paddingMatch) { - const vertical = parseFloat(paddingMatch[1].trim().split(/\s+/)[0]) - ok(vertical >= 4, `vertical padding is at least 4px (${vertical}px), so 16px text + padding >= 24px`) - } -} - -console.log("C. npm test/ci wiring") -const pkg = JSON.parse(read("package.json")) -ok( - pkg.scripts.test.includes("test-public-footer-targets.mjs"), - "npm test runs the public footer target test" -) -ok( - pkg.scripts.ci.includes("test-public-footer-targets.mjs"), - "npm run ci runs the public footer target test" -) - -console.log(`\n${checks} checks, ${failures} failures`) -process.exit(failures === 0 ? 0 : 1) diff --git a/scripts/test-public-link-targets.mjs b/scripts/test-public-link-targets.mjs new file mode 100644 index 00000000..b1d165be --- /dev/null +++ b/scripts/test-public-link-targets.mjs @@ -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(" (block.match(/= 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(/]*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 = /]*class="top-nav"[^>]*>[\s\S]*?<\/nav>/gi +const PLAIN_LIST = /]*class="plain-list"[^>]*>[\s\S]*?<\/ul>/gi +const PRODUCT_LINKS = /]*class="product-links"[^>]*>[\s\S]*?<\/div>/gi +const RAIL_ITEM = /]*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 (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(/]*>([\s\S]*?)<\/strong>/gi)]) + .reduce((sum, m) => sum + countLinks(m[1]), 0) + +const footerBlockOf = (html) => { + const start = html.indexOf("") + if (start === -1 || end === -1) return "" + return html.slice(start, end) +} + +const countFooterLinks = (block) => + (block.match(/]*>[\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)` + ) +} + +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)