pp-planer/.sisyphus/plans/pro-gen-and-ui-fixes.md

52 KiB
Raw Blame History

.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

  • cd /Users/thorsten/AI/cts-work && php artisan test — all tests pass (198+ tests)
  • cd /Users/thorsten/AI/cts-work && npm run build — build succeeds
  • Generated .pro files have no fill/stroke/shadow/feather/textScroller attributes
  • Translated song export has two textboxes with different bounds matching TestTranslated.pro
  • 'normal' arrangement selected by default in generated .pro files
  • Settings page accessible with macro configuration
  • .probundle download works for information/moderation/sermon blocks
  • Drag highlight visible when reordering slides
  • 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

  • 1. Slide Grid Drag Highlight

    What to do:

    • In resources/js/Components/SlideGrid.vue (line 207-215), add drag highlight props to the <VueDraggable> 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
  • 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-281buildSlideElement() — remove setFill/setStroke/setShadow/setFeather calls
    • vendor/propresenter/parser/src/ProFileGenerator.php:~170buildCue() — 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
  • 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-38autoMatch() — insert arrangement lookup after song_id assignment
    • app/Services/SongMatchingService.php:47-54manualAssign() — 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.phparrangements() 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
  • 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-95finalizeService() — POST with confirmation flow, handle needs_confirmation JSON response
    • resources/js/Pages/Services/Index.vue:97-119confirmFinalize() — Confirm with warnings
    • resources/js/Pages/Services/Index.vue:127-132reopenService() — 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-57showToast() utility

    API/Type References:

    • routes/web.php:55POST /services/{service}/finalize named services.finalize
    • routes/web.php:56POST /services/{service}/reopen named services.reopen
    • routes/web.php:58GET /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
  • 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:
        $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
  • 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-298buildBounds() — current single-bounds method to use as template
    • vendor/propresenter/parser/src/ProFileGenerator.php:151-192buildCue() — where slide elements are created with names "Orginal" / "Deutsch"
    • vendor/propresenter/parser/src/ProFileGenerator.php:257-281buildSlideElement() — 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
  • 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
  • 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-289download() 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.

  • F1. Plan Compliance Auditoracle 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

  • F2. Code Quality Reviewunspecified-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

  • F3. Real QA Walkthroughunspecified-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

  • F4. Scope Fidelity Checkdeep 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

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

  • All "Must Have" present
  • All "Must NOT Have" absent
  • All tests pass (Pest + PHPUnit vendor)
  • Build succeeds
  • All QA evidence captured