From 4ad126bf9d6663091b069bc2f894deb0b6547ed0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 18:55:04 +0000 Subject: [PATCH 1/5] feat: add public GitHub projects to Work page; fix localized SEO URLs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surface Santiago's public GitHub repositories on the Work page as a new "Open work on GitHub" section below the featured Directa case study. Four verified public projects are added to a `projects` array in the single source of truth (site.ts), with EN/ES copy derived from each repo's own description — no invented content. Each renders as a neo-brutalist card (language swatch, topic tags, WIP badge, "View on GitHub" link), and the GitHub profile is wired into the footer, the contact panel and the Person JSON-LD `sameAs`. Private repositories are intentionally excluded. While here, fix two pre-existing SEO defects surfaced by an audit: - Canonical, hreflang alternates and sitemap.xml were built from internal route paths, so every Spanish sub-page pointed at /es/work, /es/about… which 404 (the real paths are /es/trabajo, /es/sobre-mi…). Both now derive each locale's real public URL via next-intl getPathname and add an x-default alternate. - Person JSON-LD emitted an unverified (pending) role as jobTitle, i.e. a hard claim with no "pending" badge; it is now omitted until confirmed. Also localize two hardcoded Directa strings (the "Gallery" heading and the case-study meta title) that shipped in English on the Spanish site. Verified: typecheck, lint and build pass; a production server confirms the projects render in both locales, sitemap emits localized URLs, and /es/trabajo returns 200 with correct canonical/hreflang/x-default. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HGqP6dAJYGsYKiX4TnR5Dh --- messages/en.json | 11 ++- messages/es.json | 11 ++- src/app/[locale]/work/directa/page.tsx | 5 +- src/app/[locale]/work/page.tsx | 86 +++++++++++++++++++++++- src/app/sitemap.ts | 16 +++-- src/components/layout/Footer.tsx | 7 ++ src/components/sections/ContactPanel.tsx | 10 +++ src/content/site.ts | 75 ++++++++++++++++++++- src/lib/seo.ts | 36 +++++++--- 9 files changed, 232 insertions(+), 25 deletions(-) diff --git a/messages/en.json b/messages/en.json index 98959cf..4a983d7 100644 --- a/messages/en.json +++ b/messages/en.json @@ -53,12 +53,17 @@ }, "work": { "title": "Work", - "lede": "One featured project for now. New case studies will appear here as they are ready to be told properly.", + "lede": "A featured case study, plus the personal projects and open code I build on GitHub.", "viewCase": "View case study", "visit": "Visit live site", "role": "Role", "year": "Year", - "stack": "Stack" + "stack": "Stack", + "projectsEyebrow": "Code", + "projectsHeading": "Open work on GitHub", + "projectsLede": "Personal projects and experiments — quant systems, security tooling, AI agents and the code behind this site.", + "viewOnGithub": "View on GitHub", + "wip": "Work in progress" }, "directa": { "eyebrow": "Case study", @@ -70,6 +75,7 @@ "design": "Design decisions", "features": "Features delivered", "outcome": "Outcome", + "gallery": "Gallery", "visit": "Visit directa.mx" }, "services": { @@ -86,6 +92,7 @@ "email": "Email", "whatsapp": "WhatsApp", "linkedin": "LinkedIn", + "github": "GitHub", "calendar": "Book a call", "cv": "Download CV", "copy": "Copy", diff --git a/messages/es.json b/messages/es.json index 1524d34..081256f 100644 --- a/messages/es.json +++ b/messages/es.json @@ -53,12 +53,17 @@ }, "work": { "title": "Trabajo", - "lede": "Un proyecto destacado por ahora. Los próximos casos aparecerán aquí cuando estén listos para contarse bien.", + "lede": "Un caso destacado, más los proyectos personales y el código abierto que construyo en GitHub.", "viewCase": "Ver caso", "visit": "Visitar el sitio", "role": "Rol", "year": "Año", - "stack": "Stack" + "stack": "Stack", + "projectsEyebrow": "Código", + "projectsHeading": "Trabajo abierto en GitHub", + "projectsLede": "Proyectos y experimentos personales — sistemas cuantitativos, herramientas de seguridad, agentes de IA y el código detrás de este sitio.", + "viewOnGithub": "Ver en GitHub", + "wip": "En progreso" }, "directa": { "eyebrow": "Caso de estudio", @@ -70,6 +75,7 @@ "design": "Decisiones de diseño", "features": "Funcionalidades entregadas", "outcome": "Resultado", + "gallery": "Galería", "visit": "Visitar directa.mx" }, "services": { @@ -86,6 +92,7 @@ "email": "Email", "whatsapp": "WhatsApp", "linkedin": "LinkedIn", + "github": "GitHub", "calendar": "Reservar llamada", "cv": "Descargar CV", "copy": "Copiar", diff --git a/src/app/[locale]/work/directa/page.tsx b/src/app/[locale]/work/directa/page.tsx index 029ba38..26b1378 100644 --- a/src/app/[locale]/work/directa/page.tsx +++ b/src/app/[locale]/work/directa/page.tsx @@ -8,9 +8,10 @@ import { buildMetadata } from '@/lib/seo'; export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params; + const t = await getTranslations({ locale, namespace: 'directa' }); return buildMetadata({ locale: locale as 'en' | 'es', - title: `Directa — Case study — Santiago Rivera`, + title: `Directa — ${t('eyebrow')} — Santiago Rivera`, description: directa.context.value[locale as 'en' | 'es'], path: '/work/directa', }); @@ -133,7 +134,7 @@ export default async function DirectaCase({ params }: { params: Promise<{ locale
- Gallery + {t('gallery')}
{directa.media.gallery.map((g, i) => ( diff --git a/src/app/[locale]/work/page.tsx b/src/app/[locale]/work/page.tsx index bad420b..6d81323 100644 --- a/src/app/[locale]/work/page.tsx +++ b/src/app/[locale]/work/page.tsx @@ -1,11 +1,19 @@ import { getTranslations, setRequestLocale } from 'next-intl/server'; import type { Metadata } from 'next'; import { Link } from '@/i18n/routing'; -import { site } from '@/content/site'; +import { site, unwrap } from '@/content/site'; import { Reveal } from '@/components/ui/Reveal'; import { PendingBadge } from '@/components/ui/PendingBadge'; import { buildMetadata } from '@/lib/seo'; +/** Language-swatch / accent colour per project (see `accent` in site.projects). */ +const ACCENT_BG: Record = { + blue: 'bg-brand-blue', + coral: 'bg-brand-coral', + green: 'bg-brand-green', + yellow: 'bg-brand-yellow', +}; + export async function generateMetadata({ params }: { params: Promise<{ locale: string }> }): Promise { const { locale } = await params; const t = await getTranslations({ locale, namespace: 'work' }); @@ -67,6 +75,82 @@ export default async function WorkPage({ params }: { params: Promise<{ locale: s ))}
+ +
+ +
); } diff --git a/src/app/sitemap.ts b/src/app/sitemap.ts index 70f070a..21cd3c9 100644 --- a/src/app/sitemap.ts +++ b/src/app/sitemap.ts @@ -1,20 +1,22 @@ import type { MetadataRoute } from 'next'; -import { routing } from '@/i18n/routing'; +import { getPathname, routing, type Pathnames } from '@/i18n/routing'; -const PATHS = ['', '/about', '/work', '/work/directa', '/services', '/contact'] as const; +const PATHS: Pathnames[] = ['/', '/about', '/work', '/work/directa', '/services', '/contact']; export default function sitemap(): MetadataRoute.Sitemap { const base = process.env.NEXT_PUBLIC_SITE_URL || 'https://santiagorivera.com'; + const url = (locale: 'en' | 'es', path: Pathnames) => `${base}${getPathname({ locale, href: path })}`; + const out: MetadataRoute.Sitemap = []; - for (const locale of routing.locales) { - for (const path of PATHS) { + for (const path of PATHS) { + for (const locale of routing.locales) { out.push({ - url: `${base}/${locale}${path}`, + url: url(locale, path), lastModified: new Date(), changeFrequency: 'monthly', - priority: path === '' ? 1 : 0.7, + priority: path === '/' ? 1 : 0.7, alternates: { - languages: Object.fromEntries(routing.locales.map((l) => [l, `${base}/${l}${path}`])), + languages: Object.fromEntries(routing.locales.map((l) => [l, url(l, path)])), }, }); } diff --git a/src/components/layout/Footer.tsx b/src/components/layout/Footer.tsx index 5e1dd7f..f77883e 100644 --- a/src/components/layout/Footer.tsx +++ b/src/components/layout/Footer.tsx @@ -52,6 +52,13 @@ export function Footer() { LinkedIn ↗ + {!isPending(site.social.github) && unwrap(site.social.github) ? ( +
  • + + GitHub ↗ + +
  • + ) : null}
  • WhatsApp {isPending(site.contact.whatsapp) ? : null} diff --git a/src/components/sections/ContactPanel.tsx b/src/components/sections/ContactPanel.tsx index 5f2378b..6489300 100644 --- a/src/components/sections/ContactPanel.tsx +++ b/src/components/sections/ContactPanel.tsx @@ -19,6 +19,7 @@ export function ContactPanel(_props: { compact?: boolean } = {}) { const email = site.contact.email.value; const linkedin = site.contact.linkedin.value; + const github = unwrap(site.social.github); const whatsapp = unwrap(site.contact.whatsapp); const calendar = unwrap(site.contact.calendar); @@ -64,6 +65,15 @@ export function ContactPanel(_props: { compact?: boolean } = {}) { + {github && !isPending(site.social.github) ? ( + + {tc('github')} + + /1816x → + + + ) : null} + {tc('whatsapp')} {isPending(site.contact.whatsapp) ? ( diff --git a/src/content/site.ts b/src/content/site.ts index a5f7b44..cd8ce78 100644 --- a/src/content/site.ts +++ b/src/content/site.ts @@ -49,7 +49,7 @@ export const site = { /** Social handles for footer / metadata. */ social: { linkedin: ok('https://www.linkedin.com/in/santiagoxriv/'), - github: todo('', 'Optional — leave empty to hide'), + github: ok('https://github.com/1816x'), x: todo('', 'Optional — leave empty to hide'), }, @@ -176,6 +176,79 @@ export const site = { }, ], + /** + * Public GitHub projects — personal work and experiments, shown on the Work + * page as external links (no case-study page). Copy is derived from each + * repo's own description, so it is verified, not pending. Add or remove + * entries here; the Work page grid adapts automatically. Only list PUBLIC + * repositories — private ones must not be surfaced. + * + * `accent` picks the language-swatch / highlight colour from the palette + * ('blue' | 'coral' | 'green' | 'yellow'). `wip` flags an early-phase repo. + */ + projects: [ + { + slug: 'vuln-triage', + name: 'Vulnerability Triage Agent', + repo: '1816x/Vulnerability-Triage-Agent', + url: 'https://github.com/1816x/Vulnerability-Triage-Agent', + year: '2026', + language: 'Python · Rust', + accent: 'coral', + wip: false, + summary: { + en: 'Reachability-aware CVE prioritizer: it works out which vulnerabilities are actually reachable from your code, and drafts the GitHub issue for the ones that matter.', + es: 'Priorizador de CVEs consciente de la accesibilidad: determina qué vulnerabilidades son realmente alcanzables desde tu código y redacta el issue de GitHub para las que importan.', + }, + tags: ['Python', 'Security', 'Static analysis', 'LLM agent', 'Tree-sitter'], + }, + { + slug: 'edge-predict', + name: 'Edge Predict Model', + repo: '1816x/Edge-Predict-Model', + url: 'https://github.com/1816x/Edge-Predict-Model', + year: '2026', + language: 'Python', + accent: 'green', + wip: false, + summary: { + en: 'Quantitative sports-betting decision support: calibrated probabilities against no-vig market odds, expected-value detection, fractional-Kelly sizing and fully auditable picks.', + es: 'Soporte de decisión cuantitativo para apuestas deportivas: probabilidades calibradas frente a cuotas sin vig, detección de valor esperado, sizing por Kelly fraccional y selecciones totalmente auditables.', + }, + tags: ['Python', 'Calibration', 'Kelly criterion', 'Expected value', 'PostgreSQL'], + }, + { + slug: 'microstructure', + name: 'Trading Microstructure Engine', + repo: '1816x/Trading-Microstructure-Engine', + url: 'https://github.com/1816x/Trading-Microstructure-Engine', + year: '2026', + language: 'Rust · Python', + accent: 'blue', + wip: true, + summary: { + en: 'Tick-by-tick futures microstructure engine in Rust — order-flow imbalance computed over real tick data, exposed through a Python API layer.', + es: 'Motor de microestructura de futuros tick a tick en Rust — desequilibrio de flujo de órdenes calculado sobre datos reales, expuesto mediante una capa de API en Python.', + }, + tags: ['Rust', 'Python', 'Market microstructure', 'Order flow', 'FastAPI'], + }, + { + slug: 'web-portfolio', + name: 'Web Portfolio', + repo: '1816x/WebPortfolio', + url: 'https://github.com/1816x/WebPortfolio', + year: '2026', + language: 'TypeScript', + accent: 'yellow', + wip: false, + summary: { + en: 'This site. A bilingual Next.js 15 portfolio with an editorial neo-brutalist design system, a custom GSAP motion layer and fully localized routing.', + es: 'Este sitio. Un portafolio bilingüe en Next.js 15 con un sistema de diseño editorial neo-brutalista, una capa de motion propia con GSAP y ruteo totalmente localizado.', + }, + tags: ['TypeScript', 'Next.js', 'next-intl', 'GSAP', 'Tailwind'], + }, + ], + /** * Experience — populate from the verified CV. * Until then, the Experience section renders a clear "pending" state. diff --git a/src/lib/seo.ts b/src/lib/seo.ts index 7d3a8b6..9a7f19b 100644 --- a/src/lib/seo.ts +++ b/src/lib/seo.ts @@ -1,26 +1,38 @@ import type { Metadata } from 'next'; -import { site } from '@/content/site'; +import { site, isPending, unwrap } from '@/content/site'; +import { getPathname, routing, type Pathnames } from '@/i18n/routing'; type BuildArgs = { locale: 'en' | 'es'; title: string; description: string; - path?: string; + /** Internal routing key (e.g. '/work'); the localized public URL is derived per locale. */ + path?: Pathnames; }; +/** Resolve the absolute, locale-aware public URL for a routing key. */ +function localizedUrl(base: string, locale: 'en' | 'es', path: Pathnames): string { + return `${base}${getPathname({ locale, href: path })}`; +} + export function buildMetadata({ locale, title, description, path = '/' }: BuildArgs): Metadata { const base = process.env.NEXT_PUBLIC_SITE_URL || site.url; - const url = `${base}/${locale}${path === '/' ? '' : path}`; + const url = localizedUrl(base, locale, path); + + // hreflang alternates must point at each locale's REAL localized path + // (e.g. /es/trabajo, not /es/work) or they 404 and the SEO signal is wrong. + const languages: Record = Object.fromEntries( + routing.locales.map((l) => [l, localizedUrl(base, l, path)]), + ); + languages['x-default'] = localizedUrl(base, routing.defaultLocale, path); + return { metadataBase: new URL(base), title, description, alternates: { canonical: url, - languages: { - en: `${base}/en${path === '/' ? '' : path}`, - es: `${base}/es${path === '/' ? '' : path}`, - }, + languages, }, openGraph: { type: 'website', @@ -40,13 +52,17 @@ export function buildMetadata({ locale, title, description, path = '/' }: BuildA } export function personJsonLd(locale: 'en' | 'es') { + const base = process.env.NEXT_PUBLIC_SITE_URL || site.url; + const role = site.person.role[locale]; return { '@context': 'https://schema.org', '@type': 'Person', name: site.person.name, - url: site.url, - sameAs: [site.social.linkedin.value].filter(Boolean), - jobTitle: site.person.role[locale].value, + url: base, + sameAs: [site.social.linkedin.value, site.social.github.value, site.social.x.value].filter(Boolean), + // Only emit a verified job title — JSON-LD has no "pending" badge, so an + // unconfirmed role would ship as a hard claim. + ...(isPending(role) ? {} : { jobTitle: unwrap(role) }), worksFor: { '@type': 'Organization', name: 'Directa', From 8cb393041cebf9f4698669b4896651eb14b8d0f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 17 Jul 2026 19:40:53 +0000 Subject: [PATCH 2/5] chore: prune post-redesign dead code/deps + fix targeted a11y issues MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The site was redesigned from an editorial light/dark theme (Instrument Serif, next-themes, a react-three-fiber WebGL hero) to a light-only neo-brutalist look with a DOM CardStack hero. That left dead weight behind. This removes it and fixes the low-risk accessibility issues an audit surfaced. Dead code / dependencies (verified unused by grep; build still passes without them, which proves it): - Drop unused deps: three, @react-three/fiber, @react-three/drei, @types/three, framer-motion; remove the framer-motion optimizePackageImports entry from next.config.mjs; prune the lockfile. - Delete src/components/three/SignatureCanvas.tsx (a null stub with no importers) and the empty three/ dir. - Remove the never-read `visual` (enableSignatureField / preferWebGPU) block from site.ts. - Remove dead i18n keys from both locales: meta.ogAlt, home.heroEyebrow, common.theme/themeLight/themeDark/themeSystem/scrollHint/backHome, footer.colophonHeading/colophonBody. (Kept the hero* keys CardStack uses.) Docs drift: rewrite/trim README, DESIGN_SYSTEM, CLAUDE.md and CONTENT_GUIDE so they describe the shipped light-only neo-brutalist system (no dark mode, no Three.js, CalmToggle not ThemeToggle) and the real remaining pending items (WhatsApp/calendar/CV are done; GitHub projects added). Accessibility: - ContactPanel takes an `asPageHeading` prop so the /contact route renders a real

    (it had none — only an

    ); the home page keeps it an

    . - :focus-visible gets a cream halo so the focus ring stays visible on royal/ink dark surfaces where the royal accent outline vanished. - CalmToggle gains an aria-label (its visible label is hidden below sm). - LocaleSwitcher's aria-label is localized via common.language. - Header logo link gets a stable aria-label. - Bump the CardStack 'BASE' sub-label contrast to full on-accent (was ~4.3:1). Verified: pnpm install, typecheck, lint and build all pass; a production server confirms /contact and /contacto each have exactly one

    , the home page keeps one, the locale switcher aria-label localizes, and the focus ring is visible on the royal contact panel. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HGqP6dAJYGsYKiX4TnR5Dh --- CLAUDE.md | 35 +- CONTENT_GUIDE.md | 11 +- DESIGN_SYSTEM.md | 160 +++--- README.md | 20 +- messages/en.json | 16 +- messages/es.json | 16 +- next.config.mjs | 3 - package.json | 7 +- pnpm-lock.yaml | 665 ----------------------- src/app/[locale]/contact/page.tsx | 3 +- src/app/globals.css | 3 + src/components/layout/CalmToggle.tsx | 1 + src/components/layout/Header.tsx | 4 +- src/components/layout/LocaleSwitcher.tsx | 5 +- src/components/motion/CardStack.tsx | 2 +- src/components/sections/ContactPanel.tsx | 14 +- src/components/three/SignatureCanvas.tsx | 9 - src/content/site.ts | 10 - 18 files changed, 141 insertions(+), 843 deletions(-) delete mode 100644 src/components/three/SignatureCanvas.tsx diff --git a/CLAUDE.md b/CLAUDE.md index e0829f8..2322950 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -18,13 +18,16 @@ services studio). ## Stack — locked, do not swap without asking - Next.js 15 (App Router) + TypeScript -- Tailwind v3 (token-driven, see `globals.css`) -- next-intl (i18n) · next-themes (light/dark) +- Tailwind v3 (token-driven, see `globals.css`) — light-only neo-brutalist +- next-intl (i18n) - GSAP + ScrollTrigger (scroll timelines) · Lenis (smooth scroll) -- react-three-fiber + drei + three (one signature 3D visual on hero) -- Framer Motion (component-level micro-interactions only) - Deploy: Vercel +> The site was redesigned from an editorial light/dark theme (Instrument Serif, +> next-themes, a react-three-fiber hero) to a light-only neo-brutalist look with +> a DOM `CardStack` hero. **next-themes, three / react-three-fiber / drei and +> Framer Motion were removed — do not re-add them.** + ## Routes (6 × 2 locales = 12 static pages) | Path | EN | ES | @@ -44,7 +47,7 @@ and global `not-found.tsx`. **Almost every edit goes through two files**: - `src/content/site.ts` — person, contact, services, capabilities, work - list, experience, education, visual + analytics flags. + list, projects (public GitHub repos), experience, education, analytics flags. - `src/content/directa.ts` — Directa case-study copy. Both use a `{ value, pending, note }` wrapper for any field whose verified @@ -59,13 +62,14 @@ Do **not** scatter content across components. Always go through these files. | Item | Where to put it | | ---------------------------- | ---------------------------------------------- | | Portrait | `public/portrait/santiago.jpg` (4:5, ≥1600px) | -| CV PDF | `public/cv/santiago-rivera-cv.pdf` | -| WhatsApp number | `site.ts → contact.whatsapp` | -| Calendar URL | `site.ts → contact.calendar` | | Experience array | `site.ts → experience` | | Education array | `site.ts → education` | +| Role / positioning line | `site.ts → person.role` | | Directa case-study copy | `directa.ts → context/problem/strategy/…` | -| Directa screenshots | `public/work/directa/{cover,01,02,03}.jpg` | +| Directa screenshots + year | `public/work/directa/{cover,01,02,03}.jpg` | + +WhatsApp, calendar and the CV PDF are finalized (verified in `site.ts`). The +public GitHub projects now render on the Work page (`site.ts → projects`). The CV PDF is being redesigned in a parallel Claude Code session — do not overwrite it without checking with Santiago first. @@ -104,18 +108,17 @@ every commit. - **Routing**: localized pathnames are configured in `src/i18n/routing.ts`. When adding a new route, register it there too or `next-intl`'s type-safe `` won't accept it. -- **3D visual**: `SignatureField` is dynamically imported via - `SignatureCanvas`, which gates on viewport width > 720px **and** - `prefers-reduced-motion: no-preference`. Don't import `SignatureField` - directly anywhere. +- **Hero visual**: the hero centerpiece is `CardStack` + (`components/motion/CardStack.tsx`), a DOM card stack — the old + react-three-fiber field was removed. There is no 3D scene; don't add one. - **Reveals**: use the `` wrapper (`src/components/ui/Reveal.tsx`). It already handles reduced-motion. Don't sprinkle raw GSAP from inside components. - **Lenis**: mounted once in `SmoothScrollProvider`. Do not instantiate a second Lenis anywhere. -- **Theming**: tokens in `globals.css` (`:root` + `.dark`); Tailwind reads - them via `tailwind.config.ts → theme.extend.colors`. Add new tokens in - both blocks or dark mode will look broken. +- **Theming**: light-only. Tokens in `globals.css` (`:root`); Tailwind reads + them via `tailwind.config.ts → theme.extend.colors`. There is no `.dark` + block and no theme toggle — a `CalmToggle` controls reduced motion instead. - **Buttons**: `

  • diff --git a/src/components/sections/ContactPanel.tsx b/src/components/sections/ContactPanel.tsx index 6489300..1fcfe2d 100644 --- a/src/components/sections/ContactPanel.tsx +++ b/src/components/sections/ContactPanel.tsx @@ -12,10 +12,16 @@ const ROW = 'group flex items-center justify-between gap-4 border-b-2 border-white/25 py-3 font-mono text-[14px] no-underline transition-colors duration-200 hover:text-brand-yellow'; const ARROW = 'transition-transform duration-200 group-hover:translate-x-1'; -/** `compact` is accepted for backward compatibility with the contact route; the royal panel renders identically. */ -export function ContactPanel(_props: { compact?: boolean } = {}) { +/** + * `compact` is accepted for backward compatibility with the contact route. + * `asPageHeading` renders the panel's main heading as an

    — pass it when + * the panel is the page root (the /contact route) so the page has a top-level + * heading; on the home page it stays an

    under the Hero's

    . + */ +export function ContactPanel({ asPageHeading = false }: { compact?: boolean; asPageHeading?: boolean } = {}) { const t = useTranslations('home'); const tc = useTranslations('contact'); + const Heading = asPageHeading ? 'h1' : 'h2'; const email = site.contact.email.value; const linkedin = site.contact.linkedin.value; @@ -39,10 +45,10 @@ export function ContactPanel(_props: { compact?: boolean } = {}) {
    -

    + {leadWords} {lastWord} -

    +

    {t('contactLead')}

    diff --git a/src/components/three/SignatureCanvas.tsx b/src/components/three/SignatureCanvas.tsx deleted file mode 100644 index 71293f4..0000000 --- a/src/components/three/SignatureCanvas.tsx +++ /dev/null @@ -1,9 +0,0 @@ -/** - * The react-three-fiber signature field was removed in the neo-brutalist - * redesign: it crashed at desktop width (React reconciler mismatch) and the - * new hero uses a lightweight DOM card-stack instead. This stub stays only so - * any lingering import resolves; it renders nothing. - */ -export function SignatureCanvas() { - return null; -} diff --git a/src/content/site.ts b/src/content/site.ts index cd8ce78..2295d05 100644 --- a/src/content/site.ts +++ b/src/content/site.ts @@ -270,16 +270,6 @@ export const site = { }> >([], 'Populate from CV when finalized'), - /** - * Visual signature configuration for the WebGPU hero field. - * Off-switch in case of GPU/perf issues on a particular device. - */ - visual: { - enableSignatureField: true, - /** Whether to attempt WebGPURenderer before falling back to WebGL2. */ - preferWebGPU: true, - }, - /** Analytics hooks read from env vars; null if unset. */ analytics: { plausibleDomain: process.env.NEXT_PUBLIC_PLAUSIBLE_DOMAIN || null, From 9aaef20795a1d36cb4b83faf85f6a7cf163027d7 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 18 Jul 2026 21:16:41 +0000 Subject: [PATCH 3/5] fix(a11y): give every page a correct heading outline; restyle favicon MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Section titles were rendered as styled "chip"
    s and some per-item titles skipped a heading level (h1 -> h3 with no h2; capability groups as h4), so screen-reader/SEO document outlines were broken. Convert section-title chips to

    and demote/promote sub-item titles to

    , keeping page-eyebrow chips (the ones directly above a page's

    ) as decorative
    s. Tailwind's preflight strips default heading styles, so the tag swaps are visually identical — the chips render exactly as before. Files: IntroBlock, WorkPreview, ServicesGrid, CapabilitiesGrid (home sections), work/page (featured card name), about/page (On Directa / Experience / Education chips + role/degree items), work/directa/page (Block label + Features + Gallery). services/page and the contact route were already correct and are untouched. Favicon: replace the leftover old-design dark-serif "Sr" (icon.tsx) with the neo-brutalist "SR" lockup — cream on ink — matching the header logo and the OG card. The OpenGraph image was already neo-brutalist; only the stale MEDIA_PROMPTS note claiming otherwise is corrected, and its brand-kit blue is aligned to the real royal token (#2746D0). Verified: typecheck, lint and build pass; a production server confirms every route (/en, /es, /en/work, /es/trabajo, /en/work/directa, /en/about, /en/services, /en/contact) has exactly one

    and no skipped heading levels, the section chips look unchanged, and /icon renders the new favicon. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HGqP6dAJYGsYKiX4TnR5Dh --- MEDIA_PROMPTS.md | 14 +++++++------- src/app/[locale]/about/page.tsx | 16 ++++++++-------- src/app/[locale]/work/directa/page.tsx | 12 ++++++------ src/app/[locale]/work/page.tsx | 4 ++-- src/app/icon.tsx | 15 ++++++++------- src/components/sections/CapabilitiesGrid.tsx | 8 ++++---- src/components/sections/IntroBlock.tsx | 4 ++-- src/components/sections/ServicesGrid.tsx | 4 ++-- src/components/sections/WorkPreview.tsx | 4 ++-- 9 files changed, 41 insertions(+), 40 deletions(-) diff --git a/MEDIA_PROMPTS.md b/MEDIA_PROMPTS.md index 42e7138..b289fe8 100644 --- a/MEDIA_PROMPTS.md +++ b/MEDIA_PROMPTS.md @@ -16,7 +16,7 @@ image‑light, so generating *less* is the right call. | --- | --- | --- | | **Portrait** | `public/portrait/santiago.jpg` · 4:5 · ≥1600px | **Use your real photo.** Optionally *restyle* it with Gemini (prompt A). Never AI‑invent a face. | | **Directa screenshots** | `public/work/directa/{cover,01,02,03}.jpg` | **Use real captures of directa.mx.** Only generate the temp placeholders (prompt C) until you have them — and replace before launch. | -| **OG / social card** | `src/app/opengraph-image.tsx` (1200×630) | **Keep it code‑generated, not Gemini** — image models garble text. (It's still in the old editorial style — ask me to restyle it to neo‑brutalist.) | +| **OG / social card** | `src/app/opengraph-image.tsx` (1200×630) | **Keep it code‑generated, not Gemini** — image models garble text. Already in the neo‑brutalist style (cream/ink/royal + the "SR" lockup). | | **Favicon** | `src/app/icon.tsx` | Already code‑generated. Only make a custom one if you want (prompt D). | | **Abstract textures/accents** | optional, `public/art/` | Fair game for Gemini (prompt B) — but use sparingly; the brand avoids decoration. | @@ -33,8 +33,8 @@ image‑light, so generating *less* is the right call. STYLE: neo-brutalist, flat vector + risograph print, hard 3px black outlines, hard OFFSET solid shadow (no blur), bold geometric, high contrast, halftone dots, subtle paper grain, limited palette. -PALETTE: cream #FBF4E4, ink #111111, electric blue #3D5AFE, signal yellow #FFD23F, -coral #FF5C39, green #1FBF57. (dark variant background: graphite #161310) +PALETTE: cream #FBF4E4, ink #111111, royal blue #2746D0, signal yellow #FFD23F, +coral #FF5C39, green #1FBF57. AVOID: gradients, soft glow, blurred drop shadows, lens flare, glossy 3D render, photorealism, neon, busy detail, any text/letters/words. ``` @@ -49,7 +49,7 @@ Run this on your **actual headshot**. Keeps your likeness, gives it the brand lo ``` Edit this photo into a high-contrast two-tone (duotone) editorial portrait. Convert it to a bold halftone / risograph print look using ONLY deep ink black -#111111 and warm cream #FBF4E4, with a single electric-blue #3D5AFE accent on the +#111111 and warm cream #FBF4E4, with a single royal-blue #2746D0 accent on the shadows. Hard posterized edges, visible halftone dots, no gradients, no blur. Keep my face, likeness and pose; vertical 4:5 crop, plain cream background. Flat, graphic, confident. @@ -62,7 +62,7 @@ Flat, graphic, confident. Flat neo-brutalist abstract composition: bold geometric shapes (rectangles, circles, plus/cross marks, arrows) with thick 3px black outlines and hard offset solid-black shadows, arranged on a warm cream #FBF4E4 background. Limited palette: -electric blue #3D5AFE, signal yellow #FFD23F, coral #FF5C39. Risograph print +royal blue #2746D0, signal yellow #FFD23F, coral #FF5C39. Risograph print texture, slight paper grain, halftone dots. High contrast, no gradients, no glow, no text. Square, balanced negative space. ``` @@ -78,7 +78,7 @@ Cover (16:9): Neo-brutalist illustration of a browser window frame with a thick 3px black outline and a hard offset solid shadow, sitting on a cream #FBF4E4 background. Inside the window: an abstract minimal website layout built from solid color -blocks (cream, electric blue #3D5AFE, yellow #FFD23F, coral #FF5C39) — placeholder +blocks (cream, royal blue #2746D0, yellow #FFD23F, coral #FF5C39) — placeholder bars and rectangles only, NO readable text. Flat vector, risograph grain, high contrast, no gradients. 16:9. ``` @@ -100,7 +100,7 @@ cream background. ## 4. Workflow checklist 1. Generate in **Gemini app / AI Studio**; set the aspect ratio in the prompt **and** the tool (4:5 portrait, 16:9 cover, 4:3 gallery, 1:1 textures). -2. If a result has stray text or wrong color, say "remove all text, use exactly #3D5AFE/#FFD23F/#FF5C39 on #FBF4E4" and regenerate. +2. If a result has stray text or wrong color, say "remove all text, use exactly #2746D0/#FFD23F/#FF5C39 on #FBF4E4" and regenerate. 3. Optimize (e.g. squoosh.app) — JPG for photos/placeholders, PNG for graphics with transparency. 4. Drop the file at the exact path above. 5. Open `src/content/site.ts` / `src/content/directa.ts`, replace the path if needed and set `pending: false` so the "pending" badge disappears. diff --git a/src/app/[locale]/about/page.tsx b/src/app/[locale]/about/page.tsx index c3cf645..6ba45b5 100644 --- a/src/app/[locale]/about/page.tsx +++ b/src/app/[locale]/about/page.tsx @@ -79,10 +79,10 @@ export default async function AboutPage({ params }: { params: Promise<{ locale:
    -
    +

    {t('directaHeading')} -

    +

    {t('directaBody')}{' '} @@ -94,10 +94,10 @@ export default async function AboutPage({ params }: { params: Promise<{ locale:

    -
    +

    {t('experienceHeading')} -

    +

    {expPending || experience.length === 0 ? (
    @@ -109,7 +109,7 @@ export default async function AboutPage({ params }: { params: Promise<{ locale:
  • {exp.period}
    -

    {exp.role[l]}

    +

    {exp.role[l]}

    {exp.org}

    {exp.summary[l]}

    @@ -122,10 +122,10 @@ export default async function AboutPage({ params }: { params: Promise<{ locale:
    -
    +

    {t('educationHeading')} -

    +
  • {eduPending || education.length === 0 ? (
    @@ -137,7 +137,7 @@ export default async function AboutPage({ params }: { params: Promise<{ locale:
  • {ed.period}
    -

    {ed.degree[l]}

    +

    {ed.degree[l]}

    {ed.org}

  • diff --git a/src/app/[locale]/work/directa/page.tsx b/src/app/[locale]/work/directa/page.tsx index 26b1378..b12c19c 100644 --- a/src/app/[locale]/work/directa/page.tsx +++ b/src/app/[locale]/work/directa/page.tsx @@ -32,11 +32,11 @@ function Block({ return (
    -
    +

    {label} {data.pending ? : null} -

    +

    {data.value[l]}

    @@ -111,10 +111,10 @@ export default async function DirectaCase({ params }: { params: Promise<{ locale {/* Features */}
    -
    +

    {t('features')} -

    +
      {directa.features.map((f, i) => (
    • @@ -132,10 +132,10 @@ export default async function DirectaCase({ params }: { params: Promise<{ locale {/* Gallery */}
      -
      +

      {t('gallery')} -

      +
      {directa.media.gallery.map((g, i) => (
      diff --git a/src/app/[locale]/work/page.tsx b/src/app/[locale]/work/page.tsx index 6d81323..e0a2c36 100644 --- a/src/app/[locale]/work/page.tsx +++ b/src/app/[locale]/work/page.tsx @@ -52,9 +52,9 @@ export default async function WorkPage({ params }: { params: Promise<{ locale: s {String(i + 1).padStart(2, '0')} · {p.year.value} {p.year.pending ? : null}
      -
      +

      {p.name} -

      +

      {p.summary[l]}

      diff --git a/src/app/icon.tsx b/src/app/icon.tsx index 85eba92..de5559d 100644 --- a/src/app/icon.tsx +++ b/src/app/icon.tsx @@ -3,25 +3,26 @@ import { ImageResponse } from 'next/og'; export const size = { width: 64, height: 64 }; export const contentType = 'image/png'; +// Neo-brutalist "SR" lockup — cream on ink, matching the header logo and the +// OpenGraph card. (Replaces the old dark-serif "Sr".) export default function Icon() { return new ImageResponse( (
      - Sr + SR
      ), { ...size }, diff --git a/src/components/sections/CapabilitiesGrid.tsx b/src/components/sections/CapabilitiesGrid.tsx index dc74554..f02f205 100644 --- a/src/components/sections/CapabilitiesGrid.tsx +++ b/src/components/sections/CapabilitiesGrid.tsx @@ -11,10 +11,10 @@ export function CapabilitiesGrid() { return (
      -
      +

      05 — {t('capabilitiesHeading')} -

      +

      {t('capabilitiesLead')} @@ -23,9 +23,9 @@ export function CapabilitiesGrid() { {site.capabilities.map((cap) => (

      -

      +

      {cap.title[locale]} -

      +
        {cap.items.map((it) => (
      • -
        +

        02 — {t('introHeading')} -

        +

        diff --git a/src/components/sections/ServicesGrid.tsx b/src/components/sections/ServicesGrid.tsx index fcc260c..9f8d38d 100644 --- a/src/components/sections/ServicesGrid.tsx +++ b/src/components/sections/ServicesGrid.tsx @@ -19,10 +19,10 @@ export function ServicesGrid() { return (

        -
        +

        04 — {t('servicesHeading')} -

        +

        {t('servicesLead')} diff --git a/src/components/sections/WorkPreview.tsx b/src/components/sections/WorkPreview.tsx index 62742b0..53111f7 100644 --- a/src/components/sections/WorkPreview.tsx +++ b/src/components/sections/WorkPreview.tsx @@ -19,10 +19,10 @@ export function WorkPreview() {

        -
        +

        03 — {t('workHeading')} -

        + Date: Sun, 19 Jul 2026 02:14:15 +0000 Subject: [PATCH 4/5] ci: add GitHub Actions workflow (typecheck, lint, build, e2e) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add .github/workflows/ci.yml so pull requests to main and pushes to main run the documented CI gate — pnpm typecheck && lint && build — plus a Playwright e2e job. This gives branch protection a required status check to gate merges into main (the branch Vercel deploys to production); until now the gate only ran locally. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HGqP6dAJYGsYKiX4TnR5Dh --- .github/workflows/ci.yml | 44 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6793f33 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,44 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + +jobs: + checks: + name: typecheck · lint · build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm typecheck + - run: pnpm lint + - run: pnpm build + + e2e: + name: e2e (playwright) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: pnpm/action-setup@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: pnpm + - run: pnpm install --frozen-lockfile + - run: pnpm exec playwright install --with-deps chromium + - run: pnpm test:e2e From eb8aa928d5cf1460196e27ad5782b9186f71ae76 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 02:17:14 +0000 Subject: [PATCH 5/5] ci: install webkit for the mobile e2e project MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Playwright config runs two projects — Desktop Chrome (chromium) and iPhone 13 (webkit). CI only installed chromium, so the mobile project failed with "Executable doesn't exist ... webkit". Install both engines. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01HGqP6dAJYGsYKiX4TnR5Dh --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6793f33..4bc9449 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,5 +40,7 @@ jobs: node-version: 20 cache: pnpm - run: pnpm install --frozen-lockfile - - run: pnpm exec playwright install --with-deps chromium + # Playwright config runs two projects: Desktop Chrome (chromium) and + # iPhone 13 (webkit) — install both engines, not just chromium. + - run: pnpm exec playwright install --with-deps chromium webkit - run: pnpm test:e2e