Inital Commit
This commit is contained in:
commit
29670b22ed
69 changed files with 11455 additions and 0 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
node_modules
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
test-results
|
||||||
|
playwright-report
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
|
*.log
|
||||||
8
.editorconfig
Normal file
8
.editorconfig
Normal 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
|
||||||
88
.forgejo/workflows/ci.yml
Normal file
88
.forgejo/workflows/ci.yml
Normal file
|
|
@ -0,0 +1,88 @@
|
||||||
|
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
|
||||||
|
|
||||||
|
- 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
||||||
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal 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
5
.oxfmtrc.json
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxfmt/configuration_schema.json",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true
|
||||||
|
}
|
||||||
10
.oxlintrc.json
Normal file
10
.oxlintrc.json
Normal 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
10
.vscode/extensions.json
vendored
Normal 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
7
Dockerfile
Normal 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
|
||||||
98
README.md
Normal file
98
README.md
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
# portfolio
|
||||||
|
|
||||||
|
This template should help get you started developing with Vue 3 in Vite.
|
||||||
|
|
||||||
|
## Recommended IDE Setup
|
||||||
|
|
||||||
|
[VS Code](https://code.visualstudio.com/) + [Vue (Official)](https://marketplace.visualstudio.com/items?itemName=Vue.volar) (and disable Vetur).
|
||||||
|
|
||||||
|
## Recommended Browser Setup
|
||||||
|
|
||||||
|
- Chromium-based browsers (Chrome, Edge, Brave, etc.):
|
||||||
|
- [Vue.js devtools](https://chromewebstore.google.com/detail/vuejs-devtools/nhdogjmejiglipccpnnnanhbledajbpd)
|
||||||
|
- [Turn on Custom Object Formatter in Chrome DevTools](http://bit.ly/object-formatters)
|
||||||
|
- Firefox:
|
||||||
|
- [Vue.js devtools](https://addons.mozilla.org/en-US/firefox/addon/vue-js-devtools/)
|
||||||
|
- [Turn on Custom Object Formatter in Firefox DevTools](https://fxdx.dev/firefox-devtools-custom-object-formatters/)
|
||||||
|
|
||||||
|
## Type Support for `.vue` Imports in TS
|
||||||
|
|
||||||
|
TypeScript cannot handle type information for `.vue` imports by default, so we replace the `tsc` CLI with `vue-tsc` for type checking. In editors, we need [Volar](https://marketplace.visualstudio.com/items?itemName=Vue.volar) to make the TypeScript language service aware of `.vue` types.
|
||||||
|
|
||||||
|
## Customize configuration
|
||||||
|
|
||||||
|
See [Vite Configuration Reference](https://vite.dev/config/).
|
||||||
|
|
||||||
|
## Project Setup
|
||||||
|
|
||||||
|
```sh
|
||||||
|
bun install
|
||||||
|
```
|
||||||
|
|
||||||
|
### 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 with [ESLint](https://eslint.org/)
|
||||||
|
|
||||||
|
```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.
|
||||||
|
|
@ -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
4
e2e/tsconfig.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": ["./**/*"]
|
||||||
|
}
|
||||||
8
e2e/vue.spec.ts
Normal file
8
e2e/vue.spec.ts
Normal 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!')
|
||||||
|
})
|
||||||
1
env.d.ts
vendored
Normal file
1
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
38
eslint.config.ts
Normal file
38
eslint.config.ts
Normal 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,
|
||||||
|
)
|
||||||
4
global.d.ts
vendored
Normal file
4
global.d.ts
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
interface Window {
|
||||||
|
API_URL: string;
|
||||||
|
API_KEY: string;
|
||||||
|
}
|
||||||
12
index.html
Normal file
12
index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" href="/favicon.ico" />
|
||||||
|
<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
10
nginx.conf
Normal 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
6652
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
53
package.json
Normal file
53
package.json
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
{
|
||||||
|
"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.1",
|
||||||
|
"@unhead/vue": "^3.1.4",
|
||||||
|
"vue": "^3.5.32"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.59.1",
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/jsdom": "^28.0.1",
|
||||||
|
"@types/node": "^25.9.3",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vitest/eslint-plugin": "^1.6.16",
|
||||||
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.60.0",
|
||||||
|
"eslint-plugin-playwright": "^2.10.1",
|
||||||
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxfmt": "^0.45.0",
|
||||||
|
"oxlint": "~1.60.0",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
110
playwright.config.ts
Normal file
110
playwright.config.ts
Normal 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,
|
||||||
|
},
|
||||||
|
})
|
||||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
1
sample.env
Normal file
1
sample.env
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
VITE_API_URL=http://localhost:5173/
|
||||||
52
src/App.vue
Normal file
52
src/App.vue
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onBeforeMount, ref, watch, 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();
|
||||||
|
|
||||||
|
onBeforeMount(getPortfolioData);
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => portfolioData.value,
|
||||||
|
(data) => {
|
||||||
|
if (data) {
|
||||||
|
useHead({
|
||||||
|
title: data.name + " · Portfolio",
|
||||||
|
meta: [{ name: "description", content: "Page description" }],
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
</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>
|
||||||
11
src/__tests__/App.spec.ts
Normal file
11
src/__tests__/App.spec.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
import { describe, it, expect } from "vitest";
|
||||||
|
|
||||||
|
import { mount } from "@vue/test-utils";
|
||||||
|
import App from "../App.vue";
|
||||||
|
|
||||||
|
describe("App", () => {
|
||||||
|
it("mounts renders properly", () => {
|
||||||
|
const wrapper = mount(App);
|
||||||
|
expect(wrapper.text()).toContain("Matthew McPeak");
|
||||||
|
});
|
||||||
|
});
|
||||||
52
src/__tests__/composables/useAPI.spec.ts
Normal file
52
src/__tests__/composables/useAPI.spec.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
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 = {
|
||||||
|
something: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.resolve({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockData),
|
||||||
|
} as Response),
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useAPI();
|
||||||
|
|
||||||
|
const data = await api.getPortfolioData();
|
||||||
|
|
||||||
|
expect(data).toStrictEqual(mockData);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns undefined on failure", async () => {
|
||||||
|
const mockData = {
|
||||||
|
something: "test",
|
||||||
|
};
|
||||||
|
|
||||||
|
globalThis.fetch = vi.fn(() =>
|
||||||
|
Promise.reject({
|
||||||
|
ok: true,
|
||||||
|
json: () => Promise.resolve(mockData),
|
||||||
|
} as Response),
|
||||||
|
);
|
||||||
|
|
||||||
|
const api = useAPI();
|
||||||
|
|
||||||
|
const data = await api.getPortfolioData();
|
||||||
|
|
||||||
|
expect(data).toStrictEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
20
src/assets/brand/laurel-loading.svg
Normal file
20
src/assets/brand/laurel-loading.svg
Normal 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 |
20
src/assets/brand/laurel.svg
Normal file
20
src/assets/brand/laurel.svg
Normal 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
16
src/assets/brand/mark.svg
Normal 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 |
129
src/assets/styles/portfolio.css
Normal file
129
src/assets/styles/portfolio.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
17
src/assets/styles/styles.css
Normal file
17
src/assets/styles/styles.css
Normal 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';
|
||||||
59
src/assets/styles/tokens/base.css
Normal file
59
src/assets/styles/tokens/base.css
Normal 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;
|
||||||
|
}
|
||||||
124
src/assets/styles/tokens/colors.css
Normal file
124
src/assets/styles/tokens/colors.css
Normal 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);
|
||||||
|
}
|
||||||
787
src/assets/styles/tokens/components.css
Normal file
787
src/assets/styles/tokens/components.css
Normal 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);
|
||||||
|
}
|
||||||
12
src/assets/styles/tokens/fonts.css
Normal file
12
src/assets/styles/tokens/fonts.css
Normal 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');
|
||||||
41
src/assets/styles/tokens/spacing.css
Normal file
41
src/assets/styles/tokens/spacing.css
Normal 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);
|
||||||
|
}
|
||||||
45
src/assets/styles/tokens/typography.css
Normal file
45
src/assets/styles/tokens/typography.css
Normal 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 */
|
||||||
|
}
|
||||||
166
src/components/design/ImageSlot.vue
Normal file
166
src/components/design/ImageSlot.vue
Normal 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>
|
||||||
152
src/components/design/LaurelDivider.vue
Normal file
152
src/components/design/LaurelDivider.vue
Normal 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>
|
||||||
57
src/components/design/LaurelSpinner.vue
Normal file
57
src/components/design/LaurelSpinner.vue
Normal 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>
|
||||||
33
src/components/design/NychButton.vue
Normal file
33
src/components/design/NychButton.vue
Normal 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>
|
||||||
28
src/components/design/NychCard.vue
Normal file
28
src/components/design/NychCard.vue
Normal 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>
|
||||||
38
src/components/design/NychInputText.vue
Normal file
38
src/components/design/NychInputText.vue
Normal 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>
|
||||||
60
src/components/design/NychMessage.vue
Normal file
60
src/components/design/NychMessage.vue
Normal 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>
|
||||||
26
src/components/design/NychTag.vue
Normal file
26
src/components/design/NychTag.vue
Normal 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>
|
||||||
28
src/components/design/NychTextarea.vue
Normal file
28
src/components/design/NychTextarea.vue
Normal 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>
|
||||||
72
src/components/portfolio/AboutSection.vue
Normal file
72
src/components/portfolio/AboutSection.vue
Normal 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
|
||||||
|
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>
|
||||||
137
src/components/portfolio/ContactSection.vue
Normal file
137
src/components/portfolio/ContactSection.vue
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from "vue";
|
||||||
|
import NychInputText from "@/components/design/NychInputText.vue";
|
||||||
|
import NychTextarea from "@/components/design/NychTextarea.vue";
|
||||||
|
import NychButton from "@/components/design/NychButton.vue";
|
||||||
|
import NychMessage from "@/components/design/NychMessage.vue";
|
||||||
|
import LaurelDivider from "@/components/design/LaurelDivider.vue";
|
||||||
|
import type { ComponentProps } from "@/data/portfolio";
|
||||||
|
|
||||||
|
const { portfolioData } = defineProps<ComponentProps>();
|
||||||
|
|
||||||
|
const form = reactive({ name: "", email: "", message: "" });
|
||||||
|
const sent = ref(false);
|
||||||
|
|
||||||
|
const valid = computed(() => Boolean(form.name && form.email && form.message));
|
||||||
|
|
||||||
|
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}`,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const 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>
|
||||||
145
src/components/portfolio/HeroSection.vue
Normal file
145
src/components/portfolio/HeroSection.vue
Normal file
|
|
@ -0,0 +1,145 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch } from "vue";
|
||||||
|
|
||||||
|
import LaurelSpinner from "@/components/design/LaurelSpinner.vue";
|
||||||
|
import LaurelDivider from "@/components/design/LaurelDivider.vue";
|
||||||
|
import NychButton from "@/components/design/NychButton.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) };
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
77
src/components/portfolio/SkillsSection.vue
Normal file
77
src/components/portfolio/SkillsSection.vue
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NychTag from "@/components/design/NychTag.vue";
|
||||||
|
import LaurelDivider from "@/components/design/LaurelDivider.vue";
|
||||||
|
import type { ComponentProps } 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 },
|
||||||
|
];
|
||||||
|
const { portfolioData } = defineProps<ComponentProps>();
|
||||||
|
</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>
|
||||||
47
src/components/portfolio/TheFooter.vue
Normal file
47
src/components/portfolio/TheFooter.vue
Normal 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>
|
||||||
136
src/components/portfolio/TheNav.vue
Normal file
136
src/components/portfolio/TheNav.vue
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, onMounted, ref } from "vue";
|
||||||
|
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 visible = ref(false);
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener("scroll", onScroll, { passive: true }));
|
||||||
|
onBeforeUnmount(() => window.removeEventListener("scroll", onScroll));
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
visible.value = window.scrollY > window.innerHeight * 0.6;
|
||||||
|
};
|
||||||
|
|
||||||
|
const surname = props.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>
|
||||||
79
src/components/portfolio/WorkSection.vue
Normal file
79
src/components/portfolio/WorkSection.vue
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import NychCard from "@/components/design/NychCard.vue";
|
||||||
|
import NychTag from "@/components/design/NychTag.vue";
|
||||||
|
import LaurelDivider from "@/components/design/LaurelDivider.vue";
|
||||||
|
import ImageSlot from "@/components/design/ImageSlot.vue";
|
||||||
|
|
||||||
|
import type { ComponentProps } from "@/data/portfolio";
|
||||||
|
|
||||||
|
const ACCENT: Record<string, string> = {
|
||||||
|
primary: "var(--primary)",
|
||||||
|
info: "var(--info)",
|
||||||
|
success: "var(--success)",
|
||||||
|
warn: "var(--warn)",
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<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>
|
||||||
6
src/composables/scrollToSection.ts
Normal file
6
src/composables/scrollToSection.ts
Normal 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' })
|
||||||
|
}
|
||||||
24
src/composables/useAPI.ts
Normal file
24
src/composables/useAPI.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
import type { PortfolioData } from "@/data/portfolio";
|
||||||
|
import { useUtils } from "./useUtils";
|
||||||
|
|
||||||
|
export const useAPI = (baseURL = window.API_URL, key = window.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"))
|
||||||
|
.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,
|
||||||
|
};
|
||||||
|
};
|
||||||
25
src/composables/useReveal.ts
Normal file
25
src/composables/useReveal.ts
Normal 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()
|
||||||
|
})
|
||||||
|
}
|
||||||
32
src/composables/useTheme.ts
Normal file
32
src/composables/useTheme.ts
Normal 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 }
|
||||||
|
}
|
||||||
28
src/composables/useTypewriter.ts
Normal file
28
src/composables/useTypewriter.ts
Normal 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;
|
||||||
|
|
||||||
|
const tick = () => {
|
||||||
|
if (count.value >= text.length) return;
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
count.value += 1;
|
||||||
|
tick();
|
||||||
|
}, delay);
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
if (timer) clearTimeout(timer);
|
||||||
|
});
|
||||||
|
|
||||||
|
return { displayed, done };
|
||||||
|
}
|
||||||
11
src/composables/useUtils.ts
Normal file
11
src/composables/useUtils.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
export const useUtils = () => {
|
||||||
|
const normalizePath = (path: string) => {
|
||||||
|
return path.endsWith("/") ? path.slice(0, -1) : path;
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
url: {
|
||||||
|
normalizePath,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
34
src/data/portfolio.ts
Normal file
34
src/data/portfolio.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ComponentProps {
|
||||||
|
portfolioData?: PortfolioData;
|
||||||
|
}
|
||||||
5
src/main.ts
Normal file
5
src/main.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import App from './App.vue'
|
||||||
|
import './assets/styles/styles.css'
|
||||||
|
|
||||||
|
createApp(App).mount('#app')
|
||||||
24
tsconfig.app.json
Normal file
24
tsconfig.app.json
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": [
|
||||||
|
"vite-env.d.ts",
|
||||||
|
"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
14
tsconfig.json
Normal 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
28
tsconfig.node.json
Normal 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
21
tsconfig.vitest.json
Normal 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", "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"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
vite-env.d.ts
vendored
Normal file
19
vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
interface ViteTypeOptions {
|
||||||
|
strictImportMetaEnv: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMetaEnv {
|
||||||
|
readonly VITE_API_URL: string;
|
||||||
|
readonly VITE_API_KEY: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ImportMeta {
|
||||||
|
readonly env: ImportMetaEnv;
|
||||||
|
}
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface window {
|
||||||
|
API_URL: string;
|
||||||
|
API_KEY: string;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
vite.config.ts
Normal file
25
vite.config.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { fileURLToPath, URL } from "node:url";
|
||||||
|
import { defineConfig, loadEnv } from "vite";
|
||||||
|
|
||||||
|
import vue from "@vitejs/plugin-vue";
|
||||||
|
import vueDevTools from "vite-plugin-vue-devtools";
|
||||||
|
|
||||||
|
// 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()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
"@": baseURL,
|
||||||
|
"~": baseURL,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
16
vitest.config.ts
Normal file
16
vitest.config.ts
Normal 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)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
Loading…
Reference in a new issue