diff --git a/daniel.html b/daniel.html new file mode 100644 index 00000000..f651aca7 --- /dev/null +++ b/daniel.html @@ -0,0 +1,1340 @@ + + + + + +Daniel Di Giorgio — City Councillor for York South-Weston + + + + + + + + + + +
+ On October 26, VOTE for CHANGE +
+ + + + + +
+
+
+
Elect Daniel
+

DI GIORGIO

+
City Councillor · York South-Weston
+ +
+
Trusted
+
Committed
+
Accountable
+
+ + + + +
+
+
+ Daniel Di Giorgio, candidate for City Councillor in York South-Weston +
+
+
Daniel Di Giorgio
+
Candidate · York South-Weston
+
+
+
+
+ + +
+
+
+
+
+ Daniel Di Giorgio +
+
+
+
A Personal Message
+
A Message from Daniel Di Giorgio
+

Dear Resident,

+

I am proud to be running for City Councillor in York South-Weston. I have been a strong local advocate, standing up for residents and delivering real results. We need a councillor who puts the community first. Residents are our most valuable resource, and with reliable leadership, we can achieve remarkable progress together.

+

Toronto faces serious challenges and City Hall needs common sense. Crime is rising, traffic is worsening, public transit is failing, and property taxes are out of control. I will bring practical solutions, responsible spending and leadership that puts residents first.

+

It's time for change. Together, we can make our City stronger, safer and more affordable.

+
+ Daniel Di Giorgio + Candidate · City Councillor · York South-Weston +
+ + + Why I'm Running → + +
+
+
+
+ + +
+
+
+
+
+ Daniel Di Giorgio +
+
+ Daniel Di Giorgio at community Christmas event +
+
+
+
About Me
+

A neighbour. A community advocate.

+

I've spent a decade taking care of this neighbourhood — knocking on doors, showing up at community events, and working alongside the residents who make York South-Weston what it is. Now I'd like to do it as your councillor.

+

I'm a graduate of the University of Toronto in Engineering, with a diverse background in project management, consulting, construction, and finance. That experience has given me a practical, results-driven approach to problem-solving — one I intend to bring to every decision at City Hall.

+

I've also served two terms as a locally elected trustee, advocating for the families and students of York South-Weston. That role taught me how to navigate institutions, push for accountability, and deliver results for the people I represent.

+

I'm a lifelong resident of York South-Weston — and I'm motivated to see this area, and the City of Toronto, reverse its decline and reclaim its potential.

+ +
+
+
+
+ + +
+
+
+
+
My Priorities
+

Practical solutions. Residents first.

+

A focused agenda for City Hall — built on common sense, responsible spending, and leadership that can be trusted to deliver for York South-Weston.

+
+
+
+ Daniel Di Giorgio with community members +
+
+ Daniel Di Giorgio with football coaches +
+
+ Daniel Di Giorgio with local business owner +
+
+
+ +
+
+
1
+
+

Tackle Rising Crime

+
+
+ +
+
2
+
+

Fix Worsening Traffic

+
+
+ +
+
3
+
+

Demand Reliable Transit

+
+
+ +
+
4
+
+

Control Property Taxes

+
+
+ +
+
5
+
+

Parks & Public Spaces

+
+
+ +
+
6
+
+

Thoughtful Development

+
+
+
+
+
+ + +
+
+
+
+
Get Involved
+

Together, we can make our city stronger.

+

Whether you have an hour or a Saturday afternoon, there is a meaningful place for you in this campaign.

+ +
+
+ Daniel Di Giorgio in the community +
+
+ +
+
    +
  • +
    01
    +
    +

    Volunteer Your Time

    +

    Door-knock, make calls.

    +
    +
    +
  • +
  • +
    02
    +
    +

    Display a Lawn Sign

    +

    Email votedigiorgio@gmail.com with your request.

    +
    +
    +
  • +
  • +
    03
    +
    +

    Contribute to the Campaign

    +

    Email votedigiorgio@gmail.com for instructions.

    +
    +
    +
  • +
  • +
    04
    +
    +

    Spread the Word

    +

    Follow, share, and tell a friend. Grassroots begins with a conversation.

    +
    +
    +
  • +
+ +
+

Add your name.

+

Sign up to show your support and stay connected with the campaign.

+ Count Me In → +
+
+
+
+ + +
+

Get in touch.

+

I want to hear from you. Reach out directly.

+
+ + + 647-680-9069 + + + + votedigiorgio@gmail.com + + + + Facebook + + + + Instagram + +
+
+ + +=16' + '@dimforge/rapier3d-compat@0.12.0': + resolution: {integrity: sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==} + + '@dimforge/rapier3d-compat@0.19.2': + resolution: {integrity: sha512-AZHL1jqUF55QJkJyU1yKeh4ImX2J93bVLIezT1+o0FZqTix6O06MOaqpKoJ4MmbDCsoZmwO+qc471/SDMDm2AA==} + '@emnapi/core@1.9.1': resolution: {integrity: sha512-mukuNALVsoix/w1BJwFzwXBN/dHeejQtuVzcDsfOEsdpCumXb/E9j8w11h5S54tT1xhifGfbbSm/ICrObRb3KA==} @@ -766,9 +790,17 @@ packages: '@kurkle/color@0.3.4': resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mediapipe/tasks-vision@0.10.17': + resolution: {integrity: sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==} + '@mongodb-js/saslprep@1.4.11': resolution: {integrity: sha512-o9rAHc0IpIjuPSxRutWpE1F62x7n+4mVS4rCNHkzhIUMQcc18bb6xEq5wd2NdN0WjepIyXIppRshYI2kQDOZVA==} + '@monogrid/gainmap-js@3.4.0': + resolution: {integrity: sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==} + peerDependencies: + three: '>= 0.159.0' + '@napi-rs/wasm-runtime@0.2.12': resolution: {integrity: sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==} @@ -1439,6 +1471,49 @@ packages: '@radix-ui/rect@1.1.1': resolution: {integrity: sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==} + '@react-three/drei@10.7.7': + resolution: {integrity: sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==} + peerDependencies: + '@react-three/fiber': ^9.0.0 + react: ^19 + react-dom: ^19 + three: '>=0.159' + peerDependenciesMeta: + react-dom: + optional: true + + '@react-three/fiber@9.6.1': + resolution: {integrity: sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==} + peerDependencies: + expo: '>=43.0' + expo-asset: '>=8.4' + expo-file-system: '>=11.0' + expo-gl: '>=11.0' + react: '>=19 <19.3' + react-dom: '>=19 <19.3' + react-native: '>=0.78' + three: '>=0.156' + peerDependenciesMeta: + expo: + optional: true + expo-asset: + optional: true + expo-file-system: + optional: true + expo-gl: + optional: true + react-dom: + optional: true + react-native: + optional: true + + '@react-three/rapier@2.2.0': + resolution: {integrity: sha512-mVsqbKXlGZoN+XrqdhzFZUQmy8pibEOVzl4k7LC+LHe84bQnYBSagy1Hvbda6bL1PJDdTFyiDiBk5buKFinNIQ==} + peerDependencies: + '@react-three/fiber': ^9.0.4 + react: ^19 + three: '>=0.159.0' + '@react-types/shared@3.34.0': resolution: {integrity: sha512-gp6xo/s2lX54AlTjOiqwDnxA7UW79BNvI9dB9pr3LZTzRKCd1ZA+ZbgKw/ReIiWuvvVw/8QFJpnqeeFyLocMcQ==} peerDependencies: @@ -1574,6 +1649,9 @@ packages: '@turf/polygon-to-line@7.3.5': resolution: {integrity: sha512-Mat5tvJcW3grpXCNFcMvjHL3d8hO4eoIgF3qYpXj25BHx/S7SJUOgyCV5x3arC0rCfM/cB71VmNDm9k57ec7bw==} + '@tweenjs/tween.js@23.1.3': + resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==} + '@tybys/wasm-util@0.10.1': resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==} @@ -1583,6 +1661,9 @@ packages: '@types/debug@4.1.13': resolution: {integrity: sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==} + '@types/draco3d@1.4.10': + resolution: {integrity: sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==} + '@types/estree-jsx@1.0.5': resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} @@ -1610,6 +1691,9 @@ packages: '@types/node@20.19.37': resolution: {integrity: sha512-8kzdPJ3FsNsVIurqBs7oodNnCEVbni9yUEkaHbgptDACOPW04jimGagZ51E6+lXUwJjgnBw+hyko/lkFWCldqw==} + '@types/offscreencanvas@2019.7.3': + resolution: {integrity: sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==} + '@types/parse-json@4.0.2': resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} @@ -1618,6 +1702,11 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react-reconciler@0.28.9': + resolution: {integrity: sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==} + peerDependencies: + '@types/react': '*' + '@types/react-transition-group@4.4.12': resolution: {integrity: sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w==} peerDependencies: @@ -1626,6 +1715,12 @@ packages: '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} + '@types/stats.js@0.17.4': + resolution: {integrity: sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==} + + '@types/three@0.185.1': + resolution: {integrity: sha512-db1xTb+EgYF2didW+eudSvVPtn75zo+fGsY8ShQrJY/B5ZBmC2Fiaykv3aImHAlCNEGuMPkPGXBJGLwzu5mC7A==} + '@types/trusted-types@2.0.7': resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==} @@ -1638,6 +1733,9 @@ packages: '@types/webidl-conversions@7.0.3': resolution: {integrity: sha512-CiJJvcRtIgzadHCYXw7dqEnMNRjhGZlYK05Mj9OyktqV8uVT8fD2BFOB7S1uwBE3Kj2Z+4UyPmFw/Ixgw/LAlA==} + '@types/webxr@0.5.24': + resolution: {integrity: sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==} + '@types/whatwg-url@11.0.5': resolution: {integrity: sha512-coYR071JRaHa+xoEvvYqvnIHaVqaYrLPbsufM9BF63HkwI5Lgmy2QR8Q5K/lYDYo5AK82wOvSOS0UsLTpTG7uQ==} @@ -1798,6 +1896,14 @@ packages: cpu: [x64] os: [win32] + '@use-gesture/core@10.3.1': + resolution: {integrity: sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==} + + '@use-gesture/react@10.3.1': + resolution: {integrity: sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==} + peerDependencies: + react: '>= 16.8.0' + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1894,11 +2000,17 @@ packages: resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} engines: {node: 18 || 20 || >=22} + base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + baseline-browser-mapping@2.10.12: resolution: {integrity: sha512-qyq26DxfY4awP2gIRXhhLWfwzwI+N5Nxk6iQi8EFizIaWIjqicQTE4sLnZZVdeKPRcVNoJOkkpfzoIYuvCKaIQ==} engines: {node: '>=6.0.0'} hasBin: true + bidi-js@1.0.3: + resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==} + brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -1919,6 +2031,9 @@ packages: resolution: {integrity: sha512-WIsKqkSC0ABoBJuT1LEX+2HEvNmNKKgnTAyd0fL8qzK4SH2i9NXg+t08YtdZp/V9IZ33cxe3iV4yM0qg8lMQng==} engines: {node: '>=16.20.1'} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -1935,6 +2050,12 @@ packages: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} + camera-controls@3.1.2: + resolution: {integrity: sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==} + engines: {node: '>=22.0.0', npm: '>=10.5.1'} + peerDependencies: + three: '>=0.126.1' + caniuse-lite@1.0.30001782: resolution: {integrity: sha512-dZcaJLJeDMh4rELYFw1tvSn1bhZWYFOt468FcbHHxx/Z/dFidd1I6ciyFdi3iwfQCyOjqo9upF6lGQYtMiJWxw==} @@ -2028,6 +2149,11 @@ packages: resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} engines: {node: '>=10'} + cross-env@7.0.3: + resolution: {integrity: sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==} + engines: {node: '>=10.14', npm: '>=6', yarn: '>=1'} + hasBin: true + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -2224,6 +2350,9 @@ packages: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} + detect-gpu@5.0.70: + resolution: {integrity: sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==} + detect-libc@2.1.2: resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} engines: {node: '>=8'} @@ -2244,6 +2373,9 @@ packages: dompurify@3.3.3: resolution: {integrity: sha512-Oj6pzI2+RqBfFG+qOaOLbFXLQ90ARpcGG6UePL82bJLtdsa6CYJD7nmiU8MW9nQNOtCHV3lZ/Bzq1X0QYbBZCA==} + draco3d@1.5.7: + resolution: {integrity: sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -2475,6 +2607,12 @@ packages: fflate@0.4.8: resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==} + fflate@0.6.11: + resolution: {integrity: sha512-3JyEFWGjFn7zHmoa9+zG1BmW7X2okcmAB+0Cnu9UFbVs/jCBnl2A8o065ZlXiw145K3eBM3uLuzrYXC0RK7eDg==} + + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -2573,6 +2711,9 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + glsl-noise@0.0.0: + resolution: {integrity: sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -2658,6 +2799,9 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hls.js@1.6.16: + resolution: {integrity: sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} @@ -2671,6 +2815,9 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -2679,6 +2826,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immutable@5.1.5: resolution: {integrity: sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==} @@ -2791,6 +2941,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@2.2.2: + resolution: {integrity: sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==} + is-regex@1.2.1: resolution: {integrity: sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==} engines: {node: '>= 0.4'} @@ -2840,6 +2993,11 @@ packages: resolution: {integrity: sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==} engines: {node: '>= 0.4'} + its-fine@2.0.0: + resolution: {integrity: sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==} + peerDependencies: + react: ^19.0.0 + jiti@2.6.1: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true @@ -2912,6 +3070,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.3.0: + resolution: {integrity: sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==} + lightningcss-android-arm64@1.32.0: resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} engines: {node: '>= 12.0.0'} @@ -3020,6 +3181,12 @@ packages: peerDependencies: react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0 + maath@0.10.8: + resolution: {integrity: sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==} + peerDependencies: + '@types/three': '>=0.134.0' + three: '>=0.134.0' + magic-string@0.30.21: resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} @@ -3085,6 +3252,14 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} + meshline@3.3.1: + resolution: {integrity: sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==} + peerDependencies: + three: '>=0.137' + + meshoptimizer@1.1.1: + resolution: {integrity: sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==} + micromark-core-commonmark@2.0.3: resolution: {integrity: sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==} @@ -3475,6 +3650,9 @@ packages: rxjs: optional: true + potpack@1.0.2: + resolution: {integrity: sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==} + preact-render-to-string@5.2.3: resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} peerDependencies: @@ -3498,6 +3676,9 @@ packages: pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} + promise-worker-transferable@1.0.4: + resolution: {integrity: sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -3618,6 +3799,15 @@ packages: react: '>=16.6.0' react-dom: '>=16.6.0' + react-use-measure@2.1.7: + resolution: {integrity: sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==} + peerDependencies: + react: '>=16.13' + react-dom: '>=16.13' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.3: resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==} engines: {node: '>=0.10.0'} @@ -3661,6 +3851,10 @@ packages: remeda@2.34.0: resolution: {integrity: sha512-zL4cEPkLHxwmlDRPyvJZjojpG5M5HXrDiABNKof+dq7kkuyQttP6NrF2uJB0DKIU09K8cTq+sQDlbo2r7mdR5Q==} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + requires-port@1.0.0: resolution: {integrity: sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ==} @@ -3807,6 +4001,15 @@ packages: stable-hash@0.0.5: resolution: {integrity: sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==} + stats-gl@2.4.2: + resolution: {integrity: sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==} + peerDependencies: + '@types/three': '*' + three: '*' + + stats.js@0.17.0: + resolution: {integrity: sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==} + stop-iteration-iterator@1.1.0: resolution: {integrity: sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==} engines: {node: '>= 0.4'} @@ -3884,6 +4087,11 @@ packages: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} + suspend-react@0.1.3: + resolution: {integrity: sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==} + peerDependencies: + react: '>=17.0' + sweepline-intersections@1.5.0: resolution: {integrity: sha512-AoVmx72QHpKtItPu72TzFL+kcYjd67BPLDoR0LarIk+xyaRg+pDTMFXndIEvZf9xEKnJv6JdhgRMnocoG0D3AQ==} @@ -3905,6 +4113,19 @@ packages: resolution: {integrity: sha512-1MOpMXuhGzGL5TTCZFItxCc0AARf1EZFQkGqMm7ERKj8+Hgr5oLvJOVFcC+lRmR8hCe2S3jC4T5D7Vg/d7/fhA==} engines: {node: '>=6'} + three-mesh-bvh@0.8.3: + resolution: {integrity: sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==} + peerDependencies: + three: '>= 0.159.0' + + three-stdlib@2.36.1: + resolution: {integrity: sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==} + peerDependencies: + three: '>=0.128.0' + + three@0.185.1: + resolution: {integrity: sha512-5aojFCXKwnjBRZvUnt3WFfEcvUJgkN5LlijRFN95hMy8WVkG4I0QNcJE+OuWvuJ0bOdStrbfXn0pkd6/QyiAlg==} + tinyglobby@0.2.15: resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} engines: {node: '>=12.0.0'} @@ -3933,6 +4154,19 @@ packages: trim-trailing-lines@2.1.0: resolution: {integrity: sha512-5UR5Biq4VlVOtzqkm2AZlgvSlDJtME46uV0br0gENbwN4l5+mMKT4b9gJKqWtuL2zAIqajGJGuvbCbcAJUZqBg==} + troika-three-text@0.52.4: + resolution: {integrity: sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==} + peerDependencies: + three: '>=0.125.0' + + troika-three-utils@0.52.4: + resolution: {integrity: sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==} + peerDependencies: + three: '>=0.125.0' + + troika-worker-utils@0.52.0: + resolution: {integrity: sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==} + trough@2.2.0: resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} @@ -3956,6 +4190,9 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + tunnel-rat@0.1.2: + resolution: {integrity: sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==} + tw-animate-css@1.4.0: resolution: {integrity: sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==} @@ -4076,6 +4313,10 @@ packages: peerDependencies: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + utility-types@3.11.0: + resolution: {integrity: sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==} + engines: {node: '>= 4'} + uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). @@ -4104,6 +4345,12 @@ packages: web-vitals@5.2.0: resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==} + webgl-constants@1.1.1: + resolution: {integrity: sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==} + + webgl-sdf-generator@1.1.1: + resolution: {integrity: sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} @@ -4176,6 +4423,21 @@ packages: zod@4.3.6: resolution: {integrity: sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.12: resolution: {integrity: sha512-i77ae3aZq4dhMlRhJVCYgMLKuSiZAaUPAct2AksxQ+gOtimhGMdXljRT21P5BNpeT4kXlLIckvkPM029OljD7g==} engines: {node: '>=12.20.0'} @@ -4407,6 +4669,10 @@ snapshots: dependencies: react: 19.2.3 + '@dimforge/rapier3d-compat@0.12.0': {} + + '@dimforge/rapier3d-compat@0.19.2': {} + '@emnapi/core@1.9.1': dependencies: '@emnapi/wasi-threads': 1.2.0 @@ -4789,10 +5055,17 @@ snapshots: '@kurkle/color@0.3.4': {} + '@mediapipe/tasks-vision@0.10.17': {} + '@mongodb-js/saslprep@1.4.11': dependencies: sparse-bitfield: 3.0.3 + '@monogrid/gainmap-js@3.4.0(three@0.185.1)': + dependencies: + promise-worker-transferable: 1.0.4 + three: 0.185.1 + '@napi-rs/wasm-runtime@0.2.12': dependencies: '@emnapi/core': 1.9.1 @@ -5345,6 +5618,68 @@ snapshots: '@radix-ui/rect@1.1.1': {} + '@react-three/drei@10.7.7(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1))(@types/react@19.2.14)(@types/three@0.185.1)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@mediapipe/tasks-vision': 0.10.17 + '@monogrid/gainmap-js': 3.4.0(three@0.185.1) + '@react-three/fiber': 9.6.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1) + '@use-gesture/react': 10.3.1(react@19.2.3) + camera-controls: 3.1.2(three@0.185.1) + cross-env: 7.0.3 + detect-gpu: 5.0.70 + glsl-noise: 0.0.0 + hls.js: 1.6.16 + maath: 0.10.8(@types/three@0.185.1)(three@0.185.1) + meshline: 3.3.1(three@0.185.1) + react: 19.2.3 + stats-gl: 2.4.2(@types/three@0.185.1)(three@0.185.1) + stats.js: 0.17.0 + suspend-react: 0.1.3(react@19.2.3) + three: 0.185.1 + three-mesh-bvh: 0.8.3(three@0.185.1) + three-stdlib: 2.36.1(three@0.185.1) + troika-three-text: 0.52.4(three@0.185.1) + tunnel-rat: 0.1.2(@types/react@19.2.14)(react@19.2.3) + use-sync-external-store: 1.6.0(react@19.2.3) + utility-types: 3.11.0 + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - '@types/three' + - immer + + '@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1)': + dependencies: + '@babel/runtime': 7.29.2 + '@types/webxr': 0.5.24 + base64-js: 1.5.1 + buffer: 6.0.3 + its-fine: 2.0.0(@types/react@19.2.14)(react@19.2.3) + react: 19.2.3 + react-use-measure: 2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + scheduler: 0.27.0 + suspend-react: 0.1.3(react@19.2.3) + three: 0.185.1 + use-sync-external-store: 1.6.0(react@19.2.3) + zustand: 5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + + '@react-three/rapier@2.2.0(@react-three/fiber@9.6.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1))(react@19.2.3)(three@0.185.1)': + dependencies: + '@dimforge/rapier3d-compat': 0.19.2 + '@react-three/fiber': 9.6.1(@types/react@19.2.14)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(three@0.185.1) + react: 19.2.3 + suspend-react: 0.1.3(react@19.2.3) + three: 0.185.1 + three-stdlib: 2.36.1(three@0.185.1) + '@react-types/shared@3.34.0(react@19.2.3)': dependencies: react: 19.2.3 @@ -5501,6 +5836,8 @@ snapshots: '@types/geojson': 7946.0.16 tslib: 2.8.1 + '@tweenjs/tween.js@23.1.3': {} + '@tybys/wasm-util@0.10.1': dependencies: tslib: 2.8.1 @@ -5513,6 +5850,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 + '@types/draco3d@1.4.10': {} + '@types/estree-jsx@1.0.5': dependencies: '@types/estree': 1.0.8 @@ -5539,12 +5878,18 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/offscreencanvas@2019.7.3': {} + '@types/parse-json@4.0.2': {} '@types/react-dom@19.2.3(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 + '@types/react-reconciler@0.28.9(@types/react@19.2.14)': + dependencies: + '@types/react': 19.2.14 + '@types/react-transition-group@4.4.12(@types/react@19.2.14)': dependencies: '@types/react': 19.2.14 @@ -5553,6 +5898,17 @@ snapshots: dependencies: csstype: 3.2.3 + '@types/stats.js@0.17.4': {} + + '@types/three@0.185.1': + dependencies: + '@dimforge/rapier3d-compat': 0.12.0 + '@tweenjs/tween.js': 23.1.3 + '@types/stats.js': 0.17.4 + '@types/webxr': 0.5.24 + fflate: 0.8.3 + meshoptimizer: 1.1.1 + '@types/trusted-types@2.0.7': optional: true @@ -5562,6 +5918,8 @@ snapshots: '@types/webidl-conversions@7.0.3': {} + '@types/webxr@0.5.24': {} + '@types/whatwg-url@11.0.5': dependencies: '@types/webidl-conversions': 7.0.3 @@ -5718,6 +6076,13 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@use-gesture/core@10.3.1': {} + + '@use-gesture/react@10.3.1(react@19.2.3)': + dependencies: + '@use-gesture/core': 10.3.1 + react: 19.2.3 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -5836,8 +6201,14 @@ snapshots: balanced-match@4.0.4: {} + base64-js@1.5.1: {} + baseline-browser-mapping@2.10.12: {} + bidi-js@1.0.3: + dependencies: + require-from-string: 2.0.2 + brace-expansion@1.1.13: dependencies: balanced-match: 1.0.2 @@ -5861,6 +6232,11 @@ snapshots: bson@6.10.4: {} + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -5880,6 +6256,10 @@ snapshots: callsites@3.1.0: {} + camera-controls@3.1.2(three@0.185.1): + dependencies: + three: 0.185.1 + caniuse-lite@1.0.30001782: {} ccount@2.0.1: {} @@ -5956,6 +6336,10 @@ snapshots: path-type: 4.0.0 yaml: 1.10.3 + cross-env@7.0.3: + dependencies: + cross-spawn: 7.0.6 + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -6174,6 +6558,10 @@ snapshots: dequal@2.0.3: {} + detect-gpu@5.0.70: + dependencies: + webgl-constants: 1.1.1 + detect-libc@2.1.2: {} detect-node-es@1.1.0: {} @@ -6195,6 +6583,8 @@ snapshots: optionalDependencies: '@types/trusted-types': 2.0.7 + draco3d@1.5.7: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -6600,6 +6990,10 @@ snapshots: fflate@0.4.8: {} + fflate@0.6.11: {} + + fflate@0.8.3: {} + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -6701,6 +7095,8 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + glsl-noise@0.0.0: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -6860,6 +7256,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hls.js@1.6.16: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 @@ -6872,10 +7270,14 @@ snapshots: dependencies: safer-buffer: 2.1.2 + ieee754@1.2.1: {} + ignore@5.3.2: {} ignore@7.0.5: {} + immediate@3.0.6: {} + immutable@5.1.5: optional: true @@ -6986,6 +7388,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@2.2.2: {} + is-regex@1.2.1: dependencies: call-bound: 1.0.4 @@ -7040,6 +7444,13 @@ snapshots: has-symbols: 1.1.0 set-function-name: 2.0.2 + its-fine@2.0.0(@types/react@19.2.14)(react@19.2.3): + dependencies: + '@types/react-reconciler': 0.28.9(@types/react@19.2.14) + react: 19.2.3 + transitivePeerDependencies: + - '@types/react' + jiti@2.6.1: {} jose@4.15.9: {} @@ -7098,6 +7509,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.3.0: + dependencies: + immediate: 3.0.6 + lightningcss-android-arm64@1.32.0: optional: true @@ -7179,6 +7594,11 @@ snapshots: dependencies: react: 19.2.3 + maath@0.10.8(@types/three@0.185.1)(three@0.185.1): + dependencies: + '@types/three': 0.185.1 + three: 0.185.1 + magic-string@0.30.21: dependencies: '@jridgewell/sourcemap-codec': 1.5.5 @@ -7346,6 +7766,12 @@ snapshots: merge2@1.4.1: {} + meshline@3.3.1(three@0.185.1): + dependencies: + three: 0.185.1 + + meshoptimizer@1.1.1: {} + micromark-core-commonmark@2.0.3: dependencies: decode-named-character-reference: 1.3.0 @@ -7844,6 +8270,8 @@ snapshots: dependencies: '@posthog/core': 1.30.2 + potpack@1.0.2: {} + preact-render-to-string@5.2.3(preact@10.11.3): dependencies: preact: 10.11.3 @@ -7864,6 +8292,11 @@ snapshots: pretty-format@3.8.0: {} + promise-worker-transferable@1.0.4: + dependencies: + is-promise: 2.2.2 + lie: 3.3.0 + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -8031,6 +8464,12 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) + react-use-measure@2.1.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + optionalDependencies: + react-dom: 19.2.3(react@19.2.3) + react@19.2.3: {} readdirp@4.1.2: @@ -8113,6 +8552,8 @@ snapshots: remeda@2.34.0: {} + require-from-string@2.0.2: {} + requires-port@1.0.0: {} reselect@5.1.1: {} @@ -8301,6 +8742,13 @@ snapshots: stable-hash@0.0.5: {} + stats-gl@2.4.2(@types/three@0.185.1)(three@0.185.1): + dependencies: + '@types/three': 0.185.1 + three: 0.185.1 + + stats.js@0.17.0: {} + stop-iteration-iterator@1.1.0: dependencies: es-errors: 1.3.0 @@ -8398,6 +8846,10 @@ snapshots: supports-preserve-symlinks-flag@1.0.0: {} + suspend-react@0.1.3(react@19.2.3): + dependencies: + react: 19.2.3 + sweepline-intersections@1.5.0: dependencies: tinyqueue: 2.0.3 @@ -8416,6 +8868,22 @@ snapshots: tapable@2.3.2: {} + three-mesh-bvh@0.8.3(three@0.185.1): + dependencies: + three: 0.185.1 + + three-stdlib@2.36.1(three@0.185.1): + dependencies: + '@types/draco3d': 1.4.10 + '@types/offscreencanvas': 2019.7.3 + '@types/webxr': 0.5.24 + draco3d: 1.5.7 + fflate: 0.6.11 + potpack: 1.0.2 + three: 0.185.1 + + three@0.185.1: {} + tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.4) @@ -8443,6 +8911,20 @@ snapshots: trim-trailing-lines@2.1.0: {} + troika-three-text@0.52.4(three@0.185.1): + dependencies: + bidi-js: 1.0.3 + three: 0.185.1 + troika-three-utils: 0.52.4(three@0.185.1) + troika-worker-utils: 0.52.0 + webgl-sdf-generator: 1.1.1 + + troika-three-utils@0.52.4(three@0.185.1): + dependencies: + three: 0.185.1 + + troika-worker-utils@0.52.0: {} + trough@2.2.0: {} ts-api-utils@2.5.0(typescript@5.9.3): @@ -8467,6 +8949,14 @@ snapshots: optionalDependencies: fsevents: 2.3.3 + tunnel-rat@0.1.2(@types/react@19.2.14)(react@19.2.3): + dependencies: + zustand: 4.5.7(@types/react@19.2.14)(react@19.2.3) + transitivePeerDependencies: + - '@types/react' + - immer + - react + tw-animate-css@1.4.0: {} type-check@0.4.0: @@ -8634,6 +9124,8 @@ snapshots: dependencies: react: 19.2.3 + utility-types@3.11.0: {} + uuid@8.3.2: {} uuidv7@1.2.1: {} @@ -8659,6 +9151,10 @@ snapshots: web-vitals@5.2.0: {} + webgl-constants@1.1.1: {} + + webgl-sdf-generator@1.1.1: {} + webidl-conversions@7.0.0: {} whatwg-url@14.2.0: @@ -8732,6 +9228,13 @@ snapshots: zod@4.3.6: {} + zustand@4.5.7(@types/react@19.2.14)(react@19.2.3): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.3) + optionalDependencies: + '@types/react': 19.2.14 + react: 19.2.3 + zustand@5.0.12(@types/react@19.2.14)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)): optionalDependencies: '@types/react': 19.2.14 diff --git a/public/elections/toronto/2026/councillors/ward-1/alaa-adib.png b/public/elections/toronto/2026/councillors/ward-1/alaa-adib.png new file mode 100644 index 00000000..7fa494a3 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-1/alaa-adib.png differ diff --git a/public/elections/toronto/2026/councillors/ward-1/norman-hamilton.png b/public/elections/toronto/2026/councillors/ward-1/norman-hamilton.png new file mode 100644 index 00000000..82751a07 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-1/norman-hamilton.png differ diff --git a/public/elections/toronto/2026/councillors/ward-1/vincent-crisanti.webp b/public/elections/toronto/2026/councillors/ward-1/vincent-crisanti.webp new file mode 100644 index 00000000..7e36d804 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-1/vincent-crisanti.webp differ diff --git a/public/elections/toronto/2026/councillors/ward-2/jen-alexander.jpg b/public/elections/toronto/2026/councillors/ward-2/jen-alexander.jpg new file mode 100644 index 00000000..688e1352 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-2/jen-alexander.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-2/stephen-holyday.jpg b/public/elections/toronto/2026/councillors/ward-2/stephen-holyday.jpg new file mode 100644 index 00000000..e5657d22 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-2/stephen-holyday.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-3/amber-morley.jpg b/public/elections/toronto/2026/councillors/ward-3/amber-morley.jpg new file mode 100644 index 00000000..10e44415 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-3/amber-morley.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-3/ted-opitz.png b/public/elections/toronto/2026/councillors/ward-3/ted-opitz.png new file mode 100644 index 00000000..8bb4b85c Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-3/ted-opitz.png differ diff --git a/public/elections/toronto/2026/councillors/ward-4/adam-pham.jpg b/public/elections/toronto/2026/councillors/ward-4/adam-pham.jpg new file mode 100644 index 00000000..06d730a7 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/adam-pham.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-4/debbie-king.jpg b/public/elections/toronto/2026/councillors/ward-4/debbie-king.jpg new file mode 100644 index 00000000..2dc8d88d Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/debbie-king.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-4/diana-chan-mcnally.jpg b/public/elections/toronto/2026/councillors/ward-4/diana-chan-mcnally.jpg new file mode 100644 index 00000000..e83dfb12 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/diana-chan-mcnally.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-4/holly-weber.png b/public/elections/toronto/2026/councillors/ward-4/holly-weber.png new file mode 100644 index 00000000..2424633b Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/holly-weber.png differ diff --git a/public/elections/toronto/2026/councillors/ward-4/nadia-guerrera.png b/public/elections/toronto/2026/councillors/ward-4/nadia-guerrera.png new file mode 100644 index 00000000..72f27a67 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/nadia-guerrera.png differ diff --git a/public/elections/toronto/2026/councillors/ward-4/vanessa-raponi.jpg b/public/elections/toronto/2026/councillors/ward-4/vanessa-raponi.jpg new file mode 100644 index 00000000..7c296690 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-4/vanessa-raponi.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-5/chiara-padovani.jpg b/public/elections/toronto/2026/councillors/ward-5/chiara-padovani.jpg new file mode 100644 index 00000000..a36825ae Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-5/chiara-padovani.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-5/daniel-digiorgio.jpg b/public/elections/toronto/2026/councillors/ward-5/daniel-digiorgio.jpg new file mode 100644 index 00000000..e49d86ae Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-5/daniel-digiorgio.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-5/frances-nunziata.jpg b/public/elections/toronto/2026/councillors/ward-5/frances-nunziata.jpg new file mode 100644 index 00000000..34dfdb58 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-5/frances-nunziata.jpg differ diff --git a/public/elections/toronto/2026/councillors/ward-5/nekpen-obasogie.png b/public/elections/toronto/2026/councillors/ward-5/nekpen-obasogie.png new file mode 100644 index 00000000..aee8183d Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-5/nekpen-obasogie.png differ diff --git a/public/elections/toronto/2026/councillors/ward-5/sileen-phillips.webp b/public/elections/toronto/2026/councillors/ward-5/sileen-phillips.webp new file mode 100644 index 00000000..63d43723 Binary files /dev/null and b/public/elections/toronto/2026/councillors/ward-5/sileen-phillips.webp differ diff --git a/public/elections/toronto/2026/mayor/amy-rosen.jpg b/public/elections/toronto/2026/mayor/amy-rosen.jpg new file mode 100644 index 00000000..505608d0 Binary files /dev/null and b/public/elections/toronto/2026/mayor/amy-rosen.jpg differ diff --git a/public/elections/toronto/2026/mayor/bahira-abdulsalam.png b/public/elections/toronto/2026/mayor/bahira-abdulsalam.png new file mode 100644 index 00000000..dec51f37 Binary files /dev/null and b/public/elections/toronto/2026/mayor/bahira-abdulsalam.png differ diff --git a/public/elections/toronto/2026/mayor/brad-bradford.png b/public/elections/toronto/2026/mayor/brad-bradford.png new file mode 100644 index 00000000..4d1c36bc Binary files /dev/null and b/public/elections/toronto/2026/mayor/brad-bradford.png differ diff --git a/public/elections/toronto/2026/mayor/braeden-chow.png b/public/elections/toronto/2026/mayor/braeden-chow.png new file mode 100644 index 00000000..bdcbfba3 Binary files /dev/null and b/public/elections/toronto/2026/mayor/braeden-chow.png differ diff --git a/public/elections/toronto/2026/mayor/cory-deville.png b/public/elections/toronto/2026/mayor/cory-deville.png new file mode 100644 index 00000000..ba4d0b25 Binary files /dev/null and b/public/elections/toronto/2026/mayor/cory-deville.png differ diff --git a/public/elections/toronto/2026/mayor/darrell-brown.png b/public/elections/toronto/2026/mayor/darrell-brown.png new file mode 100644 index 00000000..8dffe225 Binary files /dev/null and b/public/elections/toronto/2026/mayor/darrell-brown.png differ diff --git a/public/elections/toronto/2026/mayor/edward-gong.png b/public/elections/toronto/2026/mayor/edward-gong.png new file mode 100644 index 00000000..32c1b7ee Binary files /dev/null and b/public/elections/toronto/2026/mayor/edward-gong.png differ diff --git a/public/elections/toronto/2026/mayor/faizan-haider.jpg b/public/elections/toronto/2026/mayor/faizan-haider.jpg new file mode 100644 index 00000000..db9e09c4 Binary files /dev/null and b/public/elections/toronto/2026/mayor/faizan-haider.jpg differ diff --git a/public/elections/toronto/2026/mayor/georgios-kalkounis.jpg b/public/elections/toronto/2026/mayor/georgios-kalkounis.jpg new file mode 100644 index 00000000..bc3bf23e Binary files /dev/null and b/public/elections/toronto/2026/mayor/georgios-kalkounis.jpg differ diff --git a/public/elections/toronto/2026/mayor/heather-he.jpg b/public/elections/toronto/2026/mayor/heather-he.jpg new file mode 100644 index 00000000..703fd781 Binary files /dev/null and b/public/elections/toronto/2026/mayor/heather-he.jpg differ diff --git a/public/elections/toronto/2026/mayor/jamie-atkinson.png b/public/elections/toronto/2026/mayor/jamie-atkinson.png new file mode 100644 index 00000000..b88b0418 Binary files /dev/null and b/public/elections/toronto/2026/mayor/jamie-atkinson.png differ diff --git a/public/elections/toronto/2026/mayor/jeffery-tunney.png b/public/elections/toronto/2026/mayor/jeffery-tunney.png new file mode 100644 index 00000000..a829ab29 Binary files /dev/null and b/public/elections/toronto/2026/mayor/jeffery-tunney.png differ diff --git a/public/elections/toronto/2026/mayor/laura-ellis.png b/public/elections/toronto/2026/mayor/laura-ellis.png new file mode 100644 index 00000000..ea52dd65 Binary files /dev/null and b/public/elections/toronto/2026/mayor/laura-ellis.png differ diff --git a/public/elections/toronto/2026/mayor/lyall-sanders.jpg b/public/elections/toronto/2026/mayor/lyall-sanders.jpg new file mode 100644 index 00000000..b72e91df Binary files /dev/null and b/public/elections/toronto/2026/mayor/lyall-sanders.jpg differ diff --git a/public/elections/toronto/2026/mayor/michael-lamoureux.jpg b/public/elections/toronto/2026/mayor/michael-lamoureux.jpg new file mode 100644 index 00000000..0f7021ef Binary files /dev/null and b/public/elections/toronto/2026/mayor/michael-lamoureux.jpg differ diff --git a/public/elections/toronto/2026/mayor/naomi-sayers.jpg b/public/elections/toronto/2026/mayor/naomi-sayers.jpg new file mode 100644 index 00000000..e321c49d Binary files /dev/null and b/public/elections/toronto/2026/mayor/naomi-sayers.jpg differ diff --git a/public/elections/toronto/2026/mayor/odessa-paloma-parker.jpg b/public/elections/toronto/2026/mayor/odessa-paloma-parker.jpg new file mode 100644 index 00000000..f5124525 Binary files /dev/null and b/public/elections/toronto/2026/mayor/odessa-paloma-parker.jpg differ diff --git a/public/elections/toronto/2026/mayor/olivia-chow.png b/public/elections/toronto/2026/mayor/olivia-chow.png new file mode 100644 index 00000000..473cb128 Binary files /dev/null and b/public/elections/toronto/2026/mayor/olivia-chow.png differ diff --git a/public/elections/toronto/2026/mayor/peter-handjis.webp b/public/elections/toronto/2026/mayor/peter-handjis.webp new file mode 100644 index 00000000..6990d1b2 Binary files /dev/null and b/public/elections/toronto/2026/mayor/peter-handjis.webp differ diff --git a/public/elections/toronto/2026/mayor/rick-lee.png b/public/elections/toronto/2026/mayor/rick-lee.png new file mode 100644 index 00000000..897a1b81 Binary files /dev/null and b/public/elections/toronto/2026/mayor/rick-lee.png differ diff --git a/public/elections/toronto/2026/mayor/robert-shusterman.png b/public/elections/toronto/2026/mayor/robert-shusterman.png new file mode 100644 index 00000000..3e54cdcd Binary files /dev/null and b/public/elections/toronto/2026/mayor/robert-shusterman.png differ diff --git a/public/elections/toronto/2026/mayor/sarah-mcvie.jpg b/public/elections/toronto/2026/mayor/sarah-mcvie.jpg new file mode 100644 index 00000000..9dbb7a5c Binary files /dev/null and b/public/elections/toronto/2026/mayor/sarah-mcvie.jpg differ diff --git a/public/elections/toronto/2026/mayor/weizhen-tang.jpg b/public/elections/toronto/2026/mayor/weizhen-tang.jpg new file mode 100644 index 00000000..ae238865 Binary files /dev/null and b/public/elections/toronto/2026/mayor/weizhen-tang.jpg differ diff --git a/src/app/api/elections/pledge/route.ts b/src/app/api/elections/pledge/route.ts new file mode 100644 index 00000000..4d3ca86c --- /dev/null +++ b/src/app/api/elections/pledge/route.ts @@ -0,0 +1,78 @@ +import { NextRequest, NextResponse } from "next/server"; + +import { API_URL } from "@/lib/api/client"; + +// "Pledge to vote" submissions — same low-friction pattern as /api/subscribe. +// Forwards {email, name, region} to York Factory, which signs the email up +// as a subscriber and records one pledge per subscriber per election +// (re-pledging just refreshes it). Region is e.g. "ward-5" for ward-scoped +// pledge buttons; defaults to the city-wide "toronto". + +const ELECTION_SLUG = "toronto-2026"; +const REGION_PATTERN = /^[a-z0-9-]{1,50}$/; +const POSTAL_PATTERN = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; + +// "M5V1A1" / "m5v 1a1" → "M5V 1A1"; anything malformed is dropped rather +// than stored dirty +function normalizePostalCode(raw: unknown): string | undefined { + if (typeof raw !== "string" || !POSTAL_PATTERN.test(raw.trim())) { + return undefined; + } + const compact = raw.trim().toUpperCase().replace(" ", ""); + return `${compact.slice(0, 3)} ${compact.slice(3)}`; +} + +export async function POST(req: NextRequest) { + try { + const body = await req.json(); + const { email, name, region, postal_code } = body; + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + return NextResponse.json( + { error: "Invalid email format" }, + { status: 400 }, + ); + } + + const safeRegion = + typeof region === "string" && REGION_PATTERN.test(region) + ? region + : "toronto"; + + const res = await fetch(`${API_URL}/elections/${ELECTION_SLUG}/pledges`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + email, + name: typeof name === "string" ? name.slice(0, 100) : undefined, + region: safeRegion, + postal_code: normalizePostalCode(postal_code), + }), + cache: "no-store", + }); + + if (!res.ok) { + const errorData = await res.json().catch(() => ({})); + return NextResponse.json( + { error: errorData.errors?.[0] || "Pledge failed" }, + { status: res.status }, + ); + } + + const data = await res.json(); + return NextResponse.json({ + success: true, + region: data.region, + regionCount: data.region_count, + shareToken: data.share_token ?? null, + name: data.name ?? null, + }); + } catch (err) { + return NextResponse.json({ error: String(err) }, { status: 500 }); + } +} diff --git a/src/app/globals.css b/src/app/globals.css index a7f4edc1..19dc5f42 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -266,6 +266,23 @@ html { --color-auburn-900: #00266d; } +/* Toronto 2026 Election tracker — warm "newsprint" broadsheet palette with + the Toronto blue accent. Warm linen panels + hairlines, but the accent + stays the Toronto blue from .theme-toronto (set explicitly here so the + page renders correctly even outside that wrapper). */ +.theme-election { + --color-linen-50: #fbf6f1; + --color-linen-100: #f6ece3; + --color-linen-200: #ead2be; + /* Toronto blue accent */ + --color-auburn-600: #0040b0; + --color-auburn-700: #003592; + --color-auburn-800: #003086; + --color-auburn-900: #00266d; + /* hairline dividers between cards/sections */ + --color-border-light: #cdc4bd; +} + @custom-variant compact (@media (min-width: 425px)); @custom-variant 2xl-memo (@media (min-width: 1200px)); @custom-variant cards (@media (min-width: 612px)); diff --git a/src/app/toronto/elections/2026/CandidateSiteLink.tsx b/src/app/toronto/elections/2026/CandidateSiteLink.tsx new file mode 100644 index 00000000..24d0ac30 --- /dev/null +++ b/src/app/toronto/elections/2026/CandidateSiteLink.tsx @@ -0,0 +1,50 @@ +"use client"; + +import posthog from "posthog-js"; +import type { ReactNode } from "react"; + +interface CandidateSiteLinkProps { + href: string; + candidate: string; + candidateKey: string; + race: "mayor" | "councillor"; + tag?: string; + ward?: string; + wardName?: string; + className?: string; + children: ReactNode; +} + +export function CandidateSiteLink({ + href, + candidate, + candidateKey, + race, + tag, + ward, + wardName, + className, + children, +}: CandidateSiteLinkProps) { + return ( + { + posthog.capture("candidate_website_clicked", { + candidate, + candidate_key: candidateKey, + race, + website: href, + tag, + ward, + ward_name: wardName, + }); + }} + > + {children} + + ); +} diff --git a/src/app/toronto/elections/2026/CountdownDays.tsx b/src/app/toronto/elections/2026/CountdownDays.tsx new file mode 100644 index 00000000..cad75c97 --- /dev/null +++ b/src/app/toronto/elections/2026/CountdownDays.tsx @@ -0,0 +1,32 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { daysUntilElection } from "./data"; + +const DEFAULT_CLASS = + "font-sans font-semibold leading-[0.8] tracking-[-0.05em] text-[clamp(6rem,20vw,15rem)] tabular-nums"; + +/** + * Renders the "days until polls open" number. The server passes an + * initialDays computed at request time so first paint matches hydration; + * the client then keeps it current across day boundaries. `className` + * overrides the default hero-sized styling (e.g. for the ward stat row). + */ +export default function CountdownDays({ + initialDays, + className, +}: { + initialDays: number; + className?: string; +}) { + const [days, setDays] = useState(initialDays); + + useEffect(() => { + const tick = () => setDays(daysUntilElection()); + tick(); + const id = setInterval(tick, 60_000); + return () => clearInterval(id); + }, []); + + return {days}; +} diff --git a/src/app/toronto/elections/2026/WardMap.tsx b/src/app/toronto/elections/2026/WardMap.tsx new file mode 100644 index 00000000..3c14f447 --- /dev/null +++ b/src/app/toronto/elections/2026/WardMap.tsx @@ -0,0 +1,64 @@ +import { WARD_SHAPES, WARD_MAP_VIEWBOX } from "./wardGeo"; + +const BASE_ID = "toronto-ward-map-base"; + +/** + * Defines the full 25-ward Toronto map once as a reusable . Render this + * a single time on the page; each WardMap references it via so the + * geometry is not duplicated per card. + */ +export function WardMapDefs() { + return ( + + ); +} + +/** + * A compact locator map of Toronto with `activeWard` filled in the accent + * colour. Requires to be present once on the page. + */ +export function WardMap({ + activeWard, + className, +}: { + activeWard: string; + className?: string; +}) { + const active = WARD_SHAPES.find((w) => w.n === activeWard); + + return ( + + + {active && ( + + )} + + ); +} diff --git a/src/app/toronto/elections/2026/api.ts b/src/app/toronto/elections/2026/api.ts new file mode 100644 index 00000000..bf06c87d --- /dev/null +++ b/src/app/toronto/elections/2026/api.ts @@ -0,0 +1,65 @@ +// Toronto 2026 election — York Factory API client. +// +// The york_factory pipeline pulls the City Clerk's registered-candidate JSON +// feeds daily and serves the assembled election (races + candidates) from +// GET /api/v1/elections/toronto-2026. This module fetches it with ISR; +// callers fall back to the hand-maintained data in ./candidates when the API +// is unreachable. + +import { apiFetch } from "@/lib/api/client"; + +export type ApiSocialLink = { name: string; url: string }; + +export type ApiCandidate = { + /** as published by the City Clerk, "Last, First" */ + full_name: string; + first_name: string | null; + last_name: string | null; + status: "active" | "withdrawn"; + nomination_date: string | null; + withdrawn_date: string | null; + website: string | null; + social_links: ApiSocialLink[]; + /** admin-reviewed portrait served from York Factory storage */ + photo_url: string | null; + photo_attribution: string | null; +}; + +export type ApiRace = { + office_type: "mayor" | "councillor" | "trustee" | "mp" | "mpp"; + district_type: string; + district_number: number | null; + district_name: string | null; + office_body: string | null; + candidates: ApiCandidate[]; +}; + +export type ApiElection = { + slug: string; + name: string; + kind: string; + election_date: string; + nomination_close_date: string | null; + jurisdiction: { name: string; slug: string; level: string }; + races: ApiRace[]; +}; + +const ELECTION_SLUG = "toronto-2026"; +// The upstream feeds refresh daily, so hourly ISR is plenty. Next dedupes +// the fetch across the components that call this within one render. +const REVALIDATE_SECONDS = 3600; + +/** + * The toronto-2026 election with all races and candidates, or null when the + * API is unreachable. + */ +export async function fetchToronto2026(): Promise { + try { + return await apiFetch(`/elections/${ELECTION_SLUG}`, { + revalidate: REVALIDATE_SECONDS, + }); + } catch (error) { + console.error("[elections] API unavailable, using local candidate data:", error); + return null; + } +} diff --git a/src/app/toronto/elections/2026/candidates.ts b/src/app/toronto/elections/2026/candidates.ts new file mode 100644 index 00000000..015e3c60 --- /dev/null +++ b/src/app/toronto/elections/2026/candidates.ts @@ -0,0 +1,576 @@ +// ───────────────────────────────────────────────────────────────────────── +// Toronto 2026 election — candidate enrichment (edit this file by hand). +// +// The candidate roster now comes from the York Factory API (see ./api and +// ./data), which mirrors the City Clerk's registered-candidate feeds daily. +// This file enriches that roster — photos, bios, Incumbent/Challenger tags, +// and hand-verified campaign sites — matched to API candidates by name +// (case-, accent-, and punctuation-insensitive). It is also the fallback +// roster when the API is unreachable. Entries for candidates no longer in +// the API roster are simply ignored. It is plain data — no framework code — +// so it is safe to edit without touching the page components. +// +// IMAGES +// Drop candidate photos in /public/elections/toronto/2026/… and reference +// them from `image` with a leading "/", e.g. +// image: "/elections/toronto/2026/mayor/eleanor-voss.jpg" +// image: "/elections/toronto/2026/councillors/ward-10/aisha-okafor.jpg" +// Portraits look best at a 4:5 ratio (mayor) or square (councillors). +// Leave `image` unset to show the initials placeholder instead. +// +// INITIALS +// `initials` is optional — when omitted it is derived from the name. +// ───────────────────────────────────────────────────────────────────────── + +export type MayoralTag = "Incumbent" | "Declared" | "Exploratory"; +export type CouncillorTag = "Incumbent" | "Challenger" | "Registered"; + +export type MayoralCandidate = { + name: string; + tag: MayoralTag; + bio: string; + /** path under /public, e.g. "/elections/toronto/2026/mayor/name.jpg" */ + image?: string; + /** full campaign-site URL, e.g. "https://voteeleanor.ca" */ + website?: string; + /** optional override; derived from `name` when omitted */ + initials?: string; +}; + +export type CouncillorCandidate = { + name: string; + tag: CouncillorTag; + bio: string; + /** path under /public, e.g. "/elections/toronto/2026/councillors/ward-10/name.jpg" */ + image?: string; + /** full campaign-site URL, e.g. "https://voteaisha.ca" */ + website?: string; + /** optional override; derived from `name` when omitted */ + initials?: string; +}; + +// ── Mayor ────────────────────────────────────────────────────────────────── + +// Registered candidates for Mayor (City Clerk's list). Ordered here by last +// name for readability; the pages re-sort by last name regardless. `bio`, +// `image`, and `website` are filled in manually per candidate. +export const MAYORAL_CANDIDATES: MayoralCandidate[] = [ + { name: "Bahira Abdulsalam", tag: "Declared", bio: "", + image: "/elections/toronto/2026/mayor/bahira-abdulsalam.png", + website: "https://www.bahira.ca/", + }, + { name: "Jamie Atkinson", tag: "Declared", bio: "", + website: "https://jamieatkinson.ca/", + image: "/elections/toronto/2026/mayor/jamie-atkinson.png", + + }, + { + name: "Brad Bradford", + tag: "Declared", + bio: "", + website: "https://bradford26.ca", + image: "/elections/toronto/2026/mayor/brad-bradford.png", + }, + { name: "Darrell Brown", tag: "Declared", + bio: "", + website: 'https://electdarrellbrown.ca/', + image: "/elections/toronto/2026/mayor/darrell-brown.png", + }, + // Braeden Chow: has a website per the Clerk's list, but only social links + // (gmail email) — add the real URL manually. + { name: "Braeden Chow", tag: "Declared", bio: "", + website: "https://www.braedenchowfortoronto.ca/", + image: "/elections/toronto/2026/mayor/braeden-chow.png", + }, + { + name: "Olivia Chow", + tag: "Incumbent", + bio: "", + website: "https://oliviachow.ca", + image: "/elections/toronto/2026/mayor/olivia-chow.png", + }, + { + name: "Cory Deville", + tag: "Declared", + bio: "", + website: "https://deville2026.ca", + image: "/elections/toronto/2026/mayor/cory-deville.png", + }, + { name: "Laura Ellis", tag: "Declared", bio: "", + website: "https://running4you.ca/", + image: "/elections/toronto/2026/mayor/laura-ellis.png", + }, + // Renato Fallico: no website, email, or socials on the Clerk's filing; no + // published photo found. + { name: "Renato Fallico", tag: "Declared", bio: "" }, + // Martin Fraser: no contact info on the Clerk's filing; name too generic to + // safely match a photo. + { name: "Martin Fraser", tag: "Declared", bio: "" }, + { + name: "Edward Gong", + tag: "Declared", + bio: "", + // His generic "for Mayor" site, kept current through 2026 (not on his + // Clerk filing, which lists only email + socials). + website: "https://gong4mayor.com", + image: "/elections/toronto/2026/mayor/edward-gong.png", + }, + { + name: "Faizan Haider", + tag: "Declared", + bio: "", + // Must be the www URL — the bare faizanhaider.ca domain has broken TLS. + website: "https://www.faizanhaider.ca", + image: "/elections/toronto/2026/mayor/faizan-haider.jpg", + }, + { + name: "Peter Handjis", + tag: "Declared", + bio: "", + // Prior campaign domains are all dead; no website on his 2026 filing. + // Photo from his VoteMate 2023 profile (him with his own campaign sign). + image: "/elections/toronto/2026/mayor/peter-handjis.webp", + }, + { + name: "Heather He", + tag: "Declared", + bio: "", + // Her Care Bank nonprofit site — printed on her campaign signs, but the + // site itself has no campaign content. Photo shows her campaign banner. + website: "https://www.carebank.world", + image: "/elections/toronto/2026/mayor/heather-he.jpg", + }, + // Mohamad Kaaki: registered 13-Jul-2026; no email, website, socials, or + // photo found anywhere. + { name: "Mohamad Kaaki", tag: "Declared", bio: "" }, + { + name: "Georgios Kalkounis", + tag: "Declared", + bio: "", + // kalkounis.com (from his email) is a parked domain; real URL is from the + // Clerk's candidate JSON. + website: "https://georgioskalkounisformayor.com/", + image: "/elections/toronto/2026/mayor/georgios-kalkounis.jpg", + }, + { + name: "Isidoros Kyrlangitses", + tag: "Declared", + bio: "", + // From the Clerk's candidate JSON. Single-page site with no headshot — + // no photo of him found anywhere. + website: "https://www.ambitioustoronto.com", + }, + { + name: "Michael Lamoureux", + tag: "Declared", + bio: "", + website: "https://www.michaellamoureux.ca/", + image: "/elections/toronto/2026/mayor/michael-lamoureux.jpg", + }, + { + name: "Rick Lee", + tag: "Declared", + bio: "", + // Satirical Canva site, carried over from his 2023 run and updated for + // 2026. Not filed with the Clerk, so identity match is likely-not-certain. + website: "https://oscarpool.my.canva.site/voteforrick", + image: "/elections/toronto/2026/mayor/rick-lee.png", + }, + // Eddie Mayanja: no website or email on the Clerk's record; no reliably + // attributable photo found. + { name: "Eddie Mayanja", tag: "Declared", bio: "" }, + { + name: "Sarah McVie", + tag: "Declared", + bio: "", + // URL confirmed in the Clerk's candidate JSON. Photo is a landscape hero + // shot (she's on the right third) — crop if a tighter portrait is needed. + website: "https://www.mayormcvie.ca", + image: "/elections/toronto/2026/mayor/sarah-mcvie.jpg", + }, + // Joseph Osuji: no website or email on the Clerk's record; several Joseph + // Osujis in Toronto, so no photo could be confidently attributed. + { name: "Joseph Osuji", tag: "Declared", bio: "" }, + { + name: "Odessa Paloma Parker", + tag: "Declared", + bio: "", + website: "https://odessaformayor.ca", + image: "/elections/toronto/2026/mayor/odessa-paloma-parker.jpg", + }, + { + name: "Amy Rosen", + tag: "Declared", + bio: "", + website: "https://rosenformayor.ca", + image: "/elections/toronto/2026/mayor/amy-rosen.jpg", + }, + // Kannan S'ree Jr: no discoverable web presence beyond the Clerk's list. + { name: "Kannan S'ree Jr", tag: "Declared", bio: "" }, + { + name: "Lyall Sanders", + tag: "Declared", + bio: "", + // sanders4mayor.ca is his campaign domain but currently stuck in a redirect + // loop (Wix misconfig) — confirmed via Wayback 2026-07-14; re-check later. + website: "https://sanders4mayor.ca", + image: "/elections/toronto/2026/mayor/lyall-sanders.jpg", + }, + // Naomi Sayers: no 2026 campaign site found; naomisayers.com is her law + // practice, not a campaign site. Photo from her professional site. + { name: "Naomi Sayers", tag: "Declared", bio: "", + image: "/elections/toronto/2026/mayor/naomi-sayers.jpg", + website: "https://www.naomisayers.com/" + }, + { + name: "Robert Shusterman", + tag: "Declared", + bio: "", + // His realtor site, but it announces his mayoral run — matches Clerk's list. + website: "https://www.robertshusterman.com/", + image: "/elections/toronto/2026/mayor/robert-shusterman.png", + }, + // Weizhen Tang: no site of his own (weizhentang.com/.ca DNS dead); campaign + // info page lives on torontonewsnet.com/weizhentang/. Photo from that page. + { name: "Weizhen Tang", tag: "Declared", bio: "", + image: "/elections/toronto/2026/mayor/weizhen-tang.jpg", + website: "https://weizhentang.today/?blogcategory=Toronto+Mayoral+Candidacy" + }, + // Jeffery Tunney: 2023 site jefferytunney4mayor.ca is dead; no 2026 site yet. + // Photo recovered from the Wayback archive of his own campaign site. + { name: "Jeffery Tunney", tag: "Declared", bio: "", + image: "/elections/toronto/2026/mayor/jeffery-tunney.png", + website: "https://jefferytunney4mayor.com/" + }, +]; + +// ── Councillors, by ward ───────────────────────────────────────────────────── +// +// Keyed by zero-padded ward number ("01" … "25"). A ward mapped to an empty +// array (or not listed) shows a "no candidates registered yet" state on its +// page — it no longer falls back to placeholder candidates. +// +// Example (copy this shape): +// "10": [ +// { +// name: "Aisha Okafor", +// tag: "Incumbent", +// bio: "Incumbent councillor since 2022; chairs the planning and housing committee.", +// image: "/elections/toronto/2026/councillors/ward-10/aisha-okafor.jpg", +// website: "https://voteaisha.ca", +// }, +// { name: "Marco Bianchi", tag: "Challenger", bio: "Community organizer and tenant-rights advocate." }, +// ], + +// Source: City Clerk's registered-candidate list. The sitting councillor is +// included only when their status is "Registered" (tagged "Incumbent"); +// everyone else is tagged "Challenger". Incumbents who are retiring, running +// for mayor, or not yet registered are omitted. Wards 15 and 24 currently have +// no registered candidates. `bio`/`image`/`website` are filled in by hand. +export const WARD_CANDIDATES: Record = { + "01": [ + { + name: "Vincent Crisanti", + tag: "Incumbent", + bio: "Incumbent councillor for Etobicoke North, running on community engagement and working hand-in-glove with local residents to build a brighter future for Ward 1. Keeps a constituency office on Kipling Avenue and gathers resident feedback through regular surveys and office hours.", + website: "https://www.vincentcrisanti.com", + image: "/elections/toronto/2026/councillors/ward-1/vincent-crisanti.webp", + }, + { name: "Abraham Abbey", tag: "Challenger", bio: "" }, + { + name: "Ala'a Adib", + tag: "Challenger", + bio: "Humber College graduate, lifelong Etobicoke resident, and community organizer with over a decade of service — founding food-insecurity programs and youth mentorship and sports initiatives. Recognized with the 2025 King Charles III Coronation Medal and named one of Canada's Top 100 Black Women to Watch. Campaigns on improving city services and accountability, supporting youth and families, strengthening neighbourhoods, and creating economic opportunity.", + website: "https://www.alaaadib.ca", + image: "/elections/toronto/2026/councillors/ward-1/alaa-adib.png", + }, + { name: "Saima Babar", tag: "Challenger", bio: "" }, + { name: "Norman Hamilton", +image: "/elections/toronto/2026/councillors/ward-1/norman-hamilton.png", + tag: "Challenger", + bio: "Etobicoke North business owner who runs a local truck-repair company and hires locally, bringing the perspective of someone who understands meeting payroll and the pressures on working families. Campaigns on accountable leadership for youth, mothers, seniors, immigrants, and working families across North Etobicoke, powered by grassroots resident contributions rather than corporate or union interests.", + website: "https://www.normanhamilton.ca/" }, + ], + "02": [ + { + name: "Stephen Holyday", + tag: "Incumbent", + bio: "", + website: "https://stephenholyday.ca/", + image: "/elections/toronto/2026/councillors/ward-2/stephen-holyday.jpg", + }, + { + name: "Jennifer Alexander", + tag: "Challenger", + bio: "", + website: "https://jenalexander.ca/", + // Cropped from a community-event photo on her campaign site — no dedicated + // headshot is published. + image: "/elections/toronto/2026/councillors/ward-2/jen-alexander.jpg", + }, + ], + "03": [ + { + name: "Amber Morley", + tag: "Incumbent", + bio: "", + website: "https://www.ambermorley.com/", + image: "/elections/toronto/2026/councillors/ward-3/amber-morley.jpg", + }, + // Anthony Internicola: perennial candidate; his old sites point to past + // Scarborough/provincial runs, no current Ward 3 site or usable photo found. + { name: "Anthony Internicola", tag: "Challenger", bio: "", + website: "https://www.toronto4life.ca/meet-our-candidate.html" + }, + { + name: "Ted Opitz", + tag: "Challenger", + bio: "", + // Former Conservative MP for Etobicoke Centre (2011–2015); no 2026 + // campaign site filed or found. Photo is an MP-era portrait. + image: "/elections/toronto/2026/councillors/ward-3/ted-opitz.png", + }, + ], + "04": [ + // Incumbent Gord Perks is retiring — open seat, large field. + // Michael Corcoran: no campaign site or photo found; no online footprint. + { name: "Michael Corcoran", tag: "Challenger", bio: "" }, + { + name: "Nadia Guerrera", + tag: "Challenger", + bio: "", + website: "https://www.nadiaguerrera.ca", + image: "/elections/toronto/2026/councillors/ward-4/nadia-guerrera.png", + }, + { + name: "Debbie King", + tag: "Challenger", + bio: "", + website: "https://www.votedebbieking.ca/", + // Cropped from her campaign banner — no standalone headshot published. + image: "/elections/toronto/2026/councillors/ward-4/debbie-king.jpg", + }, + { + name: "Diana Chan McNally", + tag: "Challenger", + bio: "", + website: "https://www.dianachanmcnally.ca", + image: "/elections/toronto/2026/councillors/ward-4/diana-chan-mcnally.jpg", + }, + // Bridget Ogundipe: site has no genuine portrait (only stock/streetscape + // images), so no usable photo. + { + name: "Bridget Ogundipe", + tag: "Challenger", + bio: "", + website: "https://www.bridgetogundipe.ca", + }, + { + name: "Adam Pham", + tag: "Challenger", + bio: "", + website: "https://adampham.ca/", + // Cropped from a High Park candid — only photos on his site are candids. + image: "/elections/toronto/2026/councillors/ward-4/adam-pham.jpg", + }, + { + name: "Vanessa Raponi", + tag: "Challenger", + bio: "", + website: "https://www.vanessaraponi.ca", + // Her campaign site has no photo; this is a cropped 2023 McMaster + // Engineering award photo (she's a P.Eng / EngiQueers Canada founder). + image: "/elections/toronto/2026/councillors/ward-4/vanessa-raponi.jpg", + }, + // Lloyd Traiforos: no campaign site or photo found. + { name: "Lloyd Traiforos", tag: "Challenger", bio: "" }, + { + name: "Holly Weber", + tag: "Challenger", + bio: "", + website: "https://www.hollyweber.ca", + image: "/elections/toronto/2026/councillors/ward-4/holly-weber.png", + }, + ], + "05": [ + { + name: "Frances Nunziata", + tag: "Incumbent", + bio: "", + // No campaign site filed; this is her own councillor site. Photo is the + // official City of Toronto Ward 5 portrait. + website: "https://www.councillornunziata.com/", + image: "/elections/toronto/2026/councillors/ward-5/frances-nunziata.jpg", + }, + // Sharmarke Ali: sharmarkeali.ca is a parked domain; no photo found. + { name: "Sharmarke Ali", tag: "Challenger", bio: "" }, + { + name: "Daniel Di Giorgio", + tag: "Challenger", + bio: "", + website: "https://www.danieldigiorgio.com/", + // His site has only candids (base64-embedded); cropped from a two-person + // community photo. + image: "/elections/toronto/2026/councillors/ward-5/daniel-digiorgio.jpg", + }, + { + name: "Nekpen Obasogie", + tag: "Challenger", + bio: "", + website: "https://nekpenobasogie.ca", + image: "/elections/toronto/2026/councillors/ward-5/nekpen-obasogie.png", + }, + { + name: "Chiara Padovani", + tag: "Challenger", + bio: "", + website: "https://www.chiarapadovani.ca/", + // Cropped from an environmental portrait outside the YSW Community Hub. + image: "/elections/toronto/2026/councillors/ward-5/chiara-padovani.jpg", + }, + { + name: "Sileen Phillips", + tag: "Challenger", + bio: "", + website: "https://www.sileenphillips-ysw.ca", + image: "/elections/toronto/2026/councillors/ward-5/sileen-phillips.webp", + }, + ], + "06": [ + { name: "James Pasternak", tag: "Incumbent", bio: "" }, + { name: "Conroy Irving", tag: "Challenger", bio: "" }, + ], + "07": [ + // Incumbent Anthony Perruzza is not registered. + { name: "Lorna Antwi", tag: "Challenger", bio: "" }, + { name: "Amanda Coombs", tag: "Challenger", bio: "" }, + ], + "08": [ + { name: "Mike Colle", tag: "Incumbent", bio: "" }, + { name: "Liz Grade", tag: "Challenger", bio: "" }, + { name: "Jason McDonald", tag: "Challenger", bio: "" }, + { name: "Enzo Torrone", tag: "Challenger", bio: "" }, + { name: "Daniel Trayes", tag: "Challenger", bio: "" }, + ], + "09": [ + { name: "Alejandra Bravo", tag: "Incumbent", bio: "" }, + { name: "Neil Simon", tag: "Challenger", bio: "" }, + ], + "10": [ + { name: "Ausma Malik", tag: "Incumbent", bio: "" }, + { name: "Ian Cunningham", tag: "Challenger", bio: "" }, + { name: "Xue Ding", tag: "Challenger", bio: "" }, + { name: "Andi Hoang-Lefranc", tag: "Challenger", bio: "" }, + { name: "Bashar Kassir", tag: "Challenger", bio: "" }, + { name: "Paul Nash", tag: "Challenger", bio: "" }, + { name: "Husain Neemuchwala", tag: "Challenger", bio: "" }, + { name: "Aanchal Vashistha", tag: "Challenger", bio: "" }, + ], + "11": [ + { name: "Dianne Saxe", tag: "Incumbent", bio: "" }, + { name: "Gabe Blanc", tag: "Challenger", bio: "" }, + { name: "Keenan Courtis", tag: "Challenger", bio: "" }, + { name: "Dana Fisher", tag: "Challenger", bio: "" }, + { name: "Terri Hawkes", tag: "Challenger", bio: "" }, + { name: "Karina Lemke", tag: "Challenger", bio: "" }, + { name: "Alice Li", tag: "Challenger", bio: "" }, + { name: "Huy Lieu", tag: "Challenger", bio: "" }, + { name: "Gian Pileri", tag: "Challenger", bio: "" }, + { name: "Diana Yoon", tag: "Challenger", bio: "" }, + ], + "12": [ + // Incumbent Josh Matlow is not registered. + { name: "Jenny Kalimbet", tag: "Challenger", bio: "" }, + { name: "Ahmed Kamal", tag: "Challenger", bio: "" }, + { name: "Joshua Kishner", tag: "Challenger", bio: "" }, + { name: "Mina Nadimi", tag: "Challenger", bio: "" }, + ], + "13": [ + // Incumbent Chris Moise is not registered. + { name: "Joe Cadeau", tag: "Challenger", bio: "" }, + { name: "Tom Cai", tag: "Challenger", bio: "" }, + { name: "Victoria Davis", tag: "Challenger", bio: "" }, + { name: "Walied Khogali Ali", tag: "Challenger", bio: "" }, + { name: "Curran Stikuts", tag: "Challenger", bio: "" }, + { name: "Nicki Ward", tag: "Challenger", bio: "" }, + ], + "14": [ + // Incumbent Paula Fletcher is not registered. + { name: "Susan Chapelle", tag: "Challenger", bio: "" }, + { name: "Jason Stevens", tag: "Challenger", bio: "" }, + ], + // Ward 15 (Don Valley West): no registered candidates yet. + "15": [], + "16": [ + { name: "Jon Burnside", tag: "Incumbent", bio: "" }, + { name: "Sinan Erdemir", tag: "Challenger", bio: "" }, + { name: "Stacey Moffatt", tag: "Challenger", bio: "" }, + ], + "17": [ + { name: "Shelley Carroll", tag: "Incumbent", bio: "" }, + { name: "Jeffery Adamson", tag: "Challenger", bio: "" }, + { name: "Hassan Mubarak Noor Mohamed", tag: "Challenger", bio: "" }, + { name: "Gregory Rodriguez", tag: "Challenger", bio: "" }, + { name: "Roy Samathanam", tag: "Challenger", bio: "" }, + { name: "Sabrina Zuniga", tag: "Challenger", bio: "" }, + ], + "18": [ + // Incumbent Lily Cheng is not registered. + { name: "David Magazzinich", tag: "Challenger", bio: "" }, + { name: "Nathan Yusifov", tag: "Challenger", bio: "" }, + { name: "Ardeshir Zarezade", tag: "Challenger", bio: "" }, + ], + "19": [ + // Incumbent Brad Bradford is running for mayor. + { name: "Jaana Syeda Ali", tag: "Challenger", bio: "" }, + { name: "James Dann", tag: "Challenger", bio: "" }, + { name: "Natalie Johnson", tag: "Challenger", bio: "" }, + { name: "Tycen Legg", tag: "Challenger", bio: "" }, + { name: "Kevin Morrison", tag: "Challenger", bio: "" }, + { name: "Adam Smith", tag: "Challenger", bio: "" }, + { name: "Austin Vieira", tag: "Challenger", bio: "" }, + { name: "Jeff Wahl", tag: "Challenger", bio: "" }, + { name: "Devin Wilkins", tag: "Challenger", bio: "" }, + { name: "Jennie Worden", tag: "Challenger", bio: "" }, + ], + "20": [ + // Incumbent Parthi Kandavel is not registered. + { name: "Malik Ahmad", tag: "Challenger", bio: "" }, + { name: "Ariel-Rachel Karokis", tag: "Challenger", bio: "" }, + { name: "Philip Mills", tag: "Challenger", bio: "" }, + { name: "Sharmina Nasrin", tag: "Challenger", bio: "" }, + { name: "Mohammad Ali Reza", tag: "Challenger", bio: "" }, + { name: "Kevin Rupasinghe", tag: "Challenger", bio: "" }, + ], + "21": [ + // Incumbent Michael Thompson is not registered. + { name: "Taiba Ahmed", tag: "Challenger", bio: "" }, + { name: "Patience Evbagharu", tag: "Challenger", bio: "" }, + { name: "Madura Shanmugaratnam", tag: "Challenger", bio: "" }, + { name: "Krissan Veerasingam", tag: "Challenger", bio: "" }, + ], + "22": [ + // Incumbent Nick Mantas is not registered. + { name: "Madhuri Azad", tag: "Challenger", bio: "" }, + { name: "Bill Chan", tag: "Challenger", bio: "" }, + { name: "Jackson Ho", tag: "Challenger", bio: "" }, + { name: "Dan Lovell", tag: "Challenger", bio: "" }, + { name: "Donny Morgan", tag: "Challenger", bio: "" }, + ], + "23": [ + { name: "Jamaal Myers", tag: "Incumbent", bio: "" }, + { name: "Han Dong", tag: "Challenger", bio: "" }, + { name: "Sajawal Javed", tag: "Challenger", bio: "" }, + { name: "Kevin Li", tag: "Challenger", bio: "" }, + { name: "John-Mark Oleh", tag: "Challenger", bio: "" }, + ], + // Ward 24 (Scarborough-Guildwood): no registered candidates yet. + "24": [], + "25": [ + { name: "Neethan Shan", tag: "Incumbent", bio: "" }, + { name: "Shawn Allen", tag: "Challenger", bio: "" }, + { name: "Ashan Fernando", tag: "Challenger", bio: "" }, + { name: "Cheryl Lewis-Thurab", tag: "Challenger", bio: "" }, + { name: "Jannette Lumley", tag: "Challenger", bio: "" }, + ], +}; diff --git a/src/app/toronto/elections/2026/data.ts b/src/app/toronto/elections/2026/data.ts new file mode 100644 index 00000000..b6c466ca --- /dev/null +++ b/src/app/toronto/elections/2026/data.ts @@ -0,0 +1,198 @@ +// Toronto 2026 municipal election — data layer. +// The candidate roster comes from the York Factory API (which mirrors the +// City Clerk's registered-candidate feeds daily); the hand-maintained data +// in ./candidates enriches it with photos, bios, tags, and verified campaign +// sites, and doubles as the fallback when the API is unreachable. + +import { differenceInCalendarDays } from "date-fns"; +import { WARD_SHAPES } from "./wardGeo"; +import { fetchToronto2026, type ApiCandidate, type ApiElection } from "./api"; + +export const ELECTION_DATE_ISO = "2026-10-26"; +/** Display strings for the key election-calendar dates. */ +export const NOMINATION_CLOSE_LABEL = "Sept 18, 2026"; +export const ELECTION_DAY_LABEL = "Mon, Oct 26"; + +/** + * Whole calendar days from `now` until election day. Counts calendar days + * (not remaining 24h periods) so the counter reads the same all day, e.g. + * "103 days" throughout Jul 15 rather than ticking to 102 by lunchtime. + */ +export function daysUntilElection(now: Date = new Date()): number { + const [y, m, d] = ELECTION_DATE_ISO.split("-").map(Number); + const electionDay = new Date(y, m - 1, d); + return Math.max(0, differenceInCalendarDays(electionDay, now)); +} + +// Hand-maintained enrichment (photos, bios, tags, verified sites) lives in +// ./candidates — see that file to fill in a candidate's profile. +import { + MAYORAL_CANDIDATES as RAW_MAYORAL_CANDIDATES, + WARD_CANDIDATES, + type MayoralCandidate, + type CouncillorCandidate, + type CouncillorTag, +} from "./candidates"; + +export type { MayoralCandidate, CouncillorCandidate, CouncillorTag }; + +/** Generational suffixes ignored when deriving a last-name sort key, so e.g. + * "Kannan S'ree Jr" sorts under "s", not "j". */ +const NAME_SUFFIXES = new Set(["jr", "jr.", "sr", "sr.", "ii", "iii", "iv", "v"]); + +/** Last-name sort key from a full name, e.g. "Eleanor Voss" → "voss". + * Used to order candidate lists alphabetically by last name. Trailing + * generational suffixes (Jr, Sr, III, …) are skipped. */ +export function lastNameKey(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + while (parts.length > 1 && NAME_SUFFIXES.has(parts[parts.length - 1].toLowerCase())) { + parts.pop(); + } + return (parts[parts.length - 1] ?? "").toLowerCase(); +} + +/** Sort candidates alphabetically by last name (stable, non-mutating). */ +function byLastName(candidates: T[]): T[] { + return [...candidates].sort((a, b) => + lastNameKey(a.name).localeCompare(lastNameKey(b.name)), + ); +} + +/** Initials from a name, e.g. "Eleanor Voss" → "EV" (used when a candidate + * has no explicit `initials` and no photo). */ +export function initialsFor(name: string): string { + const parts = name.trim().split(/\s+/).filter(Boolean); + const first = parts[0]?.[0] ?? ""; + const last = parts.length > 1 ? parts[parts.length - 1][0] : ""; + return (first + last).toUpperCase(); +} + +// ── API roster + local enrichment ────────────────────────────────────────── + +/** Matching key for enrichment lookups: lowercase, diacritics and + * punctuation stripped, so the Clerk's "Ala'a Adib" matches a local entry + * written "Alaa Adib". Also used as the stable candidate key in analytics + * events, since the Clerk's feed has no candidate IDs. */ +export function nameKey(name: string): string { + return name + .normalize("NFD") + .replace(/[\u0300-\u036f]/g, "") + .toLowerCase() + .replace(/[^a-z0-9 ]+/g, "") + .replace(/\s+/g, " ") + .trim(); +} + +/** "First Last" display name from an API candidate ("Last, First"). */ +function displayName(candidate: ApiCandidate): string { + if (candidate.first_name && candidate.last_name) { + return `${candidate.first_name} ${candidate.last_name}`; + } + const [last, first] = candidate.full_name.split(",").map((s) => s.trim()); + return first ? `${first} ${last}` : candidate.full_name; +} + +const MAYORAL_ENRICHMENT = new Map( + RAW_MAYORAL_CANDIDATES.map((c) => [nameKey(c.name), c]), +); + +function councillorEnrichment(wardNumber: number) { + const key = String(wardNumber).padStart(2, "0"); + return new Map((WARD_CANDIDATES[key] ?? []).map((c) => [nameKey(c.name), c])); +} + +function activeCandidates(election: ApiElection, wardNumber?: number): ApiCandidate[] { + const race = election.races.find((r) => + wardNumber === undefined + ? r.office_type === "mayor" + : r.office_type === "councillor" && r.district_number === wardNumber, + ); + return (race?.candidates ?? []).filter((c) => c.status === "active"); +} + +/** Mayoral candidates — API roster merged with local enrichment, sorted by + * last name. Falls back to the local list when the API is unreachable. */ +export async function getMayoralCandidates(): Promise { + const election = await fetchToronto2026(); + if (!election) return byLastName(RAW_MAYORAL_CANDIDATES); + + return byLastName( + activeCandidates(election).map((api): MayoralCandidate => { + const name = displayName(api); + const curated = MAYORAL_ENRICHMENT.get(nameKey(name)); + return { + name, + tag: curated?.tag ?? "Declared", + bio: curated?.bio ?? "", + image: curated?.image ?? api.photo_url ?? undefined, + website: curated?.website ?? api.website ?? undefined, + initials: curated?.initials, + }; + }), + ); +} + +/** + * Councillor candidates for a ward (0-based index) — API roster merged with + * local enrichment, sorted by last name. Falls back to the local list when + * the API is unreachable; empty for wards with no registered candidates yet. + */ +export async function getCouncillorCandidates( + wardIndex: number, +): Promise { + const election = await fetchToronto2026(); + const wardNumber = wardIndex + 1; + if (!election) { + return byLastName(WARD_CANDIDATES[String(wardNumber).padStart(2, "0")] ?? []); + } + + const enrichment = councillorEnrichment(wardNumber); + return byLastName( + activeCandidates(election, wardNumber).map((api): CouncillorCandidate => { + const name = displayName(api); + const curated = enrichment.get(nameKey(name)); + return { + name, + tag: curated?.tag ?? "Registered", + bio: curated?.bio ?? "", + image: curated?.image ?? api.photo_url ?? undefined, + website: curated?.website ?? api.website ?? undefined, + initials: curated?.initials, + }; + }), + ); +} + +// ── Wards ────────────────────────────────────────────────────────────────── + +export type Ward = { + n: string; + name: string; + count: number; +}; + +/** Zero-padded ward numbers ("01".."25") for static route generation. */ +export const WARD_NUMBERS: string[] = WARD_SHAPES.map((w) => w.n); + +/** + * All 25 wards with live candidate counts from the API (local counts when it + * is unreachable). Ward names/numbers come from the official City of Toronto + * ward geometry (see wardGeo.ts). + */ +export async function getWards(): Promise { + const election = await fetchToronto2026(); + return WARD_SHAPES.map((w, i) => ({ + n: w.n, + name: w.name, + count: election + ? activeCandidates(election, i + 1).length + : (WARD_CANDIDATES[String(i + 1).padStart(2, "0")] ?? []).length, + })); +} + +/** Look up a ward by its number ("01".."25" or "1".."25"). */ +export function findWardIndex(param: string): number { + const num = parseInt(param, 10); + if (Number.isNaN(num) || num < 1 || num > WARD_SHAPES.length) return -1; + return num - 1; +} diff --git a/src/app/toronto/elections/2026/page.tsx b/src/app/toronto/elections/2026/page.tsx new file mode 100644 index 00000000..c149ddd2 --- /dev/null +++ b/src/app/toronto/elections/2026/page.tsx @@ -0,0 +1,239 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Image from "next/image"; +import { ArrowUpRight, ArrowRight } from "lucide-react"; +import CountdownDays from "./CountdownDays"; +import { CandidateSiteLink } from "./CandidateSiteLink"; +import { WardMap, WardMapDefs } from "./WardMap"; +import { + getMayoralCandidates, + getWards, + daysUntilElection, + initialsFor, + nameKey, + NOMINATION_CLOSE_LABEL, +} from "./data"; + +export const metadata: Metadata = { + title: "Toronto 2026 Election", + description: + "Toronto elects its mayor and 25 city councillors on October 26, 2026. Build Canada tracks every race: the candidates for mayor, what they intend to build, and who is running in your ward.", + alternates: { canonical: "/toronto/elections/2026" }, + openGraph: { + title: "Toronto 2026 Election — Build Canada", + description: + "Tracking every race in Toronto's 2026 municipal election: the candidates for mayor and the 25 council wards.", + type: "website", + }, +}; + +export default async function Toronto2026ElectionPage() { + const [mayoralCandidates, wards] = await Promise.all([ + getMayoralCandidates(), + getWards(), + ]); + const initialDays = daysUntilElection(); + + return ( +
+
+ {/* ── Hero ─────────────────────────────────────────────── */} +
+

+ Municipal Election · City of Toronto +

+

+ Toronto 2026 Election +

+

+ Toronto elects its mayor and 25 city councillors on + October 26, 2026 — the largest civic decision Canada makes + this year. Build Canada is tracking every race: who is running, + what they intend to build, and how each ward will shape the + direction of the country’s largest city. Meet the candidates + for mayor below, then find your ward to see who is competing to + represent it. +

+
+ + {/* ── Countdown + how to vote ──────────────────────────── */} +
+
+
+ + + Days until +
+ polls open +
+
+
+
+

Ready to vote?

+

+ Check your eligibility, find your voting location, and learn what + to bring on election day. +

+ + How to Vote + + +

+ Polls open{" "} + + Monday, October 26, 2026 + + , 10:00 a.m. to 8:00 p.m. +

+
+
+ + {/* ── Candidates for mayor ─────────────────────────────── */} +
+
+
+

The Mayoralty

+

+ Candidates for Mayor +

+
+

+ {mayoralCandidates.length} declared · field open +

+
+ +
+ {mayoralCandidates.map((cand) => ( +
+
+ {cand.image ? ( + {cand.name} + ) : ( + + {cand.initials ?? initialsFor(cand.name)} + + )} + {cand.tag === 'Incumbent' && + + {cand.tag} + + } + {!cand.image && ( + + Portrait to come + + )} +
+
+

+ {cand.name} +

+

+ {cand.bio} +

+ {cand.website ? ( + + Campaign site + + + ) : ( + + Profile to come + + )} +
+
+ ))} +
+

+ Registered candidates from the City Clerk’s list; photographs + and profiles are added as they become available. The field is not + final until nominations close on {NOMINATION_CLOSE_LABEL}. +

+
+ + {/* ── Wards ────────────────────────────────────────────── */} +
+
+
+

City Council

+

+ Find your ward +

+

+ Twenty-five wards, twenty-five council races. Select a ward to + see the candidates running to represent it. +

+
+

+ {wards.length} wards +

+
+ + +
+ {wards.map((ward) => ( + +
+ + Ward {ward.n} + + +
+ + {ward.name} + +
+ + {ward.count} candidates + + +
+ + ))} +
+
+ + {/* ── Editorial footer band ────────────────────────────── */} +
+
+ “Whatever our errors are otherwise, we shall not err for want + of boldness… Canada shall be the star towards which all men who love + progress and freedom shall come.” +
+

Sir Wilfrid Laurier

+

+ Data shown is illustrative and for demonstration only. Official + candidate lists are certified by the City Clerk after nomination + day. +

+
+
+
+ ); +} diff --git a/src/app/toronto/elections/2026/pledge/PledgeClient.tsx b/src/app/toronto/elections/2026/pledge/PledgeClient.tsx new file mode 100644 index 00000000..dc0483e0 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/PledgeClient.tsx @@ -0,0 +1,75 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { ArrowRight } from "lucide-react"; +import { PledgeButton } from "../../PledgeButton"; + +const StampScene = dynamic(() => import("./StampScene"), { + ssr: false, + loading: () => ( +
+ + Printing your stamp… + +
+ ), +}); + +export default function PledgeClient({ + region, +}: { + /** e.g. "ward-5" from a ward-scoped pledge link; defaults to city-wide */ + region?: string; +}) { + return ( +
+
+ {/* ── The stamp, full bleed ──────────────────────────── */} +
+ +
+ + {/* ── Overlaid header ────────────────────────────────── */} +
+
+

+ Municipal Election · City of Toronto +

+

+ My pledge to vote. +

+
+ +
+

+ Toronto votes Monday, October 26 +

+ + Pledge to vote + + +
+
+ + {/* ── Overlaid footer ────────────────────────────────── */} +
+ + Explore the Candidates + + +

+ Drag the stamp around · Polls open 10:00 a.m. – 8:00 p.m. +

+
+
+
+ ); +} diff --git a/src/app/toronto/elections/2026/pledge/StampScene.tsx b/src/app/toronto/elections/2026/pledge/StampScene.tsx new file mode 100644 index 00000000..cdf3add9 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/StampScene.tsx @@ -0,0 +1,135 @@ +"use client"; + +import * as THREE from "three"; +import { Suspense, useMemo, useRef, useState } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { useCursor, useTexture } from "@react-three/drei"; +import { + CuboidCollider, + Physics, + RigidBody, + type RapierRigidBody, +} from "@react-three/rapier"; +import stampImage from "./toronto-stamp.png"; + +/* Stamp dimensions in world units (the artwork is square) */ +const STAMP_SIZE = 2.7; + +/* Resting tilt, like a stamp pressed on by hand */ +const REST_TILT = -8 * (Math.PI / 180); +/* z component of the resting quaternion (rotation of REST_TILT about z) */ +const REST_QZ = Math.sin(REST_TILT / 2); + +function Stamp() { + const body = useRef(null); + const vec = useMemo(() => new THREE.Vector3(), []); + const dir = useMemo(() => new THREE.Vector3(), []); + const ang = useMemo(() => new THREE.Vector3(), []); + const rot = useMemo(() => new THREE.Vector3(), []); + + const [dragged, drag] = useState(false); + const [hovered, hover] = useState(false); + useCursor(hovered, dragged ? "grabbing" : "grab"); + + const map = useTexture(stampImage.src, (tex) => { + tex.colorSpace = THREE.SRGBColorSpace; + tex.anisotropy = 16; + }); + + useFrame((state, delta) => { + if (!body.current) return; + + if (dragged) { + vec.set(state.pointer.x, state.pointer.y, 0.5).unproject(state.camera); + dir.copy(vec).sub(state.camera.position).normalize(); + vec.add(dir.multiplyScalar(state.camera.position.length())); + body.current.wakeUp(); + body.current.setNextKinematicTranslation({ + x: vec.x - dragged.x, + y: vec.y - dragged.y, + z: vec.z - dragged.z, + }); + } else { + // spring gently back toward centre so a thrown stamp never gets lost + const t = body.current.translation(); + const pull = body.current.mass() * 5 * delta; + body.current.applyImpulse( + { x: -t.x * pull, y: -t.y * pull, z: -t.z * pull }, + true, + ); + // and ease back to facing the camera, settling at the resting tilt + ang.copy(body.current.angvel()); + rot.copy(body.current.rotation()); + body.current.setAngvel( + { + x: ang.x - rot.x * 0.5, + y: ang.y - rot.y * 0.5, + z: ang.z - (rot.z - REST_QZ) * 0.5, + }, + true, + ); + } + }); + + return ( + + + hover(true)} + onPointerOut={() => hover(false)} + onPointerUp={(e) => { + (e.target as Element).releasePointerCapture(e.pointerId); + drag(false); + }} + onPointerDown={(e) => { + (e.target as Element).setPointerCapture(e.pointerId); + if (!body.current) return; + drag( + new THREE.Vector3() + .copy(e.point) + .sub(vec.copy(body.current.translation())), + ); + }} + > + {/* unlit + untonemapped so the artwork renders exactly as authored — + ACES tone mapping and env lighting wash out the soft blues */} + + + + + + + ); +} + +export default function StampScene() { + return ( + + {/* Physics suspends while the rapier WASM loads; the boundary must sit + inside the Canvas or the suspension unmounts it and kills the GL context */} + + + + + + + ); +} diff --git a/src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx b/src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx new file mode 100644 index 00000000..81ad0e7a --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/[slug]/SharedPledgeClient.tsx @@ -0,0 +1,107 @@ +"use client"; + +import dynamic from "next/dynamic"; +import Link from "next/link"; +import { useState } from "react"; +import { ArrowLeft, ArrowRight, Check, Link2 } from "lucide-react"; + +const StampScene = dynamic(() => import("../StampScene"), { + ssr: false, + loading: () => ( +
+ + Printing the stamp… + +
+ ), +}); + +export default function SharedPledgeClient({ name }: { name: string }) { + const [copied, setCopied] = useState(false); + + async function share() { + const url = window.location.href; + if (navigator.share) { + try { + await navigator.share({ + title: `${name} pledged to vote — Toronto 2026`, + url, + }); + return; + } catch { + // fall through to the clipboard if the user dismissed the sheet + } + } + await navigator.clipboard.writeText(url); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + + return ( +
+
+ {/* ── The stamp, full bleed ──────────────────────────── */} +
+ +
+ + {/* ── Overlaid header ────────────────────────────────── */} +
+
+

+ Municipal Election · City of Toronto +

+

+ {name} pledged to vote. +

+
+ + {/* ── Share + join in ──────────────────────────────── */} +
+

+ Toronto votes Monday, October 26 +

+ + Pledge to vote too + + + +
+
+ + {/* ── Overlaid footer ────────────────────────────────── */} +
+ + + Back to the election tracker + +

+ Drag the stamp around · Polls open 10:00 a.m. – 8:00 p.m. +

+
+
+
+ ); +} diff --git a/src/app/toronto/elections/2026/pledge/[slug]/opengraph-image.tsx b/src/app/toronto/elections/2026/pledge/[slug]/opengraph-image.tsx new file mode 100644 index 00000000..4fcfa05f --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/[slug]/opengraph-image.tsx @@ -0,0 +1,24 @@ +import { ImageResponse } from "next/og"; +import { resolvePledgeName } from "../pledge-data"; +import { OG_SIZE, PledgeOGImage, stampDataUri } from "../og-template"; + +export const alt = "A pledge to vote in Toronto's 2026 municipal election"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +/* Query params aren't available to OG image routes; the name resolves from + the pledge record via the slug's share token, else from the slug itself. */ +export default async function Image({ + params, +}: { + params: Promise<{ slug: string }>; +}) { + const { slug } = await params; + return new ImageResponse( + , + { ...size }, + ); +} diff --git a/src/app/toronto/elections/2026/pledge/[slug]/page.tsx b/src/app/toronto/elections/2026/pledge/[slug]/page.tsx new file mode 100644 index 00000000..87f9be7a --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/[slug]/page.tsx @@ -0,0 +1,32 @@ +import type { Metadata } from "next"; +import { resolvePledgeName } from "../pledge-data"; +import SharedPledgeClient from "./SharedPledgeClient"; + +type Props = { + params: Promise<{ slug: string }>; + searchParams: Promise<{ n?: string }>; +}; + +export async function generateMetadata({ + params, + searchParams, +}: Props): Promise { + const { slug } = await params; + const { n } = await searchParams; + const name = await resolvePledgeName(slug, n); + return { + title: `${name} pledged to vote — Toronto 2026`, + description: `${name} is on the record for Toronto's 2026 municipal election, Monday, October 26. Will you be?`, + openGraph: { + title: `${name} pledged to vote — Toronto 2026 | Build Canada`, + description: `${name} pledged to vote in Toronto's 2026 municipal election on October 26, 2026. Join them on the record.`, + type: "website", + }, + }; +} + +export default async function SharedPledgePage({ params, searchParams }: Props) { + const { slug } = await params; + const { n } = await searchParams; + return ; +} diff --git a/src/app/toronto/elections/2026/pledge/og-template.tsx b/src/app/toronto/elections/2026/pledge/og-template.tsx new file mode 100644 index 00000000..d0305e69 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/og-template.tsx @@ -0,0 +1,234 @@ +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; + +/* Shared template for the pledge OG images: the Toronto stamp on election + paper inside the site's dark frame, with an optional postmark overlay + carrying the pledger's name (mirroring the real page). Uses the runtime's + bundled font like the other OG images in the repo. */ + +export const OG_SIZE = { width: 1200, height: 630 }; + +const PAPER = "#efe4da"; +const DARK = "#272727"; +const BLUE = "#003086"; +const INK = "#2e5fa3"; +const SANS = "system-ui, -apple-system, sans-serif"; + +export async function stampDataUri(): Promise { + const data = await readFile( + join( + process.cwd(), + "src/app/toronto/elections/2026/pledge/toronto-stamp-og.png", + ), + "base64", + ); + return `data:image/png;base64,${data}`; +} + +/* The cancellation mark, as SVG waves + frame with the name laid over it */ +function Postmark({ name }: { name: string }) { + const display = name.toUpperCase(); + const nameSize = display.length > 22 ? 14 : display.length > 14 ? 18 : 22; + + return ( +
+ + {/* cancellation waves */} + {[35, 60, 85, 110].map((y) => ( + + ))} + {/* stretched beveled frame */} + + +
+
+ {display} +
+
+ 26.10.2026 +
+
+ TORONTO · #02026 +
+
+
+ ); +} + +export function PledgeOGImage({ + stampSrc, + name, +}: { + stampSrc: string; + /** when set, the stamp is postmarked and the headline names the pledger */ + name?: string; +}) { + const headline = name ? `${name} pledged to vote.` : "I pledge to vote."; + const headlineSize = + headline.length > 34 ? 52 : headline.length > 24 ? 62 : 74; + + return ( +
+ {/* the site's dark frame */} +
+ + {/* the stamp, hand-tilted, postmarked when there's a pledger */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + + {name ? : null} +
+ + {/* headline block */} +
+
+ MUNICIPAL ELECTION · CITY OF TORONTO +
+
+ {headline} +
+
+
+ Toronto votes Monday, October 26 +
+
+ + {/* footer strip */} +
+
+ POLLS OPEN 10:00 A.M. – 8:00 P.M. +
+
+ BUILDCANADA.COM/TORONTO +
+
+
+ ); +} diff --git a/src/app/toronto/elections/2026/pledge/opengraph-image.tsx b/src/app/toronto/elections/2026/pledge/opengraph-image.tsx new file mode 100644 index 00000000..247a0359 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/opengraph-image.tsx @@ -0,0 +1,13 @@ +import { ImageResponse } from "next/og"; +import { OG_SIZE, PledgeOGImage, stampDataUri } from "./og-template"; + +export const alt = "I pledge to vote — Toronto's 2026 municipal election"; +export const size = OG_SIZE; +export const contentType = "image/png"; + +export default async function Image() { + return new ImageResponse( + , + { ...size }, + ); +} diff --git a/src/app/toronto/elections/2026/pledge/page.tsx b/src/app/toronto/elections/2026/pledge/page.tsx new file mode 100644 index 00000000..f9410d50 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/page.tsx @@ -0,0 +1,24 @@ +import type { Metadata } from "next"; +import PledgeClient from "./PledgeClient"; + +export const metadata: Metadata = { + title: "I Pledge to Vote — Toronto 2026", + description: + "You pledged to vote in Toronto's 2026 municipal election. Here's your ballot for Monday, October 26, 2026.", + alternates: { canonical: "/toronto/elections/2026/pledge" }, + openGraph: { + title: "I Pledge to Vote — Toronto 2026 | Build Canada", + description: + "Pledge to vote in Toronto's 2026 municipal election on October 26, 2026.", + type: "website", + }, +}; + +export default async function PledgePage({ + searchParams, +}: { + searchParams: Promise<{ region?: string }>; +}) { + const { region } = await searchParams; + return ; +} diff --git a/src/app/toronto/elections/2026/pledge/pledge-data.ts b/src/app/toronto/elections/2026/pledge/pledge-data.ts new file mode 100644 index 00000000..a25ef933 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/pledge-data.ts @@ -0,0 +1,39 @@ +import { cache } from "react"; +import { API_URL } from "@/lib/api/client"; +import { nameFromSlug, tokenFromSlug } from "./pledge-slug"; + +interface SharedPledge { + name: string | null; + region: string; + pledged_at: string; +} + +/* Looks a pledge up in York Factory by the share token embedded in the URL. + Returns null for unknown tokens or if York Factory is unreachable — + callers fall back to deriving a name from the URL itself. */ +const fetchPledge = cache(async (token: string): Promise => { + try { + const res = await fetch( + `${API_URL}/elections/toronto-2026/pledges/${token}`, + { next: { revalidate: 300 } }, + ); + if (!res.ok) return null; + return (await res.json()) as SharedPledge; + } catch { + return null; + } +}); + +/* The name a shared pledge page displays: the server-verified pledge record + when the slug carries a share token, else the exact-case ?n= query value + (legacy links), else the title-cased slug. */ +export async function resolvePledgeName(slug: string, n?: string) { + const token = tokenFromSlug(slug); + if (token) { + const pledge = await fetchPledge(token); + if (pledge?.name) return pledge.name.slice(0, 40); + } + const fromQuery = n?.trim(); + if (fromQuery) return fromQuery.slice(0, 40); + return nameFromSlug(slug); +} diff --git a/src/app/toronto/elections/2026/pledge/pledge-slug.ts b/src/app/toronto/elections/2026/pledge/pledge-slug.ts new file mode 100644 index 00000000..4b2ac9a8 --- /dev/null +++ b/src/app/toronto/elections/2026/pledge/pledge-slug.ts @@ -0,0 +1,59 @@ +export function slugifyName(name: string) { + return ( + name + .trim() + .toLowerCase() + .normalize("NFKD") + .replace(/[̀-ͯ]/g, "") + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") || "toronto-voter" + ); +} + +/* Legacy unique path segment for a pledge: the name slugified plus a short + random suffix. Only used when the API didn't return a share token. */ +export function pledgeSlug(name: string) { + const suffix = Math.random().toString(36).slice(2, 8).padEnd(6, "0"); + return `${slugifyName(name)}-${suffix}`; +} + +/* The canonical shared URL for a pledge. With a share token from York + Factory the page is server-verified (`/pledge/jane-doe-a1b2c3d4e5`); + without one we fall back to the legacy random-suffix URL carrying the + exact-case name in the query. */ +export function pledgeSharePath(name: string, shareToken?: string | null) { + const displayName = name.trim() || "A Toronto Voter"; + if (shareToken) { + return `/toronto/elections/2026/pledge/${slugifyName(displayName)}-${shareToken}`; + } + return `/toronto/elections/2026/pledge/${pledgeSlug(displayName)}?n=${encodeURIComponent(displayName)}`; +} + +/* York Factory share tokens are 10 lowercase-alphanumeric chars; the legacy + client-generated suffix was 6. */ +const SHARE_TOKEN_PATTERN = /^[a-z0-9]{10}$/; +const LEGACY_SUFFIX_PATTERN = /^[a-z0-9]{6}$/; + +export function tokenFromSlug(slug: string): string | null { + const last = decodeURIComponent(slug).split("-").pop() ?? ""; + return SHARE_TOKEN_PATTERN.test(last) ? last : null; +} + +/* Recover a display name from a shared pledge slug by title-casing the name + part, dropping the trailing share token or legacy random suffix. */ +export function nameFromSlug(slug: string) { + const parts = decodeURIComponent(slug).split("-"); + const last = parts[parts.length - 1]; + if ( + parts.length > 1 && + (SHARE_TOKEN_PATTERN.test(last) || LEGACY_SUFFIX_PATTERN.test(last)) + ) { + parts.pop(); + } + const name = parts + .filter(Boolean) + .map((p) => p[0].toUpperCase() + p.slice(1)) + .join(" ") + .slice(0, 40); + return name || "A Toronto Voter"; +} diff --git a/src/app/toronto/elections/2026/pledge/toronto-stamp-og.png b/src/app/toronto/elections/2026/pledge/toronto-stamp-og.png new file mode 100644 index 00000000..53f7e869 Binary files /dev/null and b/src/app/toronto/elections/2026/pledge/toronto-stamp-og.png differ diff --git a/src/app/toronto/elections/2026/pledge/toronto-stamp.png b/src/app/toronto/elections/2026/pledge/toronto-stamp.png new file mode 100644 index 00000000..eefd917d Binary files /dev/null and b/src/app/toronto/elections/2026/pledge/toronto-stamp.png differ diff --git a/src/app/toronto/elections/2026/wardGeo.ts b/src/app/toronto/elections/2026/wardGeo.ts new file mode 100644 index 00000000..63b23367 --- /dev/null +++ b/src/app/toronto/elections/2026/wardGeo.ts @@ -0,0 +1,198 @@ +// AUTO-GENERATED — do not edit by hand. +// Toronto 2026 council ward boundaries, projected to match the City of +// Toronto candidate-list map (d3.geoMercator().rotate([0,-13.45])) and +// simplified (Douglas–Peucker) for a compact locator map. +// Source: https://www.toronto.ca/resources/fepe_te_candidate_list/conf/map/COTGEO_WARD.json +// Regenerate with scratchpad/geocompute/gen.mjs. + +export const WARD_MAP_VIEWBOX = "0 0 300 157"; + +export type WardShape = { + /** zero-padded ward number, e.g. "01" */ + n: string; + /** City of Toronto ward name */ + name: string; + /** SVG path in the WARD_MAP_VIEWBOX coordinate space */ + d: string; + /** projected centroid */ + cx: number; + cy: number; +}; + +export const WARD_SHAPES: WardShape[] = [ + { + "n": "01", + "name": "Etobicoke North", + "d": "M45.6 5.3L46.5 5.7L41.4 8.1L41.5 11.8L44.1 14.4L46.1 14.9L45.7 17.6L49.8 20.1L50.3 22.3L52.3 23.1L52.1 24.2L53.2 22.9L56.5 23L56.3 24.5L58.1 26.3L55.5 27.7L54.3 30.9L55.9 33.1L57.3 32.8L56.6 33.8L58 35.1L60.1 34.2L59.1 37.6L57.2 38.1L56.9 40.4L54 43.7L56.4 46.8L54.7 49.5L56.2 51.8L55.3 54.2L57.8 54.8L59.8 58L34.1 57.9L34.2 71.6L23.9 70.1L18.2 71.3L13.7 18.3L11.4 4.4L45.1 4.5L45.6 5.3Z", + "cx": 33.1, + "cy": 35.5 + }, + { + "n": "02", + "name": "Etobicoke Centre", + "d": "M58.8 58L61.8 60L62.6 62.4L66 63.8L63.9 69.5L64.2 74.3L65.9 75.3L65.8 76.6L60.7 80.3L60 84.5L58.3 87L58.6 89L61.8 89.5L54.4 91.1L49.6 97.2L48 94.6L45 94.4L45.2 92.8L42.9 91.7L43 89.7L42.1 89.4L41.4 90.4L41.4 99.8L27.4 99.9L27.1 105.9L23.1 105.9L23.1 105L21.7 104.5L20.5 105.3L16.8 105.2L12.2 101L14.2 94.8L13.8 90.5L12.2 88.4L9.5 88.9L4.4 85.9L4 84.4L18.5 74.5L18.2 71.3L23.9 70.1L34.2 71.6L34.1 57.9L58.8 58Z", + "cx": 37.5, + "cy": 80.6 + }, + { + "n": "03", + "name": "Etobicoke-Lakeshore", + "d": "M63.6 98.6L66.7 100.1L64.7 103.6L65.1 107.4L67.7 107.9L67.1 110.5L70.5 111.9L71 115.2L72.7 117.6L72.7 118.4L69.8 119.5L68 122.3L69.2 123.7L69.6 122.6L70.1 123.4L70.9 122L71.6 122.3L71.7 124.5L69.5 124.2L68 126.5L67.2 123.9L65.8 123.6L66.7 124.6L65.6 123.6L67.1 125.7L65.8 126.3L67.7 127.1L65.8 129.2L65 128.9L64.1 130.6L63.7 129.3L62.2 129.7L61.7 128.9L62.6 127.9L63.9 128.9L65.5 126.9L64.4 125.3L62.7 125.6L60.2 128.6L59.7 131L54.8 135.5L52.8 135.2L48.9 139.3L45.9 139.7L43.4 138.7L43.7 139.8L42.7 140.6L43.8 141.4L42.6 142L42.7 143.6L40 142.8L38.8 141.4L40 142.4L42.1 142.1L42.2 141L40.3 140L40 141.4L38.6 139.5L33.7 138.9L32.2 139.8L28.4 138.4L23.4 142.5L23.7 139.2L22.3 137.4L23.6 136.3L22.4 134.5L23.8 131.6L21.4 130.3L21.6 124.8L17.4 117.9L19.8 116.4L17.9 114.2L21.7 107.5L22.8 107.4L23.1 105.9L27.1 105.9L27.4 99.9L41.4 99.8L41.4 90.4L42.1 89.4L43 89.7L42.9 91.7L45.2 92.8L45 94.4L48 94.6L49.6 97.2L54.4 91.1L61.8 89.5L64 93.4L61 96.9L63.6 98.6Z", + "cx": 44.4, + "cy": 116.8 + }, + { + "n": "04", + "name": "Parkdale-High Park", + "d": "M81.8 92.1L86.6 93.7L92.1 110L97.7 117L97.2 123.5L95.3 123.7L95.1 126.1L92.2 124.4L89.2 120.2L82.4 116L77.5 115.9L72.7 117.6L71 115.2L70.5 111.9L67.1 110.5L67.7 107.9L65.1 107.4L64.7 103.6L66.6 99.9L61.1 97.1L63.8 92.9L61.7 89.4L59.1 89.4L58.2 88.5L61.8 87.4L81.8 92.1Z", + "cx": 78.9, + "cy": 104.9 + }, + { + "n": "05", + "name": "York South-Weston", + "d": "M90.8 64.1L89.6 81.9L85.9 81.8L86.3 83L83.9 82.9L86.6 93.7L61.8 87.4L58.2 88.5L60.7 80.3L65.8 76.6L65.9 75.3L64.2 74.3L63.9 69.5L66 63.8L62.6 62.4L61.8 60L58.6 57L58 55.1L55.3 54.2L56.2 51.8L54.7 49.5L56.2 47.8L63 47.8L82.4 50.1L93.1 49.8L90.8 64.1Z", + "cx": 75.4, + "cy": 68.2 + }, + { + "n": "06", + "name": "York Centre", + "d": "M118.7 27.2L119 30.6L121 32.9L122.7 33.2L125 40.2L127.5 40.8L129.3 39.6L131.2 40.4L126.3 43.7L110.9 49.9L82.4 50.1L73 49.2L75.1 32.7L84.3 33.1L84.5 30.3L81.8 27L85.8 27.2L89.2 26.3L91 27.6L94.5 5.1L122.4 5.3L121 8.1L118.7 27.2Z", + "cx": 101.9, + "cy": 30.6 + }, + { + "n": "07", + "name": "Humber River-Black Creek", + "d": "M92.1 18.8L91 27.6L89.2 26.3L85.8 27.2L81.8 27L84.5 30.3L84.3 33.1L75.1 32.7L73 49.2L63 47.8L56.2 47.8L55.4 44.6L54 43.4L56.8 40.6L57.2 38.1L59.1 37.6L60.1 34.1L58 35.1L56.6 33.8L57.3 32.8L55.9 33.1L54.3 30.7L55.4 29.5L54.8 29L55.5 27.7L58 26.2L56.3 24.5L56.5 23L53.2 22.9L52.1 24.2L52.3 23.1L50.3 22.3L49.8 20.1L45.7 17.6L46.2 15.1L44.1 14.4L41.5 11.8L41.2 8.5L42.3 7.2L44.1 7.5L46.5 5.7L45.1 4.5L94.5 5.1L92.1 18.8Z", + "cx": 68.7, + "cy": 21.7 + }, + { + "n": "08", + "name": "Eglinton-Lawrence", + "d": "M89.9 74.9L93.1 49.8L111.9 49.7L126.3 43.7L133.2 39.5L127.8 75.2L89.9 74.9Z", + "cx": 111.9, + "cy": 61 + }, + { + "n": "09", + "name": "Davenport", + "d": "M98 82.2L104.4 83.5L103.1 113.1L101.1 112.4L100.8 119.3L97.9 117.4L92.1 110L83.8 85.1L83.9 82.9L86.3 83L85.9 81.8L89.6 81.9L89.9 74.9L90.7 74.6L98.2 74.7L98 82.2Z", + "cx": 95.5, + "cy": 94.8 + }, + { + "n": "10", + "name": "Spadina-Fort York", + "d": "M124 114.5L124.2 122.3L125.5 122L125.5 122.9L141.5 121.6L141.8 124.2L134.1 125.9L136.8 136.2L135.4 135.6L136.4 138L135.7 139.4L133.5 138.1L130.7 140.7L121.3 145L115 146.3L111.3 145.3L110.3 138.7L106.6 131.6L111.5 128.4L108.7 129.4L107.7 127.4L105.9 126.8L103.3 130.6L100.7 130.2L100.7 129.2L99.2 129.8L101 130.6L98.5 129.6L97.3 128.6L97.7 127.5L99.2 127.8L99.3 128.9L99.9 127.8L95.1 126.1L95.3 123.7L97.2 123.5L97.4 117L100.8 119.3L101.1 112.4L111.8 113.3L112.6 114.2L124 114.5Z", + "cx": 117.6, + "cy": 128.3 + }, + { + "n": "11", + "name": "University-Rosedale", + "d": "M128.6 88.3L128.3 87.6L129.5 87.6L129.6 86.6L134.1 86.4L138.6 91.3L141.6 89.2L143.4 90.7L143.6 92L141.8 94L142.7 94.1L142.2 96.8L138.9 98.7L139.7 101.1L138.6 102.4L138.8 105.2L139.9 107.7L131.4 103L129.2 104.8L124.1 104.6L124 110.5L125.6 110.5L125.6 114.6L103.1 113.1L103.5 96L119.1 96.5L125.7 94.9L125.8 91.8L129.7 90.9L128.6 88.3Z", + "cx": 121.9, + "cy": 101.6 + }, + { + "n": "12", + "name": "Toronto-St. Paul's", + "d": "M129.5 87.6L128.3 87.8L129.7 90.9L125.8 91.8L125.8 94.9L119.1 96.5L103.5 96L104.4 83.5L98.1 82.7L98.2 74.7L127.8 75.2L128.2 73L132.6 73.2L132.1 86.6L129.6 86.6L129.5 87.6Z", + "cx": 116, + "cy": 84.3 + }, + { + "n": "13", + "name": "Toronto Centre", + "d": "M131.4 103L139.9 107.7L140.7 109.8L139.9 117.8L141.5 121.6L125.5 122.9L125.5 122L124.2 122.3L124 114.5L125.6 114.6L125.6 110.5L124 110.5L124.1 104.6L129.2 104.8L131.4 103Z", + "cx": 132.1, + "cy": 113.5 + }, + { + "n": "14", + "name": "Toronto-Danforth", + "d": "M161.7 88.7L161.9 89.8L164 90.6L161.9 92.8L161.5 121L159.6 121.5L161.5 124.5L159.5 124.3L159.6 125.4L155.9 129.5L153.9 134.9L151 139.5L151.8 141.1L149.5 145.3L149.9 146.4L148.4 147.8L148.9 148.8L147.4 149.5L146.9 152.2L145 151.4L147.2 148.8L145.3 148.1L145.3 150.5L143.2 148.5L140.2 148.6L135.4 152.2L135.9 148.2L137.1 146.7L136.6 145.4L137.6 145.4L137.3 147.4L138.1 147.7L139.3 146.9L137.9 144.5L139.7 144.5L140 146.2L141.2 145.7L140.5 145.6L141.6 144.1L141.2 140.8L143.1 143L142.9 144.7L143.2 144.1L144.9 144.6L147.2 141.5L146 140.8L143 141.6L143 140.9L147.4 139.3L147.4 140.6L148.6 141.1L149.6 139.6L147.6 139.4L151.5 137.6L154.1 132.6L152.8 131.8L150.7 134.7L151.4 135.5L150.4 134.9L148.2 136.8L147.8 135.4L150.3 134L150.4 131.8L151.2 132.3L152.3 131.4L152.3 130.3L150.7 129.5L144.8 133.2L145.2 134.1L146.4 133.6L145.4 134.2L142.9 133.5L138.2 136.8L136.8 136.2L134.1 125.9L142 123.9L139.9 117.8L140.7 109.5L138.8 105.2L138.6 102.4L139.7 101.1L138.9 98.7L142.2 96.8L142.4 94.6L144.6 92.4L144 90.5L144.9 89.9L147 90.1L148.8 88.5L151.6 88L154.3 90.1L159.6 88.1L161.7 88.7Z", + "cx": 149.6, + "cy": 114.3 + }, + { + "n": "15", + "name": "Don Valley West", + "d": "M154 76.2L157.1 76.8L158.4 79.4L160.1 80.4L159.3 81.3L160 83.5L162.1 83.2L160.4 88.2L154.3 90.1L152 88L148.8 88.5L147 90.1L144.2 90.2L144.6 92.4L141.8 94.5L143.6 92L143.1 90.4L141.6 89.2L138.6 91.3L134.1 86.4L132.1 86.6L132.6 73.2L128.2 73L133.2 39.5L143.1 35.9L151.7 36.1L159.9 37.8L156.1 64.1L157.8 70.3L155.5 75.6L154 76.2Z", + "cx": 144.8, + "cy": 63.2 + }, + { + "n": "16", + "name": "Don Valley East", + "d": "M171.2 81.6L169.4 83.6L170.4 83.8L170.5 85.1L164.7 85.8L160.5 88.3L162.1 83.2L160 83.5L159.3 81.3L160.1 80.4L158.4 79.4L157.1 76.8L154 76.2L155.5 75.6L157.8 70.3L156.1 64.1L159.9 37.8L183 43.3L182 81.7L171.2 81.6Z", + "cx": 169.6, + "cy": 61.9 + }, + { + "n": "17", + "name": "Don Valley North", + "d": "M146.5 36L150.9 5.5L182.4 5.5L183 43.3L154.4 36.4L146.5 36Z", + "cx": 166.2, + "cy": 22.8 + }, + { + "n": "18", + "name": "Willowdale", + "d": "M141.9 36.1L131.2 40.4L129.3 39.6L126.1 40.7L123 36L122.7 33.2L121 32.9L119.1 30.8L119.4 29.7L118.5 28.6L121 8.1L122.4 5.3L150.9 5.5L146.5 36L141.9 36.1Z", + "cx": 134.2, + "cy": 21.3 + }, + { + "n": "19", + "name": "Beaches-East York", + "d": "M182 88.8L182.5 120L171.8 121.7L168.1 121.2L165.6 123.5L166.2 124.3L165.3 125.2L165.4 126.7L164 126.5L163 127.9L162.1 126.2L164.6 126L163.1 124.6L162.6 125.8L161.5 125.3L162.4 123.9L161.5 122.1L162.2 121.8L161.5 121.2L161.9 92.8L164 90.6L161.7 89.6L161.6 87.4L167 85.1L170.5 85.1L170.4 83.8L169.4 83.6L170.9 81.5L182 81.7L182 88.8Z", + "cx": 171.9, + "cy": 102.8 + }, + { + "n": "20", + "name": "Scarborough Southwest", + "d": "M182.5 120L182.1 77.8L230.1 78.1L229.9 90L226.3 93.6L225.2 93.3L221.8 95.7L219.5 98.8L218.3 102.9L217.4 101.9L219.1 99.4L217.7 98.5L216.7 99.7L218.6 99.6L217.7 101.1L216.8 100.9L217.4 101.3L216.5 102L215.5 101.2L216.8 100.2L216.2 99.6L214.5 101.5L216 103.3L217.5 102.6L216.2 104.2L214.5 103.7L214.1 101.9L211.8 104.2L211.9 105.7L210.2 104.6L212 103.4L211.6 102.7L210.4 103.2L210.7 104.3L209.1 103.6L206.7 105.3L199.9 107L182.5 120Z", + "cx": 202, + "cy": 92.8 + }, + { + "n": "21", + "name": "Scarborough Centre", + "d": "M219.5 54.5L219 64.8L224.8 64.9L224.5 78L182.1 77.8L183 43.3L188 44.4L219.8 44.6L219.5 54.5Z", + "cx": 202, + "cy": 61.7 + }, + { + "n": "22", + "name": "Scarborough-Agincourt", + "d": "M183 43.3L182.4 5.5L207.8 5.2L208.5 44.2L188 44.4L183 43.3Z", + "cx": 195.4, + "cy": 24.8 + }, + { + "n": "23", + "name": "Scarborough North", + "d": "M248.3 7L249.7 8.2L249.7 9.9L248.3 10.4L248.1 11.6L250.1 13.3L250.6 15L241.8 16L242.2 27.7L244.2 30.7L244.8 33.5L243.3 36.7L243.2 42L231.5 44.6L208.5 44.2L207.8 5.2L247.8 4.9L248.3 7Z", + "cx": 226.4, + "cy": 24 + }, + { + "n": "24", + "name": "Scarborough-Guildwood", + "d": "M252.8 75.9L252.8 78.2L246.9 80.3L240.4 84.5L233.9 87L229.9 90L230.1 78.1L224.5 78L224.8 64.9L219 64.8L219.8 44.6L233.1 44.5L242.5 42L253.6 43L252.8 75.9Z", + "cx": 237.4, + "cy": 62.4 + }, + { + "n": "25", + "name": "Scarborough-Rouge Park", + "d": "M281.1 18.4L281.3 37.7L283.3 41.4L285 41.5L285.8 43.6L288.9 45.7L288.2 48.6L286.9 50L289.4 49.9L292 52.1L291.9 54.2L293.7 55.7L293 56.7L296 57.7L293.1 59L281.4 69.6L273.3 73.5L268.8 77L260 76.6L252.8 78.2L253.6 43L243.2 42L243.3 36.7L244.8 33.5L244.2 30.7L242.2 27.7L241.8 16L250.6 15L250.1 13.3L248.1 11.6L248.3 10.4L249.7 9.9L249.7 8.3L248.1 6.8L247.8 4.9L281 4.5L281.1 18.4Z", + "cx": 266.2, + "cy": 39.6 + } +]; diff --git a/src/app/toronto/elections/2026/wards/[ward]/page.tsx b/src/app/toronto/elections/2026/wards/[ward]/page.tsx new file mode 100644 index 00000000..866d9d0c --- /dev/null +++ b/src/app/toronto/elections/2026/wards/[ward]/page.tsx @@ -0,0 +1,259 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import Image from "next/image"; +import { notFound } from "next/navigation"; +import { ArrowLeft, ArrowRight, ArrowUpRight } from "lucide-react"; +import CountdownDays from "../../CountdownDays"; +import { CandidateSiteLink } from "../../CandidateSiteLink"; +import { WardMap, WardMapDefs } from "../../WardMap"; +import { + WARD_NUMBERS, + getWards, + getCouncillorCandidates, + findWardIndex, + initialsFor, + nameKey, + daysUntilElection, + NOMINATION_CLOSE_LABEL, + ELECTION_DAY_LABEL, + type CouncillorTag, +} from "../../data"; +import { WARD_SHAPES } from "../../wardGeo"; + +export function generateStaticParams() { + return WARD_NUMBERS.map((n) => ({ ward: n })); +} + +export async function generateMetadata({ + params, +}: { + params: Promise<{ ward: string }>; +}): Promise { + const { ward } = await params; + const idx = findWardIndex(ward); + if (idx === -1) return { title: "Ward not found" }; + const w = WARD_SHAPES[idx]; + return { + title: `Ward ${w.n} — ${w.name}`, + description: `The council race in Ward ${w.n} (${w.name}) for Toronto's October 26, 2026 municipal election. See every candidate registered to represent it.`, + alternates: { canonical: `/toronto/elections/2026/wards/${w.n}` }, + openGraph: { + title: `Ward ${w.n} — ${w.name} — Toronto 2026 Election`, + description: `Candidates for councillor in ${w.name}.`, + type: "website", + }, + }; +} + +const TAG_STYLES: Record = { + Incumbent: "border-accent text-accent", + Challenger: "border-text-secondary text-dark", + Registered: "border-border-light text-text-secondary", +}; + +export default async function WardDetailPage({ + params, +}: { + params: Promise<{ ward: string }>; +}) { + const { ward } = await params; + const idx = findWardIndex(ward); + if (idx === -1) notFound(); + + const [wards, candidates] = await Promise.all([ + getWards(), + getCouncillorCandidates(idx), + ]); + const w = wards[idx]; + const initialDays = daysUntilElection(); + + const prev = wards[(idx + wards.length - 1) % wards.length]; + const next = wards[(idx + 1) % wards.length]; + + return ( +
+
+ + + {/* ── Breadcrumb ─────────────────────────────────────── */} +
+ + All wards + + / + Ward {w.n} +
+ + {/* ── Hero ───────────────────────────────────────────── */} +
+
+

+ City Council · Ward {w.n} +

+

+ {w.name} +

+

+ One seat on Toronto city council, decided on + October 26, 2026. Below are the candidates registered to + represent {w.name} — the people who will set the direction on + housing, transit and local growth for this part of the city. +

+
+ +
+ + {/* ── Key stats ──────────────────────────────────────── */} +
+
+
+ {w.count} +
+
+ Candidates registered +
+
+
+ +
+ Days until polls open +
+
+
+
+ {ELECTION_DAY_LABEL} +
+
+ Election day +
+
+
+ + {/* ── Candidates ─────────────────────────────────────── */} +
+
+

+ Candidates for Councillor +

+
+
+ {candidates.length === 0 && ( +

+ No candidates have registered in {w.name} yet. Nominations close + on {NOMINATION_CLOSE_LABEL} — check back as more candidates + register. +

+ )} + {candidates.map((cand, i) => ( +
+
+ {cand.image ? ( + {cand.name} + ) : ( + (cand.initials ?? initialsFor(cand.name)) + )} +
+
+
+

+ {cand.name} +

+ + {cand.tag} + +
+

+ {cand.bio} +

+
+ {cand.website ? ( + + Campaign site + + + ) : ( + + Profile to come + + )} +
+ ))} +
+

+ Registered candidates from the City Clerk’s list. The field is + not final until nominations close on {NOMINATION_CLOSE_LABEL}. +

+
+ + {/* ── Prev / next ward ───────────────────────────────── */} +
+ +
+ Ward {prev.n} +
+
+ {prev.name} +
+ + +
+ Ward {next.n} +
+
+ {next.name} +
+ +
+ + {/* ── Footer band ────────────────────────────────────── */} +
+

+ Toronto 2026 Election Tracker +
A Build Canada project +

+ + Back to all wards + +
+
+
+ ); +} diff --git a/src/app/toronto/elections/PledgeButton.tsx b/src/app/toronto/elections/PledgeButton.tsx new file mode 100644 index 00000000..e86b7d1f --- /dev/null +++ b/src/app/toronto/elections/PledgeButton.tsx @@ -0,0 +1,169 @@ +"use client"; + +import { Dialog } from "@base-ui/react/dialog"; +import { useRouter } from "next/navigation"; +import { useState, type FormEvent } from "react"; +import posthog from "posthog-js"; +import { Button } from "@/components/ui/button"; +import { pledgeSharePath } from "./2026/pledge/pledge-slug"; + +/* "Pledge to vote" CTA — opens the same modal treatment as the navbar + Subscribe button. Submitting records the pledge (via /api/elections/pledge + → pledges_to_vote) and redirects to the pledger's unique shareable page. */ +export function PledgeButton({ + className, + children, + region = "toronto", + source = "get-involved", +}: { + className?: string; + children: React.ReactNode; + /** e.g. "ward-5" for ward-scoped pledges; defaults to city-wide */ + region?: string; + source?: string; +}) { + const router = useRouter(); + const [firstName, setFirstName] = useState(""); + const [lastName, setLastName] = useState(""); + const [email, setEmail] = useState(""); + const [postalCode, setPostalCode] = useState(""); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!emailRegex.test(email)) { + setError("Please enter a valid email address"); + return; + } + + const postalRegex = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; + if (!postalRegex.test(postalCode)) { + setError("Please enter a valid Canadian postal code (e.g. A1A 1A1)"); + return; + } + + setLoading(true); + try { + const name = `${firstName.trim()} ${lastName.trim()}`.trim(); + const res = await fetch("/api/elections/pledge", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, name, region, postal_code: postalCode }), + }); + const data = await res.json().catch(() => ({})); + if (!res.ok) { + setError(data.error || "Something went wrong"); + setLoading(false); + return; + } + posthog.identify(email, { email }); + posthog.capture("pledged_to_vote", { source }); + // keep the button disabled while we navigate to the shared page; + // prefer the server's record (canonical name + unguessable token) + router.push(pledgeSharePath(data.name || name, data.shareToken)); + } catch { + setError("Network error. Please try again."); + setLoading(false); + } + }; + + const inputClass = + "border border-charcoal-300 bg-white px-3 py-2.5 type-body placeholder:text-charcoal-400 outline-none focus:border-charcoal-1000 transition-colors"; + + return ( + { + if (open) posthog.capture("pledge_modal_opened", { source }); + }} + > + {children} + + + + + Pledge to vote + +
+ + Toronto votes Monday, October 26. Put your name on the record. + +
+
+ setFirstName(e.target.value)} + placeholder="First name" + required + className={inputClass} + /> + setLastName(e.target.value)} + placeholder="Last name" + required + className={inputClass} + /> + setEmail(e.target.value)} + placeholder="Email" + required + className={inputClass} + /> + setPostalCode(e.target.value)} + placeholder="Postal code (e.g. A1A 1A1)" + required + maxLength={7} + className={inputClass} + /> + + {error &&

{error}

} +
+ + + + + +
+
+
+ ); +} diff --git a/src/app/toronto/elections/get-involved/page.tsx b/src/app/toronto/elections/get-involved/page.tsx new file mode 100644 index 00000000..0dd7ac8d --- /dev/null +++ b/src/app/toronto/elections/get-involved/page.tsx @@ -0,0 +1,161 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { FileCheck2, Users, HeartHandshake, ArrowRight } from "lucide-react"; +import { PledgeButton } from "../PledgeButton"; + +export const metadata: Metadata = { + title: "Get Involved — Toronto 2026 Election", + description: + "Toronto votes Monday, October 26. Pledge to vote, register, volunteer, or donate — the Toronto you know is possible doesn't vote itself in.", + alternates: { canonical: "/toronto/elections/get-involved" }, + openGraph: { + title: "Get Involved — Toronto 2026 Election | Build Canada", + description: + "Pledge to vote in Toronto's 2026 municipal election on October 26, 2026 — and find out how to register, volunteer, and donate.", + type: "website", + }, +}; + +const WAYS = [ + { + n: "01", + icon: FileCheck2, + title: "Register to Vote", + body: ( + <> + Eligible isn’t the same as registered. Take two minutes and make + sure you’re on the voters’ list before October. + + ), + action: { + label: "registertovoteon.ca", + href: "https://registertovoteon.ca", + }, + }, + { + n: "02", + icon: Users, + title: "Volunteer", + body: ( + <> + There’s no better way to build this city than volunteering in an + election. Think of us as your political concierge — get in touch and + we’ll connect you to the campaign of your choice. The benefits are + abundant and rewarding. You’ll thank us for it later. + + ), + action: { + label: "Get in touch", + href: "mailto:toronto@buildcanada.com?subject=I%20want%20to%20volunteer", + }, + }, + { + n: "03", + icon: HeartHandshake, + title: "Donate", + body: ( + <> + Campaigns run on volunteers — and on donations. If giving your time + isn’t in the cards, giving to a campaign is just as vital. Reach + out and we’ll point you to the campaign of your choice. Your city + will thank you for it. + + ), + action: { + label: "Reach out", + href: "mailto:toronto@buildcanada.com?subject=I%20want%20to%20donate", + }, + }, +]; + +export default function GetInvolvedPage() { + return ( +
+
+ {/* ── Hero ─────────────────────────────────────────────── */} +
+

+ Toronto votes Monday, October 26 +

+

+ Don’t just live here. Build here. +

+

+ The mayor, 25 councillors, and school trustees elected that day + decide what this city builds for the next four years. Old thinking + won’t save us. You might. +

+ + Pledge to vote + + +
+ + {/* ── Ways to get involved (full-width card grid) ──────── */} +
+
+ {WAYS.map(({ n, icon: Icon, title, body, action }) => ( +
+
+ + + {n} + +
+

+ {title} +

+

+ {body} +

+ + {action.label} + + +
+ ))} +
+
+ + {/* ── Closing CTA (Toronto-blue band, full bleed) ──────── */} +
+

+ “We shall not err for want of boldness.” +
— Sir Wilfrid Laurier +

+

+ Build the Toronto you know is possible. +

+
+ + Explore the candidates + + + + Pledge to vote + + +
+
+
+
+ ); +} diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 1499e172..dacb641b 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,6 +1,7 @@ "use client"; import Image from "next/image"; +import { usePathname } from "next/navigation"; import { useRef, useState, useEffect } from "react"; import { Button } from "@/components/ui/button"; import { useSubscribeStore } from "@/components/subscribe/store"; @@ -10,6 +11,10 @@ export default function Footer() { const footerRef = useRef(null); const [leafVisible, setLeafVisible] = useState(false); const openModal = useSubscribeStore((s) => s.openModal); + const pathname = usePathname(); + // full-screen page, no site chrome + const isChromeless = + pathname?.startsWith("/toronto/elections/2026/pledge") ?? false; useEffect(() => { const el = footerRef.current; @@ -27,6 +32,8 @@ export default function Footer() { return () => observer.disconnect(); }, []); + if (isChromeless) return null; + return (