Inital Commit
All checks were successful
ci / build (push) Successful in 19s
ci / publish (push) Successful in 11s
ci / publish-docs (push) Successful in 41s

This commit is contained in:
Matthew McPeak 2026-06-14 14:50:44 -04:00
commit 8751c150cd
54 changed files with 7108 additions and 0 deletions

13
.dockerignore Normal file
View file

@ -0,0 +1,13 @@
node_modules
**/node_modules
**/dist
**/storybook-static
**/.storybook-static
.git
docs
.tmp
.bun-cache
.pw-browsers
.pw-libs
.shots
*.log

8
.editorconfig Normal file
View file

@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

112
.forgejo/workflows/ci.yml Normal file
View file

@ -0,0 +1,112 @@
name: ci
on:
push:
tags:
- 'v*'
pull_request:
jobs:
build:
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
outputs:
artifact_name: ${{ steps.meta.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set artifact name
id: meta
run: |
if [[ "${{ github.ref_type }}" == "tag" ]]; then
tag="${GITHUB_REF_NAME#v}"
else
tag="$(date -u +%Y%m%d)"
fi
echo "name=nychthemeron_${tag}" >> "$GITHUB_OUTPUT"
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Audit dependencies
run: bun audit || true
- name: Run unit tests
run: bun run test:unit
- name: Type check
run: bun run type-check -- --force
- name: Build
run: bun run build
- name: Upload dist
uses: https://code.forgejo.org/forgejo/upload-artifact@v4
with:
name: ${{ steps.meta.outputs.name }}.zip
path: packages/library/dist
publish:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/bun-ci:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install dependencies
run: bun install --frozen-lockfile
- name: Download Artifacts
uses: https://code.forgejo.org/forgejo/download-artifact@v4
with:
name: ${{ needs.build.outputs.artifact_name }}.zip
path: packages/library/dist
- name: Set package version
run: bun pm version "${GITHUB_REF_NAME#v}" --no-git-tag-version
working-directory: packages/library
- name: Configure registry auth
run: echo "//git.mcpeakdev.com/api/packages/mcpeakdev/npm/:_authToken=${FORGEJO_TOKEN}" > /root/.npmrc
working-directory: packages/library
env:
FORGEJO_TOKEN: ${{ secrets.FORGEJO_TOKEN }}
- name: Publish package
run: npm publish --registry https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/
working-directory: packages/library
publish-docs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: self-hosted
container: git.mcpeakdev.com/mcpeakdev/docker-pub:latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push docs image
uses: docker/build-push-action@v6
with:
context: .
file: packages/playground/Dockerfile
push: true
tags: |
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:latest

1
.gitattributes vendored Normal file
View file

@ -0,0 +1 @@
* text=auto eol=lf

55
.gitignore vendored Normal file
View file

@ -0,0 +1,55 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs
packages/*/dist
packages/*/node_modules
packages/*/.storybook-static
packages/*/storybook-static
# Storybook
.storybook/node_modules
node_modules/.cache
# Local browser-verification tooling (Playwright + extracted libs + screenshots)
.tmp/
.bun-cache/
.pw-browsers/
.pw-libs/
.shots/

10
.oxlintrc.json Normal file
View file

@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

6
.prettierrc.json Normal file
View file

@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

10
.vscode/extensions.json vendored Normal file
View file

@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

133
README.md Normal file
View file

@ -0,0 +1,133 @@
# nychthemeron
A CSS-reactive Vue component library built on PrimeVue (Unstyled), shipped with the nychthemeron theme as a default.
## About
nychthemeron provides a curated set of Vue 3 components powered by PrimeVue's unstyled components and the nychthemeron theme system. This approach gives you complete control over component styling while maintaining a consistent, polished design language out of the box.
### Features
- **CSS-Reactive**: Components respond to CSS changes without requiring JavaScript updates
- **PrimeVue Powered**: Built on the robust, accessible PrimeVue unstyled component library
- **Theme Included**: Ships with the nychthemeron theme, ready to use immediately
- **TypeScript**: Full type support for Vue 3 and TypeScript projects
- **Customizable**: Override or extend the default theme to match your design system
## Quick Start
### Installation
```bash
npm install @nychthemeron/library primevue vue@^3.5.0
```
### Basic Usage
Import the library components and styles:
```vue
<script setup lang="ts">
import { NychButton } from '@nychthemeron/library'
</script>
<template>
<NychButton label="Click me" />
</template>
<style>
@import '@nychthemeron/library/style';
@import '@nychthemeron/library/theme';
</style>
```
## Components
- **NychButton** - Customizable button component
More components coming soon.
## Customization
### Using the Default Theme
The nychthemeron theme is included by default:
```css
@import '@nychthemeron/library/theme';
```
### Custom Styling
Override or extend component styles by importing only the base styles and providing your own CSS:
```css
@import '@nychthemeron/library/style';
/* Your custom theme */
:root {
--primary-color: #your-color;
/* More CSS variables */
}
```
## Project Structure
This is a monorepo containing:
- **`packages/library`** - The component library package
- **`packages/playground`** - Development environment and examples
## Development
### Setup
```bash
bun install
```
### Development Server
Start the playground to develop and test components:
```bash
bun dev
```
### Build
Build the library for production:
```bash
bun build
```
### Testing
Run unit tests:
```bash
bun test
```
### Linting and Formatting
```bash
bun lint # Check and fix linting issues
bun format # Format code with Prettier
bun type-check # Run TypeScript type checking
```
## Requirements
- **Node.js**: >=20.19.0 or >=22.12.0
- **Vue**: ^3.5.0
- **PrimeVue**: ^4.5.0
## Browser Support
Supports all modern browsers with CSS Grid and CSS Variables support.
## License
MIT

1597
bun.lock Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,708 @@
# Library with Playground Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Transform nychthemeron into a monorepo with a Vue component library (with PrimeVue + Tailwind) and interactive Storybook playground.
**Architecture:** Bun workspaces manage two independent packages — library (builds to ESM) and playground (Storybook dev server and static build). Shared configs at root level.
**Tech Stack:** Vue 3, PrimeVue, Tailwind CSS, Vite, Storybook, Vitest, TypeScript, Bun workspaces
---
### Task 1: Create monorepo directory structure
**Files:**
- Create: `packages/library/` directory
- Create: `packages/playground/` directory
- [ ] **Step 1: Create packages directories**
```bash
mkdir -p packages/library/src/{components,composables}
mkdir -p packages/library/__tests__
mkdir -p packages/playground/.storybook
mkdir -p packages/playground/stories
```
- [ ] **Step 2: Verify structure**
```bash
ls -la packages/
# Expected output shows: library/ and playground/ directories
```
---
### Task 2: Move existing source to library package
**Files:**
- Move: `src/``packages/library/src/`
- Delete: `vite.config.ts`, `vitest.config.ts`, `index.html` (will recreate in packages)
- [ ] **Step 1: Move src directory**
```bash
mv src packages/library/src
```
- [ ] **Step 2: Verify move**
```bash
ls packages/library/src/
# Expected: App.vue, main.ts, __tests__/
```
- [ ] **Step 3: Delete old root-level config files**
```bash
rm vite.config.ts vitest.config.ts index.html
```
---
### Task 3: Update root package.json for monorepo
**Files:**
- Modify: `package.json`
- [ ] **Step 1: Add workspaces and update root config**
Replace the entire `package.json` with:
```json
{
"name": "nychthemeron",
"version": "0.0.0",
"private": true,
"type": "module",
"workspaces": [
"packages/*"
],
"scripts": {
"dev": "bun -r --cwd packages/playground dev",
"build": "bun -r --cwd packages/library build && bun -r --cwd packages/playground build",
"preview": "bun -r --cwd packages/playground preview",
"type-check": "vue-tsc --build",
"lint": "eslint packages/*/src --fix",
"format": "prettier --write --experimental-cli packages/*/src",
"test": "bun -r --cwd packages/library test:unit"
},
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2",
"@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-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^29.0.2",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"prettier": "3.8.3",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.4",
"vue": "^3.5.32",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}
```
- [ ] **Step 2: Install dependencies for new workspace structure**
```bash
bun install
```
Expected output: Bun detects workspaces and creates symlinks in each `packages/*/node_modules`
---
### Task 4: Create library package.json
**Files:**
- Create: `packages/library/package.json`
- [ ] **Step 1: Create library package config**
```json
{
"name": "@nychthemeron/library",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "vite build",
"test:unit": "vitest"
},
"peerDependencies": {
"vue": "^3.5.0",
"primevue": "^18.0.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"@vue/test-utils": "^2.4.6"
}
}
```
Write this to `packages/library/package.json`
- [ ] **Step 2: Verify file exists**
```bash
cat packages/library/package.json | grep '"name"'
# Expected: "@nychthemeron/library"
```
---
### Task 5: Create library tsconfig.json
**Files:**
- Create: `packages/library/tsconfig.json`
- [ ] **Step 1: Create library TypeScript config**
```json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false
},
"include": [
"src/**/*.ts",
"src/**/*.vue"
],
"exclude": [
"dist",
"**/*.spec.ts"
]
}
```
Write this to `packages/library/tsconfig.json`
---
### Task 6: Create library vite.config.ts
**Files:**
- Create: `packages/library/vite.config.ts`
- [ ] **Step 1: Create Vite library config**
```typescript
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
name: 'NychthemeronLibrary',
formats: ['es'],
fileName: () => 'index.js',
},
rollupOptions: {
external: ['vue', 'primevue'],
output: {
globals: {
vue: 'Vue',
primevue: 'PrimeVue',
},
},
},
},
})
```
Write this to `packages/library/vite.config.ts`
---
### Task 7: Create library vitest.config.ts
**Files:**
- Create: `packages/library/vitest.config.ts`
- [ ] **Step 1: Create Vitest config**
```typescript
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
include: ['**/*.spec.ts'],
},
})
```
Write this to `packages/library/vitest.config.ts`
---
### Task 8: Create library export structure
**Files:**
- Create: `packages/library/src/components/index.ts`
- Create: `packages/library/src/composables/index.ts`
- Modify: `packages/library/src/index.ts`
- [ ] **Step 1: Create components index (empty for now)**
```typescript
// Re-export all components here as they're added
// export { default as Button } from './Button/Button.vue'
// export { default as DatePicker } from './DatePicker/DatePicker.vue'
```
Write this to `packages/library/src/components/index.ts`
- [ ] **Step 2: Create composables index (empty for now)**
```typescript
// Re-export all composables here as they're added
// export { useDate } from './useDate'
```
Write this to `packages/library/src/composables/index.ts`
- [ ] **Step 3: Create root library entry point**
```typescript
export * from './components'
export * from './composables'
```
Write this to `packages/library/src/index.ts`
- [ ] **Step 4: Remove old App.vue and main.ts from library (they were for the app)**
```bash
rm packages/library/src/App.vue packages/library/src/main.ts
```
---
### Task 9: Create playground package.json
**Files:**
- Create: `packages/playground/package.json`
- [ ] **Step 1: Create playground package config**
```json
{
"name": "@nychthemeron/playground",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"preview": "npx vite preview"
},
"dependencies": {
"@nychthemeron/library": "workspace:*",
"primevue": "^18.0.0",
"vue": "^3.5.32"
},
"devDependencies": {
"@storybook/addon-essentials": "^8.0.0",
"@storybook/addon-interactions": "^8.0.0",
"@storybook/addon-links": "^8.0.0",
"@storybook/blocks": "^8.0.0",
"@storybook/vue3": "^8.0.0",
"@storybook/vue3-vite": "^8.0.0",
"storybook": "^8.0.0",
"tailwindcss": "^3.4.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"typescript": "~6.0.0",
"vue": "^3.5.32"
}
}
```
Write this to `packages/playground/package.json`
---
### Task 10: Create playground tsconfig.json
**Files:**
- Create: `packages/playground/tsconfig.json`
- [ ] **Step 1: Create playground TypeScript config**
```json
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",
"esnext"
]
},
"include": [
".storybook/**/*.ts",
".storybook/**/*.tsx",
".storybook/**/*.vue",
"stories/**/*.ts",
"stories/**/*.tsx",
"stories/**/*.vue"
]
}
```
Write this to `packages/playground/tsconfig.json`
---
### Task 11: Create playground Tailwind config
**Files:**
- Create: `packages/playground/tailwind.config.ts`
- [ ] **Step 1: Create Tailwind config**
```typescript
import type { Config } from 'tailwindcss'
export default {
content: [
'./.storybook/**/*.{ts,tsx,vue}',
'./stories/**/*.{ts,tsx,vue}',
'../library/src/**/*.vue',
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
500: '#0ea5e9',
900: '#0c2d6b',
},
},
},
},
plugins: [],
} satisfies Config
```
Write this to `packages/playground/tailwind.config.ts`
---
### Task 12: Create Storybook main config
**Files:**
- Create: `packages/playground/.storybook/main.ts`
- [ ] **Step 1: Create Storybook main configuration**
```typescript
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: [
'../stories/**/*.stories.ts',
],
addons: [
'@storybook/addon-essentials',
'@storybook/addon-interactions',
'@storybook/addon-links',
],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
core: {
disableTelemetry: true,
},
}
export default config
```
Write this to `packages/playground/.storybook/main.ts`
---
### Task 13: Create Storybook preview config
**Files:**
- Create: `packages/playground/.storybook/preview.ts`
- [ ] **Step 1: Create Storybook preview configuration**
```typescript
import type { Preview } from '@storybook/vue3'
import 'primevue/resources/themes/lara-light-blue/theme.css'
import 'primevue/resources/primevue.min.css'
import '../tailwind.css'
const preview: Preview = {
parameters: {
layout: 'centered',
docs: {
description: {
component: 'Component documentation',
},
},
},
}
export default preview
```
Write this to `packages/playground/.storybook/preview.ts`
---
### Task 14: Create Tailwind CSS entry point
**Files:**
- Create: `packages/playground/tailwind.css`
- [ ] **Step 1: Create Tailwind directives file**
```css
@tailwind base;
@tailwind components;
@tailwind utilities;
```
Write this to `packages/playground/tailwind.css`
---
### Task 15: Create example story
**Files:**
- Create: `packages/playground/stories/Example.stories.ts`
- [ ] **Step 1: Create example story for testing**
```typescript
import type { Meta, StoryObj } from '@storybook/vue3'
const meta = {
title: 'Example/Welcome',
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const Welcome: Story = {
render: () => ({
template: `
<div class="p-8 text-center">
<h1 class="text-3xl font-bold text-primary-500 mb-4">
Nychthemeron Component Library
</h1>
<p class="text-gray-600">
Interactive playground for Vue components with PrimeVue + Tailwind
</p>
</div>
`,
}),
}
```
Write this to `packages/playground/stories/Example.stories.ts`
---
### Task 16: Install dependencies and verify structure
**Files:**
- None (verification only)
- [ ] **Step 1: Install dependencies for all packages**
```bash
bun install
```
Expected: All packages get dependencies installed, workspace links created
- [ ] **Step 2: Verify workspace structure**
```bash
ls -la packages/library/node_modules/@nychthemeron/library
# Expected: symlink to ../..
ls -la packages/playground/node_modules/@nychthemeron/library
# Expected: symlink to ../../../library
```
- [ ] **Step 3: Type-check across monorepo**
```bash
bun type-check
```
Expected: No TypeScript errors
---
### Task 17: Test dev server startup
**Files:**
- None (verification only)
- [ ] **Step 1: Start Storybook playground**
```bash
bun dev
```
Expected output:
```
Storybook 8.x started
Local: http://localhost:6006
```
- [ ] **Step 2: Verify Storybook loads in browser**
Open `http://localhost:6006` in a browser.
Expected: Storybook UI loads with "Example/Welcome" story visible showing the welcome message.
- [ ] **Step 3: Verify story renders correctly**
In Storybook, click "Example/Welcome" → verify the welcome message displays with correct styling.
Expected:
- Title: "Nychthemeron Component Library" (blue, large)
- Subtitle: "Interactive playground..." (gray, smaller)
- [ ] **Step 4: Stop dev server**
```bash
# Press Ctrl+C in terminal
```
---
### Task 18: Test library build
**Files:**
- None (verification only)
- [ ] **Step 1: Build library**
```bash
bun -r --cwd packages/library build
```
Expected output:
```
✓ 123 modules transformed
dist/index.js 1.23 kb
dist/index.d.ts 0.5 kb
```
- [ ] **Step 2: Verify dist files exist**
```bash
ls packages/library/dist/
```
Expected:
```
index.d.ts
index.js
```
- [ ] **Step 3: Verify exports are correct**
```bash
head -20 packages/library/dist/index.js
```
Expected: JavaScript code with ES module exports (starts with `import` or defines exports)
---
### Task 19: Initialize git for monorepo
**Files:**
- None (git initialization only)
- [ ] **Step 1: Initialize git repo (if not already)**
```bash
git init
```
- [ ] **Step 2: Add gitignore entries for workspace**
Append to `.gitignore`:
```
packages/*/dist
packages/*/node_modules
packages/*/.storybook-static
```
- [ ] **Step 3: Create initial commit (SKIP - user requested no commits)**
Note: Commit skipped per user request
---
## Self-Review
**Spec coverage:**
- ✅ Monorepo structure (Task 1)
- ✅ Bun workspaces setup (Tasks 2-3)
- ✅ Library package creation (Tasks 4-8)
- ✅ Composables structure (Task 8)
- ✅ Playground/Storybook setup (Tasks 9-15)
- ✅ Tailwind integration (Tasks 11, 14)
- ✅ PrimeVue initialization (Task 13)
- ✅ Dev workflow verification (Task 17)
- ✅ Build verification (Task 18)
- ✅ Git initialization (Task 19)
**Placeholder scan:** No TBDs, "fill in later", incomplete steps. All code blocks are complete and exact.
**Type consistency:** Package names consistent (`@nychthemeron/library`, `@nychthemeron/playground`). All import paths and config keys match across tasks.
**Scope:** Plan produces working, testable monorepo. Library builds to `dist/`, Storybook starts and loads stories. Next phase (adding components) is separate.

View file

@ -0,0 +1,222 @@
# Docs Site Docker Image + Install Page Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add an Installation page to the Storybook playground, package the playground's static build as a `nychthemeron-docs` Docker image served by nginx, and wire up CI to build/push that image on version tags.
**Architecture:** A new MDX docs page (`packages/playground/stories/Install.mdx`) documents installing `@nychthemeron/library` via npm/pnpm/yarn/bun from the private Forgejo npm registry. A new multi-stage `packages/playground/Dockerfile` builds the library and Storybook static output with `oven/bun:1`, then copies `storybook-static` into `nginx:alpine`. A root `.dockerignore` keeps the build context lean. A new `publish-docs` job in `.forgejo/workflows/ci.yml`, parallel to `publish`, builds and pushes the image on `v*` tags.
**Tech Stack:** Storybook 10 (`@storybook/addon-docs`), Vue 3, Bun, Docker (multi-stage, nginx:alpine), Forgejo Actions.
---
### Task 1: Add Installation MDX page
**Files:**
- Create: `packages/playground/stories/Install.mdx`
- [ ] **Step 1: Create the MDX file**
```mdx
import { Meta } from '@storybook/addon-docs/blocks'
<Meta title="Get Started/Installation" />
# Installation
`@nychthemeron/library` is published to a private npm registry hosted on
Forgejo. Configure your package manager for the `@nychthemeron` scope, then
install the package.
## Configure the registry
Add the following to your project's `.npmrc`:
```
@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/
```
## npm
```bash
npm install @nychthemeron/library
```
## pnpm
```bash
pnpm add @nychthemeron/library
```
## yarn
```bash
yarn add @nychthemeron/library
```
## bun
```bash
bun add @nychthemeron/library
```
```
- [ ] **Step 2: Build Storybook and verify the page is included**
Run: `cd packages/playground && bun run build`
Expected: Build succeeds with output ending in something like
`Output directory: storybook-static`. Then run:
`grep -o '"title":"Get Started/Installation"' packages/playground/storybook-static/index.json`
Expected: prints `"title":"Get Started/Installation"`.
- [ ] **Step 3: Commit**
```bash
git add packages/playground/stories/Install.mdx
git commit -m "Add installation docs page to playground"
```
---
### Task 2: Add root `.dockerignore`
**Files:**
- Create: `.dockerignore`
- [ ] **Step 1: Create `.dockerignore`**
```
node_modules
**/node_modules
**/dist
**/storybook-static
**/.storybook-static
.git
docs
.tmp
.bun-cache
.pw-browsers
.pw-libs
.shots
*.log
```
- [ ] **Step 2: Commit**
```bash
git add .dockerignore
git commit -m "Add .dockerignore for docs Docker build"
```
---
### Task 3: Add Dockerfile for the docs site
**Files:**
- Create: `packages/playground/Dockerfile`
- [ ] **Step 1: Create the Dockerfile**
```dockerfile
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN cd packages/library && bun run build
RUN cd packages/playground && bun run build
FROM nginx:alpine AS runtime
COPY --from=build /app/packages/playground/storybook-static /usr/share/nginx/html
EXPOSE 80
```
- [ ] **Step 2: Build and run the image locally**
Run (from repo root):
```bash
docker build -t nychthemeron-docs:test -f packages/playground/Dockerfile .
docker run -d --rm --name nychthemeron-docs-test -p 8080:80 nychthemeron-docs:test
sleep 2
curl -sf http://localhost:8080/ | grep -i storybook
docker stop nychthemeron-docs-test
```
Expected: `docker build` completes successfully, the container starts, and
the `curl` output contains a reference to `storybook` (from
`storybook-static/index.html`).
> If the Docker CLI/daemon isn't available in the current environment (e.g.
> sandboxed dev container), skip running this step here and ask the user to
> run it locally before merging.
- [ ] **Step 3: Commit**
```bash
git add packages/playground/Dockerfile
git commit -m "Add Dockerfile for nychthemeron-docs static site"
```
---
### Task 4: Add `publish-docs` CI job
**Files:**
- Modify: `.forgejo/workflows/ci.yml`
- [ ] **Step 1: Add the `publish-docs` job**
Add this job after the existing `publish` job (same indentation level,
i.e. as a sibling under `jobs:`):
```yaml
publish-docs:
needs: build
if: startsWith(github.ref, 'refs/tags/v')
runs-on: docker
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Log in to registry
uses: docker/login-action@v3
with:
registry: git.mcpeakdev.com
username: ${{ github.actor }}
password: ${{ secrets.FORGEJO_TOKEN }}
- name: Extract image tag
id: meta
run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
- name: Build and push docs image
uses: docker/build-push-action@v6
with:
context: .
file: packages/playground/Dockerfile
push: true
tags: |
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:${{ steps.meta.outputs.tag }}
git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:latest
```
- [ ] **Step 2: Verify YAML structure**
Run: `bun run --eval "console.log('ok')"` is not a YAML check — instead,
visually confirm in the file that `publish-docs:` is indented exactly like
`publish:` (4 spaces under `jobs:`), and that all of its child keys
(`needs`, `if`, `runs-on`, `steps`, and each `- name:` step) match the
indentation pattern used by the `publish` job. Confirm there are exactly
three top-level jobs: `build`, `publish`, `publish-docs`.
- [ ] **Step 3: Commit**
```bash
git add .forgejo/workflows/ci.yml
git commit -m "Add publish-docs job to build and push nychthemeron-docs image"
```

View file

@ -0,0 +1,284 @@
# Library with Playground Design
**Date:** 2026-05-11
**Project:** nychthemeron
**Status:** Approved
## Overview
Transform nychthemeron from a single Vue app into a monorepo containing a Vue component library built on PrimeVue with Tailwind CSS styling, accompanied by a Storybook playground for interactive documentation and development.
## Goals
- Build a reusable Vue component library with custom Tailwind-based styling on PrimeVue
- Provide interactive documentation via Storybook for developers and stakeholders
- Enable fast, hot-reload development workflow
- Support both components and composables (e.g., date utilities for DatePicker)
- Keep internal/private — no npm publishing required
## Architecture
### Monorepo Structure
```
nychthemeron/
├── packages/
│ ├── library/ # Vue component library
│ │ ├── src/
│ │ │ ├── components/ # Vue components (DatePicker, Button, etc.)
│ │ │ ├── composables/ # Reusable logic (useDate, etc.)
│ │ │ └── index.ts # Barrel export for all exports
│ │ ├── __tests__/ # Unit tests
│ │ ├── package.json # Library metadata
│ │ ├── tsconfig.json # Library TS config
│ │ └── vite.config.ts # Library build config
│ │
│ └── playground/ # Storybook playground
│ ├── .storybook/
│ │ ├── main.ts # Storybook configuration
│ │ └── preview.ts # Global setup (Tailwind, PrimeVue)
│ ├── stories/ # Component stories (.stories.ts)
│ ├── package.json # Playground metadata
│ ├── tsconfig.json # Storybook TS config
│ └── tailwind.config.ts # Shared Tailwind config
├── package.json # Root workspace config (bun workspaces)
├── tsconfig.json # Shared base TS config
├── eslint.config.ts # Shared ESLint config
├── prettier.config.json # Shared Prettier config
└── docs/ # Documentation
```
### Dependency Model
- **Root:** Shared dev dependencies (TypeScript, ESLint, Prettier, bun workspace config)
- **Library:** Declares PrimeVue, Vue 3 as peer dependencies; exports components + composables
- **Playground:** Imports `@nychthemeron/library` and PrimeVue; bundles Storybook
### Technology Stack
| Layer | Technology |
|-------|-----------|
| Framework | Vue 3 |
| Component Base | PrimeVue |
| Styling | Tailwind CSS + custom CSS layer |
| Build (Library) | Vite (library mode) |
| Build (Playground) | Vite + Storybook |
| Testing | Vitest + Vue Test Utils |
| Package Manager | Bun (with workspaces) |
| Type Checking | TypeScript + vue-tsc |
## Library Package Details
### Export Model
**index.ts** exports all components and composables:
```typescript
export * from './components'
export * from './composables'
```
Consumers import as:
```typescript
import { DatePicker, Button } from '@nychthemeron/library'
import { useDate } from '@nychthemeron/library'
```
### Component Structure
Each component lives in its own folder:
```
src/components/
├── DatePicker/
│ ├── DatePicker.vue
│ └── DatePicker.spec.ts
├── Button/
│ ├── Button.vue
│ └── Button.spec.ts
└── index.ts
```
### Composables Structure
Reusable logic in `src/composables/`:
```
src/composables/
├── useDate.ts # Date utilities (parsing, formatting, etc.)
├── index.ts
```
Components use composables internally; consumers can also import them directly.
### Tailwind Integration
- `tailwind.config.ts` extends PrimeVue theme with custom utilities
- Library does NOT bundle Tailwind — consumers must include it in their setup
- Supports portability: teams can apply custom Tailwind themes independently
### Build Output
- ESM modules (primary) + optional CommonJS
- TypeScript declarations (`.d.ts`)
- Sourcemaps for debugging
- No dependency bundling (peer deps only)
## Playground Package Details
### Storybook Configuration
- **Addon:** Vue 3 builder
- **Global Setup:** `preview.ts` configures:
- Tailwind CSS
- PrimeVue theme setup
- Global styles
- **Stories Location:** `stories/` directory
### Story Structure
Each component gets a `.stories.ts` file:
```typescript
// stories/DatePicker.stories.ts
import DatePicker from '../components/DatePicker.vue'
export default {
component: DatePicker,
tags: ['autodocs'],
}
export const Default = {}
export const WithCustomTheme = { args: { /* ... */ } }
```
Stories serve triple duty:
1. Interactive documentation for developers
2. Visual regression baseline
3. Live testing environment during dev
### Preview Configuration
`preview.ts` sets up:
- Tailwind CSS
- PrimeVue initialization
- Global CSS imports
- Example theme configuration
## Development Workflow
### Scripts
**Root-level (`bun` commands):**
```json
{
"dev": "bun -r --cwd packages/playground dev",
"build": "bun -r --cwd packages/library build && bun -r --cwd packages/playground build",
"lint": "eslint packages/*/src",
"type-check": "vue-tsc --build",
"test": "bun -r --cwd packages/library test:unit"
}
```
**Library scripts:**
- `bun dev` — (unused for lib, optional for local testing)
- `bun build` — Vite library build
- `bun test:unit` — Vitest
**Playground scripts:**
- `bun dev` — Start Storybook dev server (port 6006)
- `bun build` — Build static Storybook site
### Developer Experience
1. **Start playground:** `bun dev` (root) → Storybook on `localhost:6006`
2. **Edit component:** Modify `packages/library/src/components/DatePicker/DatePicker.vue`
3. **See update:** Playground hot-reloads via Storybook
4. **View story:** Open `stories/DatePicker.stories.ts` to add/update examples
5. **Test:** Run `bun test` in library package
### Hot Reload
- Library changes trigger Storybook rebuild
- Playground imports `@nychthemeron/library` from source (no build step during dev)
## Testing Strategy
### Unit Tests
- **Location:** `packages/library/__tests__/`
- **Framework:** Vitest + Vue Test Utils
- **Coverage:** Component logic, composables, edge cases
- **Command:** `bun test` (from library or root)
### Visual Documentation
- Storybook stories serve as visual baseline
- Can be extended with snapshot testing if needed
### Type Safety
- `vue-tsc --build` runs across monorepo
- CI/local checks ensure no type errors
## Build & Deployment
### Library Build
- **Output:** `packages/library/dist/`
- **Formats:** ESM (primary) + optional CommonJS
- **Declarations:** Full TypeScript support
- **Size:** Optimized with tree-shaking enabled
### Playground Build
- **Output:** `packages/playground/storybook-static/`
- **Deploy:** Can be served on internal server, wiki, or documentation site
- **Audience:** Internal team, stakeholders, designers
### Internal Distribution
No npm publishing. Teams use library by:
- Cloning/pulling monorepo
- Installing dependencies: `bun install`
- Importing components from `@nychthemeron/library`
## Dependencies
### Library Package
**Peer Dependencies:**
- `vue@^3.5.0`
- `primevue@^18.0.0` (or latest)
**Dev Dependencies:** (shared from root)
- TypeScript, Vite, ESLint, Prettier
### Playground Package
**Dependencies:**
- `@nychthemeron/library` (local workspace package)
- `primevue`
- `vue`
**Dev Dependencies:**
- Storybook (@storybook/vue3)
- Vite, TypeScript
## Success Criteria
- ✅ Monorepo structure set up with bun workspaces
- ✅ Library builds to `dist/` with exports
- ✅ Storybook starts with `bun dev` and hot-reloads on library changes
- ✅ Components use PrimeVue + Tailwind CSS
- ✅ Composables (e.g., `useDate`) are accessible
- ✅ TypeScript declarations present and correct
- ✅ Unit tests run and pass
- ✅ Shared config (ESLint, Prettier, tsconfig) enforced across packages
## Next Steps
1. Restructure project into monorepo layout
2. Set up bun workspaces in root `package.json`
3. Create library package with Vite lib config
4. Create playground package with Storybook
5. Add shared configurations
6. Implement first component (e.g., Button or DatePicker)
7. Create corresponding Storybook stories
8. Test hot-reload workflow

View file

@ -0,0 +1,55 @@
# Docs site (playground) Docker image + install page
## Goal
Publish the Storybook playground as a static "docs" site, package it as a
Docker image (`nychthemeron-docs`), and add an Installation page documenting
how to install `@nychthemeron/library` from the private Forgejo npm registry
via npm, pnpm, yarn, and bun.
## Install page
- New file: `packages/playground/stories/Install.mdx`
- Storybook addon-docs MDX page (no story export), `Meta` title
`Get Started/Installation` so it sorts near the top of the sidebar.
- Content:
- Heading + short description of the library.
- Registry config snippet (`.npmrc`) showing how to point the scoped
`@nychthemeron` registry at `https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/`.
- Four code blocks showing install commands for `@nychthemeron/library`:
npm, pnpm, yarn, bun.
## Docker image (`nychthemeron-docs`)
- New file: `packages/playground/Dockerfile`, multi-stage build:
- **Stage 1 (`oven/bun:1`)**: copy the workspace, `bun install --frozen-lockfile`,
build `@nychthemeron/library` (`cd packages/library && bun run build`),
then build Storybook (`cd packages/playground && bun run build` →
`packages/playground/storybook-static`).
- **Stage 2 (`nginx:alpine`)**: copy `storybook-static` into
`/usr/share/nginx/html`, default nginx config, `EXPOSE 80`.
- Build context is the repo root (so the workspace lockfile and all
packages are available to stage 1).
## CI changes
In `.forgejo/workflows/ci.yml`, add a new `publish-docs` job:
- Parallel to the existing `publish` job (both depend on `build`).
- Gated on `startsWith(github.ref, 'refs/tags/v')`.
- Steps:
1. Checkout
2. Log in to `git.mcpeakdev.com` using `docker/login-action` with
`secrets.FORGEJO_TOKEN` (same credential as the library publish job).
3. Extract version from tag (`${GITHUB_REF_NAME#v}`).
4. `docker/build-push-action` with context `.`, dockerfile
`packages/playground/Dockerfile`, push `true`, tags:
- `git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:<version>`
- `git.mcpeakdev.com/mcpeakdev/nychthemeron-docs:latest`
## Out of scope
- No changes to the existing `build` or `publish` (npm) jobs beyond what's
already in place.
- No SPA routing / custom nginx config — Storybook's static output is
served as-is.

3
env.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference types="vite/client" />
declare module '*.css' {}

33
eslint.config.ts Normal file
View file

@ -0,0 +1,33 @@
// For more info, see https://github.com/storybookjs/eslint-plugin-storybook#configuration-flat-config-format
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

51
package.json Normal file
View file

@ -0,0 +1,51 @@
{
"name": "nychthemeron",
"version": "0.0.0",
"devDependencies": {
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.3",
"@types/node": "^25.9.3",
"@vitejs/plugin-vue": "^6.0.7",
"@vitest/coverage-v8": "^4.1.8",
"@vitest/eslint-plugin": "^1.6.20",
"@vue/eslint-config-typescript": "^14.8.0",
"@vue/test-utils": "^2.4.11",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.4.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.69.0",
"eslint-plugin-vue": "~10.9.2",
"jiti": "^2.7.0",
"jsdom": "^29.1.1",
"npm-run-all2": "^9.0.1",
"oxlint": "~1.69.0",
"playwright": "^1.60.0",
"prettier": "3.8.4",
"primevue": "^4.5.5",
"typescript": "~6.0.3",
"vite": "^8.0.16",
"vite-plugin-vue-devtools": "^8.1.2",
"vitest": "^4.1.8",
"vue": "^3.5.38",
"vue-tsc": "^3.3.4"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"private": true,
"scripts": {
"dev": "cd packages/playground && bun run dev",
"build:dev": "cd packages/library && bun run build ",
"build": "cd packages/library && bun run build && cd ../playground && bun run build",
"preview": "cd packages/playground && bun run preview",
"test:unit": "cd packages/library && bun run test:unit",
"type-check": "vue-tsc --build",
"lint": "eslint packages/*/src --fix",
"format": "prettier --write --experimental-cli packages/*/src",
"test": "vitest --config packages/library/vitest.config.ts"
},
"type": "module",
"workspaces": [
"packages/*"
]
}

View file

@ -0,0 +1,42 @@
{
"name": "@nychthemeron/library",
"version": "0.1.0",
"type": "module",
"exports": {
".": {
"import": "./dist/index.js",
"types": "./dist/src/index.d.ts"
},
"./components": {
"import": "./src/components/index.ts",
"types": "./src/index.d.ts"
},
"./style": {
"import": "./src/assets/css/nychthemeron.css",
"types": "./src/index.d.ts"
},
"./theme": {
"import": "./src/assets/theme/theme.css",
"types": "./src/index.d.ts"
}
},
"files": [
"dist",
"src/assets",
"src/components"
],
"scripts": {
"build": "vite build",
"test:unit": "vitest run",
"test:unit:watch": "vitest"
},
"peerDependencies": {
"vue": "^3.5.0",
"primevue": "^4.5.0"
},
"devDependencies": {
"@vitest/coverage-v8": "^2.0.0",
"@vue/test-utils": "^2.4.6",
"vite-plugin-dts": "^5.0.2"
}
}

View file

@ -0,0 +1,169 @@
/* ============================================================
nychthemeron.css
Toggle themes by setting data-theme on <html>:
<html data-theme="hades">
<html data-theme="apollo">
============================================================ */
@import url('https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500&display=swap');
/* ── HADES ───────────────────────────────────────────────── */
[data-theme='hades'] {
/* surfaces */
--surface-0: #18160f;
--surface-1: #28241a;
--surface-2: #2e2a1e;
--surface-3: #322e22;
--track: #32302a;
/* borders */
--border: #4a4538;
--border-hi: #5a5040;
--border-lo: #38342a;
/* primary */
--primary: #d4b278;
--primary-fg: #0e0c08;
/* semantic */
--warn: #e08840; /* phlegethon */
--warn-subtle: #281808;
--warn-border: #4a2c10;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* plague */
--danger-subtle: #200808;
--danger-border: #3a1010;
--danger-fg: #fff8e8;
--info: #88aec8; /* cocytus */
--info-subtle: #101e2c;
--info-border: #1e3850;
--info-fg: #0a1828;
--neutral: #7a6e60; /* acheron */
--neutral-fg: #fff8e8;
--success: #6aaa7a; /* laurel */
--success-subtle: #0e2214;
--success-border: #1e4228;
--success-fg: #fff8e8;
/* text */
--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);
/* fonts */
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* ── APOLLO ──────────────────────────────────────────────── */
[data-theme='apollo'] {
/* surfaces */
--surface-0: #faf7f2;
--surface-1: #f4f0e8;
--surface-2: #eee9df;
--surface-3: #e4ddd0;
--track: #e4ddd0;
/* borders */
--border: #bab2a0;
--border-hi: #c0b8a8;
--border-lo: #ccc4b4;
/* primary */
--primary: #b8860b;
--primary-fg: #fff8e8;
/* semantic */
--warn: #9a6020; /* harmony */
--warn-subtle: #fdf3e0;
--warn-border: #e8d0a0;
--warn-fg: #fff8e8;
--danger: #7a1a1a; /* plague */
--danger-subtle: #faeaea;
--danger-border: #c8a0a0;
--danger-fg: #fff8e8;
--info: #3a5a8a; /* oracle */
--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 */
--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);
/* fonts */
--font-serif: 'Cinzel', Georgia, serif;
--font-sans: 'IBM Plex Sans', system-ui, sans-serif;
--font-mono: 'IBM Plex Mono', monospace;
}
/* ── BASE RESET ──────────────────────────────────────────── */
*,
*::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;
}
[class^='nych-']:focus-visible {
outline: 2px solid transparent; /* visible in forced-colors / Windows High Contrast */
box-shadow: var(--focus-ring);
}
body {
background-color: var(--surface-0);
color: var(--text-body);
min-height: 100vh;
}
@keyframes nych-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.nych-loading-icon .wreath {
transform-origin: 340px 180px;
animation: nych-spin 2s linear infinite;
}

View file

@ -0,0 +1,727 @@
@import url('../css/nychthemeron.css');
/* Restore serif font for nych- components even if consumer overrides html font-family */
[class^='nych-'] {
font-family: var(--font-serif);
}
[class^='nych-']:disabled {
cursor: not-allowed;
opacity: 0.5;
}
[class^='nych-button'] {
padding: 7px;
border-radius: 5px;
box-shadow: 0px 0px 10px var(--text-high);
border: none;
cursor: pointer;
transition: box-shadow 0.3s ease;
}
[class^='nych-button']:hover:not(:disabled) {
box-shadow: 0px 0px 8px var(--text-high);
}
[class^='nych-button'][data-p~='loading'] {
display: flex;
}
[class^='nych-button'][data-p~='loading'] span {
margin-top: 4px;
}
[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);
}
.nych-loading-icon .wreath path {
fill: var(--primary-fg);
}
.nych-loading-icon .wreath line {
stroke: var(--text-high);
}
button[data-p~='loading'] > .nych-loading-icon {
margin-right: 5px;
margin-top: 0px;
height: 25px;
font-size: 12pt;
}
[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: 5px;
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
============================================================ */
.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: 4px;
}
.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;
}
/* Group containers (CheckboxGroup / RadioButtonGroup) — layout only. */
.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);
}
/* Keep the visually-hidden native control hidden even when disabled the
global [class^='nych-']:disabled opacity would otherwise reveal it. */
.nych-checkbox .nych-checkbox-input,
.nych-radio .nych-radio-input,
.nych-toggleswitch .nych-toggleswitch-input {
opacity: 0;
}
/* ============================================================
Radio button
============================================================ */
.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: 999px;
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: 5px;
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: 6px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
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: 4px;
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: 8px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.18);
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: 6px;
}
.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: 4px;
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;
}
/* Severity tints are mixed from the semantic colour into the surface so they
read consistently in both the dark (Hades) and light (Apollo) themes,
rather than relying on the very-dark *-subtle tokens that muddied Hades. */
.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: 4px;
background-color: var(--primary);
color: var(--primary-fg);
}
.nych-tag[data-p~='rounded'] {
border-radius: 999px;
}
.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: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, 0.5);
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: 6px;
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);
}

View file

@ -0,0 +1,70 @@
<template>
<svg class="nych-loading-icon" preserveAspectRatio="xMidYMid meet" viewBox="226 66 228 228" aria-hidden="true">
<g class="wreath">
<g transform="rotate(0,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(22.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(45,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(67.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(90,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(112.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(135,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(157.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(180,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(202.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(225,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(247.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(270,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(292.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(315,340,180)">
<path d="M340,70 C350,78 352,96 343,112 C339,107 332,93 335,76 Z" />
<line x1="340" y1="73" x2="343" y2="110" stroke-width="0.8" stroke-linecap="round" />
</g>
<g transform="rotate(337.5,340,180)">
<path d="M340,72 C350,80 352,97 343,113 C339,108 332,94 335,77 Z" />
<line x1="340" y1="75" x2="343" y2="111" stroke-width="0.8" stroke-linecap="round" />
</g>
</g>
</svg>
</template>

View file

@ -0,0 +1,2 @@
// Re-export all components here as they're added
export { default as NychLoadingIcon } from './NychLoadingIcon.vue'

View file

@ -0,0 +1,2 @@
// Re-export all composables here as they're added
// export { useDate } from './useDate'

View file

@ -0,0 +1,200 @@
import { h, type SetupContext } from 'vue'
export type LibComponent =
| { new (...args: unknown[]): unknown } // Constructor components
| { (...args: unknown[]): unknown } // Functional components
| Record<string, unknown> // Object components
export type Props<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? P
: T extends { props: infer P }
? P
: string
export type Key<T> = T extends { new (...args: unknown[]): { $props: infer P } }
? keyof P
: T extends { props: infer P }
? keyof P
: string
// Try to extract unstyled type from component
// If extraction fails, accept any value via unknown
type ExtractUnstyled<T> = T extends { props: infer P }
? P extends { unstyled: infer U }
? U
: unknown
: T extends { new (...args: unknown[]): { $props: infer P } }
? P extends { unstyled: infer U }
? U
: unknown
: unknown
export type Themeable<
T extends LibComponent,
K extends Key<T> = Key<T>,
I extends Key<T> = Key<T>,
> = {
component: T
propMutator?: (props: Record<Key<T>, unknown>) => Record<Key<T>, unknown>
classes?: string[]
styles?: string[]
injectionKeys?: {
class?: K
style?: I
}
unstyled?: ExtractUnstyled<T>
slots?: Record<string, LibComponent>
}
export type ThemeLibrary<
T extends Record<string, Themeable<LibComponent>> = Record<string, Themeable<LibComponent>>,
> = {
components: T
}
export class ThemeEngine {
readonly lib: ThemeLibrary
constructor(library: ThemeLibrary) {
this.lib = library
}
getComponent<I extends LibComponent>(key: keyof ThemeLibrary['components']): I {
const { component, classes, styles, injectionKeys, propMutator, unstyled, slots } =
this.lib.components[key]
// Build a MINIMAL pass-through wrapper rather than cloning the wrapped
// component. Cloning dragged along the component's `props`, `emits` and
// `extends` chain — which made Vue treat listeners like `onUpdate:modelValue`
// as the wrapper's own declared events and withhold them from `ctx.attrs`,
// silently breaking two-way `v-model`. By declaring only the engine's own
// props here, every other prop, listener and attribute falls through
// `ctx.attrs` and is forwarded verbatim to the real component below.
const componentObj: Record<string, unknown> = {
name: `Nych${String(key)}`,
inheritAttrs: false,
props: {
injectedClasses: { default: classes },
injectedStyles: { default: styles },
stylePlug: { type: String },
unstyled: { default: unstyled },
},
}
const applyThemeProps = (props: Record<string, unknown>) => {
const mergedProps = { ...props }
const stylePlug =
typeof mergedProps['stylePlug'] === 'string' ? mergedProps['stylePlug'] : undefined
const defaultClassValues = Array.isArray(mergedProps['injectedClasses'])
? mergedProps['injectedClasses']
: []
const defaultStyleValues = Array.isArray(mergedProps['injectedStyles'])
? mergedProps['injectedStyles']
: []
const classKey = injectionKeys?.class ?? 'class'
const styleKey = injectionKeys?.style ?? 'style'
const classValue = mergedProps[classKey] ?? []
const styleValue =
typeof mergedProps[styleKey] === 'string'
? mergedProps[styleKey]
.split(';')
.map((s) => s.trim())
.filter(Boolean)
: []
let classArray: string[] | undefined = undefined
if (typeof classValue === 'object') {
if (Array.isArray(classValue)) {
classArray = classValue
} else if (classKey !== 'pt') {
// Support Vue's object-form class syntax: { 'my-class': condition }
classArray = Object.entries(classValue as Record<string, boolean>)
.filter(([, active]) => Boolean(active))
.map(([name]) => name)
}
}
if (typeof classValue === 'string') {
classArray = classValue.split(' ')
}
if (!classArray) {
classArray = []
}
const finalClasses = stylePlug
? [...classArray, ...defaultClassValues]
.map((cls) => (cls.startsWith(`${stylePlug}-`) ? cls : `${stylePlug}-${cls}`))
.join(' ')
: [...classArray, ...defaultClassValues].join(' ')
const finalStyles = [...styleValue, ...defaultStyleValues].join('; ')
if (classKey === 'pt' || styleKey === 'pt') {
mergedProps['unstyled'] = true
const existingPt =
typeof mergedProps[classKey] === 'object'
? (mergedProps[classKey] as Record<string, unknown>)
: {}
const existingRoot = (existingPt.root as { class?: string; style?: string }) ?? {}
mergedProps[classKey] = {
...existingPt,
root: {
...existingRoot,
class: [existingRoot.class, finalClasses].filter(Boolean).join(' '),
style: [existingRoot.style, finalStyles].filter(Boolean).join('; '),
},
}
} else {
mergedProps[classKey] = finalClasses
}
if (styleKey !== 'pt') {
mergedProps[styleKey] = finalStyles
}
if (propMutator) {
// Supports both in-place mutation (returns same ref) and returning a new props object
const mutated = propMutator(mergedProps as Record<Key<typeof component>, unknown>)
if (mutated !== mergedProps) {
Object.assign(mergedProps, mutated)
}
}
return mergedProps
}
// Always wrap via a render function so the original component runs its own
// setup naturally through Vue's component system with the modified props.
// Slots are merged here so no extra wrapper component layer is needed.
componentObj.setup = (props: Record<string, unknown>, ctx: SetupContext) => {
return () => {
const themeSlots: Record<string, unknown> = {}
for (const [name, slotComponent] of Object.entries(slots || {})) {
themeSlots[name] = (scope?: Record<string, unknown>) => [h(slotComponent, scope || {})]
}
const originalSlots = ctx.slots || {}
const mergedSlots = { ...themeSlots, ...originalSlots }
// Merge attrs into props so the engine sees class/style from fallthrough attrs
// before merging theme values — prevents ctx.attrs from overwriting engine output.
// Running inside the render function also keeps mergedProps reactive to prop/attr changes.
const mergedProps = applyThemeProps({ ...props, ...ctx.attrs })
for (const key of ['injectedClasses', 'injectedStyles', 'stylePlug']) {
delete mergedProps[key as keyof typeof mergedProps]
}
return h(component as LibComponent, mergedProps, mergedSlots)
}
}
return componentObj as I
}
}

3
packages/library/src/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
declare module '@nychthemeron/library/style'
declare module '@nychthemeron/library/theme'
declare module '@nychthemeron/library'

View file

@ -0,0 +1,25 @@
export {
ThemeEngine,
type ThemeLibrary,
type Themeable,
type LibComponent,
type Key,
type Props,
} from './engine'
export {
createNychthemeron,
Button,
LoadingIcon,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
} from './lib'

View file

@ -0,0 +1,210 @@
import type { App } from 'vue'
import {
Button as PrimeButton,
InputText as PrimeInputText,
Textarea as PrimeTextarea,
Checkbox as PrimeCheckbox,
CheckboxGroup as PrimeCheckboxGroup,
RadioButton as PrimeRadioButton,
RadioButtonGroup as PrimeRadioButtonGroup,
ToggleSwitch as PrimeToggleSwitch,
Select as PrimeSelect,
Card as PrimeCard,
Dialog as PrimeDialog,
Message as PrimeMessage,
Tag as PrimeTag,
} from 'primevue'
import { ThemeLibrary, type Themeable, ThemeEngine, type LibComponent, type Key } from '../engine'
import { NychLoadingIcon } from '../components'
/**
* Build a Themeable that themes a PrimeVue component through its passthrough (`pt`) API.
*
* The engine injects the base class (`nych-<base>`) onto `pt.root`. This factory layers a
* `nych-<base>-<section>` class onto every additional section so multi-part components
* (Checkbox box, Select overlay, Card body, ) can be fully styled in unstyled mode, where
* PrimeVue emits no classes of its own.
*/
const ptThemeable = <T extends LibComponent>(
component: T,
base: string,
sections: string[] = [],
): Themeable<T> => ({
component,
unstyled: true as Themeable<T>['unstyled'],
classes: [`nych-${base}`],
injectionKeys: { class: 'pt' as Key<T> },
propMutator: ((props: Record<string, unknown>) => {
const pt = (props.pt as Record<string, { class?: string }>) ?? {}
for (const section of sections) {
const existing = pt[section]?.class
pt[section] = {
...(pt[section] ?? {}),
class: [existing, `nych-${base}-${section}`].filter(Boolean).join(' '),
}
}
props.pt = pt
return props
}) as Themeable<T>['propMutator'],
})
const buttonThemeable: Themeable<typeof PrimeButton> = {
component: PrimeButton,
unstyled: true,
propMutator: (props) => {
const severity = (props.severity as string) ?? 'primary'
delete props.severity
if (props.loading) props.disabled = true
const pt = props.pt as { root?: { class?: string } }
const classes = pt?.root?.class?.split(' ') ?? []
classes.push(`nych-button-${severity}`)
if (!pt.root) pt.root = {}
pt.root.class = classes.join(' ')
props.pt = pt
return props
},
slots: {
loadingicon: NychLoadingIcon,
},
injectionKeys: {
class: 'pt',
},
}
const loadingIconThemeable: Themeable<typeof NychLoadingIcon> = {
component: NychLoadingIcon,
unstyled: false,
}
const nychthemeron: ThemeLibrary = {
components: {
Button: buttonThemeable,
LoadingIcon: loadingIconThemeable,
InputText: ptThemeable(PrimeInputText, 'input'),
Textarea: ptThemeable(PrimeTextarea, 'textarea'),
Checkbox: ptThemeable(PrimeCheckbox, 'checkbox', ['box', 'input', 'icon']),
CheckboxGroup: ptThemeable(PrimeCheckboxGroup, 'checkbox-group'),
RadioButton: ptThemeable(PrimeRadioButton, 'radio', ['box', 'input', 'icon']),
RadioButtonGroup: ptThemeable(PrimeRadioButtonGroup, 'radio-group'),
ToggleSwitch: ptThemeable(PrimeToggleSwitch, 'toggleswitch', ['input', 'slider', 'handle']),
Select: ptThemeable(PrimeSelect, 'select', [
'label',
'dropdown',
'dropdownIcon',
'overlay',
'header',
'listContainer',
'list',
'option',
'optionLabel',
'emptyMessage',
]),
Card: ptThemeable(PrimeCard, 'card', [
'header',
'body',
'caption',
'title',
'subtitle',
'content',
'footer',
]),
Dialog: ptThemeable(PrimeDialog, 'dialog', [
'mask',
'header',
'title',
'headerActions',
'content',
'footer',
]),
Message: ptThemeable(PrimeMessage, 'message', [
'contentWrapper',
'content',
'icon',
'text',
'closeButton',
'closeIcon',
]),
Tag: ptThemeable(PrimeTag, 'tag', ['label', 'icon']),
},
}
const _engine = new ThemeEngine(nychthemeron)
export const Button: typeof PrimeButton = _engine.getComponent<typeof PrimeButton>('Button')
export const LoadingIcon = _engine.getComponent<typeof NychLoadingIcon>('LoadingIcon')
export const InputText: typeof PrimeInputText =
_engine.getComponent<typeof PrimeInputText>('InputText')
export const Textarea: typeof PrimeTextarea = _engine.getComponent<typeof PrimeTextarea>('Textarea')
export const Checkbox: typeof PrimeCheckbox = _engine.getComponent<typeof PrimeCheckbox>('Checkbox')
export const CheckboxGroup: typeof PrimeCheckboxGroup =
_engine.getComponent<typeof PrimeCheckboxGroup>('CheckboxGroup')
export const RadioButton: typeof PrimeRadioButton =
_engine.getComponent<typeof PrimeRadioButton>('RadioButton')
export const RadioButtonGroup: typeof PrimeRadioButtonGroup =
_engine.getComponent<typeof PrimeRadioButtonGroup>('RadioButtonGroup')
export const ToggleSwitch: typeof PrimeToggleSwitch =
_engine.getComponent<typeof PrimeToggleSwitch>('ToggleSwitch')
export const Select: typeof PrimeSelect = _engine.getComponent<typeof PrimeSelect>('Select')
export const Card: typeof PrimeCard = _engine.getComponent<typeof PrimeCard>('Card')
export const Dialog: typeof PrimeDialog = _engine.getComponent<typeof PrimeDialog>('Dialog')
export const Message: typeof PrimeMessage = _engine.getComponent<typeof PrimeMessage>('Message')
export const Tag: typeof PrimeTag = _engine.getComponent<typeof PrimeTag>('Tag')
export const createNychthemeron = (): {
Button: typeof PrimeButton
LoadingIcon: typeof NychLoadingIcon
InputText: typeof PrimeInputText
Textarea: typeof PrimeTextarea
Checkbox: typeof PrimeCheckbox
CheckboxGroup: typeof PrimeCheckboxGroup
RadioButton: typeof PrimeRadioButton
RadioButtonGroup: typeof PrimeRadioButtonGroup
ToggleSwitch: typeof PrimeToggleSwitch
Select: typeof PrimeSelect
Card: typeof PrimeCard
Dialog: typeof PrimeDialog
Message: typeof PrimeMessage
Tag: typeof PrimeTag
install: (app: App) => void
} => ({
Button,
LoadingIcon,
InputText,
Textarea,
Checkbox,
CheckboxGroup,
RadioButton,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
install(app: App) {
app.component('NychButton', Button)
app.component('NychLoadingIcon', LoadingIcon)
app.component('NychInputText', InputText)
app.component('NychTextarea', Textarea)
app.component('NychCheckbox', Checkbox)
app.component('NychCheckboxGroup', CheckboxGroup)
app.component('NychRadioButton', RadioButton)
app.component('NychRadioButtonGroup', RadioButtonGroup)
app.component('NychToggleSwitch', ToggleSwitch)
app.component('NychSelect', Select)
app.component('NychCard', Card)
app.component('NychDialog', Dialog)
app.component('NychMessage', Message)
app.component('NychTag', Tag)
},
})

View file

@ -0,0 +1,894 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import Button from 'primevue/button'
import { h, ref } from 'vue'
import { ThemeEngine, ThemeLibrary, type Themeable } from '../src/engine'
const getTheme = () => {
const button: Themeable<typeof Button> = {
classes: ['test-class'],
styles: ['width: 50px'],
propMutator: (props) => {
if (props.variant) {
const pt = props.pt as { root: { class?: string } }
const classes = pt.root.class?.split(' ') ?? []
classes.push(`test-${props.variant}`)
pt.root.class = classes.join(' ')
props.pt = pt
}
return props
},
injectionKeys: {
class: 'pt',
},
component: Button,
}
const buttonTheme: ThemeLibrary = {
components: {
Button: button,
},
}
return buttonTheme
}
const getSimpleTheme = () => {
const button: Themeable<typeof Button> = {
classes: ['btn-default'],
styles: ['padding: 8px'],
injectionKeys: {
class: 'pt',
},
component: Button,
}
return {
components: {
Button: button,
},
}
}
//eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => {
// Apply prop defaults from the component definition
const mergedProps: Record<string, unknown> = {}
if (component.props) {
for (const [key, propDef] of Object.entries(component.props)) {
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
mergedProps[key] = propDef.default
}
}
}
// Merge in user-provided props
Object.assign(mergedProps, props)
if (component.setup) {
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
// Engine always returns a render function; call it and return the vnode's props
// so tests can inspect the final merged props passed to the original component.
if (typeof result === 'function') {
const vnode = result()
return (vnode?.props as Record<string, unknown>) || {}
}
return result
}
return mergedProps
}
describe('ThemeEngine', () => {
it('instantiates properly', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
expect(engine).not.toBeNull()
expect(engine.lib).toBe(theme)
})
it('returns a wrapped component', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton).not.toBeNull()
expect(themedButton.props).toBeDefined()
expect(themedButton.setup).toBeDefined()
})
it('injects classes from theme definition', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.class).toContain('btn-default')
})
it('injects styles from theme definition', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toContain('padding: 8px')
})
it('applies propMutator to modify component props', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
variant: 'primary',
})
expect(result.pt?.root?.class).toContain('test-primary')
})
it('handles PT injection key for PrimeVue components', () => {
const theme = getTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt).toBeDefined()
expect(result.pt.root).toBeDefined()
expect(result.pt.root.class).toBeDefined()
expect(result.pt.root.style).toBeDefined()
})
it('applies class prefix when stylePlug is provided', () => {
const simpleTheme = getSimpleTheme()
const engine = new ThemeEngine(simpleTheme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
stylePlug: 'custom',
pt: {},
})
expect(result.pt?.root?.class).toContain('custom-btn-default')
})
it('merges user-provided PT with theme-injected classes', () => {
const simpleTheme = getSimpleTheme()
const engine = new ThemeEngine(simpleTheme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
label: {
class: 'user-label-class',
},
},
})
// Theme classes should be injected to root
expect(result.pt?.root?.class).toContain('btn-default')
// User-provided PT structure should be preserved
expect(result.pt?.label?.class).toBe('user-label-class')
})
it('preserves existing PT structure when merging', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
root: { class: 'existing' },
label: { class: 'label-class' },
},
})
expect(result.pt?.root).toBeDefined()
expect(result.pt?.label?.class).toBe('label-class')
})
it('preserves theme classes when user provides pt.root', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: { root: { class: 'user-root' } },
})
expect(result.pt?.root?.class).toContain('theme-class')
expect(result.pt?.root?.class).toContain('user-root')
})
it('handles missing injectionKeys by defaulting to class and style', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['default-class'],
styles: ['color: red'],
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.class).toBe('default-class')
expect(result.style).toBe('color: red')
})
it('supports unstyled prop passthrough', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
unstyled: true,
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.props.unstyled.default).toBe(true)
})
it('combines multiple classes in correct order', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['class-a', 'class-b', 'class-c'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.class).toBe('class-a class-b class-c')
})
it('combines multiple styles correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['padding: 8px', 'margin: 4px', 'border: 1px solid'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toBe('padding: 8px; margin: 4px; border: 1px solid')
})
it('properly injects injectedClasses and injectedStyles props', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['injected-class'],
styles: ['injected-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.props.injectedClasses).toBeDefined()
expect(themedButton.props.injectedStyles).toBeDefined()
expect(themedButton.props.injectedClasses.default).toEqual(['injected-class'])
expect(themedButton.props.injectedStyles.default).toEqual(['injected-style'])
})
it('applies non-PT class injection correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class'],
styles: ['my-style'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.class).toBe('my-class')
expect(result.style).toBe('my-style')
})
it('handles object-form class values', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
class: { 'active-class': true, 'inactive-class': false },
})
expect(result.class).toContain('active-class')
expect(result.class).not.toContain('inactive-class')
expect(result.class).toContain('theme-class')
})
it('merges array and string classes correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'class',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result1 = callSetup(themedButton, {
class: ['user-class-1', 'user-class-2'],
})
expect(result1.class).toContain('user-class-1')
expect(result1.class).toContain('user-class-2')
expect(result1.class).toContain('theme-class')
const result2 = callSetup(themedButton, {
class: 'user-string-class',
})
expect(result2.class).toContain('user-string-class')
expect(result2.class).toContain('theme-class')
})
it('wraps components with custom slots', () => {
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
expect(themedButton.setup).toBeDefined()
expect(themedButton.setup({}, {})).toBeDefined()
})
it('slot wrapper returns a render function', () => {
const MockSlot = { name: 'MockSlot', template: '<div>Mock Slot</div>' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const setupResult = themedButton.setup({}, {})
expect(typeof setupResult).toBe('function')
})
it('combines slots with classes and styles', () => {
const MockSlot = { name: 'MockSlot' }
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['btn-class'],
styles: ['color: blue'],
slots: {
icon: MockSlot,
},
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
// With slots, setup returns a render function, not props
const setupResult = themedButton.setup(
{
injectedClasses: ['btn-class'],
injectedStyles: ['color: blue'],
pt: {},
},
{},
)
expect(typeof setupResult).toBe('function')
})
it('renders original component with modified props via h()', () => {
const ComponentWithSetup = {
props: { class: String },
//eslint-disable-next-line
setup(props: Record<string, unknown>) {
return () => h('div', { class: props.class })
},
}
const theme: ThemeLibrary = {
components: {
Custom: {
component: ComponentWithSetup,
classes: ['custom-class'],
},
},
}
const engine = new ThemeEngine(theme)
const themedComponent = engine.getComponent('Custom')
const result = callSetup(themedComponent, {})
// Engine wraps via h() — vnode props contain the theme-injected class
expect(result.class).toBe('custom-class')
})
it('slot functions are created for each slot component', () => {
const MockSlot = {
name: 'MockSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'slot content')
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const renderFn = themedButton.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('forwards update:modelValue so v-model works on wrapped components', async () => {
// Regression: a cloned wrapper inherited the component's emits/extends, so
// Vue withheld onUpdate:modelValue from ctx.attrs and the engine never
// forwarded it — breaking two-way v-model on every wrapped component.
const Emitter = {
name: 'Emitter',
props: { modelValue: { type: String, default: '' } },
emits: ['update:modelValue'],
//eslint-disable-next-line
setup(_props: Record<string, unknown>, { emit }: { emit: (e: string, v: string) => void }) {
return () => h('button', { onClick: () => emit('update:modelValue', 'changed') }, 'x')
},
}
const theme: ThemeLibrary = { components: { Emitter: { component: Emitter } } }
const engine = new ThemeEngine(theme)
const Wrapped = engine.getComponent('Emitter')
const Parent = {
components: { Wrapped },
setup() {
const val = ref('initial')
return { val }
},
template: `<div><Wrapped v-model="val" /><span class="out">{{ val }}</span></div>`,
}
const wrapper = mount(Parent)
expect(wrapper.find('.out').text()).toBe('initial')
await wrapper.find('button').trigger('click')
expect(wrapper.find('.out').text()).toBe('changed')
})
it('merges theme slots with original component slots', () => {
const ThemeSlot = {
name: 'ThemeSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'theme slot')
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: ThemeSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const originalSlots = {
label: () => h('span', 'label slot'),
}
const renderFn = themedButton.setup({}, { slots: originalSlots })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('handles non-string style values by defaulting to empty', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['theme-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
style: { someObject: 'value' },
pt: {},
})
// When style is not a string, it defaults to empty array, so only theme styles are applied
expect(result.pt?.root?.style).toBe('theme-style')
})
it('preserves already-prefixed classes', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class', 'custom-already-prefixed'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
stylePlug: 'custom',
pt: {},
})
expect(result.pt?.root?.class).toContain('custom-my-class')
expect(result.pt?.root?.class).toContain('custom-already-prefixed')
})
it('handles styleKey as pt injection key', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['style-value'],
injectionKeys: {
class: 'pt',
style: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {},
})
expect(result.pt?.root?.style).toContain('style-value')
})
it('slot wrapper works with non-function modifiedSetup', () => {
const MockSlot = {
name: 'MockSlot',
//eslint-disable-next-line
setup() {
return () => h('span', 'slot')
},
}
const ComponentWithoutSetup = {
name: 'ComponentWithoutSetup',
template: '<div></div>',
}
const theme: ThemeLibrary = {
components: {
Custom: {
component: ComponentWithoutSetup,
slots: {
icon: MockSlot,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedComponent: any = engine.getComponent('Custom')
const renderFn = themedComponent.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
it('does not pass internal engine props to the wrapped component', () => {
const theme = getSimpleTheme()
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, { pt: {}, stylePlug: 'custom' })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('handles classKey that is not pt', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['my-class'],
styles: ['my-style'],
injectionKeys: {
class: 'customClass',
style: 'customStyle',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.customClass).toBe('my-class')
expect(result.customStyle).toBe('my-style')
})
it('spreads existing pt object when merging theme props', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['theme-class'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
pt: {
label: { class: 'label-class', style: 'color: red' },
icon: { class: 'icon-class' },
tooltip: { content: 'help text' },
},
})
expect(result.pt?.root?.class).toContain('theme-class')
expect(result.pt?.label?.class).toBe('label-class')
expect(result.pt?.label?.style).toBe('color: red')
expect(result.pt?.icon?.class).toBe('icon-class')
expect(result.pt?.tooltip?.content).toBe('help text')
})
it('handles style as string and splits it correctly', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
styles: ['margin: 10px'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {
style: 'padding: 5px; color: blue',
pt: {},
})
expect(result.pt?.root?.style).toContain('padding: 5px')
expect(result.pt?.root?.style).toContain('color: blue')
expect(result.pt?.root?.style).toContain('margin: 10px')
})
it('creates pt structure when pt is not provided', () => {
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
classes: ['btn-class'],
styles: ['btn-style'],
injectionKeys: {
class: 'pt',
},
},
},
}
const engine = new ThemeEngine(theme)
const themedButton = engine.getComponent<typeof Button>('Button')
const result = callSetup(themedButton, {})
expect(result.pt).toBeDefined()
expect(result.pt?.root?.class).toBe('btn-class')
expect(result.pt?.root?.style).toBe('btn-style')
})
it('slot functions created with proper scope handling', () => {
const mockSlotComponent = {
name: 'IconSlot',
//eslint-disable-next-line
setup(props: Record<string, unknown>) {
return () => h('i', `Icon: ${props.name}`)
},
}
const theme: ThemeLibrary = {
components: {
Button: {
component: Button,
slots: {
icon: mockSlotComponent,
},
},
},
}
const engine = new ThemeEngine(theme)
//eslint-disable-next-line
const themedButton: any = engine.getComponent<typeof Button>('Button')
const renderFn = themedButton.setup({}, { slots: {} })
expect(typeof renderFn).toBe('function')
const vnode = renderFn()
expect(vnode).toBeDefined()
})
})

View file

@ -0,0 +1,157 @@
import { describe, it, expect, vi } from 'vitest'
import type { App } from 'vue'
import {
createNychthemeron,
Button,
LoadingIcon,
InputText,
Textarea,
Checkbox,
RadioButton,
CheckboxGroup,
RadioButtonGroup,
ToggleSwitch,
Select,
Card,
Dialog,
Message,
Tag,
} from '../src/index'
//eslint-disable-next-line
const callSetup = (component: any, props: Record<string, unknown>) => {
const mergedProps: Record<string, unknown> = {}
if (component.props) {
for (const [key, propDef] of Object.entries(component.props)) {
if (typeof propDef === 'object' && propDef !== null && 'default' in propDef) {
mergedProps[key] = (propDef as { default: unknown }).default
}
}
}
Object.assign(mergedProps, props)
if (component.setup) {
const result = component.setup(mergedProps, { slots: {}, attrs: {} })
if (typeof result === 'function') {
const vnode = result()
return (vnode?.props as Record<string, unknown>) || {}
}
return result
}
return mergedProps
}
describe('createNychthemeron', () => {
it('returns a Vue plugin with install method', () => {
const plugin = createNychthemeron()
expect(typeof plugin.install).toBe('function')
})
it('install registers NychButton and NychLoadingIcon globally', () => {
const plugin = createNychthemeron()
const mockApp = { component: vi.fn() } as unknown as App
plugin.install(mockApp)
expect(mockApp.component).toHaveBeenCalledWith('NychButton', expect.anything())
expect(mockApp.component).toHaveBeenCalledWith('NychLoadingIcon', expect.anything())
})
it('exposes Button and LoadingIcon components', () => {
const { Button: btn, LoadingIcon: icon } = createNychthemeron()
expect(btn).toBeDefined()
expect(icon).toBeDefined()
})
})
describe('Button', () => {
it('is a valid Vue component', () => {
expect(Button).toBeDefined()
expect(typeof (Button as Record<string, unknown>).setup).toBe('function')
})
it('auto-disables when loading is true', () => {
const result = callSetup(Button, { pt: {}, loading: true })
expect(result.disabled).toBe(true)
})
it('stays enabled when loading is false', () => {
const result = callSetup(Button, { pt: {}, loading: false })
expect(result.disabled).toBeFalsy()
})
it('injects severity class into pt.root', () => {
const result = callSetup(Button, { pt: {}, severity: 'danger' })
expect(result.pt?.root?.class).toContain('nych-button-danger')
})
it('defaults severity to primary', () => {
const result = callSetup(Button, { pt: {} })
expect(result.pt?.root?.class).toContain('nych-button-primary')
})
it('does not leak engine internal props', () => {
const result = callSetup(Button, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('does not leak severity to inner component', () => {
const result = callSetup(Button, { pt: {}, severity: 'danger' })
expect(result).not.toHaveProperty('severity')
})
})
describe('LoadingIcon', () => {
it('is a valid Vue component', () => {
expect(LoadingIcon).toBeDefined()
})
})
describe('themed pt components', () => {
const cases = [
{ name: 'InputText', component: InputText, base: 'nych-input' },
{ name: 'Textarea', component: Textarea, base: 'nych-textarea' },
{ name: 'Checkbox', component: Checkbox, base: 'nych-checkbox' },
{ name: 'CheckboxGroup', component: CheckboxGroup, base: 'nych-checkbox-group' },
{ name: 'RadioButton', component: RadioButton, base: 'nych-radio' },
{ name: 'RadioButtonGroup', component: RadioButtonGroup, base: 'nych-radio-group' },
{ name: 'ToggleSwitch', component: ToggleSwitch, base: 'nych-toggleswitch' },
{ name: 'Select', component: Select, base: 'nych-select' },
{ name: 'Card', component: Card, base: 'nych-card' },
{ name: 'Dialog', component: Dialog, base: 'nych-dialog' },
{ name: 'Message', component: Message, base: 'nych-message' },
{ name: 'Tag', component: Tag, base: 'nych-tag' },
]
it.each(cases)('$name is a valid Vue component', ({ component }) => {
expect(component).toBeDefined()
expect(typeof (component as Record<string, unknown>).setup).toBe('function')
})
it.each(cases)('$name injects its base class onto pt.root', ({ component, base }) => {
const result = callSetup(component, { pt: {} })
expect(result.pt?.root?.class).toContain(base)
})
it.each(cases)('$name does not leak engine internal props', ({ component }) => {
const result = callSetup(component, { pt: {} })
expect(result).not.toHaveProperty('injectedClasses')
expect(result).not.toHaveProperty('injectedStyles')
expect(result).not.toHaveProperty('stylePlug')
})
it('Checkbox themes its box, input and icon sections', () => {
const result = callSetup(Checkbox, { pt: {} })
expect(result.pt?.box?.class).toContain('nych-checkbox-box')
expect(result.pt?.input?.class).toContain('nych-checkbox-input')
expect(result.pt?.icon?.class).toContain('nych-checkbox-icon')
})
it('Select themes its overlay and option sections', () => {
const result = callSetup(Select, { pt: {} })
expect(result.pt?.overlay?.class).toContain('nych-select-overlay')
expect(result.pt?.option?.class).toContain('nych-select-option')
})
})

View file

@ -0,0 +1,18 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"rootDir": ".",
"module": "preserve",
"moduleResolution": "bundler",
"outDir": "./dist",
"declaration": true,
"declarationMap": true,
"emitDeclarationOnly": false,
"paths": {
"@/*": ["./src/*"],
"~/*": ["./src/*"]
}
},
"include": ["src/**/*.ts", "src/**/*.vue", "tests/**/*.ts"],
"exclude": ["dist"]
}

View file

@ -0,0 +1,30 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import dts from 'vite-plugin-dts'
export default defineConfig({
plugins: [vue(), dts({ tsconfigPath: './tsconfig.json', include: ['src/**/*'] })],
resolve: {
alias: {
'~': fileURLToPath(new URL('./src', import.meta.url)),
},
},
build: {
lib: {
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
name: 'NychthemeronLibrary',
formats: ['es'],
fileName: () => 'index.js',
},
rollupOptions: {
external: ['vue', 'primevue'],
output: {
globals: {
vue: 'Vue',
primevue: 'PrimeVue',
},
},
},
},
})

View file

@ -0,0 +1,17 @@
import { defineConfig } from 'vitest/config'
import vue from '@vitejs/plugin-vue'
import { fileURLToPath } from 'node:url'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'~': fileURLToPath(new URL('./src', import.meta.url)),
},
},
test: {
globals: true,
environment: 'jsdom',
include: ['**/*.spec.ts'],
},
})

View file

@ -0,0 +1,27 @@
import { fileURLToPath } from 'node:url'
import { dirname } from 'node:path'
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: ['../stories/**/*.stories.ts', '../stories/**/*.mdx'],
addons: [getAbsolutePath('@storybook/addon-links'), getAbsolutePath('@storybook/addon-docs')],
framework: {
name: getAbsolutePath('@storybook/vue3-vite'),
options: {},
},
core: {
disableTelemetry: true,
},
viteFinal: (config) => {
if (config.build) {
config.build.chunkSizeWarningLimit = 1000
}
return config
},
}
export default config
function getAbsolutePath(value: string): string {
return dirname(fileURLToPath(import.meta.resolve(`${value}/package.json`)))
}

View file

@ -0,0 +1,58 @@
import type { Preview } from '@storybook/vue3-vite'
import { setup } from '@storybook/vue3-vite'
import PrimeVue from 'primevue/config'
import '@nychthemeron/library/theme'
setup((app) => {
app.use(PrimeVue, { unstyled: true })
})
const preview: Preview = {
parameters: {
layout: 'centered',
toolbar: {
theme: {
items: [
{ value: 'hades', title: 'Dark (Hades)', left: '🌙' },
{ value: 'apollo', title: 'Light (Apollo)', left: '☀️' },
],
dynamicTitle: true,
},
},
docs: {
description: {
component: 'Component documentation',
},
},
},
decorators: [
(story, context) => {
const theme = context.globals.theme || 'hades'
return {
setup() {
document.documentElement.setAttribute('data-theme', theme)
},
components: { story },
template: '<story />',
}
},
],
globalTypes: {
theme: {
name: 'Theme',
description: 'Global theme for all components',
defaultValue: 'hades',
toolbar: {
icon: 'paintbrush',
items: [
{ value: 'hades', title: 'Dark (Hades)' },
{ value: 'apollo', title: 'Light (Apollo)' },
],
dynamicTitle: true,
},
},
},
}
export default preview

View file

@ -0,0 +1,12 @@
# syntax=docker/dockerfile:1
FROM oven/bun:1 AS build
WORKDIR /app
COPY . .
RUN bun install --frozen-lockfile
RUN cd packages/library && bun run build
RUN cd packages/playground && bun run build
FROM nginx:alpine AS runtime
COPY --from=build /app/packages/playground/storybook-static /usr/share/nginx/html
EXPOSE 80

View file

@ -0,0 +1,26 @@
{
"name": "@nychthemeron/playground",
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "storybook dev -p 6006",
"build": "storybook build",
"preview": "npx vite preview"
},
"dependencies": {
"@nychthemeron/library": "workspace:*",
"primevue": "^4.5.0",
"vue": "^3.5.32"
},
"devDependencies": {
"@storybook/addon-links": "^10.4.2",
"@storybook/blocks": "^8.0.0",
"@storybook/vue3": "^10.4.2",
"@storybook/vue3-vite": "^10.4.2",
"storybook": "^10.4.2",
"typescript": "~6.0.0",
"vue": "^3.5.32",
"eslint-plugin-storybook": "10.4.2",
"@storybook/addon-docs": "^10.4.2"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View file

@ -0,0 +1,143 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Button as NychButton } from '@nychthemeron/library'
const meta = {
title: 'Components/Button',
component: NychButton,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['primary', 'secondary', 'info', 'success', 'warning', 'danger'],
description: 'Button color severity',
},
size: {
control: { type: 'select' },
options: ['small', 'normal', 'large'],
description: 'Button size',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the button',
},
loading: {
control: { type: 'boolean' },
description: 'Show loading state',
},
label: {
control: { type: 'text' },
description: 'Button label text',
},
},
} satisfies Meta<typeof NychButton>
export default meta
type Story = StoryObj<typeof meta>
export const Primary: Story = {
args: {
label: 'Primary Button',
severity: 'primary',
},
}
export const Secondary: Story = {
args: {
label: 'Secondary Button',
severity: 'secondary',
},
}
export const Info: Story = {
args: {
label: 'Info Button',
severity: 'info',
},
}
export const Success: Story = {
args: {
label: 'Success Button',
severity: 'success',
},
}
export const Warning: Story = {
args: {
label: 'Warning Button',
severity: 'warning',
},
}
export const Danger: Story = {
args: {
label: 'Danger Button',
severity: 'danger',
},
}
export const Disabled: Story = {
args: {
label: 'Disabled Button',
disabled: true,
},
}
export const Loading: Story = {
args: {
label: 'Loading Button',
loading: true,
},
}
export const Sizes: Story = {
render: () => ({
components: { NychButton },
setup() {
return {}
},
template: `
<div style="display: flex; gap: 1rem; align-items: center;">
<NychButton label="Small" size="small" />
<NychButton label="Normal" size="normal" />
<NychButton label="Large" size="large" />
</div>
`,
}),
}
export const Interactive: Story = {
render: () => ({
components: { NychButton },
setup() {
const clickCount = ref(0)
const isLoading = ref(false)
const handleClick = () => {
clickCount.value++
isLoading.value = true
setTimeout(() => {
isLoading.value = false
}, 1000)
}
return { clickCount, isLoading, handleClick }
},
template: `
<div style="display: flex; flex-direction: column; align-items: center; gap: 1rem; padding: 2rem;">
<NychButton
label="Click Me"
@click="handleClick"
:loading="isLoading"
:disabled="isLoading"
/>
<p style="font-size: 1.2rem; font-weight: bold;">Clicks: {{ clickCount }}</p>
</div>
`,
}),
}

View file

@ -0,0 +1,73 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Card as NychCard, Button as NychButton, Tag as NychTag } from '@nychthemeron/library'
const meta = {
title: 'Components/Card',
component: NychCard,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof NychCard>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychCard },
template: `
<NychCard style="width: 22rem;">
<template #title>Elysium</template>
<template #subtitle>The blessed fields</template>
<template #content>
A resting place reserved for the heroic and the virtuous, where the
air is mild and the laurel never wilts.
</template>
</NychCard>
`,
}),
}
export const WithFooter: Story = {
render: () => ({
components: { NychCard, NychButton },
template: `
<NychCard style="width: 22rem;">
<template #title>Passage of the Styx</template>
<template #subtitle>One obol required</template>
<template #content>
Charon ferries the souls of the dead across the river that divides
the world of the living from the world of the dead.
</template>
<template #footer>
<NychButton label="Pay the toll" />
<NychButton label="Turn back" severity="secondary" />
</template>
</NychCard>
`,
}),
}
export const WithHeaderAndTag: Story = {
render: () => ({
components: { NychCard, NychTag },
template: `
<NychCard style="width: 22rem;">
<template #header>
<div style="height: 120px; background: linear-gradient(135deg, var(--surface-2), var(--surface-3));"></div>
</template>
<template #title>
<span style="display: flex; align-items: center; gap: 0.5rem;">
Oracle of Delphi <NychTag value="Sacred" severity="info" />
</span>
</template>
<template #content>
Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse.
</template>
</NychCard>
`,
}),
}

View file

@ -0,0 +1,111 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Checkbox as NychCheckbox, CheckboxGroup as NychCheckboxGroup } from '@nychthemeron/library'
const meta = {
title: 'Components/Checkbox',
component: NychCheckbox,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the checkbox',
},
},
} satisfies Meta<typeof NychCheckbox>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(false)
return { checked }
},
template: `<NychCheckbox v-model="checked" binary />`,
}),
}
export const Checked: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(true)
return { checked }
},
template: `<NychCheckbox v-model="checked" binary />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const checked = ref(true)
return { checked }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychCheckbox :modelValue="false" binary disabled />
<NychCheckbox :modelValue="true" binary disabled />
</div>
`,
}),
}
export const WithLabels: Story = {
render: () => ({
components: { NychCheckbox },
setup() {
const offerings = ref(['honey'])
return { offerings }
},
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="honey" /> Honey
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="wine" /> Wine
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox v-model="offerings" value="laurel" /> Laurel
</label>
<p style="color: var(--text-muted); margin-top: 0.25rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
</div>
`,
}),
}
// CheckboxGroup holds the array v-model and shares a name across children.
export const Group: Story = {
render: () => ({
components: { NychCheckbox, NychCheckboxGroup },
setup() {
const offerings = ref(['honey'])
return { offerings }
},
template: `
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychCheckboxGroup v-model="offerings" name="offerings">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="honey" /> Honey
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="wine" /> Wine
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychCheckbox value="laurel" /> Laurel
</label>
</NychCheckboxGroup>
<p style="color: var(--text-muted); margin-top: 0.75rem;">Offerings: {{ offerings.join(', ') || 'none' }}</p>
</div>
`,
}),
}

View file

@ -0,0 +1,79 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Dialog as NychDialog, Button as NychButton } from '@nychthemeron/library'
const meta = {
title: 'Components/Dialog',
component: NychDialog,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta<typeof NychDialog>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychDialog, NychButton },
setup() {
const visible = ref(false)
return { visible }
},
template: `
<div>
<NychButton label="Summon the Oracle" @click="visible = true" />
<NychDialog v-model:visible="visible" modal header="Oracle of Delphi" style="width: 28rem;">
<p style="margin: 0;">
Seekers travelled from across the Aegean to hear the Pythia speak the
will of Apollo in riddling verse. Approach, and ask your question.
</p>
<template #footer>
<NychButton label="Withdraw" severity="secondary" @click="visible = false" />
<NychButton label="Ask" @click="visible = false" />
</template>
</NychDialog>
</div>
`,
}),
}
// Rendered open by default so the dialog surface is visible in docs/snapshots.
export const Open: Story = {
render: () => ({
components: { NychDialog, NychButton },
setup() {
const visible = ref(true)
return { visible }
},
template: `
<NychDialog v-model:visible="visible" modal header="Passage of the Styx" style="width: 28rem;">
<p style="margin: 0;">
Charon ferries the souls of the dead across the river that divides the
world of the living from the world of the dead. One obol is required.
</p>
<template #footer>
<NychButton label="Turn back" severity="secondary" @click="visible = false" />
<NychButton label="Pay the toll" @click="visible = false" />
</template>
</NychDialog>
`,
}),
}
export const WithoutFooter: Story = {
render: () => ({
components: { NychDialog },
setup() {
const visible = ref(true)
return { visible }
},
template: `
<NychDialog v-model:visible="visible" modal header="An Omen" style="width: 26rem;">
<p style="margin: 0;">A crow has settled upon the western gate. Interpret it as you will.</p>
</NychDialog>
`,
}),
}

View file

@ -0,0 +1,27 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta = {
title: 'Example/Welcome',
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
} satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
export const Welcome: Story = {
render: () => ({
template: `
<div style="padding: 2rem; text-align: center;">
<h1 style="font-size: 2rem; font-weight: bold; margin-bottom: 1rem;">
Nychthemeron Component Library
</h1>
<p style="color: var(--text-muted);">
Interactive playground for Vue components with PrimeVue
</p>
</div>
`,
}),
}

View file

@ -0,0 +1,93 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { InputText as NychInputText } from '@nychthemeron/library'
const meta = {
title: 'Components/InputText',
component: NychInputText,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
size: {
control: { type: 'select' },
options: ['small', undefined, 'large'],
description: 'Input size',
},
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the input',
},
invalid: {
control: { type: 'boolean' },
description: 'Mark the input as invalid',
},
},
} satisfies Meta<typeof NychInputText>
export default meta
type Story = StoryObj<typeof meta>
const modelTemplate = (attrs: string) => ({
components: { NychInputText },
setup() {
const value = ref('')
return { value }
},
template: `<NychInputText v-model="value" ${attrs} />`,
})
export const Default: Story = {
render: () => modelTemplate('placeholder="Speak, traveller…"'),
}
export const Filled: Story = {
render: () => ({
components: { NychInputText },
setup() {
const value = ref('Charon')
return { value }
},
template: `<NychInputText v-model="value" />`,
}),
}
export const Invalid: Story = {
render: () => modelTemplate('placeholder="Required" invalid'),
}
export const Disabled: Story = {
render: () => ({
components: { NychInputText },
setup() {
const value = ref('Sealed')
return { value }
},
template: `<NychInputText v-model="value" disabled />`,
}),
}
export const Sizes: Story = {
render: () => ({
components: { NychInputText },
setup() {
const a = ref('')
const b = ref('')
const c = ref('')
return { a, b, c }
},
template: `
<div style="display: flex; flex-direction: column; gap: 1rem; width: 16rem;">
<NychInputText v-model="a" size="small" placeholder="Small" />
<NychInputText v-model="b" placeholder="Normal" />
<NychInputText v-model="c" size="large" placeholder="Large" />
</div>
`,
}),
}

View file

@ -0,0 +1,41 @@
import { Meta } from '@storybook/addon-docs/blocks'
<Meta title="Get Started/Installation" />
# Installation
`@nychthemeron/library` is published to a private npm registry hosted on
Forgejo. Configure your package manager for the `@nychthemeron` scope, then
install the package.
## Configure the registry
Add the following to your project's `.npmrc`:
```
@nychthemeron:registry=https://git.mcpeakdev.com/api/packages/mcpeakdev/npm/
```
## npm
```bash
npm install @nychthemeron/library
```
## pnpm
```bash
pnpm add @nychthemeron/library
```
## yarn
```bash
yarn add @nychthemeron/library
```
## bun
```bash
bun add @nychthemeron/library
```

View file

@ -0,0 +1,86 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Message as NychMessage } from '@nychthemeron/library'
const meta = {
title: 'Components/Message',
component: NychMessage,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['info', 'success', 'warn', 'error', 'secondary'],
description: 'Message severity',
},
closable: {
control: { type: 'boolean' },
description: 'Show a close button',
},
},
} satisfies Meta<typeof NychMessage>
export default meta
type Story = StoryObj<typeof meta>
export const Info: Story = {
args: { severity: 'info' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The Oracle has spoken.</NychMessage>`,
}),
}
export const Success: Story = {
args: { severity: 'success' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The offering was accepted.</NychMessage>`,
}),
}
export const Warn: Story = {
args: { severity: 'warn' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The ferryman grows impatient.</NychMessage>`,
}),
}
export const Error: Story = {
args: { severity: 'error' },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">The gods have refused your plea.</NychMessage>`,
}),
}
export const Closable: Story = {
args: { severity: 'info', closable: true },
render: (args) => ({
components: { NychMessage },
setup: () => ({ args }),
template: `<NychMessage v-bind="args" style="width: 24rem;">This omen may be dismissed.</NychMessage>`,
}),
}
export const AllSeverities: Story = {
render: () => ({
components: { NychMessage },
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; width: 24rem;">
<NychMessage severity="info">The Oracle has spoken.</NychMessage>
<NychMessage severity="success">The offering was accepted.</NychMessage>
<NychMessage severity="warn">The ferryman grows impatient.</NychMessage>
<NychMessage severity="error">The gods have refused your plea.</NychMessage>
<NychMessage severity="secondary">A neutral notice from the keeper.</NychMessage>
</div>
`,
}),
}

View file

@ -0,0 +1,109 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import {
RadioButton as NychRadioButton,
RadioButtonGroup as NychRadioButtonGroup,
} from '@nychthemeron/library'
const meta = {
title: 'Components/RadioButton',
component: NychRadioButton,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the radio button',
},
},
} satisfies Meta<typeof NychRadioButton>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const realm = ref('underworld')
return { realm }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychRadioButton v-model="realm" value="underworld" />
<NychRadioButton v-model="realm" value="sky" />
</div>
`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const realm = ref('sky')
return { realm }
},
template: `
<div style="display: flex; gap: 1rem;">
<NychRadioButton :modelValue="'underworld'" value="sky" disabled />
<NychRadioButton :modelValue="'sky'" value="sky" disabled />
</div>
`,
}),
}
export const WithLabels: Story = {
render: () => ({
components: { NychRadioButton },
setup() {
const god = ref('apollo')
return { god }
},
template: `
<div style="display: flex; flex-direction: column; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body);">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="apollo" /> Apollo
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="hades" /> Hades
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton v-model="god" value="hermes" /> Hermes
</label>
<p style="color: var(--text-muted); margin-top: 0.25rem;">Chosen: {{ god }}</p>
</div>
`,
}),
}
// RadioButtonGroup binds the v-model once on the group and shares a name across
// children — the children only carry a `value`.
export const Group: Story = {
render: () => ({
components: { NychRadioButton, NychRadioButtonGroup },
setup() {
const god = ref('apollo')
return { god }
},
template: `
<div style="font-family: var(--font-sans); color: var(--text-body);">
<NychRadioButtonGroup v-model="god" name="god">
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="apollo" /> Apollo
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="hades" /> Hades
</label>
<label style="display: flex; align-items: center; gap: 0.5rem; cursor: pointer;">
<NychRadioButton value="hermes" /> Hermes
</label>
</NychRadioButtonGroup>
<p style="color: var(--text-muted); margin-top: 0.75rem;">Chosen: {{ god }}</p>
</div>
`,
}),
}

View file

@ -0,0 +1,77 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Select as NychSelect } from '@nychthemeron/library'
const meta = {
title: 'Components/Select',
component: NychSelect,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the select',
},
},
} satisfies Meta<typeof NychSelect>
export default meta
type Story = StoryObj<typeof meta>
const RIVERS = ['Styx', 'Acheron', 'Cocytus', 'Phlegethon', 'Lethe']
export const Default: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref(null)
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" placeholder="Choose a river" />`,
}),
}
export const Selected: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref('Lethe')
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" placeholder="Choose a river" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychSelect },
setup() {
const river = ref('Styx')
return { river, RIVERS }
},
template: `<NychSelect v-model="river" :options="RIVERS" disabled />`,
}),
}
export const ObjectOptions: Story = {
render: () => ({
components: { NychSelect },
setup() {
const god = ref(null)
const gods = [
{ name: 'Apollo', domain: 'apollo' },
{ name: 'Hades', domain: 'hades' },
{ name: 'Hermes', domain: 'hermes' },
]
return { god, gods }
},
template: `<NychSelect v-model="god" :options="gods" optionLabel="name" optionValue="domain" placeholder="Choose a god" />`,
}),
}

View file

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { Tag as NychTag } from '@nychthemeron/library'
const meta = {
title: 'Components/Tag',
component: NychTag,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
severity: {
control: { type: 'select' },
options: ['secondary', 'success', 'info', 'warn', 'danger', 'contrast'],
description: 'Tag color severity',
},
value: {
control: { type: 'text' },
description: 'Tag label',
},
rounded: {
control: { type: 'boolean' },
description: 'Pill-shaped tag',
},
},
} satisfies Meta<typeof NychTag>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
args: { value: 'Mortal' },
}
export const Rounded: Story = {
args: { value: 'Immortal', severity: 'info', rounded: true },
}
export const Severities: Story = {
render: () => ({
components: { NychTag },
template: `
<div style="display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;">
<NychTag value="Primary" />
<NychTag value="Secondary" severity="secondary" />
<NychTag value="Info" severity="info" />
<NychTag value="Success" severity="success" />
<NychTag value="Warn" severity="warn" />
<NychTag value="Danger" severity="danger" />
<NychTag value="Contrast" severity="contrast" />
</div>
`,
}),
}

View file

@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { Textarea as NychTextarea } from '@nychthemeron/library'
const meta = {
title: 'Components/Textarea',
component: NychTextarea,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
placeholder: {
control: { type: 'text' },
description: 'Placeholder text',
},
rows: {
control: { type: 'number' },
description: 'Visible rows',
},
disabled: {
control: { type: 'boolean' },
description: 'Disable the textarea',
},
invalid: {
control: { type: 'boolean' },
description: 'Mark the textarea as invalid',
},
},
} satisfies Meta<typeof NychTextarea>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" placeholder="Inscribe your oath…" style="width: 22rem;" />`,
}),
}
export const Filled: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref(
'Whoever crosses the Styx leaves the world of light behind, and is bound to the laws of the deep.',
)
return { value }
},
template: `<NychTextarea v-model="value" rows="4" style="width: 22rem;" />`,
}),
}
export const Invalid: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" invalid placeholder="Required" style="width: 22rem;" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychTextarea },
setup() {
const value = ref('This scroll is sealed.')
return { value }
},
template: `<NychTextarea v-model="value" rows="4" disabled style="width: 22rem;" />`,
}),
}

View file

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/vue3-vite'
import { ref } from 'vue'
import { ToggleSwitch as NychToggleSwitch } from '@nychthemeron/library'
const meta = {
title: 'Components/ToggleSwitch',
component: NychToggleSwitch,
parameters: {
layout: 'centered',
},
tags: ['autodocs'],
argTypes: {
disabled: {
control: { type: 'boolean' },
description: 'Disable the switch',
},
},
} satisfies Meta<typeof NychToggleSwitch>
export default meta
type Story = StoryObj<typeof meta>
export const Default: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const on = ref(false)
return { on }
},
template: `<NychToggleSwitch v-model="on" />`,
}),
}
export const On: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const on = ref(true)
return { on }
},
template: `<NychToggleSwitch v-model="on" />`,
}),
}
export const Disabled: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
return {}
},
template: `
<div style="display: flex; gap: 1rem;">
<NychToggleSwitch :modelValue="false" disabled />
<NychToggleSwitch :modelValue="true" disabled />
</div>
`,
}),
}
export const WithLabel: Story = {
render: () => ({
components: { NychToggleSwitch },
setup() {
const nocturnal = ref(true)
return { nocturnal }
},
template: `
<label style="display: flex; align-items: center; gap: 0.75rem; font-family: var(--font-sans); color: var(--text-body); cursor: pointer;">
<NychToggleSwitch v-model="nocturnal" />
{{ nocturnal ? 'Hades (night)' : 'Apollo (day)' }}
</label>
`,
}),
}

View file

@ -0,0 +1,22 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"composite": false,
"jsx": "preserve",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"rootDir": "."
},
"include": [
"../../env.d.ts",
".storybook/**/*.ts",
".storybook/**/*.tsx",
".storybook/**/*.vue",
"stories/**/*.ts",
"stories/**/*.tsx",
"stories/**/*.vue"
]
}

View file

@ -0,0 +1,12 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'~': fileURLToPath(new URL('./src', import.meta.url)),
},
},
})

8
tsconfig.json Normal file
View file

@ -0,0 +1,8 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
}
]
}

27
tsconfig.node.json Normal file
View file

@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}