Inital Commit
All checks were successful
ci / build (push) Successful in 30s
ci / publish (push) Successful in 18s

This commit is contained in:
Matthew McPeak 2026-07-16 12:38:41 -04:00
commit cb287c0e51
56 changed files with 4080 additions and 0 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
node_modules
dist-ssr
coverage
test-results
playwright-report
.git
.vscode
*.log

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

91
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,91 @@
name: ci
on:
push:
tags:
- "v*"
pull_request:
jobs:
build:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
outputs:
artifact_name: ${{ steps.meta.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set artifact name
id: meta
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${GITHUB_REF_NAME#v}"
else
tag="$(date -u +%Y%m%d)"
fi
echo "name=portfolio_${tag}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: |
printf '@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n' "$REGISTRY_TOKEN" > .npmrc
bun install --frozen-lockfile
rm .npmrc
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
- name: Audit dependencies
run: bun audit || true
- name: Run unit tests
run: bun run test:unit run
- name: Type check
run: bun run type-check -- --force
- name: Build
run: bun run build-only
env:
VITE_API_URL: ${{ vars.VITE_API_URL }}
VITE_API_KEY: ${{ secrets.VITE_API_KEY }}
- name: Upload dist
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.name }}.zip
path: dist
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download dist
uses: https://code.forgejo.org/forgejo/download-artifact@v4
with:
name: ${{ needs.build.outputs.artifact_name }}.zip
path: dist
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: |
git.mcpeakdev.com/mcpeakml/portfolio:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakml/portfolio:latest

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

44
.gitignore vendored Normal file
View file

@ -0,0 +1,44 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
test-results/
playwright-report/
.env

5
.oxfmtrc.json Normal file
View file

@ -0,0 +1,5 @@
{
"$schema": "./node_modules/oxfmt/configuration_schema.json",
"semi": false,
"singleQuote": true
}

10
.oxlintrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

10
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"ms-playwright.playwright",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode"
]
}

7
Dockerfile Normal file
View file

@ -0,0 +1,7 @@
# syntax=docker/dockerfile:1
FROM nginx:1-alpine
COPY dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

90
README.md Normal file
View file

@ -0,0 +1,90 @@
# portfolio
Personal portfolio site built with Vue 3, Vite, and Tailwind CSS, styled with
the `@nychthemeron/library` design system (private package, published to the
Forgejo registry at `git.mcpeakdev.com`). Content is fetched at runtime from
a small key-value API rather than baked into the bundle (see
`src/composables/useAPI.ts`).
## Project Setup
```sh
bun install
```
Copy `sample.env` to `.env` and fill in `VITE_API_URL` / `VITE_API_KEY` for
the content API.
### Compile and Hot-Reload for Development
```sh
bun dev
```
### Type-Check, Compile and Minify for Production
```sh
bun run build
```
### Run Unit Tests with [Vitest](https://vitest.dev/)
```sh
bun test:unit
```
### Run End-to-End Tests with [Playwright](https://playwright.dev)
```sh
# Install browsers for the first run
npx playwright install
# When testing on CI, must build the project first
bun run build
# Runs the end-to-end tests
bun test:e2e
# Runs the tests only on Chromium
bun test:e2e --project=chromium
# Runs the tests of a specific file
bun test:e2e tests/example.spec.ts
# Runs the tests in debug mode
bun test:e2e --debug
```
### Lint and Format
Linting runs both [oxlint](https://oxc.rs/docs/guide/usage/linter.html) and
[ESLint](https://eslint.org/); formatting uses [oxfmt](https://oxc.rs/).
```sh
bun lint
bun format
```
## Docker
The Dockerfile serves a pre-built `dist/` directory via nginx, so build the
app first:
```sh
bun run build
docker build -t portfolio .
docker run --rm -p 8080:80 portfolio
```
The site will be available at http://localhost:8080.
### CI publishing
A Forgejo Actions workflow (`.forgejo/workflows/ci.yml`) runs tests, type
checks, and a build on every push and pull request. When a `v*` tag is
pushed (e.g. `v1.2.3`), it also builds and pushes the image to the Forgejo
container registry:
- `git.mcpeakdev.com/mcpeakml/portfolio:<version>`
- `git.mcpeakdev.com/mcpeakml/portfolio:latest`
This requires a `REGISTRY_TOKEN` repository secret containing a token with
`write:package` permission for the registry, plus `VITE_API_URL` /
`VITE_API_KEY` build-time variables (see `ci.yml`).

1781
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,112 @@
# Refactor portfolio to use `@nychthemeron/library`
## Context
The portfolio currently ships a hand-rolled "Nychthemeron" design system:
local components in `src/components/ds/` (`NychButton`, `NychCard`,
`NychInputText`, `NychTextarea`, `NychMessage`, `NychTag`, `LaurelSpinner`,
`LaurelDivider`, `ImageSlot`) backed by ~1200 lines of token CSS in
`src/assets/styles/`.
`@nychthemeron/library` (now installed from the private registry at
`git.mcpeakdev.com`) is the extracted, PrimeVue-backed version of this same
design system: unstyled PrimeVue components (`Button`, `Card`, `InputText`,
`Textarea`, `Message`, `Tag`, `LoadingIcon`, plus others not yet used here)
wired up with the same `nych-*` passthrough classes, and ships the same
theme as CSS via `@nychthemeron/library/style` and `@nychthemeron/library/theme`.
Goal: replace the local duplicated implementation with the library, keeping
only what the library doesn't provide (`LaurelDivider`, `ImageSlot`, and the
580px static hero spinner).
## Styling / setup
- `src/main.ts`:
- Add `import '@nychthemeron/library/style'` and
`import '@nychthemeron/library/theme'`.
- Register PrimeVue: `app.use(PrimeVue)` (the library's components are
PrimeVue components under the hood).
- Keep the existing `import './assets/styles/styles.css'`, but trim its
`@import` manifest (see below) so it only covers portfolio-specific
CSS; the library CSS imports are added separately in `main.ts`.
- Delete `src/assets/styles/tokens/fonts.css`, `tokens/colors.css`,
`tokens/base.css` — fully superseded by `@nychthemeron/library/style`
(same theme CSS variables for both `hades`/`apollo` themes, font imports,
base reset, focus-ring, and the `nych-spin` keyframes).
- `src/assets/styles/tokens/components.css`: delete every section except
**LaurelDivider** (currently the last section, ~line 738 onward). Rename
the file to `tokens/laurel-divider.css` containing just that section.
Everything else (Button, Input/Textarea, Checkbox/Radio, ToggleSwitch,
Select, Card, Message, Tag, Dialog) is covered by
`@nychthemeron/library/theme`.
- Keep as-is: `tokens/spacing.css`, `tokens/typography.css`,
`portfolio.css`, brand SVGs in `src/assets/brand/`.
- Update `src/assets/styles/styles.css`'s `@import` manifest to drop the
removed files and add `tokens/laurel-divider.css` in place of
`tokens/components.css`.
## Component mapping
| Local component | Replacement | Notes |
|---|---|---|
| `NychTag` | `Tag` from `@nychthemeron/library` | `severity` / `rounded` props pass straight through |
| `NychMessage` | `Message` | `severity` / `closable` pass straight through |
| `NychInputText` | `InputText` | `v-model`, `placeholder`, `type`, `invalid` pass through; drop the local `size="normal"` prop (PrimeVue `size` only accepts `small`/`large`/default — `normal` maps to "don't pass `size`") |
| `NychTextarea` | `Textarea` | `v-model`, `rows`, `placeholder`, `invalid` — direct match |
| `NychButton` | `Button` | `severity`, `disabled`, `loading`, default + `icon` slots — direct match. The library's `Button` has a built-in loading spinner (laurel wreath), replacing the local `LaurelSpinner` usage inside `NychButton` |
| `NychCard` | `Card` | slot rename: local `image` slot → PrimeVue `header` slot; local default slot → PrimeVue `content` slot; `title`/`subtitle` props match |
| `LaurelSpinner` (small, used inside `NychButton`) | dropped — covered by `Button`'s built-in loader | |
| `LaurelSpinner` (Hero, 580px static, `:spinning="false"`) | kept as a local component, rewritten as a thin wrapper around `NychLoadingIcon` imported from `@nychthemeron/library/components`, with scoped CSS to set `width`/`height` and override/disable the `nych-spin` animation when `spinning` is false | per approved "wrap with CSS overrides" approach |
| `LaurelDivider`, `ImageSlot` | unchanged, no library equivalent | |
## Files affected
**Deleted:**
- `src/components/ds/NychTag.vue`
- `src/components/ds/NychMessage.vue`
- `src/components/ds/NychInputText.vue`
- `src/components/ds/NychTextarea.vue`
- `src/components/ds/NychButton.vue`
- `src/components/ds/NychCard.vue`
- `src/assets/styles/tokens/fonts.css`
- `src/assets/styles/tokens/colors.css`
- `src/assets/styles/tokens/base.css`
**Rewritten:**
- `src/components/ds/LaurelSpinner.vue` — becomes a thin wrapper around the
library's `NychLoadingIcon`, preserving its existing `size` / `color` /
`spinning` prop API so `HeroSection.vue` doesn't need to change its usage.
- `src/assets/styles/tokens/components.css``tokens/laurel-divider.css`
(LaurelDivider section only).
- `src/assets/styles/styles.css` (updated `@import` manifest).
- `src/main.ts` (add PrimeVue + library CSS imports, `app.use(PrimeVue)`).
**Updated imports/usages:**
- `src/components/portfolio/SkillsSection.vue``NychTag` from library.
- `src/components/portfolio/WorkSection.vue``NychCard`, `NychTag` from
library; update `NychCard` slot usage (`image` → `header`, default →
`content`).
- `src/components/portfolio/ContactSection.vue``NychInputText`,
`NychTextarea`, `NychButton`, `NychMessage` from library.
- `src/components/portfolio/HeroSection.vue``NychButton` from library;
`LaurelSpinner` continues to be imported from `./ds/LaurelSpinner.vue`
(rewritten).
## Verification
- `bun run build` and `bun run type-check` must pass.
- Visually verify via dev server (`bun run dev`):
- Hero section renders the large static laurel wreath at the same size.
- Skills/Work section tags render with correct severity colors.
- Work section cards render header image, title, content, footer tags.
- Contact form inputs/textarea/button/message render and behave
(loading spinner shows in button while "sending").
- Both `hades` and `apollo` themes (toggle `data-theme` on `<html>`) still
render correctly.
## Out of scope
- No changes to `LaurelDivider.vue` or `ImageSlot.vue` internals.
- No changes to other library components not currently used
(`Checkbox`, `Select`, `Dialog`, `ToggleSwitch`, `RadioButton`, etc.).
- No changes to the CI workflow or registry configuration.

4
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,4 @@
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": ["./**/*"]
}

8
e2e/vue.spec.ts Normal file
View file

@ -0,0 +1,8 @@
import { test, expect } from '@playwright/test'
// See here how to get started:
// https://playwright.dev/docs/intro
test('visits the app root url', async ({ page }) => {
await page.goto('/')
await expect(page.locator('h1')).toHaveText('You did it!')
})

18
env.d.ts vendored Normal file
View file

@ -0,0 +1,18 @@
/// <reference types="vite/client" />
// @nychthemeron/library ships this as a CSS-only export subpath, so it has
// no matching *.css wildcard declaration (the specifier doesn't end in .css).
declare module '@nychthemeron/library/style'
interface ViteTypeOptions {
strictImportMetaEnv: unknown;
}
interface ImportMetaEnv {
readonly VITE_API_URL: string;
readonly VITE_API_KEY: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}

38
eslint.config.ts Normal file
View file

@ -0,0 +1,38 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginPlaywright from 'eslint-plugin-playwright'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginPlaywright.configs['flat/recommended'],
files: ['e2e/**/*.{test,spec}.{js,ts,jsx,tsx}'],
},
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

2
global.d.ts vendored Normal file
View file

@ -0,0 +1,2 @@
declare const API_URL: string;
declare const API_KEY: string;

12
index.html Normal file
View file

@ -0,0 +1,12 @@
<!doctype html>
<html lang="">
<head>
<meta charset="UTF-8" />
<link rel="icon" href="/mark.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
nginx.conf Normal file
View file

@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

55
package.json Normal file
View file

@ -0,0 +1,55 @@
{
"name": "portfolio",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"test:e2e": "playwright test",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "oxfmt src/"
},
"dependencies": {
"@nychthemeron/library": "^0.0.3",
"@unhead/vue": "^3.1.8",
"vue": "^3.5.40"
},
"devDependencies": {
"@playwright/test": "^1.61.1",
"@tailwindcss/vite": "^4.3.3",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^25.9.5",
"@vitejs/plugin-vue": "^6.0.8",
"@vitest/eslint-plugin": "^1.6.23",
"@vue/eslint-config-typescript": "^14.9.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.7.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-playwright": "^2.10.5",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"oxlint": "~1.60.0",
"tailwindcss": "^4.3.3",
"typescript": "~6.0.3",
"vite": "^8.1.5",
"vite-plugin-vue-devtools": "^8.1.5",
"vitest": "^4.1.10",
"vue-tsc": "^3.3.7"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

110
playwright.config.ts Normal file
View file

@ -0,0 +1,110 @@
import process from 'node:process'
import { defineConfig, devices } from '@playwright/test'
/**
* Read environment variables from file.
* https://github.com/motdotla/dotenv
*/
// require('dotenv').config();
/**
* See https://playwright.dev/docs/test-configuration.
*/
export default defineConfig({
testDir: './e2e',
/* Maximum time one test can run for. */
timeout: 30 * 1000,
expect: {
/**
* Maximum time expect() should wait for the condition to be met.
* For example in `await expect(locator).toHaveText();`
*/
timeout: 5000,
},
/* Fail the build on CI if you accidentally left test.only in the source code. */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Opt out of parallel tests on CI. */
workers: process.env.CI ? 1 : undefined,
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
reporter: 'html',
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: {
/* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */
actionTimeout: 0,
/* Base URL to use in actions like `await page.goto('/')`. */
baseURL: process.env.CI ? 'http://localhost:4173' : 'http://localhost:5173',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry',
/* Only on CI systems run the tests headless */
headless: !!process.env.CI,
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
},
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
},
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
},
},
/* Test against mobile viewports. */
// {
// name: 'Mobile Chrome',
// use: {
// ...devices['Pixel 5'],
// },
// },
// {
// name: 'Mobile Safari',
// use: {
// ...devices['iPhone 12'],
// },
// },
/* Test against branded browsers. */
// {
// name: 'Microsoft Edge',
// use: {
// channel: 'msedge',
// },
// },
// {
// name: 'Google Chrome',
// use: {
// channel: 'chrome',
// },
// },
],
/* Folder for test artifacts such as screenshots, videos, traces, etc. */
// outputDir: 'test-results/',
/* Run your local dev server before starting the tests */
webServer: {
/**
* Use the dev server by default for faster feedback loop.
* Use the preview server on CI for more realistic testing.
* Playwright will re-use the local server if there is already a dev-server running.
*/
command: process.env.CI ? 'npm run preview' : 'npm run dev',
port: process.env.CI ? 4173 : 5173,
reuseExistingServer: !process.env.CI,
},
})

16
public/mark.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40" role="img" aria-label="Nychthemeron">
<path d="M 20 3 A 17 17 0 0 0 20 37 Z" fill="#d4b278"></path>
<circle cx="20" cy="20" r="17" fill="none" stroke="#d4b278" stroke-width="1.5"></circle>
<g stroke="rgba(12,9,3,0.18)" stroke-width="1" stroke-linecap="round">
<line x1="20" y1="20" x2="7" y2="8"></line>
<line x1="20" y1="20" x2="3" y2="20"></line>
<line x1="20" y1="20" x2="7" y2="32"></line>
</g>
<path d="M 24 11 A 10 10 0 0 0 24 29" fill="none" stroke="#d4b278" stroke-width="1.2" stroke-linecap="round" opacity="0.55"></path>
<circle cx="30" cy="15" r="1" fill="#d4b278" opacity="0.7"></circle>
</svg>

After

Width:  |  Height:  |  Size: 728 B

2
sample.env Normal file
View file

@ -0,0 +1,2 @@
VITE_API_URL=http://localhost:5173/
VITE_API_KEY=

45
src/App.vue Normal file
View file

@ -0,0 +1,45 @@
<script setup lang="ts">
import { onBeforeMount, ref, computed, type Ref } from 'vue'
import { useHead } from '@unhead/vue'
import TheNav from '@/components/portfolio/TheNav.vue'
import HeroSection from '@/components/portfolio/HeroSection.vue'
import AboutSection from '@/components/portfolio/AboutSection.vue'
import WorkSection from '@/components/portfolio/WorkSection.vue'
import SkillsSection from '@/components/portfolio/SkillsSection.vue'
import ContactSection from '@/components/portfolio/ContactSection.vue'
import TheFooter from '@/components/portfolio/TheFooter.vue'
import { useTheme } from '@/composables/useTheme'
import { useAPI } from '@/composables/useAPI'
import { useReveal } from '@/composables/useReveal'
const { theme, toggle } = useTheme()
const { getPortfolioData } = useAPI()
import type { PortfolioData } from '@/data/portfolio'
const portfolioData: Ref<PortfolioData | undefined> = ref()
useReveal()
useHead({
title: computed(() => (portfolioData.value ? portfolioData.value.name + ' · Portfolio' : '')),
meta: [{ name: 'description', content: 'Page description' }],
})
onBeforeMount(async () => (portfolioData.value = await getPortfolioData()))
</script>
<template>
<TheNav :theme="theme" @toggle="toggle" :portfolioData="portfolioData" />
<main>
<HeroSection :portfolioData="portfolioData" />
<AboutSection :portfolioData="portfolioData" />
<WorkSection :portfolioData="portfolioData" />
<SkillsSection :portfolioData="portfolioData" />
<ContactSection :portfolioData="portfolioData" />
</main>
<TheFooter :portfolioData="portfolioData" />
</template>
<style scoped></style>

17
src/__tests__/App.spec.ts Normal file
View file

@ -0,0 +1,17 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
vi.mock('@unhead/vue')
describe('App', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('NYCHTHEMERON')
})
})

View file

@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { useAPI } from '../../composables/useAPI'
describe('useAPI', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('creates properly', () => {
const api = useAPI()
expect(api).not.toBeUndefined()
})
it('returns data', async () => {
const mockData = [
{
key: 'test',
value: '{}',
},
]
globalThis.fetch = vi.fn<() => Promise<Response>>(() =>
Promise.resolve({
ok: true,
json: () => Promise.resolve(mockData),
} as Response),
)
const api = useAPI()
const data = await api.getPortfolioData()
expect(data).toStrictEqual({})
})
it('returns undefined on failure', async () => {
const mockData = {
something: 'test',
}
globalThis.fetch = vi.fn<() => Promise<Response>>(() =>
Promise.reject({
ok: true,
json: () => Promise.resolve(mockData),
} as Response),
)
const api = useAPI()
const data = await api.getPortfolioData()
expect(data).toStrictEqual(undefined)
})
})

View file

@ -0,0 +1,20 @@
<svg class="nych-loading-icon" xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="226 66 228 228" width="48" height="48" role="img" aria-label="loading">
<g class="wreath" fill="currentColor" stroke="currentColor">
<g transform="rotate(0,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(22.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(45,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(67.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(90,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(112.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(135,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(157.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(180,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(202.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(225,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(247.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(270,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(292.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(315,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(337.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

View file

@ -0,0 +1,20 @@
<svg xmlns="http://www.w3.org/2000/svg" preserveAspectRatio="xMidYMid meet" viewBox="226 66 228 228" width="48" height="48" role="img" aria-label="Nychthemeron laurel">
<g fill="#d4b278" stroke="#d4b278">
<g transform="rotate(0,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(22.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(45,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(67.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(90,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(112.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(135,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(157.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(180,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(202.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(225,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(247.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(270,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(292.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(315,340,180)"><path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z"></path><line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round"></line></g>
<g transform="rotate(337.5,340,180)"><path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z"></path><line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round"></line></g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.5 KiB

16
src/assets/brand/mark.svg Normal file
View file

@ -0,0 +1,16 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 40 40" width="40" height="40" role="img" aria-label="Nychthemeron">
<path d="M 20 3 A 17 17 0 0 0 20 37 Z" fill="#d4b278"></path>
<circle cx="20" cy="20" r="17" fill="none" stroke="#d4b278" stroke-width="1.5"></circle>
<g stroke="rgba(12,9,3,0.18)" stroke-width="1" stroke-linecap="round">
<line x1="20" y1="20" x2="7" y2="8"></line>
<line x1="20" y1="20" x2="3" y2="20"></line>
<line x1="20" y1="20" x2="7" y2="32"></line>
</g>
<path d="M 24 11 A 10 10 0 0 0 24 29" fill="none" stroke="#d4b278" stroke-width="1.2" stroke-linecap="round" opacity="0.55"></path>
<circle cx="30" cy="15" r="1" fill="#d4b278" opacity="0.7"></circle>
</svg>

After

Width:  |  Height:  |  Size: 728 B

View file

@ -0,0 +1,144 @@
/* ============================================================
portfolio.css layout & motion for the Nychthemeron
portfolio template
============================================================ */
/* PrimeIcons used directly by portfolio markup (nav, social links,
chevron); not part of @nychthemeron/library's own component classes. */
@import url('https://cdn.jsdelivr.net/npm/primeicons/primeicons.css');
/* Portfolio-only tokens not covered by @nychthemeron/library's theme. */
:root {
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.18);
}
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
overflow-x: hidden;
}
body {
margin: 0;
overflow-x: hidden;
}
#app {
min-height: 100vh;
background: var(--surface-0);
color: var(--text-body);
}
.port-section {
padding: 3rem 2rem;
max-width: 1060px;
margin: 0 auto;
}
.port-hero {
min-height: 100vh;
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 6rem 2rem 8rem;
text-align: center;
}
.port-2col {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 56px;
align-items: center;
}
.port-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 20px;
}
.port-title {
font-family: var(--font-serif);
font-size: 2.2rem;
letter-spacing: 0.08em;
color: var(--text-high);
font-weight: 600;
margin: 0 0 2rem;
}
@keyframes bob {
0%,
100% {
transform: translateX(-50%) translateY(0);
}
50% {
transform: translateX(-50%) translateY(7px);
}
}
@keyframes blink {
0%,
50% {
opacity: 1;
}
50.01%,
100% {
opacity: 0;
}
}
@media (max-width: 600px) {
.port-nav-links {
display: none;
}
.port-toggle-text {
display: none;
}
.port-nav-name {
display: none;
}
}
/* Scroll reveal */
.reveal {
opacity: 0;
transform: translateY(28px);
transition:
opacity 0.7s ease,
transform 0.7s ease;
}
.reveal.visible {
opacity: 1;
transform: none;
}
/* Card hover lift + gold edge */
.port-work-card {
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.port-work-card:hover {
transform: translateY(-4px);
box-shadow:
0 14px 36px rgba(0, 0, 0, 0.32),
0 0 24px rgba(212, 178, 120, 0.08);
border-color: var(--border-hi);
}
@media (max-width: 800px) {
.port-2col {
grid-template-columns: 1fr;
}
.port-grid {
grid-template-columns: 1fr;
}
}

View file

@ -0,0 +1,12 @@
/* ============================================================
Nychthemeron Design System global entry point
------------------------------------------------------------
A manifest of @imports; never add rules here directly.
"Nychthemeron" a full cycle of day and night. The system
ships two themes: HADES (dark) and APOLLO (light). Set
<html data-theme="hades"> or "apollo"; Hades is the default.
============================================================ */
@import './tokens/laurel-divider.css';
@import './portfolio.css';

View file

@ -0,0 +1,51 @@
/* ============================================================
LaurelDivider
============================================================ */
/* Horizontal (default): full-width running laurel SVG */
.nych-divider {
display: block;
width: 100%;
color: var(--primary);
}
/* Label variant: flanking thin lines + centered text */
.nych-divider[data-p~='label'] {
display: flex;
align-items: center;
gap: 12px;
}
.nych-divider[data-p~='label']::before,
.nych-divider[data-p~='label']::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.nych-divider-label {
font-family: var(--font-serif);
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-dim);
white-space: nowrap;
}
/* Vertical variant */
.nych-divider--vertical {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: auto;
gap: 10px;
color: var(--primary);
}
.nych-divider--vertical::before,
.nych-divider--vertical::after {
content: '';
width: 1px;
flex: 1;
background: var(--border);
}

View file

@ -0,0 +1,66 @@
<script setup lang="ts">
withDefaults(
defineProps<{
src?: string
alt?: string
shape?: 'rect' | 'rounded'
}>(),
{
shape: 'rounded',
alt: '',
},
)
</script>
<template>
<div
class="nych-image-slot"
:class="[`nych-image-slot--${shape}`, { 'nych-image-slot--filled': src }]"
>
<img v-if="src" :src="src" :alt="alt" class="nych-image-slot-img" />
<div v-else class="nych-image-slot-placeholder">
<i class="pi pi-image" aria-hidden="true" />
</div>
</div>
</template>
<style scoped>
.nych-image-slot {
position: relative;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
background-color: var(--surface-2);
border: 1px dashed var(--border);
}
.nych-image-slot--rounded {
border-radius: var(--radius-xl);
}
.nych-image-slot--rect {
border-radius: 0;
}
.nych-image-slot--filled {
border-style: solid;
}
.nych-image-slot-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nych-image-slot-placeholder {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-dim);
font-size: 1.5rem;
}
</style>

View file

@ -0,0 +1,152 @@
<script setup lang="ts">
import { computed, onMounted, onBeforeUnmount, ref } from 'vue'
const props = withDefaults(
defineProps<{
vertical?: boolean
label?: string
height?: number
size?: number
}>(),
{
vertical: false,
height: 14,
size: 18,
},
)
// 16-leaf radial wreath, used as the vertical divider's center ornament
const radialLeaves = Array.from({ length: 16 }, (_, i) => {
const angle = i * 22.5
const isB = i % 2 === 1
return isB
? {
path: 'M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z',
line: { x1: 340, y1: 75, x2: 343, y2: 111 },
transform: `rotate(${angle},340,180)`,
}
: {
path: 'M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z',
line: { x1: 340, y1: 73, x2: 343, y2: 110 },
transform: `rotate(${angle},340,180)`,
}
})
// Running laurel: the loader's Type-A leaf rotated 90° CW and tiled, fitting
// only whole leaves across the container width (no clipped edge leaves).
const wrapRef = ref<HTMLElement | null>(null)
const uid = `ld${Math.random().toString(36).slice(2, 8)}`
const svgW = ref(800)
let ro: ResizeObserver | null = null
onMounted(() => {
if (!wrapRef.value) return
svgW.value = Math.floor(wrapRef.value.getBoundingClientRect().width)
if (typeof ResizeObserver === 'undefined') return
ro = new ResizeObserver(([entry]) => {
if (entry) svgW.value = Math.floor(entry.contentRect.width)
})
ro.observe(wrapRef.value)
})
onBeforeUnmount(() => {
ro?.disconnect()
})
const h = computed(() => props.height)
const k = computed(() => (h.value * 0.7) / 20)
const tileW = computed(() => Math.round(42 * k.value))
const cx = computed(() => tileW.value / 2)
const cy = computed(() => h.value * 0.55)
const tf = (x: number, y: number): [number, number] => [
cx.value + (y - 91) * k.value,
cy.value - (x - 340) * k.value,
]
const rawPts: Array<[number, number]> = [
[340, 70],
[350, 78],
[352, 96],
[343, 112],
[339, 107],
[332, 93],
[335, 76],
]
const f = (n: number) => n.toFixed(1)
const pts = computed(() => rawPts.map(([x, y]) => tf(x, y)))
const leafPath = computed(() => {
const p = pts.value
return [
`M${f(p[0]![0])} ${f(p[0]![1])}`,
`C${f(p[1]![0])} ${f(p[1]![1])} ${f(p[2]![0])} ${f(p[2]![1])} ${f(p[3]![0])} ${f(p[3]![1])}`,
`C${f(p[4]![0])} ${f(p[4]![1])} ${f(p[5]![0])} ${f(p[5]![1])} ${f(p[6]![0])} ${f(p[6]![1])}`,
'Z',
].join(' ')
})
const numTiles = computed(() => Math.max(1, Math.floor(svgW.value / tileW.value)))
const usedW = computed(() => numTiles.value * tileW.value)
const offsetX = computed(() => (svgW.value - usedW.value) / 2)
</script>
<template>
<div v-if="vertical" class="nych-divider--vertical" role="separator">
<svg
viewBox="226 66 228 228"
:width="size"
:height="size"
fill="currentColor"
stroke="currentColor"
aria-hidden="true"
style="flex-shrink: 0"
>
<g v-for="(leaf, i) in radialLeaves" :key="i" :transform="leaf.transform">
<path :d="leaf.path" />
<line
:x1="leaf.line.x1"
:y1="leaf.line.y1"
:x2="leaf.line.x2"
:y2="leaf.line.y2"
stroke-width="0.8"
stroke-linecap="round"
/>
</g>
</svg>
</div>
<div v-else class="nych-divider" role="separator" :data-p="label ? 'label' : undefined">
<span v-if="label" class="nych-divider-label">{{ label }}</span>
<div v-else ref="wrapRef" style="width: 100%; height: 100%">
<svg :width="svgW" :height="h" aria-hidden="true">
<defs>
<pattern
:id="uid"
:x="offsetX"
y="0"
:width="tileW"
:height="h"
patternUnits="userSpaceOnUse"
>
<path :d="leafPath" fill="currentColor" opacity="0.85" />
<line
:x1="f(pts[0]![0])"
:y1="f(pts[0]![1])"
:x2="f(pts[3]![0])"
:y2="f(pts[3]![1])"
stroke="currentColor"
stroke-width="0.5"
stroke-linecap="round"
opacity="0.4"
/>
</pattern>
<clipPath :id="`${uid}c`">
<rect :x="offsetX" y="0" :width="usedW" :height="h" />
</clipPath>
</defs>
<rect :width="svgW" :height="h" :fill="`url(#${uid})`" :clip-path="`url(#${uid}c)`" />
</svg>
</div>
</div>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { LoadingIcon } from '@nychthemeron/library'
withDefaults(
defineProps<{
size?: number
color?: string
spinning?: boolean
}>(),
{
color: 'var(--primary)',
size: 48,
spinning: true,
},
)
</script>
<template>
<LoadingIcon
class="laurel-spinner"
:class="{ 'laurel-spinner--static': !spinning }"
:style="{ width: `${size}px`, height: `${size}px` }"
/>
</template>
<style scoped>
.laurel-spinner--static :deep(.wreath) {
animation: none;
}
.laurel-spinner :deep(.wreath path) {
fill: v-bind(color);
}
.laurel-spinner :deep(.wreath line) {
stroke: v-bind(color);
}
</style>

View file

@ -0,0 +1,72 @@
<script setup lang="ts">
import ImageSlot from '@/components/design/ImageSlot.vue'
import type { ComponentProps } from '@/data/portfolio'
const { portfolioData } = defineProps<ComponentProps>()
</script>
<template>
<section id="about" class="port-section" style="scroll-margin-top: 68px">
<div class="port-2col reveal">
<div class="port-about-text">
<h2 class="port-title">About</h2>
<p class="port-about-bio">{{ portfolioData?.about }}</p>
<div class="port-about-stats">
<div v-for="stat in portfolioData?.stats" :key="stat.label">
<div class="port-about-stat-value" :style="{ color: stat.color }">{{ stat.value }}</div>
<div class="port-about-stat-label">{{ stat.label }}</div>
</div>
</div>
</div>
<div class="port-about-photo">
<ImageSlot
:src="portfolioData?.photo"
alt="Profile photo"
shape="rounded"
style="width: 260px; height: 320px; filter: grayscale(100%) sepia(50%)"
/>
</div>
</div>
</section>
</template>
<style scoped>
.port-about-text {
display: flex;
flex-direction: column;
gap: 20px;
}
.port-about-bio {
font-family: var(--font-sans);
line-height: 1.75;
color: var(--text-body);
margin: 0;
text-wrap: pretty;
}
.port-about-stats {
display: flex;
gap: 28px;
padding-top: 6px;
}
.port-about-stat-value {
font-family: var(--font-mono);
font-size: 1.6rem;
font-weight: 500;
}
.port-about-stat-label {
font-family: var(--font-sans);
font-size: 0.76rem;
color: var(--text-dim);
letter-spacing: 0.04em;
margin-top: 3px;
}
.port-about-photo {
display: flex;
justify-content: center;
}
</style>

View file

@ -0,0 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue'
import LaurelDivider from '@/components/design/LaurelDivider.vue'
import type { ComponentProps } from '@/data/portfolio'
const { portfolioData } = defineProps<ComponentProps>()
const socialLinks = computed(() => [
{ icon: 'github', label: 'GitHub', href: portfolioData?.social.github },
{ icon: 'linkedin', label: 'LinkedIn', href: portfolioData?.social.linkedin },
{
icon: 'envelope',
label: portfolioData?.social.email,
href: `mailto:${portfolioData?.social.email}`,
},
])
</script>
<template>
<section id="contact" class="port-section" style="scroll-margin-top: 68px">
<LaurelDivider style="margin-bottom: 3.5rem" />
<div style="gap: 64px">
<h2 class="port-title" style="margin-bottom: 0">Get in Touch</h2>
<div class="port-contact-aside">
<p class="port-contact-blurb">Find me on the platforms below or send a dispatch by post.</p>
<div class="port-contact-links">
<a
v-for="link in socialLinks"
:key="link.icon"
:href="link.href"
class="port-contact-link"
>
<i :class="`pi pi-${link.icon}`" class="port-contact-link-icon" />
<span>{{ link.label }}</span>
</a>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.port-contact-form {
display: flex;
flex-direction: column;
gap: 18px;
}
.port-contact-fields {
display: flex;
flex-direction: column;
gap: 12px;
}
.port-contact-aside {
display: flex;
flex-direction: column;
gap: 16px;
justify-content: center;
}
.port-contact-blurb {
font-family: var(--font-sans);
color: var(--text-muted);
line-height: 1.75;
margin: 0;
text-wrap: pretty;
}
.port-contact-links {
display: flex;
flex-direction: column;
gap: 12px;
}
.port-contact-link {
display: flex;
align-items: center;
gap: 14px;
color: var(--text-body);
text-decoration: none;
font-family: var(--font-sans);
font-size: 0.93rem;
padding: 12px 16px;
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
transition: border-color 0.2s ease;
}
.port-contact-link:hover {
border-color: var(--primary);
}
.port-contact-link-icon {
color: var(--primary);
font-size: 16px;
width: 20px;
text-align: center;
flex-shrink: 0;
}
</style>

View file

@ -0,0 +1,158 @@
<script setup lang="ts">
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
import { Button } from '@nychthemeron/library'
import LaurelSpinner from '@/components/design/LaurelSpinner.vue'
import LaurelDivider from '@/components/design/LaurelDivider.vue'
import { useTypewriter } from '@/composables/useTypewriter'
import { scrollToSection } from '@/composables/scrollToSection'
import type { ComponentProps } from '@/data/portfolio'
const { portfolioData } = defineProps<ComponentProps>()
let { displayed, done } = { displayed: ref(''), done: ref(false) }
const visible = ref(true)
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
const onScroll = () => {
visible.value = window.scrollY < window.innerHeight * 0.6
}
watch(
() => portfolioData,
(data) => {
if (data) {
const typeWriter = useTypewriter(data.tagline)
displayed = typeWriter.displayed
done = typeWriter.done
}
},
)
</script>
<template>
<section class="port-hero">
<div class="port-hero-watermark" aria-hidden="true">
<LaurelSpinner :size="580" :spinning="false" />
</div>
<div class="port-hero-content">
<div class="port-hero-status">{{ portfolioData?.status }}</div>
<h1 class="port-hero-name">{{ portfolioData?.name }}</h1>
<div class="port-hero-rule" />
<p class="port-hero-role">{{ portfolioData?.role }}</p>
<p class="port-hero-tagline">
{{ displayed
}}<span class="port-hero-cursor" :class="{ 'port-hero-cursor--done': done }">|</span>
</p>
<div class="port-hero-actions">
<Button @click="scrollToSection('work')">View Work</Button>
<Button variant="secondary" @click="scrollToSection('contact')">Get in Touch</Button>
</div>
<LaurelDivider />
</div>
<div class="port-hero-chevron" :class="{ 'port-hero-chevron--visible': visible }">
<i class="pi pi-chevron-down" style="font-size: 18px; color: var(--text-dim)" />
</div>
</section>
</template>
<style scoped>
.port-hero-watermark {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
pointer-events: none;
z-index: 0;
color: var(--primary);
opacity: 0.12;
}
.port-hero-content {
position: relative;
z-index: 1;
max-width: 660px;
width: 100%;
}
.port-hero-status {
font-family: var(--font-mono);
font-size: 0.68rem;
letter-spacing: 0.2em;
color: var(--primary);
margin-bottom: 2rem;
text-transform: uppercase;
}
.port-hero-name {
font-family: var(--font-serif);
font-weight: 700;
letter-spacing: 0.07em;
color: var(--text-high);
line-height: 1.05;
margin: 0 0 0.8rem;
font-size: clamp(2.5rem, 8vw, 5.5rem);
text-shadow: 0 0 80px rgba(212, 178, 120, 0.2);
}
.port-hero-rule {
height: 1px;
background: var(--primary);
max-width: 180px;
margin: 0 auto 1.4rem;
}
.port-hero-role {
font-family: var(--font-sans);
font-size: 1.05rem;
color: var(--text-muted);
letter-spacing: 0.04em;
margin: 0 0 0.9rem;
}
.port-hero-tagline {
font-family: var(--font-sans);
font-size: 1rem;
color: var(--text-dim);
max-width: 460px;
margin: 0 auto 2.8rem;
line-height: 1.7;
text-wrap: pretty;
min-height: 1.7rem;
}
.port-hero-cursor {
color: var(--primary);
animation: blink 0.75s step-end infinite;
}
.port-hero-cursor--done {
opacity: 0;
animation: none;
}
.port-hero-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 4rem;
}
.port-hero-chevron {
position: absolute;
bottom: 2rem;
left: 50%;
opacity: 0;
animation: bob 2.5s ease-in-out infinite;
transition: opacity 0.5s ease-in-out;
}
.port-hero-chevron--visible {
opacity: 0.4 !important;
}
</style>

View file

@ -0,0 +1,76 @@
<script setup lang="ts">
import { Badge } from '@nychthemeron/library'
import LaurelDivider from '@/components/design/LaurelDivider.vue'
import type { ComponentProps } from '@/data/portfolio'
const palettes = [
{ key: 'Frontend', accent: 'var(--info)', variant: 'info' as const },
{ key: 'Backend', accent: 'var(--success)', variant: 'success' as const },
{ key: 'Tools', accent: 'var(--warn)', variant: 'warning' as const },
]
const { portfolioData } = defineProps<ComponentProps>()
</script>
<template>
<section id="skills" class="port-section" style="scroll-margin-top: 48px">
<LaurelDivider style="margin-bottom: 3.5rem" />
<h2 class="port-title">Skills</h2>
<div class="port-skills-grid reveal">
<div v-for="palette in palettes" :key="palette.key" class="port-skills-card">
<div class="port-skills-accent" :style="{ background: palette.accent }" />
<div class="port-skills-body">
<div class="port-skills-label" :style="{ color: palette.accent }">{{ palette.key }}</div>
<div class="port-skills-tags">
<Badge
v-for="tag in portfolioData?.skills[palette.key] ?? []"
:key="tag"
:variant="palette.variant"
>
{{ tag }}
</Badge>
</div>
</div>
</div>
</div>
</section>
</template>
<style scoped>
.port-skills-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 16px;
}
.port-skills-card {
background: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: var(--shadow-card);
}
.port-skills-accent {
height: 3px;
}
.port-skills-body {
padding: 16px 20px;
}
.port-skills-label {
font-family: var(--font-serif);
font-size: 0.7rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
margin-bottom: 12px;
}
.port-skills-tags {
display: flex;
flex-wrap: wrap;
gap: 7px;
}
</style>

View file

@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import laurelMark from '@/assets/brand/mark.svg'
import type { ComponentProps } from '@/data/portfolio'
const year = new Date().getFullYear()
const { portfolioData } = defineProps<ComponentProps>()
const name = computed(() => portfolioData?.name.toUpperCase())
</script>
<template>
<footer class="port-footer">
<img :src="laurelMark" alt="" width="22" height="22" style="opacity: 0.45" />
<div class="port-footer-meta">
<span>{{ name }}</span>
<span>{{ year }}</span>
<span>NYCHTHEMERON</span>
</div>
</footer>
</template>
<style scoped>
.port-footer {
border-top: 1px solid var(--border-lo);
padding: 2.5rem 1.5rem;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
text-align: center;
}
.port-footer-meta {
font-family: var(--font-mono);
font-size: 0.64rem;
color: var(--text-dim);
letter-spacing: 0.1em;
display: flex;
flex-wrap: wrap;
gap: 6px 18px;
justify-content: center;
align-items: center;
}
</style>

View file

@ -0,0 +1,131 @@
<script setup lang="ts">
import { computed } from 'vue'
import { Button } from '@nychthemeron/library'
import type { Theme } from '@/composables/useTheme'
import { scrollToSection } from '@/composables/scrollToSection'
import laurelMark from '@/assets/brand/mark.svg'
import type { ComponentProps } from '@/data/portfolio'
const links: Array<[string, string]> = [
['About', 'about'],
['Work', 'work'],
['Contact', 'contact'],
]
const props = defineProps<ComponentProps & { theme: Theme }>()
defineEmits<{ toggle: [] }>()
const night = computed(() => props.theme === 'hades')
const surname = computed(() => props.portfolioData?.name.toUpperCase())
</script>
<template>
<nav class="port-nav port-nav--visible">
<div class="port-nav-brand">
<img :src="laurelMark" alt="" width="28" height="28" />
<span class="port-nav-name">{{ surname }}</span>
</div>
<div class="port-nav-right">
<span class="port-nav-links">
<a
v-for="[label, id] in links"
:key="id"
:href="`#${id}`"
class="port-nav-link"
@click.prevent="scrollToSection(id)"
>{{ label }}</a
>
</span>
<Button class="port-nav-toggle" @click="$emit('toggle')">
<i :class="`pi pi-${night ? 'moon' : 'sun'}`" style="font-size: 12px" />
<span class="port-toggle-text">{{ night ? 'Hades' : 'Apollo' }}</span>
</Button>
</div>
</nav>
</template>
<style scoped>
.port-nav {
position: fixed;
top: 0;
left: 0;
right: 0;
z-index: 100;
height: 64px;
background: color-mix(in srgb, var(--surface-1) 86%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border-bottom: 1px solid transparent;
opacity: 0;
pointer-events: none;
transition:
opacity 0.4s ease,
border-color 0.3s ease;
padding: 0 2rem;
display: flex;
align-items: center;
gap: 16px;
width: 100vw;
}
.port-nav--visible {
opacity: 1;
pointer-events: auto;
border-bottom-color: var(--border-lo);
}
.port-nav-brand {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.port-nav-name {
font-family: var(--font-serif);
letter-spacing: 0.1em;
color: var(--text-high);
font-weight: 600;
font-size: 0.86rem;
}
.port-nav-right {
margin-left: auto;
display: flex;
gap: 22px;
align-items: center;
}
.port-nav-links {
display: flex;
gap: 22px;
align-items: center;
}
.port-nav-link {
font-family: var(--font-sans);
font-size: 0.86rem;
color: var(--text-muted);
text-decoration: none;
cursor: pointer;
letter-spacing: 0.03em;
}
.port-nav-toggle {
display: flex;
align-items: center;
gap: 6px;
padding: 5px 13px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 30px;
cursor: pointer;
color: var(--text-body);
font-family: var(--font-sans);
font-size: 0.78rem;
}
</style>

View file

@ -0,0 +1,98 @@
<script setup lang="ts">
import { Badge, Card, CardHeader, CardTitle, CardContent, CardFooter } from '@nychthemeron/library'
import LaurelDivider from '@/components/design/LaurelDivider.vue'
import ImageSlot from '@/components/design/ImageSlot.vue'
import type { ComponentProps, Project } from '@/data/portfolio'
const ACCENT: Record<string, string> = {
primary: 'var(--primary)',
info: 'var(--info)',
success: 'var(--success)',
warn: 'var(--warn)',
}
const badgeVariant = (color: Project['color']) => (color === 'warn' ? 'warning' : color)
const { portfolioData } = defineProps<ComponentProps>()
</script>
<template>
<section id="work" class="port-section" style="scroll-margin-top: 68px">
<LaurelDivider style="margin-bottom: 3.5rem" />
<h2 class="port-title">Selected Work</h2>
<div class="port-grid reveal">
<Card
v-for="project in portfolioData?.projects"
:key="project.id"
class="port-work-card"
:style="{ borderTop: `3px solid ${ACCENT[project.color]}` }"
>
<CardHeader v-if="project.image" class="port-work-image">
<ImageSlot
:src="project.image"
:alt="project.title"
shape="rect"
style="width: 100%; height: 200px"
/>
</CardHeader>
<CardContent class="port-work-content">
<CardTitle class="port-work-title-row">
<span :style="{ color: ACCENT[project.color] }">{{ project.title }}</span>
<a
:href="project.link"
class="port-work-view"
:style="{ color: ACCENT[project.color] }"
>
View <i class="pi pi-external-link" style="font-size: 10px" />
</a>
</CardTitle>
{{ project.description }}
</CardContent>
<CardFooter>
<Badge v-for="tag in project.tags" :key="tag" :variant="badgeVariant(project.color)">{{
tag
}}</Badge>
</CardFooter>
</Card>
</div>
</section>
</template>
<style scoped>
.port-work-image {
padding: 0;
}
.port-work-card {
height: 100%;
}
.port-work-card :deep([data-slot='card-header']) {
padding: 0;
}
.port-work-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-bottom: 5px;
}
.port-work-content {
flex-grow: 1;
}
.port-work-view {
font-family: var(--font-sans);
font-size: 0.78rem;
text-decoration: none;
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
font-weight: 400;
letter-spacing: 0.02em;
}
</style>

View file

@ -0,0 +1,6 @@
export const scrollToSection = (id: string) => {
const el = document.getElementById(id)
if (!el) return
const top = el.getBoundingClientRect().top + window.scrollY - 68
window.scrollTo({ top, behavior: 'smooth' })
}

28
src/composables/useAPI.ts Normal file
View file

@ -0,0 +1,28 @@
import { useUtils } from './useUtils'
import type { PortfolioData } from '@/data/portfolio'
export const useAPI = (baseURL = API_URL, key = API_KEY) => {
const { url } = useUtils()
const normalizedURL = url.normalizePath(baseURL.trim())
const generateURL = (path: string) => {
return normalizedURL + path
}
const getPortfolioData = async () =>
fetch(generateURL('/keyvalue?key=portfolio'), {
headers: {
authorization: `Bearer ` + key.trim(),
},
})
.then((resp) => resp.json())
.then((data: { key: string; value: string }[]) =>
data.length > 0 && data[0] ? (JSON.parse(data[0].value) as PortfolioData) : undefined,
)
.catch(() => undefined)
return {
getPortfolioData,
}
}

View file

@ -0,0 +1,25 @@
import { onMounted, onBeforeUnmount } from 'vue'
/**
* Adds the `visible` class to any `.reveal` element once it scrolls into view.
*/
export const useReveal = () => {
let observer: IntersectionObserver | null = null
onMounted(() => {
if (typeof IntersectionObserver === 'undefined') return
observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) entry.target.classList.add('visible')
}
},
{ threshold: 0.12 },
)
document.querySelectorAll('.reveal').forEach((el) => observer?.observe(el))
})
onBeforeUnmount(() => {
observer?.disconnect()
})
}

View file

@ -0,0 +1,32 @@
import { ref, watch } from 'vue'
export type Theme = 'hades' | 'apollo'
const STORAGE_KEY = 'nych-theme'
const theme = ref<Theme>('hades')
const applyTheme = (value: Theme) => {
document.documentElement.dataset.theme = value
}
let initialized = false
export const useTheme = () => {
if (!initialized) {
initialized = true
const stored = localStorage.getItem(STORAGE_KEY)
if (stored === 'hades' || stored === 'apollo') theme.value = stored
applyTheme(theme.value)
watch(theme, (value) => {
applyTheme(value)
localStorage.setItem(STORAGE_KEY, value)
})
}
const toggle = () => {
theme.value = theme.value === 'hades' ? 'apollo' : 'hades'
}
return { theme, toggle }
}

View file

@ -0,0 +1,28 @@
import { computed, onBeforeUnmount, ref } from 'vue'
/**
* Types `text` out character by character, exposing the in-progress
* substring and whether typing has finished (for the blinking cursor).
*/
export const useTypewriter = (text: string, delay = 28) => {
const count = ref(0)
const done = computed(() => count.value >= text.length)
const displayed = computed(() => text.slice(0, count.value))
let timer: ReturnType<typeof setTimeout> | null = null
const tick = () => {
if (count.value >= text.length) return
timer = setTimeout(() => {
count.value += 1
tick()
}, delay)
}
tick()
onBeforeUnmount(() => {
if (timer) clearTimeout(timer)
})
return { displayed, done }
}

View file

@ -0,0 +1,11 @@
export const useUtils = () => {
const normalizePath = (path: string) => {
return path.endsWith('/') ? path.slice(0, -1) : path
}
return {
url: {
normalizePath,
},
}
}

36
src/data/portfolio.ts Normal file
View file

@ -0,0 +1,36 @@
export interface Stat {
value: string
label: string
color: string
}
export interface Project {
id: string
title: string
color: 'primary' | 'info' | 'success' | 'warn'
description: string
tags: string[]
link: string
image?: string
}
export interface PortfolioData {
name: string
role: string
tagline: string
status: string
about: string
photo?: string
stats: Stat[]
projects: Project[]
skills: Record<string, string[]>
social: {
github: string
linkedin: string
email: string
}
}
export interface ComponentProps {
portfolioData?: PortfolioData
}

9
src/main.ts Normal file
View file

@ -0,0 +1,9 @@
import { createApp } from 'vue'
import { createHead } from '@unhead/vue/client'
import App from './App.vue'
import '@nychthemeron/library/style'
import './assets/styles/styles.css'
const app = createApp(App)
app.use(createHead())
app.mount('#app')

23
tsconfig.app.json Normal file
View file

@ -0,0 +1,23 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": [
"env.d.ts",
"global.d.ts",
"src/**/*",
"src/**/*.vue"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

28
tsconfig.node.json Normal file
View file

@ -0,0 +1,28 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

21
tsconfig.vitest.json Normal file
View file

@ -0,0 +1,21 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// .vue files must be explicitly included so vue-tsc enables .vue module
// resolution for this project (relying on transitive module resolution
// from test imports is unreliable in `vue-tsc --build`).
"include": ["src/**/__tests__/*", "env.d.ts", "global.d.ts", "src/**/*.vue"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": ["dom", "es2015", "esnext"],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}

41
vite.config.ts Normal file
View file

@ -0,0 +1,41 @@
import { fileURLToPath, URL } from "node:url";
import { defineConfig, loadEnv } from "vite";
import vue from "@vitejs/plugin-vue";
import vueDevTools from "vite-plugin-vue-devtools";
import tailwindcss from "@tailwindcss/vite";
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd(), "VITE");
const baseURL = fileURLToPath(new URL("./src", import.meta.url));
return {
define: {
API_URL: JSON.stringify(env.VITE_API_URL ?? ""),
API_KEY: JSON.stringify(env.VITE_API_KEY ?? ""),
},
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: [
{ find: "@", replacement: baseURL },
{ find: "~", replacement: baseURL },
// @nychthemeron/library's package.json declares a "development"
// export condition pointing at "./src/index.ts", but that file
// isn't included in the published package (only src/assets and
// src/components are) — Vite's dev server picks that condition and
// fails to resolve the bare import. Force just the bare specifier
// (not the /style or /components subpaths, which resolve fine) to
// the same pre-built dist bundle the "import" condition already
// uses for production builds. Anchored regex so it doesn't also
// match (and break) those subpaths via prefix matching.
{
find: /^@nychthemeron\/library$/,
replacement: fileURLToPath(
new URL("./node_modules/@nychthemeron/library/dist/index.js", import.meta.url),
),
},
],
},
};
});

16
vitest.config.ts Normal file
View file

@ -0,0 +1,16 @@
import { fileURLToPath } from "node:url";
import { mergeConfig, defineConfig, configDefaults } from "vitest/config";
import viteConfig from "./vite.config";
export default defineConfig((configEnv) =>
mergeConfig(
viteConfig(configEnv),
defineConfig({
test: {
environment: "jsdom",
exclude: [...configDefaults.exclude, "e2e/**"],
root: fileURLToPath(new URL("./", import.meta.url)),
},
}),
),
);