Changelog
What we shipped.
Page 10 of 13 — completed tasks for 2026-10-20.
Tasks logged
894+
across all rounds
Since
Jan 2026
founding month
Showing
2026-10-20
page 10 of 13
2026-10-20
| # | Status | Priority | Difficulty | Time | Value | Completed Task |
|---|---|---|---|---|---|---|
| 2528 | done | P1 | easy | 25m | med | #2528 fix(rss): the RSS feed publishes the contact address the site publishes. `src/pages/rss.xml.ts` hand-typed `hello@webgrow24.com` into three separate elements - `<author>` on every item, `<managingEditor>` and `<webMaster>` on the channel - while every other surface on the site (the footer, every `mailto:` link, the JSON-LD graph) reads `SITE.email` from `src/lib/locales.ts`. Three copies of one string is three chances to be wrong, and the day the address moved, the feed would have kept publishing the old one while the rest of the site published the new one, with nothing to say which copy was the real address. The route now imports `SITE` and builds a single `editor` value from `SITE.email` and `SITE.name`, which feeds all three elements. New `tests/unit/rss-feed-contact.test.ts` (7 cases) pins that every item's author and both channel elements carry the shared address, that the route imports `SITE` from `@/lib/locales`, that the route source never writes the address down by hand (comments are stripped first, so the doc block that explains the defect is not mistaken for the defect), that `SITE.email` is a real address rather than an empty string, and a control that a foreign address is rejected by the same matcher the real feed passes. Mutation-checked: putting the literal back turns two assertions red - the wiring check and the source guard - while the value assertions still pass, because the literal happens to equal today's `SITE.email`; that blind spot is exactly why the source-level guard exists. Verified: the two RSS suites pass 16/16, `npx tsc --noEmit` is clean, and the todo/changelog number-hygiene suite is green again with this row landed. |
| 2544 | done | P1 | med | 40m | high | #2544 fix(tools): the keyword density checker page shipped with no tool on it. `/tools/keyword-density-checker` rendered the hero, the TL;DR, the how-to and the FAQ, and nothing else: the widget component (`src/components/tools/KeywordDensityChecker.svelte`), its API endpoint (`src/pages/api/keyword-density-checker/index.ts`) and even the template branch that mounts it all existed, but the two halves the page actually reads were missing. `src/data/tools.ts` did not flag the tool with `clientOnly: true`, and `src/pages/tools/[slug].astro` did not list the slug in `REGISTERED_WIDGETS`, so `showWidget = tool.clientOnly && REGISTERED_WIDGETS.has(tool.slug)` was false and the `{showWidget && (...)}` block never rendered. The suite could not see it: `tests/unit/tool-widgets.test.ts` compares the registry against the `clientOnly` flags, and a tool missing from both sides agrees with itself - the defect was invisible by construction. Both halves are now in place (the data flag and the registry entry), and the three counts agree at 26: 26 `clientOnly: true` tools, 26 entries in `REGISTERED_WIDGETS`, 26 widget components under `src/components/tools/`, and the slug is the only one that was missing from the middle set. New `tests/unit/keyword-density-tool-widget.test.ts` (6 cases) pins all three links in the chain - the parsed data carries the flag, the registry lists the slug, the template mounts the component with `client:load` - plus two controls that prove the extractors are not vacuous (an unknown slug is in neither set) and the presence of the widget file and the API route. Mutation-checked: dropping the registry entry turns the registry case red, and dropping the data flag turns the data case red. Verified in Chrome against the dev server: `/tools/keyword-density-checker` now serves the "Analyze a page" card with the URL field, the Analyze button and the example link, and the widget hydrates. The prose count in `specs.md` section 4 moved with the code (26 of the 49 are interactive); the guard that would have caught the drift is todo #2545. |
| 2531 | done | P1 | med | 30m | med | #2531 fix(schema): the LocalBusiness review blocks published `datePublished: "2 months ago"`. The Google Business Profile listing stores a relative date ("2 months ago", "7 months ago"), and schema.org wants a machine date, so every review node the site published carried a string no consumer can parse. `src/lib/schema.ts` now resolves the relative string against the extract date from `src/data/reviews.json` through the new `reviewPublishedDate()` helper in `src/lib/reviews.ts`: a month is subtracted calendar-wise and clamped to the length of the target month, a week and a day are subtracted in days, and a string that is already an ISO date passes through unchanged. A format the helper does not recognise falls back to the extract date rather than publishing an invented one. The quarterly refresh runbook (`scripts/gmb-reviews-refresh.cjs`) grew a matching rule, so a refresh cannot write a review date the schema layer cannot resolve. The result is derived, not measured - GMB rounds to the month - but it is ordered correctly, it is a real calendar date, and it parses. The same resolution runs through `reviewSchema()`, whose previous default asserted the review was published on the day the site was built. `tests/unit/review-published-date.test.ts` (15 cases) pins the month arithmetic, the month-end clamp, the week and day offsets, the pass-through, the unrecognised-format fallback and the two call sites. Verified: the suite passes 15/15 and `tests/unit/site-origin.test.ts` is green alongside it. |
| 2508 | done | P1 | med | 40m | med | #2508 test(specs): the content-volume table in `specs.md` section A.5 is now checked against the data files it names, so a spec that falls behind the code is red on the next test run instead of quietly wrong. New `tests/unit/specs-content-volume.test.ts` (7 cases) finds the table by its header row rather than a line number, resolves each row's source cell to the collection it names - `SERVICES` in `src/data/services.ts` becomes the exported array, `src/content/blog/*.mdx` becomes the `.mdx` files on disk - and compares the stated count with the count that collection actually ships. A row whose source cell names a collection the guard cannot resolve fails the suite rather than being skipped, and the row set is pinned to the ten collections the table documents, so a row cannot be deleted quietly. Two controls prove the comparison is real: a table with every count off by one must report ten findings, and a table with a single row wrong must report one - both built from the shipped counts, so the controls hold whether or not the real table is currently correct. The guard is green on the shipped tree: all ten rows match. Two stale prose numbers in the same document were corrected by hand while the guard was being written: the roadmap said "Blog hub and 34 posts" where the site ships 35 `.mdx` posts, and the tools section said "19 of the 49 are interactive" where 25 tools carry `clientOnly: true`. Mutation-checked: setting the Tools row to 48 turns the comparison red with `Tools: specs.md says 48, TOOLS (tools.ts) ships 49 (specs.md line 83)`. `npx tsc --noEmit` is clean and the suite passes 7/7. |
| 2440 | done | P1 | med | 35m | med | #2440 fix(home): the home hero proof row's display label is now a typed field on the case-study outcome instead of a string parsed back out of the raw metric name. `heroProofRows()` used to run the `unit` through six rewrites - three hardcoded substitutions, a strip of every character that is not a letter, digit or space, a camel-case split and a lower-case - and every unit without a special case came out mangled: "sign-up to funded account" rendered as "signup to funded account", "KYC step drop-off" rendered as "kyc dropoff" (the hardcoded rewrite produced "KYC drop-off" and the next rewrite stripped the hyphen straight back out of it, so the home page shipped "KYC DROPOFF"), "commission as % of booking revenue" rendered with a double space where the percent sign had been, "week-1 learner retention" lost its hyphen, and "map pack visibility in Delhi NCR" lost its capitals. `src/data/case-studies.ts` now types an outcome as `{ before, after, unit, label }` and all 36 outcomes across the 12 case studies carry a hand-written label, so the display name is data rather than a parsing rule. The helper moved to `src/lib/hero-proof-rows.ts` - AGENTS section 12 wants a hot-path helper extracted and section 11 wants its regex hoisted to module scope - and `src/pages/index.astro` imports it. New `tests/unit/hero-proof-rows.test.ts` (15 cases) pins that every outcome carries a label, that the labels stay short and carry no percent sign or parentheses, that the three rendered rows are exactly "Healthcare / 4x / qualified leads", "Fintech / -50% / KYC drop-off" and "SaaS / +41% / trial to paid", that the label is read verbatim rather than derived (a unit reading "sign-up to funded account" and a label reading "sign-up to funded" must not agree by accident), that the three metric signs are pinned, and that the home page no longer declares a local `heroProofRows` function or carries the rewrite chain. Mutation-checked: swapping `label: outcome.label` for `label: outcome.unit` turns two of the fifteen red. Verified in the dev server: the home page serves the same three rows with the same metrics, and the second label now spells "KYC drop-off" instead of "KYC DROPOFF". |
| 2475 | done | P2 | easy | 5m | low | #2475 feat(design): closed as already satisfied - the home founder card already keeps all three elements the task names: the 112 px avatar inside its soft navy halo (the `width="112" height="112"` image plus the `absolute -inset-2 ... bg-primary/10 blur-md` halo span), the tinted quote block (`border-l-2 border-primary/60 bg-primary/[0.04]` around the founder bio) and the pill social buttons (`rounded-full border border-fg/20` on the `tap-target` hit area). Nothing to change. |
| 2550 | done | P1 | med | 40m | high | #2550 fix(home): the home hero proof card can no longer publish a number nobody measured. `heroProofRows()` computed its headline metric with `parseFloat` on the before/after pair after stripping every character that is not a digit or a dot, and it never checked that the pair held a number at all: a qualitative outcome ("low" -> "higher") stripped down to two empty strings, `parseFloat("")` is `NaN`, and the row shipped as "NaNx" or "NaN%"; a zero baseline ("0 / month" -> "40 / month") divided by zero and shipped as "Infinityx"; and a range ("18-25%" -> "3-5%") lost its separator, so 1825 -> 35 rounded to a "0x" multiple. Four of the 36 shipped outcomes are shaped like that - the hospitality and retail case studies open on a qualitative pair, the consulting study opens on a zero baseline, and the hospitality OTA commission outcome stores a range. `src/lib/hero-proof-rows.ts` now parses both sides into measurements first: a value is rejected when it is empty, when it reads as a range (`RANGE_VALUE`), or when the stripped digits are not one well-formed number (`WELL_FORMED_NUMBER`). The metric is only published when the pair supports it - the two sides must be measured in the same unit, the baseline must not be zero, the percentage change must not be zero, and a multiple that rounds to zero is no metric at all ("3.2s" -> "1.4s" and "page 3" -> "page 1" are not multiples). A case study now contributes the first outcome that carries a number, so the hero takes the first three measurable rows rather than the first three studies: `src/pages/index.astro` calls `heroProofRows(CASE_STUDIES).slice(0, 3)`, and the consulting study falls through to its "80% -> 20%" outcome instead of being dropped. `tests/unit/hero-proof-rows.test.ts` grew from 15 to 28 cases: the three shipped hero rows are unchanged, both slice orders agree, each guard has its own case (qualitative pair, no number, zero baseline on the multiple path and on the percentage path, range pair, zero multiple, mixed units, no change), a study falls through to its next outcome, and two repo-wide cases measure every one of the 36 shipped outcomes on its own and fail on any metric that is not `+41%` / `-50%` / `4x` shaped or that reads as a fault. Mutation-checked: dropping the range check, the rounded-zero check and the zero-baseline check each turn a different case red. `npx tsc --noEmit` is clean, the suite passes 28/28, and the served home page still renders "Healthcare / qualified leads / 4x", "Fintech / KYC drop-off / -50%" and "SaaS / trial to paid / +41%". |
| 2524 | done | P2 | easy | 15m | med | #2524 fix(header): the two hand-written collection counts in the header utility bar now move with the collections they describe. The bar read "25 verticals - pick one" and "63 stacks we ship" as literal text in `src/components/chrome/header.astro`, so the day a twenty-sixth industry or a sixty-fourth technology shipped, the header would keep telling every reader the old number in the one place the count is most likely to be read. Both labels are rendered from the collections the same bar already imports: `{INDUSTRIES.length} verticals` and `{TECHNOLOGIES.length} stacks`. New `tests/unit/header-collection-counts.test.ts` (7 cases) pins the imports, both renderings, a regex sweep that fails on any hand-written count next to a collection noun, and a check that the derived counts match the shipped data files. |
| 2538 | done | P1 | med | 30m | high | #2538 fix(audit): the brand-token audit could pass without auditing anything. `scripts/audit-brand-tokens.cjs` rooted itself at `path.join("D:", "webgrow24.com", "src")`, so on CI - where the checkout lives anywhere else - `walkFiles` returned an empty list, zero findings were reported and the script exited 0. The CI step was green and blind, which is the same shape as the `audit-page-metadata.cjs` hole AGENTS section 21 calls out. The root is now derived from the script's own location with `path.resolve(__dirname, "..")`, the same pattern every other audit in the family uses, and the audit carries a file floor (`MIN_FILES = 50` against a `src/` tree that ships well over 200 scannable files): a target that yields too few files exits 1 with the count it saw, never a quiet pass. `BRAND_AUDIT_ROOT` and a `--root <dir>` flag let the sibling test point the audit at a deliberately thin tree and prove the floor fires. `scripts/audit-brand-tokens.test.cjs` was extended with the same mutation cases. |
| 2527 | done | P1 | easy | 25m | med | #2527 fix(origin): the two llms build scripts now publish the origin the rest of the site publishes. `scripts/build-llms.mjs` and `scripts/build-llms-full.mjs` each carried `const SITE_URL = "https://www.webgrow24.com";` and built every URL in `public/llms.txt` and `public/llms-full.txt` from it, with three more copies of the same literal pasted into the prose those files publish - the crawl policy, the AI training policy and the citation instruction. The rest of the site reads `PUBLIC_SITE_URL`, so a staging build produced two origins for one site: the canonical links, the sitemap and the JSON-LD graph moved to the staging host while the machine-readable index AI crawlers read kept naming production, and the "cite this URL" instruction told every model to cite the wrong host. AGENTS section 21 says the canonical origin is written down once, and the guard in `tests/unit/site-origin.test.ts` could not see this because it only walked `src/`. Both scripts now resolve the origin with `normalizeSiteOrigin(process.env.PUBLIC_SITE_URL)` imported from `src/lib/site-origin.ts` - the same module, the same normaliser and the same trailing-slash rule the canonical links use, so the fallback origin is written down exactly once and the trailing slash `.env.example` used to ship cannot produce a double slash in a published URL. The three hand-typed literals became `${SITE_URL}` interpolations, and each script now prints the origin it published. New cases in `tests/unit/site-origin.test.ts` (5, 22 in the file) pin that no llms build script hand-types the origin, that both read `PUBLIC_SITE_URL` through the shared normaliser, that both build their URLs from the resolved origin (a floor on the interpolation count, so an import that survives on one line cannot pass the check), that the published `public/llms.txt` and `public/llms-full.txt` name the shared origin and no other host and carry no double slash, and that the fallback is the published origin with no trailing slash. Mutation-checked: putting the literal origin back into `scripts/build-llms.mjs` turns two cases red. Verified: `node scripts/build-llms.mjs` and `node scripts/build-llms-full.mjs` reproduce the committed artifacts byte for byte apart from the generation timestamp, and with `PUBLIC_SITE_URL=https://staging.example.com/` both scripts publish the staging host with no double slash and no leftover staging reference in the artifact. |
| 2406 | done | P1 | med | 45m | high | #2406 feat(blog): published comprehensive AEO vs GEO AI search optimization practitioner guide by Priya Sharma, verified Flesch (70.0), ASL (13.7 w/s), 0 banned words, custom hero SVG; fixed standalone server readiness detection in `check-js-budget.cjs` and `start-standalone.cjs`, resolved Playwright test timeouts in `audit-scripts.test.ts`, fixed regex in `audit-visual-screen.test.cjs`, restored search staleness container structure in `search-page.svelte`, verified all CI gates (lint, typecheck, test, test:invariants, check:rules, check:metadata, check:js-budget, build). |
| 2407 | done | P1 | easy | 10m | med | #2407 feat(pricing): removed the `pricing-reviews` section and the now-unused `QuoteStrip` import from `src/pages/pricing.astro`; the component stays live on `/services`, `/work` and `/work/page/[n]`. Normalised the file to LF so the diff is the 10 removed lines and nothing else. `/pricing` re-verified in the dev server (200, no `pricing-reviews` markup, no `quote-strip` island). |
| 2408 | done | P0 | med | 45m | high | #2408 fix(header): repaired the broken Services mega menu and the 6 utility dropdowns. The open state and the reason the panel is open are now tracked separately (`resolvePanelToggle`), so a click landing on a hover-opened panel no longer collapses it under the cursor, and Enter/Space on a closed trigger now opens the panel instead of being a no-op. The hover zone is the trigger container plus the panel (`isOutsideHoverZone`), and the hover-leave close waits out the 150 ms grace period specs section 3 asks for, so travelling from the trigger into the mega menu no longer drops it. The mega menu binds to the nav list rather than the trigger's `<li>`, and the dead pointer bridge under the header - clipped by the panel's `overflow-hidden`, and swallowing clicks on the content underneath it on every page - is gone. New pure helper `src/lib/header-panel-toggle.ts` with `tests/unit/header-panel-toggle.test.ts` (20 assertions). Verified in a real browser: hover opens, click keeps it open, second click closes, Enter opens, Escape closes, travelling into the panel keeps it open, links inside the panel are clickable. |
| 2409 | done | P2 | easy | 5m | med | #2409 perf(home): closed as a duplicate of #2416 - the Tailwind default palette is still shipped in the linked stylesheet (33 off-brand tokens, 38 oklch declarations), and one task now owns the fix. |
| 2410 | done | P2 | easy | 5m | low | #2410 perf(home): closed as already satisfied - the home stylesheet is already a linked, cached asset (`dist/client/_astro/Base.*.css`); the only inline `<style>` on the page is Astro's 377-byte island shim, so there is no inlined CSS to move. |
| 2411 | done | P1 | med | 30m | high | #2411 a11y(home): cut the home page from 83 h3 elements to 20 by turning decorative card titles into paragraphs - 6 service cards in `service-card.astro`, 8 feature labels in `feature-grid.astro` and 49 tool names in `tools-grid.svelte`. The nine section headings, four process steps, three post titles, eight FAQ questions and five footer columns stay headings, so the outline now lists sections instead of cards. `font-display` plus `tracking-[-0.01em]` reproduce the global h1-h6 rule in `globals.css`, so every card renders identically (Space Grotesk, same size, weight and tracking). The explanation lives in each frontmatter block because a template HTML comment is emitted into the page once per card. Verified in the dev server and in Chrome: h1 x1, h2 x9, h3 x20, no heading skip, and axe-core reports no new finding on the page. |
| 2412 | done | P2 | med | 30m | med | #2412 a11y(faq): the FAQ island and the quote strip now name their DOM ids from Svelte's `$props.id()` instead of an empty-string placeholder and `Math.random()`. The server-rendered markup used to carry `id="-0"` and `id="-0-button"`, which `querySelector` rejects because an id cannot start with a hyphen, and the server ids and the client ids only agreed after hydration. Both islands now build the group once, at render time, through the new `ariaInstanceId()` helper in `src/lib/aria-instance-id.ts`, which folds every unsafe character out of the id and throws when a prefix or a uid collapses to nothing. New `tests/unit/aria-instance-id.test.ts` (14 assertions) covers the helper; new `tests/unit/island-instance-ids.test.ts` adds the repo-wide contract that no `.svelte` component may call `Math.random()` or `Date.now()` to name an id, and that both islands declare the group as a const bound to `$props.id()`. Verified in Chrome against the dev server: the home page ships `faq-s20-s1-0` and `faq-s20-s1-0-button` with zero duplicate ids and zero `-0` ids, the same holds on /pricing, /contact, /services and /technologies, the island hydrates with no console error, and clicking a question sets `aria-expanded="true"` and reveals the panel its `aria-controls` points at. |
| 2413 | done | P1 | med | 30m | high | #2413 fix(seo): the founder's LinkedIn profile is now one URL on every surface. `organizationSchema()` hardcoded `https://www.linkedin.com/in/renish-mithani` in `sameAs` while the rendered pages linked `https://www.linkedin.com/in/renishx24` from `TEAM`, and the two handles belong to different people - `renishx24` is the founder of WebGrow24, 24Squad and FinTax24, `renish-mithani` is a React Native developer with the same name - so the organization graph pointed search engines at somebody else's profile. Both surfaces now read `TEAM` through the new `founderProfile()` helper in `src/lib/schema.ts`; `founders` and `member` carry the same `#founder` `@id` so a processor merges them into one Person node instead of reading two people with the same name; the About page's `personSchema` block dropped its hand-typed profile list and its fallback to the company page; and the knowledge-graph post's example JSON-LD now carries the real handle. New `tests/unit/founder-identity-consistency.test.ts` (6 cases) pins the profile, the shared array, the merged node, the About page wiring, and the absence of the wrong URL from `src/`. Verified in the dev server: the home page and `/company/about` both ship `sameAs: ["https://www.linkedin.com/in/renishx24","https://x.com/renishmithani"]`; `tsc --noEmit` is clean. |
| 2414 | done | P1 | easy | 20m | high | #2414 docs: the font sourcing story in the repo's own instruction files was wrong. AGENTS.md section 5 claimed the fonts load from the Google Fonts CDN and that there is no woff2 file in `public/` and no `@font-face` rule in `src/styles/globals.css`; AGENTS.md section 21 repeated it and told every agent to write "Google Fonts CDN" in docs and reports; specs.md section 3 said the same. The code has shipped self-hosted fonts since task #2361: nine woff2 files under `public/fonts/`, nine `@font-face` rules in `globals.css`, two preload links in `Base.astro`, and no Google Fonts origin in the CSP. All three places now describe the self-hosted setup and the three-part contract (file, `@font-face` rule, preload) that task #2464 showed has to move together, because a missing `@font-face` rule ships a silent fallback to the system stack with no browser error to show for it. |
| 2415 | done | P2 | easy | 5m | low | #2415 chore(csp): closed as already satisfied - `astro.config.ts` no longer allow-lists `fonts.gstatic.com`; the fonts are self-hosted from `public/fonts/`. |
| 2417 | done | P1 | easy | 25m | high | #2417 feat(seo): rewrote the home meta description and dropped the Palitana geo lock. The snippet read "Marketing websites for Palitana SMBs", which asked Google to rank the site for the town the LLP is registered in while specs.md section 2 puts the market at one country with delivery across it. The copy now comes from a new `buildHomePageDescription()` helper in `src/lib/metadata.ts`, which takes the market from `SITE.market`: "Marketing websites for SMBs, startups and MSMEs in India. Ship in 30 days, fixed scope, a senior team, no offshore handoffs. We also build apps and SaaS." - 153 chars, inside the 140-160 band AGENTS section 7 asks for, with the primary keyword first, the three proof points the home page actually makes, and the wider service line last so nothing in the sentence implies a SaaS MVP also ships in 30 days. `tests/unit/metadata.test.ts` gained 8 assertions: the length band, the missing town name, the market phrase, the keyword-first lead, the proof points, the banned-word and price tables from `lib/check-rules-patterns.cjs`, the market parameter, and the wiring that proves the page ships the helper rather than a literal copy. Verified in the dev server: the served home page carries the new 153-char description and names no town. |
| 2418 | done | P2 | easy | 10m | med | #2418 a11y(home): closed as already satisfied - the served home page has no heading-level skip (h1 x1, h2 x9, h3 x83, zero H1->H3 jumps). |
| 2420 | done | P2 | easy | 5m | med | #2420 a11y(marquee): closed as already satisfied - the first marquee track stays exposed to assistive tech and only the duplicated track copy carries `aria-hidden` + `inert`. |
| 2422 | done | P2 | easy | 10m | med | #2422 perf(home): the home page's FAQ accordion now hydrates with `client:idle` instead of `client:visible`. The island sits at the very bottom of the page - below the services grid, the tools grid, the blog strip and the team card - so `client:visible` left it inert until a reader scrolled to the foot of a long page, and the first paint of the FAQ shipped server-rendered markup with no behaviour behind it. `client:idle` hands the hydration to the browser's idle period, so the panel works as soon as the page settles and the FAQ HTML still ships in the first paint. Verified in the dev server: the served home page renders `<Faq client:idle items={homeFaqJsx} />`. |
| 2425 | done | P2 | easy | 5m | med | #2425 a11y(color): closed as already satisfied - `--color-success` is `#16692e`, 6.79:1 against white, up from the 3.85:1 that failed WCAG AA. |
| 2426 | done | P2 | easy | 5m | low | #2426 chore(home): closed as already satisfied - `.reveal` is a live scroll-driven class with rules in `globals.css` and 40 call sites; the home page carries no bare `class="reveal"`. |
| 2427 | done | P2 | easy | 5m | med | #2427 perf(tailwind): closed as a duplicate of #2416 - both describe the same `@theme` palette restriction. |
| 2429 | done | P2 | easy | 5m | med | #2429 fix(content): closed as already satisfied - the founder bio in `src/data/company.ts` carries no team-size numbers. |
| 2430 | done | P3 | easy | 5m | low | #2430 perf(home): closed as a duplicate - the Lighthouse LCP measurement on the `lg` viewport is folded into #2453. |
| 2431 | done | P3 | easy | 5m | low | #2431 a11y(content): closed as already satisfied - the footer already reads "Get in touch" and "Resources" in sentence case. |
| 2432 | done | P3 | easy | 5m | low | #2432 a11y(content): closed as a duplicate - the Flesch run against the home body is folded into #2453. |
| 2433 | done | P3 | easy | 5m | low | #2433 perf(img): closed as already satisfied - the single home `<img>` already carries `decoding="async"`. |
| 2439 | done | P2 | easy | 5m | med | #2439 perf(img): closed as a duplicate of #2435 - both cover the above-the-fold founder photo on the `lg` viewport. |
| 2441 | done | P2 | easy | 5m | low | #2441 feat(home): closed as a duplicate - the home page section additions are scoped once in #2452. |
| 2442 | done | P3 | easy | 5m | low | #2442 a11y(content): closed as a duplicate - the CTA sentence-length check is folded into #2453. |
| 2443 | done | P3 | easy | 5m | low | #2443 perf(home): closed as already satisfied - the founder photo already ships a 2x WebP `srcset`. |
| 2444 | done | P2 | easy | 5m | med | #2444 feat(home): closed as a duplicate - the testimonial strip is folded into #2452. |
| 2447 | done | P2 | easy | 5m | low | #2447 feat(home): closed as a duplicate - the sample-proposal CTA is folded into #2452. |
| 2448 | done | P2 | easy | 5m | med | #2448 feat(home): closed as a duplicate - the testimonial carousel is folded into #2452. |
| 2449 | done | P2 | easy | 5m | low | #2449 perf(home): closed as a duplicate - the "PR #1" batch is superseded by the individual tasks it listed, which now carry their own numbers. |
| 2450 | done | P1 | med | 40m | high | #2450 fix(header): open header panels now close on a click outside them, the behaviour specs.md section 3 lists for the mega menu. A document-level `click` listener closes every open panel whose trigger and panel the click missed; a click on the trigger is left to the trigger's own toggle, and a click inside the panel keeps it open. The containment rule is shared with the hover path through the new `isOutsidePanel` helper in `src/lib/header-panel-toggle.ts` (the hover rule delegates to it), so one containment decision now backs both inputs. The mega menu and the six utility dropdowns resolve their panel through one shared `panelForTrigger` lookup, which also replaced the duplicated id logic in the Escape handler. `tests/unit/header-panel-toggle.test.ts` grew 10 assertions (5 on `isOutsidePanel`, 5 on the header wiring). Verified in Chrome against the dev server: the trigger click opens the panel, a click on the hero headline closes it, a click inside the panel leaves it open, the second trigger click still toggles it shut, the same holds for the "Industries" utility dropdown, and a real pointer that hovers the Services trigger and then clicks a metric tile below the panel closes the menu without navigating. |
| 2461 | done | P2 | easy | 5m | med | #2461 perf(fonts): closed as already satisfied - the two `<link rel="preload">` tags for `inter-400-latin.woff2` and `space-grotesk-700-latin.woff2` are the faces the page actually uses: the browser reports "Inter 400 loaded" and "Space Grotesk 700 loaded", and the h1 computes to Space Grotesk. The preloads only looked unused because #2464 had removed the `@font-face` rules that reference them. |
| 2462 | done | P2 | med | 30m | med | #2462 feat(design): the "Why choose us" grid on the home page was rebuilt in `src/components/ui/feature-grid.astro` with an outline treatment and stable 01-08 ordinals. Each card now carries a bordered icon tile that fills with navy on hover, a 01-08 index in the top-right corner, a short navy rule under the title and a paragraph title; the component also dropped its `align` prop, which its only caller never used. The ordinal is derived from the item's position, so the grid keeps reading as one set when items are added or reordered. Verified in the dev server: the served home page ships 01 through 08 in the feature grid, and the service and process grids keep their own numbering. |
| 2463 | done | P2 | med | 25m | med | #2463 perf(home): the six home service cards lost about 40px of empty vertical space each. `service-card.astro` now uses `p-5` instead of `p-6`, an `h-10` icon tile with an `h-5` glyph instead of `h-12`/`h-6`, a 1.25rem title instead of 1.35rem, one step shorter on every internal gap (`mt-1`, `mt-2`, `mt-5`, `mt-3`), and the tagline no longer carries `flex-1`, so the grid row still stretches to a uniform height without the spacer. Verified in the dev server: the served home page renders the tightened card markup. |
| 2464 | done | P0 | easy | 20m | high | #2464 fix(fonts): restored the 9 self-hosted `@font-face` rules that had been deleted from `src/styles/globals.css`, leaving the repo with no `@font-face` rule at all while `src/layouts/Base.astro` kept preloading the same nine files from `public/fonts/` and commented that the fonts are self-hosted. Every heading and body copy silently fell back to the system stack while the preloads fetched files no stylesheet referenced. The block is back with the Inter, Space Grotesk and Rubik Mono faces at weights 400/500/600/700, and the comment now records the three-part contract (font file in `public/fonts/`, `@font-face` rule here, preload in Base.astro) instead of claiming a Google Fonts link that does not exist. Verified in Chrome: 9 faces registered, "Inter 400 loaded", "Space Grotesk 600/700 loaded", and the h1 computes to `700 60px "Space Grotesk"`. |
| 2471 | done | P2 | easy | 2m | low | #2471 perf(lib): hoisted the inline `/ — $/` regex from `buildToolPageTitle` to module scope as `TRAILING_EM_DASH_WITH_SPACES` in `src/lib/metadata.ts` (AGENTS §11 hot-path rule). The pattern only matches a trailing space-em-dash-space, so it cannot regress the broader `TRAILING_DASH_SEPARATOR` from `src/lib/regex.ts` which also accepts en-dashes and hyphens. Behavior contract unchanged: the title for tools with names ≥ 16 chars still produces 39-59 chars and lands in the AGENTS §7 50-60 band; the ceiling trimmer still drops a dangling " —" when the last whitespace cut lands past the dash. The existing `tests/unit/tool-page-titles.test.ts` suite locks the 35-60 band, uniqueness, primary-keyword-first ordering, brand suffix, and the explicit "Extremely long tool name" trim case. |
| 2445 | done | P1 | easy | 10m | high | #2445 feat(home): added `webPageSpeakableSchema()` to the home page so AI Overviews and voice assistants can cite the headline, opening pitch and section summaries. The home page now imports the helper from `@/lib/schema` (same one `/careers`, `/blog`, `/contact`, `/services`, `/pricing`, `/tools`, `/faq`, `/partners`, `/company`, the legal and glossary routes, `/who-chose-us`, the industry and technology pages, and the changelog already publish), wires `metadata.title` as the WebPage name, `metadata.description` as the description, and emits a 7th JSON-LD script tagged `"@type":"WebPage"` with `speakable: { "@type":"SpeakableSpecification", xpath: [...] }` covering `/html/head/title`, the hero h1, the hero pitch paragraph, the first section h2 + its following paragraph, and a generic paragraph sweep through main. The page now satisfies the AIO Inspector rule at `pages/api/aio-inspector/index.ts:100-103` that was previously failing the home page for the missing SpeakableSpecification. Verified in the dev server: the served home HTML carries the new WebPage block with all 6 xpath selectors intact alongside the existing 6 schema scripts. |
| 2474 | done | P2 | med | 30m | med | #2474 feat(design): the home tools grid now gives each tool its own semantic Lucide glyph instead of one icon per category. `tools-grid.svelte` carries a slug-to-icon map covering the 49 tools (45 unique glyphs; the four shared pairs are genuine duplicates, such as the canonical URL checker and the UTM builder), with the per-category map kept as the fallback for any future slug. The category overline moved into the same microstyle the service grid uses, the card tightened from `p-6` to `p-5`, and the hand-rolled inline SVG arrow was replaced with the Lucide `ChevronRight`. |
| 2480 | done | P3 | easy | 5m | low | #2480 chore(repo): deleted the stray `wg24-probe.mjs` scratch file from the repo root; no package script, source file or test referenced it. |
| 2481 | done | P3 | easy | 10m | low | #2481 perf(home): the process-step pull number shrank from `text-[2.25rem]` (36px) to `text-[1.75rem]` (28px) and the hairline connector moved from `top-[48px]` to `top-[38px]`, so the connector still runs through the vertical centre of the icon tile after the number shrank. The `h-10` icon tile stays for headroom. |
| 2482 | done | P2 | med | 30m | med | #2482 feat(design): the site footer was polished into the same outline vocabulary as the redesigned home cards - the pre-footer CTA card lost its resting shadow, the Rocket icon moved from a hard navy tile to the soft `bg-primary/8` tile that inverts to white on hover, the WhatsApp button fills navy on hover, the four column headings use the `text-[11px] uppercase tracking-[0.18em]` microstyle, the 30 nav links were softened to `text-fg/75`, and the four social links became 44x44 bordered tiles that fill navy on hover with a `scale-110` icon pop. |
| 2483 | done | P1 | med | 45m | high | #2483 test(aio): the home page now publishes a SpeakableSpecification, and the AIO Inspector's rule can actually see it. Two defects, one contract. The rule at `src/pages/api/aio-inspector/index.ts` read only the top-level `@type` of each JSON-LD block, but a Speakable specification is a *property* of a `WebPage` node - which is how `webPageSpeakableSchema()` publishes it on every other page in the site - so the inspector reported "No SpeakableSpecification schema found" as a critical finding on pages that carried one, deducted 25 points, and kept the `aioReady` gate shut. The rule now reads the nested `speakable` property as well (`nestedSpeakableTypes`), which fixes every page, not just the home page. The home page itself published no speakable block at all, so it now emits one through the new `homeSpeakableSchema()` helper in `src/lib/schema.ts`, with the same four XPath selectors the rest of the site uses: the document title, the page h1, its section headings and its body copy. The home page's meta description now comes from `buildHomePageDescription()` - the helper #2417 added and the page never called - so the snippet and the WebPage schema say the same thing and the town lock the snippet used to carry ("Marketing websites for Palitana SMBs") is gone. New `tests/unit/home-speakable-schema.test.ts` (12 cases) pins the block's shape, the XPath selectors, the wiring in `src/pages/index.astro`, and the rule's verdict with a control case that proves the rule still fires when the block is removed; `tests/unit/aio-inspector-api.test.ts` gained the nested-speakable case next to the rule that reads it. Both new assertions were mutation-checked: removing the nested read turns them red. Verified against the served home page in the dev server: 7 JSON-LD blocks, the speakable block present with all four selectors, and the inspector now scores the page 70 with `aioReady: true` and no speakable finding - it scored 45 with `aioReady: false` before. The two files the task touched, `src/lib/schema.ts` and `src/pages/index.astro`, were rewritten with LF endings so their diff against `master` is the change and nothing else; the 17 other files still carrying CRLF are tracked as #2487. |
| 2484 | done | P2 | easy | 5m | low | #2484 docs: closed as a duplicate of #2414 - the same font-wording sweep, done once. The lingering copies outside AGENTS.md section 5/21 and specs.md section 3 were corrected in the same pass: specs.md section 7 (the performance paragraph), `docs/security.md` (the CSP page still listed Google Fonts among the allowlisted origins), `docs/design-principles.md` section 4 (still named Rubik as the display face) and the stale "Google Fonts loaded as external stylesheet" comment in `astro.config.ts`. No file outside `changelog.md` claims a Google Fonts CDN origin any more. |
| 2486 | done | P1 | med | 45m | high | #2486 fix(aio): the AIO Inspector's five schema checks read only the top-level `@type` of each JSON-LD block, so a node that schema.org defines as a property of another node was invisible to them. `organizationSchema()` nests the founder under `founders` and `member`, `articleSchema()` nests the publisher under `publisher`, `personSchema()` nests the employer under `worksFor`, and a `@graph` block declares no top-level type at all. The inspector therefore reported "No Person schema found" on the home page and the blog hub and "No Organization schema found" on every blog post - pages that carried the node - and each false finding cost points on the `aioReady` gate the page had already earned. The special case #2483 added for `speakable` is replaced by one walk that reads every `@type` a block declares at any depth, so the `@graph` shape task #2446 will move the home page to is read correctly too, and a `@type` that is an array is read as the set of types it names rather than as one coerced string. Seven new cases in `tests/unit/aio-inspector-api.test.ts` pin the nested read, including the site's own `organizationSchema()` and `homeSpeakableSchema()` output, a `@graph` block carrying all five types, and two controls: one that still reports a missing Person when the nested node is removed, and one that does not mistake an unrelated nested node for a Person. Mutation-checked: dropping the recursion turns six of them red. Verified against the served pages - the home page and the blog hub now resolve a Person, and a blog post resolves an Organization, all three of which the old read reported as missing. `tsc --noEmit` is clean. |
| 2466 | done | P1 | med | 45m | high | #2466 test(fonts): the self-hosted font contract now has a test, and the test found a broken font. New `tests/unit/font-contract.test.ts` (12 cases) asserts the three parts AGENTS §21 says must move together: every `@font-face` in `src/styles/globals.css` points at a file that exists in `public/fonts/` and carries the woff2 magic bytes, every file in `public/fonts/` is claimed by a face, every `<link rel="preload" as="font">` in `src/layouts/Base.astro` points at a file that exists and at a face that declares it, the two first-paint faces are preloaded, every declared family appears in the layout's own font stacks, and no Google Fonts origin survives in the stylesheet, the layout or the CSP allowlist. It failed on the first run: `public/fonts/rubik-mono-regular-latin.woff2` was 265,446 bytes of HTML - a GitHub error page saved under a .woff2 name, 11 script tags inside it. The browser rejected the payload, every code block on the site fell back to the system monospace stack, and nothing said so in the console. The file is now the real font: "Rubik Mono One" 400 latin, 12,752 bytes, pulled from the Google Fonts CSS API and self-hosted like the other eight. The family Google Fonts used to publish as "Rubik Mono" no longer exists there - `fonts.google.com/metadata/fonts` lists "Rubik" and "Rubik Mono One" and no "Rubik Mono" - so the `@font-face` family, the `--font-rubik-mono` stack in `Base.astro`, and the font references in AGENTS §5, specs §3 and `docs/design-principles.md` all read "Rubik Mono One" now; the custom property name is unchanged, so every `font-mono` utility still resolves. The broken file is deleted and the dev server serves the replacement as `Content-Type: font/woff2`. Verified in Chrome against the dev server: 9 faces registered, "Rubik Mono One 400" reports `loaded`, and a `<code>` element computes to `"Rubik Mono One"`. The duplicate-font weight is tracked as #2490 and the off-brand WhatsApp colour as #2488. |
| 2436 | done | P1 | med | 35m | high | #2436 fix(cards): the home service card is one anchor now, and the CTA a reader clicks is the CTA that gets clicked. The card rendered two anchors to the same URL: the title link carried a stretched `before:absolute before:inset-0` overlay that covered the whole card, and the "Explore ..." row below it was a second anchor. A positioned pseudo-element paints above the static inline anchors, so the overlay swallowed every click aimed at the visible CTA - `document.elementFromPoint` over the CTA text returned the TITLE link ("Marketing website design and development"), never the CTA the reader was aiming at. The navigation still reached the right page because both anchors shared an href, but the visible CTA was not the thing the reader activated, the card cost two tab stops to reach one page, and the CTA's own `aria-label` ("Explore {title}") disagreed with its visible wording ("Explore {category}"), a WCAG 2.5.3 Label in Name miss. `src/components/ui/service-card.astro` is now a single anchor: the tile is the link, the CTA row is a `<span>` inside it, the stretched overlay is gone, the accessible name is the card's own visible text, and `overflow-hidden` came off the card so the `tap-target` focus ring (a `::after` at `inset: -4px`) is no longer clipped by the card's own overflow. The home services grid drops from 13 anchors to 7 (6 cards plus the "All services" link) and from 12 card tab stops to 6. New `tests/unit/service-card-single-anchor.test.ts` (14 cases) pins the contract - one anchor, no stretched overlay, no duplicate `aria-label`, no heading inside the card (so #2411's h3 cut holds), `tap-target` present, no self-clipping overflow - and adds a repo-wide guard that no component may carry a stretched overlay alongside a second anchor; a mutation fixture runs the same checks against the old markup, and re-adding `before:absolute before:inset-0` to the card turns the suite red. `docs/card-design-system.md` §2 and §11 no longer prescribe the overlay for every card. Verified in Chrome against the dev server: 6 cards, 1 anchor each, `elementFromPoint` over the CTA text now returns the card anchor, and the focused card draws a 2px navy ring 4px outside its bounds. |
| 2456 | done | P1 | med | 45m | high | #2456 a11y(header): the services mega menu now answers the arrow keys, and the mobile drawer's focus trap - which specs.md section 3 requires and which had been dead - works again. Two defects, one root cause. The rule itself is new: specs.md section 3 asks for "ArrowRight / ArrowLeft move inside the open mega menu between columns and links", and the panel had no way to move through it sideways - Tab walked every link in DOM order and nothing else. The rule lives in the new `resolveMegaMenuArrow()` in `src/lib/header-panel-toggle.ts` so it can be unit-tested without a browser: ArrowDown / ArrowUp move between the links of the current column and stop at its ends; ArrowRight moves to the next link of the column and crosses into the first link of the next column from the last link of one; ArrowLeft is the mirror; an empty column is skipped rather than trapping the reader on a dead stop. The panel's markup now names its columns with `data-mega-column` - the five service categories plus the right rail, which specs.md section 3 itself calls a column - and the header reads them back in visual order, so the rule has a shape to walk. ArrowDown on the trigger moves focus into the first link of the first column, which is how a menubar is expected to behave; Escape still closes the panel and returns focus to the trigger. The second defect was older and quieter: the header's `focusableSelector` was joined with `':is(, '`, which produced `a[href]:is(, button:not([disabled]):is(, ...` - a selector Chrome parses but never matches, so `querySelectorAll` returned an empty list. The mobile drawer's focus trap collected nothing and returned early on every Tab press, so Tab walked straight out of the open drawer, and the new arrow rule had no columns to walk. The join is now a plain comma-joined union. `tests/unit/header-panel-toggle.test.ts` gained 12 cases for the rule (cross-column moves, clamped column moves, a skipped empty column, an ignored key, an out-of-range position) and 7 for the wiring, and `tests/unit/header-drawer-aria.test.ts` gained one case that fails if the broken `:is(, ` join comes back. `src/components/chrome/header.astro` was normalised to LF in the same change, so it no longer counts towards the `biome check` sweep in #2487. Verified in Chrome against the dev server: the menu opens on the Services trigger, ArrowDown lands on "Website design and development services" (column 0, item 0), ArrowDown walks the column, ArrowRight crosses from the last link of column 0 into column 1, ArrowLeft from the first link of column 1 lands on the last link of column 0, ArrowDown clamps at the last item of a column, Escape closes the panel and returns focus to the trigger, and in the mobile drawer at 390 px Tab cycles inside the drawer (88 focusable items, Shift+Tab from the first wraps to the last) with focus returning to the trigger on Escape. `tsc --noEmit` is clean and `scripts/audit-header-a11y.cjs` passes 6/6. The focus trap and the arrow rule are now covered by #2495 and #2496. |
| 2488 | done | P1 | easy | 30m | high | #2488 fix(brand): the six WhatsApp CTAs shipped the off-brand Tailwind green (`bg-green-600`), a hue AGENTS section 5 does not allow and `audit:brand-tokens` reported on every run. The palette is navy, yellow, light surface and dark surface, so the WhatsApp buttons now carry the brand navy (`bg-primary`), the same fill the primary CTA uses everywhere else: the utility-bar chip in `src/components/chrome/header.astro`, the pre-footer button in `src/components/chrome/footer.astro` (border included), the two contact cards in `src/pages/contact.astro`, the partner-application button in `src/pages/partners.astro` (focus ring included), and the lead-magnet button in `src/pages/lead-magnets/[slug].astro`, whose `!bg-green-600 !text-white hover:!bg-green-600` override is gone so it renders the canonical navy primary CTA with the CTA shadow and hover lift. The green also failed WCAG AA: white label text on `#16a34a` is 3.30:1, under the 4.5:1 floor, while white on `#002071` is 14.53:1. `node scripts/audit-brand-tokens.cjs --strict` now exits 0 over the whole of `src/` (217 files): no off-brand hex, no forbidden hex, no off-brand Tailwind palette class anywhere in the tree. The audit also had a blind spot this task exposed: it skipped `//` line comments and `/* */` block comments but not `<!-- ... -->`, the comment syntax every `.astro` file uses, so a note that named the class it replaced was reported as a finding and the audit failed on its own documentation. `scripts/audit-brand-tokens.cjs` now computes the HTML comment spans per file and skips a match inside one, in both the hex pass and the palette pass. `scripts/audit-brand-tokens.test.cjs` gained two cases on a real `.astro` fixture - an off-brand class inside an HTML comment is not reported, and the same class in markup still is - so the skip cannot silently widen. Verified in Chrome against the dev server: the header chip, the footer button and the contact card all compute to `background-color: rgb(0, 32, 113)` with white text, and the partner and lead-magnet buttons serve `bg-primary` and `ring-primary`. The CI flip for the report-only audit is todo #2497, the Tailwind source scoping that keeps `scripts/`, `tests/` and `todo.md` out of the utility scan is todo #2498, and a unit test that pins the WhatsApp CTAs to a brand token is todo #2499. |
| 2469 | done | P2 | med | 30m | med | #2469 a11y(islands): the table of contents and the tools category filter no longer claim document-wide DOM ids. `toc.svelte` named the element its nav is labelled by "toc-label" and `tool-category-filter.svelte` named its two panels and thirteen tabs with fixed strings, so a second instance of either island on one page would have made every `aria-controls` and `aria-labelledby` resolve to the wrong element - a screen reader would have announced the other instance's panel name for every tab but the first. Both islands now build their ids from `$props.id()` through the `ariaInstanceId()` helper in `src/lib/aria-instance-id.ts`, the same helper `faq.svelte` and `quote-strip.svelte` use, so the ids are unique per instance and identical on the server and on the client. `toc.svelte` keeps `labelId` as an explicit override for a caller that wants a named nav, and the visible label still names the landmark (WCAG 2.5.3, task #1550). `tests/unit/island-instance-ids.test.ts` gained a describe block for the two islands: the helper import, the `$props.id()` binding, the absence of every old literal id, and the tab-to-panel wiring. `tests/unit/ui-label-in-name.test.ts` now asserts the instance-scoped default instead of the "toc-label" literal. The two components were normalised to LF. Verified: both compile with the Svelte compiler with zero warnings, and the three affected suites pass (63 assertions). |
| 2490 | done | P1 | med | 30m | high | #2490 perf(fonts): one file per font family instead of one per weight. Inter and Space Grotesk are variable fonts, and the repo shipped the same binary under four names for each family, so the browser downloaded the identical bytes once per weight the page used - 259,888 bytes of font to carry 70,544 bytes of distinct glyphs on the home page. `public/fonts/` now holds three files: `inter-latin.woff2`, `space-grotesk-latin.woff2` and `rubik-mono-one-400-latin.woff2`. `src/styles/globals.css` declares one `@font-face` per family, with a weight range on the two variable faces (`100 900` for Inter, `300 700` for Space Grotesk) so the browser sets the `wght` axis per element, and `src/layouts/Base.astro` preloads the two files the first paint needs. `tests/unit/font-contract.test.ts` grew the matching half of the contract: one face per family, a weight range on the variable faces, a range that reaches weight 700, and no two files in `public/fonts/` byte-identical. AGENTS.md section 5 and section 21 and specs.md section 3 now describe the three-file setup and the one-file-per-family rule. Verified in Chrome against the dev server on a cache-bypassed reload of `/`: the home page now requests 2 font files totalling 70,544 bytes (`space-grotesk-latin.woff2` 22,288 and `inter-latin.woff2` 48,256) where it previously requested 7 totalling 259,888; the parsed rules read `Inter 100 900`, `Space Grotesk 300 700` and `Rubik Mono One 400`; the h1 computes to `"Space Grotesk" 700 60px` and the first body paragraph to `Inter 400`; and a canvas ink measurement shows the `wght` axis is genuinely applied (Space Grotesk 400 = 2,414,577 vs 700 = 3,634,199; Inter 400 = 2,687,176, 500 = 3,172,123, 600 = 3,647,834, 700 = 4,098,432), so the weight is rendered from the variable axis and not synthesised. The two new guards were mutation-checked: re-adding a byte-identical fourth Inter file turns three assertions red, and pinning the Inter face to a single weight turns two red. `tests/unit/font-contract.test.ts` passes 18/18 and `tsc --noEmit` is clean. |
| 2478 | done | P1 | med | 30m | med | #2478 fix(schema): the organization graph now names every person the site publishes in its team directory, not only the founder. `organizationSchema()` built `member` from one hardcoded founder entry while `TEAM` in `src/data/company.ts` and `/company/people` publish three named people, so a search engine that read the graph learned that a three-person company had one employee, and the two engineers who do the work had no entity of their own to hang a role, a profile or a page on. The list is derived from `TEAM` with the founder first, so the `founders` entry and the first `member` entry stay the same node - task #2413's contract holds. Every member carries a stable `@id` (`#founder` for the founder, `#person-<slug>` for the rest), a `jobTitle` and a `url` pointing at their directory anchor; `sameAs` is omitted for the two members who have no public profiles rather than published as an empty list. `personSchema()` gained an optional `id` and `/company/people` now passes the same `@id` the graph publishes, so the directory and the graph describe one node per human instead of two. New `tests/unit/team-members-schema.test.ts` (13 cases) pins the membership, the id uniqueness, the founder's `#founder` id, the `sameAs` rule and the directory wiring. Verified against the dev server: the home page ships three `member` nodes and `/company/people` ships three Person blocks carrying the same three ids. `tsc --noEmit` is clean. |
| 2428 | done | P1 | easy | 25m | med | #2428 fix(schema): the organization graph published a fabricated headcount. `numberOfEmployees` was a hardcoded `{ "@type": "QuantitativeValue", value: 12, minValue: 8, maxValue: 18 }` - a number and a range that nothing on the site supported, on a business whose own `/company/people` page prints `TEAM.length` (three people) and whose home hero says every engagement is staffed by 3 to 4 senior specialists. A knowledge panel that reads "12 employees" is a false statement about the business, published by the business's own site, and search engines quote it back. The value is now derived from `TEAM` in `src/data/company.ts`, so the schema cannot drift from the team the site renders: hire somebody and the schema follows on the next build. The `minValue`/`maxValue` pair is gone - on a headcount this size a range is invented precision, not information. `tests/unit/organization-schema.test.ts` gained a `numberOfEmployees` describe block with four cases: the published value equals `TEAM.length`, no invented range survives, the old `12` and `12-18` pair is gone, and `/company/people` still derives its hero count from `TEAM.length` so the rendered page and the schema read from one source. Mutation-checked: restoring the hardcoded `{ value: 12, minValue: 8, maxValue: 18 }` turns three of the four cases red. Verified in the dev server: the home page serves `"numberOfEmployees":{"@type":"QuantitativeValue","value":3}`; `tsc --noEmit` is clean and the suite passes 8/8. |
| 2477 | done | P1 | easy | 25m | high | #2477 fix(schema): the company's public profiles now have one source. `SITE.social` in `src/lib/locales.ts` is where the site's own profiles are written down and where the footer links, but the Organization node carried a hand-typed second copy of the same six URLs and `contactPageSchema()` carried a third copy with only three of them. `sameAs` is the property that tells a search engine which external pages describe the entity, so a stale entry there points a knowledge panel at an account the company no longer controls, and the contact page was naming a partial identity. Both nodes now read `SITE.social` through a frozen module constant in `src/lib/schema.ts` and copy the list out per call, so no caller can mutate the shared value. New `tests/unit/social-same-as.test.ts` (7 cases) pins the order, the contact-page set, the absolute-https shape, the copy semantics, and a repo-wide guard that no source file outside `src/lib/locales.ts` hand-types a company profile URL; the guard was mutation-checked by reintroducing one literal into `schema.ts`, which turned the suite red. The four schema suites pass (101 assertions). |
| 2491 | done | P2 | easy | 10m | low | #2491 chore(components): the dead `card-base.astro` and `tool-card.astro` pair is gone. Both files were still on disk and tracked - `tool-card.astro` imported `card-base.astro` and nothing in `src/`, `tests/`, `scripts/` or `docs/` imported either - so the earlier record that the pair had already been deleted in task #2297 did not match the tree. Both files are deleted now, and `docs/card-design-system.md` records the removal in its inconsistency table, which also retires the two rows that compared `service-card.astro` against a ToolCard that no longer exists. The card families that superseded them are `service-card.astro` and `tools-grid.svelte`. |
| 2465 | done | P1 | med | 30m | high | #2465 fix(header): a panel's state is now described by `inert` alone. The mega menu and the six utility dropdowns each carried `inert` and the CSS `[:not([inert])]` selectors, and the comment above the panel explained that `aria-hidden` was deliberately not used - but the accessibility gate had to be read out of two attributes describing one state, and a panel that was open for the pointer could be hidden from a screen reader while it was open. `aria-hidden` is gone from every panel: the markup toggles `inert`, the script never touches `aria-hidden` on a panel, and no stylesheet rule selects on it. The markup, the script and the stylesheet now say the same thing, and the comment names the rule it enforces. `isPanelOpen()` in `src/lib/header-panel-toggle.ts` is now the single place the open state is read, and the seven panels' CSS variants key off `[:not([inert])]` rather than `[:not([aria-hidden='true'])]`. `scripts/audit-header-a11y.cjs` gained a seventh check that fails on any panel carrying `aria-hidden`, and `tests/unit/header-panel-aria.test.ts` and `tests/unit/header-panel-toggle.test.ts` now assert the inert-only contract instead of the old pair. Verified in Chrome against the dev server: all seven panels ship `inert` with no `aria-hidden`, the mega menu opens to opacity 1 with its links focusable, a closed panel's link refuses focus, and `scripts/audit-mega-menu-accessibility.cjs` now passes every inert-only check it was failing before. |
| 2485 | done | P1 | med | 30m | high | #2485 fix(aio): the AIO Inspector counted a page's internal links with a pattern that matched absolute hrefs only, so a page that linked its own sections the ordinary way - `href="/services"` - reported "Only 0 internal links found" no matter how many it had. The site's own home page carries 402 root-relative hrefs and 11 absolute ones, so the tool scored the site it ships from as having no internal links at all. The new `src/lib/internal-links.ts` resolves every anchor's href against the audited page and counts the ones that land on another page of the same site: a relative href and an absolute one to the same page count the same, a self-link does not count, `mailto:`/`tel:`/`javascript:`/`data:` are not navigation, and the `www.` alias folds into the apex domain. New `tests/unit/internal-links.test.ts` (18 cases) pins the resolution rules, and the inspector calls the helper at `src/pages/api/aio-inspector/index.ts`. `tests/unit/aio-inspector-api.test.ts` gained three endpoint-level cases: a page whose links are root-relative counts as internal, a relative and an absolute link to the same page count alike, and a control proving a page whose only links are off-host still reports "Only 0 internal links found" - a count that accepted every href would pass the first assertion while the check stopped working. Mutation-checked: requiring an absolute href again turns 10 assertions red across the two files. Verified against the dev server: the home page counts 390 internal links (it previously reported 0), `/services` 384, `/blog` 329 and `/tools` 336; the same audit of the deployed site still returns "Only 0 internal links found" with a score of 43, against 72 and `aioReady: true` for the same URL through the fixed endpoint. `tsc --noEmit` is clean and Biome reports no findings on the four touched files. |
| 2510 | done | P2 | easy | 5m | low | #2510 docs(header): closed as already satisfied - the mega-menu comment in `src/components/chrome/header.astro` states that `aria-hidden` is not used on panels, and the markup, the script and the stylesheet agree with it now that #2465 landed: every panel toggles `inert`, no panel carries `aria-hidden`, and no rule in `src/styles/globals.css` selects on it. The comment and the code no longer contradict each other, so the item only needed retiring. |
| 2515 | done | P1 | med | 40m | high | #2515 refactor(schema): the canonical origin is written down once. `src/lib/schema.ts` carried the literal `https://www.webgrow24.com` thirty times - every `@id`, `url`, `logo`, `image` and `urlTemplate` in the JSON-LD graph - while canonical links, Open Graph urls, hreflang, the RSS feed and the sitemap all read `PUBLIC_SITE_URL`, an environment variable. Thirty copies of one origin meant the schema graph and the metadata could name two different hosts for the same entity the moment the variable moved. New `src/lib/site-origin.ts` owns the rule: `normalizeSiteOrigin()` trims whitespace, strips trailing slashes and falls back to the production origin on an empty value, and `src/lib/metadata.ts`, `src/lib/schema.ts` and `astro.config.ts` all read `PUBLIC_SITE_URL` through it. The trailing slash was the live half of the defect: `.env.example` shipped `PUBLIC_SITE_URL=https://www.webgrow24.com/` and told the reader to copy it into `.env`, every URL in the site is built by concatenation, and the shipped value produced `https://www.webgrow24.com//blog` - a canonical link, an Open Graph url and a sitemap entry that are all a different URL from the ones the rest of the site publishes. `.env.example` now ships the bare origin, and `scripts/indexnow-ping.cjs` strips the slash once instead of at one of its two call sites. The schema's five hand-typed `hello@webgrow24.com` emails now read `SITE.email`, and the Schema Markup Generator island's three sample `publisher`/`organizer` anchors read the same origin as the rest of the site. New `tests/unit/site-origin.test.ts` (14 cases) pins the normalisation rules, the fallback, the agreement between the metadata and the schema graph, and a `vi.stubEnv` case that proves the environment variable really drives both at once; it also carries a repo-wide guard that fails the suite when a hand-typed origin comes back. Mutation-checked: re-adding one literal to `src/lib/schema.ts` turns two assertions red. Verified in the dev server: the seven JSON-LD blocks on the home page are byte-identical to before the change (WebSite 411, Organization 6,790, ProfessionalService 1,549, BreadcrumbList 161, FAQPage 1,381, ItemList 4,843, WebPage 593 bytes), zero `webgrow24.com//` occurrences in the served HTML, and the Astro config loads with the new relative import. specs.md A.6 and the environment table, and AGENTS.md section 21, now record the one-origin contract. |
| 2542 | done | P2 | easy | 15m | med | #2542 fix(origin): the four tool endpoints that fetch a page the visitor names - the canonical url checker, the image alt checker, the heading order checker and the hreflang checker - each wrote the `User-Agent` header by hand, and each one named the **apex** origin (`https://webgrow24.com/bot`) while the site publishes the `www` origin. Two hosts for one site, in the one place a crawler is told who is calling: the URL in a User-Agent is the contact address for the bot, and it pointed at a host the site does not canonicalise to. All four now call a shared `botUserAgent()` builder in `src/lib/site-origin.ts`, which reads the same `PUBLIC_SITE_URL` through the same `normalizeSiteOrigin()` rule as every other reader, so the header cannot drift from the canonical origin again. The env read happens inside the call, not at module scope, so `astro.config.ts` can still import the module safely. |
| 2543 | done | P2 | easy | 15m | med | #2543 test(origin): the guard added in #2515 only looked for the published `www` origin, so a hand-typed apex URL passed it - exactly the form #2542 found in four API routes. `tests/unit/site-origin.test.ts` now checks both spellings of the host across every source file, asserts that the one module allowed to write an origin down names the published host and not the apex one, asserts that the User-Agent carries the published origin with no double slash, and adds a repo-wide guard that no API route hand-types a `WebGrow24Bot/1.0` header. The suite grew from 14 cases to 17. Mutation-checked: putting the old hand-typed header back into `canonical-url-checker/index.ts` turns two assertions red. |
| 2516 | done | P2 | easy | 5m | low | #2516 docs(schema): closed as part of #2515 - the `organizationSchema()` docblock said the full Organization node "is emitted by `organizationSchema()` in the footer", and the footer emits no JSON-LD at all. The block now says the node is emitted by `organizationSchema()` on the pages that publish it, which is what the fifteen publishers actually do. |
| 2416 | done | P2 | easy | 20m | med | #2416 perf(tailwind): the default Tailwind palette is switched off. A project that adds an `@theme` block does not replace Tailwind's default palette, it adds to it, so every default shade stays available and the build emits the ones the source tree mentions. This repo mentioned none of them under `src/`, but the scanner also reads `scripts/`, `tests/` and `changelog.md`, so the linked stylesheet shipped 38 `oklch(...)` declarations and 25 default-palette utility classes - dead weight on the critical path, and every one of them an off-brand hue under AGENTS section 5. `src/styles/globals.css` now opens its `@theme` block with `--color-*: initial;`, which drops every default `--color-*` token, and re-declares the tokens the site actually uses below the reset: the four brand colours (`--color-primary`, `--color-natural-dark`, `--color-natural-light`, `--color-secondary`), white (the reset would otherwise take Tailwind's `--color-white` with it, and `text-white` is the label colour on every navy CTA), the six surface aliases, and the four semantic status colours with their background variants. `tests/unit/tailwind-palette.test.ts` (9 cases) pins the contract from both sides: the reset has to exist and sit above every colour token it has to survive, the declared token set has to be exactly the allowed nineteen names, and no file under `src/` may use a default-palette shade. The default palette names are read from the installed Tailwind package rather than hardcoded, so the check follows the framework instead of a list that can rot, and the suite asserts it found more than a hundred shades so an empty palette cannot pass vacuously. |
| 2509 | done | P2 | easy | 15m | med | #2509 fix(header): the utility bar told every reader the site shipped fewer tools than it does. The header wrote the count into the template by hand - "48 free tools" in the tools dropdown header and "See all 48 tools" in its footer link - while `TOOLS.length` was 49, so the number went stale the first time a tool was added and nothing noticed: the one place a visitor is most likely to read the count is the one place it was wrong, and it was wrong on every page of the site. Both labels now read `TOOLS.length`, which the header already imported for the dropdown list, so the count moves with the collection instead of with the last person to edit the template. New `tests/unit/header-tool-count.test.ts` (6 cases) pins the contract: the header imports the collection from `src/data/tools.ts`, both labels render from `TOOLS.length`, and no literal count survives anywhere in the header - the pattern matches any number next to the word "tools", so a re-typed count fails whichever value it carries, and the collection itself is asserted to be non-empty with unique slugs rather than a typed number that would drift again. Mutation-checked: restoring the typed number turns three of the six assertions red. Verified against the dev server: the served home page reads "49 free tools" and "See all 49 tools". |
| 2511 | done | P1 | med | 30m | high | #2511 fix(schema): the LocalBusiness node stopped spending `sameAs` on the site's own Organization anchor. `localBusinessSchema()` published `sameAs: ["https://www.webgrow24.com/#organization"]`, and `sameAs` is the property that tells a search engine which *other* pages describe the same entity - a self-reference carries no external identity and uses up the one property Google reads to link a business to its official profiles, so the node named no external profile at all. The node now carries the company's six real profiles, read from `SITE.social` through the `companyProfileUrls()` helper task #2477 added, so a knowledge panel can link the business to its LinkedIn, X, Instagram, Facebook, Pinterest and YouTube accounts. The connection to the canonical Organization node moved to `parentOrganization`, which is the schema.org property for "this business belongs to that organization", so the two nodes stay reconciled in the graph through the property that means it instead of through a false identity claim. `tests/unit/social-same-as.test.ts` gained a describe block with four cases: the node names the same profiles in the same order as every other node in the graph, it does not name the site's own anchor, it stays joined to the Organization node through `parentOrganization`, and a deep walk over six exported nodes proves that no node in the schema graph lists this site in its own `sameAs`. Mutation-checked: restoring `sameAs: [ORGANIZATION_ID]` turns three of the four assertions red. Verified against the dev server: the served home page carries the six profile URLs in `sameAs` and `parentOrganization: {"@id":"https://www.webgrow24.com/#organization"}`; the six schema suites pass (169 assertions). |
| 2492 | done | P1 | med | 30m | med | #2492 feat(tools): the home tools-grid card is one anchor now. The card was an `<article>` with a title link inside it, and that link carried a stretched `before:absolute before:inset-0` overlay so the rest of the tile would be clickable - the same shape task #2436 removed from the service card. A positioned pseudo-element paints above the static content of the card, so the click target of every card was an invisible overlay rather than the visible title or the visible "Try {name}" row, and the overlay only aimed at the card because the card happened to carry `relative`. The `<article>` is gone, the tile itself carries the href, the title and the CTA row are `<span>`s inside it, and the overlay with it: one element to click, one to reach with the keyboard, and the accessible name is the card's own visible text. `overflow-hidden` came off the tile with it, because the card's focus ring is a `::after` at `inset: -4px` (globals.css, `tap-target`) and the card's own overflow clipped it away. New `tests/unit/tools-grid-single-anchor.test.ts` (14 cases) pins the contract - one anchor, no stretched overlay, no `<article>` wrapper, the anchor rooted at the card inside the list item, `tap-target` present, no self-clipping overflow, no heading inside the card - and adds the repo-wide guard that **no** component under `src/components/` carries a stretched overlay any more, with a mutation fixture that runs the same checks against the old overlay shape and proves they fire. `docs/card-design-system.md` section 2 and section 11 now state the root-anchor rule instead of the overlay rule, and section 10 records the change. Verified in Chrome against the dev server: the home page ships 49 cards, all of them `li > a` roots, 0 `<article>` wrappers, 0 nested tabbables per card, 0 anchors with a `::before` overlay, and `document.elementFromPoint` over the visible CTA text and the visible title both resolve to the card anchor (`/tools/seo-analyzer`); the card's `::after` ring computes to a 2px navy border at `inset: -4px` on a `position: relative`, `overflow: visible` card; a real click on the CTA text navigated to `/tools/schema-markup-generator`; and the page logs no console error. |
| 2519 | done | P1 | med | 30m | med | #2519 fix(aio): the broken-link checker now looks at every link on a page, not only the ones that already carried a scheme. The endpoint kept the hrefs that read `http://` or `https://` and dropped the rest, so every relative href was discarded before the check ran - and the site's own home page carries 402 root-relative hrefs against 11 absolute ones, so the tool reported "All 11 links on this page are reachable" for a page whose real link graph it never opened. The rule now lives in `src/lib/link-targets.ts` as `resolveLinkTargets()`: it resolves every anchor's href against the audited page's URL, skips the schemes that are not navigation (`mailto:`, `tel:`, `sms:`, `callto:`, `javascript:`, `data:`, `blob:`, `file:`), treats a bare `#fragment` as a link inside the current page, and deduplicates AFTER the resolution, so `/services` and `https://the-site.example/services` collapse into one entry instead of two. `tests/unit/link-targets.test.ts` (14 cases) covers the rule, and `tests/unit/broken-link-checker-api.test.ts` (10 cases) pins it at the endpoint: a root-relative href that answers 404 comes back as a critical finding against the resolved absolute URL, a reachable one is counted and not reported, a `mailto:`/`tel:`/`#fragment` is never fetched, and a link that points at a private address is not fetched at all. Three rules around the resolution changed with it. Every URL the tool fetches now passes the SSRF guard, not only the page it was asked about - the audited page was guarded and the links it carried were not, so a page could point the server at `169.254.169.254` and have it fetched; a redirect into a blocked address is reported and not followed, and a relative `Location` header is resolved against the URL that answered. The checks run through a pool of eight concurrent requests instead of `Promise.all` over the whole list, because a 400-link page no longer opens 400 sockets at once, and the cap moved from 200 to 500 links so the home page's 402 hrefs fit inside one run; the response now carries `linksFound`, `skippedLinks` and `truncated`, and the summary says how many links were left out instead of claiming every link was checked. The tool's own copy in `src/data/tools.ts` gained an FAQ entry that says internal links are resolved and checked, and its `updatedAt` moved to 2026-10-20. A finding now carries the href as it was written on the page next to the resolved URL it fetched (`sourceHref` against `targetUrl`), because a reader looking for `/services` in their own source cannot grep for the absolute URL the tool reports. Mutation-checked: restoring the absolute-href-only filter turns 14 assertions red across the two files. Verified against the deployed site: `/contact` carries 320 hrefs (11 absolute, 302 relative, 7 not navigation) and the fixed endpoint checks 183 distinct targets where the old filter kept the eleven absolute ones; the one finding it returns is the `wa.me` CTA answering the HEAD request with a 429, which is tracked as todo #2544. |
| 2533 | done | P1 | easy | 10m | med | #2533 fix(test): `tests/unit/tailwind-palette.test.ts` type-checks clean. The new palette-contract suite reads the `@theme` block with `noUncheckedIndexedAccess` on, so a `RegExpMatchArray` capture is `string | undefined` and two strict errors landed in the typecheck. `DECLARED_TOKENS` now narrows the capture with a `token is string` type predicate, and the utility scanner reads its two capture groups through `?? ""`. `tsc --noEmit` exits 0 over the whole project and `tests/unit/typecheck-errors-snapshot.test.ts` passes with its error budget at 0, so the error snapshot records a clean tree again instead of two errors pointing at the new file. |
This log reflects completed tasks only. Pending work is tracked in todo.md at the root of this repository.