Compare commits

...

2 commits

Author SHA1 Message Date
55e585186e
feat: docker
Some checks failed
Build and publish Docker image / build (pull_request) Has been cancelled
Build and publish Docker image / docker (pull_request) Has been cancelled
2026-06-10 17:03:17 -04:00
015e608f7c
[claudesquad] update from 'portfolio' on 10 Jun 26 16:31 EDT (paused) 2026-06-10 16:31:50 -04:00
42 changed files with 9459 additions and 8 deletions

8
.dockerignore Normal file
View file

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

View file

@ -0,0 +1,67 @@
name: Build and publish Docker image
on:
push:
tags:
- 'v*'
pull_request:
jobs:
build:
runs-on: docker
container: oven/bun:1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Audit dependencies
run: bun audit
- name: Run unit tests
run: bun run test:unit run
- name: Build
run: bun run build
- name: Upload dist
uses: actions/upload-artifact@v4
with:
name: dist
path: dist
docker:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download dist
uses: actions/download-artifact@v4
with:
name: dist
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

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

View file

@ -71,3 +71,28 @@ bun test:e2e --debug
```sh
bun lint
```
## 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/docker-publish.yml`) builds the
image and pushes it to the Forgejo container registry whenever a `v*` tag is
pushed (e.g. `v1.2.3`):
- `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.

View file

@ -4,7 +4,7 @@
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title>
<title>Alexander Voss · Portfolio</title>
</head>
<body>
<div id="app"></div>

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;
}
}

6652
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,11 +1,28 @@
<script setup lang="ts"></script>
<script setup lang="ts">
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 { useReveal } from '@/composables/useReveal'
const { theme, toggle } = useTheme()
useReveal()
</script>
<template>
<h1>You did it!</h1>
<p>
Visit <a href="https://vuejs.org/" target="_blank" rel="noopener">vuejs.org</a> to read the
documentation
</p>
<TheNav :theme="theme" @toggle="toggle" />
<main>
<HeroSection />
<AboutSection />
<WorkSection />
<SkillsSection />
<ContactSection />
</main>
<TheFooter />
</template>
<style scoped></style>

View file

@ -6,6 +6,6 @@ import App from '../App.vue'
describe('App', () => {
it('mounts renders properly', () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('You did it!')
expect(wrapper.text()).toContain('Alexander Voss')
})
})

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,129 @@
/* ============================================================
portfolio.css layout & motion for the Nychthemeron
portfolio template
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
html {
scroll-behavior: smooth;
}
body {
margin: 0;
}
#app {
min-height: 100vh;
background: var(--surface-0);
color: var(--text-body);
}
.port-section {
padding: 5rem 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;
}
}
/* 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 */
.nych-card {
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.nych-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,17 @@
/* ============================================================
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/fonts.css';
@import './tokens/colors.css';
@import './tokens/typography.css';
@import './tokens/spacing.css';
@import './tokens/base.css';
@import './tokens/components.css';
@import './portfolio.css';

View file

@ -0,0 +1,59 @@
/* ============================================================
base.css reset + global defaults
============================================================ */
*,
*::before,
*::after {
box-sizing: border-box;
}
[class^='nych-'],
[class^='nych-']::before,
[class^='nych-']::after {
margin: 0;
padding: 0;
}
html {
font-family: var(--font-sans);
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
background-color: var(--surface-0);
color: var(--text-body);
min-height: 100vh;
}
/* nych- components default to the inscriptional serif unless overridden */
[class^='nych-'] {
font-family: var(--font-serif);
}
[class^='nych-']:disabled {
cursor: not-allowed;
opacity: 0.5;
}
[class^='nych-']:focus-visible {
outline: 2px solid transparent; /* visible in forced-colors mode */
box-shadow: var(--focus-ring);
}
/* The laurel wreath spins as a loading mark */
@keyframes nych-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.nych-loading-icon .wreath {
/* fill-box + center keeps origin correct regardless of rendered size */
transform-box: fill-box;
transform-origin: center;
animation: nych-spin 2s linear infinite;
}

View file

@ -0,0 +1,124 @@
/* ============================================================
colors.css Nychthemeron color system
------------------------------------------------------------
Nychthemeron ("a full day-and-night") ships two themes that
mirror the Greek cosmos: HADES (the dark underworld) and
APOLLO (the bright upper world of the sun).
Toggle by setting data-theme on <html>:
<html data-theme="hades"> default
<html data-theme="apollo">
Tokens are organised in tiers:
· surfaces / borders structural neutrals
· primary the gold of the gods
· semantic mythologically-named status colors
· text foreground ramp
============================================================ */
/* ── DEFAULT (= HADES) ───────────────────────────────────── */
:root,
[data-theme='hades'] {
/* surfaces — the descending dark of Erebus */
--surface-0: #18160f;
--surface-1: #28241a;
--surface-2: #2e2a1e;
--surface-3: #322e22;
--track: #32302a;
/* borders */
--border: #4a4538;
--border-hi: #5a5040;
--border-lo: #38342a;
/* primary — temple gold */
--primary: #d4b278;
--primary-fg: #0e0c08;
/* semantic — the five rivers & omens of the underworld */
--warn: #e08840; /* Phlegethon — the river of fire */
--warn-subtle: #281808;
--warn-border: #4a2c10;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* Plague — the wrath of the gods */
--danger-subtle: #200808;
--danger-border: #3a1010;
--danger-fg: #fff8e8;
--info: #88aec8; /* Cocytus — the river of lamentation */
--info-subtle: #101e2c;
--info-border: #1e3850;
--info-fg: #0a1828;
--neutral: #7a6e60; /* Acheron — the river of woe */
--neutral-fg: #fff8e8;
--success: #6aaa7a; /* Laurel — the victor's wreath */
--success-subtle: #0e2214;
--success-border: #1e4228;
--success-fg: #fff8e8;
/* text — moonlight on bronze */
--text-high: #f4f0e8;
--text-body: #d0c8b8;
--text-muted: #908878;
--text-dim: #807868;
--text-label: #c0b8a8;
/* focus ring */
--focus-ring: 0 0 0 3px rgba(212, 178, 120, 0.65);
}
/* ── APOLLO ──────────────────────────────────────────────── */
[data-theme='apollo'] {
/* surfaces — sun-bleached marble */
--surface-0: #faf7f2;
--surface-1: #f4f0e8;
--surface-2: #eee9df;
--surface-3: #e4ddd0;
--track: #e4ddd0;
/* borders */
--border: #bab2a0;
--border-hi: #c0b8a8;
--border-lo: #ccc4b4;
/* primary — burnished gold leaf */
--primary: #b8860b;
--primary-fg: #fff8e8;
/* semantic */
--warn: #9a6020; /* Harmony — tempered amber */
--warn-subtle: #fdf3e0;
--warn-border: #e8d0a0;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* Plague */
--danger-subtle: #faeaea;
--danger-border: #c8a0a0;
--danger-fg: #fff8e8;
--info: #3a5a8a; /* Oracle — Delphic blue */
--info-subtle: #e8eef8;
--info-border: #b0c4e0;
--info-fg: #fff8e8;
--neutral: #8a8070; /* Marble */
--neutral-fg: #fff8e8;
--success: #5a7a40; /* Laurel */
--success-subtle: #eef4e8;
--success-border: #c0d4a8;
--success-fg: #fff8e8;
/* text — ink on parchment */
--text-high: #1a1610;
--text-body: #3a3020;
--text-muted: #5a5040;
--text-dim: #8a8070;
--text-label: #5a5040;
/* focus ring */
--focus-ring: 0 0 0 3px rgba(184, 134, 11, 0.65);
}

View file

@ -0,0 +1,787 @@
/* ============================================================
components.css Nychthemeron component skins
Styles every `nych-*` primitive. Components render the markup;
this file gives them their bronze-and-marble skin.
============================================================ */
/* ============================================================
Button the signature glowing control
============================================================ */
[class^='nych-button'] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-md);
box-shadow: var(--glow-md);
border: none;
cursor: pointer;
transition: box-shadow 0.3s ease;
}
[class^='nych-button'] > span {
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1;
}
/* Vertically center text inside buttons covers nested text wrappers
(e.g. the direct-edit .__om-t span) that also receive height:25px */
[class^='nych-button'] > span span {
display: inline-flex !important;
align-items: center !important;
}
/* Ensure PrimeIcon <i> elements sit at the correct baseline in buttons */
[class^='nych-button'] i {
line-height: 1;
flex-shrink: 0;
display: inline-flex;
align-items: center;
}
[class^='nych-button']:hover:not(:disabled) {
box-shadow: var(--glow-sm);
}
[class^='nych-button'][data-p~='loading'] {
display: inline-flex;
}
[class^='nych-button'][data-p~='loading'] span {
margin-top: 0;
}
/* severity skins — apply to any nych- element with matching suffix */
[class^='nych-'][class$='-primary'] {
background-color: var(--primary);
color: var(--primary-fg);
}
[class^='nych-'][class$='-secondary'],
[class^='nych-'][class$='-muted'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
[class^='nych-'][class$='-info'] {
background-color: var(--info);
color: var(--info-fg);
}
[class^='nych-'][class$='-success'] {
background-color: var(--success);
color: var(--success-fg);
}
[class^='nych-'][class$='-warning'] {
background-color: var(--warn);
color: var(--warn-fg);
}
[class^='nych-'][class$='-danger'],
[class^='nych-'][class$='-error'] {
background-color: var(--danger);
color: var(--danger-fg);
}
button[data-p~='loading'] > .nych-loading-icon {
margin-right: 6px;
width: 20px;
height: 20px;
}
/* size scale */
[class^='nych-'][data-p~='small'] :not(svg, svg *) {
height: 15px !important;
font-size: 8pt !important;
}
[class^='nych-'][data-p~='normal'] :not(svg, svg *) {
height: 25px !important;
font-size: 12pt !important;
}
[class^='nych-'][data-p~='large'] :not(svg, svg *) {
height: 40px !important;
font-size: 20pt !important;
}
/* ============================================================
Text inputs InputText, Textarea
============================================================ */
.nych-input,
.nych-textarea {
font-family: var(--font-sans);
width: 100%;
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-input::placeholder,
.nych-textarea::placeholder {
color: var(--text-dim);
}
.nych-input:hover:not(:disabled),
.nych-textarea:hover:not(:disabled) {
border-color: var(--border-hi);
}
.nych-input:focus,
.nych-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-input[data-p~='invalid'],
.nych-textarea[data-p~='invalid'] {
border-color: var(--danger);
}
.nych-input[data-p~='small'] {
padding: 5px 8px;
font-size: 0.875rem;
}
.nych-input[data-p~='large'] {
padding: 11px 13px;
font-size: 1.125rem;
}
.nych-textarea {
resize: vertical;
min-height: 84px;
line-height: 1.5;
}
/* ============================================================
Checkbox + Radio
============================================================ */
.nych-checkbox,
.nych-radio {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
vertical-align: middle;
}
.nych-checkbox-input,
.nych-radio-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-checkbox-box,
.nych-radio-box {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: var(--surface-1);
border: 1px solid var(--border);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-checkbox-box {
border-radius: var(--radius-sm);
}
.nych-radio-box {
border-radius: 50%;
}
.nych-checkbox:hover .nych-checkbox-box,
.nych-radio:hover .nych-radio-box {
border-color: var(--border-hi);
}
.nych-checkbox[data-p-checked='true'] .nych-checkbox-box {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-checkbox-icon {
width: 13px;
height: 13px;
color: var(--primary-fg);
fill: var(--primary-fg);
stroke: var(--primary-fg);
}
.nych-checkbox[data-p-disabled='true'] .nych-checkbox-box,
.nych-radio[data-p-disabled='true'] .nych-radio-box {
opacity: 0.5;
}
.nych-checkbox-group,
.nych-radio-group {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.nych-checkbox-input:focus-visible + .nych-checkbox-box,
.nych-radio-input:focus-visible + .nych-radio-box {
box-shadow: var(--focus-ring);
}
.nych-checkbox .nych-checkbox-input,
.nych-radio .nych-radio-input,
.nych-toggleswitch .nych-toggleswitch-input {
opacity: 0;
}
.nych-radio-icon {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: transparent;
transition: background-color 0.2s ease;
}
.nych-radio[data-p-checked='true'] .nych-radio-box {
border-color: var(--primary);
}
.nych-radio[data-p-checked='true'] .nych-radio-icon {
background-color: var(--primary);
}
/* ============================================================
Toggle switch
============================================================ */
.nych-toggleswitch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
}
.nych-toggleswitch-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-toggleswitch-slider {
position: absolute;
inset: 0;
background-color: var(--track);
border: 1px solid var(--border);
border-radius: var(--radius-full);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-toggleswitch-handle {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
transform: translateY(-50%);
background-color: var(--text-muted);
border-radius: 50%;
transition:
left 0.2s ease,
background-color 0.2s ease;
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-slider {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-handle {
left: 25px;
background-color: var(--primary-fg);
}
.nych-toggleswitch-input:focus-visible + .nych-toggleswitch-slider {
box-shadow: var(--focus-ring);
}
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-slider,
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-handle {
opacity: 0.5;
}
/* ============================================================
Select
============================================================ */
.nych-select {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 13rem;
font-family: var(--font-sans);
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-select:hover {
border-color: var(--border-hi);
}
.nych-select[data-p~='focus'] {
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-select[data-p~='disabled'] {
cursor: not-allowed;
opacity: 0.5;
}
.nych-select-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nych-select-label[data-p~='placeholder'] {
color: var(--text-dim);
}
.nych-select-dropdown {
display: flex;
align-items: center;
color: var(--text-muted);
}
.nych-select-dropdownIcon {
width: 14px;
height: 14px;
}
.nych-select-overlay {
font-family: var(--font-sans);
margin-top: 4px;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nych-select-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 14rem;
overflow-y: auto;
}
.nych-select-option {
padding: 8px 11px;
border-radius: var(--radius-sm);
color: var(--text-body);
cursor: pointer;
transition: background-color 0.15s ease;
}
.nych-select-option[data-p-focused='true'],
.nych-select-option:hover {
background-color: var(--surface-3);
}
.nych-select-option[data-p-selected='true'] {
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-select-emptyMessage {
padding: 8px 11px;
color: var(--text-dim);
}
/* ============================================================
Card
============================================================ */
.nych-card {
display: flex;
flex-direction: column;
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
overflow: hidden;
color: var(--text-body);
}
.nych-card-header :where(img) {
display: block;
width: 100%;
}
.nych-card-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.nych-card-caption {
display: flex;
flex-direction: column;
gap: 4px;
}
.nych-card-title {
font-family: var(--font-serif);
font-size: 1.4rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-card-subtitle {
font-family: var(--font-sans);
font-size: 0.95rem;
color: var(--text-muted);
}
.nych-card-content {
font-family: var(--font-sans);
line-height: 1.6;
}
.nych-card-footer {
display: flex;
gap: 8px;
padding-top: 4px;
}
/* ============================================================
Message
============================================================ */
.nych-message {
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius-lg);
}
.nych-message-content {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
}
.nych-message-icon {
display: flex;
flex-shrink: 0;
width: 18px;
height: 18px;
}
.nych-message-text {
font-family: var(--font-sans);
line-height: 1.4;
}
.nych-message-closeButton {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
}
.nych-message-closeButton:hover {
opacity: 1;
background-color: rgba(128, 128, 128, 0.18);
}
.nych-message-closeIcon {
width: 12px;
height: 12px;
}
.nych-message[data-p~='info'] {
background-color: color-mix(in srgb, var(--info) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--info) 30%, var(--border));
border-left-color: var(--info);
}
.nych-message[data-p~='info'] .nych-message-icon {
color: var(--info);
}
.nych-message[data-p~='success'] {
background-color: color-mix(in srgb, var(--success) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
border-left-color: var(--success);
}
.nych-message[data-p~='success'] .nych-message-icon {
color: var(--success);
}
.nych-message[data-p~='warn'] {
background-color: color-mix(in srgb, var(--warn) 16%, var(--surface-1));
border-color: color-mix(in srgb, var(--warn) 32%, var(--border));
border-left-color: var(--warn);
}
.nych-message[data-p~='warn'] .nych-message-icon {
color: var(--warn);
}
.nych-message[data-p~='error'] {
background-color: color-mix(in srgb, var(--danger) 20%, var(--surface-1));
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
border-left-color: var(--danger);
}
.nych-message[data-p~='error'] .nych-message-icon {
color: var(--danger);
}
.nych-message[data-p~='secondary'] {
background-color: var(--surface-2);
border-left-color: var(--neutral);
}
/* ============================================================
Tag
============================================================ */
.nych-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1;
border-radius: var(--radius-sm);
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-tag[data-p~='rounded'] {
border-radius: var(--radius-full);
}
.nych-tag-icon {
width: 12px;
height: 12px;
}
.nych-tag[data-p~='secondary'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
.nych-tag[data-p~='info'] {
background-color: var(--info);
color: var(--info-fg);
}
.nych-tag[data-p~='success'] {
background-color: var(--success);
color: var(--success-fg);
}
.nych-tag[data-p~='warn'] {
background-color: var(--warn);
color: var(--warn-fg);
}
.nych-tag[data-p~='danger'] {
background-color: var(--danger);
color: var(--danger-fg);
}
.nych-tag[data-p~='contrast'] {
background-color: var(--text-high);
color: var(--surface-0);
}
/* ============================================================
Dialog
============================================================ */
.nych-dialog-mask {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.55);
}
.nych-dialog {
display: flex;
flex-direction: column;
max-width: 92vw;
max-height: 90vh;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-dialog);
overflow: hidden;
}
.nych-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-lo);
}
.nych-dialog-title {
font-family: var(--font-serif);
font-size: 1.3rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-dialog-headerActions {
display: flex;
align-items: center;
gap: 4px;
}
.nych-dialog-headerActions button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.nych-dialog-headerActions button:hover {
color: var(--text-high);
background-color: var(--surface-3);
}
.nych-dialog-headerActions button svg {
width: 14px;
height: 14px;
}
.nych-dialog-content {
font-family: var(--font-sans);
padding: 20px;
line-height: 1.6;
overflow-y: auto;
}
.nych-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-lo);
}
/* ============================================================
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,12 @@
/* ============================================================
fonts.css Nychthemeron webfonts
Loaded from Google Fonts CDN. If you need self-hosted binaries,
drop the .woff2 files in assets/fonts/ and replace this @import
with @font-face rules pointing at them.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
/* PrimeIcons the standard icon set for Nychthemeron.
Usage: <i class="pi pi-{name}"></i> (or <i className="pi pi-{name}" /> in JSX)
Full catalogue: https://primereact.org/icons/ */
@import url('https://cdn.jsdelivr.net/npm/primeicons/primeicons.css');

View file

@ -0,0 +1,41 @@
/* ============================================================
spacing.css Nychthemeron spacing, radii, shadows
------------------------------------------------------------
Spacing follows the observed component rhythm (7/8/11/16/20px)
normalised onto a 4px grid. Radii are gentle. Shadows are the
signature of the system: components GLOW rather than cast hard
drop-shadows light bleeding from bronze.
============================================================ */
:root {
/* spacing scale — 4px grid */
--space-0: 0;
--space-1: 0.25rem; /* 3.5px */
--space-2: 0.5rem; /* 7px — button padding */
--space-3: 0.75rem; /* 10.5px */
--space-4: 1rem; /* 14px */
--space-5: 1.25rem; /* 17.5px */
--space-6: 1.5rem; /* 21px — card padding */
--space-8: 2rem; /* 28px */
--space-10: 2.5rem; /* 35px */
--space-12: 3rem; /* 42px */
--space-16: 4rem; /* 56px */
/* corner radii */
--radius-sm: 4px; /* tags, checkboxes */
--radius-md: 5px; /* buttons, inputs */
--radius-lg: 6px; /* selects, messages */
--radius-xl: 8px; /* cards */
--radius-2xl: 10px; /* dialogs */
--radius-full: 999px;
/* glow shadows the bronze light. Driven by --text-high so they
invert correctly between Hades (warm white glow) and Apollo. */
--glow-sm: 0 0 8px var(--text-high);
--glow-md: 0 0 10px var(--text-high);
--glow-primary: 0 0 14px color-mix(in srgb, var(--primary) 70%, transparent);
/* soft elevation — used for cards, overlays, dialogs */
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.18);
--shadow-overlay: 0 8px 24px rgba(0, 0, 0, 0.35);
--shadow-dialog: 0 16px 48px rgba(0, 0, 0, 0.5);
}

View file

@ -0,0 +1,45 @@
/* ============================================================
typography.css Nychthemeron type system
------------------------------------------------------------
Three voices:
· Cinzel inscriptional Roman capitals, the voice of
the gods. Headings, titles, the brand.
· IBM Plex Sans the mortal voice. Body, UI, forms.
· IBM Plex Mono the scribe. Code, data, tabular figures.
============================================================ */
:root {
/* families */
--font-serif: 'Cinzel', Georgia, 'Times New Roman', serif;
--font-sans: 'IBM Plex Sans', system-ui, -apple-system, sans-serif;
--font-mono: 'IBM Plex Mono', 'SF Mono', Menlo, monospace;
/* weights */
--weight-regular: 400;
--weight-medium: 500;
--weight-semibold: 600;
--weight-bold: 700;
/* type scale — a measured climb, base 14px */
--text-xs: 0.75rem; /* 10.5px — labels, captions */
--text-sm: 0.875rem; /* 12.25px — secondary UI */
--text-base: 1rem; /* 14px — body */
--text-lg: 1.125rem; /* 15.75px — lead body */
--text-xl: 1.4rem; /* 19.6px — card titles */
--text-2xl: 1.75rem; /* 24.5px — section heads */
--text-3xl: 2.5rem; /* 35px — page titles */
--text-4xl: 3.5rem; /* 49px — display */
--text-5xl: 5rem; /* 70px — hero display */
/* line-heights */
--leading-tight: 1.15;
--leading-snug: 1.35;
--leading-normal: 1.5;
--leading-relaxed: 1.65;
/* letter-spacing — Cinzel breathes wide */
--tracking-tight: -0.01em;
--tracking-normal: 0;
--tracking-wide: 0.02em;
--tracking-wider: 0.08em;
--tracking-widest: 0.18em; /* small-caps eyebrows */
}

View file

@ -0,0 +1,166 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
const props = withDefaults(
defineProps<{
id: string
shape?: 'rect' | 'rounded'
placeholder?: string
}>(),
{
shape: 'rounded',
placeholder: 'Drop an image',
},
)
const storageKey = `image-slot:${props.id}`
const imageSrc = ref<string | null>(null)
const isDragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
onMounted(() => {
imageSrc.value = localStorage.getItem(storageKey)
})
function readFile(file: File) {
if (!file.type.startsWith('image/')) return
const reader = new FileReader()
reader.onload = () => {
const result = reader.result as string
imageSrc.value = result
localStorage.setItem(storageKey, result)
}
reader.readAsDataURL(file)
}
function onDrop(e: DragEvent) {
isDragOver.value = false
const file = e.dataTransfer?.files?.[0]
if (file) readFile(file)
}
function onChange(e: Event) {
const file = (e.target as HTMLInputElement).files?.[0]
if (file) readFile(file)
}
function clear(e: MouseEvent) {
e.stopPropagation()
imageSrc.value = null
localStorage.removeItem(storageKey)
if (fileInput.value) fileInput.value.value = ''
}
function browse() {
fileInput.value?.click()
}
</script>
<template>
<div
class="nych-image-slot"
:class="[`nych-image-slot--${shape}`, { 'nych-image-slot--drag': isDragOver, 'nych-image-slot--filled': imageSrc }]"
role="button"
tabindex="0"
@click="browse"
@keydown.enter="browse"
@dragover.prevent="isDragOver = true"
@dragleave.prevent="isDragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" accept="image/*" class="nych-image-slot-input" @change="onChange" />
<img v-if="imageSrc" :src="imageSrc" alt="" class="nych-image-slot-img" />
<div v-else class="nych-image-slot-placeholder">
<i class="pi pi-image" aria-hidden="true" />
<span>{{ placeholder }}</span>
</div>
<button v-if="imageSrc" type="button" class="nych-image-slot-clear" aria-label="Remove image" @click="clear">
<i class="pi pi-times" aria-hidden="true" />
</button>
</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);
cursor: pointer;
transition:
border-color 0.2s ease,
background-color 0.2s ease;
}
.nych-image-slot--rounded {
border-radius: var(--radius-xl);
}
.nych-image-slot--rect {
border-radius: 0;
}
.nych-image-slot:hover,
.nych-image-slot--drag {
border-color: var(--primary);
background-color: var(--surface-3);
}
.nych-image-slot--filled {
border-style: solid;
}
.nych-image-slot-input {
display: none;
}
.nych-image-slot-img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.nych-image-slot-placeholder {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 1.5rem;
font-family: var(--font-sans);
font-size: 0.85rem;
color: var(--text-dim);
text-align: center;
}
.nych-image-slot-placeholder i {
font-size: 1.5rem;
}
.nych-image-slot-clear {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
padding: 0;
color: var(--text-high);
background-color: color-mix(in srgb, var(--surface-0) 60%, transparent);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
opacity: 0;
transition: opacity 0.2s ease;
}
.nych-image-slot:hover .nych-image-slot-clear,
.nych-image-slot:focus-visible .nych-image-slot-clear {
opacity: 1;
}
</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,57 @@
<script setup lang="ts">
// 16 leaves: alternating Type A / B, rotating 22.5° apart around (340,180)
const leaves = 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)`,
}
})
withDefaults(
defineProps<{
size?: number
color?: string
spinning?: boolean
}>(),
{
size: 48,
spinning: true,
},
)
</script>
<template>
<svg
class="nych-loading-icon"
preserveAspectRatio="xMidYMid meet"
viewBox="226 66 228 228"
:width="size"
:height="size"
role="img"
aria-label="loading"
:style="{ color }"
>
<g class="wreath" fill="currentColor" stroke="currentColor" :style="spinning ? undefined : { animation: 'none' }">
<g v-for="(leaf, i) in leaves" :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>
</g>
</svg>
</template>

View file

@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
import LaurelSpinner from './LaurelSpinner.vue'
const props = withDefaults(
defineProps<{
severity?: 'primary' | 'secondary' | 'info' | 'success' | 'warning' | 'danger'
size?: 'small' | 'normal' | 'large'
loading?: boolean
disabled?: boolean
}>(),
{
severity: 'primary',
size: 'normal',
loading: false,
disabled: false,
},
)
const dataP = computed(() => [props.size, props.loading ? 'loading' : null].filter(Boolean).join(' '))
</script>
<template>
<button
:class="`nych-button nych-button-${severity}`"
:data-p="dataP"
:disabled="disabled || loading"
>
<LaurelSpinner v-if="loading" :size="20" />
<slot v-if="!loading" name="icon" />
<span><slot /></span>
</button>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
defineProps<{
title?: string
subtitle?: string
}>()
</script>
<template>
<div class="nych-card">
<div v-if="$slots.image" class="nych-card-header">
<slot name="image" />
</div>
<div class="nych-card-body">
<div v-if="title || subtitle || $slots.title" class="nych-card-caption">
<div v-if="title || $slots.title" class="nych-card-title">
<slot name="title">{{ title }}</slot>
</div>
<div v-if="subtitle" class="nych-card-subtitle">{{ subtitle }}</div>
</div>
<div v-if="$slots.default" class="nych-card-content">
<slot />
</div>
<div v-if="$slots.footer" class="nych-card-footer">
<slot name="footer" />
</div>
</div>
</div>
</template>

View file

@ -0,0 +1,38 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
modelValue?: string
size?: 'small' | 'normal' | 'large'
invalid?: boolean
type?: string
placeholder?: string
}>(),
{
modelValue: '',
size: 'normal',
invalid: false,
type: 'text',
},
)
defineEmits<{ 'update:modelValue': [value: string] }>()
const dataP = computed(() =>
[props.size === 'small' ? 'small' : props.size === 'large' ? 'large' : null, props.invalid ? 'invalid' : null]
.filter(Boolean)
.join(' '),
)
</script>
<template>
<input
class="nych-input"
:data-p="dataP || undefined"
:type="type"
:placeholder="placeholder"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLInputElement).value)"
/>
</template>

View file

@ -0,0 +1,60 @@
<script setup lang="ts">
withDefaults(
defineProps<{
severity?: 'info' | 'success' | 'warn' | 'error' | 'secondary'
closable?: boolean
}>(),
{
severity: 'info',
closable: false,
},
)
defineEmits<{ close: [] }>()
</script>
<template>
<div class="nych-message" :data-p="severity" role="status">
<div class="nych-message-content">
<span v-if="severity === 'info'" class="nych-message-icon">
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true" width="18" height="18">
<path d="M9 8.5v5M9 5.5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
<span v-else-if="severity === 'success'" class="nych-message-icon">
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true" width="18" height="18">
<path
d="M4 9.5L7.5 13L14 5.5"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</svg>
</span>
<span v-else-if="severity === 'warn'" class="nych-message-icon">
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true" width="18" height="18">
<path d="M9 4v6M9 13.5h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
<span v-else-if="severity === 'error'" class="nych-message-icon">
<svg viewBox="0 0 18 18" fill="none" aria-hidden="true" width="18" height="18">
<path d="M5.5 5.5l7 7M12.5 5.5l-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
</svg>
</span>
<span class="nych-message-text"><slot /></span>
<button
v-if="closable"
type="button"
class="nych-message-closeButton"
aria-label="Dismiss"
@click="$emit('close')"
>
<svg class="nych-message-closeIcon" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<path d="M3 3l6 6M9 3l-6 6" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" />
</svg>
</button>
</div>
</div>
</template>

View file

@ -0,0 +1,26 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
severity?: 'primary' | 'secondary' | 'info' | 'success' | 'warn' | 'danger' | 'contrast'
rounded?: boolean
}>(),
{
severity: 'primary',
rounded: false,
},
)
const dataP = computed(() =>
[props.severity !== 'primary' ? props.severity : null, props.rounded ? 'rounded' : null]
.filter(Boolean)
.join(' '),
)
</script>
<template>
<span class="nych-tag" :data-p="dataP || undefined">
<slot />
</span>
</template>

View file

@ -0,0 +1,28 @@
<script setup lang="ts">
withDefaults(
defineProps<{
modelValue?: string
invalid?: boolean
rows?: number
placeholder?: string
}>(),
{
modelValue: '',
invalid: false,
rows: 4,
},
)
defineEmits<{ 'update:modelValue': [value: string] }>()
</script>
<template>
<textarea
class="nych-textarea"
:data-p="invalid ? 'invalid' : undefined"
:rows="rows"
:placeholder="placeholder"
:value="modelValue"
@input="$emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
/>
</template>

View file

@ -0,0 +1,70 @@
<script setup lang="ts">
import ImageSlot from '@/components/ds/ImageSlot.vue'
import { portfolioData } from '@/data/portfolio'
</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
id="portfolio-photo"
placeholder="Drop your photo here"
shape="rounded"
style="width: 260px; height: 320px"
/>
</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,122 @@
<script setup lang="ts">
import { computed, reactive, ref } from 'vue'
import NychInputText from '@/components/ds/NychInputText.vue'
import NychTextarea from '@/components/ds/NychTextarea.vue'
import NychButton from '@/components/ds/NychButton.vue'
import NychMessage from '@/components/ds/NychMessage.vue'
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
import { portfolioData } from '@/data/portfolio'
const form = reactive({ name: '', email: '', message: '' })
const sent = ref(false)
const valid = computed(() => Boolean(form.name && form.email && form.message))
const socialLinks = [
{ 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}` },
]
function send() {
sent.value = true
}
</script>
<template>
<section id="contact" class="port-section" style="scroll-margin-top: 68px">
<LaurelDivider style="margin-bottom: 3.5rem" />
<div class="port-2col" style="gap: 64px">
<div class="port-contact-form">
<h2 class="port-title" style="margin-bottom: 0">Get in Touch</h2>
<NychMessage v-if="sent" severity="success">Offering received. I'll reply before the next dawn.</NychMessage>
<template v-else>
<div class="port-contact-fields">
<NychInputText v-model="form.name" placeholder="Your name" />
<NychInputText v-model="form.email" placeholder="Your email" type="email" />
<NychTextarea v-model="form.message" placeholder="Your message…" :rows="4" />
</div>
<div>
<NychButton :disabled="!valid" @click="send">
<template #icon><i class="pi pi-send" style="font-size: 12px; margin-right: 8px" /></template>
Send Offering
</NychButton>
</div>
</template>
</div>
<div class="port-contact-aside">
<p class="port-contact-blurb">
Prefer direct contact? 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,127 @@
<script setup lang="ts">
import LaurelSpinner from '@/components/ds/LaurelSpinner.vue'
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
import NychButton from '@/components/ds/NychButton.vue'
import { useTypewriter } from '@/composables/useTypewriter'
import { scrollToSection } from '@/composables/scrollToSection'
import { portfolioData } from '@/data/portfolio'
const { displayed, done } = useTypewriter(portfolioData.tagline)
</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">
<NychButton @click="scrollToSection('work')">View Work</NychButton>
<NychButton severity="secondary" @click="scrollToSection('contact')">Get in Touch</NychButton>
</div>
<LaurelDivider />
</div>
<div class="port-hero-chevron">
<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.4;
animation: bob 2.5s ease-in-out infinite;
}
</style>

View file

@ -0,0 +1,71 @@
<script setup lang="ts">
import NychTag from '@/components/ds/NychTag.vue'
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
import { portfolioData } from '@/data/portfolio'
const palettes = [
{ key: 'Design', accent: 'var(--primary)', severity: 'primary' as const },
{ key: 'Frontend', accent: 'var(--info)', severity: 'info' as const },
{ key: 'Backend', accent: 'var(--success)', severity: 'success' as const },
{ key: 'Tools', accent: 'var(--warn)', severity: 'warn' as const },
]
</script>
<template>
<section id="skills" class="port-section" style="scroll-margin-top: 68px">
<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">
<NychTag v-for="tag in portfolioData.skills[palette.key] ?? []" :key="tag" :severity="palette.severity" rounded>
{{ tag }}
</NychTag>
</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,41 @@
<script setup lang="ts">
import { portfolioData } from '@/data/portfolio'
import laurelMark from '@/assets/brand/mark.svg'
const year = new Date().getFullYear()
</script>
<template>
<footer class="port-footer">
<img :src="laurelMark" alt="" width="22" height="22" style="opacity: 0.45" />
<div class="port-footer-meta">
<span>{{ portfolioData.name.toUpperCase() }}</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,134 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import type { Theme } from '@/composables/useTheme'
import { scrollToSection } from '@/composables/scrollToSection'
import { portfolioData } from '@/data/portfolio'
import laurelMark from '@/assets/brand/mark.svg'
const props = defineProps<{ theme: Theme }>()
defineEmits<{ toggle: [] }>()
const night = computed(() => props.theme === 'hades')
const visible = ref(false)
function onScroll() {
visible.value = window.scrollY > window.innerHeight * 0.6
}
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
const links: Array<[string, string]> = [
['About', 'about'],
['Work', 'work'],
['Contact', 'contact'],
]
const surname = portfolioData.name.split(' ').pop()?.toUpperCase()
</script>
<template>
<nav class="port-nav" :class="{ 'port-nav--visible': 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;
}
.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: var(--radius-full);
cursor: pointer;
color: var(--text-body);
font-family: var(--font-sans);
font-size: 0.78rem;
}
</style>

View file

@ -0,0 +1,65 @@
<script setup lang="ts">
import NychCard from '@/components/ds/NychCard.vue'
import NychTag from '@/components/ds/NychTag.vue'
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
import ImageSlot from '@/components/ds/ImageSlot.vue'
import { portfolioData } from '@/data/portfolio'
const ACCENT: Record<string, string> = {
primary: 'var(--primary)',
info: 'var(--info)',
success: 'var(--success)',
warn: 'var(--warn)',
}
</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">
<NychCard
v-for="project in portfolioData.projects"
:key="project.id"
:style="{ borderTop: `3px solid ${ACCENT[project.color]}` }"
>
<template #image>
<ImageSlot :id="project.id" placeholder="Drop a screenshot" shape="rect" style="width: 100%; height: 200px" />
</template>
<template #title>
<div 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>
</div>
</template>
{{ project.description }}
<template #footer>
<NychTag v-for="tag in project.tags" :key="tag" :severity="project.color">{{ tag }}</NychTag>
</template>
</NychCard>
</div>
</section>
</template>
<style scoped>
.port-work-title-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.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 function scrollToSection(id: string) {
const el = document.getElementById(id)
if (!el) return
const top = el.getBoundingClientRect().top + window.scrollY - 68
window.scrollTo({ top, behavior: 'smooth' })
}

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 function 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')
function applyTheme(value: Theme) {
document.documentElement.dataset.theme = value
}
let initialized = false
export function 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)
})
}
function 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 function 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
function tick() {
if (count.value >= text.length) return
timer = setTimeout(() => {
count.value += 1
tick()
}, delay)
}
tick()
onBeforeUnmount(() => {
if (timer) clearTimeout(timer)
})
return { displayed, done }
}

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

@ -0,0 +1,85 @@
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;
}
export interface PortfolioData {
name: string;
role: string;
tagline: string;
status: string;
about: string;
stats: Stat[];
projects: Project[];
skills: Record<string, string[]>;
social: {
github: string;
linkedin: string;
email: string;
};
}
// Edit this block to make the portfolio yours.
export const portfolioData: PortfolioData = {
name: "Matthew McPeak",
role: "Product Designer · Developer",
tagline: "I craft digital experiences that feel inevitable — clear, classical, enduring.",
status: "Open for opportunities · 2026",
about:
"Based in Athens. I work at the intersection of design and engineering, building systems and products that are precise, durable, and a pleasure to use. Ten years in, I still believe the best interface is the one that gets out of the way.",
stats: [
{ value: "10+", label: "Years of craft", color: "var(--primary)" },
{ value: "40+", label: "Projects shipped", color: "var(--info)" },
{ value: "12", label: "Satisfied patrons", color: "var(--success)" },
],
projects: [
{
id: "p1",
title: "Nychthemeron",
color: "primary",
description:
"A dual-theme design system — Hades (dark) and Apollo (light) — for classical, lasting interfaces.",
tags: ["Design System", "Vue", "CSS"],
link: "#",
},
{
id: "p2",
title: "The Oracle",
color: "info",
description:
"A mythology-themed admin interface with consultations, petitioners, and prophecy tracking.",
tags: ["UI Kit", "Dashboard", "TypeScript"],
link: "#",
},
{
id: "p3",
title: "Agora",
color: "success",
description:
"A real-time marketplace for artisans, connecting makers with patrons across the ancient world.",
tags: ["Platform", "Fullstack", "Postgres"],
link: "#",
},
],
skills: {
Design: ["Figma", "Design Systems", "Typography", "Motion"],
Frontend: ["Vue", "CSS", "TypeScript", "Vite"],
Backend: ["Node.js", "Postgres", "REST", "GraphQL"],
Tools: ["Git", "Storybook", "Figma", "Linear"],
},
social: {
github: "#",
linkedin: "#",
email: "hello@example.com",
},
};

View file

@ -1,4 +1,5 @@
import { createApp } from 'vue'
import App from './App.vue'
import './assets/styles/styles.css'
createApp(App).mount('#app')