Inital Commit
This commit is contained in:
commit
8751c150cd
54 changed files with 7108 additions and 0 deletions
13
.dockerignore
Normal file
13
.dockerignore
Normal 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
8
.editorconfig
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
112
.forgejo/workflows/ci.yml
Normal file
112
.forgejo/workflows/ci.yml
Normal 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
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
* text=auto eol=lf
|
||||||
55
.gitignore
vendored
Normal file
55
.gitignore
vendored
Normal 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
10
.oxlintrc.json
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
.prettierrc.json
Normal file
6
.prettierrc.json
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
10
.vscode/extensions.json
vendored
Normal file
10
.vscode/extensions.json
vendored
Normal 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
133
README.md
Normal 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
|
||||||
708
docs/superpowers/plans/2026-05-11-library-with-playground.md
Normal file
708
docs/superpowers/plans/2026-05-11-library-with-playground.md
Normal 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.
|
||||||
222
docs/superpowers/plans/2026-06-11-docs-site-and-docker.md
Normal file
222
docs/superpowers/plans/2026-06-11-docs-site-and-docker.md
Normal 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"
|
||||||
|
```
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
3
env.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
/// <reference types="vite/client" />
|
||||||
|
|
||||||
|
declare module '*.css' {}
|
||||||
33
eslint.config.ts
Normal file
33
eslint.config.ts
Normal 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
51
package.json
Normal 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/*"
|
||||||
|
]
|
||||||
|
}
|
||||||
42
packages/library/package.json
Normal file
42
packages/library/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
packages/library/src/assets/css/nychthemeron.css
Normal file
169
packages/library/src/assets/css/nychthemeron.css
Normal 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;
|
||||||
|
}
|
||||||
727
packages/library/src/assets/theme/theme.css
Normal file
727
packages/library/src/assets/theme/theme.css
Normal 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);
|
||||||
|
}
|
||||||
70
packages/library/src/components/NychLoadingIcon.vue
Normal file
70
packages/library/src/components/NychLoadingIcon.vue
Normal 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>
|
||||||
2
packages/library/src/components/index.ts
Normal file
2
packages/library/src/components/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Re-export all components here as they're added
|
||||||
|
export { default as NychLoadingIcon } from './NychLoadingIcon.vue'
|
||||||
2
packages/library/src/composables/index.ts
Normal file
2
packages/library/src/composables/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
// Re-export all composables here as they're added
|
||||||
|
// export { useDate } from './useDate'
|
||||||
200
packages/library/src/engine/index.ts
Normal file
200
packages/library/src/engine/index.ts
Normal 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
3
packages/library/src/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
declare module '@nychthemeron/library/style'
|
||||||
|
declare module '@nychthemeron/library/theme'
|
||||||
|
declare module '@nychthemeron/library'
|
||||||
25
packages/library/src/index.ts
Normal file
25
packages/library/src/index.ts
Normal 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'
|
||||||
210
packages/library/src/lib/index.ts
Normal file
210
packages/library/src/lib/index.ts
Normal 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)
|
||||||
|
},
|
||||||
|
})
|
||||||
894
packages/library/tests/engine.spec.ts
Normal file
894
packages/library/tests/engine.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
157
packages/library/tests/lib.spec.ts
Normal file
157
packages/library/tests/lib.spec.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
18
packages/library/tsconfig.json
Normal file
18
packages/library/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
30
packages/library/vite.config.ts
Normal file
30
packages/library/vite.config.ts
Normal 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
17
packages/library/vitest.config.ts
Normal file
17
packages/library/vitest.config.ts
Normal 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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
27
packages/playground/.storybook/main.ts
Normal file
27
packages/playground/.storybook/main.ts
Normal 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`)))
|
||||||
|
}
|
||||||
58
packages/playground/.storybook/preview.ts
Normal file
58
packages/playground/.storybook/preview.ts
Normal 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
|
||||||
12
packages/playground/Dockerfile
Normal file
12
packages/playground/Dockerfile
Normal 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
|
||||||
26
packages/playground/package.json
Normal file
26
packages/playground/package.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
packages/playground/public/favicon.ico
Normal file
BIN
packages/playground/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
143
packages/playground/stories/Button.stories.ts
Normal file
143
packages/playground/stories/Button.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
73
packages/playground/stories/Card.stories.ts
Normal file
73
packages/playground/stories/Card.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
111
packages/playground/stories/Checkbox.stories.ts
Normal file
111
packages/playground/stories/Checkbox.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
79
packages/playground/stories/Dialog.stories.ts
Normal file
79
packages/playground/stories/Dialog.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
27
packages/playground/stories/Example.stories.ts
Normal file
27
packages/playground/stories/Example.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
93
packages/playground/stories/InputText.stories.ts
Normal file
93
packages/playground/stories/InputText.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
41
packages/playground/stories/Install.mdx
Normal file
41
packages/playground/stories/Install.mdx
Normal 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
|
||||||
|
```
|
||||||
86
packages/playground/stories/Message.stories.ts
Normal file
86
packages/playground/stories/Message.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
109
packages/playground/stories/RadioButton.stories.ts
Normal file
109
packages/playground/stories/RadioButton.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
77
packages/playground/stories/Select.stories.ts
Normal file
77
packages/playground/stories/Select.stories.ts
Normal 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" />`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
55
packages/playground/stories/Tag.stories.ts
Normal file
55
packages/playground/stories/Tag.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
80
packages/playground/stories/Textarea.stories.ts
Normal file
80
packages/playground/stories/Textarea.stories.ts
Normal 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;" />`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
75
packages/playground/stories/ToggleSwitch.stories.ts
Normal file
75
packages/playground/stories/ToggleSwitch.stories.ts
Normal 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>
|
||||||
|
`,
|
||||||
|
}),
|
||||||
|
}
|
||||||
22
packages/playground/tsconfig.json
Normal file
22
packages/playground/tsconfig.json
Normal 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"
|
||||||
|
]
|
||||||
|
}
|
||||||
12
packages/playground/vite.config.ts
Normal file
12
packages/playground/vite.config.ts
Normal 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
8
tsconfig.json
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
tsconfig.node.json
Normal file
27
tsconfig.node.json
Normal 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue