refactor: shadCN
All checks were successful
ci / build (push) Successful in 17s
ci / publish (push) Successful in 14s

This commit is contained in:
Matthew McPeak 2026-07-15 21:39:25 -04:00
parent e24321f79a
commit dad9501de9
Signed by: McPeakML
GPG key ID: 3D64A2E70F58D07C
22 changed files with 708 additions and 1295 deletions

584
bun.lock

File diff suppressed because it is too large Load diff

4
env.d.ts vendored
View file

@ -1 +1,5 @@
/// <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'

View file

@ -23,6 +23,7 @@
},
"devDependencies": {
"@playwright/test": "^1.59.1",
"@tailwindcss/vite": "^4.3.2",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^25.9.3",
@ -41,6 +42,7 @@
"npm-run-all2": "^8.0.4",
"oxfmt": "^0.45.0",
"oxlint": "~1.60.0",
"tailwindcss": "^4.3.2",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",

View file

@ -2,6 +2,11 @@
portfolio.css layout & motion for the Nychthemeron
portfolio template
============================================================ */
/* PrimeIcons used directly by portfolio markup (nav, social links,
chevron); not part of @nychthemeron/library's own component classes. */
@import url('https://cdn.jsdelivr.net/npm/primeicons/primeicons.css');
*,
*::before,
*::after {
@ -110,13 +115,13 @@ body {
}
/* Card hover lift + gold edge */
.nych-card {
.port-work-card {
transition:
transform 0.25s ease,
box-shadow 0.25s ease,
border-color 0.25s ease;
}
.nych-card:hover {
.port-work-card:hover {
transform: translateY(-4px);
box-shadow:
0 14px 36px rgba(0, 0, 0, 0.32),

View file

@ -8,10 +8,7 @@
<html data-theme="hades"> or "apollo"; Hades is the default.
============================================================ */
@import './tokens/fonts.css';
@import './tokens/colors.css';
@import './tokens/typography.css';
@import './tokens/spacing.css';
@import './tokens/base.css';
@import './tokens/components.css';
@import './tokens/laurel-divider.css';
@import './portfolio.css';

View file

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

View file

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

View file

@ -1,787 +0,0 @@
/* ============================================================
components.css Nychthemeron component skins
Styles every `nych-*` primitive. Components render the markup;
this file gives them their bronze-and-marble skin.
============================================================ */
/* ============================================================
Button the signature glowing control
============================================================ */
[class^='nych-button'] {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 7px 14px;
border-radius: var(--radius-md);
box-shadow: var(--glow-md);
border: none;
cursor: pointer;
transition: box-shadow 0.3s ease;
}
[class^='nych-button'] > span {
display: inline-flex;
align-items: center;
gap: 5px;
line-height: 1;
}
/* Vertically center text inside buttons covers nested text wrappers
(e.g. the direct-edit .__om-t span) that also receive height:25px */
[class^='nych-button'] > span span {
display: inline-flex !important;
align-items: center !important;
}
/* Ensure PrimeIcon <i> elements sit at the correct baseline in buttons */
[class^='nych-button'] i {
line-height: 1;
flex-shrink: 0;
display: inline-flex;
align-items: center;
}
[class^='nych-button']:hover:not(:disabled) {
box-shadow: var(--glow-sm);
}
[class^='nych-button'][data-p~='loading'] {
display: inline-flex;
}
[class^='nych-button'][data-p~='loading'] span {
margin-top: 0;
}
/* severity skins — apply to any nych- element with matching suffix */
[class^='nych-'][class$='-primary'] {
background-color: var(--primary);
color: var(--primary-fg);
}
[class^='nych-'][class$='-secondary'],
[class^='nych-'][class$='-muted'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
[class^='nych-'][class$='-info'] {
background-color: var(--info);
color: var(--info-fg);
}
[class^='nych-'][class$='-success'] {
background-color: var(--success);
color: var(--success-fg);
}
[class^='nych-'][class$='-warning'] {
background-color: var(--warn);
color: var(--warn-fg);
}
[class^='nych-'][class$='-danger'],
[class^='nych-'][class$='-error'] {
background-color: var(--danger);
color: var(--danger-fg);
}
button[data-p~='loading'] > .nych-loading-icon {
margin-right: 6px;
width: 20px;
height: 20px;
}
/* size scale */
[class^='nych-'][data-p~='small'] :not(svg, svg *) {
height: 15px !important;
font-size: 8pt !important;
}
[class^='nych-'][data-p~='normal'] :not(svg, svg *) {
height: 25px !important;
font-size: 12pt !important;
}
[class^='nych-'][data-p~='large'] :not(svg, svg *) {
height: 40px !important;
font-size: 20pt !important;
}
/* ============================================================
Text inputs InputText, Textarea
============================================================ */
.nych-input,
.nych-textarea {
font-family: var(--font-sans);
width: 100%;
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-input::placeholder,
.nych-textarea::placeholder {
color: var(--text-dim);
}
.nych-input:hover:not(:disabled),
.nych-textarea:hover:not(:disabled) {
border-color: var(--border-hi);
}
.nych-input:focus,
.nych-textarea:focus {
outline: none;
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-input[data-p~='invalid'],
.nych-textarea[data-p~='invalid'] {
border-color: var(--danger);
}
.nych-input[data-p~='small'] {
padding: 5px 8px;
font-size: 0.875rem;
}
.nych-input[data-p~='large'] {
padding: 11px 13px;
font-size: 1.125rem;
}
.nych-textarea {
resize: vertical;
min-height: 84px;
line-height: 1.5;
}
/* ============================================================
Checkbox + Radio
============================================================ */
.nych-checkbox,
.nych-radio {
position: relative;
display: inline-flex;
width: 20px;
height: 20px;
vertical-align: middle;
}
.nych-checkbox-input,
.nych-radio-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-checkbox-box,
.nych-radio-box {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
background-color: var(--surface-1);
border: 1px solid var(--border);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-checkbox-box {
border-radius: var(--radius-sm);
}
.nych-radio-box {
border-radius: 50%;
}
.nych-checkbox:hover .nych-checkbox-box,
.nych-radio:hover .nych-radio-box {
border-color: var(--border-hi);
}
.nych-checkbox[data-p-checked='true'] .nych-checkbox-box {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-checkbox-icon {
width: 13px;
height: 13px;
color: var(--primary-fg);
fill: var(--primary-fg);
stroke: var(--primary-fg);
}
.nych-checkbox[data-p-disabled='true'] .nych-checkbox-box,
.nych-radio[data-p-disabled='true'] .nych-radio-box {
opacity: 0.5;
}
.nych-checkbox-group,
.nych-radio-group {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.nych-checkbox-input:focus-visible + .nych-checkbox-box,
.nych-radio-input:focus-visible + .nych-radio-box {
box-shadow: var(--focus-ring);
}
.nych-checkbox .nych-checkbox-input,
.nych-radio .nych-radio-input,
.nych-toggleswitch .nych-toggleswitch-input {
opacity: 0;
}
.nych-radio-icon {
width: 9px;
height: 9px;
border-radius: 50%;
background-color: transparent;
transition: background-color 0.2s ease;
}
.nych-radio[data-p-checked='true'] .nych-radio-box {
border-color: var(--primary);
}
.nych-radio[data-p-checked='true'] .nych-radio-icon {
background-color: var(--primary);
}
/* ============================================================
Toggle switch
============================================================ */
.nych-toggleswitch {
position: relative;
display: inline-block;
width: 44px;
height: 24px;
vertical-align: middle;
}
.nych-toggleswitch-input {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
margin: 0;
opacity: 0;
cursor: pointer;
z-index: 1;
}
.nych-toggleswitch-slider {
position: absolute;
inset: 0;
background-color: var(--track);
border: 1px solid var(--border);
border-radius: var(--radius-full);
transition:
background-color 0.2s ease,
border-color 0.2s ease;
}
.nych-toggleswitch-handle {
position: absolute;
top: 50%;
left: 3px;
width: 16px;
height: 16px;
transform: translateY(-50%);
background-color: var(--text-muted);
border-radius: 50%;
transition:
left 0.2s ease,
background-color 0.2s ease;
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-slider {
background-color: var(--primary);
border-color: var(--primary);
}
.nych-toggleswitch[data-p-checked='true'] .nych-toggleswitch-handle {
left: 25px;
background-color: var(--primary-fg);
}
.nych-toggleswitch-input:focus-visible + .nych-toggleswitch-slider {
box-shadow: var(--focus-ring);
}
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-slider,
.nych-toggleswitch[data-p-disabled='true'] .nych-toggleswitch-handle {
opacity: 0.5;
}
/* ============================================================
Select
============================================================ */
.nych-select {
display: inline-flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-width: 13rem;
font-family: var(--font-sans);
padding: 8px 11px;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-md);
cursor: pointer;
transition:
border-color 0.2s ease,
box-shadow 0.2s ease;
}
.nych-select:hover {
border-color: var(--border-hi);
}
.nych-select[data-p~='focus'] {
border-color: var(--primary);
box-shadow: var(--focus-ring);
}
.nych-select[data-p~='disabled'] {
cursor: not-allowed;
opacity: 0.5;
}
.nych-select-label {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.nych-select-label[data-p~='placeholder'] {
color: var(--text-dim);
}
.nych-select-dropdown {
display: flex;
align-items: center;
color: var(--text-muted);
}
.nych-select-dropdownIcon {
width: 14px;
height: 14px;
}
.nych-select-overlay {
font-family: var(--font-sans);
margin-top: 4px;
background-color: var(--surface-2);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-overlay);
overflow: hidden;
}
.nych-select-list {
list-style: none;
margin: 0;
padding: 4px;
max-height: 14rem;
overflow-y: auto;
}
.nych-select-option {
padding: 8px 11px;
border-radius: var(--radius-sm);
color: var(--text-body);
cursor: pointer;
transition: background-color 0.15s ease;
}
.nych-select-option[data-p-focused='true'],
.nych-select-option:hover {
background-color: var(--surface-3);
}
.nych-select-option[data-p-selected='true'] {
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-select-emptyMessage {
padding: 8px 11px;
color: var(--text-dim);
}
/* ============================================================
Card
============================================================ */
.nych-card {
display: flex;
flex-direction: column;
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
overflow: hidden;
color: var(--text-body);
}
.nych-card-header :where(img) {
display: block;
width: 100%;
}
.nych-card-body {
display: flex;
flex-direction: column;
gap: 12px;
padding: 20px;
}
.nych-card-caption {
display: flex;
flex-direction: column;
gap: 4px;
}
.nych-card-title {
font-family: var(--font-serif);
font-size: 1.4rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-card-subtitle {
font-family: var(--font-sans);
font-size: 0.95rem;
color: var(--text-muted);
}
.nych-card-content {
font-family: var(--font-sans);
line-height: 1.6;
}
.nych-card-footer {
display: flex;
gap: 8px;
padding-top: 4px;
}
/* ============================================================
Message
============================================================ */
.nych-message {
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-left: 3px solid var(--border);
border-radius: var(--radius-lg);
}
.nych-message-content {
display: flex;
align-items: center;
gap: 10px;
padding: 11px 14px;
}
.nych-message-icon {
display: flex;
flex-shrink: 0;
width: 18px;
height: 18px;
}
.nych-message-text {
font-family: var(--font-sans);
line-height: 1.4;
}
.nych-message-closeButton {
display: flex;
align-items: center;
justify-content: center;
margin-left: auto;
width: 22px;
height: 22px;
padding: 0;
color: inherit;
background: transparent;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.7;
transition:
opacity 0.2s ease,
background-color 0.2s ease;
}
.nych-message-closeButton:hover {
opacity: 1;
background-color: rgba(128, 128, 128, 0.18);
}
.nych-message-closeIcon {
width: 12px;
height: 12px;
}
.nych-message[data-p~='info'] {
background-color: color-mix(in srgb, var(--info) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--info) 30%, var(--border));
border-left-color: var(--info);
}
.nych-message[data-p~='info'] .nych-message-icon {
color: var(--info);
}
.nych-message[data-p~='success'] {
background-color: color-mix(in srgb, var(--success) 15%, var(--surface-1));
border-color: color-mix(in srgb, var(--success) 30%, var(--border));
border-left-color: var(--success);
}
.nych-message[data-p~='success'] .nych-message-icon {
color: var(--success);
}
.nych-message[data-p~='warn'] {
background-color: color-mix(in srgb, var(--warn) 16%, var(--surface-1));
border-color: color-mix(in srgb, var(--warn) 32%, var(--border));
border-left-color: var(--warn);
}
.nych-message[data-p~='warn'] .nych-message-icon {
color: var(--warn);
}
.nych-message[data-p~='error'] {
background-color: color-mix(in srgb, var(--danger) 20%, var(--surface-1));
border-color: color-mix(in srgb, var(--danger) 38%, var(--border));
border-left-color: var(--danger);
}
.nych-message[data-p~='error'] .nych-message-icon {
color: var(--danger);
}
.nych-message[data-p~='secondary'] {
background-color: var(--surface-2);
border-left-color: var(--neutral);
}
/* ============================================================
Tag
============================================================ */
.nych-tag {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 8px;
font-size: 0.8rem;
font-weight: 600;
letter-spacing: 0.03em;
line-height: 1;
border-radius: var(--radius-sm);
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-tag[data-p~='rounded'] {
border-radius: var(--radius-full);
}
.nych-tag-icon {
width: 12px;
height: 12px;
}
.nych-tag[data-p~='secondary'] {
background-color: var(--neutral);
color: var(--neutral-fg);
}
.nych-tag[data-p~='info'] {
background-color: var(--info);
color: var(--info-fg);
}
.nych-tag[data-p~='success'] {
background-color: var(--success);
color: var(--success-fg);
}
.nych-tag[data-p~='warn'] {
background-color: var(--warn);
color: var(--warn-fg);
}
.nych-tag[data-p~='danger'] {
background-color: var(--danger);
color: var(--danger-fg);
}
.nych-tag[data-p~='contrast'] {
background-color: var(--text-high);
color: var(--surface-0);
}
/* ============================================================
Dialog
============================================================ */
.nych-dialog-mask {
position: fixed;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 1rem;
background-color: rgba(0, 0, 0, 0.55);
}
.nych-dialog {
display: flex;
flex-direction: column;
max-width: 92vw;
max-height: 90vh;
color: var(--text-body);
background-color: var(--surface-1);
border: 1px solid var(--border);
border-radius: var(--radius-2xl);
box-shadow: var(--shadow-dialog);
overflow: hidden;
}
.nych-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-lo);
}
.nych-dialog-title {
font-family: var(--font-serif);
font-size: 1.3rem;
letter-spacing: 0.02em;
color: var(--text-high);
}
.nych-dialog-headerActions {
display: flex;
align-items: center;
gap: 4px;
}
.nych-dialog-headerActions button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
color: var(--text-muted);
background: transparent;
border: none;
border-radius: var(--radius-lg);
cursor: pointer;
transition:
background-color 0.2s ease,
color 0.2s ease;
}
.nych-dialog-headerActions button:hover {
color: var(--text-high);
background-color: var(--surface-3);
}
.nych-dialog-headerActions button svg {
width: 14px;
height: 14px;
}
.nych-dialog-content {
font-family: var(--font-sans);
padding: 20px;
line-height: 1.6;
overflow-y: auto;
}
.nych-dialog-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 16px 20px;
border-top: 1px solid var(--border-lo);
}
/* ============================================================
LaurelDivider
============================================================ */
/* Horizontal (default): full-width running laurel SVG */
.nych-divider {
display: block;
width: 100%;
color: var(--primary);
}
/* Label variant: flanking thin lines + centered text */
.nych-divider[data-p~='label'] {
display: flex;
align-items: center;
gap: 12px;
}
.nych-divider[data-p~='label']::before,
.nych-divider[data-p~='label']::after {
content: '';
flex: 1;
height: 1px;
background: var(--border);
}
.nych-divider-label {
font-family: var(--font-serif);
font-size: 0.72rem;
letter-spacing: 0.16em;
text-transform: uppercase;
color: var(--text-dim);
white-space: nowrap;
}
/* Vertical variant */
.nych-divider--vertical {
display: flex;
flex-direction: column;
align-items: center;
height: 100%;
width: auto;
gap: 10px;
color: var(--primary);
}
.nych-divider--vertical::before,
.nych-divider--vertical::after {
content: '';
width: 1px;
flex: 1;
background: var(--border);
}

View file

@ -1,12 +0,0 @@
/* ============================================================
fonts.css Nychthemeron webfonts
Loaded from Google Fonts CDN. If you need self-hosted binaries,
drop the .woff2 files in assets/fonts/ and replace this @import
with @font-face rules pointing at them.
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap');
/* PrimeIcons the standard icon set for Nychthemeron.
Usage: <i class="pi pi-{name}"></i> (or <i className="pi pi-{name}" /> in JSX)
Full catalogue: https://primereact.org/icons/ */
@import url('https://cdn.jsdelivr.net/npm/primeicons/primeicons.css');

View file

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

View file

@ -1,20 +1,5 @@
<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)`,
}
})
import { LoadingIcon } from '@nychthemeron/library'
withDefaults(
defineProps<{
@ -30,28 +15,23 @@ withDefaults(
</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>
<LoadingIcon
class="laurel-spinner"
:class="{ 'laurel-spinner--static': !spinning }"
:style="{ width: `${size}px`, height: `${size}px` }"
/>
</template>
<style scoped>
.laurel-spinner--static :deep(.wreath) {
animation: none;
}
.laurel-spinner :deep(.wreath path) {
fill: v-bind(color);
}
.laurel-spinner :deep(.wreath line) {
stroke: v-bind(color);
}
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { ref, watch, onMounted, onBeforeUnmount } from "vue";
import LaurelSpinner from "@/components/design/LaurelSpinner.vue";
import LaurelDivider from "@/components/design/LaurelDivider.vue";
import NychButton from "@/components/design/NychButton.vue";
import { Button } from "@nychthemeron/library";
import { useTypewriter } from "@/composables/useTypewriter";
import { scrollToSection } from "@/composables/scrollToSection";
import type { ComponentProps } from "@/data/portfolio";
@ -47,10 +47,8 @@ watch(
}}<span class="port-hero-cursor" :class="{ 'port-hero-cursor--done': done }">|</span>
</p>
<div class="port-hero-actions">
<NychButton @click="scrollToSection('work')">View Work</NychButton>
<NychButton severity="secondary" @click="scrollToSection('contact')"
>Get in Touch</NychButton
>
<Button @click="scrollToSection('work')">View Work</Button>
<Button variant="secondary" @click="scrollToSection('contact')">Get in Touch</Button>
</div>
<LaurelDivider />
</div>

View file

@ -1,12 +1,12 @@
<script setup lang="ts">
import NychTag from "@/components/design/NychTag.vue";
import { Badge } from "@nychthemeron/library";
import LaurelDivider from "@/components/design/LaurelDivider.vue";
import type { ComponentProps } from "@/data/portfolio";
const palettes = [
{ key: "Frontend", accent: "var(--info)", severity: "info" as const },
{ key: "Backend", accent: "var(--success)", severity: "success" as const },
{ key: "Tools", accent: "var(--warn)", severity: "warn" as const },
{ key: "Frontend", accent: "var(--info)", variant: "info" as const },
{ key: "Backend", accent: "var(--success)", variant: "success" as const },
{ key: "Tools", accent: "var(--warn)", variant: "warning" as const },
];
const { portfolioData } = defineProps<ComponentProps>();
</script>
@ -21,14 +21,13 @@ 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">
<NychTag
<Badge
v-for="tag in portfolioData?.skills[palette.key] ?? []"
:key="tag"
:severity="palette.severity"
rounded
:variant="palette.variant"
>
{{ tag }}
</NychTag>
</Badge>
</div>
</div>
</div>

View file

@ -1,10 +1,9 @@
<script setup lang="ts">
import NychCard from "@/components/design/NychCard.vue";
import NychTag from "@/components/design/NychTag.vue";
import { Badge, Card, CardHeader, CardTitle, CardContent, CardFooter } from "@nychthemeron/library";
import LaurelDivider from "@/components/design/LaurelDivider.vue";
import ImageSlot from "@/components/design/ImageSlot.vue";
import type { ComponentProps } from "@/data/portfolio";
import type { ComponentProps, Project } from "@/data/portfolio";
const ACCENT: Record<string, string> = {
primary: "var(--primary)",
@ -13,6 +12,8 @@ const ACCENT: Record<string, string> = {
warn: "var(--warn)",
};
const badgeVariant = (color: Project["color"]) => (color === "warn" ? "warning" : color);
const { portfolioData } = defineProps<ComponentProps>();
</script>
@ -21,22 +22,22 @@ const { portfolioData } = defineProps<ComponentProps>();
<LaurelDivider style="margin-bottom: 3.5rem" />
<h2 class="port-title">Selected Work</h2>
<div class="port-grid reveal">
<NychCard
<Card
v-for="project in portfolioData?.projects"
:key="project.id"
class="port-work-card"
:style="{ borderTop: `3px solid ${ACCENT[project.color]}` }"
>
<template #image>
<CardHeader v-if="project.image" class="port-work-image">
<ImageSlot
v-if="project.image"
:src="project.image"
:alt="project.title"
shape="rect"
style="width: 100%; height: 200px"
/>
</template>
<template #title>
<div class="port-work-title-row">
</CardHeader>
<CardContent>
<CardTitle class="port-work-title-row">
<span :style="{ color: ACCENT[project.color] }">{{ project.title }}</span>
<a
:href="project.link"
@ -45,20 +46,28 @@ const { portfolioData } = defineProps<ComponentProps>();
>
View <i class="pi pi-external-link" style="font-size: 10px" />
</a>
</div>
</template>
{{ project.description }}
<template #footer>
<NychTag v-for="tag in project.tags" :key="tag" :severity="project.color">{{
</CardTitle>
{{ project.description }}
</CardContent>
<CardFooter>
<Badge v-for="tag in project.tags" :key="tag" :variant="badgeVariant(project.color)">{{
tag
}}</NychTag>
</template>
</NychCard>
}}</Badge>
</CardFooter>
</Card>
</div>
</section>
</template>
<style scoped>
.port-work-image {
padding: 0;
}
.port-work-card :deep([data-slot="card-header"]) {
padding: 0;
}
.port-work-title-row {
display: flex;
align-items: center;

View file

@ -1,6 +1,7 @@
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)

View file

@ -3,6 +3,7 @@ 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 }) => {
@ -14,12 +15,27 @@ export default defineConfig(({ mode }) => {
API_URL: JSON.stringify(env.VITE_API_URL ?? ""),
API_KEY: JSON.stringify(env.VITE_API_KEY ?? ""),
},
plugins: [vue(), vueDevTools()],
plugins: [vue(), vueDevTools(), tailwindcss()],
resolve: {
alias: {
"@": baseURL,
"~": baseURL,
},
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),
),
},
],
},
};
});