diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 30e11f6..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.dev-surfaces/skills/dev-surface/SKILL.md b/.dev-surfaces/skills/dev-surface/SKILL.md new file mode 100644 index 0000000..398dfdc --- /dev/null +++ b/.dev-surfaces/skills/dev-surface/SKILL.md @@ -0,0 +1,40 @@ +--- +name: portfolio-dev-surface +description: Use when working in Portfolio and needing the local Next site or Storybook. +--- + +# Portfolio Dev Surface + +Inside this repo, use the repo-native command: + +```sh +bun install +bun run dev +``` + +`bun run dev` runs `scripts/dev.mjs`, which starts the Next site and Storybook together, streams both logs, and cleans up on Ctrl-C. + +Expected ports: + +- `3201`: Next site +- `3202`: Storybook + +Useful native commands: + +```sh +bun run dev +bun run dev:site +bun run storybook +``` + +For cross-repo orchestration from anywhere, use the global workbench: + +```sh +dev launch portfolio +dev launch portfolio:site +dev status portfolio +dev health portfolio +dev stop portfolio +``` + +Do not call `.dev-surfaces/run.mjs`; this repo should not have that wrapper. diff --git a/.dev-surfaces/surfaces.json b/.dev-surfaces/surfaces.json new file mode 100644 index 0000000..aea080b --- /dev/null +++ b/.dev-surfaces/surfaces.json @@ -0,0 +1,48 @@ +{ + "version": 1, + "project": { + "id": "portfolio", + "name": "Portfolio", + "root": "/Users/afo/Code/portfolio" + }, + "surfaces": [ + { + "id": "site", + "name": "Next site", + "kind": "ui", + "ports": [ + 3201 + ], + "command": [ + "bun", + "run", + "dev:site" + ], + "urls": [ + "http://localhost:3201/" + ], + "env": { + "PATH": "/Users/afo/.local/share/mise/installs/node/22.22.1/bin:${PATH}" + } + }, + { + "id": "storybook", + "name": "Storybook", + "kind": "docs-ui", + "ports": [ + 3202 + ], + "command": [ + "bun", + "run", + "storybook" + ], + "urls": [ + "http://localhost:3202/" + ], + "env": { + "PATH": "/Users/afo/.local/share/mise/installs/node/22.22.1/bin:${PATH}" + } + } + ] +} diff --git a/.env.example b/.env.example index bece6f1..a85f1a2 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ NEXT_PUBLIC_WEBSITE_URL=https://afolabi.info -AUTH_EMAIL_PASS= -AUTH_EMAIL_USER= \ No newline at end of file +RESEND_API_KEY= +CONTACT_EMAIL_TO=contact@afolabi.info +CONTACT_EMAIL_FROM=Afolabi Portfolio diff --git a/.gitignore b/.gitignore index e245a9e..c0ca0e1 100644 --- a/.gitignore +++ b/.gitignore @@ -38,6 +38,7 @@ jspm_packages/ # Typescript v1 declaration files typings/ +*.tsbuildinfo # Optional npm cache directory .npm @@ -60,6 +61,7 @@ typings/ build/ build-storybook/ +output/agentic-browser-proof/ public/sitemap.xml public/draco/ public/og diff --git a/.plans/README.md b/.plans/README.md new file mode 100644 index 0000000..54cc184 --- /dev/null +++ b/.plans/README.md @@ -0,0 +1,19 @@ +# Portfolio Planning OS + +Portfolio uses a lightweight planning surface modeled after TAS Hub. It is meant +to keep implementation plans reusable without adding lane files, queue scripts, +or tool-specific handoff choreography. + +## Layout + +- `features//spec.md` - execution-ready plan +- `features//context.md` - current state, constraints, notes +- `audits/YYYY-MM-DD-.md` - point-in-time audits and readiness reports +- `templates/feature/` - starter files for new feature folders + +## Operating Rules + +- Keep plan artifacts human-readable and reusable by future tools. +- Do not add `.todo.md` lane files, queue metadata, handoff branches, or package scripts unless the planning system is deliberately expanded later. +- Keep runtime component, CSS, content, and asset edits out of plan-only passes. +- When the worktree has active story or UI changes, preserve those files and scope plan updates to `.plans/**`. diff --git a/.plans/features/modern-css-web-ui-primitives/context.md b/.plans/features/modern-css-web-ui-primitives/context.md new file mode 100644 index 0000000..bc5a5df --- /dev/null +++ b/.plans/features/modern-css-web-ui-primitives/context.md @@ -0,0 +1,24 @@ +# Modern CSS/Web UI Primitives Context + +## Current State + +- Source audit: `/Users/afo/Documents/Codex/2026-05-23/i-m-watching-this-great-video/modern-css-web-ui-audit.md`. +- Portfolio did not have a `.plans` directory before this bootstrap, so this feature uses the same lightweight plan shape as TAS Hub. +- The app is a Next 15, React 18, Bun 1.3.10 portfolio site using CSS Modules, SCSS, Framer Motion, Three.js, and a JavaScript token/theme provider. +- `src/components/ThemeProvider/theme.js` defines viewport-specific rem-based typography and light/dark theme tokens. +- `src/layouts/App/global.scss` sets `body` to `width: 100vw` and `overflow-x: hidden`; several pages and layout modules still use `100vh` or `100vw`. +- Current interaction code includes manual scroll listeners for hash scrolling, parallax, navbar inversion, and scroll restoration, plus Framer Motion route/page transitions. +- The app has `color-scheme` in reset CSS and several reduced-motion-aware hooks, but no explicit `prefers-contrast` or `forced-colors` contract for text, focus, canvas/WebGL, image-backed overlays, or project-page surfaces. + +## Constraints + +- This plan tracks future work only. Do not change runtime CSS, components, scripts, dependencies, metadata, or generated assets in this pass. +- Preserve the active `portfolio-story-refresh` worktree. Existing dirty story, component, package, docs, public, and asset files are unrelated unless a future task explicitly scopes them in. +- Bootstrap only the lightweight planning surface: README, templates, and this feature pack. Do not add package scripts, validation helpers, lane files, or queue metadata. +- Modern CSS/Web UI adoption should be progressive and compatible with Portfolio's current browser target of `>10%, not dead, not ie 11, not op_mini all`. + +## Notes + +- Priority primitives from the audit: text-scale readiness, dynamic viewport units, OS preference support, token roles, native browser navigation/motion primitives, and reduced scroll-listener complexity. +- Portfolio-specific candidates: `100vw`/scrollbar policy, `100vh` to dynamic viewport migration, CSS scroll spy for hash navigation, progressive View Transitions as an alternative to some Framer route transitions, and a token contract that bridges JS theme tokens with CSS Modules. +- Safe adoption path: document first, pilot one low-risk route or component, guard newer features with `@supports` or feature detection, provide static/reduced-motion fallbacks, and verify across breakpoint and preference modes. diff --git a/.plans/features/modern-css-web-ui-primitives/spec.md b/.plans/features/modern-css-web-ui-primitives/spec.md new file mode 100644 index 0000000..888cb39 --- /dev/null +++ b/.plans/features/modern-css-web-ui-primitives/spec.md @@ -0,0 +1,36 @@ +# Modern CSS/Web UI Primitives + +## Goal + +Create an execution-ready backlog for adopting modern CSS and Web UI primitives in Portfolio without starting runtime implementation yet. The work should improve accessibility, scrolling behavior, and design-token consistency while preserving the current portfolio story refresh. + +## Key Changes + +- Text-scale readiness: audit the JS token system, rem conversion, large display type, project pages, nav, contact page, and mobile layouts before considering `meta name="text-scale" content="scale"`. +- CSS architecture: document how global SCSS, CSS Modules, JS theme tokens, custom media, and project case-study styles should share ownership before introducing cascade layers, scoped CSS, or new token conventions. +- Preference and native UI readiness: define `color-scheme`, `prefers-reduced-motion`, `prefers-contrast`, `forced-colors`, visible focus, and touch-target expectations; future overlays, menus, and tooltips should prefer native `` or `popover` where component-library behavior is unnecessary. +- Viewport cleanup: replace future reliance on `width: 100vw`, `height: 100vh`, and `min-height: 100vh` with scrollbar-aware and dynamic viewport patterns such as `overflow-x: clip`, `100dvh`, `100svh`, or `100lvh` where validated. +- Scroll behavior: inventory `useScrollToHash`, `useParallax`, `ScrollRestore`, navbar scroll listeners, and project-page parallax to identify where CSS scroll spy, scroll-state queries, or awaited programmatic scroll can reduce JavaScript. +- Motion modernization: compare current Framer Motion route/page transitions with progressive View Transitions, keeping reduced-motion behavior and existing visual continuity as acceptance criteria. +- Token consistency: document how `ThemeProvider` JS tokens, CSS custom properties, CSS Modules, and hardcoded page styles should relate before adding new project case-study styles. +- Layered UI readiness: no immediate overlay work is required, but future dialogs, tooltips, and menus should prefer native `` or `popover` where component-library behavior is unnecessary. +- Research only: keep HTML-in-Canvas, overscroll gestures, scoped View Transitions, CSS `@function`, CSS `if()`, `corner-shape`, and `fit-text` out of production scope until a specific Portfolio use case justifies a pilot. + +## Validation + +- Plan-only pass: confirm `git status --short --branch` before editing and keep the diff scoped to `.plans/**`. +- Run `git diff --check` after the plan files are added. +- Each future primitive promotion should capture regression risk, vulnerable surfaces, fallback behavior, proof required, existing abstraction fit, and reversibility before runtime work starts. +- Do not run `bun run lint`, `bun run build`, or Storybook validation for this pass because no runtime files should change and the checkout has unrelated active UI work. + +## Next Proof Task + +- Keep this plan-only until a route, case-study, media, or Storybook runtime change is scoped. The next proof task is an isolated Chrome DevTools MCP or `bun run agentic:browser-proof` pass for the changed surface, capturing route/surface URL, viewport, screenshot or DOM/accessibility snapshot, console/page errors, useful network notes, `/llms.txt`, reduced-motion behavior, and `list_webmcp_tools`. +- For any View Transition prototype, capture LCP, INP, CLS, route label, `navigationType`, reduced-motion state, and media/3D state before and after the transition. Do not add RUM dependencies for this planning pass. +- Keep runtime WebMCP frozen. Any future page/case-study read-only tool candidate needs an approval spec covering candidate visible tools, forbidden tools, confirmation rules, public/privacy boundary, schema tests, wrong-tool/wrong-argument evals, and proof commands. + +## Assumptions + +- Portfolio should use the lightweight TAS-style planning model rather than the heavier Green Goods or Coop plan-hub contracts. +- Backlog-first means this feature pack becomes the tracking surface for future CSS modernization, not an implementation batch. +- Existing Framer Motion and Three.js surfaces stay in place until a future task proves a modern browser primitive can replace or simplify them safely. diff --git a/.plans/templates/feature/context.md b/.plans/templates/feature/context.md new file mode 100644 index 0000000..8b10a7c --- /dev/null +++ b/.plans/templates/feature/context.md @@ -0,0 +1,13 @@ +# Feature Context + +## Current State + +Summarize the relevant repo surface as it exists today. + +## Constraints + +- Runtime, package manager, validation, or content constraints + +## Notes + +- Links to related audits, docs, or prior decisions diff --git a/.plans/templates/feature/spec.md b/.plans/templates/feature/spec.md new file mode 100644 index 0000000..2bb51c0 --- /dev/null +++ b/.plans/templates/feature/spec.md @@ -0,0 +1,17 @@ +# Feature Spec + +## Goal + +Describe the user outcome and success condition. + +## Key Changes + +- List the behavior or implementation changes required. + +## Validation + +- List the exact commands and QA checks required before completion. + +## Assumptions + +- Record defaults chosen to keep implementation decision-complete. diff --git a/.storybook/manager-head.html b/.storybook/manager-head.html index bdb10f5..cf78229 100644 --- a/.storybook/manager-head.html +++ b/.storybook/manager-head.html @@ -38,7 +38,6 @@ content="My custom design component system created from the ground up used in building my portfolio" /> - diff --git a/AGENTS.md b/AGENTS.md index abcb2cd..58ea82f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,10 +7,23 @@ Working agreements for AI coding agents in `portfolio`. - Use Bun (`bun@1.3.10`), the checked-in `bun.lock`, and the project scripts already present in this repository. Do not reintroduce npm, yarn, or pnpm lockfiles unless the user explicitly asks for that package-manager change. - Keep changes scoped to the user request and avoid broad dependency, workflow, or agent-config churn. -## Agentic Browser QA +## Agentic Modern Web Standard +- Baseline target: Baseline Widely Available. Before frontend, UI, CSS, accessibility, browser proof, or web-design changes, run repo-installed Modern Web Guidance with `bun run agentic:guidance`, then apply the existing Next.js, Storybook, and portfolio presentation constraints. +- Prefer semantic HTML, native controls, platform CSS, and browser primitives before custom JavaScript. Keep landmarks, headings, links, buttons, forms, accessible names, focus states, touch targets, loading/error/empty states, and reduced-motion behavior clear in the rendered DOM and accessibility tree. +- Run `bun run agentic:check` for hard guidance-readiness plus the design-token guard and lint lane. For local layout, interaction, motion, 3D, or route QA, use the authenticated Brave QA profile through the live authenticated-browser path below. Treat `bun run agentic:browser-proof` as a CI/clean-room browser proof lane only; it builds Next, captures route screenshots and accessibility summaries, checks console/page errors, `/llms.txt`, reduced-motion behavior, WebMCP discovery, and then builds Storybook, but it must not be reported as local authenticated verification. `dev-surfaces` remains the cross-repo/global doctor for shared Modern Web Guidance cache refresh, Brave, and MCP readiness. - Local agentic browser QA must use the authenticated Brave QA profile. Codex: use the Codex browser-extension path and claim the already-open Brave tab/window. Claude Code: use the Claude Code Chrome/Chromium extension path (`claude --chrome` or `/chrome`) and select the authenticated Brave profile/tab when it is installed, connected, and able to control the already-open Brave window. Do not fall back merely because the extension is branded Chrome. If the Brave extension path is unavailable or not connected, use Claude computer-use/visible desktop control of the already-open Brave window; if neither can reach authenticated Brave, report QA as blocked. Use this for admin, PWA, extension, wallet/passkey, staging-session, installed-app, and profile-dependent verification. - Do not use isolated Browser, Playwright, or DevTools MCP profiles for local QA. Existing isolated browser-proof commands are CI/clean-room checks only and must not be reported as authenticated verification. If authenticated Brave access is blocked, stop and report QA as blocked. +- WebMCP has an explicitly approved public read-only runtime pilot. Keep tools in `src/utils/webmcp.ts` visible, page-scoped, and public-safe; do not add contact submission, analytics, private context, hidden admin actions, destructive operations, or background-only actions. +- For MCP/tool selection and WebMCP expansion, use `docs/agentic-mcp-tooling-runbook.md` as the operating map before adding tools or changing proof lanes. + +## Design System Guardrails + +- Load `DESIGN.md`, `src/components/ThemeProvider/theme.ts`, the nearest CSS Module or SCSS file, and Storybook context before UI/CSS work. +- Run `bun run check:design-tokens` for UI/CSS changes; it blocks new raw colors, gradients, viewport units, raw radii, and raw motion values unless they are intentionally recorded in `scripts/data/design-token-baseline.tsv`. +- Keep existing Portfolio identity intact: editorial case studies, restrained motion, and 3D as supporting atmosphere rather than a new product UI system. +- For new case studies or route changes, update project data in `src/constants.ts`, route data in `src/utils/siteRoutes.json`, and keep public/browser-proof routes aligned with `bun run check:site-routes`. Reuse `layouts/Project` and `src/pages/projects/CaseStudy.module.scss` before adding a new visual grammar. +- For contact or form changes, keep validation shared through `src/utils/contact.ts` so client constraints and API parsing do not drift. ## Supply-chain and agent safety diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..416bcd7 --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,38 @@ +--- +name: Portfolio Design System +description: Lightweight design contract for the Afolabi portfolio site, grounded in the current Next.js, Storybook, SCSS, CSS Modules, Framer Motion, and Three.js implementation. +--- + +# Portfolio Design System + +This site is a personal portfolio and case-study surface. It should feel precise, editorial, technical, and calm: the work is the signal, with motion and 3D used to deepen the story rather than decorate every section. + +## Source Of Truth + +- Runtime theme tokens live in `src/components/ThemeProvider/theme.ts`. +- Global CSS ownership lives in `src/layouts/App/global.scss` and `src/layouts/App/reset.css`. +- Component and route styling lives in CSS Modules next to the component or page. +- Project content contracts live in `src/constants.ts`; public route, nav, and browser-proof route ownership lives in `src/utils/siteRoutes.json`. +- Storybook is the visual review surface; use `bun run agentic:browser-proof` when UI, layout, motion, or public routes change. + +## Token Rules + +- Use `ThemeProvider` tokens for color, spacing, typography, motion duration, easing, and z-index before adding raw values. +- New project pages should add a typed project record first, add the route manifest entry, reuse `src/pages/projects/CaseStudy.module.scss` and existing `layouts/Project` patterns, then run `bun run check:site-routes` before adding a new visual grammar. +- Raw hex, `rgba()`, `linear-gradient()`, `100vw`, `100vh`, raw radius, raw duration, and raw easing values are design-system risks. They are allowed only when captured in `scripts/data/design-token-baseline.tsv` with a reason. +- Metadata colors in `_document.page.tsx` are allowed because browser chrome cannot consume CSS variables reliably. + +## Layout And Responsiveness + +- Prefer scrollbar-safe sizing over `width: 100vw`. +- Prefer dynamic viewport units or content-driven sizing over `height: 100vh` and `min-height: 100vh` on mobile-sensitive sections. +- Keep focus states visible, touch targets large enough for coarse pointers, and text readable when browser text scaling increases. + +## Motion And 3D + +- Framer Motion and Three.js are part of the current identity, but new motion must honor reduced-motion behavior and should not be required to understand content. +- Prefer CSS/browser primitives when they replace custom scroll listeners or route animation code without losing accessibility. + +## Validation + +Run `bun run check:design-tokens` for UI/CSS changes. Run `bun run agentic:check` before handoff. Use `bun run agentic:browser-proof` for layout, route, Storybook, motion, 3D, or public-page changes. diff --git a/README.md b/README.md index 791afed..b35e8aa 100644 --- a/README.md +++ b/README.md @@ -12,15 +12,19 @@ Make sure you have Node.js `22.22.1` and Bun `1.3.10` installed. This repo uses bun install --frozen-lockfile ``` -Once it's done start up a local server with: +Once it's done, start the full local environment with: ```bash bun run dev ``` -To view the components storybook: +That launches the Next site on `3201` and Storybook on `3202` through the +repo-native coordinator in `scripts/dev.mjs`. Press Ctrl-C to stop both. + +To run only the Next site or only Storybook: ```bash +bun run dev:site bun run storybook ``` diff --git a/bun.lock b/bun.lock index fb4f7f7..48aba74 100644 --- a/bun.lock +++ b/bun.lock @@ -37,11 +37,13 @@ "fs-extra": "^10.1.0", "globby": "^13.2.2", "mdx-bundler": "10.1.1", + "modern-web-guidance": "^0.0.169", "postcss": "^8.5.14", "postcss-flexbugs-fixes": "^5.0.2", "postcss-preset-env": "^7.8.3", "prettier": "^2.8.8", "sass": "^1.99.0", + "sharp": "0.34.5", "storybook": "10.3.6", "typescript": "4.9.5", }, @@ -791,7 +793,7 @@ "@types/html-minifier-terser": ["@types/html-minifier-terser@6.1.0", "", {}, "sha512-oh/6byDPnL1zeNXFrDXFLyZjkr1MsBG667IM792caf1L2UPOOMf65NFzjUH/ltyfwjAGfs1rsX1eftK0jC/KIg=="], - "@types/json-schema": ["@types/json-schema@7.0.11", "", {}, "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="], + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json5": ["@types/json5@0.0.29", "", {}, "sha1-7ihweulOEdK4J7y+UnC86n8+ce4="], @@ -1783,6 +1785,8 @@ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="], + "modern-web-guidance": ["modern-web-guidance@0.0.169", "", { "bin": { "modern-web-guidance": "skills/modern-web-guidance/modern-web.mjs" } }, "sha512-grDxBu7SflBSyKMYlgKtp0eE276LQAUIr/4qVKgnZouvlA0VLpszigYpmFfl+URqYxqjLr0u4Er3KiHpIAs6RQ=="], + "module-details-from-path": ["module-details-from-path@1.0.4", "", {}, "sha512-EGWKgxALGMgzvxYF1UyGTy0HXX/2vHLkw6+NvDKW2jypWbHpjQuj4UMcqQWXHERJhVGKikolT06G3bcKe4fi7w=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -2457,6 +2461,8 @@ "@types/eslint/@types/estree": ["@types/estree@0.0.51", "", {}, "sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ=="], + "@types/eslint/@types/json-schema": ["@types/json-schema@7.0.11", "", {}, "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="], + "@types/mysql/@types/node": ["@types/node@18.7.21", "", {}, "sha512-rLFzK5bhM0YPyCoTC8bolBjMk7bwnZ8qeZUBslBfjZQou2ssJdWslx9CZ8DGM+Dx7QXQiiTVZ/6QO6kwtHkZCA=="], "@types/pg/@types/node": ["@types/node@18.7.21", "", {}, "sha512-rLFzK5bhM0YPyCoTC8bolBjMk7bwnZ8qeZUBslBfjZQou2ssJdWslx9CZ8DGM+Dx7QXQiiTVZ/6QO6kwtHkZCA=="], @@ -2465,6 +2471,8 @@ "@typescript-eslint/typescript-estree/globby": ["globby@11.1.0", "", { "dependencies": { "array-union": "^2.1.0", "dir-glob": "^3.0.1", "fast-glob": "^3.2.9", "ignore": "^5.2.0", "merge2": "^1.4.1", "slash": "^3.0.0" } }, "sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g=="], + "@typescript-eslint/utils/@types/json-schema": ["@types/json-schema@7.0.11", "", {}, "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="], + "@typescript-eslint/utils/@types/semver": ["@types/semver@7.3.13", "", {}, "sha512-21cFJr9z3g5dW8B0CVI9g2O9beqaThGQ6ZFBqHfwhzLDKUxaqTIy3vnfah/UPkfOiF2pLq+tGz+W8RyCskuslw=="], "@typescript-eslint/utils/eslint-scope": ["eslint-scope@5.1.1", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^4.1.1" } }, "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw=="], @@ -2625,6 +2633,8 @@ "safe-push-apply/isarray": ["isarray@2.0.5", "", {}, "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="], + "schema-utils/@types/json-schema": ["@types/json-schema@7.0.11", "", {}, "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="], + "schema-utils/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="], "snake-case/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], @@ -2649,8 +2659,6 @@ "watchpack/graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="], - "webpack/@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], - "webpack/enhanced-resolve": ["enhanced-resolve@5.21.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.3" } }, "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ=="], "webpack/es-module-lexer": ["es-module-lexer@2.1.0", "", {}, "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ=="], @@ -2731,6 +2739,8 @@ "eslint/debug/ms": ["ms@2.1.2", "", {}, "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="], + "fork-ts-checker-webpack-plugin/schema-utils/@types/json-schema": ["@types/json-schema@7.0.11", "", {}, "sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ=="], + "fork-ts-checker-webpack-plugin/schema-utils/ajv-keywords": ["ajv-keywords@3.5.2", "", { "peerDependencies": { "ajv": "^6.9.1" } }, "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ=="], "html-minifier-terser/terser/@jridgewell/source-map": ["@jridgewell/source-map@0.3.2", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.0", "@jridgewell/trace-mapping": "^0.3.9" } }, "sha512-m7O9o2uR8k2ObDysZYzdfhb08VuEml5oWGiosa1VdaPZ/A6QyPkAJuwN0Q1lhULOf6B7MtQmHENS743hWtCrgw=="], diff --git a/docs/agentic-mcp-tooling-runbook.md b/docs/agentic-mcp-tooling-runbook.md new file mode 100644 index 0000000..022735e --- /dev/null +++ b/docs/agentic-mcp-tooling-runbook.md @@ -0,0 +1,62 @@ +# Agentic MCP and Tooling Runbook + +Status: operating guide for Portfolio website work and the current public read-only WebMCP pilot. + +Use MCP-related tools by role. Repo-native checks and browser proof are the authority; MCP/browser assistants are advisory inspection layers. + +## Operating Principles + +- Repo-native proof is the source of truth. Do not treat local MCP availability as proof that the website is correct. +- WebMCP is client-side and page-scoped. It exposes tools from the browser tab, not from a backend MCP server. +- The current WebMCP pilot is read-only and public-safe. It must not submit contact forms, read analytics, expose unpublished drafts, infer private client context, perform deployment actions, or run hidden background actions. +- Browser MCP runs must use an isolated or non-default profile. Do not connect agent tooling to a personal browser profile if private tabs, cookies, analytics, or contact context could be exposed. +- No project-scoped `.mcp.json` is currently the contract for this repo. Treat MCP availability as local/session-specific unless a future change explicitly adds project-scoped MCP config. + +## Current Tool Map + +| Tool surface | Primary use | Do not use it for | Proof surface | +| --- | --- | --- | --- | +| `AGENTS.md` | Always-loaded repo rules, design guardrails, browser proof expectations, and WebMCP safety boundary. | Replacing source inspection. | Read before work starts. | +| `DESIGN.md`, theme tokens, CSS modules, Storybook context | Portfolio identity, component behavior, visual constraints, and design-token usage. | One-off visual systems or raw token drift. | `bun run check:design-tokens`; Storybook build when relevant. | +| `bun run agentic:check` | Fast proof: route guard, design-token guard, and lint. | Final browser/runtime sign-off. | Must pass or have explicit caveats for existing warnings. | +| `bun run agentic:browser-proof` | Full route proof: Next build, screenshots, accessibility summaries, console/page errors, `/llms.txt`, reduced motion, WebMCP discovery, and Storybook build. | Manual taste review. | Inspect `output/agentic-browser-proof/report.json` and screenshots for affected routes. | +| Storybook | Component state discovery and isolated UI review. | Public-route proof by itself. | `bun run storybook` for exploration; `bun run build:storybook` in full proof. | +| WebMCP runtime pilot in `src/utils/webmcp.ts` | Read-only public page tools: `describe_portfolio_page`, `find_portfolio_project_link`. | Contact submission, analytics, unpublished drafts, private client details, deployment actions, or hidden behavior. | Browser proof must detect only expected tools; expansion requires a spec and forbidden-state evals. | +| Chrome/Brave DevTools MCP | Advisory browser inspection for DOM, accessibility, console, network, performance, media/3D issues, and WebMCP tool listing. | A replacement for repo checks or a connection to personal browser profile state. | Isolated profile, route URL, viewport, screenshot or snapshot, console/network summary, and tool listing. | +| Playwright/CDP scripts | Repeatable route flows and screenshot/regression proof. | Broad design approval. | Prefer repo scripts before ad hoc automation. | +| Figma/Stitch/design prompts | Design direction and component comparison. | Runtime proof, accessibility proof, or WebMCP privacy proof. | Map decisions back to `DESIGN.md`, tokens, Storybook, and route screenshots. | + +## Website Workflow + +1. Define the route or surface: homepage, contact, project page, case-study component, Storybook component, or media/3D section. +2. Read the relevant guidance before editing: + - `AGENTS.md` + - `DESIGN.md` + - `docs/agentic-webmcp-strategy.md` + - `docs/chrome-platform-tracker.md` +3. Use Modern Web Guidance before browser-facing platform changes, accessibility work, animation/motion changes, or WebMCP changes. +4. Make the smallest coherent change and keep Portfolio identity intact. +5. Run source proof: + ```sh + bun run agentic:check + ``` +6. Run full browser proof when route layout, interaction, motion, public route coverage, Storybook, or WebMCP behavior changes: + ```sh + bun run agentic:browser-proof + ``` +7. Use DevTools MCP only when the repo proof cannot explain a runtime issue. Keep it isolated and capture evidence. +8. Summarize closure with commands, results, screenshots/reports inspected, and any remaining manual risk. + +## WebMCP Expansion Rule + +Before adding any new WebMCP tool, write an approval-ready spec that includes: + +- tool name, description, and input schema; +- the exact visible UI state that makes the tool available; +- public/private data boundary; +- forbidden states and forbidden outputs; +- confirmation requirements for any future write-like behavior; +- wrong-tool, wrong-argument, stale-state, contact/no-private-context evals; +- proof commands and expected `list_webmcp_tools` output. + +Do not add WebMCP tools for contact submission, email actions, analytics access, unpublished drafts, deployment actions, credentials, or hidden admin/background behavior without explicit approval and a separate proof plan. diff --git a/docs/agentic-webmcp-strategy.md b/docs/agentic-webmcp-strategy.md new file mode 100644 index 0000000..9eaa9b7 --- /dev/null +++ b/docs/agentic-webmcp-strategy.md @@ -0,0 +1,48 @@ +# Portfolio WebMCP Strategy + +Status: approved public read-only runtime pilot. Keep the implementation limited to `src/utils/webmcp.ts`. + +Related operating guide: `docs/agentic-mcp-tooling-runbook.md`. + +## Candidate Visible Tools + +- Public pages: summarize visible case studies, project sections, writing, and navigation links. +- Contact surface: explain visible form requirements and submission states without submitting on the user's behalf. +- Media/3D sections: describe visible controls and reduced-motion alternatives. +- Storybook: support visible component review in local development only. + +## Implemented Pilot Tools + +- `describe_portfolio_page`: read-only summary of the current visible public page, headings, form labels, reduced-motion state, and optional visible links. +- `find_portfolio_project_link`: read-only lookup across project links visible on the current page. + +## Forbidden Tools + +- Contact-form submission, email actions, private analytics, deployment secrets, unpublished drafts, or hidden admin state. +- Background-only actions, destructive operations, bulk content changes, or cross-origin data extraction. +- Any tool that claims private employment, client, or project details not present on the public site. + +## User Confirmation And Public Safety + +- Runtime tools must be visible, page-scoped, and public-safe by default. +- Contact submission, email actions, deployment actions, analytics access, or unpublished-draft access are not WebMCP candidates. +- Read-only tools may describe visible DOM/accessibility-tree state and local Storybook state only. + +## Chrome DevTools MCP Proof Profile + +- Prefer the repo browser lane first: route screenshots/DOM, accessibility summaries, console/page errors, `/llms.txt`, reduced-motion state, WebMCP discovery, and Storybook build proof. +- Use Chrome DevTools MCP only as an additional proof pass for browser-runtime issues, network/performance traces, 3D/media debugging, or WebMCP discovery checks that the repo lane cannot explain. +- Run MCP proof from an isolated or non-default Chrome profile. Do not connect agent tooling to a normal profile when the inspected surface can expose private browser profile data, cookies, private tabs, analytics, unpublished drafts, or contact context. +- The proof bundle for any runtime candidate must include: route/surface, viewport, screenshot, DOM or accessibility snapshot, console/page error summary, network/performance notes when relevant, `/llms.txt` result, reduced-motion result, and `list_webmcp_tools` output. + +## Proof Before Runtime + +- `bun run agentic:check` is stable and Storybook/browser proof passes for affected surfaces. +- `bun run agentic:browser-proof` records route screenshots, accessibility summaries, console/page error status, `/llms.txt` status, reduced-motion status, and WebMCP discovery status in `output/agentic-browser-proof/`. +- Console health, accessibility names, focus states, and reduced-motion behavior are checked before exposing tools. +- Tool descriptions are read-only by default and page-scoped. +- Chrome DevTools MCP or Puppeteer WebMCP proves expected tool discovery, no forbidden tools, valid schema, graceful errors, and no contact submission side effect. + +## Runtime Expansion Spec + +Before expanding beyond the current read-only pilot, write an approval-ready spec that lists candidate visible tools, forbidden tools, confirmation rules, the public/privacy boundary, input and output schema tests, wrong-tool and wrong-argument evals, contact/no-private-context evals, and the exact proof commands. Do not add contact submission, email actions, analytics access, unpublished-draft access, or hidden admin behavior as WebMCP tools. diff --git a/docs/chrome-platform-tracker.md b/docs/chrome-platform-tracker.md new file mode 100644 index 0000000..009ae83 --- /dev/null +++ b/docs/chrome-platform-tracker.md @@ -0,0 +1,26 @@ +# Chrome Platform Tracker + +Last refreshed: 2026-05-24 + +Guidance sources: `modern-web-guidance@latest`, Chrome same-document View Transitions, Chrome WebMCP, Lighthouse Registered WebMCP tools, Chrome soft-navigation measurement, Chrome DevTools Performance reference, and Chrome DevTools MCP. + +| Feature | Current adoption | Candidate surface | Risk | Proof command | Status | +| --- | --- | --- | --- | --- | --- | +| `/llms.txt` and public portfolio context | Public `llms.txt` and agentic browser proof are present. | Homepage, project pages, case studies, writing, and Storybook. | Agent context must not imply private client/employment details. | `bun run agentic:browser-proof` | ship | +| Semantic, native, accessible DOM | AGENTS requires Baseline Widely Available, design-token checks, accessibility summaries, and reduced-motion proof. | Public routes, case studies, contact surface, and Storybook. | Editorial/3D atmosphere can obscure headings, focus, or accessible names. | `bun run agentic:check` | ship | +| View Transitions | No current production use found. | Route and case-study navigation if the existing Next.js flow has a clear user-facing benefit. | Transition work can add motion and routing complexity without improving reading/navigation. | Prototype in a plan first; browser-proof route screenshots and reduced motion before shipping. | prototype | +| WebMCP runtime tools | Approved public read-only pilot in `src/utils/webmcp.ts`; tools describe visible page state and visible project links only. | Public route and case-study explanation tools. | Contact submission, analytics, unpublished drafts, or private client context are forbidden. | Chrome DevTools MCP `list_webmcp_tools` must return only expected visible tools. | pilot | +| Chrome DevTools MCP proof | Browser proof records screenshots, accessibility summaries, console/page errors, `/llms.txt`, reduced motion, WebMCP discovery, and Storybook build. | Public routes, 3D/media sections, and Storybook review. | Real-profile MCP proof can expose private browsing/profile data. | `bun run agentic:browser-proof`; isolated Chrome DevTools MCP when needed. | ship | +| Core Web Vitals | Next core-web-vitals lint config is present; no explicit SPA soft-navigation measurement plan. | Public routes and media-heavy project/case-study pages. | 3D/media can affect LCP/INP/CLS; soft-navigation measurement remains developing. | Plan first: capture LCP, INP, CLS, route label, `navigationType`, and media/3D state. | watch | +| 3D accessibility, HTML-in-Canvas, Declarative Partial Updates, `streamHTML` | 3D is presentation support; experimental APIs are not production dependencies. | 3D accessibility research only where semantic DOM fallback is insufficient. | Canvas/3D text can disappear from the accessibility tree. | Spike requires DOM/AX fallback proof and reduced-motion proof. | watch | + +## Adoption Notes + +- Keep Portfolio public-safe and editorial: visible DOM, reduced motion, and Storybook proof come before new platform experiments. +- WebMCP runtime is approved only for the current public read-only pilot. Expansion still needs an explicit tool spec and proof pass. +- View Transitions should start as a narrow route/case-study prototype, not a site-wide motion rewrite. + +## Operational Follow-Up + +- Repo-native task surface: `.plans/features/modern-css-web-ui-primitives/spec.md` under `Next Proof Task`. +- Next proof task: prototype View Transitions only after a route/case-study proof plan exists, then run `bun run agentic:browser-proof` or isolated Chrome DevTools MCP with route/surface URL, viewport, screenshot/DOM or accessibility snapshot, console/page errors, `/llms.txt`, reduced motion, CWV/media context, and `list_webmcp_tools`. diff --git a/next.config.js b/next.config.js index 4692e46..9ba7dc3 100644 --- a/next.config.js +++ b/next.config.js @@ -11,7 +11,7 @@ const { withSentryConfig } = require('@sentry/nextjs') const nextConfig = { reactStrictMode: true, trailingSlash: true, - pageExtensions: ['page.js', 'page.ts', 'api.js', 'api.ts'], + pageExtensions: ['page.js', 'page.ts', 'page.tsx', 'api.js', 'api.ts'], webpack(config, { isServer }) { // Run custom scripts if (isServer) { diff --git a/package.json b/package.json index 72e10bc..478efba 100644 --- a/package.json +++ b/package.json @@ -7,19 +7,26 @@ "repository": "https://github.com/Oba-One/portfolio.git", "author": "Afolabi Aiyeloja ", "scripts": { - "dev": "next dev", - "storybook": "storybook dev -p 9009 -h localhost", + "dev": "node scripts/dev.mjs", + "dev:site": "next dev -p 3201", + "storybook": "storybook dev -p 3202 -h localhost", "build": "next build", - "build:storybook": "storybook build -o build-storybook && node scripts/draco-storybook", - "start": "next start", - "test": "echo 'write some tests'", - "lint": "eslint src .storybook instrumentation.ts instrumentation-client.ts next.config.js sentry.edge.config.js sentry.server.config.js --ext js,jsx,ts,tsx", + "build:storybook": "bunx --bun storybook build -o build-storybook && bun scripts/draco-storybook.js", + "start": "next start -p 3201", + "check:browser-verification-policy": "bun scripts/check-browser-verification-policy.mjs", + "agentic:guidance": "DISABLE_TELEMETRY=1 bun --bun modern-web-guidance search \"agentic frontend CSS accessibility browser validation DevTools MCP\" && DISABLE_TELEMETRY=1 bun --bun modern-web-guidance retrieve accessibility", + "agentic:check": "bun run agentic:guidance && bun run check:browser-verification-policy && bun run check:site-routes && bun run check:design-tokens && bun run typecheck && bun run lint", + "check:site-routes": "bun scripts/check-site-routes.mjs", + "check:design-tokens": "node scripts/check-design-tokens.mjs", + "agentic:browser-proof": "bun scripts/require-authenticated-browser-qa.mjs && bun run build && bun scripts/agentic-browser-proof.mjs && bun run build:storybook", + "agentic:verify": "bun scripts/require-authenticated-browser-qa.mjs && bun run agentic:browser-proof", + "test": "bun run typecheck && bun run lint", + "lint": "eslint src .storybook instrumentation.ts instrumentation-client.ts next.config.js sentry.edge.config.ts sentry.server.config.ts --ext ts,tsx,js,jsx", "prettier": "prettier --write '**/*.{js,jsx,ts,tsx,md,mdx}'", - "optimize:assets": "zsh scripts/optimize-assets.sh", + "generate:social-images": "node scripts/generate-social-images.mjs", + "optimize:assets": "node scripts/optimize-assets.mjs", "optimize:resize": "bunx @squoosh/cli -s '-mobile' --webp auto --resize \"{'enabled':true,'width':'25%','height':'25%','method':'lanczos3'}\" src/assets/*.webp", - "check:browser-verification-policy": "bun scripts/check-browser-verification-policy.mjs", - "agentic:check": "bun run check:browser-verification-policy", - "agentic:browser-proof": "bun scripts/require-authenticated-browser-qa.mjs" + "typecheck": "tsc --noEmit" }, "dependencies": { "@sentry/browser": "10.52.0", @@ -54,11 +61,13 @@ "fs-extra": "^10.1.0", "globby": "^13.2.2", "mdx-bundler": "10.1.1", + "modern-web-guidance": "^0.0.169", "postcss": "^8.5.14", "postcss-flexbugs-fixes": "^5.0.2", "postcss-preset-env": "^7.8.3", "prettier": "^2.8.8", "sass": "^1.99.0", + "sharp": "0.34.5", "storybook": "10.3.6", "typescript": "4.9.5" }, @@ -112,7 +121,14 @@ "no-unused-vars": "warn", "import/no-anonymous-default-export": "off", "react/display-name": "off", - "@next/next/no-img-element": "off" + "@next/next/no-img-element": "off", + "@typescript-eslint/ban-ts-comment": [ + "error", + { + "ts-nocheck": "allow-with-description", + "minimumDescriptionLength": 10 + } + ] } }, "browserslist": { diff --git a/public/favicon.png b/public/favicon.png index 5342548..21733b8 100644 Binary files a/public/favicon.png and b/public/favicon.png differ diff --git a/public/favicon.svg b/public/favicon.svg index a621823..52363af 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1,4 +1,4 @@ - - + + diff --git a/public/humans.txt b/public/humans.txt index 3378557..b094dc7 100644 --- a/public/humans.txt +++ b/public/humans.txt @@ -12,7 +12,6 @@ Design tools: Figma Canva -https://twitter.com/Afolabi_A_A_A github.com/Oba-One _______________________________________________________________________________ diff --git a/public/icon-256.png b/public/icon-256.png index 2e277fe..98b61ef 100755 Binary files a/public/icon-256.png and b/public/icon-256.png differ diff --git a/public/icon-512.png b/public/icon-512.png index ffdc63e..152376a 100755 Binary files a/public/icon-512.png and b/public/icon-512.png differ diff --git a/public/icon.svg b/public/icon.svg index 2cbe635..90ad4b6 100644 --- a/public/icon.svg +++ b/public/icon.svg @@ -1,3 +1,3 @@ - + diff --git a/public/llms.txt b/public/llms.txt new file mode 100644 index 0000000..f4fcafd --- /dev/null +++ b/public/llms.txt @@ -0,0 +1,16 @@ +# Afolabi Aiyeloja Portfolio + +This is Afolabi Aiyeloja's public portfolio site, including public work, writing, case studies, and contact surfaces. + +Public surfaces: +- Public portfolio pages. +- Public case studies, project descriptions, and visual work. +- Public contact form entry points. + +Agent guidance: +- Treat site content as public presentation material. +- Prefer visible pages and public metadata when summarizing the work. +- Respect semantic HTML, accessible controls, reduced-motion behavior, and Baseline Widely Available support. + +Do not infer or expose: +- Private analytics, contact-form secrets, unpublished drafts, email credentials, private deployment data, or internal admin-only workflows. diff --git a/public/manifest.json b/public/manifest.json index 70206dc..f154185 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -20,6 +20,6 @@ ], "start_url": ".", "display": "standalone", - "theme_color": "#b8abee", - "background_color": "#e6dff9" + "theme_color": "#b8c7a3", + "background_color": "#e8efe0" } diff --git a/public/social-image.png b/public/social-image.png index 68a8de3..f2ee81c 100644 Binary files a/public/social-image.png and b/public/social-image.png differ diff --git a/public/social/home.png b/public/social/home.png new file mode 100644 index 0000000..9a09447 Binary files /dev/null and b/public/social/home.png differ diff --git a/public/social/projects/coop.png b/public/social/projects/coop.png new file mode 100644 index 0000000..065882f Binary files /dev/null and b/public/social/projects/coop.png differ diff --git a/public/social/projects/freeport.png b/public/social/projects/freeport.png new file mode 100644 index 0000000..73c39fb Binary files /dev/null and b/public/social/projects/freeport.png differ diff --git a/public/social/projects/gentle-monster.png b/public/social/projects/gentle-monster.png new file mode 100644 index 0000000..e6ad674 Binary files /dev/null and b/public/social/projects/gentle-monster.png differ diff --git a/public/social/projects/green-goods.png b/public/social/projects/green-goods.png new file mode 100644 index 0000000..a4320c2 Binary files /dev/null and b/public/social/projects/green-goods.png differ diff --git a/public/social/projects/greenpill.png b/public/social/projects/greenpill.png new file mode 100644 index 0000000..077e8af Binary files /dev/null and b/public/social/projects/greenpill.png differ diff --git a/public/social/projects/mira-connect.png b/public/social/projects/mira-connect.png new file mode 100644 index 0000000..8ae5490 Binary files /dev/null and b/public/social/projects/mira-connect.png differ diff --git a/public/social/projects/mira-flow.png b/public/social/projects/mira-flow.png new file mode 100644 index 0000000..ad2bcbb Binary files /dev/null and b/public/social/projects/mira-flow.png differ diff --git a/public/social/projects/synesthesia.png b/public/social/projects/synesthesia.png new file mode 100644 index 0000000..d518af5 Binary files /dev/null and b/public/social/projects/synesthesia.png differ diff --git a/public/social/projects/waves.png b/public/social/projects/waves.png new file mode 100644 index 0000000..0a22d49 Binary files /dev/null and b/public/social/projects/waves.png differ diff --git a/public/social/projects/wefa.png b/public/social/projects/wefa.png new file mode 100644 index 0000000..1e4e9c9 Binary files /dev/null and b/public/social/projects/wefa.png differ diff --git a/scripts/agentic-browser-proof.mjs b/scripts/agentic-browser-proof.mjs new file mode 100644 index 0000000..787e279 --- /dev/null +++ b/scripts/agentic-browser-proof.mjs @@ -0,0 +1,557 @@ +#!/usr/bin/env node +import { + accessSync, + constants, + mkdirSync, + readFileSync, + readdirSync, + rmSync, + writeFileSync, +} from 'node:fs' +import { homedir, tmpdir } from 'node:os' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { fileURLToPath } from 'node:url' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const host = '127.0.0.1' +const port = Number(process.env.PORTFOLIO_BROWSER_PROOF_PORT || 3310) +const origin = `http://${host}:${port}` +const routeManifest = JSON.parse( + readFileSync(path.join(repoRoot, 'src/utils/siteRoutes.json'), 'utf8') +) +const defaultBrowserProofRoutes = routeManifest.browserProofRoutes?.length + ? routeManifest.browserProofRoutes + : ['/', '/contact'] +const routes = ( + process.env.PORTFOLIO_BROWSER_PROOF_ROUTES || defaultBrowserProofRoutes.join(',') +) + .split(',') + .map(route => route.trim()) + .filter(Boolean) +const widths = (process.env.PORTFOLIO_BROWSER_PROOF_WIDTHS || '375,1280') + .split(',') + .map(width => Number(width.trim())) + .filter(width => Number.isFinite(width) && width > 0) +const artifactDir = path.join(repoRoot, 'output/agentic-browser-proof') + +function existsExecutable(candidate) { + if (!candidate) return false + try { + accessSync(candidate, constants.X_OK) + return true + } catch { + return false + } +} + +function versionParts(entry) { + const match = entry.match(/(\d+(?:\.\d+)+)/) + return match ? match[1].split('.').map(part => Number(part)) : [] +} + +function compareVersionEntriesDesc(a, b) { + const aParts = versionParts(a) + const bParts = versionParts(b) + for (let index = 0; index < Math.max(aParts.length, bParts.length); index += 1) { + const diff = (bParts[index] || 0) - (aParts[index] || 0) + if (diff !== 0) return diff + } + return b.localeCompare(a) +} + +function discoverCachedChromium() { + const found = [] + const tryGlob = (base, leaf) => { + try { + for (const entry of readdirSync(base).sort(compareVersionEntriesDesc)) { + const candidate = leaf(entry) + if (existsExecutable(candidate)) found.push(candidate) + } + } catch { + // Cache directory may not exist. + } + } + + const home = homedir() + const chromeForTesting = path.join(home, '.cache/chrome-for-testing/chrome') + tryGlob(chromeForTesting, entry => + path.join( + chromeForTesting, + entry, + 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' + ) + ) + tryGlob(chromeForTesting, entry => + path.join( + chromeForTesting, + entry, + 'chrome-mac-x64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' + ) + ) + tryGlob(chromeForTesting, entry => + path.join(chromeForTesting, entry, 'chrome-linux64/chrome') + ) + + const playwright = path.join(home, 'Library/Caches/ms-playwright') + tryGlob(playwright, entry => + path.join(playwright, entry, 'chrome-headless-shell-mac-arm64/chrome-headless-shell') + ) + tryGlob(playwright, entry => + path.join(playwright, entry, 'chrome-headless-shell-mac-x64/chrome-headless-shell') + ) + tryGlob(playwright, entry => + path.join(playwright, entry, 'chrome-mac/Chromium.app/Contents/MacOS/Chromium') + ) + + const puppeteer = path.join(home, '.cache/puppeteer/chrome') + tryGlob(puppeteer, entry => + path.join( + puppeteer, + entry, + 'chrome-mac-arm64/Google Chrome for Testing.app/Contents/MacOS/Google Chrome for Testing' + ) + ) + return found +} + +function findChromeBinary() { + const candidates = [ + process.env.CHROME_BIN, + process.env.CHROMIUM_BIN, + ...discoverCachedChromium(), + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', + '/Applications/Chromium.app/Contents/MacOS/Chromium', + '/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge', + '/usr/bin/google-chrome', + '/usr/bin/chromium', + ].filter(Boolean) + return candidates.find(existsExecutable) || '' +} + +function slugRoute(route, width) { + const slug = + route === '/' + ? 'home' + : route + .replace(/^\/+/, '') + .replace(/[^a-z0-9]+/gi, '-') + .replace(/-$/, '') + return `${slug}-${width}` +} + +async function waitForHttp(url, timeoutMs = 30000) { + const started = Date.now() + let lastError = null + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(url) + if (response.ok) return + lastError = new Error(`HTTP ${response.status}`) + } catch (error) { + lastError = error + } + await new Promise(resolve => setTimeout(resolve, 250)) + } + throw lastError || new Error(`Timed out waiting for ${url}`) +} + +async function auditLlmsTxt() { + const url = `${origin}/llms.txt` + try { + const response = await fetch(url) + const body = await response.text() + return { + status: response.ok && body.trim() ? 'ok' : 'missing', + statusCode: response.status, + contentType: response.headers.get('content-type') || '', + bytes: Buffer.byteLength(body), + } + } catch (error) { + return { + status: 'error', + message: error instanceof Error ? error.message : String(error), + } + } +} + +async function startNextServer() { + const runtimeBinary = + process.env.NODE_BIN || process.env.BUN_BIN || process.execPath || 'bun' + const nextBin = path.join(repoRoot, 'node_modules/next/dist/bin/next') + const child = spawn(runtimeBinary, [nextBin, 'start', '-H', host, '-p', String(port)], { + cwd: repoRoot, + env: { + ...process.env, + NODE_ENV: 'production', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + child.stdout.on('data', chunk => process.stdout.write(`[next] ${chunk}`)) + child.stderr.on('data', chunk => process.stderr.write(`[next] ${chunk}`)) + await waitForHttp(origin) + return child +} + +async function launchChrome() { + const chromeBinary = findChromeBinary() + if (!chromeBinary) + throw new Error('No Chrome or Chromium binary found for browser proof.') + + const userDataDir = path.join( + tmpdir(), + `portfolio-agentic-browser-proof-${process.pid}` + ) + const args = [ + '--headless=new', + '--disable-gpu', + '--disable-dev-shm-usage', + '--disable-background-networking', + '--disable-extensions', + '--no-first-run', + '--no-default-browser-check', + '--enable-features=WebMCPTesting,DevToolsWebMCPSupport', + '--remote-debugging-port=0', + `--user-data-dir=${userDataDir}`, + 'about:blank', + ] + const child = spawn(chromeBinary, args, { stdio: ['ignore', 'ignore', 'pipe'] }) + const wsUrl = await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Timed out waiting for Chrome DevTools endpoint.')), + 15000 + ) + let stderr = '' + let resolved = false + child.stderr.on('data', chunk => { + stderr += chunk.toString() + const match = chunk.toString().match(/DevTools listening on (ws:\/\/\S+)/) + if (match) { + resolved = true + clearTimeout(timeout) + resolve(match[1]) + } + }) + child.once('exit', (code, signal) => { + if (resolved) return + clearTimeout(timeout) + reject( + new Error( + `Chrome exited before DevTools endpoint was ready: ${ + code ?? signal + }\n${stderr.trim()}` + ) + ) + }) + }) + + return { + wsUrl, + async close() { + child.kill('SIGTERM') + rmSync(userDataDir, { recursive: true, force: true }) + }, + } +} + +class CdpClient { + constructor(wsUrl) { + this.ws = new WebSocket(wsUrl) + this.nextId = 1 + this.pending = new Map() + this.events = new Map() + this.ready = new Promise((resolve, reject) => { + this.ws.addEventListener('open', resolve, { once: true }) + this.ws.addEventListener('error', reject, { once: true }) + }) + this.ws.addEventListener('message', event => this.handleMessage(event)) + } + + handleMessage(event) { + const message = JSON.parse(event.data) + if (message.id && this.pending.has(message.id)) { + const { resolve, reject } = this.pending.get(message.id) + this.pending.delete(message.id) + if (message.error) + reject(new Error(message.error.message || JSON.stringify(message.error))) + else resolve(message.result || {}) + return + } + if (message.method && this.events.has(message.method)) { + for (const listener of this.events.get(message.method)) + listener(message.params || {}) + } + } + + async send(method, params = {}, sessionId) { + await this.ready + const id = this.nextId++ + this.ws.send(JSON.stringify({ id, method, params, sessionId })) + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + setTimeout(() => { + if (!this.pending.has(id)) return + this.pending.delete(id) + reject(new Error(`CDP timeout: ${method}`)) + }, 15000) + }) + } + + close() { + this.ws.close() + } +} + +async function evaluate(client, sessionId, expression) { + const result = await client.send( + 'Runtime.evaluate', + { + expression, + awaitPromise: true, + returnByValue: true, + }, + sessionId + ) + if (result.exceptionDetails) { + throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed') + } + return result.result?.value +} + +async function waitForExpression(client, sessionId, expression, timeoutMs = 15000) { + const started = Date.now() + while (Date.now() - started < timeoutMs) { + if (await evaluate(client, sessionId, expression).catch(() => false)) return + await new Promise(resolve => setTimeout(resolve, 100)) + } + throw new Error(`Timed out waiting for expression: ${expression}`) +} + +function summarizeAxTree(nodes) { + return nodes + .map(node => ({ + role: node.role?.value || '', + name: node.name?.value || '', + })) + .filter(node => node.role || node.name) + .slice(0, 160) +} + +async function verifyRoute(client, route, width) { + const target = await client.send('Target.createTarget', { url: 'about:blank' }) + const attached = await client.send('Target.attachToTarget', { + targetId: target.targetId, + flatten: true, + }) + const sessionId = attached.sessionId + const height = width <= 500 ? 900 : 900 + const reduceMotion = width <= 500 + const slug = slugRoute(route, width) + const screenshotPath = path.join(artifactDir, `${slug}.png`) + const axPath = path.join(artifactDir, `${slug}.ax.json`) + + try { + await client.send('Page.enable', {}, sessionId) + await client.send('Runtime.enable', {}, sessionId) + await client.send('Accessibility.enable', {}, sessionId) + await client.send( + 'Page.addScriptToEvaluateOnNewDocument', + { + source: ` + window.__agenticProof = { consoleErrors: [], pageErrors: [] }; + const originalConsoleError = console.error; + console.error = (...args) => { + window.__agenticProof.consoleErrors.push(args.map(String).join(' ')); + originalConsoleError.apply(console, args); + }; + window.addEventListener('error', (event) => { + window.__agenticProof.pageErrors.push(event.message || 'unknown error'); + }); + window.addEventListener('unhandledrejection', (event) => { + window.__agenticProof.pageErrors.push(String(event.reason || 'unhandled rejection')); + }); + `, + }, + sessionId + ) + await client.send( + 'Emulation.setDeviceMetricsOverride', + { + width, + height, + deviceScaleFactor: 1, + mobile: width <= 500, + }, + sessionId + ) + await client.send( + 'Emulation.setEmulatedMedia', + { + features: [ + { + name: 'prefers-reduced-motion', + value: reduceMotion ? 'reduce' : 'no-preference', + }, + ], + }, + sessionId + ) + + const response = await fetch(`${origin}${route}`) + await client.send('Page.navigate', { url: `${origin}${route}` }, sessionId) + await waitForExpression(client, sessionId, "document.readyState === 'complete'") + await new Promise(resolve => setTimeout(resolve, 300)) + + const runtime = await evaluate( + client, + sessionId, + ` + (async () => { + const proof = window.__agenticProof || { consoleErrors: [], pageErrors: [] }; + const modelContext = 'modelContext' in navigator ? navigator.modelContext : undefined; + const testing = 'modelContextTesting' in navigator ? navigator.modelContextTesting : undefined; + const testingTools = typeof testing?.listTools === 'function' + ? await Promise.resolve(testing.listTools()).then((tools) => tools.map((tool) => ({ + name: tool.name || '', + description: tool.description || '' + }))).catch(() => []) + : []; + const declarativeTools = [...document.querySelectorAll('form[toolname], form[tooldescription]')].map((form) => ({ + name: form.getAttribute('toolname') || '', + description: form.getAttribute('tooldescription') || '' + })); + const overflowElements = [...document.querySelectorAll('body *')] + .map((element) => { + const rect = element.getBoundingClientRect(); + return { + tag: element.tagName.toLowerCase(), + className: typeof element.className === 'string' ? element.className : '', + text: (element.textContent || '').trim().replace(/\\s+/g, ' ').slice(0, 80), + left: Math.round(rect.left), + right: Math.round(rect.right), + width: Math.round(rect.width) + }; + }) + .filter((element) => element.right > window.innerWidth + 1 || element.left < -1) + .slice(0, 12); + return { + title: document.title, + mainExists: Boolean(document.querySelector('main')), + reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches, + horizontalOverflow: document.documentElement.scrollWidth > window.innerWidth + 1, + viewportWidth: window.innerWidth, + scrollWidth: document.documentElement.scrollWidth, + overflowElements, + consoleErrors: proof.consoleErrors, + pageErrors: proof.pageErrors, + webMcp: { + status: Boolean(modelContext) || declarativeTools.length > 0 || testingTools.length > 0 ? 'detected' : 'not_configured', + navigatorModelContext: Boolean(modelContext), + registerToolType: typeof modelContext?.registerTool, + declarativeTools, + testingTools + } + }; + })() + ` + ) + + const axTree = await client.send('Accessibility.getFullAXTree', {}, sessionId) + const screenshot = await client.send( + 'Page.captureScreenshot', + { format: 'png', captureBeyondViewport: true }, + sessionId + ) + writeFileSync(screenshotPath, Buffer.from(screenshot.data, 'base64')) + writeFileSync( + axPath, + `${JSON.stringify(summarizeAxTree(axTree.nodes || []), null, 2)}\n` + ) + + const violations = [] + if (!response.ok) violations.push(`HTTP ${response.status}`) + if (!runtime.mainExists) violations.push('missing main landmark') + if (runtime.horizontalOverflow) violations.push('horizontal overflow') + if (runtime.consoleErrors.length) + violations.push(`${runtime.consoleErrors.length} console error(s)`) + if (runtime.pageErrors.length) + violations.push(`${runtime.pageErrors.length} page error(s)`) + + return { + route, + width, + statusCode: response.status, + title: runtime.title, + reducedMotion: runtime.reducedMotion, + mainExists: runtime.mainExists, + horizontalOverflow: runtime.horizontalOverflow, + viewportWidth: runtime.viewportWidth, + scrollWidth: runtime.scrollWidth, + overflowElements: runtime.overflowElements, + consoleErrors: runtime.consoleErrors, + pageErrors: runtime.pageErrors, + webMcp: runtime.webMcp, + artifacts: { + screenshot: path.relative(repoRoot, screenshotPath), + accessibilitySummary: path.relative(repoRoot, axPath), + }, + violations, + } + } finally { + await client.send('Target.closeTarget', { targetId: target.targetId }).catch(() => {}) + } +} + +async function main() { + mkdirSync(artifactDir, { recursive: true }) + let server + let chrome + let client + const results = [] + try { + server = await startNextServer() + const llmsTxt = await auditLlmsTxt() + chrome = await launchChrome() + client = new CdpClient(chrome.wsUrl) + + for (const route of routes) { + for (const width of widths) { + const result = await verifyRoute(client, route, width) + results.push(result) + const status = result.violations.length + ? `FAIL ${result.violations.join(', ')}` + : 'ok' + console.log(`[agentic-browser-proof] ${route} @${width}: ${status}`) + } + } + + const report = { + generatedAt: new Date().toISOString(), + origin, + routes, + widths, + llmsTxt, + results, + } + const reportPath = path.join(artifactDir, 'report.json') + writeFileSync(reportPath, `${JSON.stringify(report, null, 2)}\n`) + + const hardCount = results.reduce((sum, result) => sum + result.violations.length, 0) + console.log( + `[agentic-browser-proof] wrote ${path.relative( + repoRoot, + reportPath + )} with ${hardCount} violation(s).` + ) + if (hardCount > 0) process.exitCode = 1 + } finally { + client?.close() + if (chrome) await chrome.close() + server?.kill('SIGTERM') + } +} + +main().catch(error => { + console.error(error) + process.exit(1) +}) diff --git a/scripts/check-browser-verification-policy.mjs b/scripts/check-browser-verification-policy.mjs index 03c6c50..18205f4 100644 --- a/scripts/check-browser-verification-policy.mjs +++ b/scripts/check-browser-verification-policy.mjs @@ -1,23 +1,23 @@ #!/usr/bin/env bun -import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs'; -import { join, relative } from 'node:path'; +import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' -const repoRoot = process.cwd(); -const requiredPolicyFiles = ["AGENTS.md"]; +const repoRoot = process.cwd() +const requiredPolicyFiles = ['AGENTS.md'] -const failures = []; +const failures = [] function fail(message) { - failures.push(message); + failures.push(message) } function read(relPath) { - return readFileSync(join(repoRoot, relPath), 'utf8'); + return readFileSync(join(repoRoot, relPath), 'utf8') } function walk(dir, result = []) { - if (!existsSync(dir)) return result; + if (!existsSync(dir)) return result for (const entry of readdirSync(dir)) { if ( [ @@ -32,31 +32,31 @@ function walk(dir, result = []) { 'output', ].includes(entry) ) { - continue; + continue } - const fullPath = join(dir, entry); - let stat; + const fullPath = join(dir, entry) + let stat try { - stat = statSync(fullPath); + stat = statSync(fullPath) } catch { - continue; + continue } if (stat.isDirectory()) { - walk(fullPath, result); + walk(fullPath, result) } else if (entry === 'AGENTS.md' || entry === 'CLAUDE.md') { - result.push(fullPath); + result.push(fullPath) } } - return result; + return result } for (const relPath of requiredPolicyFiles) { if (!existsSync(join(repoRoot, relPath))) { - fail(`${relPath}: missing required authenticated-browser QA policy file`); - continue; + fail(`${relPath}: missing required authenticated-browser QA policy file`) + continue } - const text = read(relPath); + const text = read(relPath) for (const phrase of [ 'authenticated Brave QA profile', 'Codex browser-extension path', @@ -66,7 +66,7 @@ for (const relPath of requiredPolicyFiles) { 'If authenticated Brave access is blocked, stop and report QA as blocked', ]) { if (!text.includes(phrase)) { - fail(`${relPath}: missing required authenticated-browser QA phrase "${phrase}"`); + fail(`${relPath}: missing required authenticated-browser QA phrase "${phrase}"`) } } } @@ -90,55 +90,66 @@ const stalePatterns = [ /Brave-only QA/i, /Chrome\/Edge QA profiles/i, /for Brave-only QA/i, -]; +] for (const filePath of walk(repoRoot)) { - const relPath = relative(repoRoot, filePath); - const text = readFileSync(filePath, 'utf8'); + const relPath = relative(repoRoot, filePath) + const text = readFileSync(filePath, 'utf8') for (const pattern of stalePatterns) { if (pattern.test(text)) { - fail(`${relPath}: stale isolated-browser QA guidance matched ${pattern}`); + fail(`${relPath}: stale isolated-browser QA guidance matched ${pattern}`) } } } -const packageJsonPath = join(repoRoot, 'package.json'); +const packageJsonPath = join(repoRoot, 'package.json') if (existsSync(packageJsonPath)) { - const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')); - const scripts = packageJson.scripts ?? {}; + const packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8')) + const scripts = packageJson.scripts ?? {} - if (scripts['check:browser-verification-policy'] !== 'bun scripts/check-browser-verification-policy.mjs') { - fail('package.json: missing check:browser-verification-policy script'); + if ( + scripts['check:browser-verification-policy'] !== + 'bun scripts/check-browser-verification-policy.mjs' + ) { + fail('package.json: missing check:browser-verification-policy script') } if (!scripts['agentic:check']?.includes('check:browser-verification-policy')) { - fail('package.json: agentic:check must run check:browser-verification-policy'); + fail('package.json: agentic:check must run check:browser-verification-policy') } if ( scripts['agentic:browser-proof'] && - !scripts['agentic:browser-proof'].startsWith('bun scripts/require-authenticated-browser-qa.mjs') + !scripts['agentic:browser-proof'].startsWith( + 'bun scripts/require-authenticated-browser-qa.mjs' + ) ) { - fail('package.json: local agentic:browser-proof must be guarded by require-authenticated-browser-qa.mjs'); + fail( + 'package.json: local agentic:browser-proof must be guarded by require-authenticated-browser-qa.mjs' + ) } if ( scripts['agentic:verify'] && scripts['agentic:verify'].includes('ui:verify') && - !scripts['agentic:verify'].startsWith('bun scripts/require-authenticated-browser-qa.mjs') + !scripts['agentic:verify'].startsWith( + 'bun scripts/require-authenticated-browser-qa.mjs' + ) ) { - fail('package.json: local agentic:verify browser lane must be guarded by require-authenticated-browser-qa.mjs'); + fail( + 'package.json: local agentic:verify browser lane must be guarded by require-authenticated-browser-qa.mjs' + ) } } if (failures.length > 0) { - console.error('Authenticated browser QA policy check failed:'); + console.error('Authenticated browser QA policy check failed:') for (const failure of failures) { - console.error(`- ${failure}`); + console.error(`- ${failure}`) } - process.exit(1); + process.exit(1) } console.log( - `Authenticated browser QA policy check passed for ${requiredPolicyFiles.length} guidance file(s).`, -); + `Authenticated browser QA policy check passed for ${requiredPolicyFiles.length} guidance file(s).` +) diff --git a/scripts/check-design-tokens.mjs b/scripts/check-design-tokens.mjs new file mode 100644 index 0000000..aa293e3 --- /dev/null +++ b/scripts/check-design-tokens.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +import { existsSync, readFileSync, readdirSync, writeFileSync } from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const baselinePath = path.join(repoRoot, 'scripts/data/design-token-baseline.tsv'); +const tokenSources = new Set(['src/components/ThemeProvider/theme.ts']); +const scanExts = new Set(['.js', '.jsx', '.ts', '.tsx', '.css', '.scss']); +const skipDirs = new Set(['node_modules', '.next', 'build', 'build-storybook', 'coverage']); + +const checks = [ + ['RAW_HEX', /#[0-9A-Fa-f]{3,8}/], + ['RAW_RGBA', /rgba\(/], + ['RAW_LINEAR_GRADIENT', /linear-gradient\(/], + ['VIEWPORT_100VW', /\bwidth\s*:\s*100vw\b/], + ['VIEWPORT_100VH', /\b(?:height|min-height)\s*:\s*100vh\b/], + ['RAW_RADIUS', /\bborder-radius\s*:\s*(?!0\b)[0-9.]+(?:px|rem)\b/], + ['RAW_CUBIC_BEZIER', /cubic-bezier\(/], + ['RAW_DURATION', /\b(?:animation-duration|transition-duration)\s*:\s*[0-9.]+(?:ms|s)\b|transition\s*:[^;]*\b[0-9.]+(?:ms|s)\b/], + ['RAW_ANIMATION_DURATION', /\banimation\s*:[^;]*\b[0-9.]+(?:ms|s)\b/], + ['FOCUS_OUTLINE_SUPPRESSION', /\boutline\s*:\s*(?:0|none)\b/], + ['TOUCH_TARGET_MIN_SIZE', /\bmin-(?:width|height)\s*:\s*(?:[0-9]|[1-3][0-9]|4[0-3])px\b/], + ['FORCED_COLORS_OPTOUT', /\bforced-color-adjust\s*:\s*none\b|@media\s*\([^)]*forced-colors[^)]*:\s*none/i], +]; + +function walk(dir, files = []) { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + if (skipDirs.has(entry.name)) continue; + const abs = path.join(dir, entry.name); + if (entry.isDirectory()) walk(abs, files); + else if (scanExts.has(path.extname(entry.name))) files.push(abs); + } + return files; +} + +function rel(abs) { + return path.relative(repoRoot, abs).split(path.sep).join('/'); +} + +function loadBaseline() { + if (!existsSync(baselinePath)) return []; + return readFileSync(baselinePath, 'utf8') + .split('\n') + .filter((line) => line.trim() && !line.startsWith('#')) + .map((line, index) => { + const [file, code, needle, reason] = line.split('\t'); + if (!file || !code || !needle || !reason) { + throw new Error(`Invalid baseline entry ${index + 1}: expected file, code, needle, reason`); + } + return { file, code, needle, reason }; + }); +} + +function collectHits() { + const hits = []; + const src = path.join(repoRoot, 'src'); + if (!existsSync(src)) return hits; + for (const file of walk(src)) { + const relative = rel(file); + const lines = readFileSync(file, 'utf8').split('\n'); + lines.forEach((line, i) => { + const trimmed = line.trim(); + if (!trimmed) return; + for (const [code, pattern] of checks) { + if (tokenSources.has(relative) && ['RAW_HEX', 'RAW_RGBA', 'RAW_CUBIC_BEZIER', 'RAW_DURATION'].includes(code)) continue; + if (pattern.test(line)) hits.push({ file: relative, line: i + 1, code, text: trimmed }); + pattern.lastIndex = 0; + } + }); + } + return hits; +} + +function partitionBaselineMatches(hits, baseline) { + const remaining = baseline.map((entry) => ({ ...entry, matched: false })); + const unapproved = []; + + for (const hit of hits) { + const match = remaining.find((entry) => !entry.matched && entry.file === hit.file && entry.code === hit.code && hit.text.includes(entry.needle)); + if (match) { + match.matched = true; + } else { + unapproved.push(hit); + } + } + + return { + unapproved, + stale: remaining.filter((entry) => !entry.matched), + }; +} + +function writeBaseline(hits) { + const lines = [ + '# file\tcode\tneedle\treason', + ...hits.map((hit) => `${hit.file}\t${hit.code}\t${hit.text}\tExisting audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface.`), + '', + ]; + writeFileSync(baselinePath, lines.join('\n')); +} + +if (!existsSync(path.join(repoRoot, 'DESIGN.md'))) { + console.error('Missing root DESIGN.md'); + process.exit(1); +} + +const hits = collectHits(); +if (process.argv.includes('--write-baseline')) { + writeBaseline(hits); + console.log(`Wrote ${hits.length} baseline design-token risk(s) to ${path.relative(repoRoot, baselinePath)}`); + process.exit(0); +} + +const baseline = loadBaseline(); +const { unapproved, stale } = partitionBaselineMatches(hits, baseline); + +if (unapproved.length || stale.length) { + if (unapproved.length) { + console.error('Unapproved Portfolio design-token/static CSS risks:'); + for (const hit of unapproved) console.error(`${hit.file}:${hit.line} ${hit.code} ${hit.text}`); + } + if (stale.length) { + console.error('Stale Portfolio design-token baseline entries:'); + for (const entry of stale) console.error(`${entry.file} ${entry.code} ${entry.needle}`); + } + process.exit(1); +} + +console.log(`Portfolio design guard passed: ${hits.length} audited risk(s), 0 unapproved.`); diff --git a/scripts/check-site-routes.mjs b/scripts/check-site-routes.mjs new file mode 100644 index 0000000..f54ab5f --- /dev/null +++ b/scripts/check-site-routes.mjs @@ -0,0 +1,80 @@ +#!/usr/bin/env bun +import { existsSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { projectSlugs, projects } from '../src/constants.ts' +import { + browserProofRoutes, + getProjectRoute, + homepageAnchorRoutes, + navLinks, + projectRoutes, + publicRoutes, +} from '../src/utils/siteRoutes.ts' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const errors = [] + +function routeToPagePath(route) { + if (route === '/') return path.join(repoRoot, 'src/pages/index.page.ts') + return path.join(repoRoot, `src/pages${route}/index.page.ts`) +} + +function assert(condition, message) { + if (!condition) errors.push(message) +} + +const projectSlugSet = new Set(projectSlugs) +const projectRouteSlugs = new Set(projectRoutes.map(route => route.slug)) +const publicRouteSet = new Set(publicRoutes) +const homepageAnchorRouteSet = new Set(homepageAnchorRoutes) + +for (const slug of projectSlugs) { + assert(Object.hasOwn(projects, slug), `Missing project record for ${slug}`) + assert(projectRouteSlugs.has(slug), `Missing project route for ${slug}`) + + const route = getProjectRoute(slug) + assert(publicRouteSet.has(route), `Project route ${route} is missing from publicRoutes`) + assert(projects[slug].cta.link === route, `Project ${slug} CTA link should be ${route}`) + assert(existsSync(routeToPagePath(route)), `Missing page file for ${route}`) +} + +for (const route of projectRoutes) { + assert(projectSlugSet.has(route.slug), `Project route references unknown slug ${route.slug}`) +} + +for (const route of publicRoutes) { + assert(existsSync(routeToPagePath(route)), `Public route ${route} has no page file`) +} + +for (const route of browserProofRoutes) { + assert(publicRouteSet.has(route), `Browser proof route ${route} is not a public route`) +} + +for (const { label, pathname } of navLinks) { + const [baseRoute, hash] = pathname.split('#') + const normalizedBase = baseRoute || '/' + + assert( + publicRouteSet.has(normalizedBase), + `Nav link ${label} points to unknown route ${normalizedBase}` + ) + + if (hash) { + assert( + homepageAnchorRouteSet.has(`${normalizedBase}#${hash}`), + `Nav link ${label} points to unknown homepage anchor ${pathname}` + ) + } +} + +if (errors.length) { + console.error('Portfolio route guard failed:') + for (const error of errors) console.error(`- ${error}`) + process.exit(1) +} + +console.log( + `Portfolio route guard passed: ${publicRoutes.length} public route(s), ${projectRoutes.length} project route(s), ${browserProofRoutes.length} browser proof route(s).` +) diff --git a/scripts/data/design-token-baseline.tsv b/scripts/data/design-token-baseline.tsv new file mode 100644 index 0000000..87d059b --- /dev/null +++ b/scripts/data/design-token-baseline.tsv @@ -0,0 +1,49 @@ +# file code needle reason +src/components/Input/Input.module.css RAW_DURATION transition: background-color 5000s linear 0s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Input/Input.module.css FOCUS_OUTLINE_SUPPRESSION outline: none; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Input/Input.module.css RAW_DURATION transition: background-color 5000s linear 0s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Link/Link.module.css RAW_LINEAR_GRADIENT --filledLineGradient: linear-gradient(rgb(var(--linkColor)), rgb(var(--linkColor))); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Link/Link.module.css RAW_LINEAR_GRADIENT --unfilledLineGradient: linear-gradient( Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Loader/Loader.module.css RAW_ANIMATION_DURATION animation: loaderSpan 1s var(--bezierFastoutSlowin) infinite; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Model/Model.module.css RAW_ANIMATION_DURATION animation: fadeIn 1s ease forwards var(--delay); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Monogram/Monogram.module.css RAW_DURATION transition: opacity 0.1s ease var(--durationM); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Monogram/Monogram.module.css RAW_DURATION transition: opacity 0.1s ease; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Monogram/Monogram.module.css RAW_DURATION transition: transform var(--durationM) var(--bezierFastoutSlowin), opacity 0.1s ease; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Navbar/Navbar.module.scss RAW_DURATION transition: color var(--durationS) ease 0.1s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/components/Section/Section.module.css FOCUS_OUTLINE_SUPPRESSION outline: none; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/App/global.scss FOCUS_OUTLINE_SUPPRESSION outline: none; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/App/global.scss FOCUS_OUTLINE_SUPPRESSION outline: none; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/DisplacementSphere.module.scss VIEWPORT_100VW width: 100vw; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/DisplacementSphere.module.scss RAW_DURATION transition-duration: 3s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss VIEWPORT_100VH height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION transition: opacity var(--durationL) var(--bezierFastoutSlowin) 0.2s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION animation-duration: 1.5s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION transition: opacity 0.5s ease var(--durationM); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION animation-duration: 1.5s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION animation-duration: 0.8s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_RADIUS border-radius: 20px; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_RADIUS border-radius: 4px; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_ANIMATION_DURATION animation: introScrollIndicator 2s ease infinite; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_DURATION animation-duration: 1.5s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Intro.module.scss RAW_CUBIC_BEZIER transition-timing-function: cubic-bezier(0.8, 0.1, 0.27, 1); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Profile.module.scss VIEWPORT_100VW width: 100vw; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/Profile.module.scss VIEWPORT_100VH min-height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/ProjectSummary.module.scss VIEWPORT_100VH height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Home/ProjectSummary.module.scss RAW_DURATION transition: opacity 1200ms ease 1400ms; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss VIEWPORT_100VH min-height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_LINEAR_GRADIENT background: linear-gradient( Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_LINEAR_GRADIENT linear-gradient( Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_DURATION transition: opacity 2s ease; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_LINEAR_GRADIENT background: linear-gradient(var(--background1), var(--background2)); Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_ANIMATION_DURATION animation: projectFadeSlide 1.4s var(--bezierFastoutSlowin) var(--initDelay) forwards; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_ANIMATION_DURATION animation: projectFadeSlide 1.4s var(--bezierFastoutSlowin) Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_ANIMATION_DURATION animation: projectFadeSlide 1.4s var(--bezierFastoutSlowin) Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/layouts/Project/Project.module.scss RAW_ANIMATION_DURATION animation: projectFadeSlide 1.5s var(--bezierFastoutSlowin) var(--delay) forwards; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/404/404.module.scss VIEWPORT_100VH height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/404/404.module.scss VIEWPORT_100VH min-height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/404/404.module.scss RAW_DURATION animation-duration: 1.8s; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/_document.page.tsx RAW_HEX Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/_document.page.tsx RAW_HEX Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/_document.page.tsx RAW_HEX Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/pages/contact/Contact.module.scss VIEWPORT_100VH min-height: 100vh; Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. +src/utils/image.ts RAW_RGBA ctx.fillStyle = 'rgba(0, 0, 0, 0)' Existing audited Portfolio design risk; replace with tokens or modern viewport primitives when touching this surface. diff --git a/scripts/dev.mjs b/scripts/dev.mjs new file mode 100644 index 0000000..0cffcf7 --- /dev/null +++ b/scripts/dev.mjs @@ -0,0 +1,96 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import readline from "node:readline"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const repoRoot = path.resolve(__dirname, ".."); +const nodeBinCandidates = [ + process.env.PORTFOLIO_DEV_NODE_BIN, + path.join(os.homedir(), ".local/share/mise/installs/node/22.22.1/bin"), + path.join(os.homedir(), ".local/share/mise/installs/node/22/bin"), +].filter(Boolean); +const nodeBin = nodeBinCandidates.find((candidate) => fs.existsSync(path.join(candidate, "node"))); + +function targetEnv() { + if (!nodeBin) return process.env; + return { + ...process.env, + PATH: `${nodeBin}:${process.env.PATH ?? ""}`, + }; +} + +const targets = [ + { + label: "site", + command: ["bun", "run", "dev:site"], + url: "http://localhost:3201/", + }, + { + label: "storybook", + command: ["bun", "run", "storybook"], + url: "http://localhost:3202/", + }, +]; + +let shuttingDown = false; +const children = []; + +function pipe(label, stream, writer) { + if (!stream) return; + const rl = readline.createInterface({ input: stream }); + rl.on("line", (line) => writer.write(`[${label}] ${line}\n`)); +} + +function killChild(child, signal = "SIGTERM") { + if (!child.pid || child.killed) return; + try { + process.kill(-child.pid, signal); + } catch { + try { + child.kill(signal); + } catch { + // Already gone. + } + } +} + +function stopAll(exitCode = 0) { + if (shuttingDown) return; + shuttingDown = true; + for (const child of children) killChild(child); + setTimeout(() => { + for (const child of children) killChild(child, "SIGKILL"); + process.exit(exitCode); + }, 1500).unref(); +} + +for (const target of targets) { + const child = spawn(target.command[0], target.command.slice(1), { + cwd: repoRoot, + env: targetEnv(), + stdio: ["ignore", "pipe", "pipe"], + }); + children.push(child); + pipe(target.label, child.stdout, process.stdout); + pipe(target.label, child.stderr, process.stderr); + child.on("exit", (code, signal) => { + if (shuttingDown) return; + console.error(`[dev] ${target.label} exited${signal ? ` from ${signal}` : ` with ${code ?? 1}`}. Stopping Portfolio dev.`); + stopAll(code ?? 1); + }); +} + +console.log("[dev] Portfolio local environment starting:"); +for (const target of targets) console.log(`[dev] ${target.label}: ${target.url}`); +console.log("[dev] Press Ctrl+C to stop."); + +process.on("SIGINT", () => stopAll(0)); +process.on("SIGTERM", () => stopAll(0)); + +await new Promise(() => {}); diff --git a/scripts/generate-sitemap.js b/scripts/generate-sitemap.js index e6ff24e..3ad01bd 100644 --- a/scripts/generate-sitemap.js +++ b/scripts/generate-sitemap.js @@ -1,20 +1,14 @@ /* eslint-disable @typescript-eslint/no-var-requires */ const fs = require('fs') const { bundleMDX } = require('mdx-bundler') +const { homepage } = require('../package.json') -function addPage(page) { - const path = page - .replace('src/pages', '') - .replace('.page.js', '') - .replace('.page.mdx', '') - .replace('/index', '/') - const route = path === '/index' ? '' : path - - // Exclude 404 page and generated `[]` pages - if (route.includes('[') || route.includes('404')) return +const siteRoutes = JSON.parse(fs.readFileSync('src/utils/siteRoutes.json', 'utf8')) +const siteUrl = (process.env.NEXT_PUBLIC_WEBSITE_URL || homepage).replace(/\/$/, '') +function addRoute(route) { return ` - ${`${process.env.NEXT_PUBLIC_WEBSITE_URL}${route}`} + ${`${siteUrl}${route}`} monthly ` } @@ -28,24 +22,18 @@ async function addPost(post) { const path = post.replace('src/posts', '/articles').replace('.mdx', '') return ` - ${`${process.env.NEXT_PUBLIC_WEBSITE_URL}${path}`} + ${`${siteUrl}${path}`} monthly ` } async function generateSitemap() { const { globby } = await import('globby') - // Ignore Next.js specific files (e.g., _app.js) and API routes. - const pages = await globby([ - 'src/pages/**/*{.page.js,.page.mdx}', - '!src/pages/_*.js', - '!src/pages/api', - ]) const postUrls = await globby(['src/posts/**/*.mdx']) const posts = await Promise.all(postUrls.map(addPost)) const sitemap = ` -${pages.map(addPage).filter(Boolean).join('\n')} +${siteRoutes.publicRoutes.map(addRoute).join('\n')} ${posts.filter(Boolean).join('\n')} \n` diff --git a/scripts/generate-social-images.mjs b/scripts/generate-social-images.mjs new file mode 100644 index 0000000..0dae8ab --- /dev/null +++ b/scripts/generate-social-images.mjs @@ -0,0 +1,337 @@ +#!/usr/bin/env node +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') +const outputRoot = path.join(repoRoot, 'public', 'social') +const projectOutputRoot = path.join(outputRoot, 'projects') +const legacyOutput = path.join(repoRoot, 'public', 'social-image.png') + +const width = 1200 +const height = 630 + +const palette = { + background: '#1b1e1b', + backgroundLight: '#2a3128', + primary: '#b8c7a3', + text: '#dbe0d6', + muted: '#aab3a3', +} + +const cards = [ + { + slug: 'home', + title: 'Afolabi Aiyeloja', + kicker: 'Portfolio', + description: 'Systems architect, ecosystem steward, and builder', + source: 'public/site-preview.webp', + output: '../social-image.png', + }, + { + slug: 'coop', + title: 'Coop', + kicker: 'Project', + description: 'Local-first group memory and agentic review workflows', + source: 'src/assets/coop/coop-mark-glow.png', + }, + { + slug: 'green-goods', + title: 'Green Goods', + kicker: 'Project', + description: 'Evidence, funding, and reporting for regenerative communities', + source: 'src/assets/green-goods/green-goods-hero-website.webp', + }, + { + slug: 'greenpill', + title: 'Greenpill', + kicker: 'Project', + description: 'Community strategy, public learning, and regenerative coordination', + source: 'src/assets/greenpill/greenpill-network-map.webp', + }, + { + slug: 'waves', + title: 'Waves', + kicker: 'Project', + description: 'Generative art and culture for live events', + source: 'src/assets/waves/waves-background.webp', + }, + { + slug: 'wefa', + title: 'WEFA', + kicker: 'Project', + description: 'Nature, community, storytelling, and plant care', + source: 'src/assets/wefa/wefa-ola-red-fruit.jpg', + }, + { + slug: 'synesthesia', + title: 'Synesthesia', + kicker: 'Project', + description: 'Mapping music taste into a personal visual signature', + source: 'src/assets/syn/syn-background.webp', + }, + { + slug: 'freeport', + title: 'Freeport', + kicker: 'Project', + description: 'Fine art ownership, DeFi liquidity, and NFT infrastructure', + source: 'src/assets/freeport/freeport-development.webp', + }, + { + slug: 'mira-connect', + title: 'Mira Connect', + kicker: 'Project', + description: 'Field support, expert calls, and industrial collaboration', + source: 'src/assets/mira-connect/connect-call-messages.webp', + }, + { + slug: 'mira-flow', + title: 'Mira Flow', + kicker: 'Project', + description: 'Tablet workflows for field observation, forms, and review', + source: 'src/assets/mira-flow/flow-tablet-login.webp', + }, + { + slug: 'gentle-monster', + title: 'Gentle Monster', + kicker: 'Project', + description: 'E-commerce for a bold, visual eyewear brand', + source: 'src/assets/gm/gm-background.webp', + }, +] + +const glyphs = { + ' ': ['000', '000', '000', '000', '000', '000', '000'], + '-': ['00000', '00000', '00000', '11110', '00000', '00000', '00000'], + '+': ['00000', '00100', '00100', '11111', '00100', '00100', '00000'], + ',': ['000', '000', '000', '000', '000', '010', '100'], + '.': ['000', '000', '000', '000', '000', '110', '110'], + '/': ['00001', '00010', '00010', '00100', '01000', '01000', '10000'], + '&': ['01100', '10010', '10100', '01000', '10101', '10010', '01101'], + 0: ['01110', '10001', '10011', '10101', '11001', '10001', '01110'], + 1: ['00100', '01100', '00100', '00100', '00100', '00100', '01110'], + 2: ['01110', '10001', '00001', '00010', '00100', '01000', '11111'], + 3: ['11110', '00001', '00001', '01110', '00001', '00001', '11110'], + 4: ['00010', '00110', '01010', '10010', '11111', '00010', '00010'], + 5: ['11111', '10000', '10000', '11110', '00001', '00001', '11110'], + 6: ['01110', '10000', '10000', '11110', '10001', '10001', '01110'], + 7: ['11111', '00001', '00010', '00100', '01000', '01000', '01000'], + 8: ['01110', '10001', '10001', '01110', '10001', '10001', '01110'], + 9: ['01110', '10001', '10001', '01111', '00001', '00001', '01110'], + A: ['01110', '10001', '10001', '11111', '10001', '10001', '10001'], + B: ['11110', '10001', '10001', '11110', '10001', '10001', '11110'], + C: ['01111', '10000', '10000', '10000', '10000', '10000', '01111'], + D: ['11110', '10001', '10001', '10001', '10001', '10001', '11110'], + E: ['11111', '10000', '10000', '11110', '10000', '10000', '11111'], + F: ['11111', '10000', '10000', '11110', '10000', '10000', '10000'], + G: ['01111', '10000', '10000', '10011', '10001', '10001', '01110'], + H: ['10001', '10001', '10001', '11111', '10001', '10001', '10001'], + I: ['11111', '00100', '00100', '00100', '00100', '00100', '11111'], + J: ['00111', '00010', '00010', '00010', '00010', '10010', '01100'], + K: ['10001', '10010', '10100', '11000', '10100', '10010', '10001'], + L: ['10000', '10000', '10000', '10000', '10000', '10000', '11111'], + M: ['10001', '11011', '10101', '10101', '10001', '10001', '10001'], + N: ['10001', '11001', '10101', '10011', '10001', '10001', '10001'], + O: ['01110', '10001', '10001', '10001', '10001', '10001', '01110'], + P: ['11110', '10001', '10001', '11110', '10000', '10000', '10000'], + Q: ['01110', '10001', '10001', '10001', '10101', '10010', '01101'], + R: ['11110', '10001', '10001', '11110', '10100', '10010', '10001'], + S: ['01111', '10000', '10000', '01110', '00001', '00001', '11110'], + T: ['11111', '00100', '00100', '00100', '00100', '00100', '00100'], + U: ['10001', '10001', '10001', '10001', '10001', '10001', '01110'], + V: ['10001', '10001', '10001', '10001', '10001', '01010', '00100'], + W: ['10001', '10001', '10001', '10101', '10101', '10101', '01010'], + X: ['10001', '10001', '01010', '00100', '01010', '10001', '10001'], + Y: ['10001', '10001', '01010', '00100', '00100', '00100', '00100'], + Z: ['11111', '00001', '00010', '00100', '01000', '10000', '11111'], + '?': ['01110', '10001', '00001', '00010', '00100', '00000', '00100'], +} + +function normalizeText(value) { + return value + .normalize('NFKD') + .replace(/[^\x20-\x7E]/g, '') + .toUpperCase() +} + +function measureLine(value, scale) { + const chars = normalizeText(value).split('') + + return chars.reduce((total, char, index) => { + const glyph = glyphs[char] ?? glyphs['?'] + const gap = index === chars.length - 1 ? 0 : scale + + return total + glyph[0].length * scale + gap + }, 0) +} + +function wrapText(value, scale, maxWidth) { + const words = normalizeText(value).split(/\s+/) + const lines = [] + let currentLine = '' + + for (const word of words) { + const nextLine = currentLine ? `${currentLine} ${word}` : word + + if (currentLine && measureLine(nextLine, scale) > maxWidth) { + lines.push(currentLine) + currentLine = word + } else { + currentLine = nextLine + } + } + + if (currentLine) { + lines.push(currentLine) + } + + return lines +} + +function renderPixelLine(value, { x, y, scale, fill, opacity = 1 }) { + let cursorX = x + + return normalizeText(value) + .split('') + .map(char => { + const glyph = glyphs[char] ?? glyphs['?'] + const rects = glyph + .flatMap((row, rowIndex) => + row.split('').map((cell, columnIndex) => { + if (cell !== '1') { + return '' + } + + return `` + }) + ) + .join('') + + cursorX += glyph[0].length * scale + scale + + return rects + }) + .join('') +} + +function renderPixelBlock(value, { x, y, scale, fill, opacity, maxWidth }) { + return wrapText(value, scale, maxWidth) + .slice(0, 3) + .map((line, index) => + renderPixelLine(line, { + x, + y: y + index * scale * 9, + scale, + fill, + opacity, + }) + ) + .join('') +} + +function textSvg({ title, kicker, description }) { + const titleScale = measureLine(title, 8) > 720 ? 7 : 8 + + return Buffer.from(` + + + + + + + + + + + + ${renderPixelLine(kicker, { + x: 64, + y: 212, + scale: 4, + fill: palette.primary, + opacity: 0.92, + })} + ${renderPixelBlock(title, { + x: 64, + y: 280, + scale: titleScale, + fill: palette.text, + opacity: 0.92, + maxWidth: 720, + })} + ${renderPixelBlock(description, { + x: 68, + y: 392, + scale: 3, + fill: palette.muted, + opacity: 0.86, + maxWidth: 620, + })} + ${renderPixelLine('afolabi.info', { + x: 64, + y: 536, + scale: 3, + fill: palette.primary, + opacity: 0.94, + })} + + `) +} + +async function makeCard(card) { + const sourcePath = path.join(repoRoot, card.source) + const outputPath = card.output + ? path.join(outputRoot, card.output) + : path.join(projectOutputRoot, `${card.slug}.png`) + + let backgroundPipeline = sharp(sourcePath) + .rotate() + .resize(width, height, { fit: 'cover', position: 'center' }) + .modulate({ saturation: 0.86, brightness: 0.82 }) + + if (card.slug === 'home') { + backgroundPipeline = backgroundPipeline.blur(0.4) + } + + const background = await backgroundPipeline.png().toBuffer() + + await sharp({ + create: { + width, + height, + channels: 4, + background: palette.backgroundLight, + }, + }) + .composite([ + { input: background, left: 0, top: 0 }, + { input: textSvg(card), left: 0, top: 0 }, + ]) + .png({ compressionLevel: 9, adaptiveFiltering: true, quality: 90 }) + .toFile(outputPath) + + console.log(`generated ${path.relative(repoRoot, outputPath)}`) +} + +mkdirSync(projectOutputRoot, { recursive: true }) + +for (const card of cards) { + await makeCard(card) +} + +await sharp(legacyOutput) + .resize(width, height, { fit: 'cover' }) + .png({ compressionLevel: 9, adaptiveFiltering: true, quality: 90 }) + .toFile(path.join(outputRoot, 'home.png')) + +console.log('generated public/social/home.png') diff --git a/scripts/optimize-assets.mjs b/scripts/optimize-assets.mjs new file mode 100644 index 0000000..39af401 --- /dev/null +++ b/scripts/optimize-assets.mjs @@ -0,0 +1,244 @@ +#!/usr/bin/env node +import { mkdirSync } from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import sharp from 'sharp' + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..') + +const conversions = [ + { + input: 'src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.png', + output: 'src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.webp', + width: 1120, + quality: 84, + }, + { + input: 'src/assets/green-goods/green-goods-action-selection-pwa.png', + output: 'src/assets/green-goods/green-goods-action-selection-pwa.webp', + width: 560, + quality: 82, + }, + { + input: 'src/assets/green-goods/green-goods-gardens-pwa.png', + output: 'src/assets/green-goods/green-goods-gardens-pwa.webp', + width: 560, + quality: 82, + }, + { + input: 'src/assets/green-goods/green-goods-media-section-pwa.png', + output: 'src/assets/green-goods/green-goods-media-section-pwa.webp', + width: 560, + quality: 82, + }, + { + input: 'src/assets/green-goods/green-goods-review-section-pwa.png', + output: 'src/assets/green-goods/green-goods-review-section-pwa.webp', + width: 560, + quality: 82, + }, + { + input: 'src/assets/coop/coop-mark-glow.png', + output: 'src/assets/coop/coop-mark-glow.webp', + width: 1024, + quality: 86, + }, + { + input: 'src/assets/wefa/wefa-elemental-characters.png', + output: 'src/assets/wefa/wefa-elemental-characters.webp', + width: 1400, + quality: 84, + }, + { + input: 'src/assets/greenpill/greenpill-map.png', + output: 'src/assets/greenpill/greenpill-map.webp', + width: 1516, + quality: 84, + }, + { + input: 'src/assets/profile.jpeg', + output: 'src/assets/profile-2x.webp', + width: 800, + quality: 88, + sharpen: true, + }, +] + +const placeholders = [ + ['src/assets/coop/coop-mark-glow.webp', 'src/assets/coop/coop-mark-glow-ph.webp'], + [ + 'src/assets/coop/coop-wordmark-flat.png', + 'src/assets/coop/coop-wordmark-flat-ph.webp', + ], + [ + 'src/assets/green-goods/green-goods-hero-website.webp', + 'src/assets/green-goods/green-goods-hero-website-ph.webp', + ], + [ + 'src/assets/green-goods/green-goods-action-selection-pwa.webp', + 'src/assets/green-goods/green-goods-action-selection-pwa-ph.webp', + ], + [ + 'src/assets/green-goods/green-goods-gardens-pwa.webp', + 'src/assets/green-goods/green-goods-gardens-pwa-ph.webp', + ], + [ + 'src/assets/green-goods/green-goods-media-section-pwa.webp', + 'src/assets/green-goods/green-goods-media-section-pwa-ph.webp', + ], + [ + 'src/assets/green-goods/green-goods-review-section-pwa.webp', + 'src/assets/green-goods/green-goods-review-section-pwa-ph.webp', + ], + [ + 'src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.webp', + 'src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle-ph.webp', + ], + [ + 'src/assets/green-goods/refi-sicilia-agroforestry.jpeg', + 'src/assets/green-goods/refi-sicilia-agroforestry-ph.webp', + ], + [ + 'src/assets/green-goods/tas-solar-hub-session.jpeg', + 'src/assets/green-goods/tas-solar-hub-session-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-books.webp', + 'src/assets/greenpill/greenpill-books-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-garden-entry.webp', + 'src/assets/greenpill/greenpill-garden-entry-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-map.webp', + 'src/assets/greenpill/greenpill-map-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-monthly-call.webp', + 'src/assets/greenpill/greenpill-monthly-call-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-network-map.webp', + 'src/assets/greenpill/greenpill-network-map-ph.webp', + ], + [ + 'src/assets/greenpill/greenpill-tech-and-sun.webp', + 'src/assets/greenpill/greenpill-tech-and-sun-ph.webp', + ], + ['src/assets/profile.jpeg', 'src/assets/profile-ph.webp'], + ['src/assets/waves/waves-background.webp', 'src/assets/waves/waves-background-ph.webp'], + [ + 'src/assets/waves/waves-deck-nurture-complete.webp', + 'src/assets/waves/waves-deck-nurture-complete-ph.webp', + ], + [ + 'src/assets/waves/waves-deck-nurture.webp', + 'src/assets/waves/waves-deck-nurture-ph.webp', + ], + [ + 'src/assets/waves/waves-deck-plants.webp', + 'src/assets/waves/waves-deck-plants-ph.webp', + ], + [ + 'src/assets/waves/waves-onboard-generated-creatures.webp', + 'src/assets/waves/waves-onboard-generated-creatures-ph.webp', + ], + [ + 'src/assets/waves/waves-onboard-select-element.webp', + 'src/assets/waves/waves-onboard-select-element-ph.webp', + ], + [ + 'src/assets/waves/waves-onboard-select-plant.webp', + 'src/assets/waves/waves-onboard-select-plant-ph.webp', + ], + ['src/assets/waves/waves-splash.webp', 'src/assets/waves/waves-splash-ph.webp'], + ['src/assets/waves/waves-story.webp', 'src/assets/waves/waves-story-ph.webp'], + [ + 'src/assets/wefa/wefa-deck-nurture-complete.webp', + 'src/assets/wefa/wefa-deck-nurture-complete-ph.webp', + ], + ['src/assets/wefa/wefa-deck-nurture.webp', 'src/assets/wefa/wefa-deck-nurture-ph.webp'], + ['src/assets/wefa/wefa-deck-plants.webp', 'src/assets/wefa/wefa-deck-plants-ph.webp'], + [ + 'src/assets/wefa/wefa-elemental-characters.webp', + 'src/assets/wefa/wefa-elemental-characters-ph.webp', + ], + [ + 'src/assets/wefa/wefa-ola-red-fruit.jpg', + 'src/assets/wefa/wefa-ola-red-fruit-ph.webp', + ], + [ + 'src/assets/wefa/wefa-onboard-generated-creatures.webp', + 'src/assets/wefa/wefa-onboard-generated-creatures-ph.webp', + ], + [ + 'src/assets/wefa/wefa-onboard-select-element.webp', + 'src/assets/wefa/wefa-onboard-select-element-ph.webp', + ], + [ + 'src/assets/wefa/wefa-onboard-select-plant.webp', + 'src/assets/wefa/wefa-onboard-select-plant-ph.webp', + ], + ['src/assets/wefa/wefa-splash.webp', 'src/assets/wefa/wefa-splash-ph.webp'], + ['src/assets/wefa/wefa-story.webp', 'src/assets/wefa/wefa-story-ph.webp'], +] + +async function convertAsset({ + input, + output, + width, + height, + quality = 84, + format = 'webp', + sharpen = false, +}) { + const inputPath = path.join(repoRoot, input) + const outputPath = path.join(repoRoot, output) + mkdirSync(path.dirname(outputPath), { recursive: true }) + + let pipeline = sharp(inputPath).rotate() + + if (width || height) { + pipeline = pipeline.resize(width, height, { + fit: width && height ? 'cover' : 'inside', + withoutEnlargement: false, + }) + } + + if (sharpen) { + pipeline = pipeline.sharpen({ sigma: 0.7, m1: 0.8, m2: 1.8 }) + } + + if (format === 'png') { + pipeline = pipeline.png({ compressionLevel: 9, adaptiveFiltering: true }) + } else { + pipeline = pipeline.webp({ quality, effort: 6 }) + } + + await pipeline.toFile(outputPath) + console.log(`optimized ${output}`) +} + +async function makePlaceholder(input, output) { + const inputPath = path.join(repoRoot, input) + const outputPath = path.join(repoRoot, output) + mkdirSync(path.dirname(outputPath), { recursive: true }) + + await sharp(inputPath) + .rotate() + .resize(32, 32, { fit: 'cover' }) + .blur(3) + .webp({ quality: 38, effort: 6 }) + .toFile(outputPath) + + console.log(`placeholder ${output}`) +} + +for (const conversion of conversions) { + await convertAsset(conversion) +} + +for (const [input, output] of placeholders) { + await makePlaceholder(input, output) +} diff --git a/sentry.edge.config.js b/sentry.edge.config.ts similarity index 100% rename from sentry.edge.config.js rename to sentry.edge.config.ts diff --git a/sentry.server.config.js b/sentry.server.config.ts similarity index 100% rename from sentry.server.config.js rename to sentry.server.config.ts diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index a370eff..0000000 Binary files a/src/.DS_Store and /dev/null differ diff --git a/src/assets/coop/coop-mark-glow-ph.webp b/src/assets/coop/coop-mark-glow-ph.webp new file mode 100644 index 0000000..f567d49 Binary files /dev/null and b/src/assets/coop/coop-mark-glow-ph.webp differ diff --git a/src/assets/coop/coop-mark-glow.png b/src/assets/coop/coop-mark-glow.png new file mode 100644 index 0000000..97bc5c4 Binary files /dev/null and b/src/assets/coop/coop-mark-glow.png differ diff --git a/src/assets/coop/coop-mark-glow.webp b/src/assets/coop/coop-mark-glow.webp new file mode 100644 index 0000000..3bf46b2 Binary files /dev/null and b/src/assets/coop/coop-mark-glow.webp differ diff --git a/src/assets/coop/coop-wordmark-flat-ph.webp b/src/assets/coop/coop-wordmark-flat-ph.webp new file mode 100644 index 0000000..24737c4 Binary files /dev/null and b/src/assets/coop/coop-wordmark-flat-ph.webp differ diff --git a/src/assets/coop/coop-wordmark-flat.png b/src/assets/coop/coop-wordmark-flat.png new file mode 100644 index 0000000..798610b Binary files /dev/null and b/src/assets/coop/coop-wordmark-flat.png differ diff --git a/src/assets/coop/index.ts b/src/assets/coop/index.ts new file mode 100644 index 0000000..b9668da --- /dev/null +++ b/src/assets/coop/index.ts @@ -0,0 +1,4 @@ +export { default as CoopMarkGlowImg } from './coop-mark-glow.webp' +export { default as CoopMarkGlowPlaceholderImg } from './coop-mark-glow-ph.webp' +export { default as CoopWordmarkFlatImg } from './coop-wordmark-flat.png' +export { default as CoopWordmarkFlatPlaceholderImg } from './coop-wordmark-flat-ph.webp' diff --git a/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle-ph.webp b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle-ph.webp new file mode 100644 index 0000000..6a81857 Binary files /dev/null and b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle-ph.webp differ diff --git a/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.png b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.png new file mode 100644 index 0000000..ab8bfa1 Binary files /dev/null and b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.png differ diff --git a/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.webp b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.webp new file mode 100644 index 0000000..e039ca4 Binary files /dev/null and b/src/assets/green-goods/gpdg-graphic-green-goods-impact-value-cycle.webp differ diff --git a/src/assets/green-goods/green-goods-action-selection-pwa-ph.webp b/src/assets/green-goods/green-goods-action-selection-pwa-ph.webp new file mode 100644 index 0000000..288d8ec Binary files /dev/null and b/src/assets/green-goods/green-goods-action-selection-pwa-ph.webp differ diff --git a/src/assets/green-goods/green-goods-action-selection-pwa.png b/src/assets/green-goods/green-goods-action-selection-pwa.png new file mode 100644 index 0000000..8460a96 Binary files /dev/null and b/src/assets/green-goods/green-goods-action-selection-pwa.png differ diff --git a/src/assets/green-goods/green-goods-action-selection-pwa.webp b/src/assets/green-goods/green-goods-action-selection-pwa.webp new file mode 100644 index 0000000..5b3704a Binary files /dev/null and b/src/assets/green-goods/green-goods-action-selection-pwa.webp differ diff --git a/src/assets/green-goods/green-goods-gardens-pwa-ph.webp b/src/assets/green-goods/green-goods-gardens-pwa-ph.webp new file mode 100644 index 0000000..d713e66 Binary files /dev/null and b/src/assets/green-goods/green-goods-gardens-pwa-ph.webp differ diff --git a/src/assets/green-goods/green-goods-gardens-pwa.png b/src/assets/green-goods/green-goods-gardens-pwa.png new file mode 100644 index 0000000..7ad4f2f Binary files /dev/null and b/src/assets/green-goods/green-goods-gardens-pwa.png differ diff --git a/src/assets/green-goods/green-goods-gardens-pwa.webp b/src/assets/green-goods/green-goods-gardens-pwa.webp new file mode 100644 index 0000000..50d285c Binary files /dev/null and b/src/assets/green-goods/green-goods-gardens-pwa.webp differ diff --git a/src/assets/green-goods/green-goods-hero-website-ph.webp b/src/assets/green-goods/green-goods-hero-website-ph.webp new file mode 100644 index 0000000..c98a204 Binary files /dev/null and b/src/assets/green-goods/green-goods-hero-website-ph.webp differ diff --git a/src/assets/green-goods/green-goods-hero-website.webp b/src/assets/green-goods/green-goods-hero-website.webp new file mode 100644 index 0000000..a160324 Binary files /dev/null and b/src/assets/green-goods/green-goods-hero-website.webp differ diff --git a/src/assets/green-goods/green-goods-logo.png b/src/assets/green-goods/green-goods-logo.png new file mode 100644 index 0000000..42268d6 Binary files /dev/null and b/src/assets/green-goods/green-goods-logo.png differ diff --git a/src/assets/green-goods/green-goods-media-section-pwa-ph.webp b/src/assets/green-goods/green-goods-media-section-pwa-ph.webp new file mode 100644 index 0000000..44f4bf0 Binary files /dev/null and b/src/assets/green-goods/green-goods-media-section-pwa-ph.webp differ diff --git a/src/assets/green-goods/green-goods-media-section-pwa.png b/src/assets/green-goods/green-goods-media-section-pwa.png new file mode 100644 index 0000000..ab41dc3 Binary files /dev/null and b/src/assets/green-goods/green-goods-media-section-pwa.png differ diff --git a/src/assets/green-goods/green-goods-media-section-pwa.webp b/src/assets/green-goods/green-goods-media-section-pwa.webp new file mode 100644 index 0000000..049b646 Binary files /dev/null and b/src/assets/green-goods/green-goods-media-section-pwa.webp differ diff --git a/src/assets/green-goods/green-goods-review-section-pwa-ph.webp b/src/assets/green-goods/green-goods-review-section-pwa-ph.webp new file mode 100644 index 0000000..be45985 Binary files /dev/null and b/src/assets/green-goods/green-goods-review-section-pwa-ph.webp differ diff --git a/src/assets/green-goods/green-goods-review-section-pwa.png b/src/assets/green-goods/green-goods-review-section-pwa.png new file mode 100644 index 0000000..8306db5 Binary files /dev/null and b/src/assets/green-goods/green-goods-review-section-pwa.png differ diff --git a/src/assets/green-goods/green-goods-review-section-pwa.webp b/src/assets/green-goods/green-goods-review-section-pwa.webp new file mode 100644 index 0000000..214e75e Binary files /dev/null and b/src/assets/green-goods/green-goods-review-section-pwa.webp differ diff --git a/src/assets/green-goods/green-goods-social-card.webp b/src/assets/green-goods/green-goods-social-card.webp new file mode 100644 index 0000000..4c58812 Binary files /dev/null and b/src/assets/green-goods/green-goods-social-card.webp differ diff --git a/src/assets/green-goods/index.ts b/src/assets/green-goods/index.ts new file mode 100644 index 0000000..81be3b4 --- /dev/null +++ b/src/assets/green-goods/index.ts @@ -0,0 +1,19 @@ +export { default as GreenGoodsLogoImg } from './green-goods-logo.png' +export { default as GreenGoodsSocialCardImg } from './green-goods-social-card.webp' +export { default as GreenGoodsHeroWebsiteImg } from './green-goods-hero-website.webp' +export { default as GreenGoodsHeroWebsitePlaceholderImg } from './green-goods-hero-website-ph.webp' +export { default as GreenGoodsGardensPwaImg } from './green-goods-gardens-pwa.webp' +export { default as GreenGoodsGardensPwaPlaceholderImg } from './green-goods-gardens-pwa-ph.webp' +export { default as GreenGoodsActionSelectionPwaImg } from './green-goods-action-selection-pwa.webp' +export { default as GreenGoodsActionSelectionPwaPlaceholderImg } from './green-goods-action-selection-pwa-ph.webp' +export { default as GreenGoodsMediaSectionPwaImg } from './green-goods-media-section-pwa.webp' +export { default as GreenGoodsMediaSectionPwaPlaceholderImg } from './green-goods-media-section-pwa-ph.webp' +export { default as GreenGoodsReviewSectionPwaImg } from './green-goods-review-section-pwa.webp' +export { default as GreenGoodsReviewSectionPwaPlaceholderImg } from './green-goods-review-section-pwa-ph.webp' +export { default as GreenGoodsImpactValueCycleImg } from './gpdg-graphic-green-goods-impact-value-cycle.webp' +export { default as GreenGoodsImpactValueCyclePlaceholderImg } from './gpdg-graphic-green-goods-impact-value-cycle-ph.webp' +export { default as RefiSiciliaAgroforestryImg } from './refi-sicilia-agroforestry.jpeg' +export { default as RefiSiciliaAgroforestryPlaceholderImg } from './refi-sicilia-agroforestry-ph.webp' +export { default as TasSolarHubSessionImg } from './tas-solar-hub-session.jpeg' +export { default as TasSolarHubSessionPlaceholderImg } from './tas-solar-hub-session-ph.webp' +export { default as VidaVerdeWasteManagementImg } from './vida-verde-waste-management.png' diff --git a/src/assets/green-goods/refi-sicilia-agroforestry-ph.webp b/src/assets/green-goods/refi-sicilia-agroforestry-ph.webp new file mode 100644 index 0000000..9d1c807 Binary files /dev/null and b/src/assets/green-goods/refi-sicilia-agroforestry-ph.webp differ diff --git a/src/assets/green-goods/refi-sicilia-agroforestry.jpeg b/src/assets/green-goods/refi-sicilia-agroforestry.jpeg new file mode 100644 index 0000000..ecdf1b6 Binary files /dev/null and b/src/assets/green-goods/refi-sicilia-agroforestry.jpeg differ diff --git a/src/assets/green-goods/tas-solar-hub-session-ph.webp b/src/assets/green-goods/tas-solar-hub-session-ph.webp new file mode 100644 index 0000000..2b67fc6 Binary files /dev/null and b/src/assets/green-goods/tas-solar-hub-session-ph.webp differ diff --git a/src/assets/green-goods/tas-solar-hub-session.jpeg b/src/assets/green-goods/tas-solar-hub-session.jpeg new file mode 100644 index 0000000..4565e14 Binary files /dev/null and b/src/assets/green-goods/tas-solar-hub-session.jpeg differ diff --git a/src/assets/green-goods/vida-verde-waste-management.png b/src/assets/green-goods/vida-verde-waste-management.png new file mode 100644 index 0000000..f2cf528 Binary files /dev/null and b/src/assets/green-goods/vida-verde-waste-management.png differ diff --git "a/src/assets/greenpill/Screenshot 2026-06-21 at 7.21.56\342\200\257PM.png" "b/src/assets/greenpill/Screenshot 2026-06-21 at 7.21.56\342\200\257PM.png" new file mode 100644 index 0000000..8a816ec Binary files /dev/null and "b/src/assets/greenpill/Screenshot 2026-06-21 at 7.21.56\342\200\257PM.png" differ diff --git "a/src/assets/greenpill/Screenshot 2026-06-21 at 8.16.06\342\200\257PM.png" "b/src/assets/greenpill/Screenshot 2026-06-21 at 8.16.06\342\200\257PM.png" new file mode 100644 index 0000000..dcc9944 Binary files /dev/null and "b/src/assets/greenpill/Screenshot 2026-06-21 at 8.16.06\342\200\257PM.png" differ diff --git a/src/assets/greenpill/greenpill-books-ph.webp b/src/assets/greenpill/greenpill-books-ph.webp new file mode 100644 index 0000000..4aa0b26 Binary files /dev/null and b/src/assets/greenpill/greenpill-books-ph.webp differ diff --git a/src/assets/greenpill/greenpill-books.webp b/src/assets/greenpill/greenpill-books.webp new file mode 100644 index 0000000..ad2a617 Binary files /dev/null and b/src/assets/greenpill/greenpill-books.webp differ diff --git a/src/assets/greenpill/greenpill-garden-entry-ph.webp b/src/assets/greenpill/greenpill-garden-entry-ph.webp new file mode 100644 index 0000000..ff3bba4 Binary files /dev/null and b/src/assets/greenpill/greenpill-garden-entry-ph.webp differ diff --git a/src/assets/greenpill/greenpill-garden-entry.webp b/src/assets/greenpill/greenpill-garden-entry.webp new file mode 100644 index 0000000..7d4ac94 Binary files /dev/null and b/src/assets/greenpill/greenpill-garden-entry.webp differ diff --git a/src/assets/greenpill/greenpill-map-ph.webp b/src/assets/greenpill/greenpill-map-ph.webp new file mode 100644 index 0000000..dd829a2 Binary files /dev/null and b/src/assets/greenpill/greenpill-map-ph.webp differ diff --git a/src/assets/greenpill/greenpill-map.png b/src/assets/greenpill/greenpill-map.png new file mode 100644 index 0000000..5a4f813 Binary files /dev/null and b/src/assets/greenpill/greenpill-map.png differ diff --git a/src/assets/greenpill/greenpill-map.webp b/src/assets/greenpill/greenpill-map.webp new file mode 100644 index 0000000..8a11812 Binary files /dev/null and b/src/assets/greenpill/greenpill-map.webp differ diff --git a/src/assets/greenpill/greenpill-monthly-call-ph.webp b/src/assets/greenpill/greenpill-monthly-call-ph.webp new file mode 100644 index 0000000..1274ea7 Binary files /dev/null and b/src/assets/greenpill/greenpill-monthly-call-ph.webp differ diff --git a/src/assets/greenpill/greenpill-monthly-call.webp b/src/assets/greenpill/greenpill-monthly-call.webp new file mode 100644 index 0000000..91b052c Binary files /dev/null and b/src/assets/greenpill/greenpill-monthly-call.webp differ diff --git a/src/assets/greenpill/greenpill-network-map-ph.webp b/src/assets/greenpill/greenpill-network-map-ph.webp new file mode 100644 index 0000000..236db6d Binary files /dev/null and b/src/assets/greenpill/greenpill-network-map-ph.webp differ diff --git a/src/assets/greenpill/greenpill-network-map.webp b/src/assets/greenpill/greenpill-network-map.webp new file mode 100644 index 0000000..1b93f8f Binary files /dev/null and b/src/assets/greenpill/greenpill-network-map.webp differ diff --git a/src/assets/greenpill/greenpill-tech-and-sun-ph.webp b/src/assets/greenpill/greenpill-tech-and-sun-ph.webp new file mode 100644 index 0000000..9b6117b Binary files /dev/null and b/src/assets/greenpill/greenpill-tech-and-sun-ph.webp differ diff --git a/src/assets/greenpill/greenpill-tech-and-sun.webp b/src/assets/greenpill/greenpill-tech-and-sun.webp new file mode 100644 index 0000000..a278815 Binary files /dev/null and b/src/assets/greenpill/greenpill-tech-and-sun.webp differ diff --git a/src/assets/greenpill/greenpill-twitter-card.png b/src/assets/greenpill/greenpill-twitter-card.png new file mode 100644 index 0000000..5e61b5f Binary files /dev/null and b/src/assets/greenpill/greenpill-twitter-card.png differ diff --git a/src/assets/greenpill/index.ts b/src/assets/greenpill/index.ts new file mode 100644 index 0000000..3ae65ea --- /dev/null +++ b/src/assets/greenpill/index.ts @@ -0,0 +1,13 @@ +export { default as GreenpillMapImg } from './greenpill-map.webp' +export { default as GreenpillMapPlaceholderImg } from './greenpill-map-ph.webp' +export { default as GreenpillTwitterCardImg } from './greenpill-twitter-card.png' +export { default as GreenpillBooksImg } from './greenpill-books.webp' +export { default as GreenpillBooksPlaceholderImg } from './greenpill-books-ph.webp' +export { default as GreenpillGardenEntryImg } from './greenpill-garden-entry.webp' +export { default as GreenpillGardenEntryPlaceholderImg } from './greenpill-garden-entry-ph.webp' +export { default as GreenpillMonthlyCallImg } from './greenpill-monthly-call.webp' +export { default as GreenpillMonthlyCallPlaceholderImg } from './greenpill-monthly-call-ph.webp' +export { default as GreenpillNetworkMapImg } from './greenpill-network-map.webp' +export { default as GreenpillNetworkMapPlaceholderImg } from './greenpill-network-map-ph.webp' +export { default as GreenpillTechAndSunImg } from './greenpill-tech-and-sun.webp' +export { default as GreenpillTechAndSunPlaceholderImg } from './greenpill-tech-and-sun-ph.webp' diff --git a/src/assets/mira-flow/index.ts b/src/assets/mira-flow/index.ts index ff2a61e..d3ebf2e 100644 --- a/src/assets/mira-flow/index.ts +++ b/src/assets/mira-flow/index.ts @@ -15,7 +15,7 @@ export { default as FlowTabletStepNumberImg } from './flow-tablet-step-number.we export { default as FlowArchitecturePlaceholderImg } from './flow-architecture-ph.webp' export { default as FlowArchitecture2PlaceholderImg } from './flow-architecture2-ph.webp' -export { default as FlowProblemPlaceholderImg } from './flow-problem.webp' +export { default as FlowProblemPlaceholderImg } from './flow-problem-ph.webp' export { default as FlowMobileCollectionsPlaceholderImg } from './flow-mobile-collections-ph.webp' export { default as FlowMobileProfilePlaceholderImg } from './flow-mobile-profile-ph.webp' export { default as FlowTabletCollectionsPlaceholderImg } from './flow-tablet-collections-ph.webp' diff --git a/src/assets/nostalgia/index.js b/src/assets/nostalgia/index.ts similarity index 100% rename from src/assets/nostalgia/index.js rename to src/assets/nostalgia/index.ts diff --git a/src/assets/profile-2x.webp b/src/assets/profile-2x.webp new file mode 100644 index 0000000..abfc34c Binary files /dev/null and b/src/assets/profile-2x.webp differ diff --git a/src/assets/profile-ph.webp b/src/assets/profile-ph.webp new file mode 100644 index 0000000..ee93fc0 Binary files /dev/null and b/src/assets/profile-ph.webp differ diff --git a/src/assets/waves/index.ts b/src/assets/waves/index.ts index 9b55d17..2606a09 100644 --- a/src/assets/waves/index.ts +++ b/src/assets/waves/index.ts @@ -1,10 +1,19 @@ export { default as WavesStoryImg } from './waves-story.webp' +export { default as WavesStoryPlaceholderImg } from './waves-story-ph.webp' export { default as WavesSplashImg } from './waves-splash.webp' +export { default as WavesSplashPlaceholderImg } from './waves-splash-ph.webp' export { default as WavesBackgroundImg } from './waves-background.webp' +export { default as WavesBackgroundPlaceholderImg } from './waves-background-ph.webp' export { default as WavesDeckNurtureCompleteImg } from './waves-deck-nurture-complete.webp' +export { default as WavesDeckNurtureCompletePlaceholderImg } from './waves-deck-nurture-complete-ph.webp' export { default as WavesDeckNurtureImg } from './waves-deck-nurture.webp' +export { default as WavesDeckNurturePlaceholderImg } from './waves-deck-nurture-ph.webp' export { default as WavesDeckPlantsImg } from './waves-deck-plants.webp' +export { default as WavesDeckPlantsPlaceholderImg } from './waves-deck-plants-ph.webp' export { default as WavesOnboardSelectElementImg } from './waves-onboard-select-element.webp' +export { default as WavesOnboardSelectElementPlaceholderImg } from './waves-onboard-select-element-ph.webp' export { default as WavesOnboardSelectPlantImg } from './waves-onboard-select-plant.webp' +export { default as WavesOnboardSelectPlantPlaceholderImg } from './waves-onboard-select-plant-ph.webp' export { default as WavesOnboardGeneratedCreaturesImg } from './waves-onboard-generated-creatures.webp' +export { default as WavesOnboardGeneratedCreaturesPlaceholderImg } from './waves-onboard-generated-creatures-ph.webp' diff --git a/src/assets/waves/waves-background-ph.webp b/src/assets/waves/waves-background-ph.webp new file mode 100644 index 0000000..eef240a Binary files /dev/null and b/src/assets/waves/waves-background-ph.webp differ diff --git a/src/assets/waves/waves-deck-nurture-complete-ph.webp b/src/assets/waves/waves-deck-nurture-complete-ph.webp new file mode 100644 index 0000000..84d72bb Binary files /dev/null and b/src/assets/waves/waves-deck-nurture-complete-ph.webp differ diff --git a/src/assets/waves/waves-deck-nurture-ph.webp b/src/assets/waves/waves-deck-nurture-ph.webp new file mode 100644 index 0000000..7bf9234 Binary files /dev/null and b/src/assets/waves/waves-deck-nurture-ph.webp differ diff --git a/src/assets/waves/waves-deck-plants-ph.webp b/src/assets/waves/waves-deck-plants-ph.webp new file mode 100644 index 0000000..37d7b9c Binary files /dev/null and b/src/assets/waves/waves-deck-plants-ph.webp differ diff --git a/src/assets/waves/waves-onboard-generated-creatures-ph.webp b/src/assets/waves/waves-onboard-generated-creatures-ph.webp new file mode 100644 index 0000000..8591ed3 Binary files /dev/null and b/src/assets/waves/waves-onboard-generated-creatures-ph.webp differ diff --git a/src/assets/waves/waves-onboard-select-element-ph.webp b/src/assets/waves/waves-onboard-select-element-ph.webp new file mode 100644 index 0000000..4fe7c60 Binary files /dev/null and b/src/assets/waves/waves-onboard-select-element-ph.webp differ diff --git a/src/assets/waves/waves-onboard-select-plant-ph.webp b/src/assets/waves/waves-onboard-select-plant-ph.webp new file mode 100644 index 0000000..538048c Binary files /dev/null and b/src/assets/waves/waves-onboard-select-plant-ph.webp differ diff --git a/src/assets/waves/waves-splash-ph.webp b/src/assets/waves/waves-splash-ph.webp new file mode 100644 index 0000000..3a12236 Binary files /dev/null and b/src/assets/waves/waves-splash-ph.webp differ diff --git a/src/assets/waves/waves-story-ph.webp b/src/assets/waves/waves-story-ph.webp new file mode 100644 index 0000000..e4596dc Binary files /dev/null and b/src/assets/waves/waves-story-ph.webp differ diff --git a/src/assets/wefa/index.ts b/src/assets/wefa/index.ts index 0ac96b7..d264b57 100644 --- a/src/assets/wefa/index.ts +++ b/src/assets/wefa/index.ts @@ -1,10 +1,22 @@ export { default as WefaStoryImg } from './wefa-story.webp' +export { default as WefaStoryPlaceholderImg } from './wefa-story-ph.webp' +export { default as WefaElementalCharactersImg } from './wefa-elemental-characters.webp' +export { default as WefaElementalCharactersPlaceholderImg } from './wefa-elemental-characters-ph.webp' +export { default as WefaOlaRedFruitImg } from './wefa-ola-red-fruit.jpg' +export { default as WefaOlaRedFruitPlaceholderImg } from './wefa-ola-red-fruit-ph.webp' export { default as WefaSplashImg } from './wefa-splash.webp' +export { default as WefaSplashPlaceholderImg } from './wefa-splash-ph.webp' export { default as WefaBackgroundImg } from './wefa-background.webp' export { default as WefaDeckNurtureCompleteImg } from './wefa-deck-nurture-complete.webp' +export { default as WefaDeckNurtureCompletePlaceholderImg } from './wefa-deck-nurture-complete-ph.webp' export { default as WefaDeckNurtureImg } from './wefa-deck-nurture.webp' +export { default as WefaDeckNurturePlaceholderImg } from './wefa-deck-nurture-ph.webp' export { default as WefaDeckPlantsImg } from './wefa-deck-plants.webp' +export { default as WefaDeckPlantsPlaceholderImg } from './wefa-deck-plants-ph.webp' export { default as WefaOnboardSelectElementImg } from './wefa-onboard-select-element.webp' +export { default as WefaOnboardSelectElementPlaceholderImg } from './wefa-onboard-select-element-ph.webp' export { default as WefaOnboardSelectPlantImg } from './wefa-onboard-select-plant.webp' +export { default as WefaOnboardSelectPlantPlaceholderImg } from './wefa-onboard-select-plant-ph.webp' export { default as WefaOnboardGeneratedCreaturesImg } from './wefa-onboard-generated-creatures.webp' +export { default as WefaOnboardGeneratedCreaturesPlaceholderImg } from './wefa-onboard-generated-creatures-ph.webp' diff --git a/src/assets/wefa/wefa-deck-nurture-complete-ph.webp b/src/assets/wefa/wefa-deck-nurture-complete-ph.webp new file mode 100644 index 0000000..84d72bb Binary files /dev/null and b/src/assets/wefa/wefa-deck-nurture-complete-ph.webp differ diff --git a/src/assets/wefa/wefa-deck-nurture-ph.webp b/src/assets/wefa/wefa-deck-nurture-ph.webp new file mode 100644 index 0000000..7bf9234 Binary files /dev/null and b/src/assets/wefa/wefa-deck-nurture-ph.webp differ diff --git a/src/assets/wefa/wefa-deck-plants-ph.webp b/src/assets/wefa/wefa-deck-plants-ph.webp new file mode 100644 index 0000000..37d7b9c Binary files /dev/null and b/src/assets/wefa/wefa-deck-plants-ph.webp differ diff --git a/src/assets/wefa/wefa-elemental-characters-ph.webp b/src/assets/wefa/wefa-elemental-characters-ph.webp new file mode 100644 index 0000000..fb738ac Binary files /dev/null and b/src/assets/wefa/wefa-elemental-characters-ph.webp differ diff --git a/src/assets/wefa/wefa-elemental-characters.png b/src/assets/wefa/wefa-elemental-characters.png new file mode 100644 index 0000000..094929b Binary files /dev/null and b/src/assets/wefa/wefa-elemental-characters.png differ diff --git a/src/assets/wefa/wefa-elemental-characters.webp b/src/assets/wefa/wefa-elemental-characters.webp new file mode 100644 index 0000000..8e00e23 Binary files /dev/null and b/src/assets/wefa/wefa-elemental-characters.webp differ diff --git a/src/assets/wefa/wefa-ola-red-fruit-ph.webp b/src/assets/wefa/wefa-ola-red-fruit-ph.webp new file mode 100644 index 0000000..c70f8b3 Binary files /dev/null and b/src/assets/wefa/wefa-ola-red-fruit-ph.webp differ diff --git a/src/assets/wefa/wefa-ola-red-fruit.jpg b/src/assets/wefa/wefa-ola-red-fruit.jpg new file mode 100644 index 0000000..11321ee Binary files /dev/null and b/src/assets/wefa/wefa-ola-red-fruit.jpg differ diff --git a/src/assets/wefa/wefa-onboard-generated-creatures-ph.webp b/src/assets/wefa/wefa-onboard-generated-creatures-ph.webp new file mode 100644 index 0000000..8591ed3 Binary files /dev/null and b/src/assets/wefa/wefa-onboard-generated-creatures-ph.webp differ diff --git a/src/assets/wefa/wefa-onboard-select-element-ph.webp b/src/assets/wefa/wefa-onboard-select-element-ph.webp new file mode 100644 index 0000000..4fe7c60 Binary files /dev/null and b/src/assets/wefa/wefa-onboard-select-element-ph.webp differ diff --git a/src/assets/wefa/wefa-onboard-select-plant-ph.webp b/src/assets/wefa/wefa-onboard-select-plant-ph.webp new file mode 100644 index 0000000..538048c Binary files /dev/null and b/src/assets/wefa/wefa-onboard-select-plant-ph.webp differ diff --git a/src/assets/wefa/wefa-splash-ph.webp b/src/assets/wefa/wefa-splash-ph.webp new file mode 100644 index 0000000..3a12236 Binary files /dev/null and b/src/assets/wefa/wefa-splash-ph.webp differ diff --git a/src/assets/wefa/wefa-story-ph.webp b/src/assets/wefa/wefa-story-ph.webp new file mode 100644 index 0000000..e4596dc Binary files /dev/null and b/src/assets/wefa/wefa-story-ph.webp differ diff --git a/src/components/Button/Button.stories.js b/src/components/Button/Button.stories.tsx similarity index 95% rename from src/components/Button/Button.stories.js rename to src/components/Button/Button.stories.tsx index 748ba2e..bb490d1 100644 --- a/src/components/Button/Button.stories.js +++ b/src/components/Button/Button.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Button } from 'components/Button' import { useState } from 'react' import { action } from 'storybook/actions' diff --git a/src/components/Button/Button.js b/src/components/Button/Button.tsx similarity index 73% rename from src/components/Button/Button.js rename to src/components/Button/Button.tsx index a36852d..6152863 100644 --- a/src/components/Button/Button.js +++ b/src/components/Button/Button.tsx @@ -2,27 +2,45 @@ import { Icon } from 'components/Icon' import { Loader } from 'components/Loader' import { Transition } from 'components/Transition' import RouterLink from 'next/link' -import { forwardRef } from 'react' +import { forwardRef, type ElementType, type ReactNode } from 'react' import { classes } from 'utils/style' import styles from './Button.module.scss' -function isExternalLink(href) { +type ButtonProps = { + href?: string + className?: string + as?: ElementType + secondary?: boolean + loading?: boolean + loadingText?: string + icon?: string + iconEnd?: string + iconHoverShift?: boolean + iconOnly?: boolean + children?: ReactNode + rel?: string + target?: string + disabled?: boolean + [key: string]: unknown +} + +function isExternalLink(href?: string) { return href?.includes('://') } -export const Button = forwardRef(({ href, ...rest }, ref) => { +export const Button = forwardRef(({ href, ...rest }, ref) => { if (isExternalLink(href) || !href) { return } return ( - + ) }) -const ButtonContent = forwardRef( +const ButtonContent = forwardRef( ( { className, @@ -40,12 +58,12 @@ const ButtonContent = forwardRef( href, disabled, ...rest - }, + }: ButtonProps, ref ) => { const isExternal = isExternalLink(href) const defaultComponent = href ? 'a' : 'button' - const Component = as || defaultComponent + const Component = (as || defaultComponent) as ElementType return ( & { + text: string + start?: boolean + delay?: number + className?: string +} + +function shuffle(content: string[], output: Character[], position: number) { return content.map((value, index) => { if (index < position) { return { type: CharType.Value, value } @@ -45,18 +59,20 @@ function shuffle(content, output, position) { } export const DecoderText = memo( - ({ text, start = true, delay: startDelay = 0, className, ...rest }) => { - const output = useRef([{ type: CharType.Glyph, value: '' }]) - const container = useRef() + ({ text, start = true, delay: startDelay = 0, className, ...rest }: DecoderTextProps) => { + const output = useRef([{ type: CharType.Glyph, value: '' }]) + const container = useRef(null) const reduceMotion = useReducedMotion() const decoderSpring = useSpring(0, { stiffness: 8, damping: 5 }) useEffect(() => { const containerInstance = container.current const content = text.split('') - let animation + let animation = false const renderOutput = () => { + if (!containerInstance) return + const characterMap = output.current.map(item => { return `${item.value}` }) @@ -75,6 +91,7 @@ export const DecoderText = memo( } if (start && !animation && !reduceMotion) { + animation = true startSpring() } diff --git a/src/components/DecoderText/index.js b/src/components/DecoderText/index.ts similarity index 100% rename from src/components/DecoderText/index.js rename to src/components/DecoderText/index.ts diff --git a/src/components/Divider/Divider.js b/src/components/Divider/Divider.tsx similarity index 58% rename from src/components/Divider/Divider.js rename to src/components/Divider/Divider.tsx index e03057d..a99319e 100644 --- a/src/components/Divider/Divider.js +++ b/src/components/Divider/Divider.tsx @@ -1,17 +1,29 @@ +import type { CSSProperties, HTMLAttributes } from 'react' import { classes, cssProps, numToMs } from 'utils/style' import styles from './Divider.module.css' +type DividerProps = HTMLAttributes & { + lineWidth?: string | number + lineHeight?: string | number + notchWidth?: string | number + notchHeight?: string | number + collapseDelay?: number + collapsed?: boolean + className?: string + style?: CSSProperties +} + export const Divider = ({ - lineWidth, - lineHeight, - notchWidth, - notchHeight, - collapseDelay, - collapsed, + lineWidth = '100%', + lineHeight = '2px', + notchWidth = '90px', + notchHeight = '10px', + collapseDelay = 0, + collapsed = false, className, style, ...rest -}) => ( +}: DividerProps) => (
) - -Divider.defaultProps = { - lineWidth: '100%', - lineHeight: '2px', - notchWidth: '90px', - notchHeight: '10px', - collapsed: false, - collapseDelay: 0, -} diff --git a/src/components/Divider/index.js b/src/components/Divider/index.ts similarity index 100% rename from src/components/Divider/index.js rename to src/components/Divider/index.ts diff --git a/src/components/Footer/Footer.module.css b/src/components/Footer/Footer.module.css index a4964ea..ed757b7 100644 --- a/src/components/Footer/Footer.module.css +++ b/src/components/Footer/Footer.module.css @@ -5,7 +5,7 @@ flex-wrap: wrap; align-items: baseline; justify-content: center; - width: 100vw; + width: 100%; padding: var(--space3XL) var(--spaceL); z-index: var(--zIndex2); position: relative; diff --git a/src/components/Footer/Footer.js b/src/components/Footer/Footer.tsx similarity index 70% rename from src/components/Footer/Footer.js rename to src/components/Footer/Footer.tsx index 0d440cd..e75a00c 100644 --- a/src/components/Footer/Footer.js +++ b/src/components/Footer/Footer.tsx @@ -3,13 +3,17 @@ import { Link } from 'components/Link' import { classes } from 'utils/style' import styles from './Footer.module.css' -export const Footer = ({ className }) => ( +type FooterProps = { + className?: string +} + +export const Footer = ({ className }: FooterProps) => (
{`© ${new Date().getFullYear()} Afolabi Aiyeloja.`} - Enter Nostalgia Pit + Enter Nostalgia Zone
) diff --git a/src/components/Footer/index.js b/src/components/Footer/index.ts similarity index 100% rename from src/components/Footer/index.js rename to src/components/Footer/index.ts diff --git a/src/components/Heading/Heading.stories.js b/src/components/Heading/Heading.stories.tsx similarity index 93% rename from src/components/Heading/Heading.stories.js rename to src/components/Heading/Heading.stories.tsx index fc1decf..1b622e7 100644 --- a/src/components/Heading/Heading.stories.js +++ b/src/components/Heading/Heading.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Heading } from 'components/Heading' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Heading/Heading.js b/src/components/Heading/Heading.tsx similarity index 61% rename from src/components/Heading/Heading.js rename to src/components/Heading/Heading.tsx index 713a328..b1f2772 100644 --- a/src/components/Heading/Heading.js +++ b/src/components/Heading/Heading.tsx @@ -1,7 +1,17 @@ -import { Fragment } from 'react' +import { Fragment, type ElementType, type ReactNode } from 'react' import { classes } from 'utils/style' import styles from './Heading.module.css' +type HeadingProps = { + children?: ReactNode + level?: number + as?: ElementType + align?: string + weight?: string + className?: string + [key: string]: unknown +} + export const Heading = ({ children, level = 1, @@ -10,9 +20,9 @@ export const Heading = ({ weight = 'medium', className, ...rest -}) => { +}: HeadingProps) => { const clampedLevel = Math.min(Math.max(level, 0), 5) - const Component = as || `h${Math.max(clampedLevel, 1)}` + const Component = (as || `h${Math.max(clampedLevel, 1)}`) as ElementType return ( diff --git a/src/components/Heading/index.js b/src/components/Heading/index.ts similarity index 100% rename from src/components/Heading/index.js rename to src/components/Heading/index.ts diff --git a/src/components/Icon/Icon.stories.js b/src/components/Icon/Icon.stories.tsx similarity index 82% rename from src/components/Icon/Icon.stories.js rename to src/components/Icon/Icon.stories.tsx index 8f0c522..4351f37 100644 --- a/src/components/Icon/Icon.stories.js +++ b/src/components/Icon/Icon.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Icon, icons } from 'components/Icon' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Icon/Icon.js b/src/components/Icon/Icon.tsx similarity index 78% rename from src/components/Icon/Icon.js rename to src/components/Icon/Icon.tsx index 660e445..2b12e68 100644 --- a/src/components/Icon/Icon.js +++ b/src/components/Icon/Icon.tsx @@ -1,3 +1,4 @@ +import type { SVGProps } from 'react' import { classes } from 'utils/style' import styles from './Icon.module.css' import ArrowLeft from './svg/arrow-left.svg' @@ -36,8 +37,15 @@ export const icons = { linkedin: Linkedin, } -export const Icon = ({ icon, className, ...rest }) => { - const IconComponent = icons[icon] +export type IconName = keyof typeof icons + +type IconProps = SVGProps & { + icon: IconName | string + className?: string +} + +export const Icon = ({ icon, className, ...rest }: IconProps) => { + const IconComponent = icons[icon as IconName] ?? icons.link return ( diff --git a/src/components/Icon/index.js b/src/components/Icon/index.ts similarity index 100% rename from src/components/Icon/index.js rename to src/components/Icon/index.ts diff --git a/src/components/Image/Image.module.scss b/src/components/Image/Image.module.scss index 7636d33..1d20120 100644 --- a/src/components/Image/Image.module.scss +++ b/src/components/Image/Image.module.scss @@ -4,6 +4,9 @@ display: grid; grid-template-columns: 100%; isolation: isolate; + max-width: 100%; + min-width: 0; + width: 100%; &[data-raised='true'] { box-shadow: 0 50px 100px -20px rgb(var(--rgbBlack) / 0.25), @@ -52,6 +55,7 @@ position: relative; display: grid; grid-template-columns: 100%; + min-width: 0; &[data-reveal='true'] { opacity: 0; @@ -69,6 +73,7 @@ .placeholder { width: 100%; + min-width: 0; height: auto; transition: opacity var(--durationM) ease var(--delay); pointer-events: none; @@ -85,6 +90,7 @@ .element { width: 100%; + min-width: 0; height: auto; opacity: 0; grid-column: 1; diff --git a/src/components/Image/Image.stories.js b/src/components/Image/Image.stories.tsx similarity index 92% rename from src/components/Image/Image.stories.js rename to src/components/Image/Image.stories.tsx index 53eec4a..d5ade85 100644 --- a/src/components/Image/Image.stories.js +++ b/src/components/Image/Image.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Image } from 'components/Image' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Image/Image.js b/src/components/Image/Image.tsx similarity index 72% rename from src/components/Image/Image.js rename to src/components/Image/Image.tsx index 06322ea..d9fc55d 100644 --- a/src/components/Image/Image.js +++ b/src/components/Image/Image.tsx @@ -1,5 +1,11 @@ // import Img from 'next/image' -import { useCallback, useRef, useState } from 'react' +import { + useCallback, + useRef, + useState, + type CSSProperties, + type ImgHTMLAttributes, +} from 'react' import { useTheme } from 'components/ThemeProvider' import { useInViewport } from 'hooks' @@ -7,6 +13,23 @@ import { srcSetToString } from 'utils/image' import { classes, cssProps, numToMs } from 'utils/style' import styles from './Image.module.scss' +type ImageSource = { + src: string + width: number + height: number +} + +type ImageProps = Omit, 'src' | 'srcSet' | 'onLoad'> & { + className?: string + style?: CSSProperties + reveal?: boolean + delay?: number + raised?: boolean + src?: ImageSource + srcSet?: ImageSource[] + placeholder?: ImageSource +} + export const Image = ({ className, style, @@ -17,10 +40,10 @@ export const Image = ({ srcSet, placeholder, ...rest -}) => { +}: ImageProps) => { const [loaded, setLoaded] = useState(false) const { themeId } = useTheme() - const containerRef = useRef() + const containerRef = useRef(null) const src = baseSrc || srcSet?.[0] const inViewport = useInViewport(containerRef, true) @@ -28,6 +51,8 @@ export const Image = ({ setLoaded(true) }, []) + if (!src) return null + return (
& { + onLoad: () => void + loaded: boolean + inViewport: boolean + src: ImageSource }) => { const [showPlaceholder, setShowPlaceholder] = useState(true) - const placeholderRef = useRef() + const placeholderRef = useRef(null) const showFullRes = inViewport const srcSetString = srcSetToString(srcSet) @@ -76,7 +106,7 @@ const ImageElements = ({ className={styles.elementWrapper} data-reveal={reveal} data-visible={inViewport || loaded} - style={cssProps({ delay: numToMs(delay + 1000) })} + style={cssProps({ delay: numToMs((delay ?? 0) + 1000) })} > - {showPlaceholder && ( + {showPlaceholder && placeholder && ( +type FieldFocusEvent = FocusEvent + +type InputProps = Omit, 'onChange' | 'onBlur'> & { + id?: string + label?: ReactNode + value?: string + multiline?: boolean + className?: string + style?: CSSProperties + error?: ReactNode + onBlur?: (event: FieldFocusEvent) => void + onChange?: (event: FieldEvent) => void +} + export const Input = ({ id, label, @@ -21,16 +45,16 @@ export const Input = ({ type, onChange, ...rest -}) => { +}: InputProps) => { const [focused, setFocused] = useState(false) const generatedId = useId() - const errorRef = useRef() + const errorRef = useRef(null) const inputId = id || `${generatedId}input` const labelId = `${inputId}-label` const errorId = `${inputId}-error` const InputElement = multiline ? TextArea : 'input' - const handleBlur = event => { + const handleBlur = (event: FieldFocusEvent) => { setFocused(false) if (onBlur) { @@ -71,7 +95,7 @@ export const Input = ({ />
- + {visible => (
, 'onChange'> & { + className?: string + resize?: string + value?: string + onChange?: (event: ChangeEvent) => void + minRows?: number + maxRows?: number +} + export const TextArea = ({ className, resize = 'none', @@ -10,12 +30,15 @@ export const TextArea = ({ minRows = 1, maxRows, ...rest -}) => { +}: TextAreaProps) => { const [rows, setRows] = useState(minRows) - const [textareaDimensions, setTextareaDimensions] = useState() - const textareaRef = useRef() + const [textareaDimensions, setTextareaDimensions] = + useState(null) + const textareaRef = useRef(null) useEffect(() => { + if (!textareaRef.current) return + const style = getComputedStyle(textareaRef.current) const lineHeight = parseInt(style.lineHeight, 10) const paddingHeight = @@ -23,8 +46,10 @@ export const TextArea = ({ setTextareaDimensions({ lineHeight, paddingHeight }) }, []) - const handleChange = event => { - onChange(event) + const handleChange = (event: ChangeEvent) => { + onChange?.(event) + + if (!textareaDimensions) return const { lineHeight, paddingHeight } = textareaDimensions const previousRows = event.target.rows diff --git a/src/components/Input/index.js b/src/components/Input/index.ts similarity index 100% rename from src/components/Input/index.js rename to src/components/Input/index.ts diff --git a/src/components/Link/Link.stories.js b/src/components/Link/Link.stories.tsx similarity index 84% rename from src/components/Link/Link.stories.js rename to src/components/Link/Link.stories.tsx index c4c956d..576b388 100644 --- a/src/components/Link/Link.stories.js +++ b/src/components/Link/Link.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Link } from 'components/Link' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Link/Link.js b/src/components/Link/Link.tsx similarity index 64% rename from src/components/Link/Link.js rename to src/components/Link/Link.tsx index 8962cfe..4bfba92 100644 --- a/src/components/Link/Link.js +++ b/src/components/Link/Link.tsx @@ -1,30 +1,40 @@ import RouterLink from 'next/link' -import { forwardRef } from 'react' +import { forwardRef, type ReactNode } from 'react' import { classes } from 'utils/style' import styles from './Link.module.css' // File extensions that can be linked to const VALID_EXT = ['txt', 'png', 'jpg'] -function isAnchor(href) { - const isValidExtension = VALID_EXT.includes(href?.split('.').pop()) +type LinkProps = { + href?: string + rel?: string + target?: string + children?: ReactNode + secondary?: boolean + className?: string + [key: string]: unknown +} + +function isAnchor(href?: string) { + const isValidExtension = VALID_EXT.includes(href?.split('.').pop() ?? '') return href?.includes('://') || href?.[0] === '#' || isValidExtension } -export const Link = forwardRef(({ href, ...rest }, ref) => { - if (isAnchor(href)) { +export const Link = forwardRef(({ href, ...rest }, ref) => { + if (isAnchor(href) || !href) { return } return ( - + ) }) -export const LinkContent = forwardRef( - ({ rel, target, children, secondary, className, href, ...rest }, ref) => { +export const LinkContent = forwardRef( + ({ rel, target, children, secondary, className, href, ...rest }: LinkProps, ref) => { const isExternal = href?.includes('://') const relValue = rel || (isExternal ? 'noreferrer noopener' : undefined) const targetValue = target || (isExternal ? '_blank' : undefined) diff --git a/src/components/Link/index.js b/src/components/Link/index.ts similarity index 100% rename from src/components/Link/index.js rename to src/components/Link/index.ts diff --git a/src/components/Loader/Loader.stories.js b/src/components/Loader/Loader.stories.tsx similarity index 77% rename from src/components/Loader/Loader.stories.js rename to src/components/Loader/Loader.stories.tsx index 1ac1075..db35917 100644 --- a/src/components/Loader/Loader.stories.js +++ b/src/components/Loader/Loader.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Loader } from 'components/Loader' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Loader/Loader.js b/src/components/Loader/Loader.tsx similarity index 77% rename from src/components/Loader/Loader.js rename to src/components/Loader/Loader.tsx index b99cec9..d4fa2f4 100644 --- a/src/components/Loader/Loader.js +++ b/src/components/Loader/Loader.tsx @@ -1,3 +1,4 @@ +import type { CSSProperties, HTMLAttributes } from 'react' import { Text } from 'components/Text' import { VisuallyHidden } from 'components/VisuallyHidden' import { useReducedMotion } from 'framer-motion' @@ -6,7 +7,20 @@ import { createPortal } from 'react-dom' import { classes, cssProps } from 'utils/style' import styles from './Loader.module.css' -export const Loader = ({ className, style, size = 32, text = 'Loading...', ...rest }) => { +type LoaderProps = HTMLAttributes & { + className?: string + style?: CSSProperties + size?: number + text?: string +} + +export const Loader = ({ + className, + style, + size = 32, + text = 'Loading...', + ...rest +}: LoaderProps) => { const reduceMotion = useReducedMotion() const hasMounted = useHasMounted() @@ -17,7 +31,7 @@ export const Loader = ({ className, style, size = 32, text = 'Loading...', ...re {text} , - document.getElementById('portal-root') + document.getElementById('portal-root') ?? document.body ) } diff --git a/src/components/Loader/index.js b/src/components/Loader/index.ts similarity index 100% rename from src/components/Loader/index.js rename to src/components/Loader/index.ts diff --git a/src/components/Meta/Meta.js b/src/components/Meta/Meta.js deleted file mode 100644 index fc776b9..0000000 --- a/src/components/Meta/Meta.js +++ /dev/null @@ -1,37 +0,0 @@ -import Head from 'next/head' - -const siteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL -const name = 'Afolabi Aiyeloja' -const twitterHandle = '@Afolabi_A_A_A' -const defaultOgImage = `${siteUrl}/social-image.png` - -export const Meta = ({ title, description, prefix = name, ogImage = defaultOgImage }) => { - const titleText = [prefix, title].filter(Boolean).join(' | ') - - return ( - - {titleText} - - - - - - - - - - - - - - - - - - - - - - - ) -} diff --git a/src/components/Meta/Meta.tsx b/src/components/Meta/Meta.tsx new file mode 100644 index 0000000..f7ea9dd --- /dev/null +++ b/src/components/Meta/Meta.tsx @@ -0,0 +1,82 @@ +import Head from 'next/head' + +const siteUrl = process.env.NEXT_PUBLIC_WEBSITE_URL || 'https://afolabi.info' +const name = 'Afolabi Aiyeloja' + +export type SocialImage = { + src: string + alt: string + width?: number + height?: number + type?: string +} + +const defaultSocialImage = { + src: '/social-image.png', + alt: 'Social preview for Afolabi Aiyeloja portfolio.', + width: 1200, + height: 630, + type: 'image/png', +} satisfies SocialImage + +function absoluteUrl(value = '/') { + return new URL(value, siteUrl).toString() +} + +type MetaProps = { + title?: string + description: string + prefix?: string + route?: string + ogImage?: string + socialImage?: SocialImage +} + +export const Meta = ({ + title, + description, + prefix = name, + route = '/', + ogImage, + socialImage, +}: MetaProps) => { + const titleText = [prefix, title].filter(Boolean).join(' | ') + const image = socialImage ?? { + ...defaultSocialImage, + src: ogImage ?? defaultSocialImage.src, + } + const imageUrl = absoluteUrl(image.src) + const pageUrl = absoluteUrl(route) + + return ( + + {titleText} + + + + + + + + + + + + + + + + + + + + + + ) +} diff --git a/src/components/Meta/index.js b/src/components/Meta/index.ts similarity index 100% rename from src/components/Meta/index.js rename to src/components/Meta/index.ts diff --git a/src/components/Model/Model.stories.js b/src/components/Model/Model.stories.tsx similarity index 96% rename from src/components/Model/Model.stories.js rename to src/components/Model/Model.stories.tsx index a685059..e3cdd42 100644 --- a/src/components/Model/Model.stories.js +++ b/src/components/Model/Model.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy JS migration; remove after adding explicit types. import phoneTexture2Placeholder from 'assets/mira-flow/flow-mobile-collections-ph.webp' import phoneTexture2 from 'assets/mira-flow/flow-mobile-collections.webp' import phoneTexturePlaceholder from 'assets/mira-flow/flow-mobile-profile-ph.webp' diff --git a/src/components/Model/Model.js b/src/components/Model/Model.tsx similarity index 91% rename from src/components/Model/Model.js rename to src/components/Model/Model.tsx index b151797..7e2f8dd 100644 --- a/src/components/Model/Model.js +++ b/src/components/Model/Model.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy JS migration; remove after adding explicit types. import { animate, useReducedMotion, useSpring } from 'framer-motion' import { useInViewport } from 'hooks' import { @@ -64,6 +65,7 @@ export const Model = ({ ...rest }) => { const [loaded, setLoaded] = useState(false) + const [webglReady, setWebglReady] = useState(true) const container = useRef() const canvas = useRef() const camera = useRef() @@ -88,9 +90,29 @@ export const Model = ({ useEffect(() => { const { clientWidth, clientHeight } = container.current + const context = + canvas.current.getContext('webgl', { + alpha: true, + antialias: false, + powerPreference: 'high-performance', + failIfMajorPerformanceCaveat: true, + }) || + canvas.current.getContext('experimental-webgl', { + alpha: true, + antialias: false, + powerPreference: 'high-performance', + failIfMajorPerformanceCaveat: true, + }) + + if (!context) { + setWebglReady(false) + setLoaded(true) + return undefined + } renderer.current = new WebGLRenderer({ canvas: canvas.current, + context, alpha: true, antialias: false, powerPreference: 'high-performance', @@ -224,6 +246,16 @@ export const Model = ({ }, []) const blurShadow = useCallback(amount => { + if ( + !renderer.current || + !renderTarget.current || + !renderTargetBlur.current || + !blurPlane.current || + !shadowCamera.current + ) { + return + } + blurPlane.current.visible = true // Blur horizontally and draw in the renderTargetBlur @@ -247,6 +279,18 @@ export const Model = ({ // Handle render passes for a single frame const renderFrame = useCallback(() => { + if ( + !renderer.current || + !scene.current || + !camera.current || + !shadowCamera.current || + !depthMaterial.current || + !renderTarget.current || + !modelGroup.current + ) { + return + } + const blurAmount = 5 // Remove the background @@ -306,7 +350,7 @@ export const Model = ({ // Handle window resize useEffect(() => { const handleResize = () => { - if (!container.current) return + if (!container.current || !renderer.current || !camera.current) return const { clientWidth, clientHeight } = container.current @@ -336,11 +380,12 @@ export const Model = ({ {...rest} > - {models.map((model, index) => ( + {webglReady && models.map((model, index) => ( { + if (!webglReady || !renderer.current || !modelGroup.current) return undefined + const applyScreenTexture = async (texture, node) => { texture.encoding = sRGBEncoding texture.flipY = false @@ -494,7 +542,7 @@ const Device = ({ }, []) useEffect(() => { - if (!loadDevice || !show) return + if (!webglReady || !loadDevice || !show) return undefined let animation const onLoad = async () => { diff --git a/src/components/Model/deviceModels.js b/src/components/Model/deviceModels.ts similarity index 100% rename from src/components/Model/deviceModels.js rename to src/components/Model/deviceModels.ts diff --git a/src/components/Model/index.js b/src/components/Model/index.ts similarity index 100% rename from src/components/Model/index.js rename to src/components/Model/index.ts diff --git a/src/components/Monogram/Monogram.js b/src/components/Monogram/Monogram.js deleted file mode 100644 index cb558d4..0000000 --- a/src/components/Monogram/Monogram.js +++ /dev/null @@ -1,37 +0,0 @@ -import { forwardRef, useId } from 'react' -import { classes } from 'utils/style' -import styles from './Monogram.module.css' - -export const Monogram = forwardRef(({ highlight, className, ...props }, ref) => { - const id = useId() - const clipId = `${id}monogram-clip` - - return ( - - - - - - - - {highlight && ( - - - - )} - - ) -}) diff --git a/src/components/Monogram/Monogram.stories.js b/src/components/Monogram/Monogram.stories.tsx similarity index 78% rename from src/components/Monogram/Monogram.stories.js rename to src/components/Monogram/Monogram.stories.tsx index c6a7088..ec65c05 100644 --- a/src/components/Monogram/Monogram.stories.js +++ b/src/components/Monogram/Monogram.stories.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- Storybook stories still use legacy untyped wrappers. import { Monogram } from 'components/Monogram' import { StoryContainer } from '../../../.storybook/StoryContainer' diff --git a/src/components/Monogram/Monogram.tsx b/src/components/Monogram/Monogram.tsx new file mode 100644 index 0000000..f09563d --- /dev/null +++ b/src/components/Monogram/Monogram.tsx @@ -0,0 +1,54 @@ +import { forwardRef, useId, type SVGProps } from 'react' +import { classes } from 'utils/style' +import styles from './Monogram.module.css' + +type MonogramProps = SVGProps & { + highlight?: boolean + className?: string +} + +export const Monogram = forwardRef( + ({ highlight, className, ...props }, ref) => { + const id = useId() + const maskId = `${id}monogram-mask` + + return ( + + + + + + + + + + + + + {highlight && ( + + + + )} + + ) + } +) diff --git a/src/components/Monogram/index.js b/src/components/Monogram/index.ts similarity index 100% rename from src/components/Monogram/index.js rename to src/components/Monogram/index.ts diff --git a/src/components/Navbar/NavToggle.module.scss b/src/components/Navbar/NavToggle.module.scss index fd46e7e..9f6defb 100644 --- a/src/components/Navbar/NavToggle.module.scss +++ b/src/components/Navbar/NavToggle.module.scss @@ -39,7 +39,7 @@ button.toggle { transition-property: opacity, transform, fill; } - &[data-open='true'] { + &[data-menu='true'][data-open='true'] { opacity: 0; @media (--mediaUseMotion) { @@ -57,8 +57,7 @@ button.toggle { } } - &[data-open='true'], - [data-close='true'] { + &[data-close='true'][data-open='true'] { opacity: 1; @media (--mediaUseMotion) { diff --git a/src/components/Navbar/NavToggle.js b/src/components/Navbar/NavToggle.tsx similarity index 89% rename from src/components/Navbar/NavToggle.js rename to src/components/Navbar/NavToggle.tsx index 190e399..d3bd474 100644 --- a/src/components/Navbar/NavToggle.js +++ b/src/components/Navbar/NavToggle.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy JS migration; remove after adding explicit types. import { Icon } from 'components/Icon' import { Button } from 'components/Button' diff --git a/src/components/Navbar/Navbar.js b/src/components/Navbar/Navbar.tsx similarity index 94% rename from src/components/Navbar/Navbar.js rename to src/components/Navbar/Navbar.tsx index 85f9bad..72270b4 100644 --- a/src/components/Navbar/Navbar.js +++ b/src/components/Navbar/Navbar.tsx @@ -1,3 +1,4 @@ +// @ts-nocheck -- legacy JS migration; remove after adding explicit types. import RouterLink from 'next/link' import { useRouter } from 'next/router' import { useEffect, useRef, useState } from 'react' @@ -143,11 +144,11 @@ export const Navbar = () => { return (
- + @@ -157,7 +158,7 @@ export const Navbar = () => {