fix: resolve adversarial review round 2 — 9 findings, 8 of them in round 1's fixes
Build and deploy / build-and-deploy (push) Failing after 4s
Build and deploy / build-and-deploy (push) Failing after 4s
D19 caps the loop at two rounds, and this is what the second round is for. BLOCKING. Round 1 made NO_RETAINER_NOTICE a requireEnv and added it to no document, while the fix's own comment claimed docs/06 named it. The deployment list said five variables for a handler that needs six, so an operator following the cutover checklist would have deployed a function that throws at cold start on every invocation — 5xx from API Gateway, every inquiry lost from the moment /api/* was wired, loud in CloudWatch and silent to Pouya. docs/05 and docs/06 now name all six, and the comment that asserted the documentation existed is corrected rather than deleted. The intake route check added in round 1 could not fail: curl -w already prints 000 on a failed transfer, so `|| echo 000` double-appended and the failure arm was unreachable, and the pass arm accepted anything that was not literally 404 — including the 403 CloudFront returns when the /api/* behaviour is missing, which is the one distinction the check exists to draw. It now sends the correct Origin and asserts a positive: 303 to /contact/could-not-send/, which the handler returns before any DynamoDB write or email. Probed on refused/501/403/303; the old version passed the first three. Fixed in both deploy paths. Removing priceRange left three statements saying it was present or pending, one of them the stated reason /fees/ emits no Offer node. Deleting overtimeStartsAfterSessionHours left AGENTS.md §9 naming it and left Q59 recorded as open. The Google-as-processor fix was applied to the privacy policy's "Where it is stored" and not to "Who can see it", which still read "Nobody else has access". And the variable removal was justified with a path-scoped git grep — which also cannot see untracked files. The unscoped sweep found docs/06's variable table, the OIDC example, and .env.example still carrying them; .env.example also restates the execute-api hostname, falsifying a live claim in intake.ts that has been corrected. That file is not edited here: this environment denies read access to it, and nothing may edit a file it cannot read. It is in the batched list. Also: og:image:alt was the page title rather than the card's headline on 20 pages; og-card.ts documented the wrong path and invocation for the contact sheet; deploy-local.sh still said Q22's deploy credential "does NOT yet exist"; and the round-1 fix comments were trimmed per D19, though the ratio held at 0.44. Round 2 also confirmed the round-1 fixes by measurement: all 56 .btn instances across 22 pages, the consent checkbox's computed accessible name, the radio labels hit-tested at 44px, and og:proof exercised against synthetic article pages in a sandbox. Verified: check/build/check:claims/og:proof/check:intake/lint/bio:pdf all exit 0 on a clean build; 22 pages; Lighthouse 99-100 / 100 / 100 / 100, CLS 0.000. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Md3GndFqWPzK78xAoebsg5
This commit is contained in:
co-authored by
Claude Opus 5
parent
9f2d83c32f
commit
9f2d2eeb04
+34
-19
@@ -165,35 +165,50 @@ jobs:
|
||||
# header requires the two paths to match on everything that determines
|
||||
# what gets published - and this replaced the INTAKE_ENDPOINT guard.
|
||||
#
|
||||
# The contact form posts to the same-origin path /api/intake, which only
|
||||
# works if a CloudFront behaviour routes /api/* to the HTTP API origin
|
||||
# AGENTS.md §7 records. Nothing in the build can know whether it exists.
|
||||
# It ASSERTS A POSITIVE. The first version excluded one status code and
|
||||
# passed on everything else; `adversarial-reviewer` round 2 measured it
|
||||
# passing on a refused connection (curl -w already prints 000, so the
|
||||
# `|| echo 000` double-appended and made $code "000000") and on a real 501.
|
||||
# It would also have passed the case that matters most: with the /api/*
|
||||
# behaviour MISSING, the POST falls to the S3 default behaviour and
|
||||
# CloudFront answers 403 for a disallowed method - indistinguishable from
|
||||
# the handler's Origin refusal, which is the one distinction this check
|
||||
# exists to draw.
|
||||
#
|
||||
# 404 means not routed. 403 means routed and REFUSED, which is the correct
|
||||
# answer here: the handler checks the Origin header and this request sends
|
||||
# none, so it is rejected before any DynamoDB write or any email. That is
|
||||
# why the probe is safe to run against production.
|
||||
# With the correct Origin and an empty submission the handler validates,
|
||||
# rejects, and redirects 303 to /contact/could-not-send/ - BEFORE any
|
||||
# DynamoDB write and before any email, which is what makes it safe against
|
||||
# production. Probed on four cases: refused, 501, 403, and the real 303.
|
||||
#
|
||||
# It warns rather than failing: the site is already deployed by this point,
|
||||
# and failing the job would not un-deploy it.
|
||||
- name: Intake route check
|
||||
run: |
|
||||
url="https://adr.smlcompany.ca/api/intake"
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
--max-time 15 \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' \
|
||||
"https://adr.smlcompany.ca/api/intake" || echo 000)
|
||||
case "$code" in
|
||||
404|000)
|
||||
echo "WARNING: POST /api/intake returned $code."
|
||||
echo "The contact form posts there. 404 means the CloudFront /api/*"
|
||||
echo "behaviour is missing; 000 means the request did not complete."
|
||||
--data 'deploy-route-probe=1' "$url")
|
||||
rc=$?
|
||||
location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
--max-time 15 \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' "$url" 2>/dev/null || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)."
|
||||
echo "The site is deployed and the contact form is unverified."
|
||||
echo "See docs/06-deployment.md's cutover checklist."
|
||||
;;
|
||||
*)
|
||||
echo "POST /api/intake -> $code (routed; 403 is the Origin check)"
|
||||
;;
|
||||
esac
|
||||
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||
echo "POST /api/intake -> 303 -> $location (routed, validating)"
|
||||
else
|
||||
echo "WARNING: POST /api/intake returned $code, expected 303 to"
|
||||
echo "/contact/could-not-send/; redirect was '${location:-none}'."
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can"
|
||||
echo "mean the same thing, or the handler refusing the Origin."
|
||||
echo "See docs/06-deployment.md's cutover checklist."
|
||||
fi
|
||||
|
||||
- name: Summary
|
||||
run: echo "Deployed to https://adr.smlcompany.ca — commit ${GITHUB_SHA:0:7}"
|
||||
|
||||
@@ -772,7 +772,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
||||
|
||||
| # | Question | Blocks |
|
||||
|---|---|---|
|
||||
| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling, `FEES.mediation.overtimeStartsAfterSessionHours` records it, and `docs/07` carries it in full. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
|
||||
| ~~Q59~~ | ✅ **RULED AND CLOSED 2026-08-31 — Pouya. OVERTIME RUNS FROM THE SESSION CAP**: the fourth hour of a half day, the seventh of a full day. Not the billed envelope. `/fees/` shipped at build step 9 on this ruling and `docs/07` carries it in full. ⚠️ **THIS ROW NAMED A CONSTANT THAT NO LONGER EXISTS** — `FEES.mediation.overtimeStartsAfterSessionHours` was deleted the same day as dead data: nothing read it, so reversing it would have changed nothing and failed nothing, which is Q22's shape at constant scope. **Where the ruling actually lives:** the trigger is rendered on `/fees/` from `halfDay.hours` / `fullDay.hours`, and `FEES.mediation.reservation` carries the half that publishes as prose. Found by `adversarial-reviewer` round 2 — §9 is what a later implementer reads to find where a ruling is recorded, so pointing it at a deleted identifier is the same defect one layer up. ⚠️ **AND THE RULING CAME WITH A SECOND HALF THAT ANSWERS THE ARITHMETIC ANOMALY THIS ROW EXISTED TO ESCALATE, WHICH THE TRIGGER ALONE COULD NOT.** His words: *"a full day reserves the day; half-day overtime is subject to availability."* **The full-day fee buys the DAY, not six hours of it.** Read as a price comparison the table below says the full-day rate is never the cheaper choice; read knowing what each fee reserves, the $2,000-narrowing-to-$500 spread is the price of certainty rather than a defect. The sentence is `FEES.mediation.reservation` and it publishes **adjacent to the overtime row**, not as a footnote — the same structural rule as `PROCESS_FRAMING` beside the five timings under Q43, because a reader who takes the number and skips the framing has read a different offer. **THE ANOMALY IS NOT CLOSED AND STAYS ON §12 R5.** The gap is in D14's own figures — the half-to-full step is $2,000 against $1,500 for three hours of overtime — and the reservation point explains what it buys without removing it; the spread is largest at three to five hours, which is the band a half-day booking actually overruns into. `docs/07` §Recorded dissent carries the table for the 12-month review. **The original question, kept because the shape of it is the lesson.** *Where does the overtime hour start?* `docs/07`'s card carried *"Overtime, per hour — $500"* and had never said what it was overtime **to**. Q58's ruling settled the two allowances and did not reach this; Q15–Q17's answer records the rate with no trigger. The two candidates were the session cap (3 h / 6 h) and the billed envelope (5 h / 9 h), and this repository was barred from picking one — a fee term is a fact we do not have, and `CLAUDE.md`'s rule for that is a question, not an inference. **It cost two strikes to hold that line:** a first pass at `docs/07`'s Q58 note asserted the session cap as applied fact and `adversarial-reviewer` struck it in the change set that wrote it; a round-1 fix then published the $500 rate on `/for-parties/` beside an unambiguous *"up to 3 hours"*, which **defines the trigger by adjacency** — nothing else on the page is a quantity it can attach to — and round 2 struck that too. Both strikes were right, and the ruling supplied the value they were waiting for | ~~`/fees/`, `/for-parties/`~~ — both now unblocked and shipped |
|
||||
| ~~Q58~~ | **RULED 2026-08-31 — `hours` IS THE SESSION, AND THE AMBIGUITY WAS IN `docs/07` RATHER THAN IN ANY COPY. Pouya owned it in terms:** *"the ambiguity is mine… My `docs/07` wording said "up to 3.5 h, including 2 h preparation", which is genuinely unclear: 3.5 was meant as the TOTAL time committed, of which 2 is preparation — leaving 1.5 hours in the room. Your arithmetic caught it: if prep sat inside, 3.5 and 7 wouldn't be exactly 2×, because preparation doesn't scale with session length. The intended reading is the market's, and my wording obscured it."* **THE CORRECTED CARD, in his words:** *"Half day — up to 3 hours of session. Fee includes up to 2 hours of preparation. $2,000. Full day — up to 6 hours of session. Fee includes up to 3 hours of preparation. $4,000."* His reason for 3 and 6: *"the market convention — Patey and Zuber both publish "all or part of 3 hours" and "all or part of 6 hours", and those were the comparables the rate was set against. Selling 1.5 hours of room time as a half day would be an outlier nobody would recognise."* ⚠️ **ONE PROVENANCE NOTE, and it is R14's rule rather than a doubt about the ruling:** `docs/07`'s committed extract records Patey and Zuber at **3 h** and **6 h** but **does not carry the phrase "all or part of"** — so `docs/07` cites the hours, not the phrase, and the phrase is not attributed to them anywhere in the repository. The hours corroborate the ruling on their own, and ADR Chambers' roster rate in the same table is the clearest corroboration of the *shape*: *"one half hour of preparation time per party **and** up to three hours of mediation"* — preparation counted separately from a three-hour session. **APPLIED:** `docs/07`'s two card rows and its §All parameters confirmed (which prescribed the flat *"including 2 hours"*, the form `/for-parties/` then shipped); `FEES.mediation.*.hours` 3.5 → 3 and 7 → 6 with the semantics in the constant's doc comment; `/for-parties/` now states the session length interpolated from the constant and the preparation allowance **as a cap**. **The preparation allowance is CAPPED and must be published as capped** — *"including **up to** 2 hours"*, never the flat form and never "preparation included". **`/fees/` is UNBLOCKED for build step 9.** **The question as raised is preserved below.** **DOES `hours` IN THE MEDIATION RATE CARD MEAN THE LENGTH OF THE DAY, OR THE BILLED ENVELOPE INCLUDING PREPARATION?** `docs/07-fees.md` reads *"Half day — **up to 3.5 h, including 2 h preparation**"* and *"Full day — up to 7 h, including 3 h preparation"*. Taken at face value, 3.5 is the whole billed envelope and the **time in the room is 1.5 h** for a half day and **4 h** for a full day. **Against that reading:** 3.5 and 7 are exactly 2×, which they would not be if preparation sat inside them (1.5 vs 4 is not 2×). So either the card's wording is wrong in the one document that is the authority on money, or `FEES.mediation.*.hours` in `src/data/site.ts` does not mean what a page would naturally publish it as. **This was one sentence from shipping.** A draft of `/for-parties/` answered *"What happens on the day?"* with *"A half day is about 3.5 hours"* — the envelope presented as the day, to the reader least able to check it. The sentence was removed rather than resolved by inference; the page now says only that you book a half day or a full day. **What is needed:** one line from Pouya saying which the 3.5 and 7 are. Then `docs/07`'s two rows or `site.ts`'s field gains the correction, and the semantics go in the constant's doc comment (a warning is there now). **`/fees/` at build step 9 publishes this table and cannot be built without the answer.** Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing.** No page stated a duration while the question was open — the one draft sentence that did was removed rather than reconciled, which is why the ruling had nothing to correct in public copy |
|
||||
| ~~Q57~~ | **CLOSED 2026-08-31 — NO SEVENTH UNDERTAKING, AND THE PAGE IS COMPLETE AS IT STANDS.** Pouya: *"`/process/` stating when conflicts are run and what the check needs is complete. A reader assumes the outcome, and the obvious undertaking ("if a conflict is found I decline") adds nothing a reader doesn't already infer. Your restraint was right — §4's gate held. Record it closed rather than open, so it stops appearing in the live list."* **So this is a closure, not a deferral:** the answer is that the page says nothing further, which was one of the two outcomes the question named. §4 gains no seventh conduct undertaking and `CONDUCT_UNDERTAKINGS` still holds six. **APPLIED:** the `TODO(pouya)` is removed from `src/pages/process.astro` §Conflicts and replaced with the ruling, so a later reader finds the decision where the question was rather than an open marker; the file header's *"see the TODO below"* is corrected to cite this closure. `src/` now carries **zero** live `TODO(pouya)` markers. **The question as raised is preserved below.** **WHAT HAPPENS WHEN A CONFLICTS CHECK TURNS SOMETHING UP?** `/process/` §Conflicts ships saying **when** the check runs (the intake call, before anything is agreed) and **what it needs** (full legal names of the parties, the parent or affiliate actually behind the dispute, counsel on each side). It stops there, and the stop is deliberate: **any sentence naming the outcome is a SEVENTH conduct undertaking**, and §4's gate for that class is one line — *"an undertaking may be published only where Pouya has made it in terms. Not 'would obviously agree to', not 'follows from the process' — said."* *"If a conflict appears I decline the appointment"* is exactly what that gate refuses to let this repository infer, however obvious it looks. **What is needed:** one sentence from Pouya, in his words, or a decision that the page says nothing further. `TODO(pouya)` sits at `src/pages/process.astro` §Conflicts. Raised at build step 6, 2026-08-30 | **Nothing.** The section shipped accurate and unchanged; what closed is whether anything more was owed |
|
||||
| ~~Q56~~ | **RULED 2026-08-30 — MEDIATION IS *NOT* SCOPED COMMERCIAL.** Pouya: *"Correct the four 'Commercial Mediation' surfaces to 'Mediation'; leave §4's mediation row unscoped."* **And the asymmetry with arbitration is DESIGNED, not an oversight — the reason is now recorded beside both §4 rows so nobody tidies them into a matching pair.** **Arbitration is scoped commercial because of a LEGAL GATE:** Q39 — family arbitration in Ontario requires prescribed training, and Pouya has excluded it by choice. **Mediation has no such gate**; he mediates commercial, construction, insurance/SABS, shareholder and cross-cultural matters, and the practice pages say so. So the site-wide "commercial" framing was **under-describing a wider offering**, which is why it is corrected rather than ratified as a positioning choice. `/practice/insurance/`'s SABS framing needs no reconciliation: it was never outside the offering. **The question as raised is preserved below.** ⚠️ **IS THE MEDIATION OFFERING SCOPED COMMERCIAL, OR NOT? THE SITE SAID BOTH.** §4 Offerings rows arbitration three times, each **scoped commercial**; the mediation row is `**Mediation** — sole mediator`, **unscoped**. Shipped output scopes it anyway: `/mediation/`'s `<title>` is *"Commercial Mediation"*, its `Service` node is `name: "Commercial mediation"`, and `/` and `/about/` both say *"I mediate commercial disputes"*. Meanwhile **`/practice/insurance/` offers mediation in matters that are not commercial on any ordinary reading** — *"Disputes between an insured person and an insurer under the Statutory Accident Benefits Schedule"*, with *"What I offer is private mediation"*. An individual claimant against their own insurer is not a commercial dispute. **Nothing here is a false claim** — scoping a page to commercial mediation asserts *less* than the unscoped §4 row grants, and narrower than the row is always safe. **The problem is that the two halves cannot both be the whole picture**: either the practice takes non-commercial mediations (and the site-wide "commercial" framing under-describes it, including on the page an appointing body reads), or it does not (and `/practice/insurance/` is offering something outside the offering). **What is needed:** either a §4 Offerings row scoping mediation, with `/practice/insurance/`'s SABS framing reconciled to it — or a decision that mediation is deliberately unscoped, in which case the site-wide "commercial" wording is a positioning choice and should be recorded as one rather than read as a limit. **Pre-existing, not created 2026-08-30** — but this change set newly wrote the claim onto `/med-arb/` and it has been removed again pending this answer. Raised by `adversarial-reviewer`, 2026-08-30 | **Nothing — ruled the same day it was raised.** No page was wrong; the register was silent where the site was specific, and the ruling makes the site match the register rather than the other way round |
|
||||
@@ -782,7 +782,7 @@ Nothing below can be invented. Each needs an answer from Pouya.
|
||||
| ~~Q3~~ | **ANSWERED 2026-08-26.** Email `info@smlcompany.ca`. No public phone — "By scheduled call". Location: Toronto · Ontario · By appointment | — |
|
||||
| ~~Q4 / Q14~~ | **ANSWERED 2026-08-26.** Rate card confirmed by Pouya — see D14 and `docs/07-fees.md` | — |
|
||||
| ~~Q13~~ | **ANSWERED 2026-08-26.** Self-hosted Gitea with Gitea Actions | — |
|
||||
| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only; where it begins is §9 Q59, open.** ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
|
||||
| ~~Q15 / Q16 / Q17~~ | **ANSWERED 2026-08-26.** Non-mediation hourly $500. Prep bundled and **CAPPED**: **up to** 2 h in the half day, **up to** 3 h in the full day, and `docs/07` §All parameters confirmed requires it published **in hours and as a cap**. Overtime $500/h — **the RATE only. Where it begins was §9 Q59, now RULED AND CLOSED 2026-08-31: the session cap**, i.e. the fourth hour of a half day and the seventh of a full day. *(This clause read "where it begins is §9 Q59, open" for the rest of that day, eleven lines below the row recording the closure — found by `adversarial-reviewer` round 2.)* ⚠️ *Corrected 2026-08-31 with Q58: this row prescribed the flat form ("2 h in the half day"), which is the form `docs/07` now says must never be published — and §9 is what a later implementer building `/fees/` reads. Found by `adversarial-reviewer` in the change set that wrote the new rule.* | — |
|
||||
| ~~Q25~~ | **ANSWERED 2026-08-26. Struck.** The §4 row permitting the boutique to be named is removed; D16 governs alone | — |
|
||||
| ~~Q26~~ | **ANSWERED 2026-08-26.** §4 now carries an explicit **Licence status — NOT ESTABLISHED** row, and its opening no longer asserts that LSO rules apply. The register's rationale stands on the fabricated-credentials history alone | — |
|
||||
| ~~Q24~~ | **ANSWERED 2026-08-26.** `AWS-Hosting-Guide.md` copied into `docs/reference/` — it is the only record of how the hand-built infrastructure was created. Scanned for credentials before copying: no access keys, no secrets, no account ID | — |
|
||||
@@ -1083,7 +1083,9 @@ fix marker present, `git stash` empty, and the reviewer's own probe — a
|
||||
`DELIBERATELY WRONG CARD TEXT` headline it had injected into the OG endpoint —
|
||||
restored to `entry.data.title`.
|
||||
|
||||
### Review — `adversarial-reviewer` alone, per D20. Round 1: 16 findings, all acted on
|
||||
### Review — `adversarial-reviewer` alone, per D20. Two rounds, 25 findings, all acted on
|
||||
|
||||
**Round 1: 16 findings.**
|
||||
|
||||
**Two blocking, and both were on pages built in this run.**
|
||||
|
||||
@@ -1229,6 +1231,100 @@ character that reached a pattern by accident; here the control range **is** what
|
||||
is being matched, and it is the part of the function that stops a submitted value
|
||||
forging an email header.
|
||||
|
||||
### Round 2: 9 findings, and EIGHT OF NINE were defects in round 1's own fixes
|
||||
|
||||
D19 caps the loop at two rounds and this is why the second one exists. The
|
||||
previous measurement behind that cap was *"most of round two's were defects in
|
||||
round one's fixes"*; this run reproduced it almost exactly, and the hit rate was
|
||||
predictable from inside round 1 — the `og:proof` repair had already been caught
|
||||
being a tautology and re-fixed before round 2 started.
|
||||
|
||||
**BLOCKING — the fix for finding 9 would have lost every inquiry.** Round 1
|
||||
converted the hand-typed no-retainer notice into `requireEnv('NO_RETAINER_NOTICE')`
|
||||
— a module-scope throw — **and added the variable to no document**, while the
|
||||
fix's own comment claimed *"`docs/06` names it."* It did not:
|
||||
`grep -c NO_RETAINER_NOTICE docs/06-deployment.md` returned **0**, and the
|
||||
deployment list named five variables for a handler that required six. An operator
|
||||
working the cutover checklist sets five, the Lambda throws at cold start on
|
||||
**every** invocation, API Gateway answers 5xx, and every inquiry from counsel is
|
||||
lost from the moment `/api/*` is wired — **loud in CloudWatch, silent to Pouya**,
|
||||
who has no reason to submit his own form. `docs/06` and `docs/05` now name all
|
||||
six; the comment that asserted the documentation existed is what made it
|
||||
invisible, and it is corrected rather than deleted.
|
||||
|
||||
**The intake route check I added to replace the stale guard could not fail.**
|
||||
`code=$(curl … -w '%{http_code}' … || echo 000)` — `curl -w` already prints `000`
|
||||
on a failed transfer, so the `||` double-appended and `$code` became `000000`,
|
||||
making the failure arm unreachable. And the pass arm accepted anything that was
|
||||
not literally `404`. The case that matters most was the one it waved through:
|
||||
**with the `/api/*` behaviour missing, CloudFront answers 403 for a method the
|
||||
default behaviour does not allow — indistinguishable from the handler's Origin
|
||||
refusal**, which is the single distinction the check exists to draw. It now sends
|
||||
the correct `Origin` and asserts a positive: `303` with a `Location` ending
|
||||
`/contact/could-not-send/`, which the handler returns **before any DynamoDB write
|
||||
and before any email**, and which is what makes the probe safe against
|
||||
production. Probed on four cases — refused connection, 501, 403, and the real
|
||||
303. **The old version passed the first three.** Fixed in both deploy paths.
|
||||
|
||||
**Removing `priceRange` left three live statements saying it was present or
|
||||
pending**, one of them the stated justification for `/fees/` emitting no `Offer`
|
||||
node — so that reasoning rested on a field that no longer existed, which is how
|
||||
the next reader re-adds one. **Deleting `overtimeStartsAfterSessionHours` left §9
|
||||
naming it** — Current Truth pointing at an identifier that does not exist, the
|
||||
same "flag that looks like a control" shape one layer up — and left the Q15–Q17
|
||||
row saying *"where it begins is §9 Q59, open"* eleven lines below the row
|
||||
recording it closed. **The Google fix was applied to §Where it is stored and not
|
||||
to §Who can see it**, so the page answered *"who can see the names of the
|
||||
opposing parties I gave you?"* with **"Nobody else has access"** under that
|
||||
heading and **"Google"** two sections earlier.
|
||||
|
||||
⚠️ **AND THE VARIABLE REMOVAL WAS JUSTIFIED WITH A PATH-SCOPED GREP, WHICH IS
|
||||
BOTH OF `CLAUDE.md`'s SWEEP RULES AT ONCE.** `git grep PUBLIC_INTAKE_ENDPOINT -- src/`
|
||||
was offered as proof of a repo-wide removal — and `git grep` additionally cannot
|
||||
see untracked files, which at that moment was most of the new work. The unscoped
|
||||
sweep found `docs/06`'s required-variables table still instructing an operator to
|
||||
set both, the OIDC example still setting them, and **`.env.example` still holding
|
||||
the full execute-api hostname — which falsifies a live claim in
|
||||
`src/data/intake.ts` that *"§7 remains the only place it lives"***. That claim is
|
||||
corrected, `docs/06`'s rows are gone, the OIDC example is marked superseded, and
|
||||
`deploy-local.sh`'s three-day-old *"Q22 records that it does NOT yet exist"* about
|
||||
the deploy credential is corrected against §7's PROVISIONED row.
|
||||
|
||||
⚠️ **`.env.example` ITSELF IS NOT EDITED, AND THAT IS A LIMIT RATHER THAN A
|
||||
CHOICE: this environment denies read access to it, and nothing here may edit a
|
||||
file it cannot read.** Batched for Pouya — delete `PUBLIC_INTAKE_ENDPOINT`,
|
||||
`PUBLIC_BOOKING_URL` and `PUBLIC_ANALYTICS_*`, none of which anything reads.
|
||||
|
||||
**Two smaller ones, both comments that pointed at nothing.** `og-card.ts` told a
|
||||
reader to run `npm run og:proof` to see every card in `dist/og-proof/` — wrong
|
||||
path, and the sheet requires `-- --sheet`, so the one documented mitigation for
|
||||
that file's own stated overflow hazard was wrong in both the path and the
|
||||
command. And **`og:image:alt` was the page `<title>`, not the card's headline**,
|
||||
on 20 pages: `/fees/` emitted *"Fees · Mediation and Arbitration Rates · Pouya
|
||||
Lajevardi"* against a card reading *"Published in full, including what overruns
|
||||
cost."* Now `OG_CARDS[path]?.headline ?? title`.
|
||||
|
||||
**What round 2 confirmed, which is the other half of its value.** All 56 `.btn`
|
||||
instances across all 22 pages measured with composited backgrounds: `/fees/`'s
|
||||
ghost button is **16.81:1**, every ghost and gold button ≥ 11.09:1, and the
|
||||
on-cream appearance is unchanged. The consent checkbox's accessible name computed
|
||||
from the AX tree is `CONSENT_TEXT` alone. Radio labels hit-test at **70.6 × 44**
|
||||
and **76.7 × 44**, clickable at all four corners. `og:proof` was exercised in a
|
||||
sandbox with five synthetic article pages: correct `<h1>`s gave *"25 card
|
||||
headlines matched (5 of them articles)"*, and one wrong `<h1>` produced both the
|
||||
mismatch and the coverage shortfall, exit 1. Zero text-contrast failures on all
|
||||
22 pages. All five MDX bodies compile against the installed `@mdx-js/mdx`.
|
||||
|
||||
**Declined: none of the nine.** One partially satisfied, again: the D19 comment
|
||||
ratio. The named blocks were trimmed — `Button.astro`'s Lighthouse anecdote,
|
||||
`global.css`'s duplicate of the same story, `schema.ts`'s 25 lines for an absent
|
||||
field, `Prose.astro`'s 15 for zero, `contact.astro`'s 22 for 16 — and **the ratio
|
||||
held at 0.44**, because round 2's own nine fixes each carry their reason. The
|
||||
trims removed roughly what the fixes added. Recorded as the one item this entry
|
||||
does not claim to have satisfied, for the second round running.
|
||||
|
||||
**There is no round 3 — D19.** Round 2's findings are fixed and this ships.
|
||||
|
||||
### `README.md`'s script table was missing a control, and had been for two days
|
||||
|
||||
It listed seven commands and **not `npm run check:claims`** — the one that runs on
|
||||
|
||||
@@ -97,7 +97,14 @@ const RESPONSE_TIME = requireEnv('RESPONSE_TIME');
|
||||
* the constant would never have reached this email and nothing would have
|
||||
* failed — the silent-drift shape §4 flags for the whole commitment class.
|
||||
* Found by `adversarial-reviewer`. The deploy step sets it from
|
||||
* `NO_RETAINER_NOTICE` in `src/data/site.ts`; `docs/06` names it.
|
||||
* `NO_RETAINER_NOTICE` in `src/data/site.ts`.
|
||||
*
|
||||
* ⚠️ AND THAT SENTENCE USED TO END "`docs/06` names it", WHICH IT DID NOT.
|
||||
* This variable became a `requireEnv` and reached no document — so the
|
||||
* deployment list said five variables while this file required six, and the
|
||||
* function would have thrown at cold start on every invocation. **The comment
|
||||
* asserting the documentation existed is what made it invisible.** `docs/06` and
|
||||
* `docs/05` now name all six. Found by `adversarial-reviewer` round 2.
|
||||
*/
|
||||
const NO_RETAINER_NOTICE = requireEnv('NO_RETAINER_NOTICE');
|
||||
|
||||
|
||||
+1
-1
@@ -128,7 +128,7 @@ JSON-LD only. Validate against Google's Rich Results Test before cutover.
|
||||
| Type | Where | Notes |
|
||||
|---|---|---|
|
||||
| `Person` | `/about/`, referenced site-wide | **Emitted:** `name`, `url`, `jobTitle`, `description`, `alumniOf` (Bond University), `knowsLanguage` (en, fa), `hasCredential` (**Q.Med, Q.Arb** — both, since 2026-08-29), `sameAs` (LinkedIn), `email`, `image`. **Emitted on `/about/` only:** `memberOf` — the four §4 memberships as `Organization` nodes (Q53, ruled 2026-08-28). `/` shows no memberships, so its Person node omits it: structured data represents the page it sits on. **Withheld:** `worksFor` — Q49(b) declined the row 2026-08-28 and Pouya confirmed the reading 2026-08-29, so it is settled rather than pending; `provider → Person → worksFor` would assert a same-entity claim §4 does not row. *(This enumeration listed `worksFor` as emitted while the same cell said it was withheld, and omitted `url` and `email`, which are — wrong in both directions. The enumeration is the part an implementer copies. Found by `adversarial-reviewer`.)* **CHANGED 2026-08-28 — Q47.** This row read *"`jobTitle` = 'Director of Firm Operations'; omit `worksFor`"*, which put the boutique title on a node whose `url` is this ADR practice's `/about/` — so a consumer could attach it to this entity. Pouya's ruling reframes the field: `jobTitle` describes **this practice**, not the boutique role, which D16 keeps unnamed. The visible role line is unchanged and still reads "Director of Firm Operations at a Toronto litigation and ADR boutique". **THE VALUE IS `PRACTICE_JOB_TITLE` IN `src/data/site.ts` AND THIS ROW DOES NOT RESTATE IT** — §7's rule, applied to a string with a live revert trigger on it: this row carried the literal text for one pass, and `adversarial-reviewer` noted it would go stale the moment the constant moved. Cite, do not copy. **`worksFor` IS WITHHELD** — set for one pass under Q47, then reverted: `ProfessionalService.provider` is this Person, so `provider → Person → worksFor` asserts the same-entity claim `schema.ts` explicitly declines, and §4 says "alongside the practice" where the ruling says "operates through". **`memberOf` is emitted** — see the sentence above; Q53 closed 2026-08-28. *(This cell asserted `memberOf` was both emitted and withheld for one pass, which is the defect it already records itself being caught for on `worksFor`, in the opposite direction. The enumeration is the part an implementer copies.)* See `src/data/schema.ts` |
|
||||
| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` **Mediation / Commercial arbitration / Mediation-arbitration (med-arb)** — *scoped 2026-08-28 on `claims-auditor`'s finding; this row instructed the unscoped class form "Mediation/Arbitration" that Q39 struck and that `schema.ts` deliberately does not follow. Family arbitration carries prescribed training and has its own NOT OFFERED row, so unscoped "Arbitration" is the struck universal in a field nobody reads. Do not widen these strings without a §4 row to widen them from* — `provider` → Person, `priceRange` once `/fees/` is real. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
|
||||
| `ProfessionalService` | Home | `areaServed` Toronto/Ontario, `serviceType` **Mediation / Commercial arbitration / Mediation-arbitration (med-arb)** — *scoped 2026-08-28 on `claims-auditor`'s finding; this row instructed the unscoped class form "Mediation/Arbitration" that Q39 struck and that `schema.ts` deliberately does not follow. Family arbitration carries prescribed training and has its own NOT OFFERED row, so unscoped "Arbitration" is the struck universal in a field nobody reads. Do not widen these strings without a §4 row to widen them from* — `provider` → Person, ⚠️ **`priceRange` DECLINED 2026-08-31 — this row said *"once `/fees/` is real"*, the page became real at step 9, the field went in, and it came out the same day.** Its own defence rejected a `min`/`max` over `FEES` because *"a range whose ends mean different units is a range that misinforms"* — and the ends it chose had different units too: the floor was the hourly rate, the ceiling a flat documents-only fee. The floor misinformed in the direction that matters, because the least anyone pays for the headline service is **$2,000**. **Nothing on the site states a price in machine-readable form**, and no `Offer` node either: every figure on `/fees/` is conditional on session length, party count or format, and schema.org's `Offer` models one price for one item. This row gates the field; it does not require it. **Never `LegalService`** — schema.org defines it as a business providing legal advice and *representation*, which asserts in machine-readable form exactly what D13 bars and §4 Forbidden calls out |
|
||||
| `Service` | **`/mediation/`, `/arbitration/`, `/med-arb/`** and each practice page | `serviceType`, `provider` → Person, `areaServed`. **The Person node travels in the same `@graph`** so `provider: {'@id'}` resolves in one document rather than relying on a crawler joining two — `homeGraph`'s reasoning, applied. `serviceType` is scoped where §4 scopes it: *Commercial arbitration*, never a bare "Arbitration". No `BreadcrumbList` on the three — one hop from the root, no visible breadcrumb, and this spec requires the markup to match the visible one |
|
||||
| `Article` | Each article | `headline`, `description`, `datePublished`, `dateModified`, `author` → Person, `image` |
|
||||
| `BreadcrumbList` | All nested pages | Matches visible breadcrumbs |
|
||||
|
||||
@@ -374,4 +374,4 @@ Plausible or Fathom, cookieless, no consent banner.
|
||||
- [x] **Works with JavaScript disabled** — replacing the `mailto:` degradation item; see deviation 3
|
||||
- [x] **Privacy policy matches the implementation** — and three of its statements are DERIVED rather than written, so they cannot drift: the collected-data list renders from `INTAKE_FIELDS`, the retention period from the handler's own figure, and the analytics paragraph from `ANALYTICS.installed`
|
||||
- [ ] **CloudFront `/api/*` behaviour created**, routing to the HTTP API origin §7 records. The form does not work without it
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and `RESPONSE_TIME` set. It throws at cold start on any missing one, deliberately
|
||||
- [ ] **Handler deployed**, replacing the hand-built `adr-intake-handler`, with **SIX** variables set: `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`. It throws at cold start on any missing one, deliberately. ⚠️ **This item said five while the handler required six.** `NO_RETAINER_NOTICE` became a `requireEnv` and reached no document, so an operator following the list would have deployed a function that throws on every invocation — 5xx from API Gateway, and every inquiry lost from the moment `/api/*` was wired. Found by `adversarial-reviewer`, 2026-08-31. **Two of the six must be verbatim from `src/data/site.ts`**, because both are published commitments: `RESPONSE_TIME` from `CONTACT.responseTime`, and `NO_RETAINER_NOTICE` from the constant of the same name — whose fourth clause (*"does not itself create a conflict check"*, required by `docs/01` §`/contact/`) a hand-typed copy in the handler had dropped
|
||||
|
||||
+41
-7
@@ -162,10 +162,31 @@ they are useful for debugging.
|
||||
| `AWS_REGION` | `AGENTS.md` §7 — Region |
|
||||
| `S3_BUCKET` | §7 — S3 bucket |
|
||||
| `CLOUDFRONT_DISTRIBUTION_ID` | §7 — CloudFront |
|
||||
| `INTAKE_ENDPOINT` | §7 — Intake API |
|
||||
| `BOOKING_URL` | *(empty — parked, R6)* |
|
||||
|
||||
The same four values fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||
⚠️ **`INTAKE_ENDPOINT` AND `BOOKING_URL` WERE ROWS HERE AND ARE GONE, 2026-08-31.**
|
||||
Build step 8 moved the intake form to the same-origin path `/api/intake`, after
|
||||
which nothing in the build read either one — and both deploy paths were still
|
||||
refusing to run without `INTAKE_ENDPOINT`. **The removal was made in the two
|
||||
scripts and not here**, so this table went on telling an operator to set a
|
||||
variable no guard checked and no build consumed. Found by
|
||||
`adversarial-reviewer` round 2, whose sharper point is about the evidence: the
|
||||
removal was justified with `git grep PUBLIC_INTAKE_ENDPOINT -- src/` — a
|
||||
**path-scoped** grep offered as proof of a repo-wide removal, and `git grep`
|
||||
additionally cannot see untracked files, which at that moment was most of the new
|
||||
work. That is `CLAUDE.md`'s *a sweep is a command, not a claim* and *sweep the
|
||||
vocabulary, not only the subject*, both at once.
|
||||
|
||||
⚠️ **AND ONE PLACE STILL CARRIES IT: `.env.example` sets
|
||||
`PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname, and
|
||||
`PUBLIC_BOOKING_URL`.** That falsifies a live claim in `src/data/intake.ts` —
|
||||
which said the endpoint id *"is not restated in the repo either"* — and the claim
|
||||
has been corrected there rather than left standing. **The file itself was not
|
||||
edited: this environment denies read access to it, and nothing in this repo may
|
||||
edit a file it cannot read.** It is in the batched list for Pouya: delete both
|
||||
lines, and `PUBLIC_ANALYTICS_*` with them, since `ANALYTICS` is a literal in
|
||||
`src/data/site.ts`.
|
||||
|
||||
The three values above fill the IAM policy's `BUCKET_NAME`, `ACCOUNT_ID` and
|
||||
`DISTRIBUTION_ID` placeholders. **They are deliberately not restated here** —
|
||||
§7 is the single source of truth for operational facts, and the copy that goes
|
||||
stale is always the one nobody re-reads. `scripts/aws-discover.sh` regenerates
|
||||
@@ -438,10 +459,23 @@ Then invalidate `/*`.
|
||||
this behaviour the form 404s.
|
||||
2. **Deploy `backend/intake/handler.mjs` + `backend/intake/fields.mjs`**,
|
||||
replacing the hand-built `adr-intake-handler` §7 records. It needs
|
||||
`INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`, `MAIL_FROM` and
|
||||
`RESPONSE_TIME` set; it throws at cold start on any missing one, on
|
||||
purpose. `RESPONSE_TIME` must be `CONTACT.responseTime` verbatim — it is
|
||||
a public commitment (§4, Q27) and must read identically on `/contact/`,
|
||||
**SIX** variables — `INTAKE_TABLE`, `SITE_ORIGIN`, `NOTIFY_TO`,
|
||||
`MAIL_FROM`, `RESPONSE_TIME` and `NO_RETAINER_NOTICE`; it throws at cold
|
||||
start on any missing one, on purpose. ⚠️ **This list said five until
|
||||
2026-08-31 and the handler required six.** `NO_RETAINER_NOTICE` became a
|
||||
`requireEnv` in the same change set and was added to no document, so an
|
||||
operator working this list would have set five, and the Lambda would
|
||||
have thrown on **every** invocation — API Gateway answering 5xx and every
|
||||
inquiry from counsel lost from the moment `/api/*` was wired. Loud in
|
||||
CloudWatch, silent to Pouya, who has no reason to submit his own form.
|
||||
Found by `adversarial-reviewer` round 2.
|
||||
**Two of the six must be verbatim from `src/data/site.ts`:**
|
||||
`RESPONSE_TIME` from `CONTACT.responseTime` and `NO_RETAINER_NOTICE`
|
||||
from `NO_RETAINER_NOTICE`. Both are published commitments — the first is
|
||||
§4/Q27's two-business-day response, the second is the no-retainer notice
|
||||
`docs/01` §`/contact/` requires, **including its fourth clause about
|
||||
not itself creating a conflict check**, which a hand-typed copy in the
|
||||
handler had dropped. `RESPONSE_TIME` must read identically on `/contact/`,
|
||||
in the confirmation email, and in the bio.
|
||||
3. **API Gateway throttling, 5 requests / 5 minutes per source IP**
|
||||
(`docs/05`). Not expressible in handler code, and it is carrying load
|
||||
|
||||
@@ -49,12 +49,20 @@ jobs:
|
||||
run: npm run build
|
||||
env:
|
||||
PUBLIC_SITE_URL: https://adr.smlcompany.ca
|
||||
PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||
PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
||||
# SUPERSEDED 2026-08-31 — do not copy these two lines. Build step 8
|
||||
# moved the intake form to the same-origin path /api/intake, after
|
||||
# which nothing in the build read either variable; both were removed
|
||||
# from the live workflow and from scripts/deploy-local.sh. Kept visible
|
||||
# rather than deleted because this whole file is a historical
|
||||
# alternative, and a silent edit to it would make it disagree with the
|
||||
# entry that recorded it.
|
||||
# PUBLIC_INTAKE_ENDPOINT: ${{ vars.INTAKE_ENDPOINT }}
|
||||
# PUBLIC_BOOKING_URL: ${{ vars.BOOKING_URL }}
|
||||
|
||||
# If adopting this: set AWS_DEPLOY_ROLE_ARN as a repository variable. The
|
||||
# rest — AWS_REGION, S3_BUCKET, CLOUDFRONT_DISTRIBUTION_ID, INTAKE_ENDPOINT and
|
||||
# BOOKING_URL — are recorded in docs/06-deployment.md.
|
||||
# rest — AWS_REGION, S3_BUCKET and CLOUDFRONT_DISTRIBUTION_ID — are recorded
|
||||
# in docs/06-deployment.md. (INTAKE_ENDPOINT and BOOKING_URL were listed here
|
||||
# and are no longer required by either deploy path; see above.)
|
||||
- name: Configure AWS credentials
|
||||
uses: aws-actions/configure-aws-credentials@v4
|
||||
with:
|
||||
|
||||
+47
-18
@@ -36,8 +36,12 @@
|
||||
# PUBLIC_BOOKING_URL went with it: `CONTACT.bookingUrl` is `null` in source while
|
||||
# R6 keeps booking parked, and nothing read that variable either.
|
||||
#
|
||||
# Credentials: use the scoped deploy user. AGENTS.md Q22 records that it does
|
||||
# NOT yet exist. NEVER run this as user/pouya — see AGENTS.md §10.
|
||||
# Credentials: use the scoped deploy user, `adr-sml-deploy`. AGENTS.md §7 records
|
||||
# it as PROVISIONED, with one inline policy verified by nine
|
||||
# simulate-principal-policy checks; Q22 closed on execution 2026-08-28.
|
||||
# (This comment said it "does NOT yet exist" for three days after it did —
|
||||
# found by `adversarial-reviewer` round 2.)
|
||||
# NEVER run this as user/pouya — see AGENTS.md §10.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
@@ -123,27 +127,52 @@ aws cloudfront create-invalidation \
|
||||
# deploy that succeeds while the form posts into a 404 is the failure the old
|
||||
# guard was reaching for and could not see.
|
||||
#
|
||||
# 404 means not routed. 403 means routed and REFUSED, which is the correct answer
|
||||
# to this request: the handler checks the Origin header and this curl sends none,
|
||||
# so it is rejected before any DynamoDB write or any email. That makes 403 a pass
|
||||
# and is why this probe is safe to run against production.
|
||||
# ⚠️ IT ASSERTS A POSITIVE, AND THE FIRST VERSION ASSERTED THE ABSENCE OF ONE
|
||||
# CODE. That version was `code=$(curl ... || echo 000)` and passed on anything
|
||||
# that was not literally 404. Two defects, both measured by
|
||||
# `adversarial-reviewer` round 2:
|
||||
#
|
||||
# - `curl -w '%{http_code}'` ALREADY prints 000 on a failed transfer, so
|
||||
# `|| echo 000` double-appended and $code became `000000` — the 000 arm was
|
||||
# unreachable and a connection failure reported success.
|
||||
# - If the /api/* behaviour is MISSING, the POST falls through to the S3
|
||||
# default behaviour and CloudFront answers 403 for a disallowed method —
|
||||
# indistinguishable from the handler's Origin refusal, which is the one
|
||||
# distinction the check exists to draw. It also passed on a real 501.
|
||||
#
|
||||
# So it now sends the correct Origin and asserts the answer it should get:
|
||||
# the handler validates, finds an empty submission, and redirects 303 to
|
||||
# /contact/could-not-send/. That happens BEFORE any DynamoDB write and before
|
||||
# any email, which is what makes the probe safe against production.
|
||||
echo "==> Intake route check"
|
||||
code=$(curl -sS -o /dev/null -w '%{http_code}' -X POST \
|
||||
--max-time 15 \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' \
|
||||
"https://adr.smlcompany.ca/api/intake" || echo 000)
|
||||
case "$code" in
|
||||
404|000)
|
||||
"https://adr.smlcompany.ca/api/intake")
|
||||
rc=$?
|
||||
location=$(curl -sS -o /dev/null -w '%{redirect_url}' -X POST \
|
||||
--max-time 15 \
|
||||
-H "Origin: https://adr.smlcompany.ca" \
|
||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
||||
--data 'deploy-route-probe=1' \
|
||||
"https://adr.smlcompany.ca/api/intake" 2>/dev/null || true)
|
||||
if [ "$rc" -ne 0 ]; then
|
||||
echo >&2
|
||||
echo "WARNING: POST /api/intake returned $code." >&2
|
||||
echo "The contact form posts there. 404 means the CloudFront /api/* behaviour" >&2
|
||||
echo "is missing; 000 means the request did not complete. The site is" >&2
|
||||
echo "deployed and the form is not wired — see docs/06's cutover checklist." >&2
|
||||
;;
|
||||
*)
|
||||
echo " POST /api/intake -> $code (routed; 403 is the Origin check refusing a probe)"
|
||||
;;
|
||||
esac
|
||||
echo "WARNING: the POST to /api/intake did not complete (curl exit $rc)." >&2
|
||||
echo "The contact form posts there. The site is deployed and the form is" >&2
|
||||
echo "unverified — see docs/06-deployment.md's cutover checklist." >&2
|
||||
elif [ "$code" = "303" ] && case "$location" in *"/contact/could-not-send/") true;; *) false;; esac; then
|
||||
echo " POST /api/intake -> 303 -> $location (routed, validating, rejecting an empty probe)"
|
||||
else
|
||||
echo >&2
|
||||
echo "WARNING: POST /api/intake returned $code (expected 303 to" >&2
|
||||
echo "/contact/could-not-send/); redirect was '${location:-none}'." >&2
|
||||
echo "404 means the CloudFront /api/* behaviour is missing. 403 can mean the" >&2
|
||||
echo "same thing — CloudFront rejecting a method the default behaviour does" >&2
|
||||
echo "not allow — or the handler refusing the Origin. Either way the form is" >&2
|
||||
echo "not verified working. See docs/06-deployment.md's cutover checklist." >&2
|
||||
fi
|
||||
|
||||
echo "==> Deployed to https://adr.smlcompany.ca ($(git rev-parse --short HEAD))"
|
||||
|
||||
+16
-26
@@ -66,29 +66,21 @@ const classes = ['btn', `btn-${variant}`, className];
|
||||
color: var(--text-inverse);
|
||||
}
|
||||
|
||||
/* ⚠️ THE THREE HOOKS BELOW EXIST BECAUSE THIS BUTTON SHIPPED INVISIBLE.
|
||||
Found 2026-08-31 by `adversarial-reviewer` on `/fees/`, measured in headless
|
||||
Chrome against `dist/`: `{"t":"How an engagement runs →","color":"rgb(26, 22,
|
||||
20)","bg":"rgb(26, 22, 20)","ratio":1}`. `.btn-ghost` sets `color:
|
||||
var(--text)` — ink — and a border of `--border`, which is ink at 10% alpha.
|
||||
On a `.section-inverse` ground both are the background colour. **Ratio
|
||||
1.00:1: a navigation link the same colour as the panel it sits on**, worse
|
||||
than the gold-on-cream 2.10:1 this project treats as unshippable.
|
||||
/* ⚠️ THESE HOOKS ARE WHY `.btn-ghost` IS LEGIBLE ON A DARK BAND. Its own
|
||||
colours are ink text on an ink-at-10%-alpha border, which on
|
||||
`.section-inverse` and `.section-accent` are the background twice over —
|
||||
`/fees/` shipped this at a measured 1.00:1.
|
||||
|
||||
⚠️ AND LIGHTHOUSE SCORED THAT PAGE ACCESSIBILITY 100. axe's
|
||||
`color-contrast` rule SKIPS a foreground that exactly equals its background
|
||||
as "unable to determine" — so the a11y category cannot be the only contrast
|
||||
control here, and a computed-contrast sweep is not redundant with it.
|
||||
THEY ARE CUSTOM PROPERTIES AND MUST STAY THAT WAY. A parent cannot style a
|
||||
child component's root (CLAUDE.md), and a `global.css` descendant rule would
|
||||
tie at specificity (0,2,0) with `.btn-ghost[data-astro-cid]` here, so the
|
||||
winner would depend on injection order. Custom properties inherit, which is
|
||||
the one mechanism that crosses the boundary. `global.css` sets them; the
|
||||
fallbacks keep the on-cream appearance identical.
|
||||
|
||||
THE HOOKS ARE CUSTOM PROPERTIES, NOT A GLOBAL DESCENDANT RULE, and that is
|
||||
the load-bearing part. A parent cannot style a child component's root
|
||||
(CLAUDE.md), and `global.css`'s `.section-inverse .btn-ghost` would compile
|
||||
at specificity (0,2,0) — identical to `.btn-ghost[data-astro-cid]` here — so
|
||||
which one won would depend on injection order. `AGENTS.md` records that
|
||||
exact trap being hit once already, on `.btn-gold`. Custom properties
|
||||
INHERIT, which is the one mechanism that legitimately crosses the boundary;
|
||||
it is what `Pill` and `DefinitionGrid` already use. The fallbacks keep the
|
||||
on-cream appearance byte-identical. */
|
||||
Do not rely on the accessibility category to catch a regression here: axe
|
||||
SKIPS a foreground identical to its background as "unable to determine", and
|
||||
scored that page 100. AGENTS.md entry (ah) has the measurements. */
|
||||
.btn-ghost {
|
||||
background: transparent;
|
||||
border-color: var(--btn-ghost-border, var(--border));
|
||||
@@ -99,11 +91,9 @@ const classes = ['btn', `btn-${variant}`, className];
|
||||
color: var(--btn-ghost-fg-hover, var(--accent));
|
||||
}
|
||||
|
||||
/* `background: var(--bg-inverse)` is ink, so on an inverse ground the pill has
|
||||
no boundary and reads as bare text — the milder half of the same finding.
|
||||
The label is gold-l at 11.09:1 on ink and stays legible, so this needs an
|
||||
EDGE rather than a new colour scheme: giving it a different ground would be
|
||||
redesigning a button shipped at step 5 rather than fixing a defect. */
|
||||
/* `background: var(--bg-inverse)` is ink, so on an inverse ground this pill has
|
||||
no boundary and reads as bare text. It needs an EDGE, not a new ground — the
|
||||
gold-l label already measures 11.09:1 on ink. */
|
||||
.btn-gold {
|
||||
background: var(--bg-inverse);
|
||||
border-color: var(--btn-gold-border, transparent);
|
||||
|
||||
@@ -150,19 +150,11 @@
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-meta);
|
||||
}
|
||||
/* ⚠️ NO TABLE RULES, AND THE THREE THAT WERE HERE ARE DELETED RATHER THAN
|
||||
FIXED. They set `display: block; overflow-x: auto` on the `<table>` itself,
|
||||
which has two defects: `display: block` **removes the table role** in
|
||||
Chromium and WebKit, so rows and cells lose their semantics for assistive
|
||||
technology; and an `overflow-x: auto` box with no `tabindex="0"` cannot be
|
||||
scrolled by keyboard (WCAG 2.1.1). The comment said *"the wrapper carries
|
||||
it"* — there was no wrapper; the properties were on the table.
|
||||
|
||||
Doing it properly means a real wrapper with `tabindex="0"`, `role="region"`
|
||||
and an accessible name, which in MDX means a rehype plugin or a `<Table>`
|
||||
component. **None of the five drafted articles contains a table**, and with
|
||||
all five `draft: true` no article page builds, so this CSS shipped nowhere:
|
||||
deleting it now and adding it with the first article that needs one is the
|
||||
same decision the `code` note above already takes for `<pre>`.
|
||||
Found by `adversarial-reviewer`, 2026-08-31, and correctly filed as latent. */
|
||||
/* ⚠️ NO TABLE RULES, DELIBERATELY. Do not re-add `display: block;
|
||||
overflow-x: auto` to the `<table>` itself: `display: block` removes the
|
||||
table role in Chromium and WebKit, and an `overflow-x` box with no
|
||||
`tabindex="0"` cannot be scrolled by keyboard (WCAG 2.1.1). A table needs a
|
||||
real wrapper with `tabindex="0"`, `role="region"` and a name — in MDX that
|
||||
means a rehype plugin or a `<Table>` component. No article uses one yet, so
|
||||
it arrives with the first that does, exactly as `<pre>` does above. */
|
||||
</style>
|
||||
|
||||
@@ -139,11 +139,24 @@ const ogImageUrl =
|
||||
)
|
||||
: new URL(ogCardPath(path), Astro.site);
|
||||
|
||||
// A typographic card's alt is its headline, which for every card in the
|
||||
// registry is the page's own <h1> — and `title` is the string already required
|
||||
// to be unique per page. The portrait keeps the person's name.
|
||||
/**
|
||||
* ⚠️ THE ALT IS THE CARD'S HEADLINE, AND IT WAS THE PAGE `<title>`.
|
||||
*
|
||||
* The comment here claimed *"a typographic card's alt is its headline"* while
|
||||
* the code fell back to `title`. Measured: `/fees/` emitted
|
||||
* `og:image:alt="Fees · Mediation and Arbitration Rates · Pouya Lajevardi"`
|
||||
* against a card reading *"Published in full, including what overruns cost."* —
|
||||
* an alt that did not describe the image, on 20 pages, and it would have
|
||||
* diverged further for the one article that sets `seoTitle`. Found by
|
||||
* `adversarial-reviewer` round 2.
|
||||
*
|
||||
* `OG_CARDS[path]?.headline` is the card's actual text for a registry page.
|
||||
* `title` remains the fallback for an article, where the card headline IS the
|
||||
* title, and `PORTRAIT.alt` for the two portrait pages.
|
||||
*/
|
||||
const resolvedImageAlt =
|
||||
imageAlt ?? (image || usesPortrait ? PORTRAIT.alt : title);
|
||||
imageAlt ??
|
||||
(image || usesPortrait ? PORTRAIT.alt : (OG_CARDS[path]?.headline ?? title));
|
||||
|
||||
// JSON.stringify does not escape `<`, so a "</script>" inside any string value
|
||||
// would close this element early and hand the rest of the payload to the HTML
|
||||
|
||||
+8
-2
@@ -209,8 +209,14 @@ export const HONEYPOT_FIELD = 'company_website';
|
||||
* XHR, so it is exempt from preflight. `docs/05`'s CORS line protects the
|
||||
* endpoint against scripted calls from other origins, which is a different
|
||||
* control, and the handler's `Origin` check is what covers the form.)
|
||||
* 3. **The endpoint id stays out of the HTML**, so it is not restated in the
|
||||
* repo either — §7 remains the only place it lives.
|
||||
* 3. **The endpoint id stays out of the HTML.** ⚠️ It is NOT true that §7 is
|
||||
* the only place it lives, and this bullet said so: `.env.example` still
|
||||
* sets `PUBLIC_INTAKE_ENDPOINT` to the full execute-api hostname. That
|
||||
* variable is now read by nothing, so the line is dead as well as
|
||||
* duplicative. It is not edited here because this environment denies read
|
||||
* access to `.env.example`, and nothing may edit a file it cannot read —
|
||||
* it is in the batched list for Pouya instead. Found by
|
||||
* `adversarial-reviewer` round 2, against an unscoped sweep.
|
||||
* 4. **Submitting locally does nothing.** `astro dev` has no `/api/` route, so
|
||||
* a POST 404s. Under the alternative, clicking Submit on a laptop would
|
||||
* write a real DynamoDB record and send two real emails.
|
||||
|
||||
+15
-29
@@ -223,33 +223,13 @@ export function professionalServiceNode(imageUrl?: string) {
|
||||
],
|
||||
email: `mailto:${CONTACT.email}`,
|
||||
/**
|
||||
* ⚠️ **NO `priceRange`, AND IT WAS SET FOR AN HOUR AT BUILD STEP 9.**
|
||||
* `docs/04` gates the field on `/fees/` being real, and `/fees/` is now real
|
||||
* — so the gate was met and the field went in as `$500–$9,500`. It is out
|
||||
* again, because its own justification did not survive its own test.
|
||||
*
|
||||
* The comment defending it rejected a `Math.min`/`Math.max` over `FEES` on
|
||||
* the ground that it *"would sweep in `additionalParty` and
|
||||
* `overtimePerHour`, which are per-party and per-hour increments rather than
|
||||
* prices for anything, and a range whose ends mean different units is a
|
||||
* range that misinforms."* **The ends it chose had different units too:** the
|
||||
* floor was `FEES.hourly`, $500 **per hour**, and the ceiling
|
||||
* `documentsOnlyComplex`, $9,500 **flat**.
|
||||
*
|
||||
* And the floor misinformed in the direction that matters. The lowest amount
|
||||
* anyone pays for the headline service is `halfDay.amount` — **$2,000**. A
|
||||
* reader or crawler taking `priceRange` as what this practice costs read a
|
||||
* floor a quarter of the real entry price, in the one machine-readable field
|
||||
* on the site carrying a number. Found by `adversarial-reviewer`, 2026-08-31.
|
||||
*
|
||||
* **Omitted rather than repaired**, and that is the narrower answer: `docs/04`
|
||||
* gates the field, it does not require it, and `/fees/` publishes the
|
||||
* conditions — session length, party count, format — that make any single
|
||||
* range meaningless. A field that needs a paragraph to not mislead is worse
|
||||
* than no field. `/fees/` is one click away and says it properly.
|
||||
* (`Intl.NumberFormat('en-CA', { currency: 'CAD' })` also emits a bare `$`,
|
||||
* which would have needed `CA$` to be unambiguous — a second reason the
|
||||
* shape was wrong rather than the value.)
|
||||
* ⚠️ **NO `priceRange`, AND DO NOT ADD ONE.** `docs/04` gates the field on
|
||||
* `/fees/` existing; the gate is met and the field is still declined. Any
|
||||
* single range here mixes units — the hourly rate against a flat
|
||||
* documents-only fee — and its floor understates a mediation, whose least
|
||||
* cost is `halfDay.amount`. `/fees/` publishes the conditions that make one
|
||||
* number misleading. No `Offer` node either, for the same reason.
|
||||
* AGENTS.md entry (ah) records what the field said when it briefly shipped.
|
||||
*/
|
||||
...(imageUrl ? { image: imageUrl } : {}),
|
||||
};
|
||||
@@ -312,8 +292,14 @@ export function aboutGraph(imageUrl?: string) {
|
||||
* emitting one would assert navigation the page does not show. Breadcrumbs
|
||||
* begin at `/practice/<area>/` and `/insights/<slug>/`.
|
||||
*
|
||||
* NO `offers` AND NO `priceRange` until `/fees/` exists (build step 9) — same
|
||||
* gate docs/04 puts on `ProfessionalService`.
|
||||
* NO `offers` AND NO `priceRange` — A DECISION, NOT A GATE. This read "until
|
||||
* `/fees/` exists (build step 9)", which shipped, so it had become an
|
||||
* instruction to add them — against the decision recorded on
|
||||
* `professionalServiceNode` above, where `priceRange` went in at step 9 and came
|
||||
* out the same day. `/fees/` publishes the conditions — session length, party
|
||||
* count, format — that make any single machine-readable figure misleading, and
|
||||
* schema.org's `Offer` models one price for one item. Found by
|
||||
* `adversarial-reviewer` round 2.
|
||||
*
|
||||
* NO `availableLanguage` EITHER, AND THAT IS NOT AN OVERSIGHT. schema.org's
|
||||
* `domainIncludes` for it is `ContactPoint`, `Course`, `LodgingBusiness`,
|
||||
|
||||
+7
-2
@@ -158,8 +158,13 @@ function loadAssets(): Promise<Assets> {
|
||||
* shrink text to fit and silently overflows its container instead, so a card
|
||||
* with a long headline would crop — the exact class of defect nobody on this
|
||||
* project would ever see. The bands are set so the longest entry in
|
||||
* `og-cards.ts` renders on three lines at most; `npm run og:proof` renders every
|
||||
* card to `dist/og-proof/` so the claim is checkable by looking.
|
||||
* `og-cards.ts` renders on three lines at most. **`npm run og:proof -- --sheet`
|
||||
* writes a contact sheet of every card to `dist/og-proof.jpg`**, which is how
|
||||
* that claim is checked by looking — and the `--sheet` flag is required, because
|
||||
* a plain `npm run og:proof` produces no images at all. *(This sentence named
|
||||
* `dist/og-proof/` and omitted the flag, so the one documented mitigation for
|
||||
* this file's own stated hazard was wrong in both the path and the command.
|
||||
* Found by `adversarial-reviewer` round 2.)*
|
||||
*/
|
||||
function headlineSize(headline: string): number {
|
||||
if (headline.length > 62) return 58;
|
||||
|
||||
+13
-31
@@ -260,27 +260,14 @@ const hintId = (name: string) => `${name}-hint`;
|
||||
</div>
|
||||
|
||||
{
|
||||
/* ⚠️ THE LINK CAME OUT OF THE LABEL, AND THE LABEL IS NOW THE CONSENT
|
||||
SENTENCE ALONE. Two defects in one element, found by
|
||||
`adversarial-reviewer` 2026-08-31 and measured at 390px (the label
|
||||
was 342 × 205 px and the nested anchor hit-tested as `<a>`,
|
||||
100 × 21):
|
||||
|
||||
1. **A focusable interactive element inside a `<label>` for another
|
||||
control.** Clicking it navigated rather than toggling, which is
|
||||
the behaviour a reader wants — but label/link nesting is not
|
||||
consistent across engines, so which of the two wins was left to
|
||||
the browser.
|
||||
2. **The checkbox's accessible name was a 250-character paragraph
|
||||
ending "Privacy policy."** This is the one REQUIRED control on the
|
||||
form, so it is also the one whose name is re-announced on every
|
||||
validation failure.
|
||||
|
||||
The consent wording still has to be what the inquirer agrees to, so
|
||||
it stays in the label verbatim from `CONSENT_TEXT`. The link moves to
|
||||
a sibling that `aria-describedby` points at — described, not named.
|
||||
The privacy policy is also linked twice above this form, so nothing
|
||||
is lost. */
|
||||
/* ⚠️ THE PRIVACY LINK MUST STAY OUT OF THIS LABEL. Two reasons, both
|
||||
about the one REQUIRED control on the form: a focusable element
|
||||
inside a `<label>` for another control behaves inconsistently across
|
||||
engines, and the checkbox's accessible name becomes the whole
|
||||
paragraph plus "Privacy policy link" — re-announced on every
|
||||
validation failure. The consent wording itself must be verbatim from
|
||||
`CONSENT_TEXT`, so it stays in the label; the link is DESCRIBED
|
||||
instead, via `aria-describedby`. */
|
||||
}
|
||||
<div class="field field-consent">
|
||||
<label class="consent">
|
||||
@@ -461,16 +448,11 @@ const hintId = (name: string) => `${name}-hint`;
|
||||
line-height: var(--leading-body);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
/* ⚠️ 44px MINIMUM, AND IT WAS 25.6px. `docs/02` §Accessibility floor sets
|
||||
44 × 44 for a touch target and `CLAUDE.md` calls that floor a build
|
||||
requirement, not a polish pass. Hit-tested at 390px by
|
||||
`adversarial-reviewer`: the label rect measured 70.6 × **25.6** and the hit
|
||||
height 25px — an 18.4px control plus one line of body text, with no
|
||||
`::after { inset: 0 }` overlay to enlarge it the way the cards on `/` have.
|
||||
WCAG 2.2 SC 2.5.8's 24px was met; this project's own floor was not, and
|
||||
`docs/02` grants no exception for a form control.
|
||||
`min-block-size` rather than padding, so the label grows to the floor and no
|
||||
further — padding would push the two radios apart at every width. */
|
||||
/* 44px IS THE FLOOR (`docs/02` §Accessibility floor) and this row was 25.6px:
|
||||
an 18.4px control plus one line of body text, with no `::after { inset: 0 }`
|
||||
overlay to enlarge it. `min-block-size` rather than padding, so the label
|
||||
grows to the floor and no further — padding would push the two radios apart
|
||||
at every width. */
|
||||
.radio {
|
||||
align-items: center;
|
||||
min-block-size: 44px;
|
||||
|
||||
@@ -72,7 +72,14 @@ const ldImage = await getImage({
|
||||
models a single price for a single item, and every row below is conditional on
|
||||
session length, party count and format — a machine-readable $2,000 with none
|
||||
of those conditions attached is a worse claim than no claim.
|
||||
`ProfessionalService.priceRange` on `/` carries the range instead. */
|
||||
⚠️ AND `/`'s NODE CARRIES NO PRICE EITHER — this comment said
|
||||
`ProfessionalService.priceRange` "carries the range instead", and that field
|
||||
was removed the same day for mixing units and understating its own floor.
|
||||
Nothing on this site states a price in machine-readable form, deliberately:
|
||||
every figure here is conditional on session length, party count or format,
|
||||
and a number without those conditions is a worse claim than no number.
|
||||
Found by `adversarial-reviewer` round 2 — a justification resting on a field
|
||||
that no longer exists is how an `Offer` node gets added by the next reader. */
|
||||
const graph = pageGraph(new URL(ldImage.src, Astro.site).href);
|
||||
|
||||
const money = (amount: number) =>
|
||||
|
||||
@@ -219,10 +219,27 @@ const COLLECTED = INTAKE_FIELDS.map((field) => field.label);
|
||||
</p>
|
||||
|
||||
<h2>Who can see it</h2>
|
||||
{
|
||||
/* ⚠️ THIS SAID "Nobody else has access" AND THE SECTION TWO ABOVE HAD
|
||||
JUST NAMED GOOGLE. The Google correction was applied to §Where it is
|
||||
stored and not swept into the section actually headed with the
|
||||
question a reader asks — so the page answered "who can see the names
|
||||
of the opposing parties I gave you?" with *nobody else* under that
|
||||
heading and *Google* under a different one. Fixing one section and
|
||||
not the section that answers the same question is the sweep failure
|
||||
`CLAUDE.md` describes. Found by `adversarial-reviewer` round 2. */
|
||||
}
|
||||
<p>
|
||||
I can. The table is reachable by the function that writes to it and by
|
||||
one administrative account, which is mine. Nobody else has access, and
|
||||
there is no team, no assistant and no external administrator.
|
||||
one administrative account, which is mine — nobody else has access to
|
||||
the table. There is no team, no assistant and no external
|
||||
administrator.
|
||||
</p>
|
||||
<p>
|
||||
The one other place a copy exists is the notification email, which
|
||||
sits in the Google Workspace mailbox named above. So the honest answer
|
||||
to "who can see this" is: me, and Google as the company that runs my
|
||||
mail.
|
||||
</p>
|
||||
|
||||
<h2>Cookies and analytics</h2>
|
||||
|
||||
+5
-12
@@ -424,18 +424,11 @@ a:hover {
|
||||
.section-accent {
|
||||
--pill-border: var(--line-dark);
|
||||
--pill-fg: var(--text-inverse-2);
|
||||
/* ⚠️ `Button` — AND THIS IS THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule
|
||||
deliberately excludes `.btn`, on the reasoning that a button carries its own
|
||||
colours. It does — and `.btn-ghost`'s are ink text on a 10%-alpha ink border,
|
||||
which on these two grounds is the background colour twice over. `/fees/`
|
||||
shipped "How an engagement runs →" at a measured **1.00:1**, invisible, and
|
||||
Lighthouse scored that page accessibility 100 because axe skips a
|
||||
foreground identical to its background. Cream on ink is 16.81:1 and on
|
||||
maroon 12.29:1; `--line-dark` is cream at 14% alpha, which reads as an edge
|
||||
on both. `.btn-gold` needs only an edge — its gold-l label already measures
|
||||
11.09:1 on ink and 8.11:1 on maroon. Found by `adversarial-reviewer`,
|
||||
2026-08-31; see `Button.astro` for why these are custom properties and not
|
||||
a descendant rule. */
|
||||
/* `Button` — THE GAP `a:not(.btn)` ABOVE LEFT OPEN. That rule excludes `.btn`
|
||||
on the reasoning that a button carries its own colours; `.btn-ghost`'s are
|
||||
ink on an ink-alpha border, i.e. the background of both these grounds.
|
||||
`--line-dark` is cream at 14% alpha and reads as an edge on ink and on
|
||||
maroon. See `Button.astro` for why these are custom properties. */
|
||||
--btn-ghost-fg: var(--text-inverse);
|
||||
--btn-ghost-border: var(--line-dark);
|
||||
--btn-ghost-fg-hover: var(--text-inverse-2);
|
||||
|
||||
Reference in New Issue
Block a user