Skip to content

Toronto 2026 election: SEO guide pages, verified voting facts, daylight hero - #74

Merged
mikaalnaik merged 12 commits into
mainfrom
mikaal/toronto-election-seo-pages
Sep 4, 2026
Merged

Toronto 2026 election: SEO guide pages, verified voting facts, daylight hero#74
mikaalnaik merged 12 commits into
mainfrom
mikaal/toronto-election-seo-pages

Conversation

@mikaalnaik

Copy link
Copy Markdown
Contributor

Two new SEO landing pages for the Toronto 2026 election, a fact-check pass over the voting information on them, a lot more internal linking into the candidate tracker, and a hero that follows the city's actual daylight.

New pages

/toronto/vote/2026/when-is-the-election — owns the "when" queries. Live countdowns to advance voting, the vote-by-mail deadlines and election day, the full calendar as a table, and FAQPage + VotingEvent JSON-LD.

/toronto/vote/2026/how-to-vote — owns "who, what and how". Eligibility, the five steps, what ID to bring, what's on the ballot. Its FAQs are deliberately disjoint from the other page's: both target the same election, so answering the same questions would have them competing in the index instead of covering the query space between them.

Supporting bits: LiveCountdown, src/lib/elections/dates.ts, an Event/VotingEvent schema generator, sitemap entries, and OG images for both pages.

Voting facts, verified

Checked every claim on /how-to-vote against the City Clerk's own pages — key dates, who-can-vote, identification, voters' list, mail-in voting, voting options. The calendar was exact. Election day, advance voting, both mail-in deadlines, the voters'-list cutoff and nomination day all match toronto.ca to the minute, and every weekday label is right for 2026. Four other things were not:

WasNow
Eligibility"All four conditions" — three listedNames the fourth, "not prohibited from voting under any law"
Ways to voteThree; mail-in called "the only one with a deadline"Four — proxy voting added, with its City Clerk certification window. Two have deadlines
Accepted ID"any document issued by ... a municipality"Scoped to Ontario, must show your Toronto address. Passports are not accepted (no address) — now its own myth box
Step copyStatic — still said "apply by mail" after applications closedgetVotingSteps(now), five phases, boundaries derived from key-dates.ts. Hourly revalidate, since two land at 4:30 p.m. and 7 p.m.

The passport one is the likeliest way the page got somebody turned away at the polls.

More paths into the candidate tracker

/when-is-the-election answered a date query and then left the reader nowhere to go. 40 internal links now, up from three: a prose line under the hero CTA, a new #ballot section above the calendar (mayoral race, ward finder, and a crawlable list of all 25 wards with live candidate counts), CTAs under three FAQ answers, and four links in the closing CTA.

The page reads the same roster the landing and ward pages use, so its counts can't disagree with the pages it links to, and it falls back to the local roster when the API is unreachable.

Trap worth knowing about, for anyone reusing these fields:view.raceCount and view.candidateCount span every race, trustees included. Quoting them as "mayor and council" is wrong — first pass rendered "362 candidates across all 55 mayor and council races". The narrower figure is derived instead. Summing ward.count is safe in Toronto because each ward elects its own councillor; in Brampton one race covers two wards and the same sum would double-count.

Daylight hero

The /toronto hero now matches the sky over the city it's a picture of — the existing blue-hour frame at night, a new daylight frame by day, with a subtle sun/moon toggle in the corner to override it.

Which one you land on comes from the sun's actual elevation over Toronto (src/lib/daylight.ts, NOAA solar equations) rather than fixed hours — a 7am–7pm window shows a sunlit skyline at 6:30pm in December, 90 minutes after dark. Validated against published sunrise/sunset: exact to the minute at both solstices and the March equinox, worst case four minutes elsewhere. It uses Toronto's daylight, not the reader's.

The scrim and every type colour move with the photograph. The day scrim is heavier on purpose: composited and measured on the real frame, the darkest pixels behind the centred type sit at 5% grey, so the veil has to lift the band to 152/255 before charcoal clears WCAG AA — it now does, at 5.2:1. The day lead is full-strength charcoal rather than a tint for the same reason; at 90% it measured 4.4:1, just under AA.

Only the photograph you land on is in the DOM initially — the hero is the LCP element, and mounting both would put a second full-bleed image on the critical path for a toggle most people never touch. The other mounts on first use and stays, so later switches cross-fade. The mode is decided server-side and handed to HeroSurface as initial state, so nothing swaps under the reader after hydration.

The last commit also carries the rest of the in-progress homepage redesign that was sitting uncommitted: the 25-ward outline map on the tracker card, trimmed card copy, focus rings, and the HeroEmailCaptureEmailCapture rename that gave the form its light/dark tone prop — which is what lets it invert with the hero.

Notes for review

  • Ephemeral toggle by design. The hero override resets on navigation, no localStorage. Automatic daylight is the intended default and persisting a manual choice would quietly fight it. Easy to change if you'd rather it stuck.
  • The hero copy is the one place still asserting mail-in as an open option year-round; it reads as an evergreen description of the election and the step section carries the phase-accurate status. Flagging in case you want it date-aware too.
  • I could not run a green next build locally — it compiles clean, then prerendering dies on ECONNREFUSED reaching the API (/about on one run, /sitemap.xml on the next). Unrelated to these changes; the elections layer degrades to its local roster as designed. tsc --noEmit and eslint are clean, and every page was verified rendering against the dev server. Worth a look at the Vercel preview build.

🤖 Generated with Claude Code

mikaalnaikand others added 5 commits September 3, 2026 10:58
Fact-checked every claim on /toronto/vote/2026/how-to-vote against the
City Clerk's own pages. The calendar was exact; four things were not.
- Step one asserted "all four conditions" while listing three. It now
names the fourth, "not prohibited from voting under any law".
- Proxy voting was missing entirely, and the page claimed mail-in was
"the only one with an application deadline". Toronto has four ways to
vote and two of them have deadlines: the proxy appointment form must
be certified by the City Clerk up to 4:30 p.m. on election day, and
on the day itself only at City Hall. Adds a proxy FAQ and links the
City's voting-options page.
- The accepted-ID list read "any document issued by the government of
Canada, Ontario, or a municipality", which invites a reader to bring
a passport. The City lists passports as *not* acceptable, since they
carry no address. Tightened to "an Ontario municipality ... as long
as it shows your Toronto address" and added a myth box saying so
outright — this is the likeliest way the page got someone turned
away at the polls.
- The steps were static, so the page kept telling readers to apply for
a mail-in package after applications closed. VOTING_STEPS becomes
getVotingSteps(now), with the phase boundaries derived from
./key-dates rather than retyped, so they cannot drift from the
calendar the countdown page uses. Five phases, from
before-mail-deadline through to over. revalidate drops to hourly,
because two of the boundaries land at 4:30 p.m. and 7 p.m. and a
daily one would leave the wrong copy up for most of a day.
Also folds in the EI/OAS/CPP benefit statements the City lists under
accepted ID.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The page answers a date query and then left the reader nowhere to go.
It now carries 40 internal links where it carried three.
- A prose line under the hero CTA, for the majority of readers who
already know the date: the mayoral field, their ward's council race,
and the how-to-vote guide. Text rather than buttons, so MyVote stays
the only solid call to action in the hero.
- A new #ballot section above the calendar table, so a reader who
bounces after the countdown has still passed a link to the races. It
carries the mayoral race, the ward finder, and a crawlable list of
all 25 wards linking to their own pages with live candidate counts.
- Optional CTAs under three FAQ answers. Deliberately not folded into
the JSON-LD answer text: a schema answer that reads "click here" is
worse than one that doesn't, and the markup gets stripped anyway.
- Four links in the closing CTA instead of three, with the mayoral one
promoted to the solid variant.
The page is now async and reads the same roster the landing and ward
pages use, so its counts cannot disagree with the pages it links to,
and it falls back to the local roster when the API is unreachable.
One trap worth recording: view.raceCount and view.candidateCount span
*every* race, trustees included, so quoting them as "mayor and council"
is wrong — it read "362 candidates across all 55 mayor and council
races". The narrower figure is derived instead, one mayoral race plus
one per ward. Summing ward.count is safe in Toronto because each ward
elects its own councillor; in Brampton one race covers two wards and
the same sum would double-count.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The hero photograph now matches the sky over the city it is a picture
of: the existing blue-hour frame at night, a new daylight frame by day,
with a subtle sun/moon toggle in the corner to override it.
Which one you land on comes from the sun's actual elevation over
Toronto (src/lib/daylight.ts, NOAA low-precision solar equations)
rather than fixed clock hours — a 7 a.m.–7 p.m. window shows a sunlit
skyline at 6.30 p.m. in December, an hour and a half after dark. It
works off the UTC instant plus a longitude, so there is no timezone
lookup and no DST edge case. Validated against published sunrise and
sunset: exact to the minute at both solstices and the March equinox,
worst case four minutes elsewhere. Toronto's daylight, not the
reader's — someone opening this from Vancouver at 5 p.m. their time is
looking at Toronto at 8 p.m.
The scrim and every type colour move with the photograph, because
light copy over a dark wash reads as evening, dark copy over a pale
wash reads as afternoon, and either one on the wrong picture is
illegible. The day scrim is the heavier of the two on purpose:
composited and measured on the real frame, the darkest pixels behind
the centred type sit at 5% grey, and the veil has to lift the band to
152/255 before charcoal clears WCAG AA on the worst of them. It now
does, at 5.2:1. The day lead is full-strength charcoal rather than a
tint for the same reason — at 90% it measured 4.4:1, just under AA.
Only the photograph the reader landed on is in the DOM to begin with;
the hero is the LCP element and mounting both would put a second
full-bleed image on the critical path for a toggle most people never
touch. The other mounts on first use and then stays, so later switches
cross-fade. Both scrims are mounted from the start, because gradient
utilities cannot transition into one another.
The mode is decided on the server and handed to HeroSurface as its
initial state, so nothing swaps under the reader after hydration. The
override is deliberately ephemeral: automatic daylight is the intended
default and persisting a manual choice would quietly fight it.
Also carries the rest of the in-progress homepage redesign that was
sitting uncommitted in page.tsx: the 25-ward outline map on the
candidate-tracker card, trimmed card copy, card focus rings, and the
HeroEmailCapture -> EmailCapture rename that gave the form its
light/dark tone prop — which is what lets it invert with the hero.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@greptile-apps

greptile-appsBot commented Sep 3, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds Toronto 2026 election guides, date-aware voting instructions, expanded candidate discovery links, and a daylight-responsive Toronto homepage hero.

  • Adds dedicated election-date and how-to-vote landing pages with countdowns, structured metadata, social previews, and sitemap coverage.
  • Corrects voting facts and updates voting steps in the browser as deadline phases change.
  • Expands candidate and ward navigation while retaining local election-data fallback behavior.
  • Introduces day/night hero imagery, a manual visual override, and coordinated subscription and homepage presentation updates.

Confidence Score: 4/5

The PR is not yet safe to merge because the vote-by-mail guide still omits proxy voting from its explicit comparison of available methods.

The follow-up fixes the three previously reported locations, but the coordinated voting guidance remains materially inconsistent: the vote-by-mail page still tells readers about only three methods, which can conceal the proxy option from voters unable to attend personally.

Files Needing Attention: src/app/toronto/vote/2026/vote-by-mail/page.tsx

Important Files Changed

FilenameOverview
src/app/toronto/vote/2026/vote-by-mail/page.tsxThe public method-comparison section remains inconsistent with the four-option guidance because it omits proxy voting.
src/app/toronto/vote/2026/when-is-the-election/page.tsxThe prior proxy-voting omission is corrected by explicitly describing the fourth method and its certification deadline.
src/app/toronto/vote/2026/how-to-vote/VotingSteps.tsxClient-side phase recalculation removes the previously reported hourly stale-action window for JavaScript-enabled readers.
src/app/toronto/vote/2026/voter-guide.tsPhase-aware copy now preserves the mail-ballot return deadline until its actual October 14 cutoff.
src/app/toronto/page.tsxThe homepage redesign adds daylight-aware hero presentation and uses only trusted internal values at its changed navigation links.
src/lib/daylight.tsAdds server-side solar-elevation calculation used to select the Toronto hero's initial day or night state.
src/app/sitemap.tsAdds public Toronto election guides and ward routes while retaining redirect and duplicate filtering.

Fix all with GreploopFix All in CodexFix All in Claude Code

Prompt To Fix All With AI
### Issue 1
src/app/toronto/vote/2026/vote-by-mail/page.tsx:360-363
**Proxy voting remains omitted**
When a voter who cannot attend a voting place reads this comparison, it lists only mail, advance voting, and election-day voting, omitting the proxy option and causing the reader to conclude incorrectly that no usable voting method remains.
```suggestion <p className="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6"> Mail is one of four ways to vote. There is also six days of advance voting, election day itself, and voting by proxy. </p>```---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (7): Last reviewed commit: "Address review: proxy voting, stale step..." | Re-trigger Greptile

Comment threadsrc/app/toronto/vote/2026/when-is-the-election/page.tsx Outdated
Comment on lines +16 to +21
/* Hourly, matching the key-dates page. Nothing here counts down, but the
"pick how you want to vote" step changes as the mail-in, advance-voting and
online-registration deadlines pass — and two of those land at 4:30 p.m. and
7 p.m., so a daily revalidate would leave the wrong copy up for most of a
day. */
export const revalidate = 3600;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1ISR retains expired actions

When a cached page is served shortly after the September 24, 4:30 p.m. deadline, getVotingSteps() still uses the phase captured during the prior hourly regeneration and displays the “Apply to vote by mail” action. This directs voters to an unavailable application path after applications have closed.

Knowledge Base Used:Municipal election engagement

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/toronto/vote/2026/how-to-vote/page.tsx
Line: 16-21
Comment:
**ISR retains expired actions**
When a cached page is served shortly after the September 24, 4:30 p.m. deadline, `getVotingSteps()` still uses the phase captured during the prior hourly regeneration and displays the “Apply to vote by mail” action. This directs voters to an unavailable application path after applications have closed.
**Knowledge Base Used:**[Municipal election engagement](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/municipal-elections.md)---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in CodexFix in Claude Code

mikaalnaikand others added 2 commits September 3, 2026 14:46
The questions are written but the results are not live yet, so the card
sells being told when they are instead of linking to the page. The CTA
becomes "Compare your view to the candidates" and opens the subscribe
modal under its own title, "Be the first to know when we launch" —
everything below the title is the weekly digest modal unchanged, since
that is the list people would be joining.
The modal title is now overridable: openModal takes an optional
headline, and closeModal clears it, or the next opener inherits a
promise that was never made to them. Every existing caller is
untouched and still gets the default.
Signups from here report source "survey-interest" rather than
borrowing "inline", so the funnel stays distinct from the hero's email
capture in PostHog and in HubSpot's placement field. The source union
now lives in the store as SubscribeSource instead of being spelled out
in three places.
The card could not stay a single anchor: its action is a modal, and a
<button> may only contain phrasing content while these cards hold a
heading and a list. So the navigating card is still one anchor, and
this one becomes a plain container with a real button at its foot
stretched over the whole card by an ::after overlay. Both keep one hit
area and one accessible name.
Nothing on /toronto links to the survey page now. That page is already
noindex and excluded from the sitemap, so this orphans nothing that
was indexed; ElectionCard carries a note to swap href={SURVEY_PATH}
back in at launch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ExitIntentHandler already did the work — it opens the subscribe modal
when the pointer crosses the top edge of the viewport, which is the
gesture that ends at the tab bar or the close button — but the root
homepage was the only thing mounting it, so none of the Toronto pages
ever offered it. Mounts it on /toronto's layout, which covers the
memos, the election tracker, the ward pages and the guides.
On the layout rather than the pages so the listener survives
client-side navigation: someone who reads three ward pages is asked at
most once, not once per page.
The two gates are unchanged and are the whole feature, so the rule is
now a pure exported function with the reasoning written down beside
it: never ask someone who has already subscribed, and never ask
someone who has already declined until that dismissal is a week old.
Worth knowing that `dismissed` is set by SubscribeModal on any close
without a signup, so opening the modal from the navbar and closing it
counts as having been asked.
Deliberately not listening for the tab actually closing. beforeunload
cannot render custom UI and Cmd/Ctrl+W is not interceptable, so the
mouse-toward-the-close-button gesture is the only honest signal; a
visibilitychange listener would have fired on an innocent Cmd+Tab.
mouseleave never fires on touch, which makes this desktop-only by
nature.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment threadsrc/app/toronto/vote/2026/voter-guide.ts Outdated
mikaalnaikand others added 5 commits September 4, 2026 08:04
/when-is-the-election was answering four questions at once. It carried
three live countdown cards, a three-column explainer of every voting
method, and eight FAQs covering early voting, mail deadlines,
eligibility and ID — which buried the one fact most of its readers
came for, which is the date of election day.
It is now the election-day countdown and the full calendar, and the
two periods with real detail of their own have their own pages:
/toronto/vote/2026/advance-voting — the six days, the hours, why
the places differ from election day's, registering on arrival,
and the accessibility services
/toronto/vote/2026/vote-by-mail — both deadlines, how to request
a package, the ward drop boxes, and the change-of-mind rule
The hub keeps a three-up signpost row in place of the countdown
cards, so a reader who needs one of those still finds it in the same
spot. Visible copy on the page drops 36%.
The mail page's hero counts down whichever deadline is still live: the
application until 4:30 p.m. on September 24, the return deadline after
that. Counting down a deadline that has already gone is worse than
counting down nothing, and its CTA swaps with it. Verified at both
boundaries.
Both new pages revalidate hourly, like the hub, since their countdowns
are baked into the HTML for crawlers and no-JS readers.
Two things this fixes as a side effect:
- No FAQ question now appears on more than one of the four pages. Six
had been competing, two of them near word-for-word duplicates of
/how-to-vote's. 26 questions, each owned by exactly one page.
- The VotingEvent markup was being emitted for all four periods on the
key-dates page. Each event is now described once, on the page that
owns it, instead of two URLs bidding for the same result.
Also drops a claim I could not source: the advance-voting blurb said
you may vote at any advance place in your ward. toronto.ca does not
say that anywhere I could find — it says your assigned place is on
your voter information card and in MyVote — so the new pages say that
instead. The "no reason required" half is standard and stays.
Adds PeriodCountdown, since the hero, both new pages and the mail
page's two deadline cards all needed the same server-reads-once,
client-ticks-forward dance, and getting `targetInstant ?? 0` wrong in
one of them would have shipped a stopped clock. Sitemap, OG images and
the landing page's guide links all pick up the two new routes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three running countdowns on one page, and the two smaller ones were
competing with the hero's — which is the only one a reader needs,
since it already shows whichever of the two deadlines is still live.
The deadline cards now state their dates and say what happens if you
miss them, which is what they were for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three P1s from the review, all of them ways the pages could tell a voter
something that costs them their ballot.
Proxy voting was missing wherever the pages compared methods. voter-guide
already had it at four; /when-is-the-election, the how-to-vote description
and OG subtitle, and the advance-voting cross-link all still said three. The
key-dates section gets the fourth as a line under the grid rather than a
card — proxy has no date of its own and no page of ours to link to — and the
copy that counted methods now counts four.
The steps could go stale for an hour. The page is ISR-cached hourly and the
steps are phase-dependent, so a cached copy could keep offering the mail-in
application after 4:30 p.m. on Sept 24, or online registration after 7 p.m.
on Oct 11 — the hour when people are actually looking. VotingSteps re-derives
from the reader's own clock on mount and every minute after, same shape as
LiveCountdown: server props verbatim on first render, effect takes over.
Crawlers still get the server's copy, never more than an hour old.
The mail-in return deadline vanished between Oct 11 at 7 p.m. and Oct 14 at
noon. It sits three days inside `election-day-only`, so the phase copy
dropped it while there were still voters holding a package. waysToVoteBody
takes `now` and keeps the sentence until the deadline actually passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Comment on lines +360 to +363
<p className="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6">
Mail is one of the ways. There is also six days of advance
voting, and election day itself.
</p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1Proxy voting remains omitted

When a voter who cannot attend a voting place reads this comparison, it lists only mail, advance voting, and election-day voting, omitting the proxy option and causing the reader to conclude incorrectly that no usable voting method remains.

Suggested change
<pclassName="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6">
Mailisoneoftheways.Thereisalsosixdaysofadvance
voting,andelectiondayitself.
</p>
<pclassName="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6">
Mailisoneoffourwaystovote.Thereisalsosixdaysof
advancevoting,electiondayitself,andvotingbyproxy.
</p>

Knowledge Base Used:Municipal election engagement

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/app/toronto/vote/2026/vote-by-mail/page.tsx
Line: 360-363
Comment:
**Proxy voting remains omitted**
When a voter who cannot attend a voting place reads this comparison, it lists only mail, advance voting, and election-day voting, omitting the proxy option and causing the reader to conclude incorrectly that no usable voting method remains.
```suggestion <p className="font-serif text-[1.15rem] leading-[1.45] max-w-[34ch] mb-6"> Mail is one of four ways to vote. There is also six days of advance voting, election day itself, and voting by proxy. </p>```**Knowledge Base Used:**[Municipal election engagement](https://app.greptile.com/buildcanada/-/custom-context/knowledge-base/buildcanada/tradingpost/-/docs/municipal-elections.md)---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in CodexFix in Claude Code

@mikaalnaik
mikaalnaik merged commit 22c4c17 into mainSep 4, 2026
2 checks passed
@mikaalnaikmikaalnaik mentioned this pull request Sep 8, 2026
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@mikaalnaik