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>
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 | |