Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
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
# 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
35 changes: 19 additions & 16 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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 `<Link>` 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 `<Reveal>` 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**: `<Button>` and `<ButtonLink>` from `components/ui/Button.tsx`
— three variants (`primary`, `ghost`, `underline`). Don't roll your own.
- **Analytics**: read from env vars at runtime. Setting either
Expand Down
11 changes: 9 additions & 2 deletions CONTENT_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,10 @@ A small "pending" badge renders in the UI for every wrapped field still marked
| `social` | Footer / metadata social links |
| `services` | The four service blocks (Problem / Deliverable / Outcome) |
| `capabilities` | Capability groups and tags |
| `work` | Featured projects list |
| `work` | Featured case studies (each with a dedicated page) |
| `projects` | Public GitHub repos shown on the Work page (external links) |
| `experience` | Work history (initially `pending`) |
| `education` | Education (initially `pending`) |
| `visual` | Whether the WebGL signature mounts |
| `analytics` | Read from env vars |

## 2. `src/content/directa.ts`
Expand Down Expand Up @@ -68,6 +68,13 @@ If you add a new namespace (top-level key) in one file, add it to both.
(the Directa page is a good template to copy).
4. The layout adapts automatically — no other code changes required.

For a **public GitHub repo** that should appear as an external card (not a full
case study), append to `projects` in `site.ts` instead — each entry is
`{ slug, name, repo, url, year, language, accent ('blue'|'coral'|'green'|'yellow'),
wip, summary: { en, es }, tags }`. It renders in the "Open work on GitHub"
section of the Work page and links straight to GitHub. Only list **public**
repositories.

## 5. Replacing the CV or portrait

- **CV** — drop a PDF at `public/cv/santiago-rivera-cv.pdf`. If you keep that
Expand Down
160 changes: 74 additions & 86 deletions DESIGN_SYSTEM.md
Original file line number Diff line number Diff line change
@@ -1,114 +1,102 @@
# Design system

The visual identity is editorial, restrained, and built around typography
rather than decoration. Two type voices, generous white space, one signal
color, one signature visual.
The visual identity is **light-only neo-brutalist**: a warm cream page, hard
3px ink borders with offset drop-shadows, a royal-blue accent over comic
ben-day halftone dots, and two type voices (a grotesque display/sans + a
mono for metadata). Motion is selective and always reduced-motion aware.

## Tokens

Tokens live in `src/app/globals.css` as CSS custom properties scoped to
`:root` (light) and `.dark`. Tailwind reads them via the `colors` config in
`tailwind.config.ts`.

| Token | Light | Dark |
| -------------------- | ------------------------ | -------------------------- |
| `--canvas` | `250 249 246` (paper) | `10 10 12` (graphite) |
| `--canvas-raised` | `255 255 255` | `18 18 22` |
| `--canvas-sunken` | `244 242 237` | `6 6 8` |
| `--ink` | `18 18 20` | `244 240 232` |
| `--ink-muted` | `75 75 80` | `175 175 180` |
| `--ink-subtle` | `130 130 135` | `110 110 115` |
| `--accent` | `220 60 30` (signal red) | `255 100 65` |
| `--gutter` | `clamp(1.25rem,2.5vw,2.5rem)` | same |

Use `rgb(var(--token))` or Tailwind classes (`bg-canvas`, `text-ink-muted`,
`text-accent`, etc.).
Tokens live in `src/app/globals.css` as CSS custom properties on `:root`
(space-separated RGB so Tailwind can apply `<alpha-value>`). Tailwind reads
them via the `colors` config in `tailwind.config.ts`. There is **no dark
theme** — a single light palette.

| Token | Value | Use |
| ----------------- | ------------ | ------------------------------------- |
| `--canvas` | `#FBF4E4` | cream page background |
| `--canvas-sunken` | `#F4EBD6` | deeper cream (media wells) |
| `--surface` | `#FFFFFF` | raised card surface |
| `--ink` | `#111111` | text + 3px borders + hard shadow |
| `--ink-muted` / `--ink-subtle` | greys | body / metadata text |
| `--royal` / `--accent` / `--blue` | `#2746D0` | lead accent (comic primary blue) |
| `--royal-deep` | `#162A8C` | footer field |
| `--yellow` | `#FFD23F` | accent fill / marker |
| `--coral` | `#FF5C39` | accent fill |
| `--green` | `#1FBF57` | accent fill / live dot |
| `--panel` / `--panel-ink` | `#111` / cream | inverse panels (contact/footer) |
| `--on-accent` | `#111111` | fixed near-black text on light fills; never flips |

Use `rgb(var(--token))` or the Tailwind classes (`bg-canvas`, `text-ink-muted`,
`text-accent`, `bg-brand-yellow`, `bg-brand-royal`, …).

## Typography

Three faces, served via `next/font`:

| Role | Family | Usage |
| -------- | ----------------- | ------------------------------------ |
| Display | Instrument Serif | Headings, hero, name lockup |
| Sans | Inter | Body, navigation, prose |
| Mono | JetBrains Mono | Labels, eyebrows, metadata, buttons |

Helper classes in `globals.css`:

- `.display` — serif headings with tight letter-spacing.
- `.eyebrow` — small uppercase mono lead-in over sections.
- `.label` — micro mono caps used for metadata.
Two faces, served via `@fontsource` and wired in `src/lib/fonts.ts`:

Tracking:
- `.tracking-tightest` (`-0.04em`) for the largest serif headings.
- `.tracking-snug` (`-0.015em`) for mid-size headings.
| Role | Family | Usage |
| ------------ | --------------- | ------------------------------------ |
| Display/Sans | Space Grotesk | Headings, hero, body, navigation |
| Mono | JetBrains Mono | Labels, eyebrows, metadata, buttons |

## Spacing
Helper classes in `globals.css`: `.eyebrow` and `.label` (small uppercase
mono lead-ins / metadata).

- Vertical rhythm uses generous section padding (`py-24 md:py-40`).
- Horizontal page gutter uses `var(--gutter)` via the `.container-x` utility.
- Section dividers are 1px `border-t border-line/10` — never a thicker rule.
## Primitives

## Color usage

- **Accent** is used sparingly: hover states, pending badge dot, one
highlight per page max. Never as a background fill.
- Surfaces alternate `--canvas` and `--canvas-sunken` for soft contrast
without losing the editorial feel.
- `.brut` — 3px ink border + `5px 5px 0` offset shadow. `.brut-sm` is the 2px
variant. The defining box of the whole look.
- `.press` — applied to interactive `.brut` boxes: lifts `-3px,-3px` on hover
(bigger shadow) and slams flat on `:active`. Uses stepped easing.
- `.halftone` / `.halftone-strong` / `.halftone-on-royal` — comic ben-day dot
fields (royal on cream, or white on royal).
- `.dot` — small accent status dot. `.mark` — marker-highlight behind text.
- Shadows: `shadow-brut` / `-sm` / `-lg` / `-xl` (Tailwind).

## Motion

- **Reveals** — `<Reveal>` component fades-up content with `expo.out` easing,
triggered by GSAP ScrollTrigger at `top 85%`, fires `once: true`.
- **Hero** — GSAP timeline animates each headline line individually with a
60px translate and 8% scroll-driven parallax.
- **Smooth scroll** — `Lenis` (1.05s duration, exponential ease) bound to
GSAP's ticker. Bypassed entirely when `prefers-reduced-motion` is set.
- **Hover** — text shifts to `italic` and `text-accent` on interactive
serif headings. Buttons translate the arrow icon 2px on hover.

All motion is gated by a `prefers-reduced-motion: reduce` check.

## The signature visual

`src/components/three/SignatureField.tsx` renders a slow-rotating point
field whose vertices respond to the cursor. It only mounts when:

1. `site.visual.enableSignatureField` is `true`,
2. the viewport is wider than 720px,
3. the user has not requested reduced motion.
- **Reveals** — the `<Reveal>` wrapper (`components/ui/Reveal.tsx`) snaps
content into place with a `back.out` overshoot via GSAP ScrollTrigger
(`start: top 86%`, `once: true`). Use it; don't sprinkle raw GSAP.
- **Hero** — GSAP staggers each headline word up into place; the `CardStack`
deals its cards in and, on fine pointers, they are draggable with an elastic
return.
- **Smooth scroll** — `Lenis`, mounted once in `SmoothScrollProvider` and
bound to GSAP's ticker. Bypassed under reduced motion.
- **Ticker / Marker** — marquee strip and marker-highlight micro-interactions.

It uses react-three-fiber with `powerPreference: 'high-performance'` and is
dynamically imported with `ssr: false` so it never blocks initial paint.
All motion is gated twice: the OS `prefers-reduced-motion: reduce` **and** a
visible `CalmToggle` that sets an `html.calm` class (persisted, applied before
hydration) for users who never set the OS flag.

## Components

| Component | Purpose |
| --------------- | --------------------------------------------------------- |
| `Header` | Sticky nav, blurs on scroll, mobile drawer |
| `Footer` | Tagline + nav + contact channels |
| `ThemeToggle` | Light / dark, system-aware (next-themes) |
| `LocaleSwitcher`| es / en (next-intl) |
| `Button` | Pill button with chevron, three variants |
| `Reveal` | Scroll-triggered fade-up wrapper |
| `PendingBadge` | Surfaces unverified content |
| `SignatureField`| Hero 3D scene |
| `ContactPanel` | Channel list with copy-email, badges for pending values |
| Component | Purpose |
| ---------------- | --------------------------------------------------------- |
| `Header` | Sticky nav, mobile drawer, skip-link target |
| `Footer` | Tagline + nav + contact channels (royal-deep field) |
| `CalmToggle` | Visible reduce-motion switch (replaces the old theme toggle) |
| `LocaleSwitcher` | es / en (next-intl), localized `aria-label` |
| `Button` | Bordered hard-shadow button; variants primary/secondary/accent/ghost/underline |
| `Reveal` | Scroll-triggered snap-in wrapper |
| `PendingBadge` | Surfaces unverified content |
| `CardStack` | Interactive DOM hero centerpiece |
| `ContactPanel` | Royal contact section (copy-email + channels) |

## Accessibility

- All interactive elements are keyboard reachable.
- Focus uses a 2px accent outline at 3px offset.
- `prefers-reduced-motion` disables Lenis, GSAP reveals, and the 3D scene.
- Color contrast on both themes meets AA for body and large text.
- `<Header>` exposes `Skip to content` as the first focusable element.
- Locale switching uses semantic `<button>`s with `aria-current`.
- `:focus-visible` uses an accent outline plus a cream halo so the ring stays
visible on both cream and royal/ink dark surfaces.
- `prefers-reduced-motion` **and** the `CalmToggle` disable Lenis + GSAP.
- `<Header>` exposes `Skip to content` as the first focusable element; nav uses
`aria-current`.
- Color contrast on the light palette meets AA for body and large text.

## What to avoid

- Generic developer-portfolio tropes: skill bars, floating tech logos, fake
dashboards, terminal headers, neon gradients.
- Animation on every element.
- More than one accent highlight per page section.
- 3D added "just because". The signature field is the only piece.
- Animation on every element; more than one accent highlight per section.
- Re-introducing a dark theme or a 3D scene — the redesign is deliberately
light-only with a DOM hero. The 3D field was removed.
Loading
Loading