Merge branch 'mcpeakml/portfolio'
Some checks failed
ci / build (push) Failing after 8s
ci / publish (push) Has been skipped

This commit is contained in:
Matthew McPeak 2026-06-17 16:22:45 -04:00
commit cb3bb16eab
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
16 changed files with 323 additions and 81 deletions

1
.env Normal file
View file

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

View file

@ -8,14 +8,11 @@ on:
jobs: jobs:
build: build:
runs-on: ubuntu-latest runs-on: self-hosted
container: oven/bun:1 container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
outputs: outputs:
artifact_name: ${{ steps.meta.outputs.name }} artifact_name: ${{ steps.meta.outputs.name }}
steps: steps:
- name: Install git and node
run: apt-get update && apt-get install -y git nodejs
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -30,7 +27,13 @@ jobs:
echo "name=portfolio_${tag}" >> "$GITHUB_OUTPUT" echo "name=portfolio_${tag}" >> "$GITHUB_OUTPUT"
- name: Install dependencies - name: Install dependencies
run: bun install --frozen-lockfile run: |
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:
BUN_AUTH_TOKEN: ${{ secrets.BUN_AUTH_TOKEN }}
- name: Audit dependencies - name: Audit dependencies
run: bun audit || true run: bun audit || true
@ -53,8 +56,8 @@ jobs:
publish: publish:
needs: build needs: build
if: startsWith(github.ref, 'refs/tags/v') if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest runs-on: self-hosted
container: catthehacker/ubuntu:act-latest container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4

View file

@ -5,13 +5,14 @@
"": { "": {
"name": "portfolio", "name": "portfolio",
"dependencies": { "dependencies": {
"@nychthemeron/library": "^0.0.1",
"vue": "^3.5.32", "vue": "^3.5.32",
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2", "@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/eslint-plugin": "^1.6.16", "@vitest/eslint-plugin": "^1.6.16",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",
@ -170,6 +171,8 @@
"@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="],
"@nychthemeron/library": ["@nychthemeron/library@0.0.1", "https://git.mcpeakdev.com/api/packages/McPeakDev/npm/%40nychthemeron%2Flibrary/-/0.0.1/library-0.0.1.tgz", { "peerDependencies": { "primevue": "^4.5.0", "vue": "^3.5.0" } }, "sha512-84pcTnF8Ead8D7TSLhKTS8ACpaOCTS0mylzkWqgGrNs4fnrH9tEJg874nFHegiFy5P1fuKCk4B7Yt/Pjc4NK/Q=="],
"@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="], "@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="],
"@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="], "@oxc-project/types": ["@oxc-project/types@0.133.0", "", {}, "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA=="],
@ -256,6 +259,16 @@
"@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="], "@polka/url": ["@polka/url@1.0.0-next.29", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
"@primeuix/styled": ["@primeuix/styled@0.7.4", "", { "dependencies": { "@primeuix/utils": "^0.6.1" } }, "sha512-QSO/NpOQg8e9BONWRBx9y8VGMCMYz0J/uKfNJEya/RGEu7ARx0oYW0ugI1N3/KB1AAvyGxzKBzGImbwg0KUiOQ=="],
"@primeuix/styles": ["@primeuix/styles@2.0.3", "", { "dependencies": { "@primeuix/styled": "^0.7.4" } }, "sha512-2ykAB6BaHzR/6TwF8ShpJTsZrid6cVIEBVlookSdvOdmlWuevGu5vWOScgIwqWwlZcvkFYAGR/SUV3OHCTBMdw=="],
"@primeuix/utils": ["@primeuix/utils@0.6.4", "", {}, "sha512-pZ5f+vj7wSzRhC7KoEQRU5fvYAe+RP9+m39CTscZ3UywCD1Y2o6Fe1rRgklMPSkzUcty2jzkA0zMYkiJBD1hgg=="],
"@primevue/core": ["@primevue/core@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/utils": "^0.6.2" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-JpkXhq1ddc70JdsC3CC4dM+UbeeWuCW/8DpS9dNBfrOk824TLSlRlMEGFyVKqRMn5WPQvYLiy3xXfLQeNdSqhQ=="],
"@primevue/icons": ["@primevue/icons@4.5.5", "", { "dependencies": { "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5" } }, "sha512-eteOhTdAOXEYE9qW1AOrBBgDxQ2szHJxSkEK1XVdV2TKxGM5FQf03Ovms0VDyZTc16XBIgvwYjXJQS0BPbhPaA=="],
"@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="], "@rolldown/binding-android-arm64": ["@rolldown/binding-android-arm64@1.0.3", "", { "os": "android", "cpu": "arm64" }, "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw=="],
"@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="], "@rolldown/binding-darwin-arm64": ["@rolldown/binding-darwin-arm64@1.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA=="],
@ -306,7 +319,7 @@
"@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="],
"@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="], "@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="],
@ -720,6 +733,8 @@
"prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="],
"primevue": ["primevue@4.5.5", "", { "dependencies": { "@primeuix/styled": "^0.7.4", "@primeuix/styles": "^2.0.3", "@primeuix/utils": "^0.6.2", "@primevue/core": "4.5.5", "@primevue/icons": "4.5.5" } }, "sha512-Kv5REIewCdP806QaoU+4nBXfmpzOGFKkZ9qH4KsL6MjiAQVc4PUzypt8erl4r3Vzh3nr3aWZIxkxYRRsLGiX2A=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="], "proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
"punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="],
@ -876,7 +891,9 @@
"@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
"@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="], "@types/jsdom/@types/node": ["@types/node@24.13.1", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-RSpUJGmvsJ1ZeBehQZFhIdpsz+bIpES0nIQXko4Ybq+N+kX6XvOq3Jo+iJ82FWLdblFq85AsMikd3m35jgezYg=="],
"@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
@ -918,6 +935,8 @@
"wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@types/jsdom/@types/node/undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
"cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], "cross-spawn/which/isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],

1
bunfig.toml Normal file
View file

@ -0,0 +1 @@
env = false

View file

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

View file

@ -17,13 +17,14 @@
"format": "oxfmt src/" "format": "oxfmt src/"
}, },
"dependencies": { "dependencies": {
"@nychthemeron/library": "^0.0.1",
"vue": "^3.5.32" "vue": "^3.5.32"
}, },
"devDependencies": { "devDependencies": {
"@playwright/test": "^1.59.1", "@playwright/test": "^1.59.1",
"@tsconfig/node24": "^24.0.4", "@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1", "@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2", "@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.6", "@vitejs/plugin-vue": "^6.0.6",
"@vitest/eslint-plugin": "^1.6.16", "@vitest/eslint-plugin": "^1.6.16",
"@vue/eslint-config-typescript": "^14.7.0", "@vue/eslint-config-typescript": "^14.7.0",

1
sample.env Normal file
View file

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

View file

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

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

@ -0,0 +1,20 @@
import { useUtils } from "./useUtils";
export const useAPI = (baseURL = API_URL) => {
const { url } = useUtils();
const normalizedURL = url.normalizePath(baseURL.trim());
const generateURL = (path: string) => {
return normalizedURL + path;
};
const getPortfolioData = async () =>
fetch(generateURL("/portfolio/"))
.then((resp) => resp.json())
.catch(() => undefined);
return {
getPortfolioData,
};
};

View file

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

View file

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

View file

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

View file

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

13
vite-env.d.ts vendored Normal file
View 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;

View file

@ -1,18 +1,24 @@
import { fileURLToPath, URL } from 'node:url' import { fileURLToPath, URL } from "node:url";
import { defineConfig, loadEnv } from "vite";
import { defineConfig } from 'vite' import vue from "@vitejs/plugin-vue";
import vue from '@vitejs/plugin-vue' import vueDevTools from "vite-plugin-vue-devtools";
import vueDevTools from 'vite-plugin-vue-devtools'
// https://vite.dev/config/ // https://vite.dev/config/
export default defineConfig({ export default defineConfig(({ mode }) => {
plugins: [ const env = loadEnv(mode, process.cwd(), "VITE");
vue(), const baseURL = fileURLToPath(new URL("./src", import.meta.url));
vueDevTools(),
], return {
resolve: { define: {
alias: { API_URL: JSON.stringify(env.VITE_API_URL),
'@': fileURLToPath(new URL('./src', import.meta.url))
}, },
}, plugins: [vue(), vueDevTools()],
}) resolve: {
alias: {
"@": baseURL,
"~": baseURL,
},
},
};
});

View file

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