Compare commits
No commits in common. "main" and "mcpeakml/portfolio" have entirely different histories.
main
...
mcpeakml/p
56 changed files with 8632 additions and 1554 deletions
1
.env
Normal file
1
.env
Normal file
|
|
@ -0,0 +1 @@
|
|||
VITE_API_URL=http://localhost:5173/
|
||||
|
|
@ -28,11 +28,12 @@ jobs:
|
|||
|
||||
- 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
|
||||
printf '@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/McPeakDev/npm/\n//git.mcpeakdev.com/api/packages/McPeakDev/npm/:_authToken=%s\n' "$BUN_AUTH_TOKEN" > .npmrc
|
||||
bun install --frozen-lockfile
|
||||
rm .npmrc
|
||||
working-directory: ui
|
||||
env:
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
|
||||
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}
|
||||
|
||||
- name: Audit dependencies
|
||||
run: bun audit || true
|
||||
|
|
@ -45,9 +46,6 @@ jobs:
|
|||
|
||||
- name: Build
|
||||
run: bun run build-only
|
||||
env:
|
||||
VITE_API_URL: ${{ vars.VITE_API_URL }}
|
||||
VITE_API_KEY: ${{ secrets.VITE_API_KEY }}
|
||||
|
||||
- name: Upload dist
|
||||
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -40,5 +40,3 @@ __screenshots__/
|
|||
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
.env
|
||||
|
|
|
|||
46
README.md
46
README.md
|
|
@ -1,10 +1,27 @@
|
|||
# portfolio
|
||||
|
||||
Personal portfolio site built with Vue 3, Vite, and Tailwind CSS, styled with
|
||||
the `@nychthemeron/library` design system (private package, published to the
|
||||
Forgejo registry at `git.mcpeakdev.com`). Content is fetched at runtime from
|
||||
a small key-value API rather than baked into the bundle (see
|
||||
`src/composables/useAPI.ts`).
|
||||
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
|
||||
|
||||
|
|
@ -12,9 +29,6 @@ a small key-value API rather than baked into the bundle (see
|
|||
bun install
|
||||
```
|
||||
|
||||
Copy `sample.env` to `.env` and fill in `VITE_API_URL` / `VITE_API_KEY` for
|
||||
the content API.
|
||||
|
||||
### Compile and Hot-Reload for Development
|
||||
|
||||
```sh
|
||||
|
|
@ -52,14 +66,10 @@ bun test:e2e tests/example.spec.ts
|
|||
bun test:e2e --debug
|
||||
```
|
||||
|
||||
### Lint and Format
|
||||
|
||||
Linting runs both [oxlint](https://oxc.rs/docs/guide/usage/linter.html) and
|
||||
[ESLint](https://eslint.org/); formatting uses [oxfmt](https://oxc.rs/).
|
||||
### Lint with [ESLint](https://eslint.org/)
|
||||
|
||||
```sh
|
||||
bun lint
|
||||
bun format
|
||||
```
|
||||
|
||||
## Docker
|
||||
|
|
@ -77,14 +87,12 @@ The site will be available at http://localhost:8080.
|
|||
|
||||
### CI publishing
|
||||
|
||||
A Forgejo Actions workflow (`.forgejo/workflows/ci.yml`) runs tests, type
|
||||
checks, and a build on every push and pull request. When a `v*` tag is
|
||||
pushed (e.g. `v1.2.3`), it also builds and pushes the image to the Forgejo
|
||||
container registry:
|
||||
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, plus `VITE_API_URL` /
|
||||
`VITE_API_KEY` build-time variables (see `ci.yml`).
|
||||
`write:package` permission for the registry.
|
||||
|
|
|
|||
1
bunfig.toml
Normal file
1
bunfig.toml
Normal file
|
|
@ -0,0 +1 @@
|
|||
env = false
|
||||
17
env.d.ts
vendored
17
env.d.ts
vendored
|
|
@ -1,18 +1 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
||||
// @nychthemeron/library ships this as a CSS-only export subpath, so it has
|
||||
// no matching *.css wildcard declaration (the specifier doesn't end in .css).
|
||||
declare module '@nychthemeron/library/style'
|
||||
|
||||
interface ViteTypeOptions {
|
||||
strictImportMetaEnv: unknown;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
readonly VITE_API_KEY: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
|
|
|||
2
global.d.ts
vendored
2
global.d.ts
vendored
|
|
@ -1,2 +0,0 @@
|
|||
declare const API_URL: string;
|
||||
declare const API_KEY: string;
|
||||
23
index.html
23
index.html
|
|
@ -1,12 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en-US">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" href="/mark.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
<!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">
|
||||
<title>Alexander Voss · Portfolio</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</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
104
package.json
104
package.json
|
|
@ -1,56 +1,52 @@
|
|||
{
|
||||
"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.5",
|
||||
"@unhead/vue": "^3.1.8",
|
||||
"lucide-vue-next": "^1.0.0",
|
||||
"vue": "^3.5.40"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@tsconfig/node24": "^24.0.4",
|
||||
"@types/jsdom": "^28.0.3",
|
||||
"@types/node": "^25.9.5",
|
||||
"@vitejs/plugin-vue": "^6.0.8",
|
||||
"@vitest/eslint-plugin": "^1.6.23",
|
||||
"@vue/eslint-config-typescript": "^14.9.0",
|
||||
"@vue/test-utils": "^2.4.11",
|
||||
"@vue/tsconfig": "^0.9.1",
|
||||
"eslint": "^10.7.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-oxlint": "~1.60.0",
|
||||
"eslint-plugin-playwright": "^2.10.5",
|
||||
"eslint-plugin-vue": "~10.8.0",
|
||||
"jiti": "^2.7.0",
|
||||
"jsdom": "^29.1.1",
|
||||
"npm-run-all2": "^8.0.4",
|
||||
"oxfmt": "^0.45.0",
|
||||
"oxlint": "~1.60.0",
|
||||
"tailwindcss": "^4.3.3",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.1.5",
|
||||
"vite-plugin-vue-devtools": "^8.1.5",
|
||||
"vitest": "^4.1.10",
|
||||
"vue-tsc": "^3.3.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
"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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
|
|
@ -1,16 +0,0 @@
|
|||
<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>
|
||||
|
Before Width: | Height: | Size: 728 B |
|
|
@ -1,2 +1 @@
|
|||
VITE_API_URL=http://localhost:5173/
|
||||
VITE_API_KEY=
|
||||
|
|
|
|||
31
src/App.vue
31
src/App.vue
|
|
@ -1,6 +1,4 @@
|
|||
<script setup lang="ts">
|
||||
import { onBeforeMount, ref, computed, type Ref } from 'vue'
|
||||
import { useHead } from '@unhead/vue'
|
||||
import TheNav from '@/components/portfolio/TheNav.vue'
|
||||
import HeroSection from '@/components/portfolio/HeroSection.vue'
|
||||
import AboutSection from '@/components/portfolio/AboutSection.vue'
|
||||
|
|
@ -8,38 +6,23 @@ import WorkSection from '@/components/portfolio/WorkSection.vue'
|
|||
import SkillsSection from '@/components/portfolio/SkillsSection.vue'
|
||||
import ContactSection from '@/components/portfolio/ContactSection.vue'
|
||||
import TheFooter from '@/components/portfolio/TheFooter.vue'
|
||||
|
||||
import { useTheme } from '@/composables/useTheme'
|
||||
import { useAPI } from '@/composables/useAPI'
|
||||
import { useReveal } from '@/composables/useReveal'
|
||||
|
||||
const { theme, toggle } = useTheme()
|
||||
const { getPortfolioData } = useAPI()
|
||||
|
||||
import type { PortfolioData } from '@/data/portfolio'
|
||||
|
||||
const portfolioData: Ref<PortfolioData | undefined> = ref()
|
||||
|
||||
useReveal()
|
||||
|
||||
useHead({
|
||||
title: computed(() => (portfolioData.value ? portfolioData.value.name + ' · Portfolio' : '')),
|
||||
meta: [{ name: 'description', content: 'Page description' }],
|
||||
})
|
||||
|
||||
onBeforeMount(async () => (portfolioData.value = await getPortfolioData()))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TheNav :theme="theme" @toggle="toggle" :portfolioData="portfolioData" />
|
||||
<TheNav :theme="theme" @toggle="toggle" />
|
||||
<main>
|
||||
<HeroSection :portfolioData="portfolioData" />
|
||||
<AboutSection :portfolioData="portfolioData" />
|
||||
<WorkSection :portfolioData="portfolioData" />
|
||||
<SkillsSection :portfolioData="portfolioData" />
|
||||
<ContactSection :portfolioData="portfolioData" />
|
||||
<HeroSection />
|
||||
<AboutSection />
|
||||
<WorkSection />
|
||||
<SkillsSection />
|
||||
<ContactSection />
|
||||
</main>
|
||||
<TheFooter :portfolioData="portfolioData" />
|
||||
<TheFooter />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,11 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import { mount } from '@vue/test-utils'
|
||||
import App from '../App.vue'
|
||||
import { mount } from "@vue/test-utils";
|
||||
import App from "../App.vue";
|
||||
|
||||
vi.mock('@unhead/vue')
|
||||
|
||||
describe('App', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
it('mounts renders properly', () => {
|
||||
const wrapper = mount(App)
|
||||
expect(wrapper.text()).toContain('NYCHTHEMERON')
|
||||
})
|
||||
})
|
||||
describe("App", () => {
|
||||
it("mounts renders properly", () => {
|
||||
const wrapper = mount(App);
|
||||
expect(wrapper.text()).toContain("Matthew McPeak");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,55 +1,52 @@
|
|||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
|
||||
import { useAPI } from '../../composables/useAPI'
|
||||
import { useAPI } from "../../composables/useAPI";
|
||||
|
||||
describe('useAPI', () => {
|
||||
describe("useAPI", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('creates properly', () => {
|
||||
const api = useAPI()
|
||||
expect(api).not.toBeUndefined()
|
||||
})
|
||||
it("creates properly", () => {
|
||||
const api = useAPI();
|
||||
expect(api).not.toBeUndefined();
|
||||
});
|
||||
|
||||
it('returns data', async () => {
|
||||
const mockData = [
|
||||
{
|
||||
key: 'test',
|
||||
value: '{}',
|
||||
},
|
||||
]
|
||||
it("returns data", async () => {
|
||||
const mockData = {
|
||||
something: "test",
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn<() => Promise<Response>>(() =>
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockData),
|
||||
} as Response),
|
||||
)
|
||||
);
|
||||
|
||||
const api = useAPI()
|
||||
const api = useAPI();
|
||||
|
||||
const data = await api.getPortfolioData()
|
||||
const data = await api.getPortfolioData();
|
||||
|
||||
expect(data).toStrictEqual({})
|
||||
})
|
||||
expect(data).toStrictEqual(mockData);
|
||||
});
|
||||
|
||||
it('returns undefined on failure', async () => {
|
||||
it("returns undefined on failure", async () => {
|
||||
const mockData = {
|
||||
something: 'test',
|
||||
}
|
||||
something: "test",
|
||||
};
|
||||
|
||||
globalThis.fetch = vi.fn<() => Promise<Response>>(() =>
|
||||
globalThis.fetch = vi.fn(() =>
|
||||
Promise.reject({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(mockData),
|
||||
} as Response),
|
||||
)
|
||||
);
|
||||
|
||||
const api = useAPI()
|
||||
const api = useAPI();
|
||||
|
||||
const data = await api.getPortfolioData()
|
||||
const data = await api.getPortfolioData();
|
||||
|
||||
expect(data).toStrictEqual(undefined)
|
||||
})
|
||||
})
|
||||
expect(data).toStrictEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,12 +2,6 @@
|
|||
portfolio.css — layout & motion for the Nychthemeron
|
||||
portfolio template
|
||||
============================================================ */
|
||||
|
||||
/* Portfolio-only tokens not covered by @nychthemeron/library's theme. */
|
||||
:root {
|
||||
--shadow-card: 0 2px 12px rgba(0, 0, 0, 0.18);
|
||||
}
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
|
|
@ -16,12 +10,10 @@
|
|||
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
|
||||
#app {
|
||||
|
|
@ -31,7 +23,7 @@ body {
|
|||
}
|
||||
|
||||
.port-section {
|
||||
padding: 3rem 2rem;
|
||||
padding: 5rem 2rem;
|
||||
max-width: 1060px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
|
@ -97,9 +89,6 @@ body {
|
|||
.port-toggle-text {
|
||||
display: none;
|
||||
}
|
||||
.port-nav-name {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Scroll reveal */
|
||||
|
|
@ -116,13 +105,13 @@ body {
|
|||
}
|
||||
|
||||
/* Card hover lift + gold edge */
|
||||
.port-work-card {
|
||||
.nych-card {
|
||||
transition:
|
||||
transform 0.25s ease,
|
||||
box-shadow 0.25s ease,
|
||||
border-color 0.25s ease;
|
||||
}
|
||||
.port-work-card:hover {
|
||||
.nych-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow:
|
||||
0 14px 36px rgba(0, 0, 0, 0.32),
|
||||
|
|
|
|||
|
|
@ -8,5 +8,10 @@
|
|||
<html data-theme="hades"> or "apollo"; Hades is the default.
|
||||
============================================================ */
|
||||
|
||||
@import './tokens/laurel-divider.css';
|
||||
@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');
|
||||
|
|
@ -1,51 +0,0 @@
|
|||
/* ============================================================
|
||||
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);
|
||||
}
|
||||
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 */
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { ImageIcon } from 'lucide-vue-next'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
src?: string
|
||||
alt?: string
|
||||
shape?: 'rect' | 'rounded'
|
||||
}>(),
|
||||
{
|
||||
shape: 'rounded',
|
||||
alt: '',
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="nych-image-slot"
|
||||
:class="[`nych-image-slot--${shape}`, { 'nych-image-slot--filled': src }]"
|
||||
>
|
||||
<img v-if="src" :src="src" :alt="alt" class="nych-image-slot-img" />
|
||||
<div v-else class="nych-image-slot-placeholder">
|
||||
<ImageIcon aria-hidden="true" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.nych-image-slot {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--surface-2);
|
||||
border: 1px dashed var(--border);
|
||||
}
|
||||
|
||||
.nych-image-slot--rounded {
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
.nych-image-slot--rect {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.nych-image-slot--filled {
|
||||
border-style: solid;
|
||||
}
|
||||
|
||||
.nych-image-slot-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nych-image-slot-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
color: var(--text-dim);
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<script setup lang="ts">
|
||||
import { LoadingIcon } from '@nychthemeron/library'
|
||||
|
||||
withDefaults(
|
||||
defineProps<{
|
||||
size?: number
|
||||
color?: string
|
||||
spinning?: boolean
|
||||
}>(),
|
||||
{
|
||||
color: 'var(--primary)',
|
||||
size: 48,
|
||||
spinning: true,
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<LoadingIcon
|
||||
class="laurel-spinner"
|
||||
:class="{ 'laurel-spinner--static': !spinning }"
|
||||
:style="{ width: `${size}px`, height: `${size}px` }"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.laurel-spinner--static :deep(.wreath) {
|
||||
animation: none;
|
||||
}
|
||||
|
||||
.laurel-spinner :deep(.wreath path) {
|
||||
fill: v-bind(color);
|
||||
}
|
||||
|
||||
.laurel-spinner :deep(.wreath line) {
|
||||
stroke: v-bind(color);
|
||||
}
|
||||
</style>
|
||||
166
src/components/ds/ImageSlot.vue
Normal file
166
src/components/ds/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>
|
||||
57
src/components/ds/LaurelSpinner.vue
Normal file
57
src/components/ds/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/ds/NychButton.vue
Normal file
33
src/components/ds/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/ds/NychCard.vue
Normal file
28
src/components/ds/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/ds/NychInputText.vue
Normal file
38
src/components/ds/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/ds/NychMessage.vue
Normal file
60
src/components/ds/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/ds/NychTag.vue
Normal file
26
src/components/ds/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/ds/NychTextarea.vue
Normal file
28
src/components/ds/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>
|
||||
|
|
@ -1,8 +1,6 @@
|
|||
<script setup lang="ts">
|
||||
import ImageSlot from '@/components/design/ImageSlot.vue'
|
||||
import type { ComponentProps } from '@/data/portfolio'
|
||||
|
||||
const { portfolioData } = defineProps<ComponentProps>()
|
||||
import ImageSlot from '@/components/ds/ImageSlot.vue'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -10,9 +8,9 @@ const { portfolioData } = defineProps<ComponentProps>()
|
|||
<div class="port-2col reveal">
|
||||
<div class="port-about-text">
|
||||
<h2 class="port-title">About</h2>
|
||||
<p class="port-about-bio">{{ portfolioData?.about }}</p>
|
||||
<p class="port-about-bio">{{ portfolioData.about }}</p>
|
||||
<div class="port-about-stats">
|
||||
<div v-for="stat in portfolioData?.stats" :key="stat.label">
|
||||
<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>
|
||||
|
|
@ -20,10 +18,10 @@ const { portfolioData } = defineProps<ComponentProps>()
|
|||
</div>
|
||||
<div class="port-about-photo">
|
||||
<ImageSlot
|
||||
:src="portfolioData?.photo"
|
||||
alt="Profile photo"
|
||||
id="portfolio-photo"
|
||||
placeholder="Drop your photo here"
|
||||
shape="rounded"
|
||||
style="width: 260px; height: 320px; filter: grayscale(100%) sepia(50%)"
|
||||
style="width: 260px; height: 320px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,37 +1,56 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Github, Linkedin, Mail } from 'lucide-vue-next'
|
||||
import LaurelDivider from '@/components/design/LaurelDivider.vue'
|
||||
import type { ComponentProps } from '@/data/portfolio'
|
||||
import { computed, reactive, ref } from 'vue'
|
||||
import NychInputText from '@/components/ds/NychInputText.vue'
|
||||
import NychTextarea from '@/components/ds/NychTextarea.vue'
|
||||
import NychButton from '@/components/ds/NychButton.vue'
|
||||
import NychMessage from '@/components/ds/NychMessage.vue'
|
||||
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
|
||||
const { portfolioData } = defineProps<ComponentProps>()
|
||||
const form = reactive({ name: '', email: '', message: '' })
|
||||
const sent = ref(false)
|
||||
|
||||
const socialLinks = computed(() => [
|
||||
{ icon: Github, label: 'GitHub', href: portfolioData?.social.github },
|
||||
{ icon: Linkedin, label: 'LinkedIn', href: portfolioData?.social.linkedin },
|
||||
{
|
||||
icon: Mail,
|
||||
label: portfolioData?.social.email,
|
||||
href: `mailto:${portfolioData?.social.email}`,
|
||||
},
|
||||
])
|
||||
const valid = computed(() => Boolean(form.name && form.email && form.message))
|
||||
|
||||
const socialLinks = [
|
||||
{ icon: 'github', label: 'GitHub', href: portfolioData.social.github },
|
||||
{ icon: 'linkedin', label: 'LinkedIn', href: portfolioData.social.linkedin },
|
||||
{ icon: 'envelope', label: portfolioData.social.email, href: `mailto:${portfolioData.social.email}` },
|
||||
]
|
||||
|
||||
function send() {
|
||||
sent.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section id="contact" class="port-section" style="scroll-margin-top: 68px">
|
||||
<LaurelDivider style="margin-bottom: 3.5rem" />
|
||||
<div style="gap: 64px">
|
||||
<h2 class="port-title" style="margin-bottom: 0">Get in Touch</h2>
|
||||
<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">Find me on the platforms below or send a dispatch by post.</p>
|
||||
<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.label"
|
||||
:href="link.href"
|
||||
class="port-contact-link"
|
||||
>
|
||||
<component :is="link.icon" :size="16" class="port-contact-link-icon" />
|
||||
<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>
|
||||
|
|
@ -95,6 +114,9 @@ const socialLinks = computed(() => [
|
|||
|
||||
.port-contact-link-icon {
|
||||
color: var(--primary);
|
||||
font-size: 16px;
|
||||
width: 20px;
|
||||
text-align: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,38 +1,12 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, watch, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { ChevronDown } from 'lucide-vue-next'
|
||||
import { Button } from '@nychthemeron/library'
|
||||
|
||||
import LaurelSpinner from '@/components/design/LaurelSpinner.vue'
|
||||
import LaurelDivider from '@/components/design/LaurelDivider.vue'
|
||||
|
||||
import LaurelSpinner from '@/components/ds/LaurelSpinner.vue'
|
||||
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
|
||||
import NychButton from '@/components/ds/NychButton.vue'
|
||||
import { useTypewriter } from '@/composables/useTypewriter'
|
||||
import { scrollToSection } from '@/composables/scrollToSection'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
|
||||
import type { ComponentProps } from '@/data/portfolio'
|
||||
|
||||
const { portfolioData } = defineProps<ComponentProps>()
|
||||
|
||||
let { displayed, done } = { displayed: ref(''), done: ref(false) }
|
||||
const visible = ref(true)
|
||||
|
||||
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
|
||||
|
||||
const onScroll = () => {
|
||||
visible.value = window.scrollY < window.innerHeight * 0.6
|
||||
}
|
||||
|
||||
watch(
|
||||
() => portfolioData,
|
||||
(data) => {
|
||||
if (data) {
|
||||
const typeWriter = useTypewriter(data.tagline)
|
||||
displayed = typeWriter.displayed
|
||||
done = typeWriter.done
|
||||
}
|
||||
},
|
||||
)
|
||||
const { displayed, done } = useTypewriter(portfolioData.tagline)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -41,22 +15,21 @@ watch(
|
|||
<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-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-role">{{ portfolioData.role }}</p>
|
||||
<p class="port-hero-tagline">
|
||||
{{ displayed
|
||||
}}<span class="port-hero-cursor" :class="{ 'port-hero-cursor--done': done }">|</span>
|
||||
{{ displayed }}<span class="port-hero-cursor" :class="{ 'port-hero-cursor--done': done }">|</span>
|
||||
</p>
|
||||
<div class="port-hero-actions">
|
||||
<Button @click="scrollToSection('work')">View Work</Button>
|
||||
<Button variant="secondary" @click="scrollToSection('contact')">Get in Touch</Button>
|
||||
<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" :class="{ 'port-hero-chevron--visible': visible }">
|
||||
<ChevronDown :size="18" style="color: var(--text-dim)" />
|
||||
<div class="port-hero-chevron">
|
||||
<i class="pi pi-chevron-down" style="font-size: 18px; color: var(--text-dim)" />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
|
@ -148,12 +121,7 @@ watch(
|
|||
position: absolute;
|
||||
bottom: 2rem;
|
||||
left: 50%;
|
||||
opacity: 0;
|
||||
opacity: 0.4;
|
||||
animation: bob 2.5s ease-in-out infinite;
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
.port-hero-chevron--visible {
|
||||
opacity: 0.4 !important;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,19 +1,18 @@
|
|||
<script setup lang="ts">
|
||||
import { Badge } from '@nychthemeron/library'
|
||||
|
||||
import LaurelDivider from '@/components/design/LaurelDivider.vue'
|
||||
import type { ComponentProps } from '@/data/portfolio'
|
||||
import NychTag from '@/components/ds/NychTag.vue'
|
||||
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
|
||||
const palettes = [
|
||||
{ key: 'Frontend', accent: 'var(--info)', variant: 'info' as const },
|
||||
{ key: 'Backend', accent: 'var(--success)', variant: 'success' as const },
|
||||
{ key: 'Tools', accent: 'var(--warn)', variant: 'warning' as const },
|
||||
{ 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: 48px">
|
||||
<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">
|
||||
|
|
@ -22,13 +21,9 @@ const { portfolioData } = defineProps<ComponentProps>()
|
|||
<div class="port-skills-body">
|
||||
<div class="port-skills-label" :style="{ color: palette.accent }">{{ palette.key }}</div>
|
||||
<div class="port-skills-tags">
|
||||
<Badge
|
||||
v-for="tag in portfolioData?.skills[palette.key] ?? []"
|
||||
:key="tag"
|
||||
:variant="palette.variant"
|
||||
>
|
||||
<NychTag v-for="tag in portfolioData.skills[palette.key] ?? []" :key="tag" :severity="palette.severity" rounded>
|
||||
{{ tag }}
|
||||
</Badge>
|
||||
</NychTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,21 +1,15 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
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>{{ portfolioData.name.toUpperCase() }}</span>
|
||||
<span>{{ year }}</span>
|
||||
<span>NYCHTHEMERON</span>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,13 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Moon, Sun } from 'lucide-vue-next'
|
||||
import { Button } from '@nychthemeron/library'
|
||||
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import type { Theme } from '@/composables/useTheme'
|
||||
import { scrollToSection } from '@/composables/scrollToSection'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
import laurelMark from '@/assets/brand/mark.svg'
|
||||
|
||||
import type { ComponentProps } from '@/data/portfolio'
|
||||
const props = defineProps<{ theme: Theme }>()
|
||||
defineEmits<{ toggle: [] }>()
|
||||
|
||||
const night = computed(() => props.theme === 'hades')
|
||||
const visible = ref(false)
|
||||
|
||||
function onScroll() {
|
||||
visible.value = window.scrollY > window.innerHeight * 0.6
|
||||
}
|
||||
|
||||
onMounted(() => window.addEventListener('scroll', onScroll, { passive: true }))
|
||||
onBeforeUnmount(() => window.removeEventListener('scroll', onScroll))
|
||||
|
||||
const links: Array<[string, string]> = [
|
||||
['About', 'about'],
|
||||
|
|
@ -15,17 +24,11 @@ const links: Array<[string, string]> = [
|
|||
['Contact', 'contact'],
|
||||
]
|
||||
|
||||
const props = defineProps<ComponentProps & { theme: Theme }>()
|
||||
|
||||
defineEmits<{ toggle: [] }>()
|
||||
|
||||
const night = computed(() => props.theme === 'hades')
|
||||
|
||||
const surname = computed(() => props.portfolioData?.name.toUpperCase())
|
||||
const surname = portfolioData.name.split(' ').pop()?.toUpperCase()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav class="port-nav port-nav--visible">
|
||||
<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>
|
||||
|
|
@ -41,11 +44,10 @@ const surname = computed(() => props.portfolioData?.name.toUpperCase())
|
|||
>{{ label }}</a
|
||||
>
|
||||
</span>
|
||||
<Button class="port-nav-toggle" @click="$emit('toggle')">
|
||||
<Moon v-if="night" :size="12" />
|
||||
<Sun v-else :size="12" />
|
||||
<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>
|
||||
</button>
|
||||
</div>
|
||||
</nav>
|
||||
</template>
|
||||
|
|
@ -71,7 +73,6 @@ const surname = computed(() => props.portfolioData?.name.toUpperCase())
|
|||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100vw;
|
||||
}
|
||||
|
||||
.port-nav--visible {
|
||||
|
|
@ -124,7 +125,7 @@ const surname = computed(() => props.portfolioData?.name.toUpperCase())
|
|||
padding: 5px 13px;
|
||||
background: var(--surface-2);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 30px;
|
||||
border-radius: var(--radius-full);
|
||||
cursor: pointer;
|
||||
color: var(--text-body);
|
||||
font-family: var(--font-sans);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
<script setup lang="ts">
|
||||
import { Badge, Card, CardHeader, CardTitle, CardContent, CardFooter } from '@nychthemeron/library'
|
||||
import { ExternalLink } from 'lucide-vue-next'
|
||||
|
||||
import LaurelDivider from '@/components/design/LaurelDivider.vue'
|
||||
import ImageSlot from '@/components/design/ImageSlot.vue'
|
||||
|
||||
import type { ComponentProps, Project } from '@/data/portfolio'
|
||||
import NychCard from '@/components/ds/NychCard.vue'
|
||||
import NychTag from '@/components/ds/NychTag.vue'
|
||||
import LaurelDivider from '@/components/ds/LaurelDivider.vue'
|
||||
import ImageSlot from '@/components/ds/ImageSlot.vue'
|
||||
import { portfolioData } from '@/data/portfolio'
|
||||
|
||||
const ACCENT: Record<string, string> = {
|
||||
primary: 'var(--primary)',
|
||||
|
|
@ -13,10 +11,6 @@ const ACCENT: Record<string, string> = {
|
|||
success: 'var(--success)',
|
||||
warn: 'var(--warn)',
|
||||
}
|
||||
|
||||
const badgeVariant = (color: Project['color']) => (color === 'warn' ? 'warning' : color)
|
||||
|
||||
const { portfolioData } = defineProps<ComponentProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -24,67 +18,39 @@ const { portfolioData } = defineProps<ComponentProps>()
|
|||
<LaurelDivider style="margin-bottom: 3.5rem" />
|
||||
<h2 class="port-title">Selected Work</h2>
|
||||
<div class="port-grid reveal">
|
||||
<Card
|
||||
v-for="project in portfolioData?.projects"
|
||||
<NychCard
|
||||
v-for="project in portfolioData.projects"
|
||||
:key="project.id"
|
||||
class="port-work-card"
|
||||
:style="{ borderTop: `3px solid ${ACCENT[project.color]}` }"
|
||||
>
|
||||
<CardHeader v-if="project.image" class="port-work-image">
|
||||
<ImageSlot
|
||||
:src="project.image"
|
||||
:alt="project.title"
|
||||
shape="rect"
|
||||
style="width: 100%; height: 200px"
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent class="port-work-content">
|
||||
<CardTitle class="port-work-title-row">
|
||||
<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 <ExternalLink :size="10" />
|
||||
<a :href="project.link" class="port-work-view" :style="{ color: ACCENT[project.color] }">
|
||||
View <i class="pi pi-external-link" style="font-size: 10px" />
|
||||
</a>
|
||||
</CardTitle>
|
||||
{{ project.description }}
|
||||
</CardContent>
|
||||
<CardFooter>
|
||||
<Badge v-for="tag in project.tags" :key="tag" :variant="badgeVariant(project.color)">{{
|
||||
tag
|
||||
}}</Badge>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
</div>
|
||||
</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-image {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.port-work-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.port-work-card :deep([data-slot='card-header']) {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.port-work-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.port-work-content {
|
||||
flex-grow: 1;
|
||||
}
|
||||
.port-work-view {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.78rem;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
export const scrollToSection = (id: string) => {
|
||||
export function scrollToSection(id: string) {
|
||||
const el = document.getElementById(id)
|
||||
if (!el) return
|
||||
const top = el.getBoundingClientRect().top + window.scrollY - 68
|
||||
|
|
|
|||
|
|
@ -1,28 +1,20 @@
|
|||
import { useUtils } from './useUtils'
|
||||
import type { PortfolioData } from '@/data/portfolio'
|
||||
import { useUtils } from "./useUtils";
|
||||
|
||||
export const useAPI = (baseURL = API_URL, key = API_KEY) => {
|
||||
const { url } = useUtils()
|
||||
export const useAPI = (baseURL = API_URL) => {
|
||||
const { url } = useUtils();
|
||||
|
||||
const normalizedURL = url.normalizePath(baseURL.trim())
|
||||
const normalizedURL = url.normalizePath(baseURL.trim());
|
||||
|
||||
const generateURL = (path: string) => {
|
||||
return normalizedURL + path
|
||||
}
|
||||
return normalizedURL + path;
|
||||
};
|
||||
|
||||
const getPortfolioData = async () =>
|
||||
fetch(generateURL('/keyvalue?key=portfolio'), {
|
||||
headers: {
|
||||
authorization: `Bearer ` + key.trim(),
|
||||
},
|
||||
})
|
||||
fetch(generateURL("/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)
|
||||
.catch(() => undefined);
|
||||
|
||||
return {
|
||||
getPortfolioData,
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { onMounted, onBeforeUnmount } from 'vue'
|
|||
/**
|
||||
* Adds the `visible` class to any `.reveal` element once it scrolls into view.
|
||||
*/
|
||||
export const useReveal = () => {
|
||||
export function useReveal() {
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
onMounted(() => {
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ const STORAGE_KEY = 'nych-theme'
|
|||
|
||||
const theme = ref<Theme>('hades')
|
||||
|
||||
const applyTheme = (value: Theme) => {
|
||||
function applyTheme(value: Theme) {
|
||||
document.documentElement.dataset.theme = value
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
export const useTheme = () => {
|
||||
export function useTheme() {
|
||||
if (!initialized) {
|
||||
initialized = true
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
|
|
@ -24,7 +24,7 @@ export const useTheme = () => {
|
|||
})
|
||||
}
|
||||
|
||||
const toggle = () => {
|
||||
function toggle() {
|
||||
theme.value = theme.value === 'hades' ? 'apollo' : 'hades'
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -4,14 +4,14 @@ import { computed, onBeforeUnmount, ref } from 'vue'
|
|||
* Types `text` out character by character, exposing the in-progress
|
||||
* substring and whether typing has finished (for the blinking cursor).
|
||||
*/
|
||||
export const useTypewriter = (text: string, delay = 28) => {
|
||||
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 = () => {
|
||||
function tick() {
|
||||
if (count.value >= text.length) return
|
||||
timer = setTimeout(() => {
|
||||
count.value += 1
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
export const useUtils = () => {
|
||||
const normalizePath = (path: string) => {
|
||||
return path.endsWith('/') ? path.slice(0, -1) : path
|
||||
}
|
||||
path.endsWith("/") ? path.slice(0, -1) : path;
|
||||
};
|
||||
|
||||
return {
|
||||
url: {
|
||||
normalizePath,
|
||||
},
|
||||
}
|
||||
}
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,36 +1,85 @@
|
|||
export interface Stat {
|
||||
value: string
|
||||
label: string
|
||||
color: string
|
||||
value: string;
|
||||
label: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
export interface Project {
|
||||
id: string
|
||||
title: string
|
||||
color: 'primary' | 'info' | 'success' | 'warn'
|
||||
description: string
|
||||
tags: string[]
|
||||
link: string
|
||||
image?: string
|
||||
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
|
||||
photo?: string
|
||||
stats: Stat[]
|
||||
projects: Project[]
|
||||
skills: Record<string, string[]>
|
||||
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
|
||||
}
|
||||
github: string;
|
||||
linkedin: string;
|
||||
email: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface ComponentProps {
|
||||
portfolioData?: PortfolioData
|
||||
}
|
||||
// Edit this block to make the portfolio yours.
|
||||
export const portfolioData: PortfolioData = {
|
||||
name: "Matthew McPeak",
|
||||
role: "Product Designer · Developer",
|
||||
tagline: "I craft digital experiences that feel inevitable — clear, classical, enduring.",
|
||||
status: "Open for opportunities · 2026",
|
||||
about:
|
||||
"Based in Athens. I work at the intersection of design and engineering, building systems and products that are precise, durable, and a pleasure to use. Ten years in, I still believe the best interface is the one that gets out of the way.",
|
||||
stats: [
|
||||
{ value: "10+", label: "Years of craft", color: "var(--primary)" },
|
||||
{ value: "40+", label: "Projects shipped", color: "var(--info)" },
|
||||
{ value: "12", label: "Satisfied patrons", color: "var(--success)" },
|
||||
],
|
||||
projects: [
|
||||
{
|
||||
id: "p1",
|
||||
title: "Nychthemeron",
|
||||
color: "primary",
|
||||
description:
|
||||
"A dual-theme design system — Hades (dark) and Apollo (light) — for classical, lasting interfaces.",
|
||||
tags: ["Design System", "Vue", "CSS"],
|
||||
link: "#",
|
||||
},
|
||||
{
|
||||
id: "p2",
|
||||
title: "The Oracle",
|
||||
color: "info",
|
||||
description:
|
||||
"A mythology-themed admin interface with consultations, petitioners, and prophecy tracking.",
|
||||
tags: ["UI Kit", "Dashboard", "TypeScript"],
|
||||
link: "#",
|
||||
},
|
||||
{
|
||||
id: "p3",
|
||||
title: "Agora",
|
||||
color: "success",
|
||||
description:
|
||||
"A real-time marketplace for artisans, connecting makers with patrons across the ancient world.",
|
||||
tags: ["Platform", "Fullstack", "Postgres"],
|
||||
link: "#",
|
||||
},
|
||||
],
|
||||
skills: {
|
||||
Design: ["Figma", "Design Systems", "Typography", "Motion"],
|
||||
Frontend: ["Vue", "CSS", "TypeScript", "Vite"],
|
||||
Backend: ["Node.js", "Postgres", "REST", "GraphQL"],
|
||||
Tools: ["Git", "Storybook", "Figma", "Linear"],
|
||||
},
|
||||
social: {
|
||||
github: "#",
|
||||
linkedin: "#",
|
||||
email: "hello@example.com",
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { createApp } from 'vue'
|
||||
import { createHead } from '@unhead/vue/client'
|
||||
import App from './App.vue'
|
||||
import '@nychthemeron/library/style'
|
||||
import './assets/styles/styles.css'
|
||||
|
||||
const app = createApp(App)
|
||||
app.use(createHead())
|
||||
app.mount('#app')
|
||||
createApp(App).mount('#app')
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
{
|
||||
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||
"include": [
|
||||
"env.d.ts",
|
||||
"global.d.ts",
|
||||
"src/**/*",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"include": ["vite-env.d.ts", "env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||
"exclude": ["src/**/__tests__/*"],
|
||||
"compilerOptions": {
|
||||
// Extra safety for array and object lookups, but may have false positives.
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@
|
|||
// .vue files must be explicitly included so vue-tsc enables .vue module
|
||||
// resolution for this project (relying on transitive module resolution
|
||||
// from test imports is unreliable in `vue-tsc --build`).
|
||||
"include": ["src/**/__tests__/*", "env.d.ts", "global.d.ts", "src/**/*.vue"],
|
||||
"include": ["src/**/__tests__/*", "env.d.ts", "src/**/*.vue"],
|
||||
"exclude": [],
|
||||
|
||||
"compilerOptions": {
|
||||
|
|
|
|||
13
vite-env.d.ts
vendored
Normal file
13
vite-env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
interface ViteTypeOptions {
|
||||
strictImportMetaEnv: unknown;
|
||||
}
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_API_URL: string;
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv;
|
||||
}
|
||||
|
||||
declare const API_URL: string;
|
||||
|
|
@ -3,39 +3,22 @@ import { defineConfig, loadEnv } from "vite";
|
|||
|
||||
import vue from "@vitejs/plugin-vue";
|
||||
import vueDevTools from "vite-plugin-vue-devtools";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig(({ mode, command }) => {
|
||||
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 ?? ""),
|
||||
API_URL: JSON.stringify(env.VITE_API_URL),
|
||||
},
|
||||
plugins: [vue(), ...(command === "serve" ? [vueDevTools()] : []), tailwindcss()],
|
||||
plugins: [vue(), vueDevTools()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: "@", replacement: baseURL },
|
||||
{ find: "~", replacement: baseURL },
|
||||
// @nychthemeron/library's package.json declares a "development"
|
||||
// export condition pointing at "./src/index.ts", but that file
|
||||
// isn't included in the published package (only src/assets and
|
||||
// src/components are) — Vite's dev server picks that condition and
|
||||
// fails to resolve the bare import. Force just the bare specifier
|
||||
// (not the /style or /components subpaths, which resolve fine) to
|
||||
// the same pre-built dist bundle the "import" condition already
|
||||
// uses for production builds. Anchored regex so it doesn't also
|
||||
// match (and break) those subpaths via prefix matching.
|
||||
{
|
||||
find: /^@nychthemeron\/library$/,
|
||||
replacement: fileURLToPath(
|
||||
new URL("./node_modules/@nychthemeron/library/dist/index.js", import.meta.url),
|
||||
),
|
||||
},
|
||||
],
|
||||
alias: {
|
||||
"@": baseURL,
|
||||
"~": baseURL,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue