refactor(webapp): vue3 migration - phase 2 - setup (#9161)

This commit is contained in:
Ulf Gebhardt 2026-02-09 11:53:12 +01:00 committed by GitHub
parent f945a4bafc
commit 5d1cabda46
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
104 changed files with 62467 additions and 0 deletions

View File

@ -126,3 +126,71 @@ updates:
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
# ui library
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
groups:
vue:
applies-to: version-updates
patterns:
- "vue*"
- "@vue*"
vite:
applies-to: version-updates
patterns:
- "vite*"
- "@vitejs*"
vitest:
applies-to: version-updates
patterns:
- "vitest*"
- "@vitest*"
# ui examples
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue3-tailwind"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue3-css"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue2-tailwind"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"
- package-ecosystem: npm
open-pull-requests-limit: 99
directory: "/packages/ui/examples/vue2-css"
rebase-strategy: "disabled"
schedule:
interval: weekly
day: "saturday"
timezone: "Europe/Berlin"
time: "03:00"

81
.github/workflows/ui-build.yml vendored Normal file
View File

@ -0,0 +1,81 @@
name: UI Build
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
build:
name: Build
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build library
run: npm run build
- name: Verify build output
run: |
echo "Checking build output..."
# Check that dist directory exists
if [ ! -d "dist" ]; then
echo "::error::dist directory not found"
exit 1
fi
# Check required files exist
FILES=(
"dist/index.mjs"
"dist/index.cjs"
"dist/index.d.ts"
"dist/index.d.cts"
"dist/tailwind.preset.mjs"
"dist/tailwind.preset.cjs"
"dist/tailwind.preset.d.ts"
"dist/tailwind.preset.d.cts"
"dist/style.css"
)
for file in "${FILES[@]}"; do
if [ ! -f "$file" ]; then
echo "::error::Missing required file: $file"
exit 1
fi
echo "✓ $file"
done
echo ""
echo "All build outputs verified!"
- name: Validate package
run: npm run validate
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: dist
path: packages/ui/dist/
retention-days: 7

88
.github/workflows/ui-compatibility.yml vendored Normal file
View File

@ -0,0 +1,88 @@
name: UI Compatibility
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
jobs:
build-library:
name: Build Library
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build library
run: npm run build
- name: Upload build artifacts
uses: actions/upload-artifact@v4
with:
name: ui-dist
path: packages/ui/dist/
retention-days: 1
test-compatibility:
name: Test ${{ matrix.example }}
needs: build-library
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
example:
- vue3-tailwind
- vue3-css
- vue2-tailwind
- vue2-css
defaults:
run:
working-directory: packages/ui/examples/${{ matrix.example }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
name: ui-dist
path: packages/ui/dist/
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/examples/${{ matrix.example }}/package-lock.json
- name: Install dependencies
run: npm install
- name: Lint
run: npm run lint
- name: Run tests
run: npm test
- name: Build example app
run: npm run build

45
.github/workflows/ui-docker.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: UI Docker
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
jobs:
build:
name: Build Docker Image
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Build development image
uses: docker/build-push-action@v6
with:
context: ./packages/ui
file: ./packages/ui/Dockerfile
target: development
push: false
tags: ocelot-social/ui:development
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Build production image
uses: docker/build-push-action@v6
with:
context: ./packages/ui
file: ./packages/ui/Dockerfile
target: production
push: false
tags: ocelot-social/ui:latest
cache-from: type=gha
cache-to: type=gha,mode=max

40
.github/workflows/ui-lint.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: UI Lint
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
lint:
name: ESLint
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run ESLint
run: npm run lint
- name: Run TypeScript type check
run: npm run typecheck

65
.github/workflows/ui-release.yml vendored Normal file
View File

@ -0,0 +1,65 @@
name: UI Release
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
- 'release-please-config.json'
- '.release-please-manifest.json'
permissions:
contents: write
pull-requests: write
jobs:
release-please:
name: Release Please
runs-on: ubuntu-latest
outputs:
release_created: ${{ steps.release.outputs['packages/ui--release_created'] }}
tag_name: ${{ steps.release.outputs['packages/ui--tag_name'] }}
version: ${{ steps.release.outputs['packages/ui--version'] }}
steps:
- name: Release Please
id: release
uses: googleapis/release-please-action@v4
with:
config-file: release-please-config.json
manifest-file: .release-please-manifest.json
publish:
name: Publish to npm
needs: release-please
if: ${{ needs.release-please.outputs.release_created == 'true' }}
runs-on: ubuntu-latest
defaults:
run:
working-directory: packages/ui
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
registry-url: 'https://registry.npmjs.org'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Validate package
run: npm run validate
- name: Publish to npm
run: npm publish --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

40
.github/workflows/ui-size.yml vendored Normal file
View File

@ -0,0 +1,40 @@
name: UI Size
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
size:
name: Bundle Size Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Check bundle size
run: npm run size

60
.github/workflows/ui-storybook.yml vendored Normal file
View File

@ -0,0 +1,60 @@
name: UI Storybook
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
build:
name: Build Storybook
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Build Storybook
run: npm run storybook:build
- name: Verify build output
run: |
echo "Checking Storybook build output..."
if [ ! -d "storybook-static" ]; then
echo "::error::storybook-static directory not found"
exit 1
fi
if [ ! -f "storybook-static/index.html" ]; then
echo "::error::index.html not found in storybook-static"
exit 1
fi
echo "✓ Storybook build verified!"
- name: Upload Storybook artifacts
uses: actions/upload-artifact@v4
with:
name: storybook-static
path: packages/ui/storybook-static/
retention-days: 7

45
.github/workflows/ui-test.yml vendored Normal file
View File

@ -0,0 +1,45 @@
name: UI Test
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
test:
name: Unit Tests
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Run tests with coverage
run: npm run test:coverage
- name: Upload coverage report
uses: actions/upload-artifact@v4
if: always()
with:
name: coverage-report
path: packages/ui/coverage/
retention-days: 7

37
.github/workflows/ui-verify.yml vendored Normal file
View File

@ -0,0 +1,37 @@
name: UI Verify
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
verify:
name: Completeness Check
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Check component completeness
run: npm run verify

50
.github/workflows/ui-visual.yml vendored Normal file
View File

@ -0,0 +1,50 @@
name: UI Visual
on:
push:
branches: [master]
paths:
- 'packages/ui/**'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
defaults:
run:
working-directory: packages/ui
jobs:
visual:
name: Visual Regression
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version-file: 'packages/ui/.tool-versions'
cache: 'npm'
cache-dependency-path: packages/ui/package-lock.json
- name: Install dependencies
run: npm ci
- name: Install Playwright browsers
run: npx playwright install chromium --with-deps
- name: Run visual tests
run: npm run test:visual
- name: Upload test results
uses: actions/upload-artifact@v4
if: failure()
with:
name: visual-test-results
path: |
packages/ui/test-results/
packages/ui/playwright-report/
retention-days: 7

View File

@ -0,0 +1,3 @@
{
"packages/ui": "0.0.1"
}

View File

@ -91,5 +91,15 @@ services:
S3_RESULT_STORAGE_BUCKET: ocelot # enable S3 result storage by specifying bucket
HTTP_LOADER_BASE_URL: http://minio:9000
ui:
image: ghcr.io/ocelot-social-community/ocelot-social/ui:local-development
build:
target: development
ports:
- 6006:6006
volumes:
- ./packages/ui:/app
- /app/node_modules
volumes:
minio_data:

View File

@ -83,5 +83,13 @@ services:
# bring the database in offline mode to export or load dumps
# command: ["tail", "-f", "/dev/null"]
ui:
image: ghcr.io/ocelot-social-community/ocelot-social/ui:${OCELOT_VERSION:-latest}
build:
context: ./packages/ui
target: production
ports:
- 6006:80
volumes:
neo4j_data:

34
packages/ui/.gitignore vendored Normal file
View File

@ -0,0 +1,34 @@
# Dependencies
node_modules/
# Build output
dist/
# IDE
.vscode/
.idea/
*.swp
*.swo
*~
# OS
.DS_Store
Thumbs.db
# Test coverage
coverage/
# Playwright
test-results/
playwright-report/
# Storybook
storybook-static/
# Logs
*.log
npm-debug.log*
# Temporary files
*.tmp
*.temp

View File

@ -0,0 +1,26 @@
import type { StorybookConfig } from '@storybook/vue3-vite'
const config: StorybookConfig = {
stories: ['../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'],
framework: {
name: '@storybook/vue3-vite',
options: {},
},
viteFinal(viteConfig) {
// Remove plugins that are only needed for library build
viteConfig.plugins = viteConfig.plugins?.filter((plugin) => {
const name = plugin && 'name' in plugin ? plugin.name : ''
return name !== 'vite:dts' && name !== 'build-css'
})
// Remove library build config
if (viteConfig.build) {
delete viteConfig.build.lib
delete viteConfig.build.rollupOptions
}
return viteConfig
},
}
export default config

View File

@ -0,0 +1,11 @@
// eslint-disable-next-line import-x/no-unassigned-import
import './storybook.css'
export const parameters = {
controls: {
matchers: {
color: /(background|color)$/i,
date: /Date$/i,
},
},
}

View File

@ -0,0 +1,34 @@
/* Inter font for consistent rendering across platforms */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@import "tailwindcss";
/* Greyscale theme for Storybook - demonstrates that colors come from the app */
/* All colors meet WCAG AA contrast requirements (4.5:1 for normal text) */
:root {
font-family: 'Inter', system-ui, sans-serif;
--color-primary: #404040;
--color-primary-hover: #262626;
--color-primary-contrast: #ffffff;
--color-secondary: #595959;
--color-secondary-hover: #404040;
--color-secondary-contrast: #ffffff;
--color-danger: #525252;
--color-danger-hover: #404040;
--color-danger-contrast: #ffffff;
--color-warning: #d4d4d4;
--color-warning-hover: #a3a3a3;
--color-warning-contrast: #000000;
--color-success: #525252;
--color-success-hover: #404040;
--color-success-contrast: #ffffff;
--color-info: #595959;
--color-info-hover: #404040;
--color-info-contrast: #ffffff;
}

View File

@ -0,0 +1 @@
nodejs 25.5.0

217
packages/ui/CONTRIBUTING.md Normal file
View File

@ -0,0 +1,217 @@
# Contributing to @ocelot-social/ui
Thank you for contributing to the ocelot.social UI library!
## Development Setup
```bash
# Install dependencies
npm install
# Start development server
npm run dev
# Run tests
npm test
# Run linter
npm run lint
```
## Creating a New Component
### File Structure
```
src/components/
└── OsButton/
├── OsButton.vue # Component
├── OsButton.spec.ts # Tests
└── index.ts # Export
```
### Naming Convention
- All components use the `Os` prefix: `OsButton`, `OsCard`, `OsModal`
- Files use PascalCase: `OsButton.vue`
### Component Template
```vue
<script setup lang="ts">
import { computed } from 'vue-demi'
import type { Size, Variant } from '@/types'
interface Props {
/** Button size */
size?: Size
/** Visual variant */
variant?: Variant
}
const props = withDefaults(defineProps<Props>(), {
size: 'md',
variant: 'primary',
})
const classes = computed(() => [
'os-button',
`os-button--${props.size}`,
`os-button--${props.variant}`,
])
</script>
<template>
<button :class="classes">
<slot />
</button>
</template>
```
## Code Standards
### TypeScript
- `strict: true` is enabled
- All props must be typed
- Use JSDoc comments for documentation
### Props
Use the complete Tailwind-based scales:
| Prop | Values |
|------|--------|
| `size` | `xs`, `sm`, `md`, `lg`, `xl`, `2xl` |
| `rounded` | `none`, `sm`, `md`, `lg`, `xl`, `2xl`, `3xl`, `full` |
| `shadow` | `none`, `sm`, `md`, `lg`, `xl`, `2xl` |
| `variant` | `primary`, `secondary`, `danger`, `warning`, `success`, `info` |
### Styling
- Use CSS Custom Properties (no hardcoded colors)
- Use Tailwind utility classes
- Dark mode via `dark:` prefix
```vue
<template>
<button class="bg-[--button-primary-bg] dark:bg-[--button-primary-bg-dark]">
<slot />
</button>
</template>
```
### Vue 2/3 Compatibility
Always import from `vue-demi`, not `vue`:
```typescript
// Correct
import { ref, computed } from 'vue-demi'
// Wrong
import { ref, computed } from 'vue'
```
## Testing
### Requirements
- **100% code coverage** is required
- Tests must pass for both Vue 2.7 and Vue 3
### Writing Tests
```typescript
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OsButton from './OsButton.vue'
describe('OsButton', () => {
it('renders slot content', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toBe('Click me')
})
it('applies size class', () => {
const wrapper = mount(OsButton, {
props: { size: 'lg' },
})
expect(wrapper.classes()).toContain('os-button--lg')
})
})
```
### Running Tests
```bash
# Run once
npm test
# Watch mode
npm run test:watch
# With coverage
npm run test:coverage
```
## Commit Conventions
We use [Conventional Commits](https://www.conventionalcommits.org/) for automatic releases:
```bash
# Features (minor version bump)
feat(button): add loading state
# Bug fixes (patch version bump)
fix(modal): correct focus trap behavior
# Breaking changes (major version bump)
feat(input)!: rename value prop to modelValue
# Other types
docs: update README
test: add button accessibility tests
chore: update dependencies
refactor: simplify dropdown logic
```
## Pull Request Checklist
Before submitting a PR, ensure:
- [ ] Tests pass (`npm test`)
- [ ] Linter passes (`npm run lint`)
- [ ] Build succeeds (`npm run build`)
- [ ] 100% code coverage maintained
- [ ] New components have Histoire stories
- [ ] Props have JSDoc documentation
- [ ] Commit messages follow Conventional Commits
## Example Apps
Test your changes in the example apps:
```bash
# Vue 3 + Tailwind
cd examples/vue3-tailwind && npm install && npm run dev
# Vue 3 + CSS
cd examples/vue3-css && npm install && npm run dev
# Vue 2 + Tailwind
cd examples/vue2-tailwind && npm install && npm run dev
# Vue 2 + CSS
cd examples/vue2-css && npm install && npm run dev
```
## Questions?
- Check the [main CONTRIBUTING.md](../../CONTRIBUTING.md) for general guidelines
- Join the [Discord](https://discord.gg/AJSX9DCSUA) for questions
- Open an issue for bugs or feature requests

29
packages/ui/Dockerfile Normal file
View File

@ -0,0 +1,29 @@
FROM node:25.5.0-alpine AS base
LABEL org.label-schema.name="ocelot.social:ui"
LABEL org.label-schema.description="UI Component Library for ocelot.social"
LABEL org.label-schema.usage="https://github.com/Ocelot-Social-Community/Ocelot-Social/blob/master/packages/ui/README.md"
LABEL org.label-schema.url="https://ocelot.social"
LABEL org.label-schema.vcs-url="https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/packages/ui"
LABEL org.label-schema.vendor="ocelot.social Community"
LABEL org.label-schema.schema-version="1.0"
LABEL maintainer="devops@ocelot.social"
RUN mkdir -p /app
WORKDIR /app
FROM base AS development
ENV NODE_ENV="development"
EXPOSE 6006
CMD ["/bin/sh", "-c", "npm install && npm run dev"]
FROM base AS build
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run storybook:build
FROM nginx:alpine AS production
LABEL org.label-schema.name="ocelot.social:ui-storybook"
LABEL org.label-schema.description="UI Component Library Storybook for ocelot.social"
COPY --from=build /app/storybook-static /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

1075
packages/ui/KATALOG.md Normal file

File diff suppressed because it is too large Load Diff

190
packages/ui/LICENSE Normal file
View File

@ -0,0 +1,190 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to the Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
Copyright 2026 Ocelot-Social-Community
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

1988
packages/ui/PROJEKT.md Normal file

File diff suppressed because it is too large Load Diff

64
packages/ui/README.md Normal file
View File

@ -0,0 +1,64 @@
# @ocelot-social/ui
Vue component library for ocelot.social - works with Vue 2.7+ and Vue 3.
## Installation
```bash
npm install @ocelot-social/ui
```
## Usage
### Option 1: Individual Imports (recommended)
Import only the components you need. This enables tree-shaking for smaller bundle sizes.
```ts
import { OsButton } from '@ocelot-social/ui'
```
```vue
<template>
<OsButton variant="primary">Click me</OsButton>
</template>
<script setup>
import { OsButton } from '@ocelot-social/ui'
</script>
```
### Option 2: Global Registration
Register all components globally via the Vue plugin. No tree-shaking - all components are included in the bundle.
```ts
// main.ts
import { createApp } from 'vue'
import { OcelotUI } from '@ocelot-social/ui'
import App from './App.vue'
const app = createApp(App)
app.use(OcelotUI)
app.mount('#app')
```
Components are then available globally without imports:
```vue
<template>
<OsButton variant="primary">Click me</OsButton>
</template>
```
## Vue 2.7 Support
This library uses [vue-demi](https://github.com/vueuse/vue-demi) for Vue 2/3 compatibility.
```ts
// Vue 2.7
import Vue from 'vue'
import { OcelotUI } from '@ocelot-social/ui'
Vue.use(OcelotUI)
```

View File

@ -0,0 +1,126 @@
// TODO: Update eslint-config-it4c to support ESLint 10 (currently incompatible)
import config, { vue3, vitest } from 'eslint-config-it4c'
import jsdocPlugin from 'eslint-plugin-jsdoc'
import playwrightPlugin from 'eslint-plugin-playwright'
import storybookPlugin from 'eslint-plugin-storybook'
import vuejsAccessibilityPlugin from 'eslint-plugin-vuejs-accessibility'
export default [
{
ignores: [
'dist/',
'coverage/',
'storybook-static/',
'**/node_modules/',
'examples/',
'test-results/',
'playwright-report/',
],
},
...config,
...vue3,
...vitest,
{
// TODO: Move these Vue-standard rules to eslint-config-it4c
rules: {
// TODO(it4c): Add .css/.scss to vue3 config
'n/file-extension-in-import': [
'error',
'never',
{
'.vue': 'always',
'.json': 'always',
'.css': 'always',
'.scss': 'always',
},
],
// TODO(it4c): Add CSS/SCSS exception to vue3 config
'import-x/no-unassigned-import': [
'error',
{
allow: ['**/*.css', '**/*.scss'],
},
],
// TODO(it4c): Disable in vue3 config (alias imports)
'import-x/no-relative-parent-imports': 'off',
// TODO(it4c): Disable in vue3 config (Prettier handles)
'vue/max-attributes-per-line': 'off',
},
},
{
// Disable TypeScript rules for JSON files
files: ['**/*.json'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
// TODO: Move these Vitest rules to eslint-config-it4c vitest config
files: ['**/*.spec.ts', '**/*.spec.tsx'],
rules: {
// TODO(it4c): Add to vitest config (standard pattern)
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
// TODO(it4c): Disable in vitest config
'vitest/prefer-expect-assertions': 'off',
// TODO(it4c): Disable in vitest config
'vitest/no-hooks': 'off',
},
},
{
// CLI scripts - allow sync methods and console
files: ['scripts/**/*.ts'],
rules: {
'n/shebang': 'off',
'n/no-sync': 'off',
'no-console': 'off',
'security/detect-non-literal-fs-filename': 'off',
},
},
{
// Playwright visual tests
files: ['**/*.visual.spec.ts'],
...playwrightPlugin.configs['flat/recommended'],
rules: {
...playwrightPlugin.configs['flat/recommended'].rules,
'n/no-process-env': 'off',
'vitest/require-hook': 'off',
},
},
{
// Playwright config
files: ['playwright.config.ts'],
rules: {
'n/no-process-env': 'off',
},
},
// Storybook files
// eslint-disable-next-line import-x/no-named-as-default-member -- flat config access pattern
...storybookPlugin.configs['flat/recommended'],
{
// Vue components - accessibility and JSDoc
files: ['src/components/**/*.vue'],
plugins: {
jsdoc: jsdocPlugin,
'vuejs-accessibility': vuejsAccessibilityPlugin,
},
rules: {
// Require JSDoc comments on Props interface properties
'jsdoc/require-jsdoc': [
'error',
{
contexts: ['TSPropertySignature'],
require: {
ClassDeclaration: false,
ClassExpression: false,
ArrowFunctionExpression: false,
FunctionDeclaration: false,
FunctionExpression: false,
MethodDefinition: false,
},
},
],
// Accessibility rules
...vuejsAccessibilityPlugin.configs.recommended.rules,
},
},
]

View File

@ -0,0 +1,32 @@
import config, { vue2, vitest } from 'eslint-config-it4c'
export default [
...config,
...vue2,
...vitest,
{ ignores: ['dist/', 'node_modules/'] },
{
rules: {
'n/file-extension-in-import': [
'error',
'never',
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
],
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
},
},
{
files: ['**/*.json'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['**/*.spec.ts', '**/*.spec.tsx'],
rules: {
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
'vitest/prefer-expect-assertions': 'off',
'vitest/no-hooks': 'off',
},
},
]

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 2 + CSS Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "vue2-css",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint --max-warnings 0 ."
},
"dependencies": {
"@ocelot-social/ui": "file:../..",
"vue": "^2.7.16"
},
"devDependencies": {
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/test-utils": "^1.3.6",
"eslint": "^9.18.0",
"eslint-config-it4c": "^0.8.0",
"jsdom": "^28.0.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,3 @@
import prettierConfig from 'eslint-config-it4c/prettier'
export default prettierConfig

View File

@ -0,0 +1,18 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import App from './App.vue'
describe('vue 2 CSS Example App', () => {
it('mounts successfully', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const wrapper = mount(App)
expect(wrapper.exists()).toBeTruthy()
})
it('displays the correct heading', () => {
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const wrapper = mount(App)
expect(wrapper.find('h1').text()).toBe('Vue 2.7 + CSS + @ocelot-social/ui')
})
})

View File

@ -0,0 +1,19 @@
<script lang="ts">
import { defineComponent } from 'vue'
// Example app to verify @ocelot-social/ui works with Vue 2.7 + pre-compiled CSS
export default defineComponent({
name: 'App',
})
</script>
<template>
<div id="app">
<h1>Vue 2.7 + CSS + @ocelot-social/ui</h1>
<p>
This example verifies that the UI library works with Vue 2.7 and pre-compiled CSS (no
Tailwind).
</p>
<!-- Components will be added here as they are developed -->
</div>
</template>

View File

@ -0,0 +1,9 @@
import '@ocelot-social/ui/style.css'
import Vue from 'vue'
import App from './App.vue'
new Vue({
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
render: (h) => h(App),
}).$mount('#app')

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
}

View File

@ -0,0 +1,10 @@
import vue from '@vitejs/plugin-vue2'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
})

View File

@ -0,0 +1,32 @@
import config, { vue2, vitest } from 'eslint-config-it4c'
export default [
...config,
...vue2,
...vitest,
{ ignores: ['dist/', 'node_modules/'] },
{
rules: {
'n/file-extension-in-import': [
'error',
'never',
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
],
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
},
},
{
files: ['**/*.json'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['**/*.spec.ts', '**/*.spec.tsx'],
rules: {
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
'vitest/prefer-expect-assertions': 'off',
'vitest/no-hooks': 'off',
},
},
]

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 2.7 + @ocelot-social/ui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "vue2-tailwind",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint --max-warnings 0 ."
},
"dependencies": {
"@ocelot-social/ui": "file:../..",
"vue": "^2.7.16"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue2": "^2.3.3",
"@vue/test-utils": "^1.3.6",
"eslint": "^9.18.0",
"eslint-config-it4c": "^0.8.0",
"jsdom": "^28.0.0",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,3 @@
import prettierConfig from 'eslint-config-it4c/prettier'
export default prettierConfig

View File

@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import App from './App.vue'
describe('vue 2 Example App', () => {
it('mounts successfully', () => {
const wrapper = mount(App)
expect(wrapper.exists()).toBeTruthy()
})
it('displays the correct heading', () => {
const wrapper = mount(App)
expect(wrapper.find('h1').text()).toBe('Vue 2.7 + Tailwind + @ocelot-social/ui')
})
})

View File

@ -0,0 +1,16 @@
<script lang="ts">
import { defineComponent } from 'vue'
// Example app to verify @ocelot-social/ui works with Vue 2.7 + Tailwind
export default defineComponent({
name: 'App',
})
</script>
<template>
<div id="app">
<h1>Vue 2.7 + Tailwind + @ocelot-social/ui</h1>
<p>This example verifies that the UI library works with Vue 2.7 and Tailwind CSS.</p>
<!-- Components will be added here as they are developed -->
</div>
</template>

View File

@ -0,0 +1,9 @@
import Vue from 'vue'
import App from './App.vue'
import './style.css'
new Vue({
render: (h) => h(App),
}).$mount('#app')

View File

@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@ -0,0 +1,4 @@
@import 'tailwindcss';
/* Scan UI library dist for utility classes */
@source '../../../dist/**/*.mjs';

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
}

View File

@ -0,0 +1,11 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue2'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue(), tailwindcss()],
test: {
globals: true,
environment: 'jsdom',
},
})

View File

@ -0,0 +1,32 @@
import config, { vue3, vitest } from 'eslint-config-it4c'
export default [
...config,
...vue3,
...vitest,
{ ignores: ['dist/', 'node_modules/'] },
{
rules: {
'n/file-extension-in-import': [
'error',
'never',
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
],
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
},
},
{
files: ['**/*.json'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['**/*.spec.ts', '**/*.spec.tsx'],
rules: {
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
'vitest/prefer-expect-assertions': 'off',
'vitest/no-hooks': 'off',
},
},
]

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 3 + CSS Example</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,26 @@
{
"name": "vue3-css",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint --max-warnings 0 ."
},
"dependencies": {
"@ocelot-social/ui": "file:../..",
"vue": "^3.5.27"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.18.0",
"eslint-config-it4c": "^0.8.0",
"jsdom": "^28.0.0",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,3 @@
import prettierConfig from 'eslint-config-it4c/prettier'
export default prettierConfig

View File

@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import App from './App.vue'
describe('vue 3 CSS Example App', () => {
it('mounts successfully', () => {
const wrapper = mount(App)
expect(wrapper.exists()).toBeTruthy()
})
it('displays the correct heading', () => {
const wrapper = mount(App)
expect(wrapper.find('h1').text()).toBe('Vue 3 + CSS + @ocelot-social/ui')
})
})

View File

@ -0,0 +1,13 @@
<script setup lang="ts">
// Example app to verify @ocelot-social/ui works with Vue 3 + pre-compiled CSS
</script>
<template>
<div id="app">
<h1>Vue 3 + CSS + @ocelot-social/ui</h1>
<p>
This example verifies that the UI library works with Vue 3 and pre-compiled CSS (no Tailwind).
</p>
<!-- Components will be added here as they are developed -->
</div>
</template>

View File

@ -0,0 +1,8 @@
import '@ocelot-social/ui/style.css'
import { createApp } from 'vue'
import App from './App.vue'
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
const app = createApp(App)
app.mount('#app')

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
}

View File

@ -0,0 +1,10 @@
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue()],
test: {
globals: true,
environment: 'jsdom',
},
})

View File

@ -0,0 +1,32 @@
import config, { vue3, vitest } from 'eslint-config-it4c'
export default [
...config,
...vue3,
...vitest,
{ ignores: ['dist/', 'node_modules/'] },
{
rules: {
'n/file-extension-in-import': [
'error',
'never',
{ '.vue': 'always', '.json': 'always', '.css': 'always' },
],
'import-x/no-unassigned-import': ['error', { allow: ['**/*.css'] }],
},
},
{
files: ['**/*.json'],
rules: {
'@typescript-eslint/no-unused-expressions': 'off',
},
},
{
files: ['**/*.spec.ts', '**/*.spec.tsx'],
rules: {
'vitest/consistent-test-filename': ['error', { pattern: '.*\\.spec\\.[tj]sx?$' }],
'vitest/prefer-expect-assertions': 'off',
'vitest/no-hooks': 'off',
},
},
]

View File

@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vue 3 + @ocelot-social/ui</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,28 @@
{
"name": "vue3-tailwind",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"test": "vitest run",
"lint": "eslint --max-warnings 0 ."
},
"dependencies": {
"@ocelot-social/ui": "file:../..",
"vue": "^3.5.27"
},
"devDependencies": {
"@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.4",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.18.0",
"eslint-config-it4c": "^0.8.0",
"jsdom": "^28.0.0",
"tailwindcss": "^4.1.18",
"vite": "^7.3.1",
"vitest": "^4.0.18"
}
}

View File

@ -0,0 +1,3 @@
import prettierConfig from 'eslint-config-it4c/prettier'
export default prettierConfig

View File

@ -0,0 +1,16 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import App from './App.vue'
describe('vue 3 Example App', () => {
it('mounts successfully', () => {
const wrapper = mount(App)
expect(wrapper.exists()).toBeTruthy()
})
it('displays the correct heading', () => {
const wrapper = mount(App)
expect(wrapper.find('h1').text()).toBe('Vue 3 + Tailwind + @ocelot-social/ui')
})
})

View File

@ -0,0 +1,11 @@
<script setup lang="ts">
// Example app to verify @ocelot-social/ui works with Vue 3 + Tailwind
</script>
<template>
<div id="app">
<h1>Vue 3 + Tailwind + @ocelot-social/ui</h1>
<p>This example verifies that the UI library works with Vue 3 and Tailwind CSS.</p>
<!-- Components will be added here as they are developed -->
</div>
</template>

View File

@ -0,0 +1,8 @@
import { createApp } from 'vue'
import App from './App.vue'
import './style.css'
const app = createApp(App)
app.mount('#app')

View File

@ -0,0 +1,6 @@
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<object, object, unknown>
export default component
}

View File

@ -0,0 +1,4 @@
@import 'tailwindcss';
/* Scan UI library dist for utility classes */
@source '../../../dist/**/*.mjs';

View File

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "preserve",
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
},
"include": ["src/**/*", "vite.config.ts", "eslint.config.ts"]
}

View File

@ -0,0 +1,11 @@
import tailwindcss from '@tailwindcss/vite'
import vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
export default defineConfig({
plugins: [vue(), tailwindcss()],
test: {
globals: true,
environment: 'jsdom',
},
})

14458
packages/ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

144
packages/ui/package.json Normal file
View File

@ -0,0 +1,144 @@
{
"name": "@ocelot-social/ui",
"version": "0.0.1",
"description": "Vue component library for ocelot.social - works with Vue 2.7+ and Vue 3",
"license": "Apache-2.0",
"type": "module",
"exports": {
".": {
"import": {
"types": "./dist/index.d.ts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./style.css": "./dist/style.css",
"./tailwind.preset": {
"style": "./dist/tailwind.preset.mjs",
"import": {
"types": "./dist/tailwind.preset.d.ts",
"default": "./dist/tailwind.preset.mjs"
},
"require": {
"types": "./dist/tailwind.preset.d.cts",
"default": "./dist/tailwind.preset.cjs"
},
"default": "./dist/tailwind.preset.mjs"
}
},
"main": "./dist/index.cjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"dev": "storybook dev -p 6006",
"build": "vite build",
"preview": "storybook build && npx http-server storybook-static -p 6006",
"storybook:build": "storybook build",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"test:visual": "playwright test",
"test:visual:update": "playwright test --update-snapshots",
"typecheck": "vue-tsc --noEmit",
"lint": "eslint --max-warnings 0 .",
"lint:fix": "eslint --fix .",
"verify": "tsx scripts/check-completeness.ts",
"size": "size-limit",
"size:check": "size-limit --json",
"validate": "publint && attw --pack . --profile node16 --exclude-entrypoints ./style.css",
"prepublishOnly": "npm run build && npm run validate"
},
"peerDependencies": {
"tailwindcss": "^4.0.0",
"vue": "^2.7.0 || ^3.0.0"
},
"peerDependenciesMeta": {
"tailwindcss": {
"optional": true
}
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"tailwind-merge": "^3.3.0",
"vue-demi": "^0.14.10"
},
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@axe-core/playwright": "^4.11.1",
"@playwright/test": "^1.58.2",
"@size-limit/file": "^12.0.0",
"@storybook/vue3-vite": "^10.2.7",
"@tailwindcss/cli": "^4.1.18",
"@tailwindcss/vite": "^4.1.18",
"@types/node": "^25.2.0",
"@vitejs/plugin-vue": "^6.0.4",
"@vitest/coverage-v8": "^4.0.18",
"@vue/test-utils": "^2.4.6",
"eslint": "^9.39.2",
"eslint-config-it4c": "^0.8.0",
"eslint-plugin-jsdoc": "^62.5.3",
"eslint-plugin-playwright": "^2.5.1",
"eslint-plugin-storybook": "^10.2.7",
"eslint-plugin-vuejs-accessibility": "^2.4.1",
"glob": "^13.0.1",
"jsdom": "^28.0.0",
"publint": "^0.3.17",
"size-limit": "^12.0.0",
"storybook": "^10.2.7",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
"vite": "^7.3.1",
"vite-plugin-dts": "^4.5.4",
"vite-tsconfig-paths": "^6.0.5",
"vitest": "^4.0.18",
"vue": "^3.5.27",
"vue-tsc": "^3.2.4"
},
"engines": {
"node": ">=18.0.0"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Ocelot-Social-Community/Ocelot-Social.git",
"directory": "packages/ui"
},
"bugs": {
"url": "https://github.com/Ocelot-Social-Community/Ocelot-Social/issues"
},
"homepage": "https://github.com/Ocelot-Social-Community/Ocelot-Social/tree/master/packages/ui#readme",
"keywords": [
"vue",
"vue3",
"vue2",
"components",
"ui",
"ocelot-social",
"design-system"
],
"publishConfig": {
"access": "public"
},
"size-limit": [
{
"path": "dist/index.mjs",
"limit": "15 kB",
"brotli": true
},
{
"path": "dist/tailwind.preset.mjs",
"limit": "2 kB"
},
{
"path": "dist/style.css",
"limit": "10 kB"
}
]
}

View File

@ -0,0 +1,63 @@
import { defineConfig, devices } from '@playwright/test'
/**
* Visual regression testing configuration for @ocelot-social/ui
*
* Tests run against Storybook to capture component screenshots.
* Baseline images are stored in e2e/__screenshots__ and committed to git.
*/
export default defineConfig({
testDir: './src/components',
testMatch: '**/*.visual.spec.ts',
/* Run tests in parallel */
fullyParallel: true,
/* Fail the build on CI if you accidentally left test.only in the source code */
forbidOnly: !!process.env.CI,
/* Retry on CI only */
retries: process.env.CI ? 2 : 0,
/* Reporter to use */
reporter: process.env.CI ? 'github' : 'html',
/* Shared settings for all the projects below */
use: {
/* Base URL for Storybook */
baseURL: 'http://localhost:6006',
/* Collect trace when retrying the failed test */
trace: 'on-first-retry',
},
/* Configure projects for major browsers */
projects: [
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
},
],
/* Run Storybook before starting the tests */
webServer: {
command: 'npm run storybook:build && npx http-server storybook-static -p 6006 -s',
url: 'http://localhost:6006',
reuseExistingServer: !process.env.CI,
timeout: 120000,
},
/* Snapshot configuration */
expect: {
toHaveScreenshot: {
/* Allow slight differences due to anti-aliasing */
maxDiffPixelRatio: 0.01,
},
},
/* Output folder for test artifacts */
outputDir: 'test-results',
/* Snapshot path template - colocated with component */
snapshotPathTemplate: '{testDir}/{testFileDir}/__screenshots__/{projectName}/{arg}{ext}',
})

View File

@ -0,0 +1,3 @@
import prettierConfig from 'eslint-config-it4c/prettier'
export default prettierConfig

View File

@ -0,0 +1,179 @@
#!/usr/bin/env npx tsx
/**
* Completeness checker for @ocelot-social/ui components
*
* Checks:
* 1. Every component has a story file (documentation)
* 2. Every component has a visual regression test file (quality)
* 3. Visual tests include accessibility checks via checkA11y() (quality)
* 4. Every component has keyboard accessibility tests (quality)
* 5. All variant values are demonstrated in stories (coverage)
* 6. All stories have visual regression tests (coverage)
*
* Note: JSDoc comments on props are checked via ESLint (jsdoc/require-jsdoc)
*/
import { existsSync, readFileSync } from 'node:fs'
import { basename, dirname, join } from 'node:path'
import { glob } from 'glob'
/**
* Convert PascalCase to kebab-case (e.g., "AllVariants" -> "all-variants")
*/
function toKebabCase(str: string): string {
return str.replace(/([a-z])([A-Z])/g, '$1-$2').toLowerCase()
}
interface CheckResult {
component: string
errors: string[]
warnings: string[]
}
const results: CheckResult[] = []
let hasErrors = false
// Find all Vue components (excluding index files)
const components = glob.sync('src/components/**/Os*.vue')
for (const componentPath of components) {
const componentName = basename(componentPath, '.vue')
const componentDir = dirname(componentPath)
const storyPath = join(componentDir, `${componentName}.stories.ts`)
const visualTestPath = join(componentDir, `${componentName}.visual.spec.ts`)
const unitTestPath = join(componentDir, `${componentName}.spec.ts`)
const variantsPath = join(
componentDir,
`${componentName.toLowerCase().replace('os', '')}.variants.ts`,
)
const result: CheckResult = {
component: componentName,
errors: [],
warnings: [],
}
// Check 1: Story file exists
if (!existsSync(storyPath)) {
result.errors.push(`Missing story file: ${storyPath}`)
}
// Check 2: Visual regression test file exists
if (!existsSync(visualTestPath)) {
result.errors.push(`Missing visual test file: ${visualTestPath}`)
}
// Check 3: Visual tests include accessibility checks
if (existsSync(visualTestPath)) {
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
if (!visualTestContent.includes('checkA11y(')) {
result.errors.push(`Missing checkA11y() calls in visual tests: ${visualTestPath}`)
}
}
// Check 4: Keyboard accessibility tests exist
if (existsSync(unitTestPath)) {
const unitTestContent = readFileSync(unitTestPath, 'utf-8')
if (!unitTestContent.includes("describe('keyboard accessibility'")) {
result.errors.push(`Missing keyboard accessibility tests in: ${unitTestPath}`)
}
} else {
result.errors.push(`Missing unit test file: ${unitTestPath}`)
}
// Check 5 & 6: Story and visual test coverage
if (existsSync(storyPath) && existsSync(visualTestPath)) {
const storyContent = readFileSync(storyPath, 'utf-8')
const visualTestContent = readFileSync(visualTestPath, 'utf-8')
// Extract exported story names (e.g., "export const Primary: Story")
const storyExports = storyContent.matchAll(/export\s+const\s+(\w+):\s*Story/g)
for (const match of storyExports) {
const storyName = match[1]
const kebabName = toKebabCase(storyName)
// Check if this story is tested in visual tests (URL pattern: --story-name)
if (!visualTestContent.includes(`--${kebabName}`)) {
result.warnings.push(`Story "${storyName}" missing visual test (--${kebabName})`)
}
}
}
// Check 5: Variant values are demonstrated in stories
if (existsSync(storyPath) && existsSync(variantsPath)) {
const variantsContent = readFileSync(variantsPath, 'utf-8')
const storyContent = readFileSync(storyPath, 'utf-8')
// Extract variants block
const variantsBlockMatch = /variants:\s*\{([\s\S]*?)\n\s{4}\},/m.exec(variantsContent)
if (variantsBlockMatch) {
const variantsBlock = variantsBlockMatch[1]
// Extract each variant type (variant, size, etc.)
const variantTypeMatches = variantsBlock.matchAll(/^\s{6}(\w+):\s*\{([\s\S]*?)\n\s{6}\}/gm)
for (const match of variantTypeMatches) {
const variantName = match[1]
const variantValues = match[2]
// Extract individual values
const valueMatches = variantValues.matchAll(/^\s+(\w+):\s*\[/gm)
for (const valueMatch of valueMatches) {
const value = valueMatch[1]
// Check if this value appears in stories (multiple patterns)
const patterns = [
`${variantName}="${value}"`,
`${variantName}='${value}'`,
`${variantName}: '${value}'`,
`${variantName}: "${value}"`,
]
const found = patterns.some((p) => storyContent.includes(p))
if (!found) {
result.warnings.push(`Variant "${variantName}=${value}" not demonstrated in story`)
}
}
}
}
}
if (result.errors.length > 0 || result.warnings.length > 0) {
results.push(result)
}
if (result.errors.length > 0) {
hasErrors = true
}
}
// Output results
if (results.length === 0) {
console.log('✓ All completeness checks passed!')
} else {
console.log('Completeness check results:\n')
for (const result of results) {
console.log(`${result.component}:`)
for (const error of result.errors) {
console.log(`${error}`)
}
for (const warning of result.warnings) {
console.log(`${warning}`)
}
console.log('')
}
if (hasErrors) {
console.log('Completeness check failed with errors.')
process.exit(1)
} else {
console.log('Completeness check passed with warnings.')
}
}

View File

@ -0,0 +1,97 @@
import { mount } from '@vue/test-utils'
import { describe, expect, it } from 'vitest'
import OsButton from './OsButton.vue'
describe('osButton', () => {
it('renders slot content', () => {
const wrapper = mount(OsButton, {
slots: { default: 'Click me' },
})
expect(wrapper.text()).toBe('Click me')
})
it('applies default variant classes', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
})
it('applies size variant classes', () => {
const wrapper = mount(OsButton, {
props: { size: 'sm' },
})
expect(wrapper.classes()).toContain('h-8')
expect(wrapper.classes()).toContain('text-sm')
})
it('applies variant classes', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
})
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
})
it('applies fullWidth class', () => {
const wrapper = mount(OsButton, {
props: { fullWidth: true },
})
expect(wrapper.classes()).toContain('w-full')
})
it('merges custom classes', () => {
const wrapper = mount(OsButton, {
props: { class: 'my-custom-class' },
})
expect(wrapper.classes()).toContain('my-custom-class')
})
it('sets disabled attribute', () => {
const wrapper = mount(OsButton, {
props: { disabled: true },
})
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('sets button type', () => {
const wrapper = mount(OsButton, {
props: { type: 'submit' },
})
expect(wrapper.attributes('type')).toBe('submit')
})
it('emits click event', async () => {
const wrapper = mount(OsButton)
await wrapper.trigger('click')
expect(wrapper.emitted('click')).toHaveLength(1)
})
describe('keyboard accessibility', () => {
it('renders as native button element for keyboard support', () => {
const wrapper = mount(OsButton)
// Native button elements have built-in Enter/Space key support
expect((wrapper.element as HTMLElement).tagName).toBe('BUTTON')
})
it('is focusable by default', () => {
const wrapper = mount(OsButton)
// No tabindex=-1 means button is in natural tab order
expect(wrapper.attributes('tabindex')).toBeUndefined()
})
it('remains focusable when disabled via aria', () => {
const wrapper = mount(OsButton, {
props: { disabled: true },
})
// Disabled buttons have disabled attribute which browsers handle correctly
expect(wrapper.attributes('disabled')).toBeDefined()
})
it('can receive focus programmatically', () => {
const wrapper = mount(OsButton, { attachTo: document.body })
const button = wrapper.element as HTMLButtonElement
button.focus()
expect(document.activeElement).toBe(button)
wrapper.unmount()
})
})
})

View File

@ -0,0 +1,131 @@
import OsButton from './OsButton.vue'
import type { Meta, StoryObj } from '@storybook/vue3-vite'
const meta: Meta<typeof OsButton> = {
title: 'Components/OsButton',
component: OsButton,
tags: ['autodocs'],
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info', 'ghost', 'outline'],
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
},
fullWidth: {
control: 'boolean',
},
disabled: {
control: 'boolean',
},
},
}
export default meta
type Story = StoryObj<typeof OsButton>
export const Primary: Story = {
args: {
variant: 'primary',
default: 'Primary Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
}),
}
export const Secondary: Story = {
args: {
variant: 'secondary',
default: 'Secondary Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
}),
}
export const Danger: Story = {
args: {
variant: 'danger',
default: 'Danger Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
}),
}
export const AllVariants: Story = {
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="primary">Primary</OsButton>
<OsButton variant="secondary">Secondary</OsButton>
<OsButton variant="danger">Danger</OsButton>
<OsButton variant="warning">Warning</OsButton>
<OsButton variant="success">Success</OsButton>
<OsButton variant="info">Info</OsButton>
<OsButton variant="ghost">Ghost</OsButton>
<OsButton variant="outline">Outline</OsButton>
</div>
`,
}),
}
export const AllSizes: Story = {
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-col gap-2 items-start">
<OsButton size="xs">Extra Small</OsButton>
<OsButton size="sm">Small</OsButton>
<OsButton size="md">Medium</OsButton>
<OsButton size="lg">Large</OsButton>
<OsButton size="xl">Extra Large</OsButton>
</div>
`,
}),
}
export const Disabled: Story = {
args: {
disabled: true,
default: 'Disabled Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
}),
}
export const FullWidth: Story = {
args: {
fullWidth: true,
default: 'Full Width Button',
},
render: (args) => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
}),
}

View File

@ -0,0 +1,82 @@
import { AxeBuilder } from '@axe-core/playwright'
import { expect, test } from '@playwright/test'
import type { Page } from '@playwright/test'
/**
* Visual regression tests for OsButton component
*
* These tests capture screenshots of Storybook stories and compare them
* against baseline images to detect unintended visual changes.
* Each test also runs accessibility checks using axe-core.
*/
const STORY_URL = '/iframe.html?id=components-osbutton'
const STORY_ROOT = '#storybook-root'
/**
* Helper to run accessibility check on the current page
*/
async function checkA11y(page: Page) {
const results = await new AxeBuilder({ page }).include(STORY_ROOT).analyze()
expect(results.violations).toEqual([])
}
test.describe('OsButton visual regression', () => {
test('primary variant', async ({ page }) => {
await page.goto(`${STORY_URL}--primary&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('button')).toHaveScreenshot('primary.png')
await checkA11y(page)
})
test('secondary variant', async ({ page }) => {
await page.goto(`${STORY_URL}--secondary&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('button')).toHaveScreenshot('secondary.png')
await checkA11y(page)
})
test('danger variant', async ({ page }) => {
await page.goto(`${STORY_URL}--danger&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('button')).toHaveScreenshot('danger.png')
await checkA11y(page)
})
test('all variants', async ({ page }) => {
await page.goto(`${STORY_URL}--all-variants&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('.flex')).toHaveScreenshot('all-variants.png')
await checkA11y(page)
})
test('all sizes', async ({ page }) => {
await page.goto(`${STORY_URL}--all-sizes&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('.flex')).toHaveScreenshot('all-sizes.png')
await checkA11y(page)
})
test('disabled state', async ({ page }) => {
await page.goto(`${STORY_URL}--disabled&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('button')).toHaveScreenshot('disabled.png')
await checkA11y(page)
})
test('full width', async ({ page }) => {
await page.goto(`${STORY_URL}--full-width&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await expect(root.locator('button')).toHaveScreenshot('full-width.png')
await checkA11y(page)
})
})

View File

@ -0,0 +1,50 @@
<script setup lang="ts">
import { computed } from 'vue-demi'
import { cn } from '../../utils'
import { buttonVariants } from './button.variants'
import type { ButtonVariants } from './button.variants'
export interface OsButtonProps {
/** Visual style variant */
variant?: ButtonVariants['variant']
/** Size of the button */
size?: ButtonVariants['size']
/** Whether button takes full width of container */
fullWidth?: boolean
/** HTML button type attribute */
type?: 'button' | 'submit' | 'reset'
/** Whether the button is disabled */
disabled?: boolean
/** Additional CSS classes */
class?: string
}
const props = withDefaults(defineProps<OsButtonProps>(), {
variant: 'primary',
size: 'md',
fullWidth: false,
type: 'button',
disabled: false,
class: '',
})
const classes = computed(() =>
cn(
buttonVariants({
variant: props.variant,
size: props.size,
fullWidth: props.fullWidth,
}),
props.class,
),
)
</script>
<template>
<button :type="type" :disabled="disabled" :class="classes">
<slot />
</button>
</template>

View File

@ -0,0 +1,251 @@
# OsButton Status
> Status-Tracking der OsButton-Komponente
---
## Übersicht
| Aspekt | Status |
|--------|--------|
| **Implementierung** | Basis implementiert |
| **CVA-Integration** | Vollständig |
| **Tests** | 9 Tests vorhanden |
| **Storybook** | Ausstehend |
| **A11y** | Teilweise |
---
## Implementierte Features
### Props
| Prop | Typ | Default | Status | Notizen |
|------|-----|---------|--------|---------|
| `variant` | `'primary' \| 'secondary' \| 'danger' \| 'warning' \| 'success' \| 'info' \| 'ghost' \| 'outline'` | `'primary'` | Implementiert | 8 Varianten via CVA |
| `size` | `'xs' \| 'sm' \| 'md' \| 'lg' \| 'xl'` | `'md'` | Implementiert | 5 Größen via CVA |
| `fullWidth` | `boolean` | `false` | Implementiert | |
| `type` | `'button' \| 'submit' \| 'reset'` | `'button'` | Implementiert | |
| `disabled` | `boolean` | `false` | Implementiert | |
| `class` | `string` | `''` | Implementiert | Via cn() gemerged |
### Slots
| Slot | Status | Notizen |
|------|--------|---------|
| `default` | Implementiert | Button-Content |
### Events
| Event | Status | Notizen |
|-------|--------|---------|
| Native Events | Implementiert | click, focus, etc. werden durchgereicht |
---
## Fehlende Features (aus KATALOG.md)
### Hohe Priorität
| Feature | Beschreibung | Aufwand |
|---------|--------------|---------|
| `icon` | Icon-Name/Komponente einbinden | Mittel - erfordert OsIcon |
| `iconPosition` | `'left' \| 'right'` | Klein - nach Icon-Support |
| `loading` | Ladezustand mit Spinner | Mittel - erfordert OsSpinner |
### Mittlere Priorität
| Feature | Beschreibung | Aufwand |
|---------|--------------|---------|
| `to` | Vue Router Link-Support | Mittel - erfordert router-link/NuxtLink |
| `href` | Externer Link-Support | Klein - `<a>` statt `<button>` |
| `circle` | Runder Button | Klein - CVA-Variant hinzufügen |
### Niedrige Priorität
| Feature | Beschreibung | Aufwand |
|---------|--------------|---------|
| `filled` vs `ghost` | Explizite Unterscheidung | Diskussion nötig - aktuell via `variant` |
### Nicht geplant
| Feature | Begründung |
|---------|------------|
| `bullet` | Zu spezifisch, kann mit `circle` + custom size erreicht werden |
| `hover` prop | CSS :hover reicht |
| `padding` prop | Sollte über size geregelt werden |
---
## Vergleich: Aktuell vs. Zielzustand
### KATALOG.md Vorschlag (OsButtonProps)
```typescript
interface OsButtonProps {
// Variante
variant?: 'default' | 'primary' | 'secondary' | 'danger'
filled?: boolean
ghost?: boolean
// Größe & Form
size?: 'tiny' | 'small' | 'base' | 'large'
circle?: boolean
fullWidth?: boolean
// Icon
icon?: string
iconPosition?: 'left' | 'right'
// Zustände
loading?: boolean
disabled?: boolean
// Link-Support
to?: string | RouteLocationRaw
href?: string
// Button-Typ
type?: 'button' | 'submit'
}
```
### Aktuelle Implementierung
```typescript
interface OsButtonProps {
variant?: 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info' | 'ghost' | 'outline'
size?: 'xs' | 'sm' | 'md' | 'lg' | 'xl'
fullWidth?: boolean
type?: 'button' \| 'submit' \| 'reset'
disabled?: boolean
class?: string
}
```
### Unterschiede
| Aspekt | KATALOG.md | Implementiert | Kommentar |
|--------|------------|---------------|-----------|
| **Varianten** | 4 + filled/ghost Modifikatoren | 8 eigenständige | Besser: Mehr Varianten ohne Modifikatoren |
| **Sizes** | tiny, small, base, large | xs, sm, md, lg, xl | Besser: 5 statt 4, Standard-Naming |
| **Icon-Support** | icon, iconPosition | - | Fehlt: erfordert OsIcon |
| **Loading** | loading | - | Fehlt: erfordert OsSpinner |
| **Link-Support** | to, href | - | Fehlt: Router-Integration |
| **Circle** | circle | - | Fehlt: einfach hinzuzufügen |
---
## Test-Coverage
**Datei:** `OsButton.spec.ts`
| Test | Beschreibung |
|------|--------------|
| renders slot content | Slot-Inhalt wird gerendert |
| applies default variant classes | Primary-Variante als Default |
| applies size variant classes | Size-Classes korrekt |
| applies variant classes | Varianten-Classes korrekt |
| applies fullWidth class | w-full wird gesetzt |
| merges custom classes | Custom Classes werden via cn() gemerged |
| sets disabled attribute | disabled-Attribut wird gesetzt |
| sets button type | type-Attribut wird gesetzt |
| emits click event | Click-Event wird emittiert |
### Fehlende Tests
- [ ] Alle 8 Varianten testen
- [ ] Alle 5 Sizes testen
- [ ] Keyboard-Navigation (Enter, Space)
- [ ] Focus-States
- [ ] Disabled-State verhindert Click
---
## Architektur
### Datei-Struktur
```
src/components/OsButton/
├── OsButton.vue # Hauptkomponente
├── OsButton.spec.ts # Tests
├── button.variants.ts # CVA-Varianten-Definition
├── index.ts # Exports
└── STATUS.md # Diese Datei
```
### CVA-Pattern
Die Komponente nutzt das CVA-Pattern (Class Variance Authority):
1. **button.variants.ts** - Definiert alle Varianten als Type-Safe Funktion
2. **OsButton.vue** - Nutzt `buttonVariants()` + `cn()` für finale Klassen
3. **Export** - Varianten-Funktion + Typen werden exportiert für Composability
### CSS-Variablen
Die Komponente nutzt CSS Custom Properties für Theming:
```css
--color-primary
--color-primary-contrast
--color-primary-hover
--color-secondary (etc.)
--color-danger (etc.)
--color-warning (etc.)
--color-success (etc.)
--color-info (etc.)
```
---
## Nächste Schritte
### Phase 1: Icon-Support (erfordert OsIcon)
1. OsIcon-Komponente erstellen
2. `icon` Prop hinzufügen (string für Icon-Name oder Komponente)
3. `iconPosition` Prop hinzufügen ('left' | 'right')
4. Layout für Icon + Text anpassen
### Phase 2: Loading-State (erfordert OsSpinner)
1. OsSpinner-Komponente erstellen
2. `loading` Prop hinzufügen
3. Bei loading: Spinner anzeigen, Button disabled
### Phase 3: Link-Support
1. `to` Prop für Vue Router Links
2. `href` Prop für externe Links
3. Dynamisches Element: `<button>` | `<router-link>` | `<a>`
### Phase 4: Circle-Variant
1. `circle` Prop hinzufügen
2. CVA-Variant für runden Button
---
## Abhängigkeiten
```
OsButton
├── Benötigt: cn() utility Vorhanden
├── Benötigt: CVA Vorhanden
├── Für Icon: OsIcon Ausstehend (Tier 1)
├── Für Loading: OsSpinner Ausstehend (Tier 1)
└── Für Link: Vue Router Optional (nur für SPA-Links)
```
---
## Changelog
| Datum | Änderung |
|-------|----------|
| 2026-02-07 | Status-Datei erstellt |
| 2026-02-07 | CVA-Integration abgeschlossen |
| 2026-02-07 | 8 Varianten, 5 Sizes implementiert |
| 2026-02-07 | 9 Tests vorhanden |

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -0,0 +1,82 @@
import { cva } from 'class-variance-authority'
import type { VariantProps } from 'class-variance-authority'
/**
* Button variants using CVA (Class Variance Authority)
*
* This pattern allows:
* - Type-safe variant props
* - Composable class combinations
* - Easy customization via class prop
*/
export const buttonVariants = cva(
// Base classes (always applied)
[
'inline-flex items-center justify-center',
'font-medium',
'transition-colors duration-200',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
],
{
variants: {
variant: {
primary: [
'bg-[var(--color-primary)] text-[var(--color-primary-contrast)]',
'hover:bg-[var(--color-primary-hover)]',
'focus-visible:ring-[var(--color-primary)]',
],
secondary: [
'bg-[var(--color-secondary)] text-[var(--color-secondary-contrast)]',
'hover:bg-[var(--color-secondary-hover)]',
'focus-visible:ring-[var(--color-secondary)]',
],
danger: [
'bg-[var(--color-danger)] text-[var(--color-danger-contrast)]',
'hover:bg-[var(--color-danger-hover)]',
'focus-visible:ring-[var(--color-danger)]',
],
warning: [
'bg-[var(--color-warning)] text-[var(--color-warning-contrast)]',
'hover:bg-[var(--color-warning-hover)]',
'focus-visible:ring-[var(--color-warning)]',
],
success: [
'bg-[var(--color-success)] text-[var(--color-success-contrast)]',
'hover:bg-[var(--color-success-hover)]',
'focus-visible:ring-[var(--color-success)]',
],
info: [
'bg-[var(--color-info)] text-[var(--color-info-contrast)]',
'hover:bg-[var(--color-info-hover)]',
'focus-visible:ring-[var(--color-info)]',
],
ghost: ['bg-transparent', 'hover:bg-gray-100', 'focus-visible:ring-gray-400'],
outline: [
'border border-current bg-transparent',
'hover:bg-gray-100',
'focus-visible:ring-gray-400',
],
},
size: {
xs: 'h-6 px-2 text-xs rounded',
sm: 'h-8 px-3 text-sm rounded-md',
md: 'h-10 px-4 text-base rounded-md',
lg: 'h-12 px-6 text-lg rounded-lg',
xl: 'h-14 px-8 text-xl rounded-lg',
},
fullWidth: {
true: 'w-full',
false: '',
},
},
defaultVariants: {
variant: 'primary',
size: 'md',
fullWidth: false,
},
},
)
export type ButtonVariants = VariantProps<typeof buttonVariants>

View File

@ -0,0 +1,2 @@
export { default as OsButton } from './OsButton.vue'
export { buttonVariants, type ButtonVariants } from './button.variants'

View File

@ -0,0 +1,10 @@
/**
* Component exports
*
* Single source of truth for all components.
* Add new components here - they will automatically be:
* - Available as named exports: import { OsButton } from '@ocelot-social/ui'
* - Registered globally when using the plugin: app.use(OcelotUI)
*/
export { OsButton, buttonVariants, type ButtonVariants } from './OsButton'

20
packages/ui/src/index.ts Normal file
View File

@ -0,0 +1,20 @@
/**
* @ocelot-social/ui
*
* Vue component library for ocelot.social
* Works with Vue 2.7+ and Vue 3
*
* Note: CSS is built separately - import '@ocelot-social/ui/style.css' in your app
*/
// Re-export all components
export * from './components'
// Export Vue plugin for global registration
export { default as OcelotUI } from './plugin'
// Export utilities
export { cn } from './utils'
// Export prop types
export type { Size, Rounded, Shadow, Variant } from './types'

View File

@ -0,0 +1,50 @@
import { describe, expect, it, vi } from 'vitest'
// eslint-disable-next-line import-x/no-namespace -- needed to verify all components are registered
import * as components from './components'
import OcelotUI from './plugin'
describe('ocelotUI Plugin', () => {
it('has an install function', () => {
expect(OcelotUI.install).toBeTypeOf('function')
})
it('registers only Os-prefixed components', () => {
const mockApp = {
component: vi.fn(),
}
OcelotUI.install?.(mockApp as never)
// Filter to only Os-prefixed entries (actual Vue components)
const osComponents = Object.entries(components).filter(([name]) => name.startsWith('Os'))
expect(mockApp.component).toHaveBeenCalledTimes(osComponents.length)
for (const [name, component] of osComponents) {
expect(mockApp.component).toHaveBeenCalledWith(name, component)
}
})
it('does not register non-component exports', () => {
const mockApp = {
component: vi.fn(),
}
OcelotUI.install?.(mockApp as never)
// buttonVariants should NOT be registered
const callArgs = mockApp.component.mock.calls.map((call: unknown[]) => call[0])
expect(callArgs).not.toContain('buttonVariants')
})
it('works without throwing', () => {
const mockApp = {
component: vi.fn(),
}
expect(() => {
OcelotUI.install?.(mockApp as never)
}).not.toThrow()
})
})

26
packages/ui/src/plugin.ts Normal file
View File

@ -0,0 +1,26 @@
// eslint-disable-next-line import-x/no-namespace -- needed for dynamic component registration
import * as components from './components'
import type { App, Component, Plugin } from 'vue-demi'
/**
* Vue plugin for global component registration
*
* Usage:
* ```ts
* import { OcelotUI } from '@ocelot-social/ui'
* app.use(OcelotUI)
* ```
*/
const OcelotUI: Plugin = {
install(app: App) {
for (const [name, component] of Object.entries(components)) {
// Only register Vue components (starting with 'Os')
if (name.startsWith('Os')) {
app.component(name, component as Component)
}
}
},
}
export default OcelotUI

View File

@ -0,0 +1,5 @@
@import "tailwindcss";
/* Scan component files for utility classes */
@source "../components/**/*.vue";
@source "../**/*.ts";

View File

@ -0,0 +1,94 @@
import { describe, it, expect, vi, afterEach } from 'vitest'
import { ocelotPreset, requiredCssVariables, validateCssVariables } from './tailwind.preset'
describe('tailwind.preset', () => {
describe('ocelotPreset', () => {
it('exports a valid Tailwind preset with theme.extend structure', () => {
expect(ocelotPreset).toBeDefined()
expect(ocelotPreset).toHaveProperty('theme')
expect(ocelotPreset.theme).toHaveProperty('extend')
})
})
describe('requiredCssVariables', () => {
it('exports an array', () => {
expect(Array.isArray(requiredCssVariables)).toBeTruthy()
})
it('contains only strings', () => {
for (const variable of requiredCssVariables) {
expect(typeof variable).toBe('string')
}
})
it('all variables start with --', () => {
// This test validates the format constraint.
for (const variable of requiredCssVariables) {
expect(variable.startsWith('--')).toBeTruthy()
}
// Ensure test runs even with empty array
expect(requiredCssVariables.every((v) => v.startsWith('--'))).toBeTruthy()
})
})
describe('validateCssVariables', () => {
afterEach(() => {
vi.unstubAllGlobals()
vi.restoreAllMocks()
})
it('does nothing when window is undefined (SSR)', () => {
vi.stubGlobal('window', undefined)
expect(() => {
validateCssVariables()
}).not.toThrow()
})
it('does not warn when all variables are defined', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockGetPropertyValue = vi.fn().mockReturnValue('some-value')
vi.stubGlobal('window', {})
vi.stubGlobal('document', {
documentElement: {},
})
vi.stubGlobal('getComputedStyle', () => ({
getPropertyValue: mockGetPropertyValue,
}))
validateCssVariables()
expect(consoleWarnSpy).not.toHaveBeenCalled()
})
it('warns when variables are missing', () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {})
const mockGetPropertyValue = vi.fn().mockReturnValue('')
vi.stubGlobal('window', {})
vi.stubGlobal('document', {
documentElement: {},
})
vi.stubGlobal('getComputedStyle', () => ({
getPropertyValue: mockGetPropertyValue,
}))
// Temporarily add a required variable for testing
const originalVariables = [...requiredCssVariables]
requiredCssVariables.push('--test-variable')
validateCssVariables()
expect(consoleWarnSpy).toHaveBeenCalledWith(
expect.stringContaining('Missing required CSS variables'),
)
expect(consoleWarnSpy).toHaveBeenCalledWith(expect.stringContaining('--test-variable'))
// Restore original state
requiredCssVariables.length = 0
requiredCssVariables.push(...originalVariables)
})
})
})

View File

@ -0,0 +1,95 @@
/**
* Tailwind CSS Preset for @ocelot-social/ui
*
* This preset defines CSS Custom Properties used by components.
* The library does NOT provide default values - the consuming app must define all variables.
*
* Branding hierarchy:
* 1. Webapp defines default branding (base colors)
* 2. Specialized brandings override the defaults
*
* Usage in your tailwind.config.js:
* ```js
* import { ocelotPreset } from '@ocelot-social/ui/tailwind.preset'
*
* export default {
* presets: [ocelotPreset],
* // your config...
* }
* ```
*
* Required CSS Variables (defined by webapp):
* - See `requiredCssVariables` export for the full list
*/
import type { Config } from 'tailwindcss'
/**
* List of CSS Custom Properties that must be defined by the consuming app.
* This list grows as components are added to the library.
*/
export const requiredCssVariables: string[] = [
// Primary
'--color-primary',
'--color-primary-hover',
'--color-primary-contrast',
// Secondary
'--color-secondary',
'--color-secondary-hover',
'--color-secondary-contrast',
// Danger
'--color-danger',
'--color-danger-hover',
'--color-danger-contrast',
// Warning
'--color-warning',
'--color-warning-hover',
'--color-warning-contrast',
// Success
'--color-success',
'--color-success-hover',
'--color-success-contrast',
// Info
'--color-info',
'--color-info-hover',
'--color-info-contrast',
]
/**
* Validates that all required CSS variables are defined.
* Call this in development to catch missing variables early.
*
* @example
* ```ts
* import { validateCssVariables } from '@ocelot-social/ui/tailwind.preset'
*
* if (process.env.NODE_ENV === 'development') {
* validateCssVariables()
* }
* ```
*/
export function validateCssVariables(): void {
if (typeof window === 'undefined') return
const styles = getComputedStyle(document.documentElement)
const missing = requiredCssVariables.filter(
(variable) => !styles.getPropertyValue(variable).trim(),
)
if (missing.length > 0) {
// eslint-disable-next-line no-console
console.warn(
`[@ocelot-social/ui] Missing required CSS variables:\n${missing.map((v) => ` - ${v}`).join('\n')}\n\nDefine these in your app's CSS.`,
)
}
}
export const ocelotPreset: Partial<Config> = {
theme: {
extend: {
// Colors and other theme extensions will be added here as components are developed.
// All values use CSS Custom Properties WITHOUT defaults.
// Example: primary: { DEFAULT: 'var(--color-primary)' }
},
},
}

29
packages/ui/src/types.d.ts vendored Normal file
View File

@ -0,0 +1,29 @@
/**
* Component prop types based on Tailwind CSS scales
*
* These types ensure consistency across all components.
* When a component supports a prop, it must support all values of that scale.
*/
/**
* Size scale for components (buttons, inputs, avatars, etc.)
* Maps to Tailwind's text/spacing scale
*/
export type Size = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
/**
* Border radius scale
* Maps to Tailwind's rounded-* utilities
*/
export type Rounded = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl' | '3xl' | 'full'
/**
* Box shadow scale
* Maps to Tailwind's shadow-* utilities
*/
export type Shadow = 'none' | 'sm' | 'md' | 'lg' | 'xl' | '2xl'
/**
* Semantic color variants for interactive components
*/
export type Variant = 'primary' | 'secondary' | 'danger' | 'warning' | 'success' | 'info'

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { cn } from './cn'
describe('cn', () => {
it('merges class names', () => {
expect(cn('foo', 'bar')).toBe('foo bar')
})
it('handles conditional classes with false', () => {
expect(cn('foo', false, 'baz')).toBe('foo baz')
})
it('handles conditional classes with string', () => {
expect(cn('foo', 'bar', 'baz')).toBe('foo bar baz')
})
it('handles undefined and null', () => {
expect(cn('foo', undefined, null, 'bar')).toBe('foo bar')
})
it('merges conflicting Tailwind classes', () => {
expect(cn('px-2 py-1', 'px-4')).toBe('py-1 px-4')
expect(cn('text-red-500', 'text-blue-500')).toBe('text-blue-500')
})
it('handles arrays', () => {
expect(cn(['foo', 'bar'], 'baz')).toBe('foo bar baz')
})
it('handles objects', () => {
expect(cn({ foo: true, bar: false, baz: true })).toBe('foo baz')
})
})

View File

@ -0,0 +1,16 @@
import { clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
import type { ClassValue } from 'clsx'
/**
* Utility function for merging Tailwind CSS classes.
* Combines clsx for conditional classes with tailwind-merge for deduplication.
*
* @example
* cn('px-2 py-1', 'px-4') // => 'py-1 px-4' (px-4 overrides px-2)
* cn('text-red-500', condition && 'text-blue-500')
*/
export function cn(...inputs: ClassValue[]): string {
return twMerge(clsx(inputs))
}

View File

@ -0,0 +1 @@
export { cn } from './cn'

Some files were not shown because too many files have changed in this diff Show More