Changelog

What we shipped.

Page 11 of 13 — completed tasks for 2026-10-21.

Tasks logged

894+

across all rounds

Since

Jan 2026

founding month

Showing

2026-10-21

page 11 of 13

2026-10-21

Completed tasks for this batch, with the status, priority, difficulty, time taken, value score and what changed.
#StatusPriorityDifficultyTimeValueCompleted Task
2419doneP3easy5mlow#2419 a11y(hero): set the "or" separator to 14 px and drop the decorative uppercase caps.
2421doneP3easy10mlow#2421 feat(seo): add 3 contextual internal links in the hero to /services, /work, /pricing.
2423doneP3easy10mlow#2423 fix(reviews): add a footnote - rating as of October 2026, link to Google Business Profile.
2472doneP3easy5mlow#2472 chore(home): drop the unused CATEGORY_SLUGS, CardBody and CardFooter from index.astro; ServiceCategory stays.
2489doneP3easy10mlow#2489 fix(icons): replace the hand-rolled clock and menu glyphs with the Lucide components.
2493doneP3easy5mlow#2493 fix(icons): the service-card CTA arrow uses strokeWidth 2 at 14px; rule is 1.5 at 16px.
2512doneP3easy5mlow#2512 chore(home): index.astro imports eleven unused Lucide icons, an unused SITE and an unused featuredCase.
2513doneP3easy5mlow#2513 fix(icons): the hero arrow, founder badge and industry icon use strokeWidth 1, 2.25 and 2.5.
2521doneP3easy5mlow#2521 fix(copy): the ai-citation-stats FAQ says the AIO inspector scores five dimensions; it returns four.
2562doneP3easy10mlow#2562 fix(rss): RSS contact elements should carry a bare email, not an address plus a name.
2563doneP3easy5mlow#2563 fix(build): build-llms-full.mjs hand-types the glossary term count; derive it from the collection.
2540doneP2easy15mmed#2540 docs(styles): the AGENTS.md brand rule no longer promises tokens the site cannot render. Section 5 said the semantic status colours come as "`--color-success`, `--color-warning`, `--color-error`, `--color-info` (each with `-dark`, `-bg`, and `-bg-dark` variants)" - sixteen tokens. `src/styles/globals.css` declares eight: one per family plus a `-bg` companion each. The four `-dark` and four `-bg-dark` variants were deleted when the site was locked light-only on 14 October 2026 (tasks #1585-#1591) and the paragraph was never corrected, so the file every agent reads before touching the repo described a palette the site cannot render. The paragraph now names the eight tokens the stylesheet declares and states that there are no dark variants, citing the light-only decision and the two guard tests. The third copy was the sharp end: `BRAND_HEX` in `lib/brand-tokens.cjs` is the allow-list `scripts/audit-brand-tokens.cjs` trusts, so while it carried the dark values, a stylesheet hardcoding one of them passed the audit as a brand colour even though the site cannot render it. New `tests/unit/semantic-status-tokens.test.ts` pins the stylesheet, the documentation and the audit allow-list to one set of eight tokens with the same hex values and no dark variant; `tests/unit/no-dark-mode.test.ts` fails the suite if a `-dark` token returns.
2476doneP3easy5mlow#2476 chore(repo): the knowledge-graph post ships LF like the other content files. `src/content/blog/knowledge-graph-seo-entity-authority.mdx` was one of the content files that still carried CRLF line endings while the rest of `src/content/blog/` had been normalised, so the file showed as a whole-file rewrite in every diff that touched it. Normalised to LF; the file is byte-identical apart from its line endings.
2526doneP1med40mhigh#2526 fix(test): nothing writes to the repository's `astro.config.ts` any more. Two suites touched that file. `scripts/check-hub-redirects.test.cjs` proved the redirect audit still fires by rewriting the real config with a self-referential redirect and restoring it in a `finally` - which left a deliberately broken config in the working tree for the length of the test, and the shared dev server (AGENTS section 15) watches that file, so it reloaded the broken config and the whole site went down while a unit test was running. The shim was the one that did it: the vitest suite `tests/unit/hub-redirects.test.ts` only ever read the config. The fix puts the mutation in a temporary copy under the OS temp directory, `scripts/check-hub-redirects.cjs` now takes the config path as an argument (defaulting to the repository's own, so `npm run check:hub-redirects` is unchanged), and the shim asserts as its fourth check that the repository's config is byte-identical to what it read. `tests/unit/hub-redirects.test.ts` gained a repo-wide guard (6 cases): no file under `tests/` or `scripts/` may write to `astro.config.ts`, with a control that proves the guard is not vacuous - the suite does read the config, and the config carries no mutation marker. Mutation-checked: the guard reports the pre-fix shim as an offender and the post-fix shim as clean. Verified: `node scripts/check-hub-redirects.test.cjs` prints 5 PASS lines and exits 0 with `git diff astro.config.ts` unchanged by the run, and `tests/unit/hub-redirects.test.ts` passes 6/6.
2437doneP1easy30mmed#2437 perf(schema): the home page no longer publishes an ItemList. `src/pages/index.astro` published an `itemListSchema()` block listing all 30 services as `ListItem` nodes, each with an absolute URL - 4,843 bytes of JSON-LD on every home page load, 30% of the page's 15,986 bytes of structured data. Three reasons it went. The list described 30 services on a page that renders 6 cards, so it pointed at a list the reader cannot find. The hub that actually lists the services, `/services`, already publishes the ItemList with every service in it. And the block nested the whole `Organization` node as its `publisher`, so the page repeated a 6.8 KB entity it already publishes on its own. specs.md section 6 lists the home page's required schema as WebSite, Organization, LocalBusiness, SearchAction and FAQPage - ItemList was never on it. The home page now ships six JSON-LD blocks and 11,143 bytes, measured against the dev server before and after. New `tests/unit/home-schema-itemlist.test.ts` (12 cases) pins the absence of the helper, the node and the list on the home page, the presence of the full ItemList on `/services`, and the six remaining blocks; two mutation fixtures prove the contract fires on both a helper call and a hand-rolled node. Mutation-checked: re-adding the `itemListSchema` call to `index.astro` turns three of the cases red. `tsc --noEmit` is clean.
2459doneP1med40mhigh#2459 a11y(cards): the decorative card marks now clear WCAG AA contrast, and the home page is axe-clean. axe-core reported one serious `color-contrast` violation on the home page covering 21 nodes: the 6 service-card ordinals and the 8 feature-grid ordinals at `text-fg/30` (#b2b2b2 on the #f1f1f1 card, 1.87:1 against a 4.5:1 requirement for 11px text), the 4 process-step pull numbers at `text-primary/20` (#c1c7d7, 1.49:1 against 3:1 for 28px text) and the 3 blog reading-time labels at `text-fg/50` (#888888, 3.13:1 against 4.5:1 for 12px text). `aria-hidden="true"` does not exempt a mark from the rule - axe measures what is painted on the screen, and a sighted reader sees the ordinal even though a screen reader does not announce it. The ordinals moved to `text-fg/70` (#5e5e5e, 5.74:1) with a `text-primary/70` hover state (5.50:1), the pull number to `text-primary/60` (4.13:1) with a `text-primary/80` hover (8.16:1), and the reading time to `text-fg/70` (5.74:1). New `tests/unit/card-contrast-tokens.test.ts` (12 cases) reads the colour tokens out of `src/styles/globals.css`, composites each tint over the card surface and computes the WCAG ratio, so the guard fails on the arithmetic rather than on a string it happens to be looking for; the mutation fixture proves the old tokens fail. Mutation-checked: putting `text-fg/30` back on the service card turns the case red with the same 1.88:1 axe reported. Verified in Chrome against the dev server: axe-core now reports zero violations on `/` where it reported one serious rule with 21 nodes before. `/tools/seo-analyzer` still fails on five nodes of the same family, filed as #2555.
2435doneP3easy10mlow#2435 perf(home): closed as already satisfied - the home page has no image above the fold at any viewport, so there is no LCP image to give `fetchpriority="high"`. The founder photo (`/team/renish-mithani.webp`) sits in the team section, the ninth section of the page; measured in Chrome against the dev server on a 1440x900 viewport its top edge is at y=9340, and the blog cover images are further down still. `loading="lazy"` is correct on both, and adding `fetchpriority="high"` would contradict it. The Lighthouse LCP measurement on the home page is tracked by #2453.
2438doneP2easy10mmed#2438 fix(content): closed as already satisfied - the home hero's "1-day reply" is not an unsourced claim. The same promise is published on `/contact` in four places: the page tagline ("We reply within one business day"), the phone and email rows ("Phone: ... (1-day reply)"), the contact FAQ ("We reply within one business day") and the page's meta title ("Contact WebGrow24 - reply in one business day"). The home proof row is a summary of commitments the site already makes, so the claim has a source and the hero copy needs no change.
2556doneP1med45mhigh#2556 fix(email): the contact email is written down once. The address had five copies on the site, and each one was a chance to be wrong. `src/lib/locales.ts` is the record - the footer, every `mailto:` link, the JSON-LD graph, the CTA helpers and the RSS feed all read `SITE.email` from it - but five surfaces kept their own: the blog hub FAQ answer ("Send us a note at hello@webgrow24.com"), the glossary hub FAQ answer and the speakable-schema copy that ships beside it, the tools hub TL;DR takeaway, the two llms build scripts (three literals across the pair), and the CommonJS prompt-builder shim `scripts/lib/build-system-prompt.cjs`, which re-declared the whole contact record inline - phone, email, hours and date format - under a docblock claiming to be a shim for a `src/lib/build-system-prompt.ts` that does not exist. The prompt is the one string the site hands to a model that then speaks to a lead, so a stale copy there is the worst copy on the site: the operating hours were corrected by hand in that file on 2026-10-16 for exactly that reason, and `docs/blocked-owner-actions.md` still lists it among the places that had to be edited by hand. All five now read the shared record. The three pages interpolate `SITE.email` into the FAQ answer and into the JSON-LD `Answer` text that ships with it. `scripts/build-llms.mjs` and `scripts/build-llms-full.mjs` import `SITE` from `src/lib/locales.ts` and publish `SITE.email`, `SITE.phone`, `SITE.whatsapp`, `SITE.registeredOffice`, `SITE.legalName`, `SITE.registrationNumber`, `SITE.hours`, `SITE.founder`, `SITE.founded`, `SITE.locale` and `SITE.currency` instead of typing them. The prompt-builder shim requires `../../src/lib/locales.ts` - Node 26 type-strips a `.ts` file on require, so the CommonJS shape survives - and declares no contact value of its own; the stale `BRAND_VOICE` declaration in `build-system-prompt.d.cts` was dropped and the file now declares the three exports the module really has. The published `public/llms.txt` and `public/llms-full.txt` were regenerated and every line is byte-identical to the previous build except one: the WhatsApp line now publishes the canonical `SITE.whatsapp` (+919687572424) instead of the phone number's display format (+91 96875 72424), which proves the wiring is faithful rather than a rewrite. Verified against the dev server: `/blog`, `/glossary` and `/tools` still publish the address in the FAQ answer and the TL;DR, and the prompt builder still emits the same voice paragraph, byte for byte.
2557doneP1med40mhigh#2557 test(email): the contact email has a guard, the way the origin has one. Task #2515 wrote the canonical origin down once and `tests/unit/site-origin.test.ts` guards it; the contact email had no such guard, so every copy removed by #2556 could grow back one literal at a time with nothing to say so. New `tests/unit/shared-contact-constants.test.ts` (10 cases) pins four things. `src/lib/locales.ts` is the only file under `src/` that writes the contact email down - comments are stripped first, so the doc block that explains the defect is not mistaken for the defect, and the `//` pattern refuses a slash that follows a colon so `https://` survives the strip. Neither llms build script hand-types a contact detail: the sweep covers eleven fields - email, phone, WhatsApp, registered office, legal name, registration number, hours, founder, founding date, locale and currency - and names the field that drifted rather than only that something did. Both scripts interpolate `SITE.email` at the point of publication, with a floor on the count, so an import that survives on one line cannot pass the check. And the published artifacts carry the shared values. Four controls keep the guard honest: a hand-typed email is detected, a hand-typed phone and office are detected, a script that imports the record and never uses it is caught by the interpolation floor, and a commented-out literal does not count as a hand-typed value. Mutation-checked on the real files: putting `hello@webgrow24.com` back into `scripts/build-llms.mjs` turns two cases red - the hand-typed sweep and the interpolation floor - and putting the literal back into `src/pages/tools/index.astro` turns the `src/` sweep red with the offending path named. The suite passes 10/10.
2479doneP1med30mhigh#2479 fix(schema): the About page described its founder twice. `/company/about` published an organization graph that names the founder under the `#founder` `@id` - task #2413 gave the graph one identity for it - and, beside it, a second Person block for the same human with no `@id` at all. A Person node without an `@id` is an anonymous node: a processor can only match it by name, so the page read as two people called Renish Mithani. One carried a job title, a profile URL and a directory anchor; the other carried the certifications and the languages. A processor that merged them kept one set of properties and dropped the other, and the certifications and the profile URL never met. `src/lib/schema.ts` now exports `founderPersonId()` and `founderPersonUrl()` - the id the graph already publishes and the team-directory anchor the graph's `member` entry already points at - and `src/pages/company/about.astro` passes both into `personSchema()`. The two blocks are one node now. New `tests/unit/founder-person-id.test.ts` (6 cases) pins the shared id, the shared url, the wiring in the page, and a control that proves a Person built without the id publishes no `@id` at all, so the merge depends on the wiring rather than on something `personSchema()` does on its own. Verified against the dev server: `/company/about` serves the founder as `"@id":"https://www.webgrow24.com/#founder","url":"https://www.webgrow24.com/company/people#renish-mithani"` - the same id and url the graph's `founders` entry and its first `member` entry carry - and the four schema suites pass 33/33.
2520doneP1med35mmed#2520 fix(aio): the SEO analyzer classified a link by how it was spelled, not by where it pointed. `analyzeHTML()` in `src/pages/api/seo-analyzer/index.ts` called a link external when its href started with `http://` or `https://`, so a page's own navigation bar - which spells its links in full - read as a set of outbound links, and an absolute link to the audited site's own host was indistinguishable from a link to another site. The count was also never returned: `externalLinks` fed only the `externalLinks.length === 0 && linkEls.length === 0` branch, which is the same test as `linkEls.length === 0`, so the classification had no observable effect anywhere. `src/lib/internal-links.ts` now owns the rule: `classifyHrefs()` and `classifyLinks()` resolve every href against the audited page, compare the host with the `www.` alias folded away, and report `{ total, internal, external }`. The existing `countInternalLinks()` was rewritten on the same resolver, so the AIO Inspector's stricter measure (links to OTHER pages, self-links excluded) and the new breakdown share one definition of "same site" instead of two. The endpoint returns the breakdown as `links`, and `src/components/tools/seo-analyzer.svelte` shows it under the score as Links / Internal / External. The score and the issue set are untouched: a page with ten outbound links is not a worse page than one with none, so the split is reported rather than scored. New cases in `tests/unit/internal-links.test.ts` (13) and `tests/unit/seo-analyzer-api.test.ts` (7) pin the destination rule - an absolute same-host link is internal, a root-relative link is internal, a `mailto:` or a bare `#fragment` is neither, the `www` alias is the same site and a lookalike host is not, and `total` always equals `internal + external`. Verified in the dev server and in Chrome: the home page now reports 406 links, 397 internal and 9 external, where the old rule counted all 11 of its absolute hrefs as outbound.
2566doneP1easy20mmed#2566 fix(origin): the assistant prompt no longer names the site host by hand. `scripts/lib/build-system-prompt.cjs` - the CommonJS shim that builds the string the site hands to the model that then speaks to a lead - carried `www.webgrow24.com` in the paragraph that tells the assistant which pages it may link to. Every other reader of the origin reads `PUBLIC_SITE_URL`: the canonical links, the Open Graph urls, the sitemap, the JSON-LD graph and, since #2527, both llms build scripts. This was the last second origin on the site, and the cost of it is not cosmetic: a staging build moved every page to the staging host while the assistant kept telling readers to link to production, and the prompt is the one string that sends real traffic off the site. The shim now requires `src/lib/site-origin.ts` and resolves the origin through the same `normalizeSiteOrigin()` every other reader uses, through a new `siteOrigin()` export. The read happens at call time rather than at require-time, so a build that sets `PUBLIC_SITE_URL` moves the prompt with it, and the trailing slash `.env.example` used to ship cannot reach the prompt as `https://www.webgrow24.com//`. `scripts/lib/build-system-prompt.d.cts` declares the new export. `tests/unit/site-origin.test.ts` grew a fifth describe block (5 cases): the shim writes no host down by hand (the bare host, the published origin and the apex form are all checked, and a comment quoting the old literal does not count), it reads the origin through the shared normaliser, the prompt it builds names the resolved origin and no other host, the origin follows `PUBLIC_SITE_URL` with the trailing slash stripped, and a control proves the matcher is not vacuous. Mutation-checked: putting `www.webgrow24.com` back into the cite paragraph turns three of the five cases red. Verified: the shim prints the production origin with the environment unset and the staging origin with `PUBLIC_SITE_URL` set, the suite passes 27/27 and `npx tsc --noEmit` is clean. The three scripts that still hand-type the host - the two link auditors and the PDF builder - are filed as #2577, and the guard that walks `src/` only is filed as #2580.
2558doneP1med25mmed#2558 fix(copy): the tool pages say where the work happens. The site shipped forty-nine tool pages and every one of them promised that the results are processed in the reader's browser, while twelve of the tools fetch a URL and run a check on the server - the SEO analyzer, the AIO inspector, the broken link checker and the rest. The claim was false on twelve pages and it was a privacy claim, which is the kind of copy that has to be exactly true. `src/data/tools.ts` now carries a `processing` field on every tool record (`"browser"` by default, `"server"` for the twelve that call an API), and `src/pages/tools/[slug].astro` builds the page's privacy note, its TL;DR and its meta description from that flag: a server tool says "This check runs on our server, so what you submit is sent to us for processing. We do not store it.", a browser tool says the work never leaves the device. The tools hub derives its counts from the same field - `serverToolCount` and `browserToolCount` come from `TOOLS.filter((t) => t.processing === "server")` - and states both numbers, so the hub cannot claim "all processing happens locally" the way it used to. Closed by the purge in this round: the field, the page wiring and the hub copy were already in the tree.
2560doneP2easy5mlow#2560 fix(links): closed as already satisfied - the broken-link checker resolves every relative href. Task #2519 put the resolution in `src/lib/link-targets.ts`, where `resolveLinkTargets()` resolves every anchor's href against the audited page and returns the absolute URLs to fetch, and the endpoint reads it through `resolveLinkTargets(hrefs, url)`. A root-relative href is therefore fetched and counted like any other link, and the cap was raised to 500 to clear the home page's 402. `tests/unit/broken-link-checker-api.test.ts` (11 cases) pins it: a root-relative href is checked and reported with its source href, a `../` href resolves against the page that was fetched, a relative href and an absolute one to the same page are one link, and a `mailto:`, a `tel:` and a fragment are not links at all. Closed by the purge in this round.
2572doneP2easy10mmed#2572 feat(tools): closed as already satisfied - `/tools/aio-inspector` ships the widget. The page is built from `src/pages/tools/[slug].astro`, and all three halves are in place: `src/components/tools/aio-inspector.svelte` exists, the slug is in `REGISTERED_WIDGETS` and the template mounts `<AioInspector client:load />`, and the tool record in `src/data/tools.ts` carries `clientOnly: true` and `processing: "server"`. `tests/unit/tool-widgets.test.ts` compares the three lists - the `clientOnly` flags, the registry and the mount blocks - so a tool that is flagged, registered and never mounted is an error, and the three agree at 26 entries each. Closed by the purge in this round.
2546doneP1med30mmed#2546 test(tools): the tool page's widget block is now checked against the registry. `tests/unit/tool-widgets.test.ts` grew a third list to the pair it already compared: `clientOnly: true` in `src/data/tools.ts`, the `REGISTERED_WIDGETS` set in `src/pages/tools/[slug].astro`, and the `{tool.slug === "..." && <X client:load />}` mount block. A tool that is flagged, registered and never mounted is now an error, and the three lists agree at 26 entries each. The check is built on a `mountFindings()` helper that re-derives the lists from a mutated source, so three mutation controls - drop a mount, drop a registry entry, drop the `client:` directive - each turn the suite red. The case that motivated the task, `keyword-density-checker`, flagged but never mounted, is the one the registry now carries.
2487doneP1med45mhigh#2487 chore(repo): the `src/` tree is LF-only and `biome check src/` is green. 93 files under `src/` shipped CRLF - 73 `.astro`, 14 `.svelte`, 4 `.mdx`, 1 `.ts`, 1 `.css` - and the push-time lint gate (`npm run lint`, which is `biome check src/`) was red on 15 of them: 13 `format` diagnostics and 2 `assist/source/organizeImports`. The prescribed command is `biome check --write src/`, and it does not do what the task assumed. Biome normalises the script block of a `.astro` file and the frontmatter of a `.ts` file, but it leaves a template body and a `<style>` block alone, so 91 files kept their CRLF after the write and `biome check src/` still reported them. The command was run - "Checked 220 files. Fixed 15 files." - and the rest of the tree was then normalised mechanically: every file under `src/` rewritten CRLF to LF, 10,425 line endings converted, and a scan now finds 0 files carrying CRLF under `src/`. `npm run lint` exits 0 ("Checked 220 files in 105ms. No fixes applied."), `node scripts/audit-biome-warnings.cjs` exits 0, and `npx tsc --noEmit` is clean. Two suites still fail, both before this change and both already tracked: `tests/unit/search-busy-indicator.test.ts` (todo #2534) and `tests/unit/audit-scripts.test.ts` (todo #2535). The `audit-list` assertion inside the second suite failed on the first run and passed on the next; `scripts/audit-list.cjs` drives a cold dev server on 127.0.0.1:1126, so that is the port race, not a defect. The one-line-ending rule for the rest of the repository is todo #2581 and the guard that keeps it is todo #2582.
2536doneP1easy10mmed#2536 chore(audit): `audit-biome-warnings` is green again. The audit reported 17 findings across 15 files, every one of them at `error` severity - 15 `format` diagnostics and 2 `assist/source/organizeImports` - and `error` diagnostics are deliberately not allowlistable, so the gate could not be talked out of failing: the allowlist is empty and it stays empty, because the fix was to make the tree match Biome's output rather than to accept a warning. After the `biome check --write src/` pass and the line-ending normalisation the audit prints "220 file(s) checked, 0 error(s), all 0 known warning(s) match. Biome is clean." and exits 0. The non-vacuity guards still hold: 220 files is above the audit's 100-file floor and Biome withheld no diagnostics, so a green run still means the tree was really checked rather than silently skipped.
2525doneP2easy15mmed#2525 chore(repo): `.gitattributes` pins one line ending for the repository. The file did not exist, so the tree's line endings were decided by whichever editor last touched a file: `git ls-files --eol` reported 42 files committed with CRLF in the index - `AGENTS.md`, `specs.md`, `astro.config.ts`, `tsconfig.json`, 14 scripts, 21 test files, one workflow and one tracked HTML artifact - and 464 files carrying CRLF in the working tree. `* text=auto eol=lf` lets Git decide what is text and writes LF into the working tree on every platform, so a Windows checkout matches the Linux CI checkout byte for byte; `*.bat`, `*.cmd` and `*.ps1` keep `eol=crlf` because `cmd.exe` parses a batch file by line; the font, PDF and image assets are marked `binary` so nothing is converted. `git check-attr` confirms the three cases. The `src/` half of the drift is gone with #2487; the 42 files still committed with CRLF are now tracked as todo #2581, because the attribute takes effect on the next commit rather than on the checkout already on disk.

This log reflects completed tasks only. Pending work is tracked in todo.md at the root of this repository.