refactor(webapp): vue3 migration - phase 3 - integration (#9180)

This commit is contained in:
Ulf Gebhardt 2026-02-10 21:56:32 +01:00 committed by GitHub
parent f2e77595b2
commit 9b98dcae9a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 2496 additions and 485 deletions

View File

@ -1,5 +1,9 @@
# These file filter patterns are used by the action https://github.com/dorny/paths-filter
ui: &ui
- '.github/workflows/ui-*.yml'
- 'packages/ui/**/*'
backend: &backend
- '.github/workflows/test-backend.yml'
- 'backend/**/*'
@ -14,6 +18,7 @@ webapp: &webapp
- 'webapp/**/*'
- 'styleguide/**/*'
- 'package.json'
- *ui
docs-check: &docs-check
- '.github/workflows/check-documentation.yml'

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-build.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-build.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,14 +5,34 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-compatibility.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-compatibility.yml'
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build-library:
name: Build Library
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
defaults:
run:
@ -44,7 +64,8 @@ jobs:
test-compatibility:
name: Test ${{ matrix.example }}
needs: build-library
if: needs.files-changed.outputs.ui == 'true'
needs: [files-changed, build-library]
runs-on: ubuntu-latest
strategy:
fail-fast: false

View File

@ -5,14 +5,34 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-docker.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-docker.yml'
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build Docker Image
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-lint.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-lint.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
lint:
name: ESLint
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-size.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-size.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
size:
name: Bundle Size Check
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-storybook.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-storybook.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
build:
name: Build Storybook
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-test.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-test.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
test:
name: Unit Tests
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-verify.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-verify.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
verify:
name: Completeness Check
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -5,18 +5,38 @@ on:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-visual.yml'
pull_request:
branches: [master]
paths:
- 'packages/ui/**'
- '.github/workflows/ui-visual.yml'
defaults:
run:
working-directory: packages/ui
jobs:
files-changed:
name: Detect File Changes
runs-on: ubuntu-latest
outputs:
ui: ${{ steps.changes.outputs.ui }}
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Check for file changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
id: changes
with:
token: ${{ github.token }}
filters: .github/file-filters.yml
visual:
name: Visual Regression
if: needs.files-changed.outputs.ui == 'true'
needs: files-changed
runs-on: ubuntu-latest
steps:

View File

@ -1,4 +1,6 @@
// eslint-disable-next-line import-x/no-unassigned-import
import '@fontsource-variable/inter'
// eslint-disable-next-line import-x/no-unassigned-import
import './storybook.css'
export const parameters = {

View File

@ -1,34 +1,54 @@
/* 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 */
/* Watercolor theme for Storybook - vibrant colors inspired by aquarelle paints */
/* All colors meet WCAG AA contrast requirements (4.5:1 for normal text) */
:root {
font-family: 'Inter', system-ui, sans-serif;
font-family: 'Inter Variable', system-ui, sans-serif;
--color-primary: #404040;
--color-primary-hover: #262626;
/* Default (Buff / Raw Umber) - neutral button */
--color-default: #d8c9b8;
--color-default-hover: #c4b19c;
--color-default-active: #a89478;
--color-default-contrast: #3d2e1e;
--color-default-contrast-inverse: #ffffff;
/* Primary (Ultramarine Blue) */
--color-primary: #4a5a9e;
--color-primary-hover: #5e6db2;
--color-primary-active: #3b4882;
--color-primary-contrast: #ffffff;
--color-secondary: #595959;
--color-secondary-hover: #404040;
/* Secondary (Dioxazine Violet) */
--color-secondary: #7b519a;
--color-secondary-hover: #9068ad;
--color-secondary-active: #654280;
--color-secondary-contrast: #ffffff;
--color-danger: #525252;
--color-danger-hover: #404040;
/* Danger (Alizarin Crimson) */
--color-danger: #b83a4e;
--color-danger-hover: #cc5567;
--color-danger-active: #9a2e3f;
--color-danger-contrast: #ffffff;
--color-warning: #d4d4d4;
--color-warning-hover: #a3a3a3;
--color-warning-contrast: #000000;
/* Warning (Yellow Ochre / Raw Sienna) - dark amber, works as text on white */
--color-warning: #946b1d;
--color-warning-hover: #a87c28;
--color-warning-active: #7d5a15;
--color-warning-contrast: #ffffff;
--color-success: #525252;
--color-success-hover: #404040;
/* Success (Viridian Green) */
--color-success: #3d7a5e;
--color-success-hover: #509470;
--color-success-active: #30644c;
--color-success-contrast: #ffffff;
--color-info: #595959;
--color-info-hover: #404040;
/* Info (Cerulean Blue) */
--color-info: #387598;
--color-info-hover: #4e8bae;
--color-info-active: #2d6080;
--color-info-contrast: #ffffff;
/* Disabled (Payne's Grey - muted watercolor grey) */
--color-disabled: #c4bdb5;
--color-disabled-contrast: #5a4f45;
}

View File

@ -10,8 +10,9 @@
### Übersicht
```
Phase 0: Analyse ██████████ 100% (8/8 Schritte)
Phase 3: Migration ████░░░░░░ 36% (32/90 Buttons)
───────────────────────────────────────────
Nächste Phase: Phase 2 (Projekt-Setup)
Aktuelle Phase: Phase 3 - Milestone 4a ✅, nächster: Milestone 4b
```
### Statistiken
@ -24,6 +25,109 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| Duplikate gefunden | 5 direkte + 3 Familien |
| Zur Migration priorisiert | 15 Kern-Komponenten |
### OsButton Migration (Phase 3)
| Status | Anzahl | Details |
|--------|--------|---------|
| ✅ Migriert | 32 | Erste Welle (16) + Milestone 4a (14) + NotificationMenu (2) |
| ⏳ Ausstehend (mit neuen Props) | ~60 | Milestone 4c (benötigen icon/circle/loading) |
| **Gesamt** | **~90** | In ~50 Dateien |
**Migrierte Komponenten (32):**
*Erste Welle (16):*
- UserTeaserPopover.vue (1 Button)
- GroupForm.vue (1 Button - Cancel)
- EmbedComponent.vue (2 Buttons - Cancel, Play Now)
- DonationInfo.vue (1 Button)
- CommentCard.vue (1 Button - Show More)
- MapStylesButtons.vue (1 Button)
- GroupMember.vue (1 Button)
- embeds.vue (2 Buttons)
- notifications.vue (3 Buttons)
- privacy.vue (1 Button)
- terms-and-conditions-confirm.vue (2 Buttons)
*Milestone 4a (14) ✅:*
- ✅ DisableModal.vue (1 Button - Cancel)
- ✅ DeleteUserModal.vue (1 Button - Cancel)
- ✅ ReleaseModal.vue (1 Button - Cancel)
- ✅ ContributionForm.vue (1 Button - Cancel)
- ✅ EnterNonce.vue (1 Button - Submit)
- ✅ MySomethingList.vue (1 Button - Cancel)
- ✅ ImageUploader.vue (2 Buttons - Crop)
- ✅ admin/donations.vue (1 Button - Save)
- ✅ profile/_id/_slug.vue (2 Buttons - Unblock, Unmute)
- ✅ settings/badges.vue (1 Button - Remove)
- ✅ notifications/index.vue (1 Button - Mark All Read)
- ✅ ReportRow.vue (1 Button - More Details)
*Sonstige (2):*
- ✅ NotificationMenu.vue (2 Buttons - Mark All Read, Notification Page)
**Ausstehend - benötigen neue Props (~60):**
*Button-Komponenten mit icon/circle/loading:*
- ActionButton.vue: 3 Buttons (icon, circle)
- LabeledButton.vue: 1 Button (icon)
- MenuBarButton.vue: 1 Button (icon)
- EmotionButton.vue: 1 Button (icon)
- ShoutButton.vue: 1 Button (icon)
- FollowButton.vue: 1 Button (icon, loading)
- JoinLeaveButton.vue: 1 Button (icon, loading)
- ObserveButton.vue: 1 Button (icon, loading)
- InviteButton.vue: 1 Button (icon, loading)
- MapButton.vue: 1 Button (icon)
- PaginationButtons.vue: 2 Buttons (icon, circle)
*Navigation mit icon:*
- LocaleSwitch.vue: 1 Button (icon)
- HeaderMenu.vue: 2 Buttons (icon)
- AvatarMenu.vue: 1 Button (icon, circle)
- NotificationMenu.vue: 1 Button (icon, circle)
- ChatNotificationMenu.vue: 1 Button (icon, circle)
- FilterMenu.vue: 1 Button (icon)
*Editor-Buttons:*
- Editor.vue: ~10 Toolbar-Buttons (icon)
- ContextMenu.vue: 3 Buttons (icon)
- LinkInput.vue: 2 Buttons (icon, circle)
*Filter-Buttons:*
- CategoriesFilter.vue: 1 Button (icon)
- HashtagsFilter.vue: 1 Button (icon)
- DropdownFilter.vue: 1 Button (icon)
- FilterMenuSection.vue: 2 Buttons (icon)
- DateTimeRange.vue: 2 Buttons (icon)
*Chat-Buttons:*
- Chat.vue: 2 Buttons (icon)
- AddChatRoomByUserSearch.vue: 1 Button (icon, circle)
*Form-Buttons:*
- CommentForm.vue: 1 Button (icon, loading)
- SearchField.vue: 1 Button (icon, circle)
- ShowPassword.vue: 1 Button (icon)
*Modal-Buttons:*
- ConfirmModal.vue: 1 Button (loading)
- ReportModal.vue: 1 Button (loading)
*Feature-Buttons:*
- CreateInvitation.vue: 1 Button (icon)
- Invitation.vue: 1 Button (icon, circle)
- SocialMediaListItem.vue: 1 Button (icon, circle)
- Request.vue: 1 Button (icon, loading)
- GroupButton.vue: 1 Button (icon)
- CtaJoinLeaveGroup.vue: 1 Button (icon)
- data-download.vue: 1 Button (icon, loading)
- AddGroupMember.vue: 1 Button (icon)
- GroupForm.vue Submit: 1 Button (icon)
*Page-Buttons:*
- CommentCard.vue Reply: 1 Button (icon, circle)
- EmbedComponent.vue Close: 1 Button (icon, circle)
- PostTeaser.vue: 2 Buttons (icon)
---
## Styleguide Komponenten (38)
@ -50,7 +154,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
### Data Input
| # | Komponente | Status | Webapp-Duplikat | Varianten | Priorität | Notizen |
|---|------------|--------|-----------------|-----------|-----------|---------|
| 13 | Button | ⬜ Ausstehend | BaseButton, CustomButton, ActionButton, ... | | | VIELE Varianten! |
| 13 | Button | ⏳ Migration | BaseButton, CustomButton, ActionButton, ... | | | → OsButton (16/90 migriert) |
| 14 | CopyField | ⬜ Ausstehend | | | | |
| 15 | Form | ⬜ Ausstehend | | | | |
| 16 | FormItem | ⬜ Ausstehend | | | | |
@ -115,7 +219,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| 7 | BadgeSelection | ⬜ Ausstehend | Input | | |
| 8 | Badges | ⬜ Ausstehend | Display | | |
| 9 | BadgesSection | ⬜ Ausstehend | Display | | |
| 10 | BaseButton | ⬜ Ausstehend | Button | Button | 🔄 Button-Familie |
| 10 | BaseButton | ⏳ Migration | Button | Button | 🔄 → OsButton (16/90 migriert) |
| 11 | BaseCard | ⬜ Ausstehend | Layout | Card | 🔗 DUPLIKAT |
| 12 | BaseIcon | ⬜ Ausstehend | Display | Icon | 🔗 DUPLIKAT |
@ -129,7 +233,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| 17 | Change | ⬜ Ausstehend | Feature | | |
| 18 | Chat | ⬜ Ausstehend | Feature | | Chat-spezifisch |
| 19 | ChatNotificationMenu | ⬜ Ausstehend | Feature | | Chat-spezifisch |
| 20 | CommentCard | ⬜ Ausstehend | Display | Card | |
| 20 | CommentCard | ⏳ Teilweise | Display | Card | 1/2 Buttons → OsButton |
| 21 | CommentForm | ⬜ Ausstehend | Input | Form | |
| 22 | CommentList | ⬜ Ausstehend | Display | List | |
| 23 | ComponentSlider | ⬜ Ausstehend | Layout | | |
@ -152,12 +256,12 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| 36 | DeleteData | ⬜ Ausstehend | Feature | | |
| 37 | DeleteUserModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 38 | DisableModal | ⬜ Ausstehend | Feedback | Modal | 🔄 Modal-Familie |
| 39 | DonationInfo | ⬜ Ausstehend | Display | | |
| 39 | DonationInfo | ✅ Migriert | Display | | Button → OsButton |
| 40 | Dropdown | ⬜ Ausstehend | Input | Select | |
| 41 | DropdownFilter | ⬜ Ausstehend | Filter | Select | |
| 42 | Editor | ⬜ Ausstehend | Input | | Rich-Text |
| 43 | EmailDisplayAndVerify | ⬜ Ausstehend | Feature | | |
| 44 | EmbedComponent | ⬜ Ausstehend | Display | | |
| 44 | EmbedComponent | ⏳ Teilweise | Display | | 2/3 Buttons → OsButton |
| 45 | EmotionButton | ⬜ Ausstehend | Button | Button | |
| 46 | Emotions | ⬜ Ausstehend | Feature | | |
| 47 | Empty | ⬜ Ausstehend | Feedback | Placeholder | |
@ -176,10 +280,10 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| 56 | FollowList | ⬜ Ausstehend | Display | List | |
| 57 | GroupButton | ⬜ Ausstehend | Button | Button | |
| 58 | GroupContentMenu | ⬜ Ausstehend | Navigation | Menu | |
| 59 | GroupForm | ⬜ Ausstehend | Input | Form | |
| 59 | GroupForm | ⏳ Teilweise | Input | Form | 1/2 Buttons → OsButton |
| 60 | GroupLink | ⬜ Ausstehend | Navigation | | |
| 61 | GroupList | ⬜ Ausstehend | Display | List | |
| 62 | GroupMember | ⬜ Ausstehend | Display | | |
| 62 | GroupMember | ✅ Migriert | Display | | Button → OsButton |
| 63 | GroupTeaser | ⬜ Ausstehend | Display | Card | |
### H-L
@ -211,7 +315,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| # | Komponente | Status | Kategorie | Styleguide-Pendant | Notizen |
|---|------------|--------|-----------|-------------------|---------|
| 85 | MapButton | ⬜ Ausstehend | Button | Button | |
| 86 | MapStylesButtons | ⬜ Ausstehend | Button | Button | |
| 86 | MapStylesButtons | ✅ Migriert | Button | Button | Button → OsButton |
| 87 | MasonryGrid | ⬜ Ausstehend | Layout | Grid | |
| 88 | MasonryGridItem | ⬜ Ausstehend | Layout | GridItem | |
| 89 | MenuBar | ⬜ Ausstehend | Navigation | Menu | |
@ -274,7 +378,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| 134 | UserTeaser | ⬜ Ausstehend | Display | Card | |
| 135 | UserTeaserHelper | ⬜ Ausstehend | Display | | |
| 136 | UserTeaserNonAnonymous | ⬜ Ausstehend | Display | | |
| 137 | UserTeaserPopover | ⬜ Ausstehend | Display | | |
| 137 | UserTeaserPopover | ✅ Migriert | Display | | Button → OsButton |
---
@ -308,7 +412,7 @@ Nächste Phase: Phase 2 (Projekt-Setup)
| EmotionButton | Emotion | Feature-spezifisch |
| JoinLeaveButton | Beitreten/Verlassen | Feature-spezifisch |
| MapButton | Karten-Button | Feature-spezifisch |
| MapStylesButtons | Kartenstile | Feature-spezifisch |
| MapStylesButtons | Kartenstile | ✅ → OsButton |
| CtaJoinLeaveGroup | CTA | Feature-spezifisch |
| CtaUnblockAuthor | CTA | Feature-spezifisch |
@ -374,11 +478,18 @@ Diese sollten zuerst migriert werden:
| 2026-02-04 | Claude | Priorisierung | 15 Komponenten in 4 Tiers priorisiert |
| 2026-02-04 | Claude | Konsolidierungsplan | 3 Phasen definiert, Token-Liste erstellt |
| 2026-02-04 | Claude | **Phase 0 abgeschlossen** | Bereit für Phase 2 (Projekt-Setup) |
| 2026-02-08 | Claude | OsButton entwickelt | CVA-Varianten, Vue 2/3 kompatibel via vue-demi |
| 2026-02-08 | Claude | Webapp-Integration | Jest Mock, Docker Build, CI-Kompatibilität |
| 2026-02-08 | Claude | **16 Buttons migriert** | Alle ohne icon/circle/loading Props, validiert |
| 2026-02-08 | Claude | OsButton erweitert | attrs/listeners Forwarding für Vue 2 ($listeners) |
| 2026-02-09 | Claude | Scope erweitert | ~90 Buttons identifiziert (16 migriert, 14 ohne Props, ~60 mit Props) |
| 2026-02-09 | Claude | **Milestone 4a: 8 Buttons** | DisableModal, DeleteUserModal, ReleaseModal, ContributionForm, EnterNonce, MySomethingList, ImageUploader (2x) |
---
## Nächste Schritte
### Phase 0: Analyse ✅
1. [x] Webapp-Komponenten auflisten
2. [x] Styleguide-Komponenten auflisten
3. [x] Offensichtliche Duplikate identifizieren
@ -388,9 +499,33 @@ Diese sollten zuerst migriert werden:
7. [x] Priorisierung festlegen
8. [x] Konsolidierungsplan finalisieren
### Phase 3: OsButton Migration (in Arbeit)
9. [x] OsButton entwickeln (CVA, vue-demi)
10. [x] Webapp-Integration (Jest, Docker, CI)
11. [x] 16 Buttons migrieren (validiert ✅)
**Milestone 4a: 14 Buttons ohne neue Props**
12. [ ] Modal Cancel-Buttons (3)
13. [ ] Form Cancel/Submit-Buttons (3)
14. [ ] ImageUploader Crop-Buttons (2)
15. [ ] Page Buttons (6)
**Milestone 4b: Props für ~60 Buttons hinzufügen**
16. [ ] icon-Prop zu OsButton hinzufügen
17. [ ] circle-Variant zu OsButton hinzufügen
18. [ ] loading-Prop zu OsButton hinzufügen
**Milestone 4c: ~60 Buttons mit neuen Props migrieren**
19. [ ] Button-Komponenten (~15)
20. [ ] Navigation (~8)
21. [ ] Editor (~15)
22. [ ] Filter/Chat (~10)
23. [ ] Forms/Modals (~5)
24. [ ] Features/Pages (~12)
---
**✅ Phase 0 abgeschlossen!** Weiter mit Phase 2 (Projekt-Setup).
**✅ Phase 0 abgeschlossen!** Phase 3 zu 27% erledigt (24/90 Buttons migriert). Milestone 4a: 8/14 Buttons.
---
@ -1073,3 +1208,99 @@ $z-index-dropdown: 8888
$box-shadow-x-large: 0 15px 30px 0 rgba(0,0,0,.11), ...
$box-shadow-small-inset: inset 0 0 0 1px rgba(0,0,0,.05)
```
---
## Phase 3: Webapp-Integration (Tracking)
### OsButton Migration - Abgeschlossen (16/90)
| # | Datei | Button | Status |
|---|-------|--------|--------|
| 1 | UserTeaserPopover.vue | Open Profile | ✅ Migriert |
| 2 | GroupForm.vue | Cancel | ✅ Migriert |
| 3 | EmbedComponent.vue | Cancel | ✅ Migriert |
| 4 | EmbedComponent.vue | Play Now | ✅ Migriert |
| 5 | DonationInfo.vue | Donate | ✅ Migriert |
| 6 | CommentCard.vue | Show More | ✅ Migriert |
| 7 | MapStylesButtons.vue | Style Toggle | ✅ Migriert |
| 8 | GroupMember.vue | Remove | ✅ Migriert |
| 9 | embeds.vue | Allow All | ✅ Migriert |
| 10 | embeds.vue | Deny All | ✅ Migriert |
| 11 | notifications.vue | Check All | ✅ Migriert |
| 12 | notifications.vue | Uncheck All | ✅ Migriert |
| 13 | notifications.vue | Save | ✅ Migriert |
| 14 | privacy.vue | Save | ✅ Migriert |
| 15 | terms-and-conditions-confirm.vue | Read T&C | ✅ Migriert |
| 16 | terms-and-conditions-confirm.vue | Save | ✅ Migriert |
### OsButton Migration - Ausstehend ohne neue Props (Milestone 4a: 14/90)
| # | Datei | Button | OsButton Props | Status |
|---|-------|--------|----------------|--------|
| 17 | Modal/DisableModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 18 | Modal/DeleteUserModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 19 | Modal/ReleaseModal.vue | Cancel | `default` | ⬜ Ausstehend |
| 20 | ContributionForm.vue | Cancel | `:disabled` | ⬜ Ausstehend |
| 21 | EnterNonce.vue | Submit | `variant="primary" :disabled` | ⬜ Ausstehend |
| 22 | MySomethingList.vue | Cancel | `default` | ⬜ Ausstehend |
| 23 | ImageUploader.vue | Crop Confirm 1 | `variant="primary"` | ⬜ Ausstehend |
| 24 | ImageUploader.vue | Crop Confirm 2 | `variant="primary"` | ⬜ Ausstehend |
| 25 | admin/donations.vue | Save | `variant="primary"` | ⬜ Ausstehend |
| 26 | profile/_id/_slug.vue | Unblock | `default` | ⬜ Ausstehend |
| 27 | profile/_id/_slug.vue | Unmute | `default` | ⬜ Ausstehend |
| 28 | settings/badges.vue | Remove | `default` | ⬜ Ausstehend |
| 29 | notifications/index.vue | Mark All Read | `variant="primary" :disabled` | ⬜ Ausstehend |
| 30 | ReportRow.vue | More Details | `size="sm"` | ⬜ Ausstehend |
### OsButton Migration - Ausstehend mit neuen Props (Milestone 4c: ~60/90)
> Diese Buttons benötigen icon, circle, und/oder loading Props.
> Siehe "Ausstehend - benötigen neue Props (~60)" oben für vollständige Liste.
**Kategorien:**
| Kategorie | Anzahl | Props benötigt |
|-----------|--------|----------------|
| Button-Komponenten | ~15 | icon, circle, loading |
| Navigation | ~8 | icon, circle |
| Editor | ~15 | icon |
| Filter/Chat | ~10 | icon, circle |
| Forms/Modals | ~5 | icon, loading |
| Features/Pages | ~12 | icon, circle, loading |
### Fehlende OsButton-Features
| Feature | Benötigt für | Status |
|---------|-------------|--------|
| `icon` Prop | ~55 Buttons | ⬜ Fehlt |
| `circle` Variant | ~25 Buttons | ⬜ Fehlt |
| `loading` Prop | ~10 Buttons | ⬜ Fehlt |
| `appearance="outline"` | ✅ Implementiert | ✅ Erledigt |
| `appearance="ghost"` | ✅ Implementiert | ✅ Erledigt |
### Nächste Schritte
**Milestone 4a: 14 Buttons ohne neue Props migrieren**
1. Modal Cancel-Buttons (3)
2. Form Cancel/Submit-Buttons (3)
3. ImageUploader Crop-Buttons (2)
4. Page Buttons (6)
**Milestone 4b: Props für ~60 Buttons hinzufügen**
1. Icon-Prop zu OsButton hinzufügen
2. Circle-Variant zu OsButton hinzufügen
3. Loading-Prop zu OsButton hinzufügen
**Milestone 4c: ~60 Buttons mit neuen Props migrieren**
1. Button-Komponenten (~15)
2. Navigation (~8)
3. Editor (~15)
4. Filter/Chat (~10)
5. Forms/Modals (~5)
6. Features/Pages (~12)
### Integrations-Protokoll
| Datum | Aktion | Details |
|-------|--------|---------|
| 2026-02-08 | Analyse | 6 Einsatzstellen identifiziert, 2 minimal (nur variant) |

View File

@ -51,6 +51,8 @@
| # | Abschnitt |
|---|-----------|
| 16 | [Library vs. Webapp](#16-library-vs-webapp) |
| 16a | [Webapp ↔ Maintenance Code-Sharing](#16a-webapp--maintenance-code-sharing) |
| 16b | [Daten-Entkopplung (ViewModel/Mapper)](#16b-daten-entkopplung-viewmodelmapper-pattern) |
| 17 | [Externe Abhängigkeiten](#17-externe-abhängigkeiten) |
| 18 | [Kompatibilitätstests (Details)](#18-kompatibilitätstests-details) |
| 19 | [Komplexitätsanalyse](#19-komplexitätsanalyse) |
@ -78,12 +80,11 @@
Phase 0: ██████████ 100% (6/6 Aufgaben) ✅
Phase 1: ██████████ 100% (6/6 Aufgaben) ✅
Phase 2: ██████████ 100% (26/26 Aufgaben) ✅
Phase 3: ░░░░░░░░░░ 0% (0/7 Aufgaben)
Phase 3: █████████░ 83% (20/24 Aufgaben) - Webapp-Integration (M4a ✅, M5 ✅)
Phase 4: █░░░░░░░░░ 6% (1/17 Aufgaben) - OsButton ✅
Phase 5: ░░░░░░░░░░ 0% (0/7 Aufgaben)
Webapp: ░░░░░░░░░░ 0% (0/1 Aufgaben)
───────────────────────────────────────
Gesamt: █████░░░░░ 56% (39/70 Aufgaben)
Gesamt: ███████░░░ 69% (59/86 Aufgaben)
```
### Katalogisierung (Details in KATALOG.md)
@ -93,22 +94,30 @@ Styleguide: ██████████ 100% (38 Komponenten erfasst)
Analyse: ██████████ 100% (Button, Modal, Menu detailiert)
```
### Komponenten-Migration (Priorisiert: 15)
### OsButton Migration (Phase 3)
```
Analysiert: 3 Familien (Button, Modal, Menu)
Spezifiziert: 1 (OsButton)
Entwickelt: 1 (OsButton mit CVA)
QA bestanden: 1 (OsButton: 100% Coverage, Visual, A11y, Keyboard)
Integriert: 0
Scope gesamt: ~90 Buttons in Webapp
├─ Migriert: 32 Buttons (36%) ✅
├─ Ohne neue Props: 0 Buttons (Milestone 4a ✅)
└─ Mit icon/circle/loading: ~60 Buttons (Milestone 4c)
OsButton Features:
├─ variant: ✅ primary, secondary, danger, warning, success, info, default
├─ appearance: ✅ filled, outline, ghost
├─ size: ✅ xs, sm, md, lg, xl
├─ disabled: ✅ mit hover/active-Override
├─ icon: ⬜ TODO (Milestone 4b)
├─ circle: ⬜ TODO (Milestone 4b)
└─ loading: ⬜ TODO (Milestone 4b)
```
---
## Aktueller Stand
**Letzte Aktualisierung:** 2026-02-08
**Letzte Aktualisierung:** 2026-02-10 (Session 12)
**Aktuelle Phase:** Phase 3 (Token-System & Basis) - Bereit zum Start
**Aktuelle Phase:** Phase 3 (Webapp-Integration) - Milestone 4a abgeschlossen ✅ (32 Buttons migriert, nächster: Milestone 4b)
**Zuletzt abgeschlossen:**
- [x] Projektordner erstellt
@ -166,8 +175,8 @@ Integriert: 0
- cn() Utility für Tailwind-Klassen-Merge (clsx + tailwind-merge)
- OsButton Komponente mit CVA-Varianten implementiert
- ESLint-Konfiguration angepasst (vue/max-attributes-per-line, import-x/no-relative-parent-imports)
- Storybook 10 für Dokumentation eingerichtet (Greyscale-Theme)
- OsButton.stories.ts mit allen Varianten
- Storybook 10 für Dokumentation eingerichtet (Wasserfarben-Theme)
- OsButton.stories.ts mit Playground + allen Varianten/Appearances/Sizes
- Storybook Build-Konfiguration (viteFinal entfernt Library-Plugins)
- Docker Setup (Dockerfile, docker-compose, ui-docker.yml)
- Visual Regression Tests (Playwright, colocated) mit integriertem A11y-Check
@ -175,21 +184,91 @@ Integriert: 0
- ESLint Plugins: vuejs-accessibility, playwright, storybook, jsdoc
**Aktuell in Arbeit:**
- Bereit für Phase 3: Token-System & Basis
- Phase 3, Milestone 4b: icon/circle/loading Props in OsButton implementieren
- Phase 3, Milestone 4c: ~60 Buttons mit icon/circle/loading migrieren
**Zuletzt abgeschlossen (Session 12 - CSS-Linting, CI-Optimierung, Code-Review Fixes):**
- [x] CSS-Linting: `@eslint/css` + `tailwind-csstree` für Tailwind v4 Syntax-Support
- [x] `excludeCSS()` Helper: JS-Regeln von CSS-Dateien fernhalten (language-Inkompatibilität)
- [x] CSS-Regeln: `no-empty-blocks`, `no-duplicate-imports`, `no-invalid-at-rules`
- [x] CI-Workflow-Trigger optimiert: 9 UI-Workflows von `on: push` auf Branch+Path-Filter (`master`, `packages/ui/**`)
- [x] `custom-class``class` Migration: 4 Stellen in 3 Webapp-Dateien (notifications, MapStylesButtons, EmbedComponent)
- [x] Vue 3 Template-Fix: `this.$t()``$t()` in CommentCard.vue (Zeile 5 + 58)
- [x] Pre-existing Fix: `async` Arrow-Function in OsButton.visual.spec.ts
**Zuvor abgeschlossen (Session 11 - Storybook & Code-Review Fixes):**
- [x] Wasserfarben-Farbschema für Storybook (Ultramarin, Dioxazin-Violett, Alizarin, Ocker, Viridian, Cöruleum)
- [x] Stories erweitert: Playground (interaktive Controls), alle Varianten in allen Stories
- [x] Einzelne Stories (Primary, Secondary, Danger, Default) durch AllVariants ersetzt
- [x] AllAppearances zeigt alle 7 Varianten × 3 Appearances
- [x] Einheitlicher Border (0.8px) über alle Appearances (kein Layout-Shift mehr)
- [x] WCAG 2.4.7 Fix: Default-Variante hat jetzt `focus:outline-dashed focus:outline-current`
- [x] Keyboard Accessibility Test: prüft Focus-Indikator auf allen Buttons im Browser
- [x] `data-appearance` Attribut: robuste CSS-Selektoren statt fragile escaped Tailwind-Klassen
- [x] Code-Review Feedback eingearbeitet (Unit-Tests, Testnamen, CSS-Selektoren)
**Zuvor abgeschlossen (Milestone 5 + Analyse):**
- [x] Visuelle Validierung: 16/16 Buttons validiert ✅
- [x] OsButton Features: `appearance` (outline, ghost), `xs` size, focus/active states
- [x] Disabled-Styles: CSS-Variablen, hover/active-Override, Border-Fix
- [x] Codebase-Analyse: 14 weitere migrierbare Buttons identifiziert (Scope: 16/35)
**Zuletzt erledigt (Phase 3):**
- [x] vue-demi zur Webapp hinzugefügt (Vue 2.7 Kompatibilität)
- [x] Webpack-Alias für vue-demi (nutzt Webapp's Vue 2.7 statt UI-Library's Vue 3)
- [x] Webpack-Alias für @ocelot-social/ui (dist Pfade mit $ für exakten Match)
- [x] OsButton mit isVue2 Render-Funktion (Vue 2: attrs-Objekt, Vue 3: flat props)
- [x] CSS-Reihenfolge angepasst (UI-Library nach Styleguide für korrekte Spezifität)
- [x] Manueller visueller Vergleich ✅
- [x] **Jest-Integration für vue-demi**
- Custom Mock (`test/__mocks__/@ocelot-social/ui.js`) statt direktem Import
- Problem: Jest's moduleNameMapper greift nicht für verschachtelte requires in CJS
- Problem: Jest lädt `vue.runtime.common.js` mit exports unter `default`
- Lösung: Module._load Patch für vue-demi + defineComponent von Vue.default
- Setup-File (`test/vueDemiSetup.js`) für Module._resolveFilename Patch
- **979 Tests bestehen ✅**
- [x] Button-Variants an ds-button angepasst (font-semibold, rounded, box-shadow)
- [x] UserTeaserPopover.vue migriert (verwendet `<os-button>`)
- [x] **Docker Build für UI-Library**
- ui-library Stage in Dockerfile + Dockerfile.maintenance
- COPY --from=ui-library ./app/ /packages/ui/
- [x] **CI-Kompatibilität**
- Relativer Pfad `file:../packages/ui` statt absolut `/packages/ui`
- Funktioniert lokal, in CI und in Docker
- [x] **OsButton attrs/listeners Forwarding**
- getCurrentInstance() für $listeners Zugriff in Vue 2
- inheritAttrs: false für manuelle Weiterleitung
- Jest Mock um alle Composition API Funktionen erweitert
- [x] **16 Buttons migriert** (ohne icon/circle/loading) ✅
- GroupForm.vue, EmbedComponent.vue, DonationInfo.vue, CommentCard.vue
- MapStylesButtons.vue, GroupMember.vue, embeds.vue
- notifications.vue, privacy.vue, terms-and-conditions-confirm.vue, UserTeaserPopover.vue
- [x] **Disabled-Styles korrigiert**
- CSS-Variablen `--color-disabled` und `--color-disabled-contrast` hinzugefügt
- Filled-Buttons: Grauer Hintergrund statt opacity (wie buttonStates Mixin)
- Outline/Ghost: Graue Border/Text
- [x] terms-and-conditions-confirm.vue: Read T&C Button → `appearance="outline" variant="primary"`
- [x] **Disabled:active/hover Spezifität**
- CSS-Regeln in index.css mit höherer Spezifität für disabled:hover und disabled:active
- Button zeigt sofort disabled-Farben, auch wenn während :active disabled wird
- [x] notifications.vue: Check All + Uncheck All → `appearance="outline" variant="primary"`
- [x] embeds.vue: Allow All → `appearance="outline" variant="primary"`
- [x] **Disabled Border-Fix**
- CSS-Regeln in index.css: `border-style: solid` und `border-width: 0.8px` bei disabled
- Verhindert Layout-Sprung wenn Button disabled wird
**Nächste Schritte:**
1. ~~Phase 0: Komponenten-Analyse~~
2. ~~Phase 1: Vue 2.7 Upgrade~~
3. ~~**Phase 2: Projekt-Setup**~~ ✅ ABGESCHLOSSEN
- [x] CSS Custom Properties Token-System aufsetzen
- [x] Storybook für Dokumentation einrichten
- [x] Docker Setup (Dockerfile, docker-compose, ui-docker.yml)
- [x] Visual Regression Tests (Playwright + @axe-core/playwright)
- [x] Keyboard Accessibility Tests
- [x] ESLint Plugins (storybook, playwright, vuejs-accessibility, jsdoc)
- [x] Storybook Build Workflow (ui-storybook.yml)
- [x] Completeness Check Script (verify: Story, Visual, checkA11y, Keyboard, Varianten)
4. **Phase 3: Token-System & Basis** - Nächste Phase
4. **Phase 3: Webapp-Integration** - 32/90 Buttons migriert (36%)
- [x] yarn link / Webpack-Alias in Webapp
- [x] CSS-Variablen definieren (ocelot-ui-variables.scss)
- [x] 16 Buttons migriert & validiert ✅
- [x] Docker Build + CI-Kompatibilität
- [x] **Milestone 4a:** 14 weitere Buttons (ohne neue Props) ✅
- [ ] **Milestone 4b:** icon/circle/loading Props implementieren
- [ ] **Milestone 4c:** ~60 Buttons mit icon/circle/loading migrieren
**Manuelle Setup-Aufgaben (außerhalb Code):**
- [ ] `NPM_TOKEN` als GitHub Secret einrichten (für npm publish in ui-release.yml)
@ -239,7 +318,7 @@ Integriert: 0
- [x] Visual Regression Tests einrichten (Playwright, colocated mit Komponenten)
- [x] Accessibility Tests in Visual Tests integriert (@axe-core/playwright)
- [x] Keyboard Accessibility Tests (describe('keyboard accessibility'))
- [x] ESLint Plugins: vuejs-accessibility, playwright, storybook, jsdoc
- [x] ESLint Plugins: vuejs-accessibility, playwright, storybook, jsdoc, @eslint/css
- [x] Bundle Size Check einrichten (size-limit, ui-size.yml)
- [x] Package-Validierung einrichten (publint, arethetypeswrong)
- [x] Example Apps erstellen (vue3-tailwind, vue3-css, vue2-tailwind, vue2-css)
@ -252,14 +331,175 @@ Integriert: 0
- [x] CONTRIBUTING.md
- [x] Completeness Check Script (Story, Visual+checkA11y, Keyboard, Varianten)
### Phase 3: Token-System & Basis
- [ ] Base Tokens definieren (Farben, Spacing, Typography)
- [ ] Semantic Tokens definieren
- [ ] Component Tokens definieren
- [ ] Branding-System implementieren (CSS Variables)
- [ ] Beispiel-Branding erstellen (Standard + Yunite)
- [ ] Storybook Theme-Farben anpassen (ocelot.social Branding)
- [ ] Token-Dokumentation in Storybook
### Phase 3: Webapp-Integration (Validierung)
**Ziel:** OsButton in der Webapp einbinden, ohne visuelle oder funktionale Änderungen.
**Ansatz:** Integration First - Library einbinden, dann schrittweise OsButton ersetzen, beginnend mit einfachsten Stellen.
**Milestone 1: Library-Einbindung** ✅
- [x] @ocelot-social/ui in Webapp installieren (yarn link + Webpack-Alias)
- [x] vue-demi zur Webapp hinzugefügt (für Vue 2.7 Kompatibilität)
- [x] Webpack-Alias für vue-demi (nutzt Webapp's Vue 2.7)
- [x] Webpack-Alias für @ocelot-social/ui$ und style.css$
- [x] CSS Custom Properties in Webapp definieren (ocelot-ui-variables.scss)
- [x] CSS-Reihenfolge angepasst (UI-Library nach Styleguide)
- [x] Import-Pfade testen
- [x] Docker Build Stage für UI-Library (Dockerfile + Dockerfile.maintenance)
- [x] Relativer Pfad für CI-Kompatibilität (file:../packages/ui)
- [x] Jest Mock für @ocelot-social/ui (test/__mocks__/@ocelot-social/ui.js)
**Milestone 2: Erste Integration (Minimaler Aufwand)** ✅
- [x] OsButton mit isVue2 Render-Funktion (Vue 2/3 kompatibel)
- [x] Button-Variants an ds-button angepasst (font-semibold, rounded, box-shadow)
- [x] OsButton in UserTeaserPopover.vue eingesetzt (`variant="primary"`)
- [x] Manueller visueller Vergleich ✅
- [x] Webapp-Tests bestehen ✅ (979 Tests, jest moduleNameMapper für vue-demi)
**Milestone 3: Schrittweise Erweiterung** ✅
- [x] GroupForm.vue Cancel-Button migriert
- [x] OsButton attrs/listeners Forwarding (Vue 2 $listeners via getCurrentInstance)
- [x] 14 weitere Buttons migriert (alle ohne icon/circle/loading)
**Milestone 4a: Weitere Buttons migrieren (14 ohne neue Props)**
- [ ] Modal Cancel-Buttons (DisableModal, DeleteUserModal, ReleaseModal)
- [ ] Form Cancel/Submit-Buttons (ContributionForm, EnterNonce, MySomethingList)
- [ ] ImageUploader.vue (2× Crop-Buttons)
- [ ] Page-Buttons (donations, badges, notifications/index, profile Unblock/Unmute)
- [ ] ReportRow.vue More-Details-Button
**Milestone 4b: OsButton Props erweitern**
- [ ] `icon` Prop implementieren (slot-basiert oder Icon-Komponente)
- [ ] `circle` Variant zu CVA hinzufügen
- [ ] `loading` Prop mit Spinner implementieren
**Milestone 4c: Buttons mit icon/circle/loading migrieren (~60 Buttons)**
*Button-Komponenten (Wrapper):*
- [ ] Button/JoinLeaveButton.vue (icon, loading)
- [ ] Button/FollowButton.vue (icon, loading)
- [ ] LoginButton/LoginButton.vue (icon, circle)
- [ ] InviteButton/InviteButton.vue (icon, circle)
- [ ] EmotionButton/EmotionButton.vue (circle)
- [ ] CustomButton/CustomButton.vue (2× circle)
- [ ] LabeledButton/LabeledButton.vue (icon, circle)
*Navigation & Menus:*
- [ ] ContentMenu/ContentMenu.vue (icon, circle)
- [ ] ContentMenu/GroupContentMenu.vue (icon, circle)
- [ ] ChatNotificationMenu.vue (circle)
- [ ] NotificationMenu.vue (3× icon, circle)
- [ ] HeaderMenu/HeaderMenu.vue (icon, circle)
- [ ] Map/MapButton.vue (circle)
*Editor:*
- [ ] Editor/MenuBarButton.vue (icon, circle)
- [ ] Editor/MenuLegend.vue (~10× icon, circle)
*Filter & Input:*
- [ ] HashtagsFilter.vue (icon, circle)
- [ ] CategoriesSelect.vue (icon)
- [ ] SearchableInput.vue (icon, circle)
- [ ] Select/LocationSelect.vue (icon)
- [ ] PaginationButtons.vue (2× icon, circle)
*Chat:*
- [ ] Chat/Chat.vue (2× icon, circle)
- [ ] Chat/AddChatRoomByUserSearch.vue (icon, circle)
*Forms & Auth:*
- [ ] LoginForm/LoginForm.vue (icon, loading)
- [ ] PasswordReset/Request.vue (loading)
- [ ] PasswordReset/ChangePassword.vue (loading)
- [ ] Password/Change.vue (loading)
- [ ] ContributionForm.vue Submit (icon, loading)
- [ ] GroupForm.vue Submit (icon)
- [ ] CommentForm/CommentForm.vue (loading)
*Modals:*
- [ ] Modal/ConfirmModal.vue (2× icon, loading)
- [ ] Modal/ReportModal.vue (2× icon, loading)
- [ ] Modal/DisableModal.vue Confirm (icon)
- [ ] Modal/DeleteUserModal.vue Confirm (icon)
- [ ] Modal/ReleaseModal.vue Confirm (icon)
*Features:*
- [ ] ComponentSlider.vue (2× icon, loading)
- [ ] MySomethingList.vue (3× icon, circle, loading)
- [ ] CreateInvitation.vue (icon, circle)
- [ ] Invitation.vue (2× icon, circle)
- [ ] ProfileList.vue (loading)
- [ ] ReportRow.vue Confirm (icon)
- [ ] ImageUploader.vue Delete/Cancel (2× icon, circle)
- [ ] CommentCard.vue Reply (icon, circle)
- [ ] EmbedComponent.vue Close (icon, circle)
- [ ] CtaUnblockAuthor.vue (icon)
- [ ] data-download.vue (icon, loading)
*Pages:*
- [ ] pages/groups/_id/_slug.vue (3× icon, circle, loading)
- [ ] pages/admin/users/index.vue (2× icon, circle, loading)
- [ ] pages/settings/index.vue (icon, loading)
- [ ] pages/settings/blocked-users.vue (icon, circle)
- [ ] pages/settings/muted-users.vue (icon, circle)
- [ ] pages/settings/my-email-address/*.vue (2× icon)
- [ ] pages/profile/_id/_slug.vue Chat (icon)
- [ ] pages/post/_id/_slug/index.vue (icon, circle)
**Milestone 5: Validierung & Dokumentation** ✅
- [x] Keine visuellen Änderungen bestätigt (16/16 Buttons validiert)
- [x] Keine funktionalen Änderungen bestätigt
- [x] Disabled-Styles korrigiert (hover/active-Override, Border-Fix)
- [ ] Webapp-Tests bestehen weiterhin (TODO: Regressionstest)
- [ ] Erkenntnisse in KATALOG.md dokumentiert
**Einsatzstellen-Übersicht:**
| Kategorie | Buttons | Status |
|-----------|---------|--------|
| ✅ Migriert & Validiert | 24 | Erledigt |
| ⏳ Ohne neue Props (M4a) | 6 | In Arbeit (8 von 14 erledigt) |
| ⬜ Mit icon/circle/loading (M4c) | ~60 | Ausstehend |
| **Gesamt** | **~90** | **27% erledigt** |
**Details siehe KATALOG.md** (vollständige Tracking-Tabellen)
**Erfolgskriterien:**
| Kriterium | Prüfung |
|-----------|---------|
| Visuell identisch | Manueller Screenshot-Vergleich |
| Funktional identisch | Click, Disabled funktionieren |
| Keine Regression | Webapp Unit-Tests bestehen |
**Visuelle Validierung (OsButton vs Original):**
Jeder migrierte Button muss manuell geprüft werden: Normal, Hover, Focus, Active, Disabled.
| Datei | Button | Props | Validiert |
|-------|--------|-------|-----------|
| `components/Group/GroupForm.vue` | Cancel | `default` | ✅ |
| `components/Group/GroupMember.vue` | Remove Member | `appearance="outline" variant="primary" size="sm"` | ✅ |
| `components/CommentCard/CommentCard.vue` | Show more/less | `appearance="ghost" variant="primary" size="sm"` | ✅ |
| `components/UserTeaser/UserTeaserPopover.vue` | Open Profile | `variant="primary"` | ✅ |
| `components/DonationInfo/DonationInfo.vue` | Donate Now | `size="sm" variant="primary"` | ✅ |
| `components/Map/MapStylesButtons.vue` | Map Styles | `:appearance` dynamisch + custom CSS | ✅ |
| `components/Embed/EmbedComponent.vue` | Cancel | `appearance="outline" variant="danger"` + custom CSS | ✅ |
| `components/Embed/EmbedComponent.vue` | Play Now | `variant="primary"` + custom CSS | ✅ |
| `pages/terms-and-conditions-confirm.vue` | Read T&C | `appearance="outline" variant="primary"` | ✅ |
| `pages/terms-and-conditions-confirm.vue` | Save | `variant="primary"` + disabled | ✅ |
| `pages/settings/privacy.vue` | Save | `variant="primary"` + disabled | ✅ |
| `pages/settings/notifications.vue` | Check All | `appearance="outline" variant="primary"` + disabled | ✅ |
| `pages/settings/notifications.vue` | Uncheck All | `appearance="outline" variant="primary"` + disabled | ✅ |
| `pages/settings/notifications.vue` | Save | `variant="primary"` + disabled | ✅ |
| `pages/settings/embeds.vue` | Allow All | `appearance="outline" variant="primary"` + disabled | ✅ |
| `pages/settings/embeds.vue` | Deny All | `variant="primary"` + disabled | ✅ |
**Validierung abgeschlossen:** 16/16 (100%) ✅
**Nach Abschluss aller Validierungen:**
- [ ] Gesamt-Regressionstest durchführen
- [ ] Alle Unit-Tests bestehen
- [ ] Dokumentation aktualisieren
### Phase 4: Komponenten-Migration (15 Komponenten + 2 Infrastruktur)
@ -1061,6 +1301,8 @@ Bei der Migration werden:
| 66 | Branding-Hierarchie | Webapp → Spezialisiertes Branding | Default-Branding in Webapp, Overrides pro Instanz |
| 67 | Variable-Validierung | Runtime-Check in Development | `validateCssVariables()` warnt bei fehlenden Variablen |
| 68 | Branding-Test (Webapp) | CI-Test in Webapp | Webapp testet, dass Default-Branding alle Library-Variablen definiert |
| 69 | Webapp ↔ Maintenance Sharing | Webapp als Source of Truth | Kein separates "shared" Package, maintenance importiert aus webapp/ (siehe §16a) |
| 70 | Daten-Entkopplung | ViewModel/Mapper Pattern | Komponenten kennen nur ViewModels, Mapper transformieren API-Daten (siehe §16b) |
### Komponenten-API & Konventionen
@ -1185,6 +1427,65 @@ Bei der Migration werden:
| 2026-02-08 | **Projekt-Optimierung** | src/test/setup.ts entfernt, @storybook/vue3 entfernt, README.md fix |
| 2026-02-08 | **Package Updates** | size-limit 12.0.0, eslint-plugin-jsdoc 62.5.4, vite-tsconfig-paths 6.1.0 |
| 2026-02-08 | **TODO: eslint-config-it4c** | Muss auf ESLint 10 aktualisiert werden (aktuell inkompatibel) |
| 2026-02-08 | **Phase 3: vue-demi Integration** | vue-demi zur Webapp hinzugefügt, Webpack-Alias für Vue 2.7 Kompatibilität |
| 2026-02-08 | **Phase 3: Webpack-Alias** | @ocelot-social/ui$ und style.css$ Aliase für yarn-linked Package |
| 2026-02-08 | **Phase 3: isVue2 Render** | OsButton mit isVue2 Check: Vue 2 attrs-Objekt, Vue 3 flat props |
| 2026-02-08 | **Phase 3: CSS-Spezifität** | UI-Library CSS nach Styleguide laden (styleguide.js Plugin) |
| 2026-02-08 | **Phase 3: Jest vue-demi** | Custom Mock (`__mocks__/@ocelot-social/ui.js`) mit Module._load Patch, defineComponent von Vue.default, vueDemiSetup.js, 979 Tests ✅ |
| 2026-02-08 | **Phase 3: Button-Styles** | Variants angepasst: font-semibold, rounded, box-shadow, h-[37.5px] |
| 2026-02-08 | **Phase 3: Erste Integration** | UserTeaserPopover.vue verwendet `<os-button>` |
| 2026-02-08 | **Phase 3: Visueller Test** | Manueller Vergleich OsButton vs ds-button erfolgreich ✅ |
| 2026-02-08 | **Phase 3: v8 ignore** | Vue 2 Branch in OsButton mit `/* v8 ignore */` für 100% Coverage in Vitest |
| 2026-02-08 | **Phase 3: Docker Build** | ui-library Stage in Dockerfile + Dockerfile.maintenance, COPY --from=ui-library |
| 2026-02-08 | **Phase 3: CI-Fix** | Relativer Pfad `file:../packages/ui` statt absolut für yarn install außerhalb Docker |
| 2026-02-08 | **Phase 3: Storybook Fix** | TypeScript-Fehler in Stories behoben (`default` aus args entfernt) |
| 2026-02-08 | **Phase 3: attrs/listeners** | OsButton forwarded jetzt attrs + $listeners für Vue 2 (getCurrentInstance) |
| 2026-02-08 | **Phase 3: Jest Mock erweitert** | Alle Composition API Funktionen (computed, ref, watch, etc.) im Mock |
| 2026-02-08 | **Phase 3: 15 Buttons migriert** | GroupForm, EmbedComponent, DonationInfo, CommentCard, MapStylesButtons, GroupMember, embeds, notifications, privacy, terms-and-conditions-confirm |
| 2026-02-08 | **Phase 3: Test-Updates** | privacy.spec.js Selektoren, notifications Snapshot, DonationInfo.spec.js |
| 2026-02-08 | **OsButton: appearance Prop** | Neue `appearance` Prop: `filled` (default), `outline`, `ghost` - ermöglicht base-button Stile |
| 2026-02-08 | **OsButton: xs Size** | Exakte Pixel-Werte für base-button --small: h-26px, px-8px, text-12px, rounded-5px |
| 2026-02-08 | **OsButton: outline primary** | Grüner Rahmen + grüner Text + hellgrüner Hintergrund-Tint (rgba(25,122,49,0.18)) |
| 2026-02-08 | **OsButton: ghost primary** | Transparenter Hintergrund, grüner Text, Hover füllt grün, Active dunkler |
| 2026-02-08 | **OsButton: Focus Style** | `focus:outline-dashed focus:outline-1` statt ring (wie base-button) |
| 2026-02-08 | **OsButton: Active State** | `active:bg-[var(--color-*-hover)]` für dunkleren Hintergrund beim Drücken |
| 2026-02-08 | **Visuelle Validierung** | Tracking-Tabelle in PROJEKT.md für manuelle Button-Vergleiche (4/16 validiert) |
| 2026-02-08 | **Storybook Grayscale Theme** | Vollständige CSS-Variablen: default, active-states, contrast-inverse |
| 2026-02-08 | **Tailwind Source Filter** | `@import "tailwindcss" source(none)` - verhindert Markdown-Scanning |
| 2026-02-08 | **Button Variants Konsistenz** | Alle 21 compound variants mit korrekten active-states (`--color-*-active`) |
| 2026-02-08 | **CSS-Variablen erweitert** | `--color-secondary/warning/success/info-active` in ocelot-ui-variables.scss |
| 2026-02-08 | **Story Dokumentation** | "Medium (37.5px)" → "Medium (36px)" korrigiert |
| 2026-02-08 | **Playwright Toleranz** | `maxDiffPixelRatio: 0.03` für Cross-Platform Font-Rendering |
| 2026-02-09 | **Disabled-Styles korrigiert** | CSS-Variablen `--color-disabled`, filled: grauer Hintergrund statt opacity |
| 2026-02-09 | **terms-and-conditions-confirm** | Read T&C Button → `appearance="outline" variant="primary"` |
| 2026-02-09 | **Visuelle Validierung** | 10/16 Buttons validiert (terms-and-conditions-confirm.vue abgeschlossen) |
| 2026-02-09 | **Disabled:active/hover Fix** | CSS-Regeln in index.css mit höherer Spezifität für sofortige disabled-Darstellung |
| 2026-02-09 | **notifications.vue** | Check All + Uncheck All → `appearance="outline" variant="primary"` |
| 2026-02-09 | **Visuelle Validierung** | 14/16 Buttons validiert (notifications.vue abgeschlossen) |
| 2026-02-09 | **embeds.vue** | Allow All → `appearance="outline" variant="primary"` |
| 2026-02-09 | **Disabled Border-Fix** | CSS-Regeln in index.css: `border-style: solid` + `border-width: 0.8px` bei :disabled |
| 2026-02-09 | **Visuelle Validierung abgeschlossen** | 16/16 Buttons validiert (100%) ✅ Milestone 5 erfolgreich |
| 2026-02-09 | **Button-Analyse erweitert** | 14 weitere Buttons identifiziert (ohne icon/circle/loading) → Scope: 16/35 |
| 2026-02-09 | **Scope auf ~90 erweitert** | ~60 weitere Buttons mit icon/circle/loading identifiziert |
| 2026-02-09 | **Milestone 4a: 8 Buttons** | DisableModal, DeleteUserModal, ReleaseModal, ContributionForm, EnterNonce, MySomethingList, ImageUploader (2x) |
| 2026-02-09 | **ImageUploader CSS-Fix** | `position: absolute !important` für crop-confirm (überschreibt OsButton `relative`) |
| 2026-02-09 | **§16a hinzugefügt** | Webapp ↔ Maintenance Code-Sharing: Webapp als Source of Truth (Entscheidung #69) |
| 2026-02-09 | **§16b hinzugefügt** | Daten-Entkopplung: ViewModel/Mapper Pattern für API-agnostische Komponenten (Entscheidung #70) |
| 2026-02-09 | **NotificationMenu.vue** | 2 Buttons migriert (ghost primary), padding-top Fix für vertical-align Unterschied |
| 2026-02-09 | **Milestone 4a abgeschlossen** | 6 weitere Buttons migriert: donations.vue (Save), profile/_id/_slug.vue (Unblock, Unmute), badges.vue (Remove), notifications/index.vue (Mark All Read), ReportRow.vue (More Details) |
| 2026-02-10 | **Wasserfarben-Farbschema** | Greyscale-Theme → Aquarell-Farben (Ultramarin, Dioxazin-Violett, Alizarin, Ocker, Viridian, Cöruleum), WCAG AA konform |
| 2026-02-10 | **Stories konsolidiert** | Primary/Secondary/Danger/Default entfernt → AllVariants; AllSizes/AllAppearances/Disabled/FullWidth zeigen alle 7 Varianten |
| 2026-02-10 | **Appearance: Filled/Outline/Ghost** | Einzelne Stories umbenannt und mit allen 7 Varianten erweitert |
| 2026-02-10 | **Playground-Story** | Interaktive Controls (argTypes nur in Playground, nicht global) |
| 2026-02-10 | **Einheitlicher Border** | `border-[0.8px] border-solid border-transparent` als Base-Klasse für alle Appearances |
| 2026-02-10 | **WCAG 2.4.7 Fix** | Default-Variante: `focus:outline-none``focus:outline-dashed focus:outline-current` |
| 2026-02-10 | **Keyboard A11y Test** | Playwright-Test fokussiert alle Buttons und prüft `outlineStyle !== 'none'` |
| 2026-02-10 | **data-appearance Attribut** | OsButton rendert `data-appearance` auf `<button>`; CSS-Selektoren nutzen `[data-appearance="filled"]` statt escaped Tailwind-Klassen |
| 2026-02-10 | **Code-Review Fixes** | Unit-Tests: spezifischere Assertions (Compound-Variant-Logik), Trailing Spaces in Testnamen, ESLint restrict-template-expressions Fix |
| 2026-02-10 | **CSS-Linting** | `@eslint/css` + `tailwind-csstree` für Tailwind v4 Custom Syntax; `excludeCSS()` Helper verhindert JS-Regel-Konflikte; Regeln: no-empty-blocks, no-duplicate-imports, no-invalid-at-rules |
| 2026-02-10 | **CI-Workflow-Trigger** | 9 UI-Workflows von `on: push` auf `push`+`pull_request` mit Branch-Filter (`master`) und Path-Filter (`packages/ui/**` + Workflow-Datei) umgestellt |
| 2026-02-10 | **custom-class entfernt** | `custom-class` Prop (entfernt aus OsButton) → `class` Attribut in notifications.vue, MapStylesButtons.vue, EmbedComponent.vue (4 Stellen); Snapshot aktualisiert |
| 2026-02-10 | **Vue 3 Template-Fix** | `this.$t()``$t()` in CommentCard.vue (this im Template in Vue 3 nicht verfügbar) |
---
@ -1608,6 +1909,301 @@ Vor dem Erstellen einer Komponente diese Fragen beantworten:
---
## 16a. Webapp ↔ Maintenance Code-Sharing
### Problemstellung
Die Webapp und Maintenance-App sind aktuell verschachtelt und sollen getrennt werden.
Einige Business-Komponenten werden in beiden Apps benötigt, gehören aber nicht in die UI-Library.
**Das DX-Problem:** "shared" hat kein logisches Kriterium außer "wird in beiden gebraucht".
### Analysierte Optionen
| Option | Beschreibung | Bewertung |
|--------|--------------|-----------|
| **A: Domain Packages** | `@ocelot-social/auth`, `@ocelot-social/posts`, etc. | Gut bei vielen Komponenten, aber Overhead |
| **B: Core + Duplikation** | Composables teilen, Komponenten duplizieren | Gut wenn UI unterschiedlich |
| **C: Webapp als Source** | Maintenance importiert aus Webapp | Einfachste Lösung |
### Empfehlung: Option C (Webapp als Source of Truth)
```
┌─────────────────────────────────────────────────────────────┐
@ocelot-social/ui │
│ ───────────────── │
│ • OsButton, OsModal, OsCard, OsInput │
│ • Rein präsentational, keine Abhängigkeiten │
├─────────────────────────────────────────────────────────────┤
│ webapp/ │
│ ─────── │
│ • Alle Business-Komponenten (Source of Truth) │
│ • Composables in webapp/lib/composables/ │
│ • GraphQL in webapp/graphql/ │
│ • Ist die "Haupt-App" │
├─────────────────────────────────────────────────────────────┤
│ maintenance/ │
│ ──────────── │
│ • Importiert aus @ocelot-social/ui │
│ • Importiert aus webapp/ via Alias │
│ • Nur maintenance-spezifische Komponenten lokal │
└─────────────────────────────────────────────────────────────┘
```
### Umsetzung
**maintenance/nuxt.config.js:**
```javascript
export default {
alias: {
'@webapp': '../webapp',
'@ocelot-social/ui': '../packages/ui/dist'
}
}
```
**Import in Maintenance:**
```typescript
// UI-Komponenten aus Library
import { OsButton, OsModal } from '@ocelot-social/ui'
// Business-Komponenten aus Webapp
import FollowButton from '@webapp/components/FollowButton.vue'
import PostTeaser from '@webapp/components/PostTeaser.vue'
// Composables aus Webapp
import { useAuth } from '@webapp/lib/composables/useAuth'
import { useFollow } from '@webapp/lib/composables/useFollow'
```
### Kriterien für Entwickler
| Frage | Antwort |
|-------|---------|
| Wo suche ich eine UI-Komponente? | `@ocelot-social/ui` |
| Wo suche ich eine Business-Komponente? | `webapp/components/` |
| Wo erstelle ich eine neue geteilte Komponente? | `webapp/components/` |
| Wo erstelle ich maintenance-spezifische Komponenten? | `maintenance/components/` |
### Vorteile
1. **Klare Regel:** Alles Business-bezogene ist in Webapp
2. **Kein neues Package:** Weniger Overhead
3. **Eine Source of Truth:** Keine Sync-Probleme
4. **Einfache Migration:** Später ggf. Domain-Packages extrahieren
### Spätere Evolution (optional)
Wenn klare Patterns entstehen, können Domain-Packages extrahiert werden:
```
Phase 1 (jetzt): Webapp ist Source of Truth
Phase 2 (später): Patterns identifizieren
Phase 3 (später): @ocelot-social/auth, @ocelot-social/posts, etc.
```
### Entscheidung
| # | Datum | Entscheidung |
|---|-------|--------------|
| 68 | 2026-02-09 | Webapp als Source of Truth für geteilte Business-Komponenten |
---
## 16b. Daten-Entkopplung (ViewModel/Mapper Pattern)
### Problemstellung
Komponenten sind oft direkt an API/GraphQL-Strukturen gekoppelt:
```vue
<!-- ❌ Tight Coupling -->
<UserCard :user="graphqlResponse.User" />
// Komponente kennt GraphQL-Struktur
props.user.avatar.url
props.user._followedByCurrentUserCount // Underscore?!
props.user.__typename // Leaked!
```
**Probleme:**
- Schema-Änderung = alle Komponenten anpassen
- `__typename`, `_count` etc. leaken in die UI
- Schwer testbar (braucht echte GraphQL-Struktur)
- Komponenten nicht wiederverwendbar
### Lösung: ViewModel + Mapper Pattern
```
┌─────────────────────────────────────────────────────────────┐
│ GraphQL / API Layer │
│ • Queries & Mutations │
│ • Generated Types (graphql-codegen) │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Mappers (einziger Ort der API-Struktur kennt) │
│ • toUserCardViewModel(graphqlUser) → UserCardViewModel │
│ • toPostTeaserViewModel(graphqlPost) → PostTeaserViewModel │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ ViewModels (was die UI braucht) │
│ • UserCardViewModel { displayName, avatarUrl, ... } │
│ • PostTeaserViewModel { title, excerpt, ... } │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ Presentational Components (kennen nur ViewModels) │
│ • <UserCard :user="UserCardViewModel" />
│ • <PostTeaser :post="PostTeaserViewModel" />
└─────────────────────────────────────────────────────────────┘
```
### Implementierung
**1. ViewModels definieren:**
```typescript
// types/viewModels.ts
export interface UserCardViewModel {
id: string
displayName: string
avatarUrl: string | null
followerCount: number
isFollowedByMe: boolean
}
export interface PostTeaserViewModel {
id: string
title: string
excerpt: string
authorName: string
authorAvatarUrl: string | null
createdAt: Date
commentCount: number
canEdit: boolean
}
```
**2. Mapper-Funktionen:**
```typescript
// mappers/userMapper.ts
import type { UserCardViewModel } from '~/types/viewModels'
import type { UserGraphQL } from '~/graphql/types'
export function toUserCardViewModel(
user: UserGraphQL,
currentUserId?: string
): UserCardViewModel {
return {
id: user.id,
displayName: user.name || user.slug || 'Anonymous',
avatarUrl: user.avatar?.url ?? null,
followerCount: user._followedByCurrentUserCount ?? 0,
isFollowedByMe: user.followedByCurrentUser ?? false,
}
}
```
**3. Komponenten nutzen nur ViewModels:**
```vue
<!-- components/UserCard.vue -->
<script setup lang="ts">
import type { UserCardViewModel } from '~/types/viewModels'
// Komponente kennt NUR das ViewModel, nicht GraphQL
defineProps<{
user: UserCardViewModel
}>()
</script>
```
**4. Composables kapseln Mapping:**
```typescript
// composables/useUser.ts
import { computed } from 'vue'
import { useQuery } from '@vue/apollo-composable'
import { GET_USER } from '~/graphql/queries'
import { toUserCardViewModel } from '~/mappers/userMapper'
import type { UserCardViewModel } from '~/types/viewModels'
export function useUser(userId: string) {
const { result, loading, error } = useQuery(GET_USER, { id: userId })
const user = computed<UserCardViewModel | null>(() => {
if (!result.value?.User) return null
return toUserCardViewModel(result.value.User)
})
return { user, loading, error }
}
```
### Ordnerstruktur
```
webapp/
├── graphql/
│ ├── queries/
│ ├── mutations/
│ └── types/ # Generated by graphql-codegen
├── types/
│ └── viewModels.ts # UI-spezifische Interfaces
├── mappers/
│ ├── userMapper.ts
│ ├── postMapper.ts
│ └── index.ts
├── lib/
│ └── composables/
│ ├── useAuth.ts
│ ├── useUser.ts # Gibt ViewModel zurück
│ └── usePost.ts
├── components/ # Presentational (nur ViewModels)
│ ├── UserCard.vue
│ └── PostTeaser.vue
└── pages/ # Nutzen Composables
└── users/[id].vue
```
### Vorteile
| Aspekt | Ohne Mapper | Mit Mapper |
|--------|-------------|------------|
| **API-Änderung** | Alle Komponenten anpassen | Nur Mapper anpassen |
| **Testing** | Mock GraphQL Response | Einfaches ViewModel-Objekt |
| **Wiederverwendung** | Komponente an API gebunden | Komponente API-agnostisch |
| **TypeScript** | Komplexe/generierte Types | Klare, einfache Interfaces |
| **webapp ↔ maintenance** | Verschiedene Strukturen | Gleiche ViewModels |
### Regeln
```
┌─────────────────────────────────────────────────────────────┐
│ Regel 1: Komponenten definieren was sie BRAUCHEN │
│ (ViewModel), nicht was die API LIEFERT │
├─────────────────────────────────────────────────────────────┤
│ Regel 2: Mapper sind der EINZIGE Ort der API kennt │
│ API-Änderung = nur Mapper ändern │
├─────────────────────────────────────────────────────────────┤
│ Regel 3: Composables kapseln Fetching + Mapping │
│ useUser() gibt UserCardViewModel zurück │
├─────────────────────────────────────────────────────────────┤
│ Regel 4: Presentational Components sind API-agnostisch │
│ Einfach testbar, wiederverwendbar │
└─────────────────────────────────────────────────────────────┘
```
### Entscheidung
| # | Datum | Entscheidung |
|---|-------|--------------|
| 70 | 2026-02-09 | ViewModel/Mapper Pattern für Daten-Entkopplung |
---
## 17. Externe Abhängigkeiten
### Übersicht

View File

@ -1,9 +1,22 @@
// TODO: Update eslint-config-it4c to support ESLint 10 (currently incompatible)
import css from '@eslint/css'
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'
import { tailwind4 } from 'tailwind-csstree'
import type { Linter } from 'eslint'
/** Exclude CSS files from JS-focused config blocks (JS rules crash on CSS language) */
function excludeCSS(configs: Linter.Config[]): Linter.Config[] {
return configs.map((c) => {
// Don't touch global-ignores-only blocks
if (Object.keys(c).length === 1 && 'ignores' in c) return c
return { ...c, ignores: [...(c.ignores ?? []), '**/*.css'] }
})
}
export default [
{
@ -17,10 +30,11 @@ export default [
'playwright-report/',
],
},
...config,
...vue3,
...vitest,
...excludeCSS(config),
...excludeCSS(vue3),
...excludeCSS(vitest),
{
ignores: ['**/*.css'],
rules: {
// TODO: replace with alias
'import-x/no-relative-parent-imports': 'off',
@ -82,4 +96,18 @@ export default [
...vuejsAccessibilityPlugin.configs.recommended.rules,
},
},
{
// CSS files with Tailwind v4 syntax support
files: ['**/*.css'],
plugins: { css },
language: 'css/css',
languageOptions: {
customSyntax: tailwind4,
},
rules: {
'css/no-empty-blocks': 'error',
'css/no-duplicate-imports': 'error',
'css/no-invalid-at-rules': 'error',
},
},
]

View File

@ -17,6 +17,8 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@axe-core/playwright": "^4.11.1",
"@eslint/css": "^0.14.1",
"@fontsource-variable/inter": "^5.2.8",
"@playwright/test": "^1.58.2",
"@size-limit/file": "^12.0.0",
"@storybook/vue3-vite": "^10.2.7",
@ -37,6 +39,7 @@
"publint": "^0.3.17",
"size-limit": "^12.0.0",
"storybook": "^10.2.7",
"tailwind-csstree": "^0.1.4",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",
@ -1314,6 +1317,42 @@
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/css": {
"version": "0.14.1",
"resolved": "https://registry.npmjs.org/@eslint/css/-/css-0.14.1.tgz",
"integrity": "sha512-NXiteSacmpaXqgyIW3+GcNzexXyfC0kd+gig6WTjD4A74kBGJeNx1tV0Hxa0v7x0+mnIyKfGPhGNs1uhRFdh+w==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"@eslint/core": "^0.17.0",
"@eslint/css-tree": "^3.6.6",
"@eslint/plugin-kit": "^0.4.1"
},
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}
},
"node_modules/@eslint/css-tree": {
"version": "3.6.8",
"resolved": "https://registry.npmjs.org/@eslint/css-tree/-/css-tree-3.6.8.tgz",
"integrity": "sha512-s0f40zY7dlMp8i0Jf0u6l/aSswS0WRAgkhgETgiCJRcxIWb4S/Sp9uScKHWbkM3BnoFLbJbmOYk5AZUDFVxaLA==",
"dev": true,
"license": "MIT",
"dependencies": {
"mdn-data": "2.23.0",
"source-map-js": "^1.0.1"
},
"engines": {
"node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0"
}
},
"node_modules/@eslint/css-tree/node_modules/mdn-data": {
"version": "2.23.0",
"resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.23.0.tgz",
"integrity": "sha512-786vq1+4079JSeu2XdcDjrhi/Ry7BWtjDl9WtGPWLiIHb2T66GvIVflZTBoSNZ5JqTtJGYEVMuFA/lbQlMOyDQ==",
"dev": true,
"license": "CC0-1.0"
},
"node_modules/@eslint/eslintrc": {
"version": "3.3.3",
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.3.tgz",
@ -1400,6 +1439,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/@fontsource-variable/inter": {
"version": "5.2.8",
"resolved": "https://registry.npmjs.org/@fontsource-variable/inter/-/inter-5.2.8.tgz",
"integrity": "sha512-kOfP2D+ykbcX/P3IFnokOhVRNoTozo5/JxhAIVYLpea/UBmCQ/YWPBfWIDuBImXX/15KH+eKh4xpEUyS2sQQGQ==",
"dev": true,
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@graphql-eslint/eslint-plugin": {
"version": "4.4.0",
"resolved": "https://registry.npmjs.org/@graphql-eslint/eslint-plugin/-/eslint-plugin-4.4.0.tgz",
@ -3927,66 +3976,6 @@
"node": ">=14.0.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.1.0",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.7.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.0",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/core": "^1.7.1",
"@emnapi/runtime": "^1.7.1",
"@tybys/wasm-util": "^0.10.1"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@tybys/wasm-util": {
"version": "0.10.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/tslib": {
"version": "2.8.1",
"dev": true,
"inBundle": true,
"license": "0BSD",
"optional": true
},
"node_modules/@tailwindcss/oxide-win32-arm64-msvc": {
"version": "4.1.18",
"resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz",
@ -12845,6 +12834,20 @@
"url": "https://opencollective.com/synckit"
}
},
"node_modules/tailwind-csstree": {
"version": "0.1.4",
"resolved": "https://registry.npmjs.org/tailwind-csstree/-/tailwind-csstree-0.1.4.tgz",
"integrity": "sha512-FzD187HuFIZEyeR7Xy6sJbJll2d4SybS90satC8SKIuaNRC05CxMvdzN7BUsfDQffcnabckRM5OIcfArjsZ0mg==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=18.18"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/nzakas"
}
},
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",

View File

@ -72,6 +72,8 @@
"devDependencies": {
"@arethetypeswrong/cli": "^0.18.2",
"@axe-core/playwright": "^4.11.1",
"@eslint/css": "^0.14.1",
"@fontsource-variable/inter": "^5.2.8",
"@playwright/test": "^1.58.2",
"@size-limit/file": "^12.0.0",
"@storybook/vue3-vite": "^10.2.7",
@ -92,6 +94,7 @@
"publint": "^0.3.17",
"size-limit": "^12.0.0",
"storybook": "^10.2.7",
"tailwind-csstree": "^0.1.4",
"tailwindcss": "^4.1.18",
"tsx": "^4.21.0",
"typescript": "^5.9.3",

View File

@ -50,7 +50,7 @@ export default defineConfig({
/* Snapshot configuration */
expect: {
toHaveScreenshot: {
/* Allow slight differences due to anti-aliasing */
/* Allow slight differences due to font rendering across platforms (self-hosted font) */
maxDiffPixelRatio: 0.01,
},
},

View File

@ -11,24 +11,73 @@ describe('osButton', () => {
expect(wrapper.text()).toBe('Click me')
})
it('applies default variant classes', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
describe('variant prop', () => {
it('applies default variant classes by default', () => {
const wrapper = mount(OsButton)
// Default variant with filled appearance
expect(wrapper.classes()).toContain('bg-[var(--color-default)]')
})
it('applies primary variant classes', () => {
const wrapper = mount(OsButton, {
props: { variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-[var(--color-primary)]')
})
it('applies danger variant classes', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
})
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
})
})
it('applies size variant classes', () => {
const wrapper = mount(OsButton, {
props: { size: 'sm' },
describe('appearance prop', () => {
it('applies filled appearance by default', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)]')
})
it('applies outline appearance classes', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'outline', variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-transparent')
expect(wrapper.classes()).toContain('border-[var(--color-primary)]')
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
})
it('applies ghost appearance classes', () => {
const wrapper = mount(OsButton, {
props: { appearance: 'ghost', variant: 'primary' },
})
expect(wrapper.classes()).toContain('bg-transparent')
expect(wrapper.classes()).toContain('text-[var(--color-primary)]')
expect(wrapper.classes()).not.toContain('border-[var(--color-primary)]')
})
expect(wrapper.classes()).toContain('h-8')
expect(wrapper.classes()).toContain('text-sm')
})
it('applies variant classes', () => {
const wrapper = mount(OsButton, {
props: { variant: 'danger' },
describe('size prop', () => {
it('applies md size by default', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('h-[36px]')
})
it('applies sm size classes', () => {
const wrapper = mount(OsButton, {
props: { size: 'sm' },
})
expect(wrapper.classes()).toContain('h-[26px]')
expect(wrapper.classes()).toContain('text-[12px]')
})
it('applies lg size classes', () => {
const wrapper = mount(OsButton, {
props: { size: 'lg' },
})
expect(wrapper.classes()).toContain('h-12')
})
expect(wrapper.classes()).toContain('bg-[var(--color-danger)]')
})
it('applies fullWidth class', () => {
@ -40,7 +89,7 @@ describe('osButton', () => {
it('merges custom classes', () => {
const wrapper = mount(OsButton, {
props: { class: 'my-custom-class' },
attrs: { class: 'my-custom-class' },
})
expect(wrapper.classes()).toContain('my-custom-class')
})
@ -65,6 +114,23 @@ describe('osButton', () => {
expect(wrapper.emitted('click')).toHaveLength(1)
})
describe('focus styles', () => {
it('default variant has dashed outline focus style using currentColor', () => {
const wrapper = mount(OsButton)
expect(wrapper.classes()).toContain('focus:outline-dashed')
expect(wrapper.classes()).toContain('focus:outline-current')
expect(wrapper.classes()).toContain('focus:outline-1')
})
it('colored variants have dashed outline focus style', () => {
const wrapper = mount(OsButton, {
props: { variant: 'primary' },
})
expect(wrapper.classes()).toContain('focus:outline-dashed')
expect(wrapper.classes()).toContain('focus:outline-1')
})
})
describe('keyboard accessibility', () => {
it('renders as native button element for keyboard support', () => {
const wrapper = mount(OsButton)

View File

@ -6,14 +6,24 @@ const meta: Meta<typeof OsButton> = {
title: 'Components/OsButton',
component: OsButton,
tags: ['autodocs'],
}
export default meta
type Story = StoryObj<typeof OsButton>
export const Playground: Story = {
argTypes: {
variant: {
control: 'select',
options: ['primary', 'secondary', 'danger', 'warning', 'success', 'info', 'ghost', 'outline'],
options: ['default', 'primary', 'secondary', 'danger', 'warning', 'success', 'info'],
},
appearance: {
control: 'select',
options: ['filled', 'outline', 'ghost'],
},
size: {
control: 'select',
options: ['xs', 'sm', 'md', 'lg', 'xl'],
options: ['sm', 'md', 'lg', 'xl'],
},
fullWidth: {
control: 'boolean',
@ -22,50 +32,19 @@ const meta: Meta<typeof OsButton> = {
control: 'boolean',
},
},
}
export default meta
type Story = StoryObj<typeof OsButton>
export const Primary: Story = {
args: {
variant: 'primary',
default: 'Primary Button',
appearance: 'filled',
size: 'md',
fullWidth: false,
disabled: false,
},
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>',
template: '<OsButton v-bind="args">Button</OsButton>',
}),
}
@ -74,14 +53,13 @@ export const AllVariants: Story = {
components: { OsButton },
template: `
<div class="flex flex-wrap gap-2">
<OsButton variant="default">Default</OsButton>
<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>
`,
}),
@ -91,41 +69,219 @@ 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 class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Small (26px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="sm" variant="default">Default</OsButton>
<OsButton size="sm" variant="primary">Primary</OsButton>
<OsButton size="sm" variant="secondary">Secondary</OsButton>
<OsButton size="sm" variant="danger">Danger</OsButton>
<OsButton size="sm" variant="warning">Warning</OsButton>
<OsButton size="sm" variant="success">Success</OsButton>
<OsButton size="sm" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Medium (36px)</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="md" variant="default">Default</OsButton>
<OsButton size="md" variant="primary">Primary</OsButton>
<OsButton size="md" variant="secondary">Secondary</OsButton>
<OsButton size="md" variant="danger">Danger</OsButton>
<OsButton size="md" variant="warning">Warning</OsButton>
<OsButton size="md" variant="success">Success</OsButton>
<OsButton size="md" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Large</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="lg" variant="default">Default</OsButton>
<OsButton size="lg" variant="primary">Primary</OsButton>
<OsButton size="lg" variant="secondary">Secondary</OsButton>
<OsButton size="lg" variant="danger">Danger</OsButton>
<OsButton size="lg" variant="warning">Warning</OsButton>
<OsButton size="lg" variant="success">Success</OsButton>
<OsButton size="lg" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Extra Large</h3>
<div class="flex flex-wrap gap-2 items-center">
<OsButton size="xl" variant="default">Default</OsButton>
<OsButton size="xl" variant="primary">Primary</OsButton>
<OsButton size="xl" variant="secondary">Secondary</OsButton>
<OsButton size="xl" variant="danger">Danger</OsButton>
<OsButton size="xl" variant="warning">Warning</OsButton>
<OsButton size="xl" variant="success">Success</OsButton>
<OsButton size="xl" variant="info">Info</OsButton>
</div>
</div>
</div>
`,
}),
}
export const AllAppearances: Story = {
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Filled (default)</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="filled" variant="default">Default</OsButton>
<OsButton appearance="filled" variant="primary">Primary</OsButton>
<OsButton appearance="filled" variant="secondary">Secondary</OsButton>
<OsButton appearance="filled" variant="danger">Danger</OsButton>
<OsButton appearance="filled" variant="warning">Warning</OsButton>
<OsButton appearance="filled" variant="success">Success</OsButton>
<OsButton appearance="filled" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Outline</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="outline" variant="default">Default</OsButton>
<OsButton appearance="outline" variant="primary">Primary</OsButton>
<OsButton appearance="outline" variant="secondary">Secondary</OsButton>
<OsButton appearance="outline" variant="danger">Danger</OsButton>
<OsButton appearance="outline" variant="warning">Warning</OsButton>
<OsButton appearance="outline" variant="success">Success</OsButton>
<OsButton appearance="outline" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Ghost</h3>
<div class="flex flex-wrap gap-2">
<OsButton appearance="ghost" variant="default">Default</OsButton>
<OsButton appearance="ghost" variant="primary">Primary</OsButton>
<OsButton appearance="ghost" variant="secondary">Secondary</OsButton>
<OsButton appearance="ghost" variant="danger">Danger</OsButton>
<OsButton appearance="ghost" variant="warning">Warning</OsButton>
<OsButton appearance="ghost" variant="success">Success</OsButton>
<OsButton appearance="ghost" variant="info">Info</OsButton>
</div>
</div>
</div>
`,
}),
}
export const AppearanceFilled: Story = {
name: 'Appearance: Filled',
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-wrap gap-2">
<OsButton appearance="filled" variant="default">Default</OsButton>
<OsButton appearance="filled" variant="primary">Primary</OsButton>
<OsButton appearance="filled" variant="secondary">Secondary</OsButton>
<OsButton appearance="filled" variant="danger">Danger</OsButton>
<OsButton appearance="filled" variant="warning">Warning</OsButton>
<OsButton appearance="filled" variant="success">Success</OsButton>
<OsButton appearance="filled" variant="info">Info</OsButton>
</div>
`,
}),
}
export const AppearanceOutline: Story = {
name: 'Appearance: Outline',
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-wrap gap-2">
<OsButton appearance="outline" variant="default">Default</OsButton>
<OsButton appearance="outline" variant="primary">Primary</OsButton>
<OsButton appearance="outline" variant="secondary">Secondary</OsButton>
<OsButton appearance="outline" variant="danger">Danger</OsButton>
<OsButton appearance="outline" variant="warning">Warning</OsButton>
<OsButton appearance="outline" variant="success">Success</OsButton>
<OsButton appearance="outline" variant="info">Info</OsButton>
</div>
`,
}),
}
export const AppearanceGhost: Story = {
name: 'Appearance: Ghost',
render: () => ({
components: { OsButton },
template: `
<div class="flex flex-wrap gap-2">
<OsButton appearance="ghost" variant="default">Default</OsButton>
<OsButton appearance="ghost" variant="primary">Primary</OsButton>
<OsButton appearance="ghost" variant="secondary">Secondary</OsButton>
<OsButton appearance="ghost" variant="danger">Danger</OsButton>
<OsButton appearance="ghost" variant="warning">Warning</OsButton>
<OsButton appearance="ghost" variant="success">Success</OsButton>
<OsButton appearance="ghost" variant="info">Info</OsButton>
</div>
`,
}),
}
export const Disabled: Story = {
args: {
disabled: true,
default: 'Disabled Button',
},
render: (args) => ({
render: () => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
template: `
<div class="flex flex-col gap-4">
<div>
<h3 class="text-sm font-bold mb-2">Filled</h3>
<div class="flex flex-wrap gap-2">
<OsButton disabled appearance="filled" variant="default">Default</OsButton>
<OsButton disabled appearance="filled" variant="primary">Primary</OsButton>
<OsButton disabled appearance="filled" variant="secondary">Secondary</OsButton>
<OsButton disabled appearance="filled" variant="danger">Danger</OsButton>
<OsButton disabled appearance="filled" variant="warning">Warning</OsButton>
<OsButton disabled appearance="filled" variant="success">Success</OsButton>
<OsButton disabled appearance="filled" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Outline</h3>
<div class="flex flex-wrap gap-2">
<OsButton disabled appearance="outline" variant="default">Default</OsButton>
<OsButton disabled appearance="outline" variant="primary">Primary</OsButton>
<OsButton disabled appearance="outline" variant="secondary">Secondary</OsButton>
<OsButton disabled appearance="outline" variant="danger">Danger</OsButton>
<OsButton disabled appearance="outline" variant="warning">Warning</OsButton>
<OsButton disabled appearance="outline" variant="success">Success</OsButton>
<OsButton disabled appearance="outline" variant="info">Info</OsButton>
</div>
</div>
<div>
<h3 class="text-sm font-bold mb-2">Ghost</h3>
<div class="flex flex-wrap gap-2">
<OsButton disabled appearance="ghost" variant="default">Default</OsButton>
<OsButton disabled appearance="ghost" variant="primary">Primary</OsButton>
<OsButton disabled appearance="ghost" variant="secondary">Secondary</OsButton>
<OsButton disabled appearance="ghost" variant="danger">Danger</OsButton>
<OsButton disabled appearance="ghost" variant="warning">Warning</OsButton>
<OsButton disabled appearance="ghost" variant="success">Success</OsButton>
<OsButton disabled appearance="ghost" variant="info">Info</OsButton>
</div>
</div>
</div>
`,
}),
}
export const FullWidth: Story = {
args: {
fullWidth: true,
default: 'Full Width Button',
},
render: (args) => ({
render: () => ({
components: { OsButton },
setup() {
return { args }
},
template: '<OsButton v-bind="args">{{ args.default }}</OsButton>',
template: `
<div class="flex flex-col gap-2">
<OsButton fullWidth variant="default">Default</OsButton>
<OsButton fullWidth variant="primary">Primary</OsButton>
<OsButton fullWidth variant="secondary">Secondary</OsButton>
<OsButton fullWidth variant="danger">Danger</OsButton>
<OsButton fullWidth variant="warning">Warning</OsButton>
<OsButton fullWidth variant="success">Success</OsButton>
<OsButton fullWidth variant="info">Info</OsButton>
</div>
`,
}),
}

View File

@ -14,6 +14,13 @@ import type { Page } from '@playwright/test'
const STORY_URL = '/iframe.html?id=components-osbutton'
const STORY_ROOT = '#storybook-root'
/**
* Wait for all fonts to be loaded before taking screenshots
*/
async function waitForFonts(page: Page) {
await page.evaluate(async () => document.fonts.ready)
}
/**
* Helper to run accessibility check on the current page
*/
@ -23,35 +30,32 @@ async function checkA11y(page: Page) {
expect(results.violations).toEqual([])
}
test.describe('OsButton keyboard accessibility', () => {
test('all variants show visible focus indicator', async ({ page }) => {
await page.goto(`${STORY_URL}--all-appearances&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
const buttons = root.locator('button:not([disabled])')
const count = await buttons.count()
expect(count).toBeGreaterThan(0)
for (let i = 0; i < count; i++) {
const button = buttons.nth(i)
await button.focus()
const outline = await button.evaluate((el) => getComputedStyle(el).outlineStyle)
const label = (await button.textContent()) ?? ''
expect(outline, `Button "${label}" must have visible focus outline`).not.toBe('none')
}
})
})
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 waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('all-variants.png')
await checkA11y(page)
})
@ -60,7 +64,44 @@ test.describe('OsButton visual regression', () => {
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 waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('all-sizes.png')
await checkA11y(page)
})
test('appearance filled', async ({ page }) => {
await page.goto(`${STORY_URL}--appearance-filled&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-filled.png')
await checkA11y(page)
})
test('appearance outline', async ({ page }) => {
await page.goto(`${STORY_URL}--appearance-outline&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-outline.png')
await checkA11y(page)
})
test('appearance ghost', async ({ page }) => {
await page.goto(`${STORY_URL}--appearance-ghost&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex')).toHaveScreenshot('appearance-ghost.png')
await checkA11y(page)
})
test('all appearances', async ({ page }) => {
await page.goto(`${STORY_URL}--all-appearances&viewMode=story`)
const root = page.locator(STORY_ROOT)
await root.waitFor()
await waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('all-appearances.png')
await checkA11y(page)
})
@ -68,7 +109,8 @@ test.describe('OsButton visual regression', () => {
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 waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('disabled.png')
await checkA11y(page)
})
@ -76,7 +118,8 @@ test.describe('OsButton visual regression', () => {
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 waitForFonts(page)
await expect(root.locator('.flex-col').first()).toHaveScreenshot('full-width.png')
await checkA11y(page)
})
})

View File

@ -1,50 +1,102 @@
<script setup lang="ts">
import { computed } from 'vue-demi'
<script lang="ts">
import { computed, defineComponent, getCurrentInstance, h, isVue2 } from 'vue-demi'
import { cn } from '../../utils'
import { buttonVariants } from './button.variants'
import type { ButtonVariants } from './button.variants'
import type { PropType } from 'vue-demi'
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
}
export default defineComponent({
name: 'OsButton',
// In Vue 2, inheritAttrs must be false to manually forward attrs
inheritAttrs: false,
props: {
variant: {
type: String as PropType<ButtonVariants['variant']>,
default: 'default',
},
appearance: {
type: String as PropType<ButtonVariants['appearance']>,
default: 'filled',
},
size: {
type: String as PropType<ButtonVariants['size']>,
default: 'md',
},
fullWidth: {
type: Boolean,
default: false,
},
type: {
type: String as PropType<'button' | 'submit' | 'reset'>,
default: 'button',
},
disabled: {
type: Boolean,
default: false,
},
},
setup(props, { slots, attrs }) {
const classes = computed(() =>
cn(
buttonVariants({
variant: props.variant,
appearance: props.appearance,
size: props.size,
fullWidth: props.fullWidth,
}),
),
)
const props = withDefaults(defineProps<OsButtonProps>(), {
variant: 'primary',
size: 'md',
fullWidth: false,
type: 'button',
disabled: false,
class: '',
// Get component instance for Vue 2 $listeners access
const instance = getCurrentInstance()
return () => {
const children = slots.default?.()
/* v8 ignore start -- Vue 2 branch tested in webapp Jest tests */
if (isVue2) {
// Vue 2: separate attrs and on (listeners)
// $listeners contains event handlers like @click
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const proxy = instance?.proxy as any
const listeners = proxy?.$listeners || {}
// In Vue 2, class/style are not in $attrs - access via $vnode
const parentClass = proxy?.$vnode?.data?.staticClass || ''
const parentDynClass = proxy?.$vnode?.data?.class
return h(
'button',
{
class: [classes.value, parentClass, parentDynClass].filter(Boolean),
attrs: {
type: props.type,
disabled: props.disabled || undefined,
'data-appearance': props.appearance,
...attrs,
},
on: listeners,
},
children,
)
}
/* v8 ignore stop */
// Vue 3: flat props, attrs includes listeners
// Extract class from attrs to merge instead of overwrite
const { class: attrClass, ...restAttrs } = attrs as Record<string, unknown>
return h(
'button',
{
type: props.type,
disabled: props.disabled,
'data-appearance': props.appearance,
class: cn(classes.value, (attrClass as string) || ''),
...restAttrs,
},
children,
)
}
},
})
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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.1 KiB

View File

@ -11,68 +11,257 @@ import type { VariantProps } from 'class-variance-authority'
* - Easy customization via class prop
*/
export const buttonVariants = cva(
// Base classes (always applied)
// Base classes (always applied) - matching ds-button styles
[
'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',
'inline-flex items-center justify-center align-middle [white-space-collapse:collapse]',
'relative appearance-none',
'font-semibold tracking-[0.05em]', // 0.75px at 15px font-size
'rounded-[4px]',
'transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)]',
'cursor-pointer select-none',
'border-[0.8px] border-solid border-transparent', // consistent border across all appearances
'focus:outline-1', // outline width, style set per variant
'disabled:pointer-events-none disabled:cursor-default',
],
{
variants: {
variant: {
primary: [
'bg-[var(--color-primary)] text-[var(--color-primary-contrast)]',
'hover:bg-[var(--color-primary-hover)]',
'focus-visible:ring-[var(--color-primary)]',
// Color variants - actual styling comes from compound variants with appearance
default: ['focus:outline-dashed focus:outline-current'],
primary: ['focus:outline-dashed focus:outline-[var(--color-primary)]'],
secondary: ['focus:outline-dashed focus:outline-[var(--color-secondary)]'],
danger: ['focus:outline-dashed focus:outline-[var(--color-danger)]'],
warning: ['focus:outline-dashed focus:outline-[var(--color-warning)]'],
success: ['focus:outline-dashed focus:outline-[var(--color-success)]'],
info: ['focus:outline-dashed focus:outline-[var(--color-info)]'],
},
appearance: {
filled: [
'shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)]',
// Disabled: gray background with white text (matches buttonStates mixin)
// Keep inset shadow to prevent layout shift
'disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)]',
'disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent]',
],
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',
'bg-transparent shadow-none',
// Disabled: gray border and text
'disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)]',
],
ghost: [
'bg-transparent shadow-none',
// Disabled: gray text
'disabled:text-[var(--color-disabled)]',
],
},
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',
sm: 'h-[26px] px-[8px] py-0 text-[12px] leading-[normal] tracking-[0.6px] rounded-[5px] overflow-hidden whitespace-nowrap align-middle', // base-button --small
md: 'h-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle',
lg: 'h-12 px-6 py-3 text-lg',
xl: 'h-14 px-8 py-4 text-xl',
},
fullWidth: {
true: 'w-full',
false: '',
},
},
compoundVariants: [
// Filled variants (default appearance)
{
variant: 'default',
appearance: 'filled',
class: [
'bg-[var(--color-default)] text-[var(--color-default-contrast)] border-[var(--color-default)]',
'hover:bg-[var(--color-default-hover)] hover:border-[var(--color-default-hover)]',
'active:bg-[var(--color-default-active)] active:border-[var(--color-default-active)] active:text-[var(--color-default-contrast-inverse)]',
],
},
{
variant: 'primary',
appearance: 'filled',
class: [
'bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)]',
'hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)]',
'active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)]',
],
},
{
variant: 'secondary',
appearance: 'filled',
class: [
'bg-[var(--color-secondary)] text-[var(--color-secondary-contrast)] border-[var(--color-secondary)]',
'hover:bg-[var(--color-secondary-hover)] hover:border-[var(--color-secondary-hover)] hover:text-[var(--color-secondary-contrast)]',
'active:bg-[var(--color-secondary-active)] active:border-[var(--color-secondary-active)] active:text-[var(--color-secondary-contrast)]',
],
},
{
variant: 'danger',
appearance: 'filled',
class: [
'bg-[var(--color-danger)] text-[var(--color-danger-contrast)] border-[var(--color-danger)]',
'hover:bg-[var(--color-danger-hover)] hover:border-[var(--color-danger-hover)] hover:text-[var(--color-danger-contrast)]',
'active:bg-[var(--color-danger-active)] active:border-[var(--color-danger-active)] active:text-[var(--color-danger-contrast)]',
],
},
{
variant: 'warning',
appearance: 'filled',
class: [
'bg-[var(--color-warning)] text-[var(--color-warning-contrast)] border-[var(--color-warning)]',
'hover:bg-[var(--color-warning-hover)] hover:border-[var(--color-warning-hover)] hover:text-[var(--color-warning-contrast)]',
'active:bg-[var(--color-warning-active)] active:border-[var(--color-warning-active)] active:text-[var(--color-warning-contrast)]',
],
},
{
variant: 'success',
appearance: 'filled',
class: [
'bg-[var(--color-success)] text-[var(--color-success-contrast)] border-[var(--color-success)]',
'hover:bg-[var(--color-success-hover)] hover:border-[var(--color-success-hover)] hover:text-[var(--color-success-contrast)]',
'active:bg-[var(--color-success-active)] active:border-[var(--color-success-active)] active:text-[var(--color-success-contrast)]',
],
},
{
variant: 'info',
appearance: 'filled',
class: [
'bg-[var(--color-info)] text-[var(--color-info-contrast)] border-[var(--color-info)]',
'hover:bg-[var(--color-info-hover)] hover:border-[var(--color-info-hover)] hover:text-[var(--color-info-contrast)]',
'active:bg-[var(--color-info-active)] active:border-[var(--color-info-active)] active:text-[var(--color-info-contrast)]',
],
},
// Outline variants
{
variant: 'default',
appearance: 'outline',
class: [
'border-[var(--color-default-contrast)] text-[var(--color-default-contrast)]',
'hover:bg-[var(--color-default-hover)]',
'active:bg-[var(--color-default-active)] active:text-[var(--color-default-contrast-inverse)]',
],
},
{
variant: 'primary',
appearance: 'outline',
class: [
'border-[var(--color-primary)] text-[var(--color-primary)]',
'hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)]',
'active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)]',
],
},
{
variant: 'secondary',
appearance: 'outline',
class: [
'border-[var(--color-secondary)] text-[var(--color-secondary)]',
'hover:bg-[var(--color-secondary)] hover:text-[var(--color-secondary-contrast)]',
'active:bg-[var(--color-secondary-active)] active:border-[var(--color-secondary-active)] active:text-[var(--color-secondary-contrast)]',
],
},
{
variant: 'danger',
appearance: 'outline',
class: [
'border-[var(--color-danger)] text-[var(--color-danger)]',
'hover:bg-[var(--color-danger)] hover:text-[var(--color-danger-contrast)]',
'active:bg-[var(--color-danger-active)] active:border-[var(--color-danger-active)] active:text-[var(--color-danger-contrast)]',
],
},
{
variant: 'warning',
appearance: 'outline',
class: [
'border-[var(--color-warning)] text-[var(--color-warning)]',
'hover:bg-[var(--color-warning)] hover:text-[var(--color-warning-contrast)]',
'active:bg-[var(--color-warning-active)] active:border-[var(--color-warning-active)] active:text-[var(--color-warning-contrast)]',
],
},
{
variant: 'success',
appearance: 'outline',
class: [
'border-[var(--color-success)] text-[var(--color-success)]',
'hover:bg-[var(--color-success)] hover:text-[var(--color-success-contrast)]',
'active:bg-[var(--color-success-active)] active:border-[var(--color-success-active)] active:text-[var(--color-success-contrast)]',
],
},
{
variant: 'info',
appearance: 'outline',
class: [
'border-[var(--color-info)] text-[var(--color-info)]',
'hover:bg-[var(--color-info)] hover:text-[var(--color-info-contrast)]',
'active:bg-[var(--color-info-active)] active:border-[var(--color-info-active)] active:text-[var(--color-info-contrast)]',
],
},
// Ghost variants - transparent background, colored text, hover fills, active darkens
{
variant: 'default',
appearance: 'ghost',
class: [
'text-[var(--color-default-contrast)]',
'hover:bg-[var(--color-default-hover)]',
'active:bg-[var(--color-default-active)] active:text-[var(--color-default-contrast-inverse)]',
],
},
{
variant: 'primary',
appearance: 'ghost',
class: [
'text-[var(--color-primary)]',
'hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)]',
'active:bg-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)]',
],
},
{
variant: 'secondary',
appearance: 'ghost',
class: [
'text-[var(--color-secondary)]',
'hover:bg-[var(--color-secondary)] hover:text-[var(--color-secondary-contrast)]',
'active:bg-[var(--color-secondary-active)] active:text-[var(--color-secondary-contrast)]',
],
},
{
variant: 'danger',
appearance: 'ghost',
class: [
'text-[var(--color-danger)]',
'hover:bg-[var(--color-danger)] hover:text-[var(--color-danger-contrast)]',
'active:bg-[var(--color-danger-active)] active:text-[var(--color-danger-contrast)]',
],
},
{
variant: 'warning',
appearance: 'ghost',
class: [
'text-[var(--color-warning)]',
'hover:bg-[var(--color-warning)] hover:text-[var(--color-warning-contrast)]',
'active:bg-[var(--color-warning-active)] active:text-[var(--color-warning-contrast)]',
],
},
{
variant: 'success',
appearance: 'ghost',
class: [
'text-[var(--color-success)]',
'hover:bg-[var(--color-success)] hover:text-[var(--color-success-contrast)]',
'active:bg-[var(--color-success-active)] active:text-[var(--color-success-contrast)]',
],
},
{
variant: 'info',
appearance: 'ghost',
class: [
'text-[var(--color-info)]',
'hover:bg-[var(--color-info)] hover:text-[var(--color-info-contrast)]',
'active:bg-[var(--color-info-active)] active:text-[var(--color-info-contrast)]',
],
},
],
defaultVariants: {
variant: 'primary',
variant: 'default',
appearance: 'filled',
size: 'md',
fullWidth: false,
},

View File

@ -1,5 +1,37 @@
@import "tailwindcss";
/*
* Tailwind CSS ohne Preflight (Reset)
* Preflight überschreibt globale Styles und kollidiert mit bestehenden Apps.
* Stattdessen importieren wir nur Theme und Utilities.
*
* WICHTIG: Kein globaler button-Reset hier!
* Das würde alle Buttons in der Webapp überschreiben (BaseButton, ds-button, etc.)
* OsButton verwendet stattdessen Tailwind-Klassen direkt.
*/
@import "tailwindcss/theme";
@import "tailwindcss/utilities";
/* Scan component files for utility classes */
@source "../components/**/*.vue";
@source "../**/*.ts";
/*
* Disabled state overrides
* These rules have higher specificity than :active/:hover to ensure
* disabled buttons immediately show disabled styling, even when clicked.
*/
/* Filled buttons: gray background when disabled, regardless of hover/active */
button[data-appearance="filled"]:disabled:hover,
button[data-appearance="filled"]:disabled:active {
background-color: var(--color-disabled) !important;
border-color: var(--color-disabled) !important;
color: var(--color-disabled-contrast) !important;
}
/* Outline buttons: transparent background when disabled, regardless of hover/active */
button[data-appearance="outline"]:disabled:hover,
button[data-appearance="outline"]:disabled:active {
background-color: transparent !important;
border-color: var(--color-disabled) !important;
color: var(--color-disabled) !important;
}

View File

@ -1,5 +1,7 @@
/// <reference types="vite/client" />
declare module '@fontsource-variable/inter'
declare module '*.vue' {
import type { DefineComponent } from 'vue-demi'

View File

@ -12,6 +12,9 @@ import { defineConfig } from 'vitest/config'
const execAsync = promisify(exec)
export default defineConfig({
optimizeDeps: {
exclude: ['vue-demi'],
},
plugins: [
vue(),
tailwindcss(),

View File

@ -6,6 +6,14 @@ COPY styleguide .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build:lib
FROM node:25.6.0-alpine AS ui-library
RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
COPY packages/ui .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build
FROM node:25.6.0-alpine AS base
LABEL org.label-schema.name="ocelot.social:webapp"
LABEL org.label-schema.description="Web Frontend of the Social Network Software ocelot.social"
@ -22,6 +30,7 @@ RUN apk --no-cache add git python3 make g++ bash jq
RUN mkdir -p /app
WORKDIR /app
COPY --from=styleguide ./app/ /styleguide/
COPY --from=ui-library ./app/ /packages/ui/
CMD ["/bin/bash", "-c", "yarn run start"]
FROM base AS development

View File

@ -16,10 +16,19 @@ COPY styleguide .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build:lib
FROM node:25.6.0-alpine AS ui-library
RUN apk --no-cache add git python3 make g++
RUN mkdir -p /app
WORKDIR /app
COPY packages/ui .
RUN yarn install --production=false --frozen-lockfile --non-interactive
RUN yarn run build
FROM node:25.6.0-alpine AS build
ENV NODE_ENV="production"
RUN apk --no-cache add git python3 make g++ bash jq
COPY --from=styleguide ./app/ /styleguide/
COPY --from=ui-library ./app/ /packages/ui/
RUN mkdir -p /app
WORKDIR /app
COPY webapp/ .

View File

@ -0,0 +1,57 @@
/**
* CSS Custom Properties für @ocelot-social/ui
*
* Diese Datei mappt die bestehenden SCSS-Variablen auf die CSS Custom Properties,
* die von der UI-Library erwartet werden.
*
* Hinweis: Die SCSS-Variablen werden über styleResources global geladen (nuxt.config.js).
*/
:root {
// Default (grau) - entspricht ds-button ohne Modifier
--color-default: #{$background-color-softer}; // neutral-90: rgb(245, 244, 246)
--color-default-hover: #{$color-neutral-70}; // rgb(203, 199, 209) - deutlich dunkler
--color-default-active: #{$color-neutral-60}; // rgb(177, 171, 186) - noch dunkler
--color-default-contrast: #{$text-color-base};
--color-default-contrast-inverse: #{$color-neutral-100}; // weiß für dunkle Hintergründe
// Primary (grün) - verwendet $text-color-primary-inverse wie ds-button
--color-primary: #{$color-primary};
--color-primary-hover: #{$color-primary-light}; // filled buttons hover to lighter color
--color-primary-active: #{$color-primary-dark}; // active state uses darker color
--color-primary-contrast: #{$text-color-primary-inverse};
// Secondary (blau)
--color-secondary: #{$color-secondary};
--color-secondary-hover: #{$color-secondary-active};
--color-secondary-active: #{darken($color-secondary, 15%)};
--color-secondary-contrast: #{$text-color-secondary-inverse};
// Danger (rot)
--color-danger: #{$color-danger};
--color-danger-hover: #{$color-danger-light}; // filled buttons hover to lighter color
--color-danger-active: #{$color-danger-dark}; // active state uses darker color
--color-danger-contrast: #{$text-color-danger-inverse};
// Warning (orange)
--color-warning: #{$color-warning};
--color-warning-hover: #{$color-warning-active};
--color-warning-active: #{darken($color-warning, 15%)};
--color-warning-contrast: #{$text-color-primary-inverse};
// Success (grün, wie primary)
--color-success: #{$color-success};
--color-success-hover: #{$color-success-active};
--color-success-active: #{darken($color-success, 15%)};
--color-success-contrast: #{$text-color-primary-inverse};
// Info (blau, wie secondary)
--color-info: #{$color-secondary};
--color-info-hover: #{$color-secondary-active};
--color-info-active: #{darken($color-secondary, 15%)};
--color-info-contrast: #{$text-color-secondary-inverse};
// Disabled state
--color-disabled: #{$color-neutral-60}; // rgb(177, 171, 186)
--color-disabled-contrast: #{$color-neutral-100}; // weiß
}

View File

@ -2,7 +2,7 @@
<base-card v-if="isUnavailable" class="comment-card">
<p>
<base-icon name="ban" />
{{ this.$t('comment.content.unavailable-placeholder') }}
{{ $t('comment.content.unavailable-placeholder') }}
</p>
</base-card>
<base-card v-else :class="commentClass" :id="anchor">
@ -35,9 +35,15 @@
/>
<template v-else>
<content-viewer :content="commentContent" class="content" />
<base-button v-if="hasLongContent" size="small" ghost @click="isCollapsed = !isCollapsed">
<os-button
v-if="hasLongContent"
size="sm"
appearance="ghost"
variant="primary"
@click="isCollapsed = !isCollapsed"
>
{{ isCollapsed ? $t('comment.show.more') : $t('comment.show.less') }}
</base-button>
</os-button>
</template>
<div class="actions">
<shout-button
@ -49,7 +55,7 @@
node-type="Comment"
/>
<base-button
:title="this.$t('post.comment.reply')"
:title="$t('post.comment.reply')"
icon="level-down"
class="reply-button"
circle
@ -62,6 +68,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters } from 'vuex'
import { COMMENT_MAX_UNTRUNCATED_LENGTH, COMMENT_TRUNCATE_TO_LENGTH } from '~/constants/comment'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
@ -74,6 +81,7 @@ import scrollToAnchor from '~/mixins/scrollToAnchor.js'
export default {
components: {
OsButton,
UserTeaser,
ContentMenu,
ContentViewer,
@ -208,13 +216,6 @@ export default {
justify-content: space-between;
margin-bottom: $space-small;
}
.actions {
display: flex;
width: 100%;
justify-content: space-between;
align-items: center;
}
}
@keyframes highlight {

View File

@ -173,9 +173,15 @@
</ds-flex-item>
<ds-flex-item width="0.15" />
<ds-flex-item class="action-buttons-group" width="2">
<base-button data-test="cancel-button" :disabled="loading" @click="$router.back()">
<os-button
data-test="cancel-button"
variant="primary"
appearance="outline"
:disabled="loading"
@click="$router.back()"
>
{{ $t('actions.cancel') }}
</base-button>
</os-button>
<base-button type="submit" icon="check" :loading="loading" :disabled="errors" filled>
{{ $t('actions.save') }}
</base-button>
@ -187,6 +193,7 @@
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import { mapGetters } from 'vuex'
import Editor from '~/components/Editor/Editor'
@ -202,11 +209,12 @@ import GetCategories from '~/mixins/getCategoriesMixin.js'
export default {
mixins: [GetCategories],
components: {
Editor,
ImageUploader,
PageParamsLink,
CategoriesSelect,
DatePicker,
Editor,
ImageUploader,
OsButton,
PageParamsLink,
},
props: {
contribution: {

View File

@ -1,4 +1,5 @@
import { mount } from '@vue/test-utils'
import flushPromises from 'flush-promises'
import DeleteData from './DeleteData.vue'
import Vue from 'vue'
import Vuex from 'vuex'
@ -36,9 +37,7 @@ describe('DeleteData.vue', () => {
success: jest.fn(),
},
$router: {
history: {
push: jest.fn(),
},
push: jest.fn(),
},
}
getters = {
@ -86,9 +85,9 @@ describe('DeleteData.vue', () => {
expect(wrapper.vm.deleteEnabled).toEqual(false)
})
it('does not call the delete user mutation if deleteEnabled is false', () => {
it('does not call the delete user mutation if deleteEnabled is false', async () => {
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')
deleteAccountBtn.trigger('click')
await deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).not.toHaveBeenCalled()
})
@ -99,8 +98,8 @@ describe('DeleteData.vue', () => {
deleteAccountBtn = wrapper.find('[data-test="delete-button"]')
})
it('if deleteEnabled is true and only deletes user ', () => {
deleteAccountBtn.trigger('click')
it('if deleteEnabled is true and only deletes user', async () => {
await deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
@ -111,14 +110,14 @@ describe('DeleteData.vue', () => {
)
})
it("deletes user's posts and comments if requested by default ", () => {
it("deletes user's posts and comments if requested by default", async () => {
enableContributionDeletionCheckbox = wrapper.find(
'[data-test="contributions-deletion-checkbox"]',
)
enableContributionDeletionCheckbox.setChecked(true)
enableCommentDeletionCheckbox = wrapper.find('[data-test="comments-deletion-checkbox"]')
enableCommentDeletionCheckbox.setChecked(true)
deleteAccountBtn.trigger('click')
await deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
@ -129,12 +128,12 @@ describe('DeleteData.vue', () => {
)
})
it("deletes a user's posts if requested", () => {
it("deletes a user's posts if requested", async () => {
enableContributionDeletionCheckbox = wrapper.find(
'[data-test="contributions-deletion-checkbox"]',
)
enableContributionDeletionCheckbox.setChecked(true)
deleteAccountBtn.trigger('click')
await deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
@ -145,10 +144,10 @@ describe('DeleteData.vue', () => {
)
})
it("deletes a user's comments if requested", () => {
it("deletes a user's comments if requested", async () => {
enableCommentDeletionCheckbox = wrapper.find('[data-test="comments-deletion-checkbox"]')
enableCommentDeletionCheckbox.setChecked(true)
deleteAccountBtn.trigger('click')
await deleteAccountBtn.trigger('click')
expect(mocks.$apollo.mutate).toHaveBeenCalledWith(
expect.objectContaining({
variables: {
@ -166,7 +165,8 @@ describe('DeleteData.vue', () => {
it('redirect the user to the homepage', async () => {
await deleteAccountBtn.trigger('click')
expect(mocks.$router.history.push).toHaveBeenCalledWith('/')
await flushPromises()
expect(mocks.$router.push).toHaveBeenCalledWith('/')
})
})

View File

@ -111,10 +111,14 @@ export default {
`,
variables: { id: this.currentUser.id, resource: resourceArgs },
})
.then(() => {
.then(async () => {
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
this.logout()
this.$router.history.push('/')
try {
await this.logout()
} catch {
// Logout-Fehler ignorieren Account ist bereits gelöscht
}
this.$router.push('/')
})
.catch((error) => {
this.$toast.error(error.message)

View File

@ -4,10 +4,18 @@ import DonationInfo from './DonationInfo.vue'
const localVue = global.localVue
const mockDate = new Date(2019, 11, 6)
global.Date = jest.fn(() => mockDate)
const OriginalDate = global.Date
const mockDate = new OriginalDate(2019, 11, 6)
describe('DonationInfo.vue', () => {
beforeAll(() => {
global.Date = jest.fn(() => mockDate)
})
afterAll(() => {
global.Date = OriginalDate
})
let mocks, wrapper, propsData
beforeEach(() => {
@ -35,7 +43,7 @@ describe('DonationInfo.vue', () => {
})
it('displays the action button', () => {
expect(wrapper.find('.base-button').text()).toBe('donations.donate-now')
expect(wrapper.find('button').text()).toBe('donations.donate-now')
})
describe('mount with data', () => {

View File

@ -1,19 +1,21 @@
<template>
<div class="donation-info">
<progress-bar :label="label" :goal="goal" :progress="progress">
<base-button size="small" filled @click="redirectToPage(links.DONATE)">
<os-button size="sm" variant="primary" @click="redirectToPage(links.DONATE)">
{{ $t('donations.donate-now') }}
</base-button>
</os-button>
</progress-bar>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import links from '~/constants/links.js'
import ProgressBar from '~/components/ProgressBar/ProgressBar.vue'
export default {
components: {
OsButton,
ProgressBar,
},
props: {

View File

@ -24,12 +24,23 @@
<h3>{{ $t('editor.embed.data_privacy_warning') }}</h3>
<ds-text>{{ $t('editor.embed.data_privacy_info') }} {{ embedPublisher }}</ds-text>
<div class="buttons">
<base-button @click="closeOverlay()" data-test="cancel-button" danger>
<os-button
@click="closeOverlay()"
data-test="cancel-button"
appearance="outline"
variant="danger"
class="embed-button"
>
{{ $t('actions.cancel') }}
</base-button>
<base-button @click="allowEmbed()" data-test="play-now-button" filled>
</os-button>
<os-button
@click="allowEmbed()"
data-test="play-now-button"
variant="primary"
class="embed-button"
>
{{ $t('editor.embed.play_now') }}
</base-button>
</os-button>
</div>
<label class="checkbox">
<input type="checkbox" v-model="checkedAlwaysAllowEmbeds" />
@ -47,11 +58,15 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
export default {
name: 'embed-component',
components: {
OsButton,
},
props: {
dataEmbedUrl: {
type: String,
@ -222,9 +237,8 @@ export default {
background-color: $color-neutral-100;
> .buttons {
.base-button {
button {
margin-right: $space-small;
white-space: nowrap;
}
}

View File

@ -20,17 +20,20 @@
<ds-text>
{{ $t('components.registration.email-nonce.form.click-next') }}
</ds-text>
<base-button :disabled="disabled" filled name="submit" type="submit">
<os-button variant="primary" :disabled="disabled" name="submit" type="submit">
{{ $t('components.registration.email-nonce.form.next') }}
</base-button>
</os-button>
<slot></slot>
</ds-form>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import registrationConstants from '~/constants/registration'
export default {
name: 'EnterNonce',
components: { OsButton },
props: {
email: { type: String, required: true },
},

View File

@ -133,7 +133,7 @@
<!-- submit -->
<ds-space margin-top="large">
<nuxt-link to="/groups">
<ds-button>{{ $t('actions.cancel') }}</ds-button>
<os-button>{{ $t('actions.cancel') }}</os-button>
</nuxt-link>
<ds-button type="submit" icon="save" primary :disabled="checkFormError(errors)" fill>
{{ update ? $t('group.update') : $t('group.save') }}
@ -145,6 +145,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import CategoriesSelect from '~/components/CategoriesSelect/CategoriesSelect'
import { CATEGORIES_MIN, CATEGORIES_MAX } from '~/constants/categories.js'
import {
@ -165,6 +166,7 @@ export default {
Editor,
ActionRadiusSelect,
LocationSelect,
OsButton,
},
props: {
update: {

View File

@ -53,17 +53,18 @@
</ds-chip>
</template>
<template #edit="scope">
<base-button
<os-button
v-if="scope.row.membership.role !== 'owner'"
size="small"
primary
appearance="outline"
variant="primary"
size="sm"
@click="
isOpen = true
userId = scope.row.user.id
"
>
{{ $t('group.removeMemberButton') }}
</base-button>
</os-button>
</template>
</ds-table>
<ds-modal
@ -79,12 +80,14 @@
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { changeGroupMemberRoleMutation, removeUserFromGroupMutation } from '~/graphql/groups.js'
import ProfileAvatar from '~/components/_new/generic/ProfileAvatar/ProfileAvatar'
export default {
name: 'GroupMember',
components: {
OsButton,
ProfileAvatar,
},
props: {

View File

@ -27,7 +27,7 @@ export default {
}
</script>
<style lang="scss" scope>
<style lang="scss" scoped>
.login-button {
color: $color-secondary;
}

View File

@ -1,20 +1,24 @@
<template>
<div>
<base-button
:class="['map-style-button', actualStyle === style.url ? '' : '--deactivated']"
<os-button
v-for="style in styles"
:key="style.title"
filled
size="small"
:appearance="actualStyle === style.url ? 'filled' : 'outline'"
variant="primary"
size="sm"
class="map-style-button"
@click="setStyle(style.url)"
>
{{ style.title }}
</base-button>
</os-button>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
export default {
components: { OsButton },
name: 'MapStylesButtons',
props: {
styles: { type: Array, required: true },
@ -25,15 +29,28 @@ export default {
</script>
<style lang="scss">
// Outline (nicht ausgewählt) button styles
button.map-style-button.bg-transparent {
background-color: $background-color-softer !important;
color: $text-color-base !important;
}
button.map-style-button.bg-transparent:hover {
background-color: $color-primary-light !important;
border-color: $color-primary-light !important;
color: white !important;
}
button.map-style-button.bg-transparent:active {
background-color: $color-primary-dark !important;
border-color: $color-primary-dark !important;
color: white !important;
}
.map-style-button {
position: relative;
margin-left: 6px;
margin-bottom: 6px;
margin-top: 6px;
&.--deactivated {
color: $text-color-base;
background-color: $background-color-softer;
}
}
</style>

View File

@ -9,7 +9,7 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template slot="footer">
<template #footer>
<base-button
class="cancel"
:danger="!modalData.buttons.confirm.danger"
@ -42,6 +42,7 @@ export default {
components: {
SweetalertIcon,
},
emits: ['close'],
props: {
name: { type: String, default: '' }, // only used for compatibility with the other modals in 'Modal.vue'
type: { type: String, required: true }, // only used for compatibility with the other modals in 'Modal.vue'
@ -85,6 +86,7 @@ export default {
}, 1500)
} catch (err) {
this.isOpen = false
this.$emit('close')
} finally {
this.loading = false
}

View File

@ -36,8 +36,10 @@
</ds-section>
</div>
<template slot="footer">
<base-button class="cancel" @click="cancel">{{ $t('actions.cancel') }}</base-button>
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('actions.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="openModal">
{{ $t('settings.deleteUserAccount.name') }}
</base-button>
@ -46,6 +48,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
import { mapMutations } from 'vuex'
import { SweetalertIcon } from 'vue-sweetalert-icons'
@ -55,9 +58,10 @@ import UserTeaser from '~/components/UserTeaser/UserTeaser'
export default {
name: 'DeleteUserModal',
components: {
UserTeaser,
SweetalertIcon,
DateTime,
OsButton,
SweetalertIcon,
UserTeaser,
},
props: {
userdata: { type: Object, required: true },
@ -67,8 +71,6 @@ export default {
isOpen: true,
success: false,
loading: false,
// isAdmin: this.$store.getters['auth/isAdmin'],
isAdmin: true,
}
},
computed: {
@ -124,8 +126,9 @@ export default {
}, 1000)
},
async confirm() {
this.$apollo
.mutate({
this.loading = true
try {
await this.$apollo.mutate({
mutation: gql`
mutation ($id: ID!, $resource: [Deletable]) {
DeleteUser(id: $id, resource: $resource) {
@ -135,26 +138,24 @@ export default {
`,
variables: { id: this.userdata.id, resource: ['Post', 'Comment'] },
})
.then(({ _data }) => {
this.success = true
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
setTimeout(() => {
this.isOpen = false
setTimeout(() => {
this.success = false
this.$emit('close')
this.$router.history.replace('/')
}, 500)
}, 1500)
this.loading = false
})
.catch((err) => {
this.$emit('close')
this.success = false
this.$toast.error(err.message)
this.success = true
this.$toast.success(this.$t('settings.deleteUserAccount.success'))
setTimeout(() => {
this.isOpen = false
this.loading = false
})
setTimeout(() => {
this.success = false
this.$emit('close')
this.$router.replace('/')
}, 500)
}, 1500)
} catch (err) {
this.success = false
this.$toast.error(err.message)
this.isOpen = false
this.$emit('close')
} finally {
this.loading = false
}
},
},
}

View File

@ -3,8 +3,10 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template slot="footer">
<base-button class="cancel" @click="cancel">{{ $t('disable.cancel') }}</base-button>
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('disable.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="confirm">
{{ $t('disable.submit') }}
</base-button>
@ -13,10 +15,12 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
export default {
name: 'DisableModal',
components: { OsButton },
props: {
name: { type: String, default: '' },
type: { type: String, required: true },

View File

@ -51,18 +51,18 @@
<ds-flex class="notifications-link-container">
<ds-flex-item>
<nuxt-link :to="{ name: 'notifications' }">
<base-button ghost primary>
<os-button appearance="ghost" variant="primary">
{{ $t('notifications.pageLink') }}
</base-button>
</os-button>
</nuxt-link>
<base-button
ghost
primary
<os-button
appearance="ghost"
variant="primary"
@click="markAllAsRead(closeMenu)"
data-test="markAllAsRead-button"
>
{{ $t('notifications.markAllAsRead') }}
</base-button>
</os-button>
</ds-flex-item>
</ds-flex>
<div class="notifications-menu-popover">
@ -79,6 +79,7 @@
<script>
import { mapGetters } from 'vuex'
import unionBy from 'lodash/unionBy'
import { OsButton } from '@ocelot-social/ui'
import {
notificationQuery,
markAsReadMutation,
@ -95,6 +96,7 @@ export default {
NotificationsTable,
CounterIcon,
Dropdown,
OsButton,
},
data() {
return {
@ -200,6 +202,7 @@ export default {
background-color: $background-color-softer-active;
text-align: right;
padding: $space-x-small 0;
padding-top: $space-x-small;
flex-direction: row;
}
</style>

View File

@ -24,7 +24,7 @@ export default {
},
methods: {
onNextClick() {
this.$router.history.push('/login')
this.$router.push('/login')
return true
},
},

View File

@ -3,8 +3,10 @@
<!-- eslint-disable-next-line vue/no-v-html -->
<p v-html="message" />
<template slot="footer">
<base-button class="cancel" @click="cancel">{{ $t('release.cancel') }}</base-button>
<template #footer>
<os-button variant="primary" appearance="outline" class="cancel" @click="cancel">
{{ $t('release.cancel') }}
</os-button>
<base-button danger filled class="confirm" icon="exclamation-circle" @click="confirm">
{{ $t('release.submit') }}
</base-button>
@ -13,9 +15,12 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import gql from 'graphql-tag'
export default {
name: 'ReleaseModal',
components: { OsButton },
props: {
name: { type: String, default: '' },
type: { type: String, required: true },

View File

@ -26,15 +26,15 @@
/>
</div>
<div v-show="!showCropper && imageCanBeCropped" class="crop-overlay">
<base-button class="crop-confirm" filled @click="initCropper">
<os-button class="crop-confirm" variant="primary" @click="initCropper">
{{ $t('contribution.teaserImage.cropImage') }}
</base-button>
</os-button>
</div>
<div v-show="showCropper" class="crop-overlay">
<img id="cropping-image" />
<base-button class="crop-confirm" filled @click="cropImage">
<os-button class="crop-confirm" variant="primary" @click="cropImage">
{{ $t('contribution.teaserImage.cropperConfirm') }}
</base-button>
</os-button>
<base-button
class="crop-cancel"
icon="close"
@ -49,8 +49,9 @@
</template>
<script>
import VueDropzone from 'nuxt-dropzone'
import { OsButton } from '@ocelot-social/ui'
import Cropper from 'cropperjs'
import VueDropzone from 'nuxt-dropzone'
import LoadingSpinner from '~/components/_new/generic/LoadingSpinner/LoadingSpinner'
import 'cropperjs/dist/cropper.css'
@ -59,6 +60,7 @@ const minAspectRatio = 0.3
export default {
components: {
LoadingSpinner,
OsButton,
VueDropzone,
},
props: {
@ -197,7 +199,7 @@ export default {
}
> .crop-confirm {
position: absolute;
position: absolute !important;
left: $space-x-small;
top: $space-x-small;
z-index: $z-index-surface;

View File

@ -28,12 +28,13 @@
</li>
</ul>
<nuxt-link v-if="isTouchDevice && userLink" :to="userLink" class="link">
<ds-button primary>{{ $t('user-teaser.popover.open-profile') }}</ds-button>
<os-button variant="primary">{{ $t('user-teaser.popover.open-profile') }}</os-button>
</nuxt-link>
</div>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import Badges from '~/components/Badges.vue'
import LocationInfo from '~/components/LocationInfo/LocationInfo.vue'
import { isTouchDevice } from '~/components/utils/isTouchDevice'
@ -44,6 +45,7 @@ export default {
components: {
Badges,
LocationInfo,
OsButton,
},
props: {
userId: { type: String },

View File

@ -56,19 +56,27 @@
>
{{ isEditing ? $t('actions.save') : texts.addButton }}
</base-button>
<base-button v-if="isEditing" id="cancel" @click="handleCancel()">
<os-button
v-if="isEditing"
id="cancel"
variant="primary"
appearance="outline"
@click="handleCancel()"
>
{{ $t('actions.cancel') }}
</base-button>
</os-button>
</ds-space>
</ds-space>
</ds-form>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapMutations } from 'vuex'
export default {
name: 'MySomethingList',
components: { OsButton },
props: {
useFormData: { type: Object, default: () => ({}) },
useFormSchema: { type: Object, default: () => ({}) },
@ -186,7 +194,7 @@ export default {
}
</script>
<style lang="scss" scope>
<style lang="scss" scoped>
.divider {
opacity: 0.4;
padding: 0 $space-small;

View File

@ -11,9 +11,14 @@
<span class="user-count">
{{ $t('moderation.reports.numberOfUsers', { count: report.filed.length }) }}
</span>
<base-button size="small" @click="showFiledReports = !showFiledReports">
<os-button
variant="primary"
appearance="outline"
size="sm"
@click="showFiledReports = !showFiledReports"
>
{{ $t('moderation.reports.moreDetails') }}
</base-button>
</os-button>
</td>
<!-- Content Column -->
@ -22,7 +27,7 @@
<user-teaser :user="report.resource" :showAvatar="false" :showPopover="false" />
</client-only>
<nuxt-link v-else class="title" :to="linkTarget">
{{ linkText | truncate(50) }}
{{ $filters.truncate(linkText, 50) }}
</nuxt-link>
</td>
@ -78,14 +83,17 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import FiledReportsTable from '~/components/features/FiledReportsTable/FiledReportsTable'
import UserTeaser from '~/components/UserTeaser/UserTeaser'
export default {
components: {
OsButton,
FiledReportsTable,
UserTeaser,
},
emits: ['confirm-report'],
props: {
report: {
type: Object,

View File

@ -1,3 +1,5 @@
const path = require('path')
module.exports = {
verbose: true,
collectCoverage: true,
@ -21,14 +23,32 @@ module.exports = {
},
},
coverageProvider: 'v8',
setupFiles: ['<rootDir>/test/registerContext.js', '<rootDir>/test/testSetup.js'],
// IMPORTANT: vueDemiSetup must be FIRST to ensure vue-demi is loaded from webapp's node_modules
setupFiles: [
'<rootDir>/test/vueDemiSetup.js',
'<rootDir>/test/registerContext.js',
'<rootDir>/test/testSetup.js',
],
transform: {
'.*\\.(vue)$': '@vue/vue2-jest',
'^.+\\.js$': 'babel-jest',
'^.+\\.mjs$': 'babel-jest',
},
// Transform ESM packages that Jest can't handle natively
// Note: @ocelot-social/ui is NOT in the exception list because we load from dist/index.cjs
// which is already CommonJS and doesn't need transformation
transformIgnorePatterns: ['node_modules/(?!(vue-demi)/)', '<rootDir>/../packages/ui/'],
testMatch: ['**/?(*.)+(spec|test).js?(x)'],
modulePathIgnorePatterns: ['<rootDir>/dist/'],
moduleNameMapper: {
// IMPORTANT: vue-demi must be mapped BEFORE @ocelot-social/ui
// This ensures that when the UI library's dist/index.cjs calls require("vue-demi"),
// it gets webapp's Vue 2.7-configured vue-demi instead of UI library's Vue 3 one
'^vue-demi$': path.resolve(__dirname, 'node_modules/vue-demi/lib/index.cjs'),
// UI library - use mock that loads dist with correct vue-demi
'^@ocelot-social/ui$': '<rootDir>/test/__mocks__/@ocelot-social/ui.js',
'^@ocelot-social/ui/style.css$': 'identity-obj-proxy',
// Other mappings
'\\.(svg)$': '<rootDir>/test/fileMock.js',
'\\.(scss|css|less)$': 'identity-obj-proxy',
'@mapbox/mapbox-gl-geocoder': 'identity-obj-proxy',
@ -38,7 +58,7 @@ module.exports = {
'^@@/': '<rootDir>/../styleguide/dist/system.umd.min.js',
'^~/(.*)$': '<rootDir>/$1',
},
moduleFileExtensions: ['js', 'json', 'vue'],
moduleFileExtensions: ['js', 'mjs', 'json', 'vue'],
testEnvironment: 'jest-environment-jsdom',
snapshotSerializers: ['jest-serializer-vue'],
}

View File

@ -1,4 +1,5 @@
import path from 'path'
import fs from 'fs'
import manifest from './constants/manifest.js'
import metadata from './constants/metadata.js'
@ -94,6 +95,9 @@ export default {
'~assets/_new/styles/resets.scss',
'~assets/styles/main.scss',
'~assets/styles/imports/_branding.scss',
// @ocelot-social/ui CSS variables (loaded before styleguide)
'~assets/_new/styles/ocelot-ui-variables.scss',
// Note: @ocelot-social/ui/style.css is loaded via plugin after styleguide
],
/*
@ -247,6 +251,9 @@ export default {
** Build configuration
*/
build: {
// Transpile ESM modules for SSR compatibility
// vue-demi and @ocelot-social/ui must be transpiled to ensure isVue2 is consistent
transpile: ['vue-demi', '@ocelot-social/ui'],
// Invalidate cache between versions
// https://www.reddit.com/r/Nuxt/comments/18i8hp2/comment/kdc1wa3/
// https://v2.nuxt.com/docs/configuration-glossary/configuration-build/#filenames
@ -281,6 +288,15 @@ export default {
config.resolve.alias['@@'] = path.resolve(__dirname, `${styleguidePath}/dist`)
// Vue 2.7 has built-in Composition API - redirect old imports
config.resolve.alias['@vue/composition-api'] = 'vue'
// Ensure vue-demi uses webapp's Vue 2.7 (not UI library's Vue 3)
config.resolve.alias['vue-demi'] = path.resolve(__dirname, 'node_modules/vue-demi')
// UI library alias - point to dist folder
// In Docker: /packages/ui, locally: ../packages/ui (via yarn link)
const uiLibraryPath = fs.existsSync('/packages/ui/dist')
? '/packages/ui/dist'
: path.resolve(__dirname, '../packages/ui/dist')
config.resolve.alias['@ocelot-social/ui$'] = path.join(uiLibraryPath, 'index.mjs')
config.resolve.alias['@ocelot-social/ui/style.css$'] = path.join(uiLibraryPath, 'style.css')
config.module.rules.push({
resourceQuery: /blockType=docs/,
loader: require.resolve(`${styleguidePath}/src/loader/docs-trim-loader.js`),

View File

@ -22,6 +22,7 @@
"postinstall": "node scripts/fix-vue2-jest.js && node scripts/fix-v-mapbox.js"
},
"dependencies": {
"@ocelot-social/ui": "file:../packages/ui",
"@mapbox/mapbox-gl-geocoder": "^5.0.2",
"@nuxtjs/apollo": "^4.0.0-rc19",
"@nuxtjs/axios": "~5.9.7",
@ -55,6 +56,7 @@
"vue": "~2.7.16",
"vue-advanced-chat": "^2.1.2",
"vue-count-to": "~1.0.13",
"vue-demi": "^0.14.10",
"vue-infinite-loading": "^2.4.5",
"vue-izitoast": "^1.2.1",
"vue-observe-visibility": "^1.0.0",

View File

@ -33,17 +33,19 @@
:disabled="!showDonations"
data-test="donations-progress"
/>
<base-button class="donations-info-button" filled type="submit">
<os-button class="donations-info-button" variant="primary" type="submit">
{{ $t('actions.save') }}
</base-button>
</os-button>
</ds-form>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { DonationsQuery, UpdateDonations } from '~/graphql/Donations'
export default {
components: { OsButton },
data() {
return {
formData: {

View File

@ -58,7 +58,7 @@ export default {
},
})
this.$toast.success(this.$t('group.groupCreated'))
this.$router.history.push({
this.$router.push({
name: 'groups-id-slug',
params: { id: responseId, slug: responseSlug },
})

View File

@ -59,7 +59,7 @@ export default {
},
})
this.$toast.success(this.$t('group.updatedGroup'))
this.$router.history.push({
this.$router.push({
name: 'groups-id-slug',
params: { id: responseId, slug: responseSlug },
})

View File

@ -12,14 +12,15 @@
</ds-flex>
<ds-space />
<ds-flex-item class="notifications-header-button" :width="{ base: 'auto' }" centered>
<base-button
primary
<os-button
variant="primary"
appearance="outline"
:disabled="unreadNotificationsCount === 0"
@click="markAllAsRead"
data-test="markAllAsRead-button"
@click="markAllAsRead"
>
{{ $t('notifications.markAllAsRead') }}
</base-button>
</os-button>
</ds-flex-item>
<ds-space />
<notifications-table
@ -41,6 +42,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import NotificationsTable from '~/components/NotificationsTable/NotificationsTable'
import DropdownFilter from '~/components/DropdownFilter/DropdownFilter'
import PaginationButtons from '~/components/_new/generic/PaginationButtons/PaginationButtons'
@ -48,6 +50,7 @@ import { notificationQuery, markAsReadMutation, markAllAsReadMutation } from '~/
export default {
components: {
OsButton,
DropdownFilter,
NotificationsTable,
PaginationButtons,
@ -57,7 +60,7 @@ export default {
return {
offset: 0,
notifications: [],
nofiticationRead: null,
notificationRead: null,
pageSize,
first: pageSize,
hasNext: false,

View File

@ -65,9 +65,7 @@ describe('PostSlug', () => {
},
// If you are mocking the router, then don't use VueRouter with localVue: https://vue-test-utils.vuejs.org/guides/using-with-vue-router.html
$router: {
history: {
push: jest.fn(),
},
push: jest.fn(),
},
$toast: {
success: jest.fn(),
@ -142,7 +140,7 @@ describe('PostSlug', () => {
})
it('does go to index (main) page', () => {
expect(mocks.$router.history.push).toHaveBeenCalledTimes(1)
expect(mocks.$router.push).toHaveBeenCalledWith('/')
})
})
})

View File

@ -329,7 +329,7 @@ export default {
try {
await this.$apollo.mutate(deletePostMutation(this.post.id))
this.$toast.success(this.$t('delete.contribution.success'))
this.$router.history.push('/') // Redirect to index (main) page
this.$router.push('/') // Redirect to index (main) page
} catch (err) {
this.$toast.error(err.message)
}

View File

@ -71,12 +71,24 @@
</ds-flex-item>
</ds-flex>
<div v-if="!myProfile" class="action-buttons">
<base-button v-if="user.isBlocked" @click="unblockUser(user)">
<os-button
v-if="user.isBlocked"
variant="primary"
appearance="outline"
full-width
@click="unblockUser(user)"
>
{{ $t('settings.blocked-users.unblock') }}
</base-button>
<base-button v-if="user.isMuted" @click="unmuteUser(user)">
</os-button>
<os-button
v-if="user.isMuted"
variant="primary"
appearance="outline"
full-width
@click="unmuteUser(user)"
>
{{ $t('settings.muted-users.unmute') }}
</base-button>
</os-button>
<hc-follow-button
v-if="!user.isMuted && !user.isBlocked"
:follow-id="user.id"
@ -192,6 +204,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import uniqBy from 'lodash/uniqBy'
import { mapGetters, mapMutations } from 'vuex'
import postListActions from '~/mixins/postListActions'
@ -225,6 +238,7 @@ const tabToFilterMapping = ({ tab, id }) => {
export default {
components: {
OsButton,
SocialMedia,
PostTeaser,
HcFollowButton,
@ -363,7 +377,7 @@ export default {
},
async unmuteUser(user) {
try {
this.$apollo.mutate({ mutation: unmuteUser(), variables: { id: user.id } })
await this.$apollo.mutate({ mutation: unmuteUser(), variables: { id: user.id } })
} catch (error) {
this.$toast.error(error.message)
} finally {
@ -383,7 +397,7 @@ export default {
},
async unblockUser(user) {
try {
this.$apollo.mutate({ mutation: unblockUser(), variables: { id: user.id } })
await this.$apollo.mutate({ mutation: unblockUser(), variables: { id: user.id } })
} catch (error) {
this.$toast.error(error.message)
} finally {
@ -489,10 +503,12 @@ export default {
.action-buttons {
margin: $space-small 0;
> button {
margin-bottom: $space-x-small;
}
> .base-button {
display: block;
width: 100%;
margin-bottom: $space-x-small;
}
}
</style>

View File

@ -35,17 +35,11 @@ exports[`notifications.vue mount renders 1`] = `
</label></div>
</div>
</div>
<div class="ds-space" style="margin-top: 24px; margin-bottom: 8px;"><button type="button" class="base-button">
<!---->
<!---->
<div class="ds-space" style="margin-top: 24px; margin-bottom: 8px;"><button type="button" data-appearance="outline" class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)]">
settings.notifications.checkAll
</button> <button type="button" class="base-button">
<!---->
<!---->
</button> <button type="button" data-appearance="outline" class="inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] bg-transparent shadow-none disabled:border-[var(--color-disabled)] disabled:text-[var(--color-disabled)] h-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle border-[var(--color-primary)] text-[var(--color-primary)] hover:bg-[var(--color-primary)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)]">
settings.notifications.uncheckAll
</button> <button disabled="disabled" type="button" class="save-button base-button --filled">
<!---->
<!---->
</button> <button type="button" disabled="disabled" data-appearance="filled" class="save-button inline-flex items-center justify-center [white-space-collapse:collapse] relative appearance-none font-semibold tracking-[0.05em] transition-[color,background-color] duration-[80ms] ease-[cubic-bezier(0.25,0.46,0.45,0.94)] cursor-pointer select-none border-[0.8px] border-solid focus:outline-1 disabled:pointer-events-none disabled:cursor-default focus:outline-dashed focus:outline-[var(--color-primary)] shadow-[inset_0_0_0_1px_rgba(0,0,0,0.05)] disabled:bg-[var(--color-disabled)] disabled:text-[var(--color-disabled-contrast)] disabled:border-[var(--color-disabled)] disabled:shadow-[inset_0_0_0_1px_transparent] h-[36px] px-[16px] py-0 text-[15px] leading-[normal] rounded-[5px] align-middle bg-[var(--color-primary)] text-[var(--color-primary-contrast)] border-[var(--color-primary)] hover:bg-[var(--color-primary-hover)] hover:border-[var(--color-primary-hover)] hover:text-[var(--color-primary-contrast)] active:bg-[var(--color-primary-active)] active:border-[var(--color-primary-active)] active:text-[var(--color-primary-contrast)] save-button">
actions.save
</button></div>
<!---->

View File

@ -20,18 +20,23 @@
<strong>
{{
selectedBadgeIndex === null
? this.$t('settings.badges.click-to-select')
? $t('settings.badges.click-to-select')
: isEmptySlotSelected
? this.$t('settings.badges.click-to-use')
? $t('settings.badges.click-to-use')
: ''
}}
</strong>
</div>
<div v-if="selectedBadgeIndex !== null && !isEmptySlotSelected" class="badge-actions">
<base-button @click="removeBadgeFromSlot" class="remove-button">
<os-button
variant="primary"
appearance="outline"
class="remove-button"
@click="removeBadgeFromSlot"
>
{{ $t('settings.badges.remove') }}
</base-button>
</os-button>
</div>
<div
@ -49,6 +54,7 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { setTrophyBadgeSelected } from '~/graphql/User'
import scrollToContent from './scroll-to-content.js'
@ -56,7 +62,7 @@ import Badges from '../../components/Badges.vue'
import BadgeSelection from '../../components/BadgeSelection.vue'
export default {
components: { BadgeSelection, Badges },
components: { OsButton, BadgeSelection, Badges },
mixins: [scrollToContent],
data() {
return {

View File

@ -18,12 +18,22 @@
{{ $t('settings.embeds.status.change.question') }}
</ds-text>
<ds-space margin-top="small" margin-bottom="base">
<base-button @click="submit" :filled="!disabled" :disabled="!disabled">
<os-button
@click="submit"
:appearance="!disabled ? 'filled' : 'outline'"
variant="primary"
:disabled="!disabled"
>
{{ $t('settings.embeds.status.change.deny') }}
</base-button>
<base-button @click="submit" :filled="disabled" :disabled="disabled">
</os-button>
<os-button
@click="submit"
:appearance="disabled ? 'filled' : 'outline'"
variant="primary"
:disabled="disabled"
>
{{ $t('settings.embeds.status.change.allow') }}
</base-button>
</os-button>
</ds-space>
<h3>{{ $t('settings.embeds.info-description') }}</h3>
<ds-space margin="small">
@ -45,12 +55,14 @@
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import axios from 'axios'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User.js'
import scrollToContent from './scroll-to-content.js'
export default {
components: { OsButton },
mixins: [scrollToContent],
head() {
return {

View File

@ -20,25 +20,37 @@
</div>
</ds-space>
<ds-space margin-top="base" margin-bottom="x-small">
<base-button @click="checkAll" :disabled="isCheckAllDisabled">
<os-button
appearance="outline"
variant="primary"
@click="checkAll"
:disabled="isCheckAllDisabled"
>
{{ $t('settings.notifications.checkAll') }}
</base-button>
<base-button @click="uncheckAll" :disabled="isUncheckAllDisabled">
</os-button>
<os-button
appearance="outline"
variant="primary"
@click="uncheckAll"
:disabled="isUncheckAllDisabled"
>
{{ $t('settings.notifications.uncheckAll') }}
</base-button>
<base-button class="save-button" filled @click="submit" :disabled="isSubmitDisabled">
</os-button>
<os-button class="save-button" variant="primary" @click="submit" :disabled="isSubmitDisabled">
{{ $t('actions.save') }}
</base-button>
</os-button>
</ds-space>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User'
import scrollToContent from './scroll-to-content.js'
export default {
components: { OsButton },
mixins: [scrollToContent],
data() {
return {

View File

@ -52,14 +52,14 @@ describe('privacy.vue', () => {
it('clicking on submit changes shoutsAllowed to false', async () => {
await wrapper.find('#allow-shouts').setChecked(false)
await wrapper.find('.base-button').trigger('click')
await wrapper.find('button').trigger('click')
expect(wrapper.vm.shoutsAllowed).toBe(false)
})
it('clicking on submit with a server error shows a toast and shoutsAllowed is still true', async () => {
mocks.$apollo.mutate = jest.fn().mockRejectedValue({ message: 'Ouch!' })
await wrapper.find('#allow-shouts').setChecked(false)
await wrapper.find('.base-button').trigger('click')
await wrapper.find('button').trigger('click')
expect(mocks.$toast.error).toHaveBeenCalledWith('Ouch!')
expect(wrapper.vm.shoutsAllowed).toBe(true)
})

View File

@ -5,16 +5,20 @@
<input id="allow-shouts" type="checkbox" v-model="shoutsAllowed" />
<label for="allow-shouts">{{ $t('settings.privacy.make-shouts-public') }}</label>
</ds-space>
<base-button filled @click="submit" :disabled="disabled">{{ $t('actions.save') }}</base-button>
<os-button variant="primary" @click="submit" :disabled="disabled">
{{ $t('actions.save') }}
</os-button>
</base-card>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { updateUserMutation } from '~/graphql/User'
import scrollToContent from './scroll-to-content.js'
export default {
components: { OsButton },
mixins: [scrollToContent],
data() {
return {

View File

@ -4,28 +4,30 @@
<base-icon name="balance-scale" />
<h2 class="title">{{ $t(`termsAndConditions.newTermsAndConditions`) }}</h2>
<nuxt-link :to="{ name: 'terms-and-conditions' }" target="_blank">
<base-button>
<os-button appearance="outline" variant="primary">
{{ $t(`termsAndConditions.termsAndConditionsNewConfirmText`) }}
</base-button>
</os-button>
</nuxt-link>
<label for="checkbox">
<input id="checkbox" type="checkbox" v-model="checked" :checked="checked" />
{{ $t('termsAndConditions.termsAndConditionsNewConfirm') }}
</label>
<base-button filled @click="submit" :disabled="!checked">
<os-button variant="primary" @click="submit" :disabled="!checked">
{{ $t(`actions.save`) }}
</base-button>
</os-button>
</base-card>
</ds-container>
</template>
<script>
import { OsButton } from '@ocelot-social/ui'
import { mapGetters, mapMutations } from 'vuex'
import { VERSION } from '~/constants/terms-and-conditions-version.js'
import { updateUserMutation } from '~/graphql/User.js'
export default {
name: 'TermsAndConditionsConfirm',
components: { OsButton },
layout: 'default',
head() {
return {

View File

@ -1,5 +1,7 @@
import Vue from 'vue'
import Styleguide from '@@/system.umd.min.js'
import '@@/system.css'
// Load UI library CSS after styleguide to ensure correct specificity
import '@ocelot-social/ui/style.css'
Vue.use(Styleguide)

View File

@ -0,0 +1,83 @@
/**
* Jest mock for @ocelot-social/ui
*
* This mock ensures vue-demi is loaded from webapp's node_modules (Vue 2.7)
* BEFORE loading the UI library's dist file.
*
* Without this, Jest would load the UI library through the symlink, which
* would use the UI library's vue-demi (configured for Vue 3).
*/
const path = require('path')
const Module = require('module')
// Load vue-demi from webapp's node_modules
// __dirname is test/__mocks__/@ocelot-social, so go up 3 levels to webapp
const vueDemiPath = path.resolve(__dirname, '../../../node_modules/vue-demi/lib/index.cjs')
const vueDemi = require(vueDemiPath)
// Verify vue-demi is correctly configured for Vue 2
if (!vueDemi.isVue2) {
throw new Error('vue-demi is not configured for Vue 2! isVue2=' + vueDemi.isVue2)
}
// Patch missing Composition API functions from Vue.default
// This is needed because Jest loads vue.runtime.common.js which exports under default
const Vue = require('vue')
const VueApi = Vue.default || Vue
// List of Composition API functions that vue-demi should export
const compositionApiFns = [
'defineComponent',
'computed',
'ref',
'reactive',
'watch',
'watchEffect',
'onMounted',
'onUnmounted',
'onBeforeMount',
'onBeforeUnmount',
'provide',
'inject',
'toRef',
'toRefs',
'unref',
'isRef',
'shallowRef',
'triggerRef',
'customRef',
'shallowReactive',
'shallowReadonly',
'readonly',
'toRaw',
'markRaw',
'effectScope',
'getCurrentScope',
'onScopeDispose',
'getCurrentInstance',
'h',
'nextTick',
]
// Patch any missing functions
for (const fn of compositionApiFns) {
if (!vueDemi[fn] && VueApi[fn]) {
vueDemi[fn] = VueApi[fn]
}
}
// Patch Module._load to return the correct vue-demi when the UI library loads it
const originalLoad = Module._load
Module._load = function (request, parent, isMain) {
if (request === 'vue-demi') {
return vueDemi
}
return originalLoad.apply(this, arguments)
}
// Load the UI library dist
const uiDistPath = path.resolve(__dirname, '../../../node_modules/@ocelot-social/ui/dist/index.cjs')
const ui = require(uiDistPath)
// Export everything from the UI library
module.exports = ui

View File

@ -0,0 +1,25 @@
/**
* This setup file ensures vue-demi is loaded correctly for Vue 2.7.
*
* The @ocelot-social/ui package is handled by the mock in test/__mocks__/@ocelot-social/ui.js
*/
const path = require('path')
const Module = require('module')
// Path to webapp's vue-demi (configured for Vue 2.7)
const vueDemiPath = path.resolve(__dirname, '../node_modules/vue-demi/lib/index.cjs')
// Patch Module._resolveFilename to intercept vue-demi requires
const originalResolveFilename = Module._resolveFilename
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === 'vue-demi') {
return vueDemiPath
}
return originalResolveFilename.apply(this, arguments)
}
// Pre-load vue-demi to ensure correct version is cached
const vueDemi = require(vueDemiPath)
if (!vueDemi.isVue2) {
throw new Error('vue-demi setup failed: isVue2 should be true')
}

View File

@ -3835,6 +3835,14 @@
mustache "^2.3.0"
stack-trace "0.0.10"
"@ocelot-social/ui@file:../packages/ui":
version "0.0.1"
dependencies:
class-variance-authority "^0.7.1"
clsx "^2.1.1"
tailwind-merge "^3.3.0"
vue-demi "^0.14.10"
"@oclif/color@^0.0.0":
version "0.0.0"
resolved "https://registry.yarnpkg.com/@oclif/color/-/color-0.0.0.tgz#54939bbd16d1387511bf1a48ccda1a417248e6a9"
@ -7965,6 +7973,13 @@ class-utils@^0.3.5:
isobject "^3.0.0"
static-extend "^0.1.1"
class-variance-authority@^0.7.1:
version "0.7.1"
resolved "https://registry.yarnpkg.com/class-variance-authority/-/class-variance-authority-0.7.1.tgz#4008a798a0e4553a781a57ac5177c9fb5d043787"
integrity sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==
dependencies:
clsx "^2.1.1"
clean-css@^4.2.3:
version "4.2.4"
resolved "https://registry.yarnpkg.com/clean-css/-/clean-css-4.2.4.tgz#733bf46eba4e607c6891ea57c24a989356831178"
@ -8117,6 +8132,11 @@ clone-response@^1.0.2:
dependencies:
mimic-response "^1.0.0"
clsx@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
co@^4.6.0:
version "4.6.0"
resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184"
@ -20000,6 +20020,11 @@ table@^6.0.9:
string-width "^4.2.3"
strip-ansi "^6.0.1"
tailwind-merge@^3.3.0:
version "3.4.0"
resolved "https://registry.yarnpkg.com/tailwind-merge/-/tailwind-merge-3.4.0.tgz#5a264e131a096879965f1175d11f8c36e6b64eca"
integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==
tapable@^1.0.0, tapable@^1.0.0-beta.5, tapable@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/tapable/-/tapable-1.1.3.tgz#a1fccc06b58db61fd7a45da2da44f5f3a3e67ba2"
@ -21249,6 +21274,11 @@ vue-count-to@~1.0.13:
resolved "https://registry.yarnpkg.com/vue-count-to/-/vue-count-to-1.0.13.tgz#3e7573ea6e64c2b2972f64e0a2ab2e23c7590ff3"
integrity sha512-6R4OVBVNtQTlcbXu6SJ8ENR35M2/CdWt3Jmv57jOUM+1ojiFmjVGvZPH8DfHpMDSA+ITs+EW5V6qthADxeyYOQ==
vue-demi@^0.14.10:
version "0.14.10"
resolved "https://registry.yarnpkg.com/vue-demi/-/vue-demi-0.14.10.tgz#afc78de3d6f9e11bf78c55e8510ee12814522f04"
integrity sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==
vue-eslint-parser@^9.4.3:
version "9.4.3"
resolved "https://registry.yarnpkg.com/vue-eslint-parser/-/vue-eslint-parser-9.4.3.tgz#9b04b22c71401f1e8bca9be7c3e3416a4bde76a8"