Apply website QA: light theme, clearer contact form, office emails

- Add a light theme with a header toggle; the choice is remembered and applied before paint
- Make the globe, product visuals and footer logo follow the theme
- Give the message field a full outline, strengthen the captcha box border and keep autofilled fields readable
- Accept website addresses without https://
- List the info email under every office
- Keep the phone header on one row: languages move into the menu below 480px
This commit is contained in:
Alejandro Veras
2026-10-16 11:56:10 -04:00
parent 50c5868da6
commit 2d7e7f2fe1
15 changed files with 240 additions and 54 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

+9 -1
View File
@@ -5,6 +5,7 @@ import "../globals.css";
import SiteHeader from "@/components/site-header"; import SiteHeader from "@/components/site-header";
import SiteFooter from "@/components/site-footer"; import SiteFooter from "@/components/site-footer";
import Analytics, { AnalyticsNoScript } from "@/components/analytics"; import Analytics, { AnalyticsNoScript } from "@/components/analytics";
import { themeScript } from "@/components/theme-toggle";
import { dirOf, locales, localize, ogLocale } from "@/i18n/config"; import { dirOf, locales, localize, ogLocale } from "@/i18n/config";
import { mainNav } from "@/i18n/nav"; import { mainNav } from "@/i18n/nav";
import { getUI } from "@/i18n/server"; import { getUI } from "@/i18n/server";
@@ -51,7 +52,7 @@ export async function generateMetadata(): Promise<Metadata> {
export const viewport: Viewport = { export const viewport: Viewport = {
themeColor: "#000000", themeColor: "#000000",
colorScheme: "dark", colorScheme: "dark light",
}; };
export default async function RootLayout({ children }: LayoutProps<"/[lang]">) { export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
@@ -63,6 +64,8 @@ export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
closeMenu: t.nav.closeMenu, closeMenu: t.nav.closeMenu,
language: t.nav.language, language: t.nav.language,
logoHome: t.logoHome, logoHome: t.logoHome,
lightTheme: t.nav.lightTheme,
darkTheme: t.nav.darkTheme,
}; };
return ( return (
@@ -71,7 +74,12 @@ export default async function RootLayout({ children }: LayoutProps<"/[lang]">) {
dir={dirOf(locale)} dir={dirOf(locale)}
className={`${inter.variable} ${arabic.variable}`} className={`${inter.variable} ${arabic.variable}`}
data-scroll-behavior="smooth" data-scroll-behavior="smooth"
// The theme script sets data-theme before React hydrates.
suppressHydrationWarning
> >
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<body> <body>
<AnalyticsNoScript /> <AnalyticsNoScript />
<a href="#main" className="skip-link"> <a href="#main" className="skip-link">
+133 -33
View File
@@ -13,6 +13,13 @@
--fumo: #8f8f8f; --fumo: #8f8f8f;
--piombo: #666666; --piombo: #666666;
--argento: #d2d2d2; --argento: #d2d2d2;
/* The names keep their roles in both themes: bianco is ink, nero is paper. */
--ink-rgb: 255, 255, 255;
--paper-rgb: 0, 0, 0;
--cap-line: #262626;
--panel: #070707;
--panel-line: #1c1c1c;
color-scheme: dark;
--font-sans: var(--font-inter), "Helvetica Neue", Arial, sans-serif; --font-sans: var(--font-inter), "Helvetica Neue", Arial, sans-serif;
@@ -28,6 +35,23 @@
--dir: -1; --dir: -1;
} }
/* Light theme, chosen with the header toggle. */
:root[data-theme="light"] {
--bianco: #0a0a0a;
--nero: #ffffff;
--notte: #f4f4f2;
--grafite: #d9d9d6;
--fumo: #5c5c5c;
--piombo: #808080;
--argento: #333333;
--ink-rgb: 10, 10, 10;
--paper-rgb: 255, 255, 255;
--cap-line: #e2e2e2;
--panel: #f7f7f5;
--panel-line: #e4e4e1;
color-scheme: light;
}
*, *,
*::before, *::before,
*::after { *::after {
@@ -55,7 +79,7 @@ body {
::selection { ::selection {
background: var(--rosso); background: var(--rosso);
color: var(--bianco); color: #fff;
} }
a { a {
@@ -220,7 +244,7 @@ a {
.btn-ghost { .btn-ghost {
background: transparent; background: transparent;
color: var(--bianco); color: var(--bianco);
border-color: rgba(255, 255, 255, 0.35); border-color: rgba(var(--ink-rgb), 0.35);
} }
.btn-ghost:hover { .btn-ghost:hover {
@@ -261,7 +285,7 @@ a {
align-items: center; align-items: center;
height: 30px; height: 30px;
padding: 0 14px; padding: 0 14px;
border: 1px solid rgba(255, 255, 255, 0.28); border: 1px solid rgba(var(--ink-rgb), 0.28);
border-radius: 9999px; border-radius: 9999px;
font-size: 10px; font-size: 10px;
font-weight: 500; font-weight: 500;
@@ -303,7 +327,7 @@ a {
} }
.site-header.is-scrolled { .site-header.is-scrolled {
background: rgba(0, 0, 0, 0.82); background: rgba(var(--paper-rgb), 0.82);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border-color: var(--grafite); border-color: var(--grafite);
@@ -350,6 +374,7 @@ a {
} }
.logo-type b { .logo-type b {
white-space: nowrap;
font-size: 14px; font-size: 14px;
font-weight: 600; font-weight: 600;
letter-spacing: 0.2em; letter-spacing: 0.2em;
@@ -376,7 +401,7 @@ a {
font-weight: 500; font-weight: 500;
letter-spacing: 0.2em; letter-spacing: 0.2em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(255, 255, 255, 0.78); color: rgba(var(--ink-rgb), 0.78);
transition: color 0.3s var(--ease); transition: color 0.3s var(--ease);
} }
@@ -512,9 +537,9 @@ a {
inset: 0; inset: 0;
z-index: -1; z-index: -1;
background: background:
linear-gradient(180deg, rgba(0, 0, 0, 0.55) 0%, transparent 22%), linear-gradient(180deg, rgba(var(--paper-rgb), 0.55) 0%, transparent 22%),
linear-gradient(0deg, #000 4%, rgba(0, 0, 0, 0.6) 34%, transparent 62%), linear-gradient(0deg, var(--nero) 4%, rgba(var(--paper-rgb), 0.6) 34%, transparent 62%),
linear-gradient(90deg, rgba(0, 0, 0, 0.7) 0%, transparent 55%); linear-gradient(90deg, rgba(var(--paper-rgb), 0.7) 0%, transparent 55%);
pointer-events: none; pointer-events: none;
} }
@@ -858,13 +883,13 @@ a {
} }
.cap-map line { .cap-map line {
stroke: #262626; stroke: var(--cap-line);
stroke-width: 1; stroke-width: 1;
transition: stroke 0.5s var(--ease); transition: stroke 0.5s var(--ease);
} }
.cap-map line.on { .cap-map line.on {
stroke: rgba(255, 255, 255, 0.6); stroke: rgba(var(--ink-rgb), 0.6);
} }
.cap-map .node { .cap-map .node {
@@ -882,7 +907,7 @@ a {
.cap-map .core { .cap-map .core {
fill: var(--nero); fill: var(--nero);
stroke: rgba(255, 255, 255, 0.4); stroke: rgba(var(--ink-rgb), 0.4);
} }
.cap-map text { .cap-map text {
@@ -1002,8 +1027,8 @@ a {
grid-column: 1 / span 6; grid-column: 1 / span 6;
position: relative; position: relative;
aspect-ratio: 600 / 412; aspect-ratio: 600 / 412;
background: #070707; background: var(--panel);
outline: 1px solid #1c1c1c; outline: 1px solid var(--panel-line);
overflow: hidden; overflow: hidden;
} }
@@ -1071,7 +1096,7 @@ a {
/* journey viz */ /* journey viz */
.viz-journey .path { .viz-journey .path {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.16); stroke: rgba(var(--ink-rgb), 0.16);
stroke-width: 1; stroke-width: 1;
} }
@@ -1099,7 +1124,7 @@ a {
.viz-journey .node { .viz-journey .node {
fill: var(--nero); fill: var(--nero);
stroke: rgba(255, 255, 255, 0.6); stroke: rgba(var(--ink-rgb), 0.6);
} }
.viz-journey .goal { .viz-journey .goal {
@@ -1117,11 +1142,11 @@ a {
/* radar viz */ /* radar viz */
.viz-radar .ring { .viz-radar .ring {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.12); stroke: rgba(var(--ink-rgb), 0.12);
} }
.viz-radar .axis { .viz-radar .axis {
stroke: rgba(255, 255, 255, 0.07); stroke: rgba(var(--ink-rgb), 0.07);
} }
.viz-radar .sweep { .viz-radar .sweep {
@@ -1160,12 +1185,12 @@ a {
/* grid viz */ /* grid viz */
.viz-grid .cell { .viz-grid .cell {
fill: rgba(255, 255, 255, 0.035); fill: rgba(var(--ink-rgb), 0.035);
stroke: rgba(255, 255, 255, 0.08); stroke: rgba(var(--ink-rgb), 0.08);
} }
.viz-grid .cell.on { .viz-grid .cell.on {
fill: rgba(255, 255, 255, 0.85); fill: rgba(var(--ink-rgb), 0.85);
stroke: none; stroke: none;
animation: cell 4.8s var(--ease) infinite; animation: cell 4.8s var(--ease) infinite;
animation-delay: var(--t); animation-delay: var(--t);
@@ -1201,7 +1226,7 @@ a {
.viz-grid .frame { .viz-grid .frame {
fill: none; fill: none;
stroke: rgba(255, 255, 255, 0.5); stroke: rgba(var(--ink-rgb), 0.5);
} }
@media (max-width: 1023px) { @media (max-width: 1023px) {
@@ -1290,7 +1315,7 @@ a {
place-items: center; place-items: center;
width: 96px; width: 96px;
height: 96px; height: 96px;
border: 1px solid rgba(255, 255, 255, 0.35); border: 1px solid rgba(var(--ink-rgb), 0.35);
border-radius: 9999px; border-radius: 9999px;
font-size: 9px; font-size: 9px;
font-weight: 600; font-weight: 600;
@@ -1360,7 +1385,7 @@ a {
} }
.journey ol::after { .journey ol::after {
background: linear-gradient(90deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(90deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform: scaleX(0); transform: scaleX(0);
transform-origin: left; transform-origin: left;
transition: transform 2.6s var(--ease) 0.2s; transition: transform 2.6s var(--ease) 0.2s;
@@ -1434,7 +1459,7 @@ a {
height: auto; height: auto;
} }
.journey ol::after { .journey ol::after {
background: linear-gradient(180deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(180deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform: scaleY(0); transform: scaleY(0);
transform-origin: top; transform-origin: top;
} }
@@ -1542,7 +1567,7 @@ a {
} }
.gaps .break line { .gaps .break line {
stroke: rgba(255, 255, 255, 0.45); stroke: rgba(var(--ink-rgb), 0.45);
} }
.gaps .break .cut { .gaps .break .cut {
@@ -1635,7 +1660,7 @@ a {
position: absolute; position: absolute;
inset: 0; inset: 0;
z-index: -1; z-index: -1;
background: radial-gradient(ellipse at center, rgba(0, 0, 0, 0.6) 0%, rgba(0, 0, 0, 0.9) 55%, #000 80%); background: radial-gradient(ellipse at center, rgba(var(--paper-rgb), 0.6) 0%, rgba(var(--paper-rgb), 0.9) 55%, var(--nero) 80%);
pointer-events: none; pointer-events: none;
} }
@@ -1758,7 +1783,7 @@ a {
line-height: 0.9; line-height: 0.9;
letter-spacing: -0.05em; letter-spacing: -0.05em;
text-transform: uppercase; text-transform: uppercase;
color: rgba(255, 255, 255, 0.07); color: rgba(var(--ink-rgb), 0.07);
white-space: nowrap; white-space: nowrap;
user-select: none; user-select: none;
} }
@@ -1873,7 +1898,7 @@ a {
.nav-panel-inner { .nav-panel-inner {
padding: 22px 28px 26px; padding: 22px 28px 26px;
background: rgba(0, 0, 0, 0.94); background: rgba(var(--paper-rgb), 0.94);
backdrop-filter: blur(14px); backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px); -webkit-backdrop-filter: blur(14px);
border: 1px solid var(--grafite); border: 1px solid var(--grafite);
@@ -2115,7 +2140,7 @@ a {
} }
.link-card:hover { .link-card:hover {
background: rgba(255, 255, 255, 0.03); background: rgba(var(--ink-rgb), 0.03);
} }
.link-card:hover .arrow-link { .link-card:hover .arrow-link {
@@ -2516,11 +2541,28 @@ a {
resize: vertical; resize: vertical;
} }
/* The message gets a full outline so it reads as a box to type in. */
.field textarea {
min-height: 132px;
padding: 12px 14px;
border: 1px solid var(--grafite);
}
.field input:focus, .field input:focus,
.field textarea:focus { .field textarea:focus {
border-color: var(--rosso); border-color: var(--rosso);
} }
/* Browser autofill paints its own pale background; keep the field on-theme and readable. */
.field input:-webkit-autofill,
.field input:-webkit-autofill:hover,
.field input:-webkit-autofill:focus {
-webkit-text-fill-color: var(--bianco);
caret-color: var(--bianco);
-webkit-box-shadow: 0 0 0 1000px var(--nero) inset;
transition: background-color 9999s;
}
.field input::placeholder { .field input::placeholder {
color: var(--piombo); color: var(--piombo);
} }
@@ -2680,6 +2722,64 @@ a {
color: var(--bianco); color: var(--bianco);
} }
.theme-toggle {
display: inline-grid;
place-items: center;
width: 36px;
height: 36px;
flex: none;
padding: 0;
border: 1px solid var(--grafite);
border-radius: 50%;
background: transparent;
color: var(--bianco);
cursor: pointer;
transition: border-color 0.3s var(--ease);
}
.theme-toggle:hover {
border-color: var(--bianco);
}
.theme-toggle svg {
width: 16px;
height: 16px;
fill: none;
stroke: currentColor;
stroke-width: 1.5;
stroke-linecap: round;
}
/* Shows the theme you'd switch to: a sun on dark pages, a moon on light ones. */
.theme-toggle .moon,
:root[data-theme="light"] .theme-toggle .sun {
display: none;
}
:root[data-theme="light"] .theme-toggle .moon {
display: block;
}
/* Phones: the languages move into the menu (it carries its own switch) so the logo has room. */
@media (max-width: 480px) {
.site-header .lang-switch {
display: none;
}
.site-header .theme-toggle {
margin-inline-start: auto;
}
}
/* The stacked logo's wordmark is white; the light theme swaps in the dark-lettered file. */
.logo-stacked .on-light,
:root[data-theme="light"] .logo-stacked .on-dark {
display: none;
}
:root[data-theme="light"] .logo-stacked .on-light {
display: block;
}
.lang-switch [aria-current] { .lang-switch [aria-current] {
color: var(--bianco); color: var(--bianco);
box-shadow: inset 0 -1px 0 var(--rosso); box-shadow: inset 0 -1px 0 var(--rosso);
@@ -2706,7 +2806,7 @@ a {
gap: 16px; gap: 16px;
max-width: 340px; max-width: 340px;
padding: 14px 18px; padding: 14px 18px;
border: 1px solid var(--grafite); border: 1px solid var(--piombo);
transition: border-color 0.3s var(--ease); transition: border-color 0.3s var(--ease);
} }
@@ -2739,7 +2839,7 @@ a {
width: 24px; width: 24px;
height: 24px; height: 24px;
flex: none; flex: none;
border: 1px solid var(--fumo); border: 1.5px solid var(--argento);
transition: transition:
border-color 0.3s var(--ease), border-color 0.3s var(--ease),
background-color 0.3s var(--ease); background-color 0.3s var(--ease);
@@ -2754,7 +2854,7 @@ a {
width: 18px; width: 18px;
height: 18px; height: 18px;
fill: none; fill: none;
stroke: var(--bianco); stroke: #fff;
stroke-width: 1.8; stroke-width: 1.8;
stroke-dasharray: 16; stroke-dasharray: 16;
stroke-dashoffset: 16; stroke-dashoffset: 16;
@@ -2810,7 +2910,7 @@ a {
} }
[dir="rtl"] .journey ol::after { [dir="rtl"] .journey ol::after {
background: linear-gradient(270deg, rgba(255, 255, 255, 0.7), var(--rosso)); background: linear-gradient(270deg, rgba(var(--ink-rgb), 0.7), var(--rosso));
transform-origin: right; transform-origin: right;
} }
+6 -2
View File
@@ -98,9 +98,13 @@ export default function ContactForm({
<span>{t.website}</span> <span>{t.website}</span>
<input <input
name="website" name="website"
type="url" // Plain text, not type="url": people write "company.com" and shouldn't be made to add https://.
type="text"
inputMode="url" inputMode="url"
placeholder="https://" autoComplete="url"
autoCapitalize="none"
spellCheck={false}
placeholder="company.com"
maxLength={300} maxLength={300}
defaultValue={values?.website} defaultValue={values?.website}
dir="ltr" dir="ltr"
+17 -6
View File
@@ -59,6 +59,10 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches; const reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
// Dots and lines use the theme's ink colour, re-read whenever the theme changes.
const readInk = () => getComputedStyle(document.documentElement).getPropertyValue("--ink-rgb").trim() || "255, 255, 255";
let ink = readInk();
// Fibonacci sphere // Fibonacci sphere
const COUNT = 1500; const COUNT = 1500;
const points: Vec[] = []; const points: Vec[] = [];
@@ -159,15 +163,15 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
// soft halo // soft halo
const halo = ctx.createRadialGradient(cx, cy, R * 0.7, cx, cy, R * 1.45); const halo = ctx.createRadialGradient(cx, cy, R * 0.7, cx, cy, R * 1.45);
halo.addColorStop(0, "rgba(255,255,255,0.045)"); halo.addColorStop(0, `rgba(${ink},0.045)`);
halo.addColorStop(1, "rgba(255,255,255,0)"); halo.addColorStop(1, `rgba(${ink},0)`);
ctx.fillStyle = halo; ctx.fillStyle = halo;
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
// outline // outline
ctx.beginPath(); ctx.beginPath();
ctx.arc(cx, cy, R * 1.0, 0, Math.PI * 2); ctx.arc(cx, cy, R * 1.0, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255,255,255,0.08)"; ctx.strokeStyle = `rgba(${ink},0.08)`;
ctx.lineWidth = 1; ctx.lineWidth = 1;
ctx.stroke(); ctx.stroke();
@@ -177,7 +181,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const front = p.z > 0; const front = p.z > 0;
const a = front ? 0.18 + p.z * 0.62 : 0.05 + (1 + p.z) * 0.05; const a = front ? 0.18 + p.z * 0.62 : 0.05 + (1 + p.z) * 0.05;
const size = (front ? 1.15 : 0.8) * p.s; const size = (front ? 1.15 : 0.8) * p.s;
ctx.fillStyle = `rgba(255,255,255,${a.toFixed(3)})`; ctx.fillStyle = `rgba(${ink},${a.toFixed(3)})`;
ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size); ctx.fillRect(p.x - size / 2, p.y - size / 2, size, size);
} }
@@ -190,7 +194,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
const b = proj[s]; const b = proj[s];
const vis = (a.z + b.z) / 2; const vis = (a.z + b.z) / 2;
if (vis < -0.15) continue; if (vis < -0.15) continue;
ctx.strokeStyle = `rgba(255,255,255,${(0.08 + Math.max(0, vis) * 0.22).toFixed(3)})`; ctx.strokeStyle = `rgba(${ink},${(0.08 + Math.max(0, vis) * 0.22).toFixed(3)})`;
ctx.beginPath(); ctx.beginPath();
ctx.moveTo(a.x, a.y); ctx.moveTo(a.x, a.y);
ctx.lineTo(b.x, b.y); ctx.lineTo(b.x, b.y);
@@ -224,7 +228,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
ctx.beginPath(); ctx.beginPath();
ctx.arc(p.x, p.y, 3 + pulse * 14, 0, Math.PI * 2); ctx.arc(p.x, p.y, 3 + pulse * 14, 0, Math.PI * 2);
ctx.stroke(); ctx.stroke();
ctx.fillStyle = `rgba(255,255,255,${(0.4 + p.z * 0.6).toFixed(3)})`; ctx.fillStyle = `rgba(${ink},${(0.4 + p.z * 0.6).toFixed(3)})`;
ctx.fillRect(p.x - 2.5, p.y - 2.5, 5, 5); ctx.fillRect(p.x - 2.5, p.y - 2.5, 5, 5);
} }
@@ -243,6 +247,12 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
}); });
ro.observe(canvas); ro.observe(canvas);
const themeObserver = new MutationObserver(() => {
ink = readInk();
if (reduced) draw(performance.now());
});
themeObserver.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
const io = new IntersectionObserver(([entry]) => { const io = new IntersectionObserver(([entry]) => {
const visible = entry.isIntersecting; const visible = entry.isIntersecting;
if (visible && !running) { if (visible && !running) {
@@ -263,6 +273,7 @@ export default function GlobeCanvas({ layout = "hero", className = "hero-canvas"
running = false; running = false;
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
ro.disconnect(); ro.disconnect();
themeObserver.disconnect();
io.disconnect(); io.disconnect();
window.removeEventListener("pointermove", onMove); window.removeEventListener("pointermove", onMove);
}; };
+2 -1
View File
@@ -16,7 +16,8 @@ export default function Logo({ href, label }: { href: string; label: string }) {
export function LogoStacked({ href, label }: { href: string; label: string }) { export function LogoStacked({ href, label }: { href: string; label: string }) {
return ( return (
<Link href={href} className="logo-stacked" aria-label={label}> <Link href={href} className="logo-stacked" aria-label={label}>
<Image src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} /> <Image className="on-dark" src="/logo-stacked.png" alt="AI Growth Technologies" width={480} height={547} />
<Image className="on-light" src="/logo-stacked-dark.png" alt="AI Growth Technologies" width={480} height={547} />
</Link> </Link>
); );
} }
+2 -2
View File
@@ -25,7 +25,7 @@ function JourneyViz({ labels }: { labels: VisualLabels }) {
return ( return (
<svg className="viz viz-journey" viewBox={`0 0 ${W} ${H}`} aria-hidden="true"> <svg className="viz viz-journey" viewBox={`0 0 ${W} ${H}`} aria-hidden="true">
{[180, 280, 380].map((x) => ( {[180, 280, 380].map((x) => (
<line key={x} x1={x} y1={28} x2={x} y2={H - 28} stroke="rgba(255,255,255,0.05)" /> <line key={x} x1={x} y1={28} x2={x} y2={H - 28} style={{ stroke: "rgba(var(--ink-rgb), 0.05)" }} />
))} ))}
{sources.map((s, i) => { {sources.map((s, i) => {
const y = top + i * gap; const y = top + i * gap;
@@ -98,7 +98,7 @@ function RadarViz({ labels }: { labels: VisualLabels }) {
))} ))}
<g className="sweep"> <g className="sweep">
<path d={`M${cx} ${cy} L${cx + sweepR} ${cy} A${sweepR} ${sweepR} 0 0 0 ${ex} ${ey} Z`} fill="url(#sweepGrad)" /> <path d={`M${cx} ${cy} L${cx + sweepR} ${cy} A${sweepR} ${sweepR} 0 0 0 ${ex} ${ey} Z`} fill="url(#sweepGrad)" />
<line x1={cx} y1={cy} x2={cx + sweepR} y2={cy} stroke="rgba(255,255,255,0.6)" /> <line x1={cx} y1={cy} x2={cx + sweepR} y2={cy} style={{ stroke: "rgba(var(--ink-rgb), 0.6)" }} />
</g> </g>
{dots.map((d, i) => { {dots.map((d, i) => {
const x = cx + Math.cos(rad(d.a)) * d.r; const x = cx + Math.cos(rad(d.a)) * d.r;
+4
View File
@@ -4,6 +4,7 @@ import Link from "next/link";
import { usePathname } from "next/navigation"; import { usePathname } from "next/navigation";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Logo from "./logo"; import Logo from "./logo";
import ThemeToggle from "./theme-toggle";
import { localeNames, localeShort, locales, localize, splitLocale, type Locale } from "@/i18n/config"; import { localeNames, localeShort, locales, localize, splitLocale, type Locale } from "@/i18n/config";
import type { NavItem } from "@/i18n/nav"; import type { NavItem } from "@/i18n/nav";
@@ -14,6 +15,8 @@ export type HeaderLabels = {
closeMenu: string; closeMenu: string;
language: string; language: string;
logoHome: string; logoHome: string;
lightTheme: string;
darkTheme: string;
}; };
function LanguageSwitch({ locale, path, label }: { locale: Locale; path: string; label: string }) { function LanguageSwitch({ locale, path, label }: { locale: Locale; path: string; label: string }) {
@@ -120,6 +123,7 @@ export default function SiteHeader({ locale, nav, t }: { locale: Locale; nav: Na
)} )}
</nav> </nav>
<LanguageSwitch locale={locale} path={path} label={t.language} /> <LanguageSwitch locale={locale} path={path} label={t.language} />
<ThemeToggle t={t} />
<Link href={contactHref} className="btn btn-solid header-cta"> <Link href={contactHref} className="btn btn-solid header-cta">
{t.bookCall} <span className="arrow">→</span> {t.bookCall} <span className="arrow">→</span>
</Link> </Link>
+52
View File
@@ -0,0 +1,52 @@
"use client";
import { useSyncExternalStore } from "react";
export type Theme = "dark" | "light";
// The site is dark by default; a visitor's choice is remembered in localStorage under this key.
const KEY = "theme";
/** Runs in <head> before the page paints, so a saved light theme never flashes dark first. */
export const themeScript = `try{if(localStorage.getItem("${KEY}")==="light")document.documentElement.dataset.theme="light"}catch(e){}`;
const read = (): Theme => (document.documentElement.dataset.theme === "light" ? "light" : "dark");
function subscribe(onChange: () => void) {
const observer = new MutationObserver(onChange);
observer.observe(document.documentElement, { attributes: true, attributeFilter: ["data-theme"] });
return () => observer.disconnect();
}
/** The current theme, kept in sync with <html data-theme>. */
export function useTheme(): Theme {
return useSyncExternalStore(subscribe, read, () => "dark");
}
export default function ThemeToggle({ t }: { t: { lightTheme: string; darkTheme: string } }) {
const theme = useTheme();
const label = theme === "dark" ? t.lightTheme : t.darkTheme;
function toggle() {
const next: Theme = theme === "dark" ? "light" : "dark";
if (next === "light") document.documentElement.dataset.theme = "light";
else delete document.documentElement.dataset.theme;
try {
localStorage.setItem(KEY, next);
} catch {
// Storage can be blocked (private mode); the theme still switches for this visit.
}
}
return (
<button type="button" className="theme-toggle" onClick={toggle} aria-label={label} title={label}>
<svg className="sun" viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="4.2" />
<path d="M12 2.5v2.2M12 19.3v2.2M2.5 12h2.2M19.3 12h2.2M5.3 5.3l1.6 1.6M17.1 17.1l1.6 1.6M5.3 18.7l1.6-1.6M17.1 6.9l1.6-1.6" />
</svg>
<svg className="moon" viewBox="0 0 24 24" aria-hidden="true">
<path d="M20 14.6A8.5 8.5 0 0 1 9.4 4a8.5 8.5 0 1 0 10.6 10.6z" />
</svg>
</button>
);
}
+3 -3
View File
@@ -128,7 +128,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة للشركات في تكساس وخارجها.", "نقطة تواصل في الولايات المتحدة للشركات في تكساس وخارجها.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة مقرها جنوب فلوريدا.", "نقطة تواصل في الولايات المتحدة مقرها جنوب فلوريدا.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "مكتب شريك", "subtitle": "مكتب شريك",
"body": [ "body": [
"نقطة تواصل في الولايات المتحدة في أحد أبرز أسواق الأعمال في العالم.", "نقطة تواصل في الولايات المتحدة في أحد أبرز أسواق الأعمال في العالم.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+3 -3
View File
@@ -128,7 +128,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact for businesses across Texas and beyond.", "A US point of contact for businesses across Texas and beyond.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact based in South Florida.", "A US point of contact based in South Florida.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "Partner Office", "subtitle": "Partner Office",
"body": [ "body": [
"A US point of contact in one of the world’s leading business markets.", "A US point of contact in one of the world’s leading business markets.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+3 -3
View File
@@ -128,7 +128,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. para empresas de Texas y más allá.", "Un punto de contacto en EE. UU. para empresas de Texas y más allá.",
"Downtown Houston, TX, USA\nPhone: +1 832-350-0465" "Downtown Houston, TX, USA\nPhone: +1 832-350-0465\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -139,7 +139,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. ubicado en el sur de Florida.", "Un punto de contacto en EE. UU. ubicado en el sur de Florida.",
"Brickell Avenue, FL, USA\nPhone: +1 954-235-4974" "Brickell Avenue, FL, USA\nPhone: +1 954-235-4974\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
@@ -150,7 +150,7 @@
"subtitle": "Oficina asociada", "subtitle": "Oficina asociada",
"body": [ "body": [
"Un punto de contacto en EE. UU. en uno de los mercados de negocios más importantes del mundo.", "Un punto de contacto en EE. UU. en uno de los mercados de negocios más importantes del mundo.",
"Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577" "Manhattan Financial District, NY, USA\nPhone: +1 713-289-4577\nEmail: info@aigrowthtechnologies.com"
], ],
"list": [], "list": [],
"cta": null "cta": null
+2
View File
@@ -19,6 +19,8 @@ export const ar: UI = {
openMenu: "فتح القائمة", openMenu: "فتح القائمة",
closeMenu: "إغلاق القائمة", closeMenu: "إغلاق القائمة",
language: "اللغة", language: "اللغة",
lightTheme: "التبديل إلى الوضع الفاتح",
darkTheme: "التبديل إلى الوضع الداكن",
}, },
bookCall: "احجز مكالمة استراتيجية", bookCall: "احجز مكالمة استراتيجية",
services: { services: {
+2
View File
@@ -19,6 +19,8 @@ export const en = {
openMenu: "Open menu", openMenu: "Open menu",
closeMenu: "Close menu", closeMenu: "Close menu",
language: "Language", language: "Language",
lightTheme: "Switch to light theme",
darkTheme: "Switch to dark theme",
}, },
bookCall: "Book a Strategy Call", bookCall: "Book a Strategy Call",
services: { services: {
+2
View File
@@ -19,6 +19,8 @@ export const es: UI = {
openMenu: "Abrir menú", openMenu: "Abrir menú",
closeMenu: "Cerrar menú", closeMenu: "Cerrar menú",
language: "Idioma", language: "Idioma",
lightTheme: "Cambiar a tema claro",
darkTheme: "Cambiar a tema oscuro",
}, },
bookCall: "Agenda una llamada estratégica", bookCall: "Agenda una llamada estratégica",
services: { services: {