SPB Git forge
28commits 1branches 0releases
7.7 MBsize
maindefault branch
10 days agolast push
Python 66.3% TypeScript 22.7% JavaScript 8.6% HTML 1.4% CSS 0.7%

brand: atlas-plate mark, SVG logo set, favicon.ico + PNG icons, redesigned share images (site, company, industry, country), credits footer and About section

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Simon-Pierre Boucher committed 11 days ago (Sep 13, 2026) parent a390f85

30 changed files +1,254 −169

modified apps/web/package.json +2 −1
@@ -9,7 +9,8 @@
9 9 "typecheck": "tsc -p tsconfig.json --noEmit",
10 10 "qa": "node qa/screens.mjs",
11 11 "qa:flows": "node qa/flows.mjs",
12 − "mock": "node qa/mock-api.mjs"
12 + "mock": "node qa/mock-api.mjs",
13 + "icons": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON scripts/build-icons.mjs"
13 14 },
14 15 "dependencies": {
15 16 "d3-array": "^3.2.4",
added apps/web/public/apple-touch-icon.png +0 −0

Binary file not shown.

added apps/web/public/favicon.ico +0 −0

Binary file not shown.

added apps/web/public/icon-192.png +0 −0

Binary file not shown.

added apps/web/public/icon-512-maskable.png +0 −0

Binary file not shown.

added apps/web/public/icon-512.png +0 −0

Binary file not shown.

added apps/web/public/logo-dark.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 258 40" width="258" height="40" fill="none" role="img" aria-label="Company Atlas"><title>Company Atlas</title><g transform="translate(0 0) scale(0.625)"><rect x="0" y="0" width="64" height="64" rx="16" fill="#e8ebf1"/><rect x="0.75" y="0.75" width="62.5" height="62.5" rx="15.25" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1" opacity="0.55"/><path d="M23.5 32a8.5 20 0 1 0 17 0a8.5 20 0 1 0 -17 0Z" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1.9"/><path d="M14.68 22H49.32M14.68 42H49.32" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1.9" stroke-linecap="round"/><path d="M12 32a20 20 0 1 0 40 0a20 20 0 1 0 -40 0Z" fill="none" stroke="#0a0d12" stroke-width="3"/><path d="M12 32H52" fill="none" stroke="#0a0d12" stroke-width="3" stroke-linecap="round"/><path d="M35 30.9L48.6 28.3V35.7L35 33.1Z" fill="#16a34a" opacity="0.55"/><path d="M43.4 32a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0Z" fill="none" stroke="#16a34a" stroke-width="1.6" opacity="0.45"/><path d="M47 32a5 5 0 1 0 10 0a5 5 0 1 0 -10 0Z" fill="#16a34a"/></g><path d="M63.92 31.13Q61.07 31.13 58.91 29.8Q56.75 28.46 55.55 25.97Q54.35 23.48 54.35 20.03Q54.35 16.58 55.55 14.08Q56.75 11.57 58.91 10.22Q61.07 8.87 63.92 8.87Q67.79 8.87 69.92 10.82Q72.05 12.77 72.71 16.13L69.98 16.31Q69.53 14.03 68.06 12.71Q66.59 11.39 63.92 11.39Q61.88 11.39 60.34 12.43Q58.79 13.46 57.92 15.4Q57.05 17.33 57.05 20.03Q57.05 22.73 57.92 24.65Q58.79 26.57 60.34 27.59Q61.88 28.61 63.92 28.61Q66.71 28.61 68.24 27.15Q69.77 25.7 70.16 23.21L72.89 23.39Q72.5 25.7 71.36 27.44Q70.22 29.18 68.35 30.15Q66.47 31.13 63.92 31.13ZM82.1 31.01Q79.91 31.01 78.26 29.99Q76.61 28.97 75.71 27.1Q74.81 25.22 74.81 22.7Q74.81 20.15 75.71 18.29Q76.61 16.43 78.26 15.41Q79.91 14.39 82.1 14.39Q84.29 14.39 85.93 15.41Q87.56 16.43 88.46 18.29Q89.36 20.15 89.36 22.7Q89.36 25.22 88.46 27.1Q87.56 28.97 85.93 29.99Q84.29 31.01 82.1 31.01ZM82.1 28.61Q84.29 28.61 85.51 27.04Q86.72 25.46 86.72 22.7Q86.72 19.94 85.51 18.36Q84.29 16.79 82.1 16.79Q79.91 16.79 78.68 18.36Q77.45 19.94 77.45 22.7Q77.45 25.46 78.68 27.04Q79.91 28.61 82.1 28.61ZM92.24 30.65L92.24 14.75L94.55 14.75L94.61 17.54Q95.21 16.07 96.38 15.23Q97.55 14.39 99.08 14.39Q100.88 14.39 102.11 15.26Q103.34 16.13 103.85 17.72Q104.36 16.13 105.56 15.26Q106.76 14.39 108.5 14.39Q111.05 14.39 112.4 15.97Q113.75 17.54 113.75 20.42L113.75 30.65L111.23 30.65L111.23 20.96Q111.23 16.61 107.9 16.61Q106.25 16.61 105.25 17.81Q104.24 19.01 104.24 21.14L104.24 30.65L101.72 30.65L101.72 21.14Q101.72 19.01 100.97 17.81Q100.22 16.61 98.45 16.61Q96.8 16.61 95.78 17.81Q94.76 19.01 94.76 21.14L94.76 30.65L92.24 30.65ZM117.65 35.15L117.65 14.75L119.99 14.75L120.02 17.18Q120.74 15.8 122.02 15.1Q123.29 14.39 124.91 14.39Q127.28 14.39 128.79 15.56Q130.31 16.73 131.04 18.62Q131.78 20.51 131.78 22.7Q131.78 24.89 131.04 26.78Q130.31 28.67 128.79 29.84Q127.28 31.01 124.91 31.01Q123.35 31.01 122.08 30.35Q120.8 29.69 120.17 28.55L120.17 35.15L117.65 35.15ZM124.67 28.61Q126.74 28.61 127.94 27.05Q129.14 25.49 129.14 22.7Q129.14 19.91 127.94 18.35Q126.74 16.79 124.67 16.79Q122.6 16.79 121.39 18.27Q120.17 19.76 120.17 22.7Q120.17 25.61 121.37 27.11Q122.57 28.61 124.67 28.61ZM138.59 31.01Q136.13 31.01 134.67 29.87Q133.22 28.73 133.22 26.69Q133.22 24.65 134.42 23.48Q135.62 22.31 138.23 21.83L143.87 20.78Q143.87 16.79 140.09 16.79Q138.44 16.79 137.48 17.52Q136.52 18.26 136.16 19.64L133.49 19.43Q133.94 17.18 135.66 15.78Q137.39 14.39 140.09 14.39Q143.15 14.39 144.77 16.11Q146.39 17.84 146.39 20.87L146.39 27.44Q146.39 28.43 147.23 28.43L147.86 28.43L147.86 30.65Q147.5 30.71 146.9 30.71Q145.52 30.71 144.81 30.15Q144.11 29.6 143.93 28.34L143.9 28.19Q143.3 29.42 141.83 30.22Q140.36 31.01 138.59 31.01ZM138.83 28.79Q141.23 28.79 142.55 27.44Q143.87 26.09 143.87 24.11L143.87 22.97L138.71 23.93Q137.09 24.23 136.47 24.85Q135.86 25.46 135.86 26.45Q135.86 27.56 136.65 28.17Q137.45 28.79 138.83 28.79ZM150.35 30.65L150.35 14.75L152.66 14.75L152.72 17.6Q153.35 15.95 154.66 15.17Q155.96 14.39 157.61 14.39Q159.44 14.39 160.63 15.2Q161.81 16.01 162.39 17.38Q162.98 18.74 162.98 20.42L162.98 30.65L160.46 30.65L160.46 21.14Q160.46 18.92 159.67 17.77Q158.87 16.61 157.07 16.61Q155.24 16.61 154.05 17.77Q152.87 18.92 152.87 21.14L152.87 30.65L150.35 30.65ZM167.75 35.15L167.75 32.93L169.58 32.93Q170.42 32.93 170.83 32.67Q171.23 32.42 171.44 31.85L172.01 30.35L171.14 30.35L165.32 14.75L168.08 14.75L172.82 28.01L177.35 14.75L180.11 14.75L173.66 32.66Q173.21 33.95 172.31 34.55Q171.41 35.15 169.82 35.15L167.75 35.15Z" fill="#a3a9b8"/><path d="M186.62 30.65L194.3 9.35L199.07 9.35L206.75 30.65L202.67 30.65L200.9 25.61L192.47 25.61L190.7 30.65L186.62 30.65ZM193.61 22.28L199.76 22.28L196.7 13.28L193.61 22.28ZM215.24 30.65Q212.84 30.65 211.71 29.56Q210.59 28.46 210.59 26.06L210.59 17.6L208.07 17.6L208.07 14.63L210.59 14.63L210.59 10.88L214.43 10.88L214.43 14.63L218.66 14.63L218.66 17.6L214.43 17.6L214.43 25.7Q214.43 26.84 214.93 27.26Q215.42 27.68 216.41 27.68L218.66 27.68L218.66 30.65L215.24 30.65ZM222.83 30.65Q221.06 30.65 220.02 29.75Q218.99 28.85 218.99 26.87L218.99 9.35L222.83 9.35L222.83 26.48Q222.83 27.68 224.03 27.68L225.2 27.68L225.2 30.65L222.83 30.65ZM231.95 31.01Q229.43 31.01 227.9 29.87Q226.37 28.73 226.37 26.63Q226.37 24.56 227.66 23.39Q228.95 22.22 231.59 21.71L236.9 20.66Q236.9 17.24 233.81 17.24Q232.43 17.24 231.62 17.88Q230.81 18.53 230.54 19.73L226.64 19.55Q227.12 17.03 228.98 15.65Q230.84 14.27 233.81 14.27Q237.23 14.27 238.99 16Q240.74 17.72 240.74 20.93L240.74 26.75Q240.74 27.38 240.96 27.62Q241.19 27.86 241.64 27.86L242.15 27.86L242.15 30.65Q241.64 30.77 240.74 30.77Q239.45 30.77 238.55 30.23Q237.65 29.69 237.41 28.22L237.41 28.19Q236.81 29.45 235.36 30.23Q233.9 31.01 231.95 31.01ZM232.73 28.22Q234.65 28.22 235.77 27.11Q236.9 26 236.9 24.17L236.9 23.27L232.76 24.11Q231.47 24.38 230.92 24.9Q230.36 25.43 230.36 26.27Q230.36 28.22 232.73 28.22ZM250.55 31.01Q246.89 31.01 245.09 29.48Q243.29 27.95 243.11 25.61L247.04 25.43Q247.25 26.69 248.05 27.38Q248.84 28.07 250.58 28.07Q251.93 28.07 252.64 27.64Q253.34 27.2 253.34 26.27Q253.34 25.73 253.09 25.37Q252.83 25.01 252.1 24.74Q251.36 24.47 249.95 24.2Q247.52 23.78 246.14 23.17Q244.76 22.55 244.21 21.62Q243.65 20.69 243.65 19.31Q243.65 17.09 245.38 15.68Q247.1 14.27 250.43 14.27Q252.74 14.27 254.23 15.01Q255.71 15.74 256.48 16.95Q257.24 18.17 257.36 19.67L253.46 19.85Q253.4 18.71 252.68 17.96Q251.96 17.21 250.37 17.21Q249.02 17.21 248.32 17.75Q247.61 18.29 247.61 19.19Q247.61 20.15 248.29 20.62Q248.96 21.08 250.76 21.38Q253.22 21.74 254.66 22.37Q256.1 23 256.71 23.95Q257.33 24.89 257.33 26.21Q257.33 28.52 255.49 29.76Q253.64 31.01 250.55 31.01Z" fill="#e8ebf1"/></svg>
\ No newline at end of file
added apps/web/public/logo-light.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 258 40" width="258" height="40" fill="none" role="img" aria-label="Company Atlas"><title>Company Atlas</title><g transform="translate(0 0) scale(0.625)"><rect x="0" y="0" width="64" height="64" rx="16" fill="#0f1419"/><rect x="0.75" y="0.75" width="62.5" height="62.5" rx="15.25" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1" opacity="0.55"/><path d="M23.5 32a8.5 20 0 1 0 17 0a8.5 20 0 1 0 -17 0Z" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1.9"/><path d="M14.68 22H49.32M14.68 42H49.32" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1.9" stroke-linecap="round"/><path d="M12 32a20 20 0 1 0 40 0a20 20 0 1 0 -40 0Z" fill="none" stroke="#f7f7f4" stroke-width="3"/><path d="M12 32H52" fill="none" stroke="#f7f7f4" stroke-width="3" stroke-linecap="round"/><path d="M35 30.9L48.6 28.3V35.7L35 33.1Z" fill="#3fd07a" opacity="0.55"/><path d="M43.4 32a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0Z" fill="none" stroke="#3fd07a" stroke-width="1.6" opacity="0.45"/><path d="M47 32a5 5 0 1 0 10 0a5 5 0 1 0 -10 0Z" fill="#3fd07a"/></g><path d="M63.92 31.13Q61.07 31.13 58.91 29.8Q56.75 28.46 55.55 25.97Q54.35 23.48 54.35 20.03Q54.35 16.58 55.55 14.08Q56.75 11.57 58.91 10.22Q61.07 8.87 63.92 8.87Q67.79 8.87 69.92 10.82Q72.05 12.77 72.71 16.13L69.98 16.31Q69.53 14.03 68.06 12.71Q66.59 11.39 63.92 11.39Q61.88 11.39 60.34 12.43Q58.79 13.46 57.92 15.4Q57.05 17.33 57.05 20.03Q57.05 22.73 57.92 24.65Q58.79 26.57 60.34 27.59Q61.88 28.61 63.92 28.61Q66.71 28.61 68.24 27.15Q69.77 25.7 70.16 23.21L72.89 23.39Q72.5 25.7 71.36 27.44Q70.22 29.18 68.35 30.15Q66.47 31.13 63.92 31.13ZM82.1 31.01Q79.91 31.01 78.26 29.99Q76.61 28.97 75.71 27.1Q74.81 25.22 74.81 22.7Q74.81 20.15 75.71 18.29Q76.61 16.43 78.26 15.41Q79.91 14.39 82.1 14.39Q84.29 14.39 85.93 15.41Q87.56 16.43 88.46 18.29Q89.36 20.15 89.36 22.7Q89.36 25.22 88.46 27.1Q87.56 28.97 85.93 29.99Q84.29 31.01 82.1 31.01ZM82.1 28.61Q84.29 28.61 85.51 27.04Q86.72 25.46 86.72 22.7Q86.72 19.94 85.51 18.36Q84.29 16.79 82.1 16.79Q79.91 16.79 78.68 18.36Q77.45 19.94 77.45 22.7Q77.45 25.46 78.68 27.04Q79.91 28.61 82.1 28.61ZM92.24 30.65L92.24 14.75L94.55 14.75L94.61 17.54Q95.21 16.07 96.38 15.23Q97.55 14.39 99.08 14.39Q100.88 14.39 102.11 15.26Q103.34 16.13 103.85 17.72Q104.36 16.13 105.56 15.26Q106.76 14.39 108.5 14.39Q111.05 14.39 112.4 15.97Q113.75 17.54 113.75 20.42L113.75 30.65L111.23 30.65L111.23 20.96Q111.23 16.61 107.9 16.61Q106.25 16.61 105.25 17.81Q104.24 19.01 104.24 21.14L104.24 30.65L101.72 30.65L101.72 21.14Q101.72 19.01 100.97 17.81Q100.22 16.61 98.45 16.61Q96.8 16.61 95.78 17.81Q94.76 19.01 94.76 21.14L94.76 30.65L92.24 30.65ZM117.65 35.15L117.65 14.75L119.99 14.75L120.02 17.18Q120.74 15.8 122.02 15.1Q123.29 14.39 124.91 14.39Q127.28 14.39 128.79 15.56Q130.31 16.73 131.04 18.62Q131.78 20.51 131.78 22.7Q131.78 24.89 131.04 26.78Q130.31 28.67 128.79 29.84Q127.28 31.01 124.91 31.01Q123.35 31.01 122.08 30.35Q120.8 29.69 120.17 28.55L120.17 35.15L117.65 35.15ZM124.67 28.61Q126.74 28.61 127.94 27.05Q129.14 25.49 129.14 22.7Q129.14 19.91 127.94 18.35Q126.74 16.79 124.67 16.79Q122.6 16.79 121.39 18.27Q120.17 19.76 120.17 22.7Q120.17 25.61 121.37 27.11Q122.57 28.61 124.67 28.61ZM138.59 31.01Q136.13 31.01 134.67 29.87Q133.22 28.73 133.22 26.69Q133.22 24.65 134.42 23.48Q135.62 22.31 138.23 21.83L143.87 20.78Q143.87 16.79 140.09 16.79Q138.44 16.79 137.48 17.52Q136.52 18.26 136.16 19.64L133.49 19.43Q133.94 17.18 135.66 15.78Q137.39 14.39 140.09 14.39Q143.15 14.39 144.77 16.11Q146.39 17.84 146.39 20.87L146.39 27.44Q146.39 28.43 147.23 28.43L147.86 28.43L147.86 30.65Q147.5 30.71 146.9 30.71Q145.52 30.71 144.81 30.15Q144.11 29.6 143.93 28.34L143.9 28.19Q143.3 29.42 141.83 30.22Q140.36 31.01 138.59 31.01ZM138.83 28.79Q141.23 28.79 142.55 27.44Q143.87 26.09 143.87 24.11L143.87 22.97L138.71 23.93Q137.09 24.23 136.47 24.85Q135.86 25.46 135.86 26.45Q135.86 27.56 136.65 28.17Q137.45 28.79 138.83 28.79ZM150.35 30.65L150.35 14.75L152.66 14.75L152.72 17.6Q153.35 15.95 154.66 15.17Q155.96 14.39 157.61 14.39Q159.44 14.39 160.63 15.2Q161.81 16.01 162.39 17.38Q162.98 18.74 162.98 20.42L162.98 30.65L160.46 30.65L160.46 21.14Q160.46 18.92 159.67 17.77Q158.87 16.61 157.07 16.61Q155.24 16.61 154.05 17.77Q152.87 18.92 152.87 21.14L152.87 30.65L150.35 30.65ZM167.75 35.15L167.75 32.93L169.58 32.93Q170.42 32.93 170.83 32.67Q171.23 32.42 171.44 31.85L172.01 30.35L171.14 30.35L165.32 14.75L168.08 14.75L172.82 28.01L177.35 14.75L180.11 14.75L173.66 32.66Q173.21 33.95 172.31 34.55Q171.41 35.15 169.82 35.15L167.75 35.15Z" fill="#4a5160"/><path d="M186.62 30.65L194.3 9.35L199.07 9.35L206.75 30.65L202.67 30.65L200.9 25.61L192.47 25.61L190.7 30.65L186.62 30.65ZM193.61 22.28L199.76 22.28L196.7 13.28L193.61 22.28ZM215.24 30.65Q212.84 30.65 211.71 29.56Q210.59 28.46 210.59 26.06L210.59 17.6L208.07 17.6L208.07 14.63L210.59 14.63L210.59 10.88L214.43 10.88L214.43 14.63L218.66 14.63L218.66 17.6L214.43 17.6L214.43 25.7Q214.43 26.84 214.93 27.26Q215.42 27.68 216.41 27.68L218.66 27.68L218.66 30.65L215.24 30.65ZM222.83 30.65Q221.06 30.65 220.02 29.75Q218.99 28.85 218.99 26.87L218.99 9.35L222.83 9.35L222.83 26.48Q222.83 27.68 224.03 27.68L225.2 27.68L225.2 30.65L222.83 30.65ZM231.95 31.01Q229.43 31.01 227.9 29.87Q226.37 28.73 226.37 26.63Q226.37 24.56 227.66 23.39Q228.95 22.22 231.59 21.71L236.9 20.66Q236.9 17.24 233.81 17.24Q232.43 17.24 231.62 17.88Q230.81 18.53 230.54 19.73L226.64 19.55Q227.12 17.03 228.98 15.65Q230.84 14.27 233.81 14.27Q237.23 14.27 238.99 16Q240.74 17.72 240.74 20.93L240.74 26.75Q240.74 27.38 240.96 27.62Q241.19 27.86 241.64 27.86L242.15 27.86L242.15 30.65Q241.64 30.77 240.74 30.77Q239.45 30.77 238.55 30.23Q237.65 29.69 237.41 28.22L237.41 28.19Q236.81 29.45 235.36 30.23Q233.9 31.01 231.95 31.01ZM232.73 28.22Q234.65 28.22 235.77 27.11Q236.9 26 236.9 24.17L236.9 23.27L232.76 24.11Q231.47 24.38 230.92 24.9Q230.36 25.43 230.36 26.27Q230.36 28.22 232.73 28.22ZM250.55 31.01Q246.89 31.01 245.09 29.48Q243.29 27.95 243.11 25.61L247.04 25.43Q247.25 26.69 248.05 27.38Q248.84 28.07 250.58 28.07Q251.93 28.07 252.64 27.64Q253.34 27.2 253.34 26.27Q253.34 25.73 253.09 25.37Q252.83 25.01 252.1 24.74Q251.36 24.47 249.95 24.2Q247.52 23.78 246.14 23.17Q244.76 22.55 244.21 21.62Q243.65 20.69 243.65 19.31Q243.65 17.09 245.38 15.68Q247.1 14.27 250.43 14.27Q252.74 14.27 254.23 15.01Q255.71 15.74 256.48 16.95Q257.24 18.17 257.36 19.67L253.46 19.85Q253.4 18.71 252.68 17.96Q251.96 17.21 250.37 17.21Q249.02 17.21 248.32 17.75Q247.61 18.29 247.61 19.19Q247.61 20.15 248.29 20.62Q248.96 21.08 250.76 21.38Q253.22 21.74 254.66 22.37Q256.1 23 256.71 23.95Q257.33 24.89 257.33 26.21Q257.33 28.52 255.49 29.76Q253.64 31.01 250.55 31.01Z" fill="#0f1419"/></svg>
\ No newline at end of file
added apps/web/public/logo-mark.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"><rect x="0" y="0" width="64" height="64" rx="16" fill="#0f1419"/><rect x="0.75" y="0.75" width="62.5" height="62.5" rx="15.25" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1" opacity="0.55"/><path d="M23.5 32a8.5 20 0 1 0 17 0a8.5 20 0 1 0 -17 0Z" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1.9"/><path d="M14.68 22H49.32M14.68 42H49.32" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="1.9" stroke-linecap="round"/><path d="M12 32a20 20 0 1 0 40 0a20 20 0 1 0 -40 0Z" fill="none" stroke="#f7f7f4" stroke-width="3"/><path d="M12 32H52" fill="none" stroke="#f7f7f4" stroke-width="3" stroke-linecap="round"/><path d="M35 30.9L48.6 28.3V35.7L35 33.1Z" fill="#3fd07a" opacity="0.55"/><path d="M43.4 32a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0Z" fill="none" stroke="#3fd07a" stroke-width="1.6" opacity="0.45"/><path d="M47 32a5 5 0 1 0 10 0a5 5 0 1 0 -10 0Z" fill="#3fd07a"/></svg>
\ No newline at end of file
added apps/web/public/logo.svg +1 −0
@@ -0,0 +1 @@
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 294 76" width="294" height="76" fill="none" role="img" aria-label="Company Atlas"><title>Company Atlas</title><rect x="0" y="0" width="294" height="76" rx="14" fill="#0a0d12"/><g transform="translate(18 18) scale(0.625)"><rect x="0" y="0" width="64" height="64" rx="16" fill="#e8ebf1"/><rect x="0.75" y="0.75" width="62.5" height="62.5" rx="15.25" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1" opacity="0.55"/><path d="M23.5 32a8.5 20 0 1 0 17 0a8.5 20 0 1 0 -17 0Z" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1.9"/><path d="M14.68 22H49.32M14.68 42H49.32" fill="none" stroke="rgba(10,13,18,0.26)" stroke-width="1.9" stroke-linecap="round"/><path d="M12 32a20 20 0 1 0 40 0a20 20 0 1 0 -40 0Z" fill="none" stroke="#0a0d12" stroke-width="3"/><path d="M12 32H52" fill="none" stroke="#0a0d12" stroke-width="3" stroke-linecap="round"/><path d="M35 30.9L48.6 28.3V35.7L35 33.1Z" fill="#16a34a" opacity="0.55"/><path d="M43.4 32a8.6 8.6 0 1 0 17.2 0a8.6 8.6 0 1 0 -17.2 0Z" fill="none" stroke="#16a34a" stroke-width="1.6" opacity="0.45"/><path d="M47 32a5 5 0 1 0 10 0a5 5 0 1 0 -10 0Z" fill="#16a34a"/></g><path d="M81.92 49.13Q79.07 49.13 76.91 47.8Q74.75 46.46 73.55 43.97Q72.35 41.48 72.35 38.03Q72.35 34.58 73.55 32.08Q74.75 29.57 76.91 28.22Q79.07 26.87 81.92 26.87Q85.79 26.87 87.92 28.82Q90.05 30.77 90.71 34.13L87.98 34.31Q87.53 32.03 86.06 30.71Q84.59 29.39 81.92 29.39Q79.88 29.39 78.33 30.43Q76.79 31.46 75.92 33.39Q75.05 35.33 75.05 38.03Q75.05 40.73 75.92 42.65Q76.79 44.57 78.33 45.59Q79.88 46.61 81.92 46.61Q84.71 46.61 86.24 45.16Q87.77 43.7 88.16 41.21L90.89 41.39Q90.5 43.7 89.36 45.44Q88.22 47.18 86.35 48.16Q84.47 49.13 81.92 49.13ZM100.1 49.01Q97.91 49.01 96.26 47.99Q94.61 46.97 93.71 45.1Q92.81 43.22 92.81 40.7Q92.81 38.15 93.71 36.29Q94.61 34.43 96.26 33.41Q97.91 32.39 100.1 32.39Q102.29 32.39 103.93 33.41Q105.56 34.43 106.46 36.29Q107.36 38.15 107.36 40.7Q107.36 43.22 106.46 45.1Q105.56 46.97 103.93 47.99Q102.29 49.01 100.1 49.01ZM100.1 46.61Q102.29 46.61 103.51 45.04Q104.72 43.46 104.72 40.7Q104.72 37.94 103.51 36.36Q102.29 34.79 100.1 34.79Q97.91 34.79 96.68 36.36Q95.45 37.94 95.45 40.7Q95.45 43.46 96.68 45.04Q97.91 46.61 100.1 46.61ZM110.24 48.65L110.24 32.75L112.55 32.75L112.61 35.54Q113.21 34.07 114.38 33.23Q115.55 32.39 117.08 32.39Q118.88 32.39 120.11 33.26Q121.34 34.13 121.85 35.72Q122.36 34.13 123.56 33.26Q124.76 32.39 126.5 32.39Q129.05 32.39 130.4 33.97Q131.75 35.54 131.75 38.42L131.75 48.65L129.23 48.65L129.23 38.96Q129.23 34.61 125.9 34.61Q124.25 34.61 123.25 35.81Q122.24 37.01 122.24 39.14L122.24 48.65L119.72 48.65L119.72 39.14Q119.72 37.01 118.97 35.81Q118.22 34.61 116.45 34.61Q114.8 34.61 113.78 35.81Q112.76 37.01 112.76 39.14L112.76 48.65L110.24 48.65ZM135.65 53.15L135.65 32.75L137.99 32.75L138.02 35.18Q138.74 33.8 140.01 33.1Q141.29 32.39 142.91 32.39Q145.28 32.39 146.79 33.56Q148.31 34.73 149.04 36.62Q149.78 38.51 149.78 40.7Q149.78 42.89 149.04 44.78Q148.31 46.67 146.79 47.84Q145.28 49.01 142.91 49.01Q141.35 49.01 140.07 48.35Q138.8 47.69 138.17 46.55L138.17 53.15L135.65 53.15ZM142.67 46.61Q144.74 46.61 145.94 45.05Q147.14 43.49 147.14 40.7Q147.14 37.91 145.94 36.35Q144.74 34.79 142.67 34.79Q140.6 34.79 139.39 36.28Q138.17 37.76 138.17 40.7Q138.17 43.61 139.37 45.11Q140.57 46.61 142.67 46.61ZM156.59 49.01Q154.13 49.01 152.67 47.87Q151.22 46.73 151.22 44.69Q151.22 42.65 152.42 41.48Q153.62 40.31 156.23 39.83L161.87 38.78Q161.87 34.79 158.09 34.79Q156.44 34.79 155.48 35.53Q154.52 36.26 154.16 37.64L151.49 37.43Q151.94 35.18 153.66 33.78Q155.39 32.39 158.09 32.39Q161.15 32.39 162.77 34.11Q164.39 35.84 164.39 38.87L164.39 45.44Q164.39 46.43 165.23 46.43L165.86 46.43L165.86 48.65Q165.5 48.71 164.9 48.71Q163.52 48.71 162.81 48.16Q162.11 47.6 161.93 46.34L161.9 46.19Q161.3 47.42 159.83 48.22Q158.36 49.01 156.59 49.01ZM156.83 46.79Q159.23 46.79 160.55 45.44Q161.87 44.09 161.87 42.11L161.87 40.97L156.71 41.93Q155.09 42.23 154.47 42.85Q153.86 43.46 153.86 44.45Q153.86 45.56 154.65 46.18Q155.45 46.79 156.83 46.79ZM168.35 48.65L168.35 32.75L170.66 32.75L170.72 35.6Q171.35 33.95 172.66 33.17Q173.96 32.39 175.61 32.39Q177.44 32.39 178.63 33.2Q179.81 34.01 180.4 35.38Q180.98 36.74 180.98 38.42L180.98 48.65L178.46 48.65L178.46 39.14Q178.46 36.92 177.67 35.77Q176.87 34.61 175.07 34.61Q173.24 34.61 172.05 35.77Q170.87 36.92 170.87 39.14L170.87 48.65L168.35 48.65ZM185.75 53.15L185.75 50.93L187.58 50.93Q188.42 50.93 188.83 50.68Q189.23 50.42 189.44 49.85L190.01 48.35L189.14 48.35L183.32 32.75L186.08 32.75L190.82 46.01L195.35 32.75L198.11 32.75L191.66 50.66Q191.21 51.95 190.31 52.55Q189.41 53.15 187.82 53.15L185.75 53.15Z" fill="#a3a9b8"/><path d="M204.62 48.65L212.3 27.35L217.07 27.35L224.75 48.65L220.67 48.65L218.9 43.61L210.47 43.61L208.7 48.65L204.62 48.65ZM211.61 40.28L217.76 40.28L214.7 31.28L211.61 40.28ZM233.24 48.65Q230.84 48.65 229.71 47.56Q228.59 46.46 228.59 44.06L228.59 35.6L226.07 35.6L226.07 32.63L228.59 32.63L228.59 28.88L232.43 28.88L232.43 32.63L236.66 32.63L236.66 35.6L232.43 35.6L232.43 43.7Q232.43 44.84 232.93 45.26Q233.42 45.68 234.41 45.68L236.66 45.68L236.66 48.65L233.24 48.65ZM240.83 48.65Q239.06 48.65 238.02 47.75Q236.99 46.85 236.99 44.87L236.99 27.35L240.83 27.35L240.83 44.48Q240.83 45.68 242.03 45.68L243.2 45.68L243.2 48.65L240.83 48.65ZM249.95 49.01Q247.43 49.01 245.9 47.87Q244.37 46.73 244.37 44.63Q244.37 42.56 245.66 41.39Q246.95 40.22 249.59 39.71L254.9 38.66Q254.9 35.24 251.81 35.24Q250.43 35.24 249.62 35.89Q248.81 36.53 248.54 37.73L244.64 37.55Q245.12 35.03 246.98 33.65Q248.84 32.27 251.81 32.27Q255.23 32.27 256.99 33.99Q258.74 35.72 258.74 38.93L258.74 44.75Q258.74 45.38 258.96 45.62Q259.19 45.86 259.64 45.86L260.15 45.86L260.15 48.65Q259.64 48.77 258.74 48.77Q257.45 48.77 256.55 48.23Q255.65 47.69 255.41 46.22L255.41 46.19Q254.81 47.45 253.36 48.23Q251.9 49.01 249.95 49.01ZM250.73 46.22Q252.65 46.22 253.77 45.11Q254.9 44 254.9 42.17L254.9 41.27L250.76 42.11Q249.47 42.38 248.92 42.91Q248.36 43.43 248.36 44.27Q248.36 46.22 250.73 46.22ZM268.55 49.01Q264.89 49.01 263.09 47.48Q261.29 45.95 261.11 43.61L265.04 43.43Q265.25 44.69 266.05 45.38Q266.84 46.07 268.58 46.07Q269.93 46.07 270.64 45.64Q271.34 45.2 271.34 44.27Q271.34 43.73 271.08 43.37Q270.83 43.01 270.09 42.74Q269.36 42.47 267.95 42.2Q265.52 41.78 264.14 41.17Q262.76 40.55 262.21 39.62Q261.65 38.69 261.65 37.31Q261.65 35.09 263.38 33.68Q265.1 32.27 268.43 32.27Q270.74 32.27 272.23 33Q273.71 33.74 274.48 34.96Q275.24 36.17 275.36 37.67L271.46 37.85Q271.4 36.71 270.68 35.96Q269.96 35.21 268.37 35.21Q267.02 35.21 266.32 35.75Q265.61 36.29 265.61 37.19Q265.61 38.15 266.29 38.61Q266.96 39.08 268.76 39.38Q271.22 39.74 272.66 40.37Q274.1 41 274.71 41.95Q275.33 42.89 275.33 44.21Q275.33 46.52 273.49 47.77Q271.64 49.01 268.55 49.01Z" fill="#e8ebf1"/></svg>
\ No newline at end of file
added apps/web/scripts/build-icons.mjs +431 −0
@@ -0,0 +1,431 @@
1 +#!/usr/bin/env node
2 +/**
3 + * Company Atlas — brand asset build.
4 + *
5 + * From the single mark geometry (`src/components/brand/geometry.ts`, imported with Node's type stripping) and the Geist
6 + * TTFs shipped by the `geist` package, writes:
7 + *
8 + * src/app/icon.svg favicon (tiny detail level, hard-coded colours) → served at /icon.svg
9 + * public/logo-mark.svg the mark alone, dark plate
10 + * public/logo.svg lockup on its own dark card (works on any background)
11 + * public/logo-light.svg lockup for light backgrounds (dark plate, dark text), transparent
12 + * public/logo-dark.svg lockup for dark backgrounds (light plate, light text), transparent
13 + * public/favicon.ico real multi-size ICO (16 / 32 / 48, PNG-encoded entries)
14 + * public/icon-192.png, icon-512.png, icon-512-maskable.png, apple-touch-icon.png
15 + *
16 + * The wordmark in the standalone SVGs is real vector text: glyph outlines are read from Geist-Regular / Geist-SemiBold
17 + * (TrueType `glyf`), and glyph positions (advances + kerning) are measured by Chromium with the same font files, so the
18 + * exported logo needs no font installed. PNGs are rendered by Playwright at 1× (no sub-pixel hairlines: stroke weights
19 + * come from the geometry's detail levels).
20 + *
21 + * Run from apps/web: node scripts/build-icons.mjs (env PLAYWRIGHT_MODULE overrides the Playwright import path)
22 + */
23 +import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
24 +import { createRequire } from 'node:module';
25 +import path from 'node:path';
26 +import { fileURLToPath } from 'node:url';
27 +
28 +const HERE = path.dirname(fileURLToPath(import.meta.url));
29 +const WEB = path.resolve(HERE, '..');
30 +const PUBLIC = path.join(WEB, 'public');
31 +const APP = path.join(WEB, 'src', 'app');
32 +const PLAYWRIGHT = process.env.PLAYWRIGHT_MODULE ?? '/Users/simon-pierreboucher/Desktop/uqo-eval/node_modules/playwright/index.mjs';
33 +
34 +const { chromium } = await import(PLAYWRIGHT);
35 +const geometry = await import('../src/components/brand/geometry.ts');
36 +const { MARK_DARK, MARK_LIGHT, MARK_VIEWBOX, markShapes, markSvgString } = geometry;
37 +
38 +const require = createRequire(import.meta.url);
39 +// `geist` only exports its entry points, so locate dist/ through `geist/font` (→ dist/font.js) and walk to the TTFs.
40 +const GEIST_DIR = path.join(path.dirname(require.resolve('geist/font')), 'fonts', 'geist-sans');
41 +const FONT_REGULAR = path.join(GEIST_DIR, 'Geist-Regular.ttf');
42 +const FONT_SEMIBOLD = path.join(GEIST_DIR, 'Geist-SemiBold.ttf');
43 +
44 +// Wordmark palette (mirrors --ink / --ink-2 of each theme in globals.css).
45 +const TEXT_ON_LIGHT = { company: '#4a5160', atlas: '#0f1419' };
46 +const TEXT_ON_DARK = { company: '#a3a9b8', atlas: '#e8ebf1' };
47 +
48 +/* ----------------------------------------------------------------------------------------------------------------- */
49 +/* Minimal TrueType reader: cmap (format 4/12) → glyph id, glyf outlines (simple + composite) → SVG path data. */
50 +/* ----------------------------------------------------------------------------------------------------------------- */
51 +function parseTtf(file) {
52 + const buf = readFileSync(file);
53 + const dv = new DataView(buf.buffer, buf.byteOffset, buf.byteLength);
54 + const tables = {};
55 + const n = dv.getUint16(4);
56 + for (let i = 0; i < n; i++) {
57 + const o = 12 + i * 16;
58 + tables[buf.toString('ascii', o, o + 4)] = { offset: dv.getUint32(o + 8), length: dv.getUint32(o + 12) };
59 + }
60 + for (const t of ['head', 'maxp', 'loca', 'glyf', 'cmap', 'hhea', 'hmtx']) if (!tables[t]) throw new Error(`${path.basename(file)}: missing ${t} table (not a TrueType-flavoured font)`);
61 + const unitsPerEm = dv.getUint16(tables.head.offset + 18);
62 + const longLoca = dv.getInt16(tables.head.offset + 50) === 1;
63 + const numGlyphs = dv.getUint16(tables.maxp.offset + 4);
64 + const loca = new Array(numGlyphs + 1);
65 + for (let i = 0; i <= numGlyphs; i++) loca[i] = longLoca ? dv.getUint32(tables.loca.offset + i * 4) : dv.getUint16(tables.loca.offset + i * 2) * 2;
66 + const os2 = tables['OS/2'];
67 + const capHeight = os2 && dv.getUint16(os2.offset) >= 2 ? dv.getInt16(os2.offset + 88) : Math.round(unitsPerEm * 0.7);
68 + const ascender = dv.getInt16(tables.hhea.offset + 4);
69 + const descender = dv.getInt16(tables.hhea.offset + 6);
70 +
71 + // cmap: prefer a Unicode subtable (platform 3/1 or 0/x), formats 4 and 12
72 + const cmap = tables.cmap.offset;
73 + const subtables = dv.getUint16(cmap + 2);
74 + let best = null;
75 + for (let i = 0; i < subtables; i++) {
76 + const pid = dv.getUint16(cmap + 4 + i * 8);
77 + const eid = dv.getUint16(cmap + 6 + i * 8);
78 + const off = dv.getUint32(cmap + 8 + i * 8);
79 + const fmt = dv.getUint16(cmap + off);
80 + const score = fmt === 12 ? 3 : fmt === 4 ? 2 : 0;
81 + if ((pid === 3 && (eid === 1 || eid === 10)) || pid === 0) if (score && (!best || score > best.score)) best = { off: cmap + off, fmt, score };
82 + }
83 + if (!best) throw new Error('no usable cmap subtable');
84 + function glyphId(cp) {
85 + const o = best.off;
86 + if (best.fmt === 12) {
87 + const groups = dv.getUint32(o + 12);
88 + for (let g = 0; g < groups; g++) {
89 + const s = dv.getUint32(o + 16 + g * 12);
90 + const e = dv.getUint32(o + 20 + g * 12);
91 + if (cp >= s && cp <= e) return dv.getUint32(o + 24 + g * 12) + (cp - s);
92 + }
93 + return 0;
94 + }
95 + const segX2 = dv.getUint16(o + 6);
96 + const seg = segX2 / 2;
97 + const endP = o + 14;
98 + const startP = endP + segX2 + 2;
99 + const deltaP = startP + segX2;
100 + const rangeP = deltaP + segX2;
101 + for (let i = 0; i < seg; i++) {
102 + const end = dv.getUint16(endP + i * 2);
103 + if (cp > end) continue;
104 + const start = dv.getUint16(startP + i * 2);
105 + if (cp < start) return 0;
106 + const delta = dv.getInt16(deltaP + i * 2);
107 + const rangeOff = dv.getUint16(rangeP + i * 2);
108 + if (rangeOff === 0) return (cp + delta) & 0xffff;
109 + const addr = rangeP + i * 2 + rangeOff + (cp - start) * 2;
110 + const gid = dv.getUint16(addr);
111 + return gid === 0 ? 0 : (gid + delta) & 0xffff;
112 + }
113 + return 0;
114 + }
115 +
116 + const numHMetrics = dv.getUint16(tables.hhea.offset + 34);
117 + function advance(gid) {
118 + const i = Math.min(gid, numHMetrics - 1);
119 + return dv.getUint16(tables.hmtx.offset + i * 4);
120 + }
121 +
122 + /** Contours of one glyph as arrays of {x, y, on} in font units (y up); composites are flattened. */
123 + function contours(gid, depth = 0) {
124 + const start = tables.glyf.offset + loca[gid];
125 + const end = tables.glyf.offset + loca[gid + 1];
126 + if (end <= start) return [];
127 + const nc = dv.getInt16(start);
128 + if (nc >= 0) {
129 + let p = start + 10;
130 + const endPts = [];
131 + for (let i = 0; i < nc; i++) endPts.push(dv.getUint16(p + i * 2));
132 + p += nc * 2;
133 + const nPts = nc ? endPts[nc - 1] + 1 : 0;
134 + const instrLen = dv.getUint16(p);
135 + p += 2 + instrLen;
136 + const flags = new Array(nPts);
137 + for (let i = 0; i < nPts; ) {
138 + const f = dv.getUint8(p++);
139 + flags[i++] = f;
140 + if (f & 8) {
141 + let r = dv.getUint8(p++);
142 + while (r-- > 0 && i < nPts) flags[i++] = f;
143 + }
144 + }
145 + const xs = new Array(nPts);
146 + let v = 0;
147 + for (let i = 0; i < nPts; i++) {
148 + const f = flags[i];
149 + if (f & 2) {
150 + const d = dv.getUint8(p++);
151 + v += f & 16 ? d : -d;
152 + } else if (!(f & 16)) {
153 + v += dv.getInt16(p);
154 + p += 2;
155 + }
156 + xs[i] = v;
157 + }
158 + const ys = new Array(nPts);
159 + v = 0;
160 + for (let i = 0; i < nPts; i++) {
161 + const f = flags[i];
162 + if (f & 4) {
163 + const d = dv.getUint8(p++);
164 + v += f & 32 ? d : -d;
165 + } else if (!(f & 32)) {
166 + v += dv.getInt16(p);
167 + p += 2;
168 + }
169 + ys[i] = v;
170 + }
171 + const out = [];
172 + let s = 0;
173 + for (let c = 0; c < nc; c++) {
174 + const pts = [];
175 + for (let i = s; i <= endPts[c]; i++) pts.push({ x: xs[i], y: ys[i], on: (flags[i] & 1) === 1 });
176 + out.push(pts);
177 + s = endPts[c] + 1;
178 + }
179 + return out;
180 + }
181 + // composite glyph
182 + if (depth > 4) return [];
183 + const out = [];
184 + let p = start + 10;
185 + for (;;) {
186 + const flags = dv.getUint16(p);
187 + const cg = dv.getUint16(p + 2);
188 + p += 4;
189 + let dx;
190 + let dy;
191 + if (flags & 1) {
192 + dx = dv.getInt16(p);
193 + dy = dv.getInt16(p + 2);
194 + p += 4;
195 + } else {
196 + dx = dv.getInt8(p);
197 + dy = dv.getInt8(p + 1);
198 + p += 2;
199 + }
200 + let a = 1;
201 + let b = 0;
202 + let c = 0;
203 + let d = 1;
204 + const f2 = (o) => dv.getInt16(o) / 16384;
205 + if (flags & 8) {
206 + a = d = f2(p);
207 + p += 2;
208 + } else if (flags & 0x40) {
209 + a = f2(p);
210 + d = f2(p + 2);
211 + p += 4;
212 + } else if (flags & 0x80) {
213 + a = f2(p);
214 + b = f2(p + 2);
215 + c = f2(p + 4);
216 + d = f2(p + 6);
217 + p += 8;
218 + }
219 + if (!(flags & 2)) {
220 + dx = 0;
221 + dy = 0;
222 + } // point-matching placement is not needed for Latin letters
223 + for (const ct of contours(cg, depth + 1)) out.push(ct.map((q) => ({ x: a * q.x + c * q.y + dx, y: b * q.x + d * q.y + dy, on: q.on })));
224 + if (!(flags & 0x20)) break;
225 + }
226 + return out;
227 + }
228 +
229 + /** SVG path data for a glyph, transformed: X = ox + x·s, Y = oy − y·s (font y-up → SVG y-down). */
230 + function glyphPath(gid, ox, oy, s) {
231 + const f = (v) => (Math.round(v * 100) / 100).toString();
232 + const P = (q) => `${f(ox + q.x * s)} ${f(oy - q.y * s)}`;
233 + const mid = (a, b) => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 });
234 + let d = '';
235 + for (const pts of contours(gid)) {
236 + const n = pts.length;
237 + if (!n) continue;
238 + let si = pts.findIndex((q) => q.on);
239 + let startPt;
240 + if (si === -1) {
241 + startPt = mid(pts[0], pts[1 % n]);
242 + si = 0;
243 + } else startPt = pts[si];
244 + d += `M${P(startPt)}`;
245 + let prevOff = null;
246 + for (let k = 1; k <= n; k++) {
247 + const cur = pts[(si + k) % n];
248 + const isStart = k === n;
249 + const target = isStart ? startPt : cur;
250 + if (isStart && pts[si].on === false) break;
251 + if (cur.on || isStart) {
252 + d += prevOff ? `Q${P(prevOff)} ${P(target)}` : `L${P(target)}`;
253 + prevOff = null;
254 + } else if (prevOff) {
255 + d += `Q${P(prevOff)} ${P(mid(prevOff, cur))}`;
256 + prevOff = cur;
257 + } else prevOff = cur;
258 + }
259 + if (prevOff) d += `Q${P(prevOff)} ${P(startPt)}`;
260 + d += 'Z';
261 + }
262 + return d;
263 + }
264 +
265 + return { unitsPerEm, capHeight, ascender, descender, glyphId, advance, glyphPath, file };
266 +}
267 +
268 +/* ----------------------------------------------------------------------------------------------------------------- */
269 +/* Wordmark: glyph positions measured by Chromium (kerning applied) at font-size = unitsPerEm → font units. */
270 +/* ----------------------------------------------------------------------------------------------------------------- */
271 +async function measureRuns(page, runs) {
272 + const faces = runs.map((r, i) => ({ name: `CA${i}`, data: readFileSync(r.font.file).toString('base64'), weight: r.weight }));
273 + const css = faces.map((f) => `@font-face{font-family:"${f.name}";src:url(data:font/ttf;base64,${f.data}) format("truetype");font-weight:${f.weight};}`).join('\n');
274 + await page.setContent(`<html><head><style>${css}</style></head><body><canvas id="c"></canvas>${faces.map((f) => `<span style="font-family:'${f.name}'">x</span>`).join('')}</body></html>`);
275 + return page.evaluate(
276 + async ({ runs, faces }) => {
277 + await Promise.all(faces.map((f) => document.fonts.load(`${f.weight} 100px "${f.name}"`)));
278 + const ctx = document.getElementById('c').getContext('2d');
279 + return runs.map((r, i) => {
280 + ctx.font = `${faces[i].weight} ${r.upem}px "${faces[i].name}"`;
281 + ctx.fontKerning = 'normal';
282 + ctx.letterSpacing = `${r.tracking * r.upem}px`;
283 + const xs = [];
284 + for (let k = 0; k <= r.text.length; k++) xs.push(ctx.measureText(r.text.slice(0, k)).width);
285 + return xs; // xs[k] = pen position before glyph k (font units); xs[len] = total advance
286 + });
287 + },
288 + { runs: runs.map((r) => ({ text: r.text, upem: r.font.unitsPerEm, tracking: r.tracking })), faces: faces.map((f) => ({ name: f.name, weight: f.weight })) },
289 + );
290 +}
291 +
292 +/** Returns { paths: [{d, fill}], width } for "Company Atlas" at the given font size, pen starting at (x, baselineY). */
293 +function wordmarkPaths({ regular, semibold, measured, x, baselineY, fontSize, colors }) {
294 + const runs = [
295 + { text: 'Company ', font: regular, fill: colors.company, xs: measured[0] },
296 + { text: 'Atlas', font: semibold, fill: colors.atlas, xs: measured[1] },
297 + ];
298 + const paths = [];
299 + let pen = x;
300 + for (const r of runs) {
301 + const s = fontSize / r.font.unitsPerEm;
302 + let d = '';
303 + for (let k = 0; k < r.text.length; k++) {
304 + const ch = r.text[k];
305 + if (ch === ' ') continue;
306 + const gid = r.font.glyphId(ch.codePointAt(0));
307 + d += r.font.glyphPath(gid, pen + r.xs[k] * s, baselineY, s);
308 + }
309 + paths.push({ d, fill: r.fill });
310 + pen += r.xs[r.text.length] * s;
311 + }
312 + return { paths, width: pen - x };
313 +}
314 +
315 +/* ----------------------------------------------------------------------------------------------------------------- */
316 +/* SVG documents */
317 +/* ----------------------------------------------------------------------------------------------------------------- */
318 +const attrs = (o) =>
319 + Object.entries(o)
320 + .map(([k, v]) => `${k}="${v}"`)
321 + .join(' ');
322 +const shapesToSvg = (shapes) => shapes.map((s) => `<${s.tag} ${attrs(s.attrs)}/>`).join('');
323 +
324 +/** Lockup: mark (size M) + wordmark; `card` wraps everything in a rounded dark card with padding. */
325 +function lockupSvg({ markColors, textColors, fonts, measured, card = null }) {
326 + const M = 40; // mark size
327 + const GAP = 13;
328 + const F = 30; // font size → cap height ≈ 21 px, optically balanced with the 40 px plate
329 + const cap = (fonts.regular.capHeight / fonts.regular.unitsPerEm) * F;
330 + const baseline = M / 2 + cap / 2;
331 + const pad = card ? 18 : 0;
332 + const { paths, width } = wordmarkPaths({ regular: fonts.regular, semibold: fonts.semibold, measured, x: pad + M + GAP, baselineY: pad + baseline, fontSize: F, colors: textColors });
333 + const W = Math.ceil(pad + M + GAP + width + pad);
334 + const H = M + pad * 2;
335 + const mark = markShapes(markColors, { level: 'full' })
336 + .map((s) => ({ ...s }))
337 + .map((s) => `<${s.tag} ${attrs(s.attrs)}/>`)
338 + .join('');
339 + const scale = M / MARK_VIEWBOX;
340 + return [
341 + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}" fill="none" role="img" aria-label="Company Atlas">`,
342 + `<title>Company Atlas</title>`,
343 + card ? `<rect x="0" y="0" width="${W}" height="${H}" rx="${card.radius}" fill="${card.fill}"/>` : '',
344 + `<g transform="translate(${pad} ${pad}) scale(${scale})">${mark}</g>`,
345 + ...paths.map((p) => `<path d="${p.d}" fill="${p.fill}"/>`),
346 + `</svg>`,
347 + ].join('');
348 +}
349 +
350 +/* ----------------------------------------------------------------------------------------------------------------- */
351 +/* Rasters + ICO */
352 +/* ----------------------------------------------------------------------------------------------------------------- */
353 +async function renderPng(browser, svg, px) {
354 + const page = await browser.newPage({ viewport: { width: px, height: px }, deviceScaleFactor: 1 });
355 + await page.setContent(`<html><body style="margin:0;background:transparent"><img src="data:image/svg+xml;utf8,${encodeURIComponent(svg)}" width="${px}" height="${px}" style="display:block"></body></html>`);
356 + await page.waitForFunction(() => document.images[0]?.complete);
357 + const png = await page.screenshot({ omitBackground: true, type: 'png' });
358 + await page.close();
359 + return png;
360 +}
361 +
362 +/** ICO container by hand: 6-byte header, 16-byte directory entries, then PNG-encoded images (valid since Vista). */
363 +function packIco(entries) {
364 + const header = Buffer.alloc(6);
365 + header.writeUInt16LE(0, 0); // reserved
366 + header.writeUInt16LE(1, 2); // type: icon
367 + header.writeUInt16LE(entries.length, 4);
368 + const dir = Buffer.alloc(16 * entries.length);
369 + let offset = header.length + dir.length;
370 + entries.forEach((e, i) => {
371 + const o = i * 16;
372 + dir.writeUInt8(e.size >= 256 ? 0 : e.size, o); // width (0 = 256)
373 + dir.writeUInt8(e.size >= 256 ? 0 : e.size, o + 1); // height
374 + dir.writeUInt8(0, o + 2); // colour palette
375 + dir.writeUInt8(0, o + 3); // reserved
376 + dir.writeUInt16LE(1, o + 4); // colour planes
377 + dir.writeUInt16LE(32, o + 6); // bits per pixel
378 + dir.writeUInt32LE(e.png.length, o + 8);
379 + dir.writeUInt32LE(offset, o + 12);
380 + offset += e.png.length;
381 + });
382 + return Buffer.concat([header, dir, ...entries.map((e) => e.png)]);
383 +}
384 +
385 +/* ----------------------------------------------------------------------------------------------------------------- */
386 +async function main() {
387 + mkdirSync(PUBLIC, { recursive: true });
388 + const fonts = { regular: parseTtf(FONT_REGULAR), semibold: parseTtf(FONT_SEMIBOLD) };
389 + const browser = await chromium.launch();
390 + try {
391 + const page = await browser.newPage();
392 + const measured = await measureRuns(page, [
393 + { text: 'Company ', font: fonts.regular, weight: 400, tracking: -0.02 },
394 + { text: 'Atlas', font: fonts.semibold, weight: 600, tracking: -0.02 },
395 + ]);
396 + await page.close();
397 +
398 + // --- SVGs -------------------------------------------------------------------------------------------------------
399 + const written = [];
400 + const put = (file, data) => {
401 + writeFileSync(file, data);
402 + written.push(`${path.relative(WEB, file)} (${(data.length / 1024).toFixed(1)} KB)`);
403 + };
404 + put(path.join(APP, 'icon.svg'), markSvgString(MARK_DARK, { level: 'tiny' }));
405 + put(path.join(PUBLIC, 'logo-mark.svg'), markSvgString(MARK_DARK, { level: 'full' }));
406 + put(path.join(PUBLIC, 'logo-light.svg'), lockupSvg({ markColors: MARK_DARK, textColors: TEXT_ON_LIGHT, fonts, measured }));
407 + put(path.join(PUBLIC, 'logo-dark.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured }));
408 + put(path.join(PUBLIC, 'logo.svg'), lockupSvg({ markColors: MARK_LIGHT, textColors: TEXT_ON_DARK, fonts, measured, card: { fill: '#0a0d12', radius: 14 } }));
409 +
410 + // --- PNGs -------------------------------------------------------------------------------------------------------
411 + const fav = [];
412 + for (const [px, level] of [
413 + [16, 'tiny'],
414 + [32, 'small'],
415 + [48, 'full'],
416 + ]) {
417 + fav.push({ size: px, png: await renderPng(browser, markSvgString(MARK_DARK, { level }), px) });
418 + }
419 + put(path.join(PUBLIC, 'favicon.ico'), packIco(fav));
420 + put(path.join(PUBLIC, 'icon-192.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 192));
421 + put(path.join(PUBLIC, 'icon-512.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full' }), 512));
422 + // maskable / Apple: full-bleed plate (the OS applies its own corner mask), globe inset stays inside the safe zone
423 + put(path.join(PUBLIC, 'icon-512-maskable.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 512));
424 + put(path.join(PUBLIC, 'apple-touch-icon.png'), await renderPng(browser, markSvgString(MARK_DARK, { level: 'full', radius: 0 }), 180));
425 + console.log(written.map((w) => ` wrote ${w}`).join('\n'));
426 + } finally {
427 + await browser.close();
428 + }
429 +}
430 +
431 +await main();
modified apps/web/src/app/about/page.tsx +27 −3
@@ -2,7 +2,7 @@ import type { Metadata } from 'next';
2 2 import Link from 'next/link';
3 3 import { ConfidenceBadge } from '@/components/ui/badges';
4 4 import { Container, PageHeader } from '@/components/ui/section';
5 −import { ALT_TAGLINE, CONTACT_EMAIL, DESCRIPTION, routes } from '@/lib/site';
5 +import { ALT_TAGLINE, AUTHOR_NAME, CONTACT_EMAIL, DESCRIPTION, HOST_NAME, HOST_URL, routes } from '@/lib/site';
6 6
7 7 export const metadata: Metadata = { title: 'About', description: `${DESCRIPTION} Positioning, methodology summary and careful-language policy.` };
8 8
@@ -58,9 +58,33 @@ export default function AboutPage() {
58 58 </p>
59 59 <h2>Privacy</h2>
60 60 <p>No consumer profiling. People appear only in their public professional context on the company’s own pages (name, title, first/last seen). Nothing behind a login is ever collected.</p>
61 − <h2>Who builds it</h2>
61 + <h2 id="who-builds-it">Who builds Company Atlas</h2>
62 62 <p>
63 − Company Atlas is designed and operated by Simon-Pierre Boucher and hosted on the MacLustr cluster. Contact: <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>. Public API and exports: <Link href={routes.api()}>/api</Link>.
63 + Company Atlas is designed, built and operated by <strong className="text-ink">{AUTHOR_NAME}</strong>. It is an independent project: no paid placement, no consumer profiling.
64 + </p>
65 + <dl className="mt-4 grid gap-x-8 gap-y-3 border-y border-rule py-4 sm:grid-cols-[max-content_1fr]" data-builder>
66 + <dt className="eyebrow sm:pt-1">Built by</dt>
67 + <dd className="text-ink">{AUTHOR_NAME}</dd>
68 + <dt className="eyebrow sm:pt-1">Contact</dt>
69 + <dd>
70 + <a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a>
71 + <span className="text-ink-3"> — questions, corrections, exclusion requests, API access.</span>
72 + </dd>
73 + <dt className="eyebrow sm:pt-1">Hosting</dt>
74 + <dd>
75 + Served from the{' '}
76 + <a href={HOST_URL} target="_blank" rel="noopener noreferrer">
77 + {HOST_NAME} cluster
78 + </a>{' '}
79 + (<span className="mono text-[13px]">{HOST_URL.replace(/^https?:\/\//, '')}</span>), a self-hosted cluster of Apple-silicon machines operated by the same author; the sensors, the database and this site all run there.
80 + </dd>
81 + <dt className="eyebrow sm:pt-1">Data</dt>
82 + <dd>
83 + Public API and exports: <Link href={routes.api()}>/api</Link>. Crawler identity and opt-out: <Link href={routes.bot()}>/bot</Link>.
84 + </dd>
85 + </dl>
86 + <p className="mt-4 text-sm text-ink-3">
87 + The Company Atlas mark — an atlas plate carrying a globe grid with one live observation on its horizon — is available as SVG for press and links: <a href="/logo.svg">logo.svg</a> (dark card), <a href="/logo-light.svg">logo-light.svg</a>, <a href="/logo-dark.svg">logo-dark.svg</a>, <a href="/logo-mark.svg">logo-mark.svg</a>.
64 88 </p>
65 89 </div>
66 90 </Container>
deleted apps/web/src/app/apple-icon.tsx +0 −17
@@ -1,17 +0,0 @@
1 −import { ImageResponse } from 'next/og';
2 −import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 −
4 −/** 180×180 PNG: the atlas plate on the dark canvas (iOS applies its own corner mask). */
5 −export const size = { width: 180, height: 180 };
6 −export const contentType = 'image/png';
7 −
8 −export default function AppleIcon() {
9 − return new ImageResponse(
10 − (
11 − <div style={{ width: 180, height: 180, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
12 − <MarkImg px={180} colors={{ ...MARK_DARK, plate: '#11151c' }} radius={0} />
13 − </div>
14 − ),
15 − { ...size },
16 − );
17 −}
added apps/web/src/app/company/[slug]/opengraph-image.tsx +15 −0
@@ -0,0 +1,15 @@
1 +import { api, safe } from '@/lib/api';
2 +import { OG_CONTENT_TYPE, OG_SIZE, renderCompanyOg, renderSiteOg } from '@/lib/og';
3 +import { SITE_NAME } from '@/lib/site';
4 +
5 +/** Per-company share image: logo (fetched server-side, monogram fallback), name, domain · country · industry, key facts, metric tiles. */
6 +export const alt = `Company profile on ${SITE_NAME}`;
7 +export const size = OG_SIZE;
8 +export const contentType = OG_CONTENT_TYPE;
9 +
10 +export default async function CompanyOpenGraphImage({ params }: { params: Promise<{ slug: string }> }) {
11 + const { slug } = await params;
12 + const c = await safe(api.company(slug));
13 + if (!c) return renderSiteOg(await safe(api.stats()));
14 + return renderCompanyOg(c);
15 +}
added apps/web/src/app/company/[slug]/twitter-image.tsx +14 −0
@@ -0,0 +1,14 @@
1 +import { api, safe } from '@/lib/api';
2 +import { OG_CONTENT_TYPE, OG_SIZE, renderCompanyOg, renderSiteOg } from '@/lib/og';
3 +import { SITE_NAME } from '@/lib/site';
4 +
5 +export const alt = `Company profile on ${SITE_NAME}`;
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +
9 +export default async function CompanyTwitterImage({ params }: { params: Promise<{ slug: string }> }) {
10 + const { slug } = await params;
11 + const c = await safe(api.company(slug));
12 + if (!c) return renderSiteOg(await safe(api.stats()));
13 + return renderCompanyOg(c);
14 +}
added apps/web/src/app/country/[code]/opengraph-image.tsx +31 −0
@@ -0,0 +1,31 @@
1 +import { api, safe } from '@/lib/api';
2 +import { fmtInt } from '@/lib/format';
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderSiteOg, renderTopicOg } from '@/lib/og';
4 +import { SITE_NAME } from '@/lib/site';
5 +
6 +/** Country share image: name, region, monitored companies, activity counters. */
7 +export const alt = `Country on ${SITE_NAME}`;
8 +export const size = OG_SIZE;
9 +export const contentType = OG_CONTENT_TYPE;
10 +
11 +export default async function CountryOpenGraphImage({ params }: { params: Promise<{ code: string }> }) {
12 + const { code } = await params;
13 + const d = await safe(api.country(code.toUpperCase()));
14 + if (!d) return renderSiteOg(await safe(api.stats()));
15 + const list = Array.isArray(d.companies) ? d.companies : [];
16 + const count = Array.isArray(d.companies) ? d.companies.length : d.companies;
17 + const industries = (d.industries ?? []).length;
18 + return renderTopicOg({
19 + eyebrow: d.region ? `Country · ${d.region}` : 'Country',
20 + title: d.name,
21 + subtitle: `Companies headquartered in ${d.name}, observed continuously on their public web surfaces.`,
22 + names: list.map((c) => c.display_name),
23 + counters: [
24 + ['Companies', fmtInt(count)],
25 + ['Events 7d', fmtInt(d.events_7d)],
26 + ['Events 30d', fmtInt(d.events_30d)],
27 + ['Industries', fmtInt(industries)],
28 + ],
29 + path: `/country/${d.code.toLowerCase()}`,
30 + });
31 +}
modified apps/web/src/app/globals.css +2 −2
@@ -74,7 +74,7 @@
74 74 /* brand plate (LogoMark) inverted per theme */
75 75 --brand-plate: #0f1419;
76 76 --brand-ink: #f7f7f4;
77 − --brand-grid: rgba(247, 247, 244, 0.22);
77 + --brand-grid: rgba(247, 247, 244, 0.3);
78 78 --brand-accent: #3fd07a;
79 79
80 80 --radius: 4px;
@@ -165,7 +165,7 @@
165 165
166 166 --brand-plate: #e8ebf1;
167 167 --brand-ink: #0a0d12;
168 − --brand-grid: rgba(10, 13, 18, 0.2);
168 + --brand-grid: rgba(10, 13, 18, 0.26);
169 169 --brand-accent: #16a34a;
170 170 }
171 171
deleted apps/web/src/app/icon-512/route.tsx +0 −16
@@ -1,16 +0,0 @@
1 −import { ImageResponse } from 'next/og';
2 −import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 −
4 −/** 512×512 maskable PWA icon (the mark inset in a solid plate so masks never clip the globe). */
5 −export const dynamic = 'force-static';
6 −
7 −export function GET() {
8 − return new ImageResponse(
9 − (
10 − <div style={{ width: 512, height: 512, display: 'flex', alignItems: 'center', justifyContent: 'center', background: MARK_DARK.plate }}>
11 − <MarkImg px={400} colors={{ ...MARK_DARK, plate: MARK_DARK.plate }} radius={0} />
12 − </div>
13 − ),
14 − { width: 512, height: 512 },
15 − );
16 −}
modified apps/web/src/app/icon.svg +1 −1
@@ -1 +1 @@
1 −<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none"><rect x="1" y="1" width="30" height="30" rx="7" fill="#0a0d12"/><circle cx="16" cy="16" r="10" stroke="#e8ebf1" stroke-width="1.6" fill="none"/><ellipse cx="16" cy="16" rx="4.2" ry="10" stroke="rgba(232,235,241,0.34)" stroke-width="1.1" fill="none"/><line x1="6" x2="26" y1="16" y2="16" stroke="#e8ebf1" stroke-width="1.6" stroke-linecap="round"/><circle cx="26" cy="16" r="2.6" fill="#3fd07a"/></svg>
1 +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="none"><rect x="0" y="0" width="64" height="64" rx="16" fill="#0f1419"/><path d="M23.5 32a8.5 20 0 1 0 17 0a8.5 20 0 1 0 -17 0Z" fill="none" stroke="rgba(247,247,244,0.30)" stroke-width="3.6"/><path d="M12 32a20 20 0 1 0 40 0a20 20 0 1 0 -40 0Z" fill="none" stroke="#f7f7f4" stroke-width="5.2"/><path d="M12 32H52" fill="none" stroke="#f7f7f4" stroke-width="5.2" stroke-linecap="round"/><path d="M35 30.9L48.6 28.3V35.7L35 33.1Z" fill="#3fd07a" opacity="0.55"/><path d="M45.4 32a6.6 6.6 0 1 0 13.2 0a6.6 6.6 0 1 0 -13.2 0Z" fill="#3fd07a"/></svg>
\ No newline at end of file
added apps/web/src/app/industry/[slug]/opengraph-image.tsx +30 −0
@@ -0,0 +1,30 @@
1 +import { api, safe } from '@/lib/api';
2 +import { fmtInt } from '@/lib/format';
3 +import { OG_CONTENT_TYPE, OG_SIZE, renderSiteOg, renderTopicOg } from '@/lib/og';
4 +import { SITE_NAME } from '@/lib/site';
5 +
6 +/** Industry share image: name, description, monitored companies, activity counters. */
7 +export const alt = `Industry on ${SITE_NAME}`;
8 +export const size = OG_SIZE;
9 +export const contentType = OG_CONTENT_TYPE;
10 +
11 +export default async function IndustryOpenGraphImage({ params }: { params: Promise<{ slug: string }> }) {
12 + const { slug } = await params;
13 + const d = await safe(api.industry(slug));
14 + if (!d) return renderSiteOg(await safe(api.stats()));
15 + const list = Array.isArray(d.companies) ? d.companies : [];
16 + const count = Array.isArray(d.companies) ? d.companies.length : d.companies;
17 + return renderTopicOg({
18 + eyebrow: 'Industry',
19 + title: d.name,
20 + subtitle: d.description,
21 + names: list.map((c) => c.display_name),
22 + counters: [
23 + ['Companies', fmtInt(count)],
24 + ['Events 7d', fmtInt(d.events_7d)],
25 + ['Events 30d', fmtInt(d.events_30d)],
26 + ['Open jobs', fmtInt(d.hiring?.open)],
27 + ],
28 + path: `/industry/${d.slug}`,
29 + });
30 +}
modified apps/web/src/app/layout.tsx +8 −1
@@ -21,7 +21,14 @@ export const metadata: Metadata = {
21 21 alternates: { canonical: '/' },
22 22 openGraph: { type: 'website', siteName: SITE_NAME, url: SITE_URL, title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
23 23 twitter: { card: 'summary_large_image', title: `${SITE_NAME} — ${TAGLINE}`, description: DESCRIPTION },
24 − icons: { icon: [{ url: '/icon.svg', type: 'image/svg+xml' }], apple: [{ url: '/apple-icon', sizes: '180x180', type: 'image/png' }] },
24 + // Favicon set built by `pnpm icons` (scripts/build-icons.mjs): multi-size ICO for URL bars, SVG for modern browsers, PNG for iOS.
25 + icons: {
26 + icon: [
27 + { url: '/favicon.ico', sizes: 'any', type: 'image/x-icon' },
28 + { url: '/icon.svg', type: 'image/svg+xml' },
29 + ],
30 + apple: [{ url: '/apple-touch-icon.png', sizes: '180x180', type: 'image/png' }],
31 + },
25 32 };
26 33
27 34 export const viewport: Viewport = {
modified apps/web/src/app/manifest.ts +4 −2
@@ -16,10 +16,12 @@ export default function manifest(): MetadataRoute.Manifest {
16 16 lang: 'en',
17 17 dir: 'ltr',
18 18 categories: ['business', 'reference', 'news'],
19 + // PNGs generated by scripts/build-icons.mjs from the mark geometry (`pnpm icons`).
19 20 icons: [
21 + { src: '/icon-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' },
22 + { src: '/icon-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' },
23 + { src: '/icon-512-maskable.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
20 24 { src: '/icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' },
21 − { src: '/apple-icon', sizes: '180x180', type: 'image/png', purpose: 'any' },
22 − { src: '/icon-512', sizes: '512x512', type: 'image/png', purpose: 'maskable' },
23 25 ],
24 26 shortcuts: [
25 27 { name: 'Live feed', short_name: 'Live', description: 'Structured corporate events as they are detected', url: '/live' },
modified apps/web/src/app/opengraph-image.tsx +6 −55
@@ -1,60 +1,11 @@
1 −import { ImageResponse } from 'next/og';
2 −import { MARK_DARK, MarkImg } from '@/components/brand/mark';
3 1 import { api, safe } from '@/lib/api';
4 −import { fmtInt } from '@/lib/format';
5 −import { SITE_NAME, TAGLINE } from '@/lib/site';
2 +import { OG_ALT, OG_CONTENT_TYPE, OG_SIZE, renderSiteOg } from '@/lib/og';
6 3
7 −export const runtime = 'nodejs';
8 −export const alt = `${SITE_NAME} — ${TAGLINE}`;
9 −export const size = { width: 1200, height: 630 };
10 −export const contentType = 'image/png';
4 +/** Root Open Graph image: brand lockup, headline, live counters from /stats (brand-only when the API is down). */
5 +export const alt = OG_ALT;
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
11 8
12 −/** Root Open Graph image: mark + name + tagline + live counters from /stats (brand-only when the API is down). */
13 9 export default async function OpenGraphImage() {
14 − const stats = await safe(api.stats());
15 − const counters: [string, string][] = stats
16 − ? [
17 − ['Companies', fmtInt(stats.companies)],
18 − ['Sensors', fmtInt(stats.sensors)],
19 − ['Observations', fmtInt(stats.observations)],
20 − ['Structured events', fmtInt(stats.events)],
21 − ]
22 − : [];
23 − return new ImageResponse(
24 − (
25 − <div style={{ width: 1200, height: 630, display: 'flex', flexDirection: 'column', justifyContent: 'space-between', padding: 64, background: '#0a0d12', color: '#e8ebf1', fontFamily: 'Helvetica, Arial, sans-serif' }}>
26 − <div style={{ position: 'absolute', inset: 0, backgroundImage: 'linear-gradient(to right, rgba(190,200,225,0.08) 1px, transparent 1px), linear-gradient(to bottom, rgba(190,200,225,0.08) 1px, transparent 1px)', backgroundSize: '48px 48px' }} />
27 − <div style={{ display: 'flex', alignItems: 'center', gap: 20 }}>
28 − <MarkImg px={64} colors={MARK_DARK} />
29 − <div style={{ display: 'flex', flexDirection: 'column' }}>
30 − <div style={{ display: 'flex', gap: 8, fontSize: 30, letterSpacing: -0.5 }}>
31 − <span style={{ color: '#a3a9b8' }}>Company</span>
32 − <span style={{ fontWeight: 700 }}>Atlas</span>
33 − </div>
34 − <div style={{ fontSize: 15, color: '#6f7688', letterSpacing: 2, textTransform: 'uppercase' }}>Continuous corporate observation network</div>
35 − </div>
36 − <div style={{ marginLeft: 'auto', display: 'flex', alignItems: 'center', gap: 10, color: '#3fd07a', fontSize: 18 }}>
37 − <div style={{ width: 12, height: 12, borderRadius: 12, background: '#3fd07a' }} /> LIVE
38 − </div>
39 − </div>
40 − <div style={{ display: 'flex', flexDirection: 'column', gap: 18 }}>
41 − <div style={{ fontSize: 66, fontWeight: 700, letterSpacing: -2.5, lineHeight: 1.02 }}>{TAGLINE}</div>
42 − <div style={{ fontSize: 24, color: '#a3a9b8', maxWidth: 980, lineHeight: 1.35 }}>Company Atlas continuously observes the public web to track how companies evolve — products, hiring, pricing, leadership, locations, technology and strategy.</div>
43 − </div>
44 − <div style={{ display: 'flex', gap: 48, borderTop: '1px solid rgba(190,200,225,0.2)', paddingTop: 24 }}>
45 − {counters.length ? (
46 − counters.map(([label, value]) => (
47 − <div key={label} style={{ display: 'flex', flexDirection: 'column' }}>
48 − <div style={{ fontSize: 13, letterSpacing: 2, textTransform: 'uppercase', color: '#6f7688' }}>{label}</div>
49 − <div style={{ fontSize: 40, fontWeight: 600, letterSpacing: -1, fontFamily: 'Menlo, monospace' }}>{value}</div>
50 − </div>
51 − ))
52 − ) : (
53 − <div style={{ fontSize: 18, color: '#6f7688' }}>www.company-atlas.co</div>
54 − )}
55 − </div>
56 − </div>
57 − ),
58 − { ...size },
59 − );
10 + return renderSiteOg(await safe(api.stats()));
60 11 }
added apps/web/src/app/twitter-image.tsx +11 −0
@@ -0,0 +1,11 @@
1 +import { api, safe } from '@/lib/api';
2 +import { OG_ALT, OG_CONTENT_TYPE, OG_SIZE, renderSiteOg } from '@/lib/og';
3 +
4 +/** Twitter/X card image — same render as the Open Graph image (kept as its own route, nothing re-exported). */
5 +export const alt = OG_ALT;
6 +export const size = OG_SIZE;
7 +export const contentType = OG_CONTENT_TYPE;
8 +
9 +export default async function TwitterImage() {
10 + return renderSiteOg(await safe(api.stats()));
11 +}
added apps/web/src/components/brand/geometry.ts +106 −0
@@ -0,0 +1,106 @@
1 +/**
2 + * Company Atlas mark — single source of geometry.
3 + *
4 + * The "atlas plate": a rounded square plate carrying a globe drawn as a sparse grid (ring, one meridian ellipse, two
5 + * parallels) crossed by a strong equator — the timeline — that ends on a bright "now" node sitting on the horizon,
6 + * with a short pulse trail behind it. One 64 × 64 geometry; only stroke weights change with the rendered size so the
7 + * mark stays crisp at 16 px (favicon) and rich at 1200 px (share image).
8 + *
9 + * Pure TypeScript, no JSX and no DOM: imported by the React components AND by `scripts/build-icons.mjs` (Node strips
10 + * the types), which writes the standalone SVGs, the favicon set and the PNGs from exactly this description.
11 + */
12 +
13 +export const MARK_VIEWBOX = 64;
14 +export const MARK_PLATE_RADIUS = 16;
15 +
16 +const C = 32; // globe centre
17 +const R = 20; // globe radius
18 +const NODE_X = 52; // "now" node sits on the horizon, at the right end of the equator
19 +
20 +function circle(cx: number, cy: number, r: number): string {
21 + return `M${cx - r} ${cy}a${r} ${r} 0 1 0 ${2 * r} 0a${r} ${r} 0 1 0 ${-2 * r} 0Z`;
22 +}
23 +function ellipse(cx: number, cy: number, rx: number, ry: number): string {
24 + return `M${cx - rx} ${cy}a${rx} ${ry} 0 1 0 ${2 * rx} 0a${rx} ${ry} 0 1 0 ${-2 * rx} 0Z`;
25 +}
26 +const PAR_DY = 10;
27 +const PAR_HALF = Math.sqrt(R * R - PAR_DY * PAR_DY); // 17.32
28 +
29 +/** Path data, in 64-unit coordinates. */
30 +export const MARK_PATHS = {
31 + ring: circle(C, C, R),
32 + meridian: ellipse(C, C, 8.5, R),
33 + parallels: `M${(C - PAR_HALF).toFixed(2)} ${C - PAR_DY}H${(C + PAR_HALF).toFixed(2)}M${(C - PAR_HALF).toFixed(2)} ${C + PAR_DY}H${(C + PAR_HALF).toFixed(2)}`,
34 + equator: `M12 ${C}H${NODE_X}`,
35 + /** Tapered pulse trail running along the equator into the node. */
36 + trail: `M35 30.9L48.6 28.3V35.7L35 33.1Z`,
37 + node: circle(NODE_X, C, 5),
38 + halo: circle(NODE_X, C, 8.6),
39 +} as const;
40 +
41 +export type MarkColors = { plate: string; ink: string; grid: string; accent: string };
42 +
43 +/** Dark plate (for light surfaces, print, favicon). */
44 +export const MARK_DARK: MarkColors = { plate: '#0f1419', ink: '#f7f7f4', grid: 'rgba(247,247,244,0.30)', accent: '#3fd07a' };
45 +/** Light plate (for dark surfaces). */
46 +export const MARK_LIGHT: MarkColors = { plate: '#e8ebf1', ink: '#0a0d12', grid: 'rgba(10,13,18,0.26)', accent: '#16a34a' };
47 +/** Theme-following plate through the `--brand-*` tokens declared in globals.css. */
48 +export const MARK_TOKENS: MarkColors = { plate: 'var(--brand-plate)', ink: 'var(--brand-ink)', grid: 'var(--brand-grid)', accent: 'var(--brand-accent)' };
49 +
50 +/** Detail level: `tiny` ≤ 20 px (favicon 16), `small` ≤ 40 px (favicon 32, header), `full` above. */
51 +export type MarkLevel = 'tiny' | 'small' | 'full';
52 +export function markLevel(px: number): MarkLevel {
53 + return px <= 20 ? 'tiny' : px <= 40 ? 'small' : 'full';
54 +}
55 +
56 +type Weights = { ring: number; grid: number; equator: number; halo: number; node: number; parallels: boolean; halo_on: boolean; bevel: boolean };
57 +const WEIGHTS: Record<MarkLevel, Weights> = {
58 + tiny: { ring: 5.2, grid: 3.6, equator: 5.2, halo: 0, node: 6.6, parallels: false, halo_on: false, bevel: false },
59 + small: { ring: 4, grid: 2.6, equator: 4, halo: 2, node: 5.6, parallels: true, halo_on: true, bevel: false },
60 + full: { ring: 3, grid: 1.9, equator: 3, halo: 1.6, node: 5, parallels: true, halo_on: true, bevel: true },
61 +};
62 +
63 +export type Shape = { tag: 'rect' | 'path'; attrs: Record<string, string | number> };
64 +
65 +export type MarkOptions = {
66 + level?: MarkLevel;
67 + /** Draw the plate (false → globe only, e.g. mono variant in text). */
68 + plate?: boolean;
69 + /** Plate corner radius in 64-unit coordinates (0 for full-bleed icons that get masked by the OS). */
70 + radius?: number;
71 +};
72 +
73 +/** The ordered list of shapes that make the mark — the one place its drawing is defined. */
74 +export function markShapes(colors: MarkColors, { level = 'full', plate = true, radius = MARK_PLATE_RADIUS }: MarkOptions = {}): Shape[] {
75 + const w = WEIGHTS[level];
76 + const out: Shape[] = [];
77 + if (plate) {
78 + out.push({ tag: 'rect', attrs: { x: 0, y: 0, width: MARK_VIEWBOX, height: MARK_VIEWBOX, rx: radius, fill: colors.plate } });
79 + if (w.bevel && radius > 0) out.push({ tag: 'rect', attrs: { x: 0.75, y: 0.75, width: MARK_VIEWBOX - 1.5, height: MARK_VIEWBOX - 1.5, rx: Math.max(0, radius - 0.75), fill: 'none', stroke: colors.grid, 'stroke-width': 1, opacity: 0.55 } });
80 + }
81 + out.push({ tag: 'path', attrs: { d: MARK_PATHS.meridian, fill: 'none', stroke: colors.grid, 'stroke-width': w.grid } });
82 + if (w.parallels) out.push({ tag: 'path', attrs: { d: MARK_PATHS.parallels, fill: 'none', stroke: colors.grid, 'stroke-width': w.grid, 'stroke-linecap': 'round' } });
83 + out.push({ tag: 'path', attrs: { d: MARK_PATHS.ring, fill: 'none', stroke: colors.ink, 'stroke-width': w.ring } });
84 + out.push({ tag: 'path', attrs: { d: MARK_PATHS.equator, fill: 'none', stroke: colors.ink, 'stroke-width': w.equator, 'stroke-linecap': 'round' } });
85 + out.push({ tag: 'path', attrs: { d: MARK_PATHS.trail, fill: colors.accent, opacity: 0.55 } });
86 + if (w.halo_on) out.push({ tag: 'path', attrs: { d: MARK_PATHS.halo, fill: 'none', stroke: colors.accent, 'stroke-width': w.halo, opacity: 0.45 } });
87 + out.push({ tag: 'path', attrs: { d: circle(NODE_X, C, w.node), fill: colors.accent } });
88 + return out;
89 +}
90 +
91 +function attrString(attrs: Record<string, string | number>): string {
92 + return Object.entries(attrs)
93 + .map(([k, v]) => `${k}="${v}"`)
94 + .join(' ');
95 +}
96 +
97 +/** Standalone SVG document of the mark (used for icon.svg, logo-mark.svg and Satori `<img>` data URIs). */
98 +export function markSvgString(colors: MarkColors, opts: MarkOptions & { size?: number } = {}): string {
99 + const shapes = markShapes(colors, opts);
100 + const dim = opts.size ? ` width="${opts.size}" height="${opts.size}"` : '';
101 + return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}"${dim} fill="none">${shapes.map((s) => `<${s.tag} ${attrString(s.attrs)}/>`).join('')}</svg>`;
102 +}
103 +
104 +export function markDataUri(colors: MarkColors, opts?: MarkOptions & { size?: number }): string {
105 + return `data:image/svg+xml;utf8,${encodeURIComponent(markSvgString(colors, opts))}`;
106 +}
modified apps/web/src/components/brand/logo.tsx +25 −11
@@ -1,25 +1,39 @@
1 1 import { cn } from '@/lib/cn';
2 −import { MARK_TOKENS, MarkArt } from './mark';
2 +import { MARK_TOKENS, MARK_VIEWBOX, MarkArt, markLevel, type MarkColors } from './mark';
3 3
4 −/** The mark; `plate` follows the theme through `--brand-*` tokens, `mono` draws the globe in currentColor. */
4 +/**
5 + * The mark as an inline SVG. `plate` follows the theme through the `--brand-*` tokens (dark plate on the light UI,
6 + * light plate on the dark UI); `mono` draws the globe in `currentColor` with the live node in `--live`, for use inside
7 + * text. Detail level follows the rendered size so small instances keep thick strokes.
8 + */
5 9 export function LogoMark({ size = 24, className, variant = 'plate', title }: { size?: number; className?: string; variant?: 'plate' | 'mono'; title?: string }) {
6 − const colors = variant === 'plate' ? MARK_TOKENS : { plate: 'transparent', ink: 'currentColor', grid: 'currentColor', accent: 'var(--live)' };
10 + const colors: MarkColors = variant === 'plate' ? MARK_TOKENS : { plate: 'transparent', ink: 'currentColor', grid: 'currentColor', accent: 'var(--live)' };
7 11 return (
8 − <svg width={size} height={size} viewBox="0 0 32 32" fill="none" className={cn('shrink-0', className)} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined}>
12 + <svg width={size} height={size} viewBox={`0 0 ${MARK_VIEWBOX} ${MARK_VIEWBOX}`} fill="none" className={cn('shrink-0', className)} aria-hidden={title ? undefined : true} role={title ? 'img' : undefined}>
9 13 {title && <title>{title}</title>}
10 − <MarkArt colors={colors} plate={variant === 'plate'} simplified={size <= 20} />
14 + <MarkArt colors={colors} plate={variant === 'plate'} level={markLevel(size)} />
11 15 </svg>
12 16 );
13 17 }
14 18
15 −/** Lockup: mark + "Company Atlas" ("Company" medium, "Atlas" bold). */
16 −export function Wordmark({ className, markSize = 22, textClassName }: { className?: string; markSize?: number; textClassName?: string }) {
19 +/** Wordmark only: "Company" regular, "Atlas" semibold, tight tracking — Geist through the UI font stack. */
20 +export function WordmarkText({ className }: { className?: string }) {
17 21 return (
18 − <span className={cn('inline-flex items-center gap-2 text-ink', className)}>
22 + <span className={cn('whitespace-nowrap tracking-[-0.02em]', className)}>
23 + <span className="font-normal text-ink-2">Company</span> <span className="font-semibold text-ink">Atlas</span>
24 + </span>
25 + );
26 +}
27 +
28 +/** Lockup: mark + wordmark. */
29 +export function Wordmark({ className, markSize = 24, textClassName }: { className?: string; markSize?: number; textClassName?: string }) {
30 + return (
31 + <span className={cn('inline-flex items-center gap-2.5 text-ink', className)}>
19 32 <LogoMark size={markSize} />
20 − <span className={cn('text-[17px] tracking-tight', textClassName)}>
21 − <span className="font-medium text-ink-2">Company</span> <span className="font-bold">Atlas</span>
22 − </span>
33 + <WordmarkText className={cn('text-[17px]', textClassName)} />
23 34 </span>
24 35 );
25 36 }
37 +
38 +/** Alias kept for readability at call sites that want the full lockup. */
39 +export const Logo = Wordmark;
modified apps/web/src/components/brand/mark.tsx +15 −43
@@ -1,55 +1,27 @@
1 1 /**
2 − * The Company Atlas mark — an atlas plate: a rounded square carrying a globe grid (a meridian ellipse + equator + two
3 − * parallels) with a bright "now" node on the equator's right edge, where the live observation sits on the timeline.
4 − * One 32 × 32 geometry for the favicon, the header lockup, the Apple icon and the OG images.
2 + * React renderers of the Company Atlas mark. Geometry lives in `geometry.ts` (shared with the icon build script);
3 + * this file only turns the shape list into SVG elements (`MarkArt`) or into a data-URI `<img>` for Satori (`MarkImg`).
5 4 */
6 −export type MarkColors = { plate: string; ink: string; grid: string; accent: string };
5 +import { type MarkColors, type MarkOptions, MARK_DARK, MARK_VIEWBOX, markDataUri, markShapes } from './geometry';
7 6
8 −export const MARK_DARK: MarkColors = { plate: '#0a0d12', ink: '#e8ebf1', grid: 'rgba(232,235,241,0.34)', accent: '#3fd07a' };
9 −export const MARK_LIGHT: MarkColors = { plate: '#e8ebf1', ink: '#0a0d12', grid: 'rgba(10,13,18,0.3)', accent: '#16a34a' };
10 −export const MARK_TOKENS: MarkColors = { plate: 'var(--brand-plate)', ink: 'var(--brand-ink)', grid: 'var(--brand-grid)', accent: 'var(--brand-accent)' };
7 +export { MARK_DARK, MARK_LIGHT, MARK_PLATE_RADIUS, MARK_TOKENS, MARK_VIEWBOX, markDataUri, markLevel, markSvgString } from './geometry';
8 +export type { MarkColors, MarkLevel, MarkOptions } from './geometry';
11 9
12 −type MarkOpts = { simplified?: boolean; plate?: boolean; radius?: number };
13 −
14 −export function MarkArt({ colors, simplified = false, plate = true, radius = 7 }: { colors: MarkColors } & MarkOpts) {
15 − const sw = simplified ? 1.6 : 1.3;
10 +/** Shapes only — wrap in your own <svg viewBox="0 0 64 64">. */
11 +export function MarkArt({ colors, ...opts }: { colors: MarkColors } & MarkOptions) {
16 12 return (
17 13 <>
18 − {plate && <rect x="1" y="1" width="30" height="30" rx={radius} fill={colors.plate} />}
19 − {/* globe outline */}
20 − <circle cx="16" cy="16" r="10" stroke={colors.ink} strokeWidth={sw} fill="none" />
21 − {/* central meridian ellipse */}
22 − <ellipse cx="16" cy="16" rx="4.2" ry="10" stroke={colors.grid} strokeWidth={simplified ? 1.1 : 0.9} fill="none" />
23 − {/* parallels */}
24 − {!simplified && <line x1="7.6" x2="24.4" y1="11" y2="11" stroke={colors.grid} strokeWidth="0.9" />}
25 − {!simplified && <line x1="7.6" x2="24.4" y1="21" y2="21" stroke={colors.grid} strokeWidth="0.9" />}
26 − {/* equator (timeline) */}
27 − <line x1="6" x2="26" y1="16" y2="16" stroke={colors.ink} strokeWidth={sw} strokeLinecap="round" />
28 − {/* the "now" node */}
29 − <circle cx="26" cy="16" r={simplified ? 2.6 : 2.3} fill={colors.accent} />
14 + {markShapes(colors, opts).map((s, i) => {
15 + const { 'stroke-width': sw, 'stroke-linecap': cap, ...rest } = s.attrs as Record<string, string | number | undefined>;
16 + const props = { ...rest, strokeWidth: sw, strokeLinecap: cap as 'round' | undefined };
17 + return s.tag === 'rect' ? <rect key={i} {...props} /> : <path key={i} {...props} />;
18 + })}
30 19 </>
31 20 );
32 21 }
33 22
34 −export function markSvgString(colors: MarkColors, { simplified = false, plate = true, radius = 7 }: MarkOpts = {}): string {
35 − const sw = simplified ? 1.6 : 1.3;
36 − const parts = [`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">`];
37 − if (plate) parts.push(`<rect x="1" y="1" width="30" height="30" rx="${radius}" fill="${colors.plate}"/>`);
38 − parts.push(`<circle cx="16" cy="16" r="10" stroke="${colors.ink}" stroke-width="${sw}" fill="none"/>`);
39 − parts.push(`<ellipse cx="16" cy="16" rx="4.2" ry="10" stroke="${colors.grid}" stroke-width="${simplified ? 1.1 : 0.9}" fill="none"/>`);
40 − if (!simplified) parts.push(`<line x1="7.6" x2="24.4" y1="11" y2="11" stroke="${colors.grid}" stroke-width="0.9"/>`, `<line x1="7.6" x2="24.4" y1="21" y2="21" stroke="${colors.grid}" stroke-width="0.9"/>`);
41 − parts.push(`<line x1="6" x2="26" y1="16" y2="16" stroke="${colors.ink}" stroke-width="${sw}" stroke-linecap="round"/>`);
42 − parts.push(`<circle cx="26" cy="16" r="${simplified ? 2.6 : 2.3}" fill="${colors.accent}"/>`);
43 − parts.push('</svg>');
44 − return parts.join('');
45 −}
46 −
47 −export function markDataUri(colors: MarkColors, opts?: MarkOpts): string {
48 − return `data:image/svg+xml;utf8,${encodeURIComponent(markSvgString(colors, opts))}`;
49 −}
50 −
51 −/** For ImageResponse routes (satori rasterises <img> data URIs reliably). Fixed hex colours only. */
52 −export function MarkImg({ px, colors = MARK_DARK, simplified = false, plate = true, radius }: { px: number; colors?: MarkColors } & MarkOpts) {
23 +/** For ImageResponse routes (Satori rasterises `<img>` data URIs reliably). Fixed colours only — no CSS variables. */
24 +export function MarkImg({ px, colors = MARK_DARK, ...opts }: { px: number; colors?: MarkColors } & MarkOptions) {
53 25 // eslint-disable-next-line @next/next/no-img-element
54 − return <img width={px} height={px} src={markDataUri(colors, { simplified, plate, radius })} alt="" style={{ width: px, height: px }} />;
26 + return <img width={px} height={px} src={markDataUri(colors, { ...opts, size: MARK_VIEWBOX })} alt="" style={{ width: px, height: px }} />;
55 27 }
modified apps/web/src/components/layout/site-footer.tsx +25 −15
@@ -1,19 +1,18 @@
1 1 import Link from 'next/link';
2 −import { LogoMark } from '@/components/brand/logo';
2 +import { Wordmark } from '@/components/brand/logo';
3 3 import { AUTHOR_NAME, CONTACT_EMAIL, footerGroups, HOST_NAME, HOST_URL, SITE_URL } from '@/lib/site';
4 4
5 +const HOST_LABEL = HOST_URL.replace(/^https?:\/\//, '');
6 +
5 7 export function SiteFooter() {
6 8 return (
7 9 <footer className="mt-16 border-t border-rule">
8 10 <div className="container-x mx-auto max-w-[1600px] py-10">
9 11 <div className="grid gap-8 md:grid-cols-[1.6fr_1fr_1fr_1fr]">
10 12 <div>
11 − <p className="flex items-center gap-2 text-ink">
12 − <LogoMark size={22} />
13 − <span className="tracking-tight">
14 − <span className="font-medium text-ink-2">Company</span> <span className="font-bold">Atlas</span>
15 − </span>
16 − </p>
13 + <Link href="/" className="inline-flex" aria-label="Company Atlas home">
14 + <Wordmark markSize={22} textClassName="text-[16px]" />
15 + </Link>
17 16 <p className="mt-3 max-w-sm text-sm leading-relaxed text-ink-2">
18 17 A continuously updated corporate observation network. Every event links to the public page where it was detected; inferred facts carry a confidence label; history is never overwritten.
19 18 </p>
@@ -40,18 +39,29 @@ export function SiteFooter() {
40 39 </div>
41 40 ))}
42 41 </div>
43 − <div className="mt-10 flex flex-col gap-2 border-t border-rule pt-5 text-xs text-ink-3 md:flex-row md:items-center md:justify-between">
44 − <p>
45 − Built by <span className="text-ink-2">{AUTHOR_NAME}</span> ·{' '}
46 − <a href={`mailto:${CONTACT_EMAIL}`} className="text-ink-2 hover:text-ink">
42 + <div className="mt-10 flex flex-col gap-3 border-t border-rule pt-5 text-[13px] leading-relaxed text-ink-2 md:flex-row md:items-start md:justify-between md:gap-8">
43 + {/* explicit spaces around the separators give the line break opportunities on narrow screens */}
44 + <p className="md:max-w-[60%]" data-credits>
45 + <span className="whitespace-nowrap">
46 + Built by <span className="font-medium text-ink">{AUTHOR_NAME}</span>
47 + </span>{' '}
48 + <span className="text-ink-3">·</span>{' '}
49 + <a href={`mailto:${CONTACT_EMAIL}`} className="underline decoration-rule-strong underline-offset-[3px] hover:text-ink hover:decoration-ink">
47 50 {CONTACT_EMAIL}
48 51 </a>{' '}
49 − · Hosted on{' '}
50 − <a href={HOST_URL} className="text-ink-2 hover:text-ink" rel="noopener noreferrer">
51 − {HOST_NAME}
52 + <span className="text-ink-3">·</span>{' '}
53 + <span className="whitespace-nowrap">
54 + Hosted on{' '}
55 + <a href={HOST_URL} target="_blank" rel="noopener noreferrer" className="font-medium text-ink hover:underline">
56 + {HOST_NAME}
57 + </a>
58 + </span>{' '}
59 + <span className="text-ink-3">—</span>{' '}
60 + <a href={HOST_URL} target="_blank" rel="noopener noreferrer" className="mono text-[12.5px] hover:text-ink">
61 + {HOST_LABEL}
52 62 </a>
53 63 </p>
54 − <p>Data provenance: observations of publicly accessible corporate web pages, collected by {`CompanyAtlasBot`} in accordance with robots.txt. Interpretations are labelled, never asserted as fact.</p>
64 + <p className="max-w-xl text-ink-3 md:text-right">Data provenance: observations of publicly accessible corporate web pages, collected by CompanyAtlasBot in accordance with robots.txt. Interpretations are labelled, never asserted as fact.</p>
55 65 </div>
56 66 </div>
57 67 </footer>
added apps/web/src/lib/og.tsx +436 −0
@@ -0,0 +1,436 @@
1 +/**
2 + * Share images (Open Graph / Twitter), rendered by Satori through `next/og`.
3 + *
4 + * One render function per family — `renderSiteOg`, `renderCompanyOg`, `renderTopicOg` — used by thin route files
5 + * (`opengraph-image.tsx` + `twitter-image.tsx` at the root, under `/company/[slug]`, `/industry/[slug]`,
6 + * `/country/[code]`). Route files export only `alt`/`size`/`contentType` + the default function; nothing else is
7 + * re-exported between them (re-exporting `runtime` between sibling metadata routes broke sibling builds).
8 + *
9 + * Satori rules honoured here: every box is `display:flex`; a div never mixes several text children (wrap in spans);
10 + * images are data URIs; fonts are Geist TTFs read from the `geist` package at first use.
11 + */
12 +import { existsSync } from 'node:fs';
13 +import * as fs from 'node:fs/promises';
14 +import { createRequire } from 'node:module';
15 +import path from 'node:path';
16 +import { geoGraticule, geoOrthographic, geoPath } from 'd3-geo';
17 +import { ImageResponse } from 'next/og';
18 +import { feature } from 'topojson-client';
19 +import type { GeometryCollection, Topology } from 'topojson-specification';
20 +import land110 from 'world-atlas/land-110m.json';
21 +import { MARK_DARK, MarkImg } from '@/components/brand/mark';
22 +import { API_URL } from '@/lib/api';
23 +import { countryName } from '@/lib/countries';
24 +import { fmtInt, fmtPctSigned, fmtScore, num } from '@/lib/format';
25 +import { logoCandidates, monogram } from '@/lib/profile';
26 +import { HOST_NAME, SITE_NAME, SITE_URL, TAGLINE } from '@/lib/site';
27 +import type { CompanyDetail, Stats } from '@/lib/types';
28 +
29 +export const OG_SIZE = { width: 1200, height: 630 };
30 +export const OG_CONTENT_TYPE = 'image/png';
31 +export const OG_ALT = `${SITE_NAME} — ${TAGLINE}`;
32 +
33 +/* Palette: the dark theme tokens of globals.css, hard-coded (Satori has no CSS variables). */
34 +const P = {
35 + bg: '#0a0d12',
36 + surface: '#11151c',
37 + surface2: '#171c25',
38 + ink: '#e8ebf1',
39 + ink2: '#a3a9b8',
40 + ink3: '#6f7688',
41 + rule: 'rgba(190,200,225,0.14)',
42 + ruleStrong: 'rgba(190,200,225,0.26)',
43 + accent: '#6d95ff',
44 + live: '#3fd07a',
45 + positive: '#3fd07a',
46 + danger: '#ff6b74',
47 +};
48 +const SANS = 'Geist';
49 +const MONO = 'Geist Mono';
50 +const HOST = SITE_URL.replace(/^https?:\/\//, '');
51 +
52 +/* ------------------------------------------------------------------------------------------------------------------ */
53 +/* Fonts */
54 +/* ------------------------------------------------------------------------------------------------------------------ */
55 +type Font = { name: string; data: ArrayBuffer; weight: 400 | 500 | 600; style: 'normal' };
56 +
57 +function geistFontsDir(): string | null {
58 + const cwd = process.cwd();
59 + for (const base of [cwd, path.join(cwd, 'apps', 'web'), path.resolve(cwd, '..', '..')]) {
60 + const p = path.join(base, 'node_modules', 'geist', 'dist', 'fonts');
61 + if (existsSync(p)) return p;
62 + }
63 + try {
64 + const req = createRequire(path.join(cwd, 'index.js'));
65 + return path.join(path.dirname(req.resolve('geist/font')), 'fonts');
66 + } catch {
67 + return null;
68 + }
69 +}
70 +
71 +let fontsPromise: Promise<Font[]> | null = null;
72 +/** Geist Regular / Medium / SemiBold + Geist Mono Medium. Missing files degrade to Satori's default font. */
73 +export function loadFonts(): Promise<Font[]> {
74 + fontsPromise ??= (async () => {
75 + const dir = geistFontsDir();
76 + if (!dir) return [];
77 + const wanted: [string, string, Font['weight']][] = [
78 + ['geist-sans/Geist-Regular.ttf', SANS, 400],
79 + ['geist-sans/Geist-Medium.ttf', SANS, 500],
80 + ['geist-sans/Geist-SemiBold.ttf', SANS, 600],
81 + ['geist-mono/GeistMono-Medium.ttf', MONO, 500],
82 + ];
83 + const out: Font[] = [];
84 + for (const [file, name, weight] of wanted) {
85 + try {
86 + // Runtime lookup on purpose (fonts come from node_modules/geist, which is not deployed as a traced asset); plain
87 + // string concatenation keeps Turbopack's output-file tracing from pulling the whole project in.
88 + const buf = await fs.readFile(/*turbopackIgnore: true*/ `${dir}/${file}`);
89 + out.push({ name, data: buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength) as ArrayBuffer, weight, style: 'normal' });
90 + } catch {
91 + /* font missing → skip */
92 + }
93 + }
94 + return out;
95 + })();
96 + return fontsPromise;
97 +}
98 +
99 +async function respond(node: React.ReactElement): Promise<ImageResponse> {
100 + const fonts = await loadFonts();
101 + return new ImageResponse(node, { ...OG_SIZE, fonts: fonts.length ? fonts : undefined });
102 +}
103 +
104 +/* ------------------------------------------------------------------------------------------------------------------ */
105 +/* Globe wireframe (orthographic land + graticule) — the "atlas" backdrop on the right of every share image */
106 +/* ------------------------------------------------------------------------------------------------------------------ */
107 +let globeCache: string | null = null;
108 +function globeDataUri(): string {
109 + if (globeCache) return globeCache;
110 + const W = 760;
111 + const H = 760;
112 + const R = 340;
113 + const projection = geoOrthographic()
114 + .rotate([-12, -28, 0])
115 + .scale(R)
116 + .translate([W / 2, H / 2])
117 + .clipAngle(90);
118 + const pathOf = geoPath(projection);
119 + const topo = land110 as unknown as Topology<{ land: GeometryCollection }>;
120 + const land = feature(topo, topo.objects.land);
121 + const graticule = geoGraticule().step([15, 15])();
122 + const sphere = pathOf({ type: 'Sphere' }) ?? '';
123 + const svg =
124 + `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${W}" height="${H}">` +
125 + `<defs><radialGradient id="g" cx="38%" cy="32%" r="75%"><stop offset="0" stop-color="#1b2433"/><stop offset="1" stop-color="#0a0d12"/></radialGradient></defs>` +
126 + `<path d="${sphere}" fill="url(#g)"/>` +
127 + `<path d="${pathOf(graticule) ?? ''}" fill="none" stroke="rgba(190,200,225,0.16)" stroke-width="1"/>` +
128 + `<path d="${pathOf(land) ?? ''}" fill="rgba(190,200,225,0.13)" stroke="rgba(190,200,225,0.32)" stroke-width="1"/>` +
129 + `<path d="${sphere}" fill="none" stroke="rgba(190,200,225,0.34)" stroke-width="2"/>` +
130 + `</svg>`;
131 + globeCache = `data:image/svg+xml;utf8,${encodeURIComponent(svg)}`;
132 + return globeCache;
133 +}
134 +
135 +/* ------------------------------------------------------------------------------------------------------------------ */
136 +/* Building blocks */
137 +/* ------------------------------------------------------------------------------------------------------------------ */
138 +type Style = React.CSSProperties;
139 +const row = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'row', alignItems: 'center', ...extra });
140 +const col = (extra?: Style): Style => ({ display: 'flex', flexDirection: 'column', ...extra });
141 +
142 +function Frame({ children, globe = 'right' }: { children: React.ReactNode; globe?: 'right' | 'corner' | 'none' }) {
143 + return (
144 + <div style={{ ...col({ justifyContent: 'space-between' }), width: OG_SIZE.width, height: OG_SIZE.height, padding: 56, background: P.bg, color: P.ink, fontFamily: SANS, position: 'relative', overflow: 'hidden' }}>
145 + <div style={{ position: 'absolute', left: 0, top: 0, width: 1200, height: 630, display: 'flex', backgroundImage: `linear-gradient(to right, rgba(190,200,225,0.045) 1px, transparent 1px), linear-gradient(to bottom, rgba(190,200,225,0.045) 1px, transparent 1px)`, backgroundSize: '60px 60px' }} />
146 + {globe !== 'none' && (
147 + // eslint-disable-next-line @next/next/no-img-element
148 + <img src={globeDataUri()} width={760} height={760} alt="" style={globe === 'right' ? { position: 'absolute', right: -170, top: -40, width: 760, height: 760, opacity: 0.95 } : { position: 'absolute', right: -300, top: 230, width: 760, height: 760, opacity: 0.8 }} />
149 + )}
150 + <div style={{ position: 'absolute', left: 0, top: 0, width: 1200, height: 630, display: 'flex', backgroundImage: 'linear-gradient(to right, rgba(10,13,18,0.9) 40%, rgba(10,13,18,0.35) 70%, rgba(10,13,18,0) 100%)' }} />
151 + <div style={{ position: 'absolute', left: 0, bottom: 0, width: 1200, height: 6, display: 'flex', background: `linear-gradient(to right, ${P.live}, ${P.accent})` }} />
152 + {children}
153 + </div>
154 + );
155 +}
156 +
157 +/** Mark + wordmark, in Geist: "Company" regular ink-2, "Atlas" semibold ink. */
158 +function Lockup({ markPx = 44, fontSize = 26 }: { markPx?: number; fontSize?: number }) {
159 + return (
160 + <div style={row({ gap: Math.round(markPx * 0.3) })}>
161 + <MarkImg px={markPx} colors={MARK_DARK} />
162 + <div style={row({ gap: Math.round(fontSize * 0.28), fontSize, letterSpacing: -0.6 })}>
163 + <span style={{ color: P.ink2, fontWeight: 400 }}>Company</span>
164 + <span style={{ color: P.ink, fontWeight: 600 }}>Atlas</span>
165 + </div>
166 + </div>
167 + );
168 +}
169 +
170 +function LivePill({ label = 'LIVE' }: { label?: string }) {
171 + return (
172 + <div style={row({ gap: 10, padding: '8px 14px', borderRadius: 999, border: `1px solid rgba(63,208,122,0.4)`, background: 'rgba(63,208,122,0.08)', color: P.live, fontSize: 16, fontFamily: MONO, letterSpacing: 2 })}>
173 + <div style={{ width: 10, height: 10, borderRadius: 10, background: P.live, display: 'flex' }} />
174 + <span>{label}</span>
175 + </div>
176 + );
177 +}
178 +
179 +function Eyebrow({ children, color = P.ink3 }: { children: string; color?: string }) {
180 + return <span style={{ fontSize: 15, letterSpacing: 2.5, textTransform: 'uppercase', color, fontFamily: MONO, fontWeight: 500 }}>{children}</span>;
181 +}
182 +
183 +function Counter({ label, value, tone }: { label: string; value: string; tone?: string }) {
184 + return (
185 + <div style={col({ gap: 6, minWidth: 150 })}>
186 + <Eyebrow>{label}</Eyebrow>
187 + <span style={{ fontSize: 40, fontWeight: 600, letterSpacing: -1.5, fontFamily: MONO, color: tone ?? P.ink, lineHeight: 1 }}>{value}</span>
188 + </div>
189 + );
190 +}
191 +
192 +function Footer({ left }: { left?: string }) {
193 + return (
194 + <div style={row({ justifyContent: 'space-between', width: '100%' })}>
195 + <span style={{ fontFamily: MONO, fontSize: 20, color: P.ink2, letterSpacing: -0.3 }}>{left ?? HOST}</span>
196 + <div style={row({ gap: 10, fontSize: 18, color: P.ink3 })}>
197 + <span>Hosted on</span>
198 + <span style={{ color: P.ink2, fontWeight: 500 }}>{HOST_NAME}</span>
199 + </div>
200 + </div>
201 + );
202 +}
203 +
204 +/* ------------------------------------------------------------------------------------------------------------------ */
205 +/* 1. Site */
206 +/* ------------------------------------------------------------------------------------------------------------------ */
207 +export async function renderSiteOg(stats: Stats | null): Promise<ImageResponse> {
208 + const counters: [string, string][] = stats
209 + ? [
210 + ['Companies', fmtInt(stats.companies)],
211 + ['Sensors', fmtInt(stats.sensors)],
212 + ['Observations', fmtInt(stats.observations)],
213 + ['Events', fmtInt(stats.events)],
214 + ]
215 + : [];
216 + return respond(
217 + <Frame>
218 + <div style={row({ justifyContent: 'space-between', width: '100%' })}>
219 + <Lockup markPx={56} fontSize={32} />
220 + <LivePill />
221 + </div>
222 + <div style={col({ gap: 22, maxWidth: 780 })}>
223 + <Eyebrow>Continuous corporate observation network</Eyebrow>
224 + <span style={{ fontSize: 78, fontWeight: 600, letterSpacing: -3.5, lineHeight: 1.0, color: P.ink }}>{TAGLINE}</span>
225 + <span style={{ fontSize: 24, color: P.ink2, lineHeight: 1.35, maxWidth: 700 }}>Public-web sensors on thousands of companies: products, hiring, pricing, leadership, locations and technology — observed, versioned, never overwritten.</span>
226 + </div>
227 + <div style={col({ gap: 30, width: '100%' })}>
228 + {counters.length > 0 && (
229 + <div style={row({ gap: 56, borderTop: `1px solid ${P.ruleStrong}`, paddingTop: 26 })}>
230 + {counters.map(([label, value]) => (
231 + <Counter key={label} label={label} value={value} />
232 + ))}
233 + </div>
234 + )}
235 + <Footer />
236 + </div>
237 + </Frame>,
238 + );
239 +}
240 +
241 +/* ------------------------------------------------------------------------------------------------------------------ */
242 +/* 2. Company */
243 +/* ------------------------------------------------------------------------------------------------------------------ */
244 +const LOGO_TIMEOUT_MS = 2500;
245 +const LOGO_MAX_BYTES = 1_500_000;
246 +const LOGO_TYPES = new Set(['image/png', 'image/jpeg', 'image/svg+xml']);
247 +
248 +/** True for URLs the server may fetch: http(s), public hostnames (no IP literals / localhost) — the API host excepted (mock logos in dev). */
249 +function logoUrlAllowed(u: URL): boolean {
250 + if (u.protocol !== 'http:' && u.protocol !== 'https:') return false;
251 + if (u.origin === new URL(API_URL).origin) return true;
252 + const h = u.hostname.toLowerCase();
253 + if (h === 'localhost' || h.endsWith('.local') || h.endsWith('.internal') || h.endsWith('.localhost')) return false;
254 + if (/^\d{1,3}(\.\d{1,3}){3}$/.test(h) || h.startsWith('[') || h.includes(':')) return false;
255 + return true;
256 +}
257 +
258 +/** Fetches the first loadable company logo as a data URI (Satori cannot load remote images reliably); null on any problem. */
259 +export async function fetchLogoDataUri(candidates: string[]): Promise<string | null> {
260 + for (const c of candidates.slice(0, 3)) {
261 + try {
262 + const u = new URL(c);
263 + if (!logoUrlAllowed(u)) continue;
264 + const res = await fetch(u, { signal: AbortSignal.timeout(LOGO_TIMEOUT_MS), headers: { accept: 'image/png,image/jpeg,image/svg+xml', 'user-agent': 'CompanyAtlasBot/1.0 (+https://www.company-atlas.co/bot; share-image)' }, redirect: 'follow', next: { revalidate: 86400 } });
265 + if (!res.ok) continue;
266 + const type = (res.headers.get('content-type') ?? '').split(';')[0]!.trim().toLowerCase();
267 + if (!LOGO_TYPES.has(type)) continue;
268 + const len = Number(res.headers.get('content-length') ?? 0);
269 + if (len > LOGO_MAX_BYTES) continue;
270 + const buf = Buffer.from(await res.arrayBuffer());
271 + if (!buf.length || buf.length > LOGO_MAX_BYTES) continue;
272 + return `data:${type};base64,${buf.toString('base64')}`;
273 + } catch {
274 + /* try next candidate */
275 + }
276 + }
277 + return null;
278 +}
279 +
280 +function LogoTile({ name, dataUri, px }: { name: string; dataUri: string | null; px: number }) {
281 + if (dataUri) {
282 + return (
283 + <div style={{ ...row({ justifyContent: 'center' }), width: px, height: px, borderRadius: 20, background: '#ffffff', border: `1px solid ${P.ruleStrong}`, padding: Math.round(px * 0.12), flexShrink: 0 }}>
284 + {/* eslint-disable-next-line @next/next/no-img-element */}
285 + <img src={dataUri} alt="" width={px * 0.76} height={px * 0.76} style={{ width: px * 0.76, height: px * 0.76, objectFit: 'contain' }} />
286 + </div>
287 + );
288 + }
289 + return (
290 + <div style={{ ...row({ justifyContent: 'center' }), width: px, height: px, borderRadius: 20, background: P.surface2, border: `1px solid ${P.ruleStrong}`, color: P.ink, fontFamily: MONO, fontWeight: 600, fontSize: Math.round(px * 0.4), letterSpacing: -1, flexShrink: 0 }}>
291 + <span>{monogram(name)}</span>
292 + </div>
293 + );
294 +}
295 +
296 +function Fact({ label, value }: { label: string; value: string }) {
297 + return (
298 + <div style={col({ gap: 6 })}>
299 + <Eyebrow>{label}</Eyebrow>
300 + <span style={{ fontSize: 22, color: P.ink, fontWeight: 500, letterSpacing: -0.3 }}>{value}</span>
301 + </div>
302 + );
303 +}
304 +
305 +function MetricTile({ label, value, hint, tone }: { label: string; value: string; hint: string; tone?: string }) {
306 + return (
307 + <div style={{ ...col({ gap: 8 }), flex: 1, padding: '18px 22px', borderRadius: 12, background: 'rgba(17,21,28,0.85)', border: `1px solid ${P.rule}` }}>
308 + <Eyebrow>{label}</Eyebrow>
309 + <span style={{ fontSize: 40, fontWeight: 600, fontFamily: MONO, letterSpacing: -1.5, lineHeight: 1, color: tone ?? P.ink }}>{value}</span>
310 + <span style={{ fontSize: 14, color: P.ink3 }}>{hint}</span>
311 + </div>
312 + );
313 +}
314 +
315 +function clampName(n: string): { text: string; size: number } {
316 + const text = n.length > 42 ? `${n.slice(0, 41).trimEnd()}…` : n;
317 + return { text, size: text.length > 28 ? 42 : text.length > 18 ? 52 : 64 };
318 +}
319 +
320 +export async function renderCompanyOg(c: CompanyDetail): Promise<ImageResponse> {
321 + const p = c.profile ?? null;
322 + const logo = await fetchLogoDataUri(logoCandidates(c));
323 + const name = clampName(c.display_name);
324 + const country = c.country ? countryName(c.country) : null;
325 + const industry = c.industry_primary ?? c.industries[0] ?? null;
326 + const hqCity = p?.hq?.city ?? c.hq_city;
327 + const hq = [hqCity, p?.hq?.region ?? c.hq_region, country].filter(Boolean).join(', ');
328 + const founded = p?.founded_year ?? c.founded_year;
329 + const employees = p?.employees ? `${fmtInt(p.employees)}${p.employees_year ? ` (${p.employees_year})` : ''}` : c.employees_band ? c.employees_band : null;
330 + const facts: [string, string][] = [];
331 + if (founded) facts.push(['Founded', String(founded)]);
332 + if (employees) facts.push(['Employees', employees]);
333 + if (hq) facts.push(['Headquarters', hq]);
334 + const detail = new Map((c.metrics_detail ?? []).map((m) => [m.metric, m]));
335 + const tile = (metric: 'activity_score' | 'hiring_momentum_30d' | 'ai_adoption', label: string, pct = false) => {
336 + const v = num(c.metrics[metric]);
337 + const d = detail.get(metric);
338 + if (v === null) return { label, value: '—', hint: 'not enough monitored evidence' };
339 + return { label, value: pct ? fmtPctSigned(v) : fmtScore(v), hint: d ? `confidence ${Math.round(d.confidence * 100)} %` : pct ? 'vs 30 days ago' : '0–100', tone: pct ? (v > 0 ? P.positive : v < 0 ? P.danger : undefined) : undefined };
340 + };
341 + const tiles = [tile('activity_score', 'Activity Score'), tile('hiring_momentum_30d', 'Hiring Momentum 30d', true), tile('ai_adoption', 'AI Adoption')];
342 + const chips = [c.canonical_domain, country, industry].filter((x): x is string => Boolean(x));
343 +
344 + return respond(
345 + <Frame globe="corner">
346 + <div style={row({ justifyContent: 'space-between', width: '100%' })}>
347 + <Lockup markPx={40} fontSize={23} />
348 + <div style={row({ gap: 8, fontFamily: MONO, fontSize: 16, color: P.ink3 })}>
349 + <span>{fmtInt(c.counts.sensors)} sensors</span>
350 + <span>·</span>
351 + <span>{fmtInt(c.counts.events)} events</span>
352 + <span>·</span>
353 + <span>{fmtInt(c.counts.changes)} changes</span>
354 + </div>
355 + </div>
356 + <div style={col({ gap: 26, width: '100%' })}>
357 + <div style={row({ gap: 30 })}>
358 + <LogoTile name={c.display_name} dataUri={logo} px={124} />
359 + <div style={col({ gap: 12, flex: 1, minWidth: 0 })}>
360 + <span style={{ fontSize: name.size, fontWeight: 600, letterSpacing: -2, lineHeight: 1.02, color: P.ink }}>{name.text}</span>
361 + <div style={row({ gap: 10, flexWrap: 'wrap' })}>
362 + {chips.map((chip, i) => (
363 + <div key={i} style={{ ...row(), padding: '6px 12px', borderRadius: 6, border: `1px solid ${P.ruleStrong}`, background: 'rgba(17,21,28,0.7)', fontFamily: i === 0 ? MONO : SANS, fontSize: 18, color: P.ink2 }}>
364 + <span>{chip}</span>
365 + </div>
366 + ))}
367 + </div>
368 + </div>
369 + </div>
370 + {facts.length > 0 && (
371 + <div style={row({ gap: 48, borderTop: `1px solid ${P.rule}`, paddingTop: 20 })}>
372 + {facts.map(([l, v]) => (
373 + <Fact key={l} label={l} value={v} />
374 + ))}
375 + </div>
376 + )}
377 + <div style={row({ gap: 16, width: '100%' })}>
378 + {tiles.map((t) => (
379 + <MetricTile key={t.label} {...t} />
380 + ))}
381 + </div>
382 + </div>
383 + <Footer left={`${HOST}/company/${c.slug}`} />
384 + </Frame>,
385 + );
386 +}
387 +
388 +/* ------------------------------------------------------------------------------------------------------------------ */
389 +/* 3. Topic (industry / country) */
390 +/* ------------------------------------------------------------------------------------------------------------------ */
391 +export type TopicOg = {
392 + eyebrow: string;
393 + title: string;
394 + subtitle?: string | null;
395 + counters: [string, string][];
396 + /** Up to eight names shown as chips ("Monitored companies include …"). */
397 + names?: string[];
398 + path: string;
399 +};
400 +
401 +export async function renderTopicOg(t: TopicOg): Promise<ImageResponse> {
402 + const title = t.title.length > 40 ? `${t.title.slice(0, 39).trimEnd()}…` : t.title;
403 + const names = (t.names ?? []).slice(0, 8);
404 + return respond(
405 + <Frame>
406 + <div style={row({ justifyContent: 'space-between', width: '100%' })}>
407 + <Lockup markPx={44} fontSize={25} />
408 + <LivePill />
409 + </div>
410 + <div style={col({ gap: 18, maxWidth: 820 })}>
411 + <Eyebrow color={P.accent}>{t.eyebrow}</Eyebrow>
412 + <span style={{ fontSize: title.length > 24 ? 56 : 72, fontWeight: 600, letterSpacing: -2.5, lineHeight: 1.02, color: P.ink }}>{title}</span>
413 + {t.subtitle && <span style={{ fontSize: 22, color: P.ink2, lineHeight: 1.35, maxWidth: 760 }}>{t.subtitle.length > 160 ? `${t.subtitle.slice(0, 159).trimEnd()}…` : t.subtitle}</span>}
414 + {names.length > 0 && (
415 + <div style={row({ gap: 8, flexWrap: 'wrap', maxWidth: 800 })}>
416 + {names.map((n) => (
417 + <div key={n} style={{ ...row(), padding: '5px 10px', borderRadius: 6, border: `1px solid ${P.rule}`, background: 'rgba(17,21,28,0.7)', fontSize: 16, color: P.ink2 }}>
418 + <span>{n}</span>
419 + </div>
420 + ))}
421 + </div>
422 + )}
423 + </div>
424 + <div style={col({ gap: 28, width: '100%' })}>
425 + {t.counters.length > 0 && (
426 + <div style={row({ gap: 56, borderTop: `1px solid ${P.ruleStrong}`, paddingTop: 24 })}>
427 + {t.counters.map(([label, value]) => (
428 + <Counter key={label} label={label} value={value} />
429 + ))}
430 + </div>
431 + )}
432 + <Footer left={`${HOST}${t.path}`} />
433 + </div>
434 + </Frame>,
435 + );
436 +}
modified docs/FRONTEND.md +61 −2
@@ -36,7 +36,7 @@ node apps/web/qa/flows.mjs [BASE] # live feed → drawer → company
36 36 | `/watchlist` | client (owner token) | `/watchlist`, `/alerts`, `/alerts/deliveries` |
37 37 | `/system`, `/methodology`, `/about`, `/api`, `/bot` | ISR / static | `/system`, `/stats`, `/stats/history`, `/methodology` |
38 38 | `/admin`, `/admin/[module]` (overview, connectors, sensors, companies, failures, queue, llm, reviews, quality, costs) | client, token in localStorage → `X-CA-Admin-Token` | `/admin/*` |
39 −| `robots.txt`, `sitemap.xml` (index) + `/sitemaps/sitemap/<id>.xml` (shards via `generateSitemaps`), `manifest.webmanifest`, `opengraph-image`, `icon.svg`, `apple-icon`, `icon-512` | metadata routes | `/sitemap?kind&page`, `/stats` |
39 +| `robots.txt`, `sitemap.xml` (index) + `/sitemaps/sitemap/<id>.xml` (shards via `generateSitemaps`), `manifest.webmanifest`, `opengraph-image` + `twitter-image` (root, `/company/[slug]`), `opengraph-image` (`/industry/[slug]`, `/country/[code]`), `icon.svg` — see **Brand** below | metadata routes | `/sitemap?kind&page`, `/stats`, `/companies/{slug}`, `/industries/{slug}`, `/countries/{code}` |
40 40
41 41 Not-found: `not-found.tsx` (dynamic segments check existence in their `layout.tsx`/page and call `notFound()`); `error.tsx`
42 42 re-applies the theme. No root `loading.tsx` (Next 16 soft-404 gotcha).
@@ -53,7 +53,10 @@ src/components layout/ (header, mobile tab bar, ⌘K dialog, theme, density, foo
53 53 events/ (row/list, drawer + context, evidence, filters, diff viewer) · live/ (SSE feed, counters)
54 54 company/ (header + density strip, company-logo (client, error → monogram), profile-panels (SourceChip, DescriptionAttribution,
55 55 KeyFactsPanel, CorporateStructurePanel, WikidataProducts), metric tiles, table/cards/mini list, tab panels, compare picker,
56 − watch button, watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (mark, logo)
56 + watch button, watchlist client) · rankings/ · admin/ (shell, modules) · brand/ (geometry — single source of the mark, mark, logo)
57 +src/lib/og.tsx share-image renderers (site / company / topic) used by the opengraph-image + twitter-image routes
58 +scripts/ build-icons.mjs (favicon set, PNG icons, standalone logo SVGs — `pnpm icons`)
59 +public/ favicon.ico · icon-192/512(.png) · icon-512-maskable.png · apple-touch-icon.png · logo*.svg (all generated, committed)
57 60 qa/ mock-api.mjs · screens.mjs · flows.mjs · screens/ (output, git-ignored)
58 61 ```
59 62
@@ -61,6 +64,62 @@ Design tokens live at the top of `src/app/globals.css` (canvas/surface/ink/rule/
61 64 per-event-type hues `--ev-*`, sensor tiers, series, map, brand plate, density variables) and are exposed to Tailwind through
62 65 `@theme inline`. Both themes are hand-tuned; default follows the system and `lib/prepaint.ts` sets `data-theme` before paint.
63 66
67 +## Brand (2026-09-13)
68 +
69 +**Mark — the "atlas plate".** A rounded square plate carrying a globe drawn as a sparse grid (ring, one meridian ellipse, two
70 +parallels) crossed by a strong equator — the timeline — that ends on a bright "now" node sitting on the horizon, with a short
71 +pulse trail behind it. One 64 × 64 geometry, three detail levels chosen by rendered size so nothing becomes a sub-pixel
72 +hairline: `tiny` ≤ 20 px (favicon 16: no parallels / halo, 5.2-unit strokes), `small` ≤ 40 px (favicon 32, header), `full`
73 +above (bevel hairline on the plate, halo ring). Colourways: dark plate `#0f1419` / ink `#f7f7f4` / live `#3fd07a` for light
74 +surfaces; light plate `#e8ebf1` / ink `#0a0d12` / live `#16a34a` for dark surfaces. In the UI the plate follows the theme through
75 +the `--brand-plate / --brand-ink / --brand-grid / --brand-accent` tokens in `globals.css` (dark plate on the light UI, light
76 +plate on the dark UI). Wordmark: Geist, "Company" regular in `--ink-2` + "Atlas" semibold in `--ink`, tracking −0.02 em.
77 +
78 +**Single source.** `src/components/brand/geometry.ts` (pure TS, no JSX) owns the path data, weights, palettes and
79 +`markShapes()` / `markSvgString()`. `mark.tsx` turns the shape list into React (`MarkArt`) or a data-URI `<img>` for Satori
80 +(`MarkImg`); `logo.tsx` exposes `LogoMark` (`plate` | `mono`), `WordmarkText`, `Wordmark` / `Logo`. `scripts/build-icons.mjs`
81 +imports the same `geometry.ts` (Node type stripping) so every exported file is generated from that one description.
82 +
83 +| File | What | How it is made |
84 +|---|---|---|
85 +| `src/app/icon.svg` → `/icon.svg` | favicon for SVG-capable browsers (tiny level, hard-coded colours) | generated |
86 +| `public/favicon.ico` | real ICO container, 16 (tiny) / 32 (small) / 48 (full) PNG-encoded entries, packed by hand | generated |
87 +| `public/icon-192.png`, `icon-512.png` | PWA icons (`purpose: any`, rounded plate on transparent) | generated |
88 +| `public/icon-512-maskable.png`, `apple-touch-icon.png` (180) | full-bleed plate (`radius: 0`), the OS applies its own mask; globe stays inside the safe zone | generated |
89 +| `public/logo-mark.svg` | the mark alone, dark plate | generated |
90 +| `public/logo-light.svg` / `logo-dark.svg` | lockup for light / dark backgrounds, transparent, **vector text** (Geist outlines, no font needed) | generated |
91 +| `public/logo.svg` | lockup on its own dark rounded card — safe on any background (press, READMEs) | generated |
92 +
93 +`layout.tsx` lists `icons.icon = [/favicon.ico (sizes any, image/x-icon), /icon.svg]` and `icons.apple = /apple-touch-icon.png`;
94 +`manifest.ts` references the PNGs. The former `apple-icon.tsx` and `icon-512/route.tsx` ImageResponse routes were removed.
95 +
96 +**Regenerate** (after touching `geometry.ts` or the wordmark): `pnpm icons` from `apps/web` (= `node scripts/build-icons.mjs`;
97 +`PLAYWRIGHT_MODULE` overrides the Playwright import, default `~/Desktop/uqo-eval/node_modules/playwright/index.mjs`). The script
98 +reads Geist-Regular / Geist-SemiBold TTFs from the `geist` package, parses `glyf` outlines itself, measures glyph positions
99 +(advances + kerning) in Chromium with the same font files, then renders each PNG at 1× with Playwright and packs the ICO
100 +(6-byte header, 16-byte directory entries, PNG payloads). Commit the outputs.
101 +
102 +**Share images** (`src/lib/og.tsx`, Satori via `next/og`, 1200 × 630, Geist TTFs loaded from the `geist` package at first use):
103 +- `renderSiteOg(stats)` — root `opengraph-image.tsx` / `twitter-image.tsx`: lockup, "Continuous corporate observation network",
104 + headline *The Live Atlas of Global Companies*, live counters (companies · sensors · observations · events from `/stats`,
105 + omitted when the API is down), URL bottom-left, "Hosted on MacLustr" bottom-right, orthographic globe (world-atlas land +
106 + 15° graticule through d3-geo) on the right.
107 +- `renderCompanyOg(detail)` — `/company/[slug]/opengraph-image.tsx` + `twitter-image.tsx`: company logo fetched **server-side**
108 + as a buffer (`fetchLogoDataUri`: candidates `profile.logo_url → icon_url → logo_url`, http(s) only, no IP literals / localhost
109 + except the API host, 2.5 s timeout, ≤ 1.5 MB, png/jpeg/svg only, 24 h data cache) with a monogram plate fallback; name
110 + (auto-sized), domain · country · industry chips, key facts (founded, employees, HQ — only what is sourced), three metric
111 + tiles (Activity Score, Hiring Momentum 30d, AI Adoption — "—" + "not enough monitored evidence" when missing), sensors ·
112 + events · changes, URL. Unknown slug → site image.
113 +- `renderTopicOg()` — `/industry/[slug]` and `/country/[code]` `opengraph-image.tsx`: eyebrow, name, description / region,
114 + up to eight monitored company chips, four counters.
115 +- Satori rules: every box `display:flex`; never several text children in one div (wrap in spans); images as data URIs; no
116 + `runtime` re-export between sibling routes (thin route files only export `alt` / `size` / `contentType` / default).
117 +
118 +**Credits.** Footer bottom line on every page (13 px, `--ink-2`): "Built by Simon-Pierre Boucher · contact@spboucher.ai · Hosted on
119 +MacLustr — www.maclustr.io" (`mailto:`, `https://www.maclustr.io` with `rel="noopener noreferrer"`), constants in `lib/site.ts`
120 +(`AUTHOR_NAME`, `CONTACT_EMAIL`, `HOST_NAME`, `HOST_URL`). `/about#who-builds-it` — "Who builds Company Atlas": built by, contact,
121 +hosting on the MacLustr cluster (link), data links, and the SVG logo files for press.
122 +
64 123 ## Careful language, enforced in the UI
65 124
66 125 `no_longer_listed` → "no longer listed"; job decreases say "listings are no longer visible … not evidence of layoffs";
67 126