# .pro Generation Improvements + UI Fixes ## TL;DR > **Quick Summary**: Improve ProPresenter .pro file generation (remove visual attributes, fix translated textbox positioning, add macro support, default arrangement selection, .probundle export) and complete three pending UI fixes (drag highlighting, arrangement auto-persist, finalize buttons on Edit page). > > **Deliverables**: > - Cleaned .pro output (no fill/stroke/shadow/feather/textScroller) > - Correct dual-textbox layout for translated slides matching TestTranslated.pro reference > - 'normal' arrangement auto-selected in generator and on song match > - Global settings UI for macro configuration + macro on COPYRIGHT slide > - .probundle export for service slide blocks (information, moderation, sermon) > - Drag highlight CSS on SlideGrid > - Finalize + "Finalize & Download" buttons on Service Edit page > > **Estimated Effort**: Medium-Large (8 tasks across 3 waves) > **Parallel Execution**: YES - 3 waves > **Critical Path**: Task 2 → Task 5/6 → Task 7 --- ## Context ### Original Requests **Request #14** (from prior session, NOT STARTED): 5 improvements to .pro file generation — remove slide attributes, add macro to COPYRIGHT slide with global settings UI, set 'normal' arrangement as default, two textboxes for translated slides, export service slides as .probundle. **Request #12** (from prior session, analyzed but NOT IMPLEMENTED): 3 UI improvements — slide drag highlight, default arrangement auto-persist on song match, finalize/download buttons on Edit page. ### Interview Summary **Key Discussions**: - User explicitly said "take attention of the 'naming' and the exact position of the textboxes" for translated slides - User said "which macro, should be selectable in a global settings UI - tell me what you need, with examples from the Module tests/samples" - User said "set arrangement 'normal' as selected if exist" - User said service blocks (information, moderation, sermon) should be exportable as .probundle **Research Findings**: - **Textbox positioning (CRITICAL)**: TestTranslated.pro uses DIFFERENT bounds — "Orginal" at origin(150, 99.543) size(1620×182.946) top; "Deutsch" at origin(150, 303.166) size(1620×113.889) below. Current generator puts BOTH at origin(150,100) size(1620×880) overlaid. Must fix to match reference. - **Macro structure**: 4 fields needed — name, uuid, collectionName, collectionUuid. buildMacroAction() already exists in generator (lines 206-227). Collection UUID default: `8D02FC57-83F8-4042-9B90-81C229728426` - **Settings**: NO infrastructure exists. Need migration, model, controller, Vue page, nav link. - **.probundle**: NOT implemented anywhere. Must build from scratch. ZIP with .pro + images. - **SlideGrid.vue**: Uses vue-draggable-plus. Add ghost-class/chosen-class/drag-class props. - **Finalize flow**: Fully implemented in Index.vue. Port to Edit.vue. ### Metis Review **Identified Gaps** (addressed): - Textbox bounds differ from generator defaults — using exact values from TestTranslated.pro - "Remove attributes" ambiguity — ✅ RESOLVED: Set enable=false (not remove entirely) - COPYRIGHT slide doesn't exist in export flow — ✅ RESOLVED: Slide with group name 'COPYRIGHT', configurable via settings - .probundle format undefined — ✅ RESOLVED: Flat ZIP with .pro file + image files - Default arrangement selection needs API change — resolved: find 'normal' by name in generate() - manualAssign() should also auto-set arrangement — resolved: yes, same pattern as autoMatch() --- ## Work Objectives ### Core Objective Complete 5 .pro generation improvements and 3 UI fixes to bring the presenter app to full feature parity with the spec. ### Concrete Deliverables - Modified `ProFileGenerator.php` — cleaned attributes, correct textbox bounds, arrangement selection - New `Setting` model + migration + controller + Vue page for macro config - Modified `ProExportService.php` — macro injection into COPYRIGHT slide - New `ProBundleExportService.php` — .probundle ZIP export for image slides - Modified `SlideGrid.vue` — drag highlight CSS - Modified `SongMatchingService.php` — auto-arrangement on match - Modified `Edit.vue` — finalize + download buttons ### Definition of Done - [x] `cd /Users/thorsten/AI/cts-work && php artisan test` — all tests pass (198+ tests) - [x] `cd /Users/thorsten/AI/cts-work && npm run build` — build succeeds - [x] Generated .pro files have no fill/stroke/shadow/feather/textScroller attributes - [x] Translated song export has two textboxes with different bounds matching TestTranslated.pro - [x] 'normal' arrangement selected by default in generated .pro files - [x] Settings page accessible with macro configuration - [x] .probundle download works for information/moderation/sermon blocks - [x] Drag highlight visible when reordering slides - [x] Finalize buttons work on Edit page ### Must Have - Exact textbox names preserved: "Orginal" (typo is intentional) and "Deutsch" - Non-translated slides keep full-size single textbox at origin(150,100) size(1620×880) - Existing 198 tests continue passing - German UI text (Du, not Sie) - Immediate persistence on all actions ### Must NOT Have (Guardrails) - Generic settings CRUD framework — ONLY the macro fields needed - Changes to ProExportService public API signature (used by PlaylistExportService and ProFileController) - Changes to ArrangementConfigurator drag-and-drop behavior - CTS API writes of any kind - Unnecessary abstractions or over-engineering - JSDoc or documentation bloat --- ## Verification Strategy > **ZERO HUMAN INTERVENTION** — ALL verification is agent-executed. No exceptions. ### Test Decision - **Infrastructure exists**: YES - **Automated tests**: Tests-after (add targeted tests for new behavior) - **Framework**: Pest (Laravel) + PHPUnit (vendor module) ### QA Policy Every task includes agent-executed QA scenarios. Evidence saved to `.sisyphus/evidence/task-{N}-{scenario-slug}.{ext}`. - **Backend**: Use Bash — run test commands, curl endpoints, verify responses - **Frontend/UI**: Use Playwright (playwright skill) — navigate, interact, assert DOM, screenshot - **Vendor module**: Use Bash — run PHPUnit, verify generated file contents --- ## Execution Strategy ### Parallel Execution Waves ``` Wave 1 (Start Immediately — 4 independent tasks): ├── Task 1: SlideGrid drag highlight CSS [quick] ├── Task 2: Set slide attributes to enable=false in ProFileGenerator [quick] ├── Task 3: Auto-select arrangement on song match [quick] └── Task 4: Finalize + Download buttons on Edit page [visual-engineering] Wave 2 (After Task 2 — same file, avoid conflicts): ├── Task 5: Default arrangement selection in generator [quick] └── Task 6: Translated textbox positioning [unspecified-low] Wave 3 (After Waves 1-2 + user decisions): ├── Task 7: Settings infrastructure + macro on COPYRIGHT group slide [unspecified-high] └── Task 8: .probundle export for service slides [deep] Wave FINAL (After ALL tasks — 4 parallel reviews): ├── Task F1: Plan compliance audit [oracle] ├── Task F2: Code quality review [unspecified-high] ├── Task F3: Real QA walkthrough [unspecified-high] └── Task F4: Scope fidelity check [deep] Critical Path: Task 2 → Task 5/6 → Task 7 Parallel Speedup: ~60% faster than sequential Max Concurrent: 4 (Wave 1) ``` ### Dependency Matrix | Task | Depends On | Blocks | Wave | |------|-----------|--------|------| | 1 (Drag highlight) | — | — | 1 | | 2 (Remove attributes) | — | 5, 6 | 1 | | 3 (Auto arrangement match) | — | — | 1 | | 4 (Finalize buttons Edit) | — | — | 1 | | 5 (Default arrangement gen) | 2 | 7 | 2 | | 6 (Translated textbox) | 2 | 7 | 2 | | 7 (Settings + macro) | 2, 5, 6 + USER | — | 3 | | 8 (.probundle export) | 2, 5, 6 + USER | — | 3 | | F1-F4 (Final verification) | ALL | — | FINAL | ### Agent Dispatch Summary - **Wave 1**: 4 tasks — T1 → `quick`, T2 → `quick`, T3 → `quick`, T4 → `visual-engineering` - **Wave 2**: 2 tasks — T5 → `quick`, T6 → `unspecified-low` - **Wave 3**: 2 tasks — T7 → `unspecified-high`, T8 → `deep` - **FINAL**: 4 tasks — F1 → `oracle`, F2 → `unspecified-high`, F3 → `unspecified-high`, F4 → `deep` --- ## TODOs - [x] 1. Slide Grid Drag Highlight **What to do**: - In `resources/js/Components/SlideGrid.vue` (line 207-215), add drag highlight props to the `` component: `ghost-class="slide-drag-ghost"`, `chosen-class="slide-drag-chosen"`, `drag-class="slide-drag-active"` - Add scoped CSS styles (after line 453) for these classes: - `.slide-drag-ghost`: `opacity: 0.4; border: 2px dashed rgb(99, 102, 241);` (indigo dashed border, reduced opacity — shows drop position) - `.slide-drag-chosen`: `ring-2 ring-indigo-500 shadow-lg scale-105` (highlight the picked-up element) - `.slide-drag-active`: `opacity: 0.8; transform: rotate(2deg);` (slight rotation on the dragged clone) - Also check if `ArrangementConfigurator.vue` has a separate drag area that needs the same treatment — if so, apply same classes there **Must NOT do**: - Do NOT change the drag-and-drop behavior or reordering logic - Do NOT change existing cursor styles (cursor-grab / cursor-grabbing) **Recommended Agent Profile**: - **Category**: `quick` - Reason: Single component CSS addition, well-defined insertion points - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Visual styling task, needs design sensibility for drag feedback **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (with Tasks 2, 3, 4) - **Blocks**: Nothing - **Blocked By**: None (can start immediately) **References**: **Pattern References**: - `resources/js/Components/SlideGrid.vue:207-215` — VueDraggable component, add ghost-class/chosen-class/drag-class props here - `resources/js/Components/SlideGrid.vue:453-465` — Existing scoped styles section, add new CSS classes here - `resources/js/Components/SlideGrid.vue:219` — Current `.slide-card` class with cursor-grab styling **API/Type References**: - vue-draggable-plus docs: ghostClass, chosenClass, dragClass are string props passed to underlying SortableJS **WHY Each Reference Matters**: - Line 207-215: This is the exact VueDraggable tag where props go — the only place drag config lives - Line 453-465: This is where scoped styles are defined — new CSS goes after existing styles - Line 219: Shows current Tailwind styling pattern to match **Acceptance Criteria**: - [ ] `npm run build` succeeds - [ ] `php artisan test` — all existing tests pass **QA Scenarios (MANDATORY):** ``` Scenario: Drag highlight visible on slide reorder Tool: Playwright (playwright skill) Preconditions: App running at http://cts-work.test, logged in, navigate to a service Edit page with multiple slides in Information block Steps: 1. Navigate to http://cts-work.test/services and click "Bearbeiten" on any service 2. Expand the "Informationen" block 3. If slides exist, mousedown on a slide thumbnail and start dragging 4. Assert: the ghost element has class `slide-drag-ghost` with reduced opacity 5. Assert: the chosen element has class `slide-drag-chosen` with ring highlight 6. Take screenshot during drag state Expected Result: Visual highlight visible on dragged slide — ghost at drop position, chosen element highlighted Failure Indicators: No CSS class applied, no visual change during drag Evidence: .sisyphus/evidence/task-1-drag-highlight.png Scenario: No drag highlight on non-draggable elements Tool: Playwright (playwright skill) Preconditions: Same as above Steps: 1. Attempt to drag the upload area (has .no-drag class) 2. Assert: no drag classes applied, upload area doesn't move Expected Result: Upload area is not draggable, no highlight classes applied Evidence: .sisyphus/evidence/task-1-no-drag-upload.png ``` **Commit**: YES - Message: `fix(ui): add drag highlight to slide grid` - Files: `resources/js/Components/SlideGrid.vue` - Pre-commit: `npm run build` - [x] 2. Set Visual Attributes to enable=false in ProFileGenerator **What to do**: - In `vendor/propresenter/parser/src/ProFileGenerator.php`: - **DECISION APPLIED**: Set attributes with `enable=false` (not remove entirely). This matches how ProPresenter natively generates reference files. - Modify `buildFill()`, `buildStroke()`, `buildShadow()`, `buildFeather()` to call `setEnabled(false)` instead of `setEnabled(true)` - For `buildTextScroller()`, set to disabled/inactive state - Keep all method calls in `buildSlideElement()` and `buildCue()` — attributes are still created, just disabled - Keep all `use` imports — classes are still used - Update tests in `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` to assert attributes are PRESENT but DISABLED (enable=false) **Must NOT do**: - Do NOT remove `buildBounds()` — bounds are still needed for textbox positioning - Do NOT remove `buildPath()` — paths may still be needed - Do NOT change the public API signature of `generate()` or `generateAndWrite()` - Do NOT touch ProFileReader (reading logic stays intact) **Recommended Agent Profile**: - **Category**: `quick` - Reason: Deletion of known methods, well-defined scope - **Skills**: `[]` - No special skills needed — pure PHP method removal **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (with Tasks 1, 3, 4) - **Blocks**: Tasks 5, 6 (same file — must complete before they start) - **Blocked By**: None (can start immediately) **References**: **Pattern References**: - `vendor/propresenter/parser/src/ProFileGenerator.php:257-281` — `buildSlideElement()` — remove setFill/setStroke/setShadow/setFeather calls - `vendor/propresenter/parser/src/ProFileGenerator.php:~170` — `buildCue()` — remove setTextScroller call - `vendor/propresenter/parser/src/ProFileGenerator.php:300-386` — Methods to delete: buildFill, buildStroke, buildShadow, buildFeather, buildTextScroller **Test References**: - `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` — Update assertions that check for fill/stroke/shadow/feather presence - `/Users/thorsten/AI/propresenter-work/ref/TestTranslated.pro` — Reference file showing attributes with enable=false (for comparison) **WHY Each Reference Matters**: - Lines 257-281: The exact method where attribute setters are called — surgical removal needed - Lines 300-386: Dead code after removal — must be deleted to keep codebase clean - Tests: Must be updated to not expect removed attributes, otherwise test suite breaks **Acceptance Criteria**: - [ ] `cd /Users/thorsten/AI/cts-work && php -r "require 'vendor/autoload.php'; echo 'autoload ok';"` — autoload works - [ ] Vendor PHPUnit tests pass (run from within vendor dir or via app test suite) - [ ] `php artisan test` — all app tests pass **QA Scenarios (MANDATORY):** ``` Scenario: Generated .pro file has no visual attributes Tool: Bash Preconditions: ProFileGenerator available via autoload Steps: 1. Run: cd /Users/thorsten/AI/cts-work && php -r " require 'vendor/autoload.php'; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileWriter; use ProPresenter\Parser\ProFileReader; \$song = ProFileGenerator::generate('AttrTest', [['name'=>'V1','color'=>[0,0,0,1],'slides'=>[['text'=>'Hello World']]]], [['name'=>'normal','groupNames'=>['V1']]]); ProFileWriter::write('/tmp/attr-test.pro', \$song); \$reader = new ProFileReader('/tmp/attr-test.pro'); \$slides = \$reader->getSlides(); \$el = \$slides[0]->getAllElements()[0]; \$raw = \$el->getRawElement(); echo 'hasFill:' . (\$raw->getFill() ? 'YES' : 'NO') . PHP_EOL; echo 'hasStroke:' . (\$raw->getStroke() ? 'YES' : 'NO') . PHP_EOL; " 2. Assert: fill is present but enabled=false, stroke present but enabled=false Expected Result: Generated file has fill/stroke/shadow/feather present but ALL with enabled=false Failure Indicators: Any attribute returns YES Evidence: .sisyphus/evidence/task-2-no-attributes.txt Scenario: Existing tests still pass after removal Tool: Bash Preconditions: Vendor test suite available Steps: 1. Run: cd /Users/thorsten/AI/cts-work && php artisan test 2. Assert: 0 failures, 0 errors Expected Result: All 198+ tests pass Evidence: .sisyphus/evidence/task-2-tests-pass.txt ``` **Commit**: YES - Message: `refactor(pro): remove visual attributes from slide generation` - Files: `vendor/propresenter/parser/src/ProFileGenerator.php`, `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` - Pre-commit: `php artisan test` - [x] 3. Auto-Select Default Arrangement on Song Match **What to do**: - In `app/Services/SongMatchingService.php`, in `autoMatch()` method (lines 34-38), after setting `song_id`: - Find the default arrangement: `$defaultArrangement = $song->arrangements()->where('is_default', true)->first() ?? $song->arrangements()->where('name', 'normal')->first() ?? $song->arrangements()->first();` - If found, set `$serviceSong->song_arrangement_id = $defaultArrangement->id;` - Apply same logic in `manualAssign()` method (lines 47-54), but ONLY if `song_arrangement_id` is currently null (don't override existing selection) - Add a test in the appropriate test file verifying: after autoMatch, serviceSong has a non-null song_arrangement_id pointing to the 'normal' arrangement **Must NOT do**: - Do NOT change how arrangements are created or structured - Do NOT change the ArrangementConfigurator component behavior - Do NOT override an existing arrangement selection in manualAssign **Recommended Agent Profile**: - **Category**: `quick` - Reason: Small service method change, ~10 lines added - **Skills**: `[]` - Pure PHP backend logic **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (with Tasks 1, 2, 4) - **Blocks**: Nothing - **Blocked By**: None (can start immediately) **References**: **Pattern References**: - `app/Services/SongMatchingService.php:34-38` — `autoMatch()` — insert arrangement lookup after `song_id` assignment - `app/Services/SongMatchingService.php:47-54` — `manualAssign()` — insert arrangement lookup if song_arrangement_id is null - `resources/js/Components/ArrangementConfigurator.vue:27` — Shows `is_default` priority pattern: `props.arrangements.find((item) => item.is_default)?.id ?? props.arrangements[0]?.id` **API/Type References**: - `app/Models/Song.php` — `arrangements()` relationship - `app/Models/SongArrangement.php` — has `is_default` boolean, `name` string - `app/Models/ServiceSong.php` — has `song_arrangement_id` foreign key **WHY Each Reference Matters**: - Lines 34-38: Exact insertion point in autoMatch — add arrangement lookup right after song_id is set - Lines 47-54: Same for manualAssign — only set if currently null to not override user choice - ArrangementConfigurator line 27: Shows the priority order to replicate in PHP: is_default → 'normal' → first **Acceptance Criteria**: - [ ] `php artisan test` — all tests pass - [ ] New test: after autoMatch, serviceSong.song_arrangement_id is not null - [ ] New test: after manualAssign with existing arrangement, song_arrangement_id unchanged **QA Scenarios (MANDATORY):** ``` Scenario: Auto-match sets default arrangement Tool: Bash Preconditions: Database has a song with 'normal' arrangement and is_default=true Steps: 1. Run: php artisan test --filter=SongMatchingServiceTest 2. Assert: test for auto-arrangement passes 3. Alternatively, check via tinker: php artisan tinker --execute=" \$song = App\Models\Song::first(); \$arrangement = \$song->arrangements()->where('is_default', true)->first(); echo 'has_default_arrangement: ' . (\$arrangement ? 'YES' : 'NO'); " Expected Result: Song matching auto-selects the default (or 'normal') arrangement Failure Indicators: song_arrangement_id remains null after match Evidence: .sisyphus/evidence/task-3-auto-arrangement.txt Scenario: Manual assign preserves existing arrangement choice Tool: Bash Preconditions: ServiceSong already has song_arrangement_id set Steps: 1. Run test that manually assigns to same song 2. Assert: song_arrangement_id unchanged Expected Result: Existing arrangement selection is not overridden Evidence: .sisyphus/evidence/task-3-preserve-arrangement.txt ``` **Commit**: YES - Message: `feat(songs): auto-select default arrangement on song match` - Files: `app/Services/SongMatchingService.php`, test file - Pre-commit: `php artisan test` - [x] 4. Finalize + Download Buttons on Service Edit Page **What to do**: - In `resources/js/Pages/Services/Edit.vue`: - Add a sticky bottom bar after the blocks accordion (after line ~344) with: - If NOT finalized: "Abschließen" button (primary) + "Abschließen & Herunterladen" button (secondary) - If finalized: "Wieder öffnen" button + "Herunterladen" button - Port the following from `resources/js/Pages/Services/Index.vue`: - `finalizeService(serviceId)` method (lines 69-95) — POST to `services.finalize` with `{ confirmed: false }`, handle `needs_confirmation` response - `confirmFinalize()` method (lines 97-119) — POST with `{ confirmed: true }` - `reopenService(serviceId)` method (lines 127-132) — POST to `services.reopen` - Confirmation dialog with warnings list (lines 417-470) - Toast notification pattern (lines 50-57) - Add "Abschließen & Herunterladen" flow: call finalize first, on success trigger download via `window.location.href = route('services.download', service.id)` - After successful finalize, update the local `service.finalized_at` reactively (or reload the page) - After successful reopen, clear `service.finalized_at` reactively **Must NOT do**: - Do NOT change the finalize/reopen controller logic - Do NOT change the routes - Do NOT redesign the finalization UX (keep same flow as Index.vue) - Do NOT add finalize before all blocks are loaded **Recommended Agent Profile**: - **Category**: `visual-engineering` - Reason: Vue component with UI layout, modal dialog, button styling - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Sticky footer layout, button hierarchy, modal design in German **Parallelization**: - **Can Run In Parallel**: YES - **Parallel Group**: Wave 1 (with Tasks 1, 2, 3) - **Blocks**: Nothing - **Blocked By**: None (can start immediately) **References**: **Pattern References**: - `resources/js/Pages/Services/Index.vue:69-95` — `finalizeService()` — POST with confirmation flow, handle needs_confirmation JSON response - `resources/js/Pages/Services/Index.vue:97-119` — `confirmFinalize()` — Confirm with warnings - `resources/js/Pages/Services/Index.vue:127-132` — `reopenService()` — Simple POST, toast on success - `resources/js/Pages/Services/Index.vue:417-470` — Confirmation modal with warnings list - `resources/js/Pages/Services/Index.vue:50-57` — `showToast()` utility **API/Type References**: - `routes/web.php:55` — `POST /services/{service}/finalize` named `services.finalize` - `routes/web.php:56` — `POST /services/{service}/reopen` named `services.reopen` - `routes/web.php:58` — `GET /services/{service}/download` named `services.download` - `app/Http/Controllers/ServiceController.php:224-245` — finalize() returns JSON: `{ needs_confirmation, warnings, success }` - `app/Http/Controllers/ServiceController.php:247-256` — reopen() returns redirect with flash - `app/Http/Controllers/ServiceController.php:269-289` — download() returns BinaryFileResponse **WHY Each Reference Matters**: - Index.vue 69-132: Complete working implementation to port — copy these methods, adapt for Edit page context - Index.vue 417-470: Confirmation dialog template — reuse HTML structure - Routes: Needed for `route()` helper calls in Vue - Controller: Understanding response format to handle correctly (JSON vs redirect) **Acceptance Criteria**: - [ ] `npm run build` succeeds - [ ] `php artisan test` — all tests pass - [ ] Finalize button visible on Edit page when service is not finalized - [ ] Reopen + Download buttons visible when service is finalized **QA Scenarios (MANDATORY):** ``` Scenario: Finalize from Edit page Tool: Playwright (playwright skill) Preconditions: App running at http://cts-work.test, logged in, navigate to an un-finalized service Edit page Steps: 1. Navigate to http://cts-work.test/services 2. Click "Bearbeiten" on a service that is NOT finalized 3. Scroll to bottom — assert "Abschließen" button visible 4. Click "Abschließen" 5. If confirmation dialog appears, click confirm button 6. Assert: page shows "Wieder öffnen" button (finalized state) 7. Take screenshot Expected Result: Service is finalized, buttons switch to reopen/download state Failure Indicators: Button not visible, finalize fails, page errors Evidence: .sisyphus/evidence/task-4-finalize-edit.png Scenario: Reopen from Edit page Tool: Playwright (playwright skill) Preconditions: Service is finalized (from previous scenario) Steps: 1. On the same Edit page, click "Wieder öffnen" 2. Assert: buttons switch back to "Abschließen" / "Abschließen & Herunterladen" 3. Take screenshot Expected Result: Service is reopened, buttons revert to non-finalized state Failure Indicators: Reopen fails, buttons don't update Evidence: .sisyphus/evidence/task-4-reopen-edit.png Scenario: Finalize & Download from Edit page Tool: Playwright (playwright skill) Preconditions: Service is not finalized, has at least some content Steps: 1. Click "Abschließen & Herunterladen" 2. If confirmation appears, confirm 3. Assert: download starts (check network for download response) 4. Assert: buttons switch to finalized state Expected Result: Service finalized AND file download triggered Evidence: .sisyphus/evidence/task-4-finalize-download.png ``` **Commit**: YES - Message: `feat(services): add finalize and download buttons to edit page` - Files: `resources/js/Pages/Services/Edit.vue` - Pre-commit: `npm run build && php artisan test` - [x] 5. Default Arrangement Selection in ProFileGenerator **What to do**: - In `vendor/propresenter/parser/src/ProFileGenerator.php`, in `generate()` method (lines 115-117): - Currently: `if (isset($arrangementProtos[0])) { $presentation->setSelectedArrangement($arrangementProtos[0]->getUuid()); }` - Change to: Loop through `$arrangementProtos`, find the one where `getName()` matches 'normal' (case-insensitive). If found, use its UUID. If not found, fall back to first arrangement. - Implementation: ```php $selectedArrangement = null; foreach ($arrangementProtos as $arr) { if (strtolower($arr->getName()) === 'normal') { $selectedArrangement = $arr; break; } } $selectedArrangement = $selectedArrangement ?? ($arrangementProtos[0] ?? null); if ($selectedArrangement) { $presentation->setSelectedArrangement($selectedArrangement->getUuid()); } ``` - Add test: generate with arrangements `['other', 'normal']`, verify 'normal' is selected (not 'other') - Add test: generate with arrangements `['custom']` only (no 'normal'), verify 'custom' is selected as fallback **Must NOT do**: - Do NOT change the public API signature of `generate()` or `generateAndWrite()` - Do NOT add new parameters to these methods - Do NOT change how arrangements are built — only which one is selected **Recommended Agent Profile**: - **Category**: `quick` - Reason: Small logic change in one method, ~15 lines - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with Task 6) - **Parallel Group**: Wave 2 (with Task 6) - **Blocks**: Task 7 (depends on stable generator) - **Blocked By**: Task 2 (same file, avoid merge conflicts) **References**: **Pattern References**: - `vendor/propresenter/parser/src/ProFileGenerator.php:115-117` — Current arrangement selection logic to replace - `vendor/propresenter/parser/src/ProFileGenerator.php:96-113` — Arrangement protobuf building loop (context for understanding $arrangementProtos) **Test References**: - `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` — Existing arrangement tests to extend **WHY Each Reference Matters**: - Lines 115-117: The EXACT code to replace — currently selects first, need to find 'normal' - Lines 96-113: Shows how arrangements are built and stored in $arrangementProtos — needed to understand getName() **Acceptance Criteria**: - [ ] Vendor PHPUnit tests pass - [ ] `php artisan test` — all app tests pass - [ ] New test: 'normal' arrangement selected when present among multiple arrangements - [ ] New test: first arrangement selected as fallback when 'normal' not present **QA Scenarios (MANDATORY):** ``` Scenario: 'normal' arrangement auto-selected Tool: Bash Preconditions: ProFileGenerator available Steps: 1. Run: cd /Users/thorsten/AI/cts-work && php -r " require 'vendor/autoload.php'; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileReader; use ProPresenter\Parser\ProFileWriter; \$song = ProFileGenerator::generate('SelectTest', [['name'=>'V1','color'=>[0,0,0,1],'slides'=>[['text'=>'Hello']]]], [['name'=>'other','groupNames'=>['V1']], ['name'=>'normal','groupNames'=>['V1']]] ); ProFileWriter::write('/tmp/select-test.pro', \$song); \$reader = new ProFileReader('/tmp/select-test.pro'); \$arrangements = \$reader->getArrangements(); \$selected = \$reader->getSelectedArrangement(); echo 'selected: ' . \$selected; " 2. Assert output contains: selected: normal Expected Result: 'normal' arrangement is selected, not 'other' Failure Indicators: 'other' is selected or no arrangement selected Evidence: .sisyphus/evidence/task-5-normal-selected.txt Scenario: Fallback to first when no 'normal' Tool: Bash Steps: 1. Generate with arrangements ['custom'] only 2. Read back and verify 'custom' is selected Expected Result: Falls back to first arrangement gracefully Evidence: .sisyphus/evidence/task-5-fallback-first.txt ``` **Commit**: YES - Message: `feat(pro): select normal arrangement by default in generator` - Files: `vendor/propresenter/parser/src/ProFileGenerator.php`, `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` - Pre-commit: `php artisan test` - [x] 6. Correct Translated Textbox Positioning **What to do**: - In `vendor/propresenter/parser/src/ProFileGenerator.php`: - Currently `buildBounds()` returns a single fixed rect: origin(150,100) size(1620×880) used for ALL textboxes - Create two additional private methods: - `buildOriginalBounds()`: Returns rect with origin(150, 99.543) size(1620, 182.946) — top position, ~183px tall - `buildTranslationBounds()`: Returns rect with origin(150, 303.166) size(1620, 113.889) — below, ~114px tall - Keep existing `buildBounds()` unchanged for non-translated (single textbox) slides - Modify `buildCue()` (lines 151-192) where it creates slide elements: - Currently: `$elements[] = self::buildSlideElement('Orginal', ...)` and `$elements[] = self::buildSlideElement('Deutsch', ...)` - Both call `buildSlideElement()` which uses `buildBounds()` - Change: Pass a `$boundsMethod` parameter or create `buildSlideElementWithBounds($name, $text, $bounds)` variant - For translated slides: first element uses `buildOriginalBounds()`, second uses `buildTranslationBounds()` - For non-translated slides: single element uses `buildBounds()` (unchanged) - Add test: generate translated song, read back, verify: - Element 0 name is "Orginal", bounds height ≈ 183 - Element 1 name is "Deutsch", bounds height ≈ 114 - Element 0 origin.y ≈ 99.5, Element 1 origin.y ≈ 303.2 - Add test: generate non-translated song, verify single element at full bounds (150,100, 1620×880) **Must NOT do**: - Do NOT change textbox names — "Orginal" (with intentional typo) and "Deutsch" must stay exactly as-is - Do NOT change non-translated slide bounds - Do NOT change `buildBounds()` default behavior **Recommended Agent Profile**: - **Category**: `unspecified-low` - Reason: Precise positioning work, needs careful float handling, but small scope - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with Task 5) - **Parallel Group**: Wave 2 (with Task 5) - **Blocks**: Task 7 (depends on stable generator) - **Blocked By**: Task 2 (same file, avoid merge conflicts) **References**: **Pattern References**: - `vendor/propresenter/parser/src/ProFileGenerator.php:283-298` — `buildBounds()` — current single-bounds method to use as template - `vendor/propresenter/parser/src/ProFileGenerator.php:151-192` — `buildCue()` — where slide elements are created with names "Orginal" / "Deutsch" - `vendor/propresenter/parser/src/ProFileGenerator.php:257-281` — `buildSlideElement()` — where bounds are applied **External References**: - `/Users/thorsten/AI/propresenter-work/ref/TestTranslated.pro` — THE reference file with exact positioning values. Confirmed bounds: Original origin(150, 99.543) size(1620×182.946), Deutsch origin(150, 303.166) size(1620×113.889) **Test References**: - `vendor/propresenter/parser/tests/ProFileReaderTest.php` — Shows how to read back slides and elements for assertions - `vendor/propresenter/parser/tests/SlideTest.php` — Shows element/textbox structure **WHY Each Reference Matters**: - `buildBounds()`: Template for creating new bounds methods — same pattern, different values - `buildCue()` lines 151-192: Where the if(translation) branch creates two elements — need to pass different bounds here - TestTranslated.pro: THE source of truth for exact positioning values — user explicitly said to match this **Acceptance Criteria**: - [ ] Vendor PHPUnit tests pass - [ ] `php artisan test` — all app tests pass - [ ] New test: translated slide has two elements with DIFFERENT bounds heights - [ ] New test: non-translated slide has one element with full-size bounds **QA Scenarios (MANDATORY):** ``` Scenario: Translated slide has correctly positioned dual textboxes Tool: Bash Preconditions: ProFileGenerator available Steps: 1. Run: cd /Users/thorsten/AI/cts-work && php -r " require 'vendor/autoload.php'; use ProPresenter\Parser\ProFileGenerator; use ProPresenter\Parser\ProFileWriter; use ProPresenter\Parser\ProFileReader; \$song = ProFileGenerator::generate('TranslateTest', [['name'=>'V1','color'=>[0,0,0,1],'slides'=>[['text'=>'Amazing Grace','translation'=>'Erstaunliche Gnade']]]], [['name'=>'normal','groupNames'=>['V1']]] ); ProFileWriter::write('/tmp/translate-test.pro', \$song); \$reader = new ProFileReader('/tmp/translate-test.pro'); \$slides = \$reader->getSlides(); \$elements = \$slides[0]->getAllElements(); echo 'count: ' . count(\$elements) . PHP_EOL; echo 'name0: ' . \$elements[0]->getName() . PHP_EOL; echo 'name1: ' . \$elements[1]->getName() . PHP_EOL; // Check heights differ \$b0 = \$elements[0]->getRawElement()->getBounds(); \$b1 = \$elements[1]->getRawElement()->getBounds(); echo 'height0: ' . round(\$b0->getSize()->getHeight(), 1) . PHP_EOL; echo 'height1: ' . round(\$b1->getSize()->getHeight(), 1) . PHP_EOL; " 2. Assert: count=2, name0=Orginal, name1=Deutsch, height0≈182.9, height1≈113.9 Expected Result: Two textboxes with correct names and different heights matching TestTranslated.pro reference Failure Indicators: Same height for both, wrong names, or only 1 element Evidence: .sisyphus/evidence/task-6-translated-bounds.txt Scenario: Non-translated slide keeps full-size single textbox Tool: Bash Steps: 1. Generate song without translation 2. Read back, verify 1 element with height ≈ 880 Expected Result: Single element at origin(150,100) size(1620×880) Evidence: .sisyphus/evidence/task-6-single-bounds.txt ``` **Commit**: YES - Message: `feat(pro): correct translated textbox positioning` - Files: `vendor/propresenter/parser/src/ProFileGenerator.php`, `vendor/propresenter/parser/tests/ProFileGeneratorTest.php` - Pre-commit: `php artisan test` - [x] 7. Settings Infrastructure + Macro on COPYRIGHT Slide **✅ DECISION RESOLVED**: The COPYRIGHT slide is a slide within a group named "COPYRIGHT" in the song data. The macro should be attached to slides in the COPYRIGHT group. The macro settings (name, UUID, collection) are configurable via the Settings page. **What to do**: - **Migration**: Create `database/migrations/xxxx_create_settings_table.php`: - Schema: `id`, `key` (string, unique index), `value` (text, nullable), `created_at`, `updated_at` - **Model**: Create `app/Models/Setting.php`: - Static helper: `Setting::get($key, $default = null)` — returns value for key - Static helper: `Setting::set($key, $value)` — upserts key-value pair - **Controller**: Create `app/Http/Controllers/SettingsController.php`: - `index()` — Inertia render `Settings` page with current macro settings - `update(Request $request)` — Validate and save macro fields - **Vue Page**: Create `resources/js/Pages/Settings.vue`: - Form with 4 fields: Macro-Name (text), Macro-UUID (text), Collection-Name (text, default "--MAIN--"), Collection-UUID (text, default "8D02FC57-83F8-4042-9B90-81C229728426") - Auto-save on blur (immediate persistence pattern) - German labels: "Makro-Name", "Makro-UUID", "Collection-Name", "Collection-UUID" - Help text explaining these values come from ProPresenter's macro configuration - **Navigation**: Add "Einstellungen" link in `resources/js/Layouts/AuthenticatedLayout.vue` (after "API-Log" in nav, lines 95-126) - **Routes**: Add `Route::get('/settings', [SettingsController::class, 'index'])->name('settings.index')` and `Route::patch('/settings', [SettingsController::class, 'update'])->name('settings.update')` in `routes/web.php` - **Shared Props**: In `app/Http/Middleware/HandleInertiaRequests.php`, add macro settings to shared data so ProExportService can access them - **Integration**: In `app/Services/ProExportService.php`, read macro settings from `Setting::get()` and inject into slides in groups named 'COPYRIGHT' as `'macro' => [...]` array. If macro settings are empty/null, skip macro injection (no error). If no COPYRIGHT group exists in the song, no macro is added (no error). **Must NOT do**: - Do NOT build a generic settings framework — ONLY the 4 macro fields - Do NOT move existing .env-based configs (song_request email) to the settings table - Do NOT add settings to every page via shared props unless needed **Recommended Agent Profile**: - **Category**: `unspecified-high` - Reason: Full-stack feature — migration, model, controller, Vue page, navigation, integration - **Skills**: [`frontend-ui-ux`] - `frontend-ui-ux`: Settings page design, form layout, auto-save UX **Parallelization**: - **Can Run In Parallel**: YES (with Task 8) - **Parallel Group**: Wave 3 (with Task 8) - **Blocks**: Nothing - **Blocked By**: Tasks 2, 5, 6 (generator must be stable) + user decision on COPYRIGHT slide **References**: **Pattern References**: - `app/Http/Controllers/ServiceController.php` — Controller pattern with Inertia rendering - `resources/js/Pages/Songs/Index.vue` — Existing page pattern (Inertia props, layout usage) - `resources/js/Layouts/AuthenticatedLayout.vue:95-126` — Navigation links to extend - `app/Http/Middleware/HandleInertiaRequests.php` — Shared props pattern - `config/services.php:38-40` — Existing config pattern for song_request email (context, NOT to migrate) **API/Type References**: - `vendor/propresenter/parser/src/ProFileGenerator.php:180-182` — Where macro data is read from slideData - `vendor/propresenter/parser/src/ProFileGenerator.php:206-227` — buildMacroAction() data structure - `vendor/propresenter/parser/tests/ProFileGeneratorTest.php:334-339` — Macro test data example **WHY Each Reference Matters**: - ServiceController: Copy the Inertia render pattern for the new SettingsController - Songs/Index.vue: Template for a full page with layout, shows Inertia props pattern - AuthenticatedLayout: Exact file and lines where nav link must be added - ProFileGenerator macro lines: Shows exact data structure the macro config must produce **Acceptance Criteria**: - [ ] `php artisan migrate` — settings table created - [ ] `php artisan test` — all tests pass - [ ] `npm run build` — succeeds - [ ] Settings page accessible at /settings with macro form - [ ] Macro values persist on page reload **QA Scenarios (MANDATORY):** ``` Scenario: Settings page accessible and saves macro config Tool: Playwright (playwright skill) Preconditions: App running, logged in, migration run Steps: 1. Navigate to http://cts-work.test/settings 2. Assert: page loads with "Einstellungen" heading 3. Fill "Makro-Name" with "Test Macro" 4. Fill "Makro-UUID" with "11111111-2222-3333-4444-555555555555" 5. Tab out (blur) to trigger auto-save 6. Reload page 7. Assert: "Makro-Name" field contains "Test Macro" 8. Assert: "Makro-UUID" field contains the UUID 9. Take screenshot Expected Result: Settings saved to DB and persist across reloads Failure Indicators: Fields empty after reload, save fails Evidence: .sisyphus/evidence/task-7-settings-save.png Scenario: Navigation link present Tool: Playwright (playwright skill) Steps: 1. Navigate to any page 2. Assert: "Einstellungen" link visible in top navigation 3. Click it 4. Assert: URL is /settings Expected Result: Settings accessible from navigation Evidence: .sisyphus/evidence/task-7-nav-link.png Scenario: Empty macro config doesn't break export Tool: Bash Steps: 1. Ensure settings table has no macro values 2. Run: curl -s http://cts-work.test/api/songs/1/download-pro -o /tmp/no-macro.pro 3. Assert: download succeeds (HTTP 200) Expected Result: Export works without macro — no error when settings empty Evidence: .sisyphus/evidence/task-7-no-macro-export.txt ``` **Commit**: YES - Message: `feat(settings): add global settings UI with macro configuration` - Files: `database/migrations/xxxx_create_settings_table.php`, `app/Models/Setting.php`, `app/Http/Controllers/SettingsController.php`, `resources/js/Pages/Settings.vue`, `resources/js/Layouts/AuthenticatedLayout.vue`, `routes/web.php`, `app/Services/ProExportService.php` - Pre-commit: `php artisan test && npm run build` - [x] 8. .probundle Export for Service Slide Blocks **✅ DECISION RESOLVED**: .probundle is a flat ZIP containing one `.pro` file + image files at root level. Structure: `{block_name}.pro` + `image1.jpg`, `image2.jpg`, etc. The .pro file references images via media actions. Each slide in the .pro is an image slide (no text, just media reference). Applies to blocks: information, moderation, sermon. **What to do**: - **Service**: Create `app/Services/ProBundleExportService.php`: - Method: `generateBundle(Service $service, string $blockType): string` — returns path to generated .probundle file - For each slide in the block: - Get the stored image file path - Create a ProFileGenerator slide entry with media action: `'media' => 'file:///' . $absolutePath`, `'format' => 'JPG'` - Text can be empty or the slide's original filename - Use ProFileGenerator::generateAndWrite() to create the .pro file inside a temp dir - ZIP the .pro file + all image files into a .probundle (extension is `.probundle`, format is ZIP) - Return the .probundle path - **Controller**: Add `downloadBundle(Service $service, string $blockType)` method to `ServiceController.php`: - Validate blockType is one of: information, moderation, sermon - Call ProBundleExportService - Return BinaryFileResponse with content-type `application/zip` and `.probundle` extension - **Route**: Add `Route::get('/services/{service}/download-bundle/{blockType}', [ServiceController::class, 'downloadBundle'])->name('services.download-bundle')` in `routes/web.php` - **UI**: In each block component (InformationBlock, ModerationBlock, SermonBlock), add a ".probundle herunterladen" download button/link - **Tests**: Add test for bundle generation — verify ZIP contains .pro file + image files **Must NOT do**: - Do NOT change existing slide storage or upload logic - Do NOT modify ProFileGenerator's core behavior for this - Do NOT include slides from other blocks in a block's bundle - Do NOT require finalization for bundle download (any time, independent of finalize state) **Recommended Agent Profile**: - **Category**: `deep` - Reason: New service with ZIP handling, file system operations, integration across multiple layers - **Skills**: `[]` **Parallelization**: - **Can Run In Parallel**: YES (with Task 7) - **Parallel Group**: Wave 3 (with Task 7) - **Blocks**: Nothing - **Blocked By**: Tasks 2, 5, 6 (generator must be stable) + user decision on .probundle format **References**: **Pattern References**: - `app/Services/ProExportService.php` — Existing export service pattern to follow (temp file creation, generator call, return path) - `app/Http/Controllers/ServiceController.php:269-289` — `download()` method pattern for BinaryFileResponse - `vendor/propresenter/parser/src/ProFileGenerator.php:180-195` — Media action support: `if (isset($slideData['media'])) { $actions[] = self::buildMediaAction(...); }` **API/Type References**: - `app/Models/Slide.php` — has `stored_filename`, `original_name`, `type` (information/moderation/sermon) - `app/Models/Service.php` — has relationships to slides - PHP `ZipArchive` class — for creating .probundle (ZIP format) **External References**: - `/Users/thorsten/AI/propresenter-work/ref/TestMitBildernUndMakro.pro` — Reference for .pro files with image/media actions (1.9KB) **WHY Each Reference Matters**: - ProExportService: Pattern for temp file handling, generator invocation — copy this structure - ServiceController download: Exact pattern for returning file downloads — reuse for .probundle - ProFileGenerator media action: Shows how to create image slides — need 'media' + 'format' in slide data - Slide model: Need stored_filename to locate actual image files on disk for ZIP inclusion **Acceptance Criteria**: - [ ] `php artisan test` — all tests pass (including new bundle test) - [ ] `npm run build` — succeeds - [ ] New test: bundle contains .pro file + correct number of images - [ ] Download button visible on each block **QA Scenarios (MANDATORY):** ``` Scenario: Download .probundle for information block Tool: Bash Preconditions: Service has uploaded information slides Steps: 1. Run: curl -s -o /tmp/info-bundle.probundle http://cts-work.test/services/1/download-bundle/information --cookie "session_cookie" 2. Run: unzip -l /tmp/info-bundle.probundle 3. Assert: ZIP contains exactly 1 .pro file + N image files (matching slide count) 4. Assert: .pro file name contains block type Expected Result: Valid ZIP file with .pro + images Failure Indicators: Not a valid ZIP, missing .pro file, wrong image count Evidence: .sisyphus/evidence/task-8-bundle-contents.txt Scenario: .probundle download button visible in UI Tool: Playwright (playwright skill) Preconditions: Logged in, service has slides Steps: 1. Navigate to service Edit page 2. Expand "Informationen" block 3. Assert: ".probundle herunterladen" button/link visible 4. Take screenshot Expected Result: Download button present in each block that has slides Evidence: .sisyphus/evidence/task-8-bundle-button.png Scenario: Invalid block type returns error Tool: Bash Steps: 1. Run: curl -s -w "%{http_code}" http://cts-work.test/services/1/download-bundle/invalid 2. Assert: HTTP 422 or 404 Expected Result: Invalid block type rejected gracefully Evidence: .sisyphus/evidence/task-8-invalid-block.txt ``` **Commit**: YES - Message: `feat(export): add probundle export for service slide blocks` - Files: `app/Services/ProBundleExportService.php`, `app/Http/Controllers/ServiceController.php`, `routes/web.php`, `resources/js/Components/Blocks/InformationBlock.vue`, `resources/js/Components/Blocks/ModerationBlock.vue`, `resources/js/Components/Blocks/SermonBlock.vue` - Pre-commit: `php artisan test && npm run build` --- ## Final Verification Wave (MANDATORY — after ALL implementation tasks) > 4 review agents run in PARALLEL. ALL must APPROVE. Rejection → fix → re-run. - [x] F1. **Plan Compliance Audit** — `oracle` Read the plan end-to-end. For each "Must Have": verify implementation exists (read file, run command). For each "Must NOT Have": search codebase for forbidden patterns — reject with file:line if found. Check evidence files exist in .sisyphus/evidence/. Compare deliverables against plan. Output: `Must Have [N/N] | Must NOT Have [N/N] | Tasks [N/N] | VERDICT: APPROVE/REJECT` - [x] F2. **Code Quality Review** — `unspecified-high` Run `php artisan test`, `npm run build`. Review all changed files for: `as any`/`@ts-ignore`, empty catches, console.log in prod, commented-out code, unused imports. Check AI slop: excessive comments, over-abstraction, generic names. Output: `Build [PASS/FAIL] | Tests [N pass/N fail] | Files [N clean/N issues] | VERDICT` - [x] F3. **Real QA Walkthrough** — `unspecified-high` (+ `playwright` skill) Start from clean state. Execute EVERY QA scenario from EVERY task — follow exact steps, capture evidence. Test cross-task integration. Test edge cases: empty state, invalid input, rapid actions. Save to `.sisyphus/evidence/final-qa/`. Output: `Scenarios [N/N pass] | Integration [N/N] | Edge Cases [N tested] | VERDICT` - [x] F4. **Scope Fidelity Check** — `deep` For each task: read "What to do", read actual diff (git log/diff). Verify 1:1 — everything in spec was built (no missing), nothing beyond spec was built (no creep). Check "Must NOT do" compliance. Flag unaccounted changes. Output: `Tasks [N/N compliant] | Contamination [CLEAN/N issues] | Unaccounted [CLEAN/N files] | VERDICT` --- ## Commit Strategy - **Wave 1**: 4 separate commits (one per task) - `fix(ui): add drag highlight to slide grid` — SlideGrid.vue - `refactor(pro): remove visual attributes from slide generation` — ProFileGenerator.php - `feat(songs): auto-select default arrangement on song match` — SongMatchingService.php - `feat(services): add finalize and download buttons to edit page` — Edit.vue - **Wave 2**: 2 commits - `feat(pro): select normal arrangement by default in generator` — ProFileGenerator.php - `feat(pro): correct translated textbox positioning` — ProFileGenerator.php - **Wave 3**: 2 commits - `feat(settings): add global settings UI with macro configuration` — multiple files - `feat(export): add probundle export for service slide blocks` — multiple files --- ## Success Criteria ### Verification Commands ```bash cd /Users/thorsten/AI/cts-work && php artisan test # Expected: 200+ tests, 0 failures cd /Users/thorsten/AI/cts-work && npm run build # Expected: build succeeds ``` ### Final Checklist - [x] All "Must Have" present - [x] All "Must NOT Have" absent - [x] All tests pass (Pest + PHPUnit vendor) - [x] Build succeeds - [x] All QA evidence captured