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
27 changes: 25 additions & 2 deletions src/app/pricing/pricing.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@
background: linear-gradient(
180deg,
var(--copper),
color-mix(in oklab, var(--copper) 78%, black)
color-mix(in oklab, var(--copper) 96%, black)
);
color: var(--on-copper);
font-weight: 500;
Expand Down Expand Up @@ -321,11 +321,34 @@
border-color: var(--copper);
color: var(--brass);
}
/* THE DARK STOP IS BOUND BY AAA, NOT BY TASTE (#778/#459).
*
* This was `... 78%, black` and measured 4.21:1 against --on-copper on
* scripthammer-dark — below the 7:1 this 11.84px/600 label requires. It passed
* every gate for months because axe cannot compute contrast against a gradient:
* it returns `contrastRatio: null` and files the node under `passes`.
*
* Measured, per mix percentage, dark theme (light theme is white-on-dark-copper,
* where darkening RAISES contrast — it passes at every value, 8.84–12.74:1):
*
* 78% → 4.21 84% → 5.02 90% → 6.01 94% → 6.77
* 96% → 7.11 ✓ 98% → 7.54 100% → 7.96
*
* 96% is the first passing value, so the fade is now only a 4% black mix and is
* close to imperceptible. That is the honest consequence: with a light --copper
* and a dark --on-copper, a VISIBLE downward darkening and AAA cannot both hold.
* Left as a gradient rather than flattened so the CTA still matches its siblings.
*
* The 0.11 margin is thin, and that is acceptable only because the gate now
* MEASURES this instead of assuming it — tests/e2e/color-contrast.spec.ts falls
* back to canvas readback for every null-ratio node. Change --copper or
* --on-copper and it will tell you.
*/
.btnPrimary {
background: linear-gradient(
180deg,
var(--copper),
color-mix(in oklab, var(--copper) 78%, black)
color-mix(in oklab, var(--copper) 96%, black)
);
border-color: var(--copper);
color: var(--on-copper);
Expand Down
7 changes: 6 additions & 1 deletion src/app/sign-in/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,12 @@ export default function SignInPage() {
</p>
<p className="text-base-content text-sm">
New here?{' '}
<Link href={signUpHref} className="link link-secondary">
{/* NOT link-secondary (#778). Measured against the hero gradient:
6.07:1 on scripthammer-light, under the 7:1 this text needs.
text-base-content measures 10.1 light / 11.81 dark. `link`
stays, so the UNDERLINE marks it as a link rather than colour
alone — which WCAG prefers regardless. */}
<Link href={signUpHref} className="link text-base-content">
Create an account
</Link>
</p>
Expand Down
10 changes: 8 additions & 2 deletions src/components/auth/SignInForm/SignInForm.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -364,10 +364,16 @@ export default function SignInForm({
<span>Remember Me</span>
</label>
{/* Conventionally belongs on this row, not below the submit button.
`min-h-11` keeps the 44px touch target the mobile gate requires. */}
`min-h-11` keeps the 44px touch target the mobile gate requires.

NOT link-primary (#778). Measured against the hero gradient: 6.8:1
on dark and 6.08:1 on light, both under the 7:1 this text needs.
text-base-content measures 11.81 / 10.1. `link` stays so the
UNDERLINE carries the affordance rather than colour alone, which
WCAG prefers regardless. */}
<Link
href="/forgot-password"
className="link link-primary min-h-11 content-center text-sm"
className="link text-base-content min-h-11 content-center text-sm"
>
Forgot password?
</Link>
Expand Down
18 changes: 16 additions & 2 deletions src/components/molecular/SEOAnalysisPanel/SEOAnalysisPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -194,12 +194,26 @@ export default function SEOAnalysisPanel({
key={i}
className={`alert ${alertSeverityClass(suggestion.severity)} px-3 py-2`}
>
{/* THE ALERT OWNS THE TEXT COLOUR HERE (#459).
`.alert-warning` paints a dark amber in the LIGHT theme and
sets a matching content colour. Two children fought it: this
span carried `text-base-content/85`, and `.badge` sets a
`base-content` of its own — both dark, on dark amber,
measuring 1.66:1 against a 7:1 requirement. The sibling <p>
below never overrode anything, which is exactly why it
passed while these did not.

`text-current` makes the badge inherit. The /85 opacity is
REMOVED rather than reduced: dimming text on a coloured
surface is the same trap globals.css documents for DaisyUI's
.label, and it is what put this below AAA in the first
place. */}
<div className="flex-1">
<div className="mb-1 flex flex-wrap items-center justify-between gap-2">
<span className="badge badge-xs sm:badge-sm badge-outline">
<span className="badge badge-xs sm:badge-sm badge-outline !text-current">
{suggestion.category}
</span>
<span className="text-base-content/85 text-xs">
<span className="text-xs">
Impact: {suggestion.impact}/10
</span>
</div>
Expand Down
105 changes: 92 additions & 13 deletions tests/e2e/color-contrast.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@ import {
} from './utils/test-user-factory';
import { dirname, join } from 'node:path';
import { waitForLoadStateOrGiveUp } from './utils/settle';
import {
measureNullRatioNodes,
UNRESOLVABLE_ALLOWLIST,
VENDOR_EXCLUDED,
} from './utils/contrast-fallback';

// Pa11y's axe runner reports axe `incomplete` results as errors, which
// produces 14–61 false positives per page on DaisyUI — .btn gradients
Expand Down Expand Up @@ -362,26 +367,82 @@ test.describe('WCAG AAA color-contrast-enhanced (violations only)', () => {
// axe could not compute one — most often a background it cannot resolve
// (an image, a gradient, a transparent stack). It is not a pass; it is a
// question that was never answered, and it was being counted as covered.
const unmeasured = (results.passes ?? []).flatMap((rule) =>
const unmeasured: string[] = (results.passes ?? []).flatMap((rule) =>
rule.nodes
.filter((n) => (n.any?.[0]?.data?.contrastRatio ?? null) === null)
.map((n) => n.target?.[0])
// A node axe cannot give a selector for cannot be re-resolved in the
// page either, so it would only become a phantom "unresolvable".
.filter((t): t is string => typeof t === 'string')
);
const passCount = (results.passes ?? []).reduce(
(n, v) => n + v.nodes.length,
0
);
// MEASURE THEM OURSELVES (#459). Reporting the count was the previous
// behaviour and it closed nothing — a printed number can grow for
// months without anyone reading the log. Every one of these is text on
// a gradient, which axe declines to compute but which is perfectly
// computable from the gradient's stops. See utils/contrast-fallback.ts
// for why worst-of-stops is the correct bound and why canvas readback
// is the only thing that resolves this codebase's oklch() colours.
const fallback = unmeasured.length
? await page.evaluate(measureNullRatioNodes, unmeasured)
: [];

const fallbackFailures = fallback
.filter((r) => r.kind === 'measured' && r.ratio! < r.required!)
// Vendor chrome we do not style. Named and reasoned in
// VENDOR_EXCLUDED, never a bare threshold.
.filter(
(r) =>
!VENDOR_EXCLUDED.some(
(v) =>
r.selector.includes(v.selectorFragment) ||
r.signature.includes(v.selectorFragment)
)
)
.map((r) => ({
target: r.selector,
html: `${r.signature} "${r.text}"`,
fg: r.fg,
bg: r.bg,
ratio: r.ratio,
expected: r.required,
note: `axe reported this as a PASS with contrastRatio: null (${r.mode})`,
}));

// Anything still unmeasurable must be a KNOWN category. Asserted as a
// set rather than a count: a count stays green while one unresolvable
// node appears and another is fixed.
const unresolvedUnknown = fallback
.filter((r) => r.kind === 'unresolvable')
.filter(
(r) =>
!UNRESOLVABLE_ALLOWLIST.some(
(a) => a.signature.test(r.signature) && a.reason === r.reason
)
)
.map(
(r) =>
`${r.signature} [${r.reason}] "${r.text}"` +
(r.baseColorRatio
? ` (base background-color alone would be ${r.baseColorRatio}:1)`
: '')
);

const measuredByFallback = fallback.filter(
(r) => r.kind === 'measured'
).length;
const notVisible = fallback.filter(
(r) => r.kind === 'not-visible'
).length;
if (unmeasured.length) {
// Reported, not thrown. These are pre-existing and repo-wide; failing
// on them today would block every merge on a backlog this PR does not
// fix. The number is printed on every run so it cannot quietly grow,
// which is the same posture `check-first-load-budget.mjs` takes toward
// first-party 3D code.
console.log(
`::warning::${path} [${theme}]: ${unmeasured.length} of ${passCount} ` +
`"passing" elements were never measured (contrastRatio: null) — ` +
`axe could not resolve a background. See #459. ` +
`e.g. ${unmeasured.slice(0, 3).join(', ')}`
`${path} [${theme}]: ${passCount} axe passes, ${unmeasured.length} with a ` +
`null ratio -> ${measuredByFallback} measured by fallback, ` +
`${notVisible} not visible, ` +
`${fallback.length - measuredByFallback - notVisible} unresolvable`
);
}

Expand All @@ -392,11 +453,29 @@ test.describe('WCAG AAA color-contrast-enhanced (violations only)', () => {
if (openedAdmin) await openedAdmin.close();
await deleteIsolatedAdmin(adminFixture);

// A NEW unmeasurable CATEGORY is a failure, because the alternative is
// the gate quietly shrinking again. Fix the element, or add an
// allowlist entry that says why it cannot be measured.
expect(
unresolvedUnknown,
`${path} [${theme}]: ${unresolvedUnknown.length} element(s) could not be ` +
`measured and are not in UNRESOLVABLE_ALLOWLIST (tests/e2e/utils/` +
`contrast-fallback.ts). axe reported them as PASSING with a null ` +
`ratio, so leaving them here means they are unverified:\n ` +
unresolvedUnknown.join('\n ')
).toEqual([]);

// ONE assertion for both sources. axe's own violations and the ones it
// declined to compute are the same defect to a user, so they fail the
// same way and carry the same fg/bg/ratio dump.
const allFailures = [...details, ...fallbackFailures];
expect(
details,
allFailures,
`color-contrast-enhanced (AAA) violations on ${path} [${theme}] ` +
`(${incompleteCount} incomplete/needs-review — expected, not a failure):\n` +
JSON.stringify(details, null, 2)
`(${incompleteCount} incomplete/needs-review — expected, not a failure; ` +
`${fallbackFailures.length} of these were measured by the #459 fallback ` +
`after axe passed them with a null ratio):\n` +
JSON.stringify(allFailures, null, 2)
).toHaveLength(0);
});
}
Expand Down
Loading
Loading