# Solution Design Document

## Validation Checklist

- [x] All required sections are complete
- [x] No [NEEDS CLARIFICATION] markers remain
- [x] All context sources are listed with relevance ratings
- [x] Project commands are discovered from actual project files
- [x] Constraints → Strategy → Design → Implementation path is logical
- [x] Architecture pattern is clearly stated with rationale
- [x] Every component in diagram has directory mapping
- [x] Every interface has specification
- [x] Error handling covers all error types
- [x] Quality requirements are specific and measurable
- [x] Every quality requirement has test coverage
- [x] **All architecture decisions confirmed by user** ✅ 7/7 ADRs confirmed
- [x] Component names consistent across diagrams
- [x] A developer could implement from this design

---

## Constraints

**CON-1: Technology Stack**
- PHP 8.x with Slim 2.6.2 framework
- Twig 1.44.8 templating
- MySQL with multi-store database pattern (kiosk_{typeNum})
- Redis for caching
- Browser-based displays (Chrome, Firefox, Edge - no native apps)
- 16:9 landscape aspect ratio only (no portrait mode)

**CON-2: Existing System Compatibility**
- Must maintain backward compatibility with existing dsSlides/dsLoop tables
- Existing `/typeNum/digitalsign` URLs must continue working
- Corporate and Hipbone slide sources must remain accessible
- Event integration (SignageAdapter) must continue functioning
- Ably real-time infrastructure must be preserved

**CON-3: Performance & Reliability**
- Display player must work offline (offline-first architecture)
- Queue widgets must update in real-time via Ably (< 5 second latency)
- Media caching must support entire playlist locally
- Page transitions must be smooth (60fps target)

**CON-4: External Dependencies**
- Weather API (provider TBD - OpenWeatherMap or WeatherAPI)
- Social Media APIs (Meta Graph API for FB/IG, TikTok API)
- Canva Connect API (requires Canva approval process)
- Ably for real-time messaging (existing infrastructure)

**CON-5: Security & Privacy**
- Social media content requires store-level approval before display
- QR code tracking must not expose PII
- Media uploads must use randomized filenames (existing pattern)
- API endpoints require authentication per existing patterns

## Implementation Context

### Required Context Sources

```yaml
# Internal documentation and patterns
- doc: docs/features/digital-signage-overview.md
  relevance: HIGH
  why: "Current system architecture and business rules"

- doc: docs/features/digital-signage-business-rules.md
  relevance: HIGH
  why: "Existing business rules that must be preserved"

- doc: docs/features/digital-signage-technical-patterns.md
  relevance: HIGH
  why: "Technical patterns to follow/extend"

- doc: docs/patterns/psr4-autoloading.md
  relevance: MEDIUM
  why: "Namespace and class organization standards"

- doc: docs/patterns/namespace-structure.md
  relevance: MEDIUM
  why: "Where new classes should be placed"

# Source code - Core signage system
- file: userfrosting/src/BuyerKiosk/DigitalSign/StoreLoop.php
  relevance: HIGH
  why: "Current playlist/loop management - will be extended"

- file: userfrosting/src/BuyerKiosk/DigitalSign/Slide.php
  relevance: HIGH
  why: "Slide entity model - base for new Page model"

- file: userfrosting/src/BuyerKiosk/DigitalSign/Constants.php
  relevance: HIGH
  why: "Enumerated values pattern to extend"

- file: userfrosting/src/BuyerKiosk/DigitalSign/Controllers/LoopController.php
  relevance: HIGH
  why: "Playlist management endpoints - will be extended"

# Source code - Event integration
- file: userfrosting/src/BuyerKiosk/EventManagement/Adapters/SignageAdapter.php
  relevance: MEDIUM
  why: "Event-signage integration to preserve"

# Frontend display
- file: userfrosting/templates/themes/default/ds/loop.html
  relevance: HIGH
  why: "Current display player - needs major refactor"

- file: userfrosting/templates/themes/default/ds/snips/queue.html
  relevance: HIGH
  why: "Queue display logic to extract into widget"

# External APIs
- url: https://developers.meta.com/docs/marketing-api/
  relevance: MEDIUM
  sections: [Page Posts, Instagram Media]
  why: "Social media content fetching"

- url: https://www.canva.dev/docs/connect/
  relevance: MEDIUM
  sections: [Asset Upload, Template Embedding]
  why: "Canva design integration"

- url: https://openweathermap.org/api
  relevance: LOW
  why: "Weather data provider option"
```

### Implementation Boundaries

- **Must Preserve**:
  - Existing dsSlides/dsLoop table schemas (add columns, don't remove)
  - `/typeNum/digitalsign` URL pattern for displays
  - Corporate/Hipbone slide access via slideUploader field
  - Event integration via SignageAdapter
  - Ably channel pattern `{typeNum}` for real-time updates
  - Tag system (SlideTagService) recently implemented

- **Can Modify**:
  - Display player template (loop.html) - major refactor allowed
  - StoreLoop class - can extend with new methods
  - Admin templates for signage management
  - Add new tables for widgets, pages, zones
  - Add new API endpoints

- **Must Not Touch**:
  - Core queue system (BuyQueue, queue JSON endpoints)
  - Authentication system (checkAccess, checkStoreGroup)
  - Store configuration (Store.php)
  - FFMpeg video processing (UploadHandler)

### External Interfaces

#### System Context Diagram

```mermaid
graph TB
    subgraph Users
        SM[Store Manager]
        RM[Regional Manager]
        CM[Corporate Marketing]
    end

    subgraph BuyerKiosk Platform
        Admin[Admin Interface]
        API[Signage API]
        Player[Display Player]
        Scheduler[Schedule Processor]
    end

    subgraph External Services
        Ably[Ably Realtime]
        Weather[Weather API]
        Meta[Meta Graph API]
        TikTok[TikTok API]
        Canva[Canva Connect]
    end

    subgraph Data Stores
        StoreDB[(Store DB)]
        GlobalDB[(Global DB)]
        Redis[(Redis Cache)]
        MediaStore[Media Storage]
    end

    SM --> Admin
    RM --> Admin
    CM --> Admin

    Admin --> API
    API --> StoreDB
    API --> GlobalDB
    API --> Redis
    API --> MediaStore

    Player --> API
    Player --> Ably
    Player --> MediaStore

    Scheduler --> StoreDB
    Scheduler --> GlobalDB
    Scheduler --> Ably

    API --> Weather
    API --> Meta
    API --> TikTok
    API --> Canva
```

#### Interface Specifications

```yaml
# Inbound Interfaces
inbound:
  - name: "Admin Web Interface"
    type: HTTP/HTTPS
    format: HTML + AJAX
    authentication: Session-based (UserFrosting)
    data_flow: "CRUD for zones, pages, widgets, playlists"

  - name: "Display Player"
    type: HTTP/HTTPS
    format: JSON API
    authentication: None (public URLs by design)
    data_flow: "Fetch playlist, media, real-time updates"

  - name: "Ably Webhooks"
    type: HTTPS
    format: JSON
    authentication: Ably signature
    data_flow: "Real-time event notifications"

# Outbound Interfaces
outbound:
  - name: "Ably Realtime"
    type: WebSocket
    format: JSON messages
    authentication: API Key
    doc: existing Ably integration
    data_flow: "Push playlist updates, queue changes"
    criticality: HIGH

  - name: "Weather API"
    type: HTTPS
    format: REST/JSON
    authentication: API Key
    doc: Provider TBD
    data_flow: "Current conditions, forecast, alerts"
    criticality: MEDIUM

  - name: "Meta Graph API"
    type: HTTPS
    format: REST/JSON
    authentication: OAuth2 (Page Access Token)
    data_flow: "Fetch page posts, Instagram media"
    criticality: MEDIUM

  - name: "TikTok API"
    type: HTTPS
    format: REST/JSON
    authentication: OAuth2
    data_flow: "Fetch video content"
    criticality: LOW

  - name: "Canva Connect"
    type: HTTPS
    format: REST/JSON
    authentication: OAuth2
    data_flow: "Import designs, template library"
    criticality: MEDIUM

# Data Interfaces
data:
  - name: "Store Database"
    type: MySQL
    connection: PDO via dbConnectByName()
    data_flow: "Pages, widgets, playlists per store"

  - name: "Global Database"
    type: MySQL
    connection: PDO via kiosk_buykiosk
    data_flow: "Corporate templates, zones, social approvals"

  - name: "Redis Cache"
    type: Redis
    connection: Predis client
    data_flow: "Weather cache, social feed cache, session data"

  - name: "Media Storage"
    type: Filesystem
    connection: Direct file access
    path: public_html/upload/digitalSign/
    data_flow: "Images, videos, thumbnails"
```

### Project Commands

```bash
# Environment Setup
cd userfrosting && composer install
# Environment Variables: See .env.example

# Testing Commands
./test.sh                           # Run all tests
./test.sh --testsuite unit          # Run unit tests only
./test.sh --testsuite integration   # Run integration tests only
./test.sh --coverage                # Run with coverage report
./test.sh --stan                    # Run tests + PHPStan analysis

# Code Quality Commands
cd userfrosting && ./vendor/bin/phpstan analyse

# CSS Build Commands
php userfrosting/conductor build-css           # Development build
php userfrosting/conductor build-css --minify  # Production build
php userfrosting/conductor build-css --watch   # Watch mode

# Database Migrations
php userfrosting/conductor run

# Deployment
./deploy.sh                         # Test + deploy
```

## Solution Strategy

### Architecture Pattern: Layered Modular Architecture with Widget Component System

**Pattern Description:**
The solution uses a layered architecture within the existing BuyerKiosk framework, introducing a **Widget Component System** that allows dynamic composition of display pages. Each widget is a self-contained unit with its own data fetching, rendering, and configuration.

```
┌─────────────────────────────────────────────────────────────────┐
│                     PRESENTATION LAYER                          │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Admin UI     │  │ Display      │  │ Widget Renderers     │  │
│  │ (Twig + JS)  │  │ Player (JS)  │  │ (Twig Components)    │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                      API LAYER                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Zone API     │  │ Page API     │  │ Widget API           │  │
│  │ Playlist API │  │ Media API    │  │ Social/Weather APIs  │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                    SERVICE LAYER                                │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ ZoneService  │  │ PageService  │  │ WidgetRegistry       │  │
│  │ Playlist     │  │ LayoutEngine │  │ ExternalDataService  │  │
│  │ Service      │  │              │  │ (Weather, Social)    │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                   DOMAIN LAYER                                  │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Zone         │  │ Page         │  │ Widget (Abstract)    │  │
│  │ Playlist     │  │ Layout       │  │ - QueueWidget        │  │
│  │ PlaylistItem │  │ LayoutZone   │  │ - MediaWidget        │  │
│  │              │  │              │  │ - WeatherWidget      │  │
│  │              │  │              │  │ - SocialWidget       │  │
│  │              │  │              │  │ - QRCodeWidget       │  │
│  │              │  │              │  │ - TextWidget         │  │
│  │              │  │              │  │ - EventWidget        │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
├─────────────────────────────────────────────────────────────────┤
│                 INFRASTRUCTURE LAYER                            │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────────────┐  │
│  │ Repositories │  │ External API │  │ Cache (Redis)        │  │
│  │ (MySQL)      │  │ Clients      │  │ Ably Client          │  │
│  └──────────────┘  └──────────────┘  └──────────────────────┘  │
└─────────────────────────────────────────────────────────────────┘
```

**Integration Approach:**
- Extend existing DigitalSign namespace with new components
- New tables alongside existing dsSlides/dsLoop (additive, not replacement)
- Existing slides auto-migrate to "Full Screen Media" pages
- Display player refactored to support both legacy loop and new page/widget system

**Justification:**
1. **Backward Compatibility**: Additive approach preserves existing functionality
2. **Incremental Adoption**: Stores can adopt new features gradually
3. **Widget Isolation**: Each widget is independently testable and deployable
4. **Familiar Patterns**: Follows existing BuyerKiosk patterns (Services, Controllers, Repositories)

**Key Decisions:**
- **Widget Registry Pattern**: Central registry for widget types, enabling future extensibility
- **Layout Templates over Drag-Drop**: Pre-defined layouts reduce complexity, per PRD decision
- **Service Worker for Offline**: Progressive enhancement for display player
- **Redis Caching for External Data**: Weather/social data cached to reduce API calls

## Building Block View

### Components

```mermaid
graph TB
    subgraph Admin["Admin Interface"]
        ZoneManager[Zone Manager]
        PageEditor[Page Editor]
        PlaylistManager[Playlist Manager]
        MediaLibrary[Media Library]
        SocialApproval[Social Approval Queue]
        Analytics[Analytics Dashboard]
    end

    subgraph API["API Controllers"]
        ZoneController[ZoneController]
        PageController[PageController]
        WidgetController[WidgetController]
        PlaylistController[PlaylistController]
        MediaController[MediaController]
        SocialController[SocialController]
        WeatherController[WeatherController]
        AnalyticsController[AnalyticsController]
    end

    subgraph Services["Service Layer"]
        ZoneService[ZoneService]
        PageService[PageService]
        WidgetRegistry[WidgetRegistry]
        PlaylistService[PlaylistService]
        MediaService[MediaService]
        SocialService[SocialService]
        WeatherService[WeatherService]
        QRTrackingService[QRTrackingService]
        AnalyticsService[AnalyticsService]
    end

    subgraph Widgets["Widget Types"]
        QueueWidgets[Queue Widgets]
        MediaWidget[Media Widget]
        WeatherWidgets[Weather Widgets]
        SocialWidgets[Social Widgets]
        QRWidget[QR Code Widget]
        TextWidget[Text Widget]
        EventWidget[Event Widget]
    end

    subgraph Display["Display Player"]
        PlayerCore[Player Core]
        LayoutRenderer[Layout Renderer]
        WidgetRenderer[Widget Renderer]
        CacheManager[Cache Manager]
        AblySubscriber[Ably Subscriber]
    end

    subgraph Data["Data Layer"]
        ZoneRepo[ZoneRepository]
        PageRepo[PageRepository]
        WidgetRepo[WidgetRepository]
        PlaylistRepo[PlaylistRepository]
        SocialRepo[SocialRepository]
        AnalyticsRepo[AnalyticsRepository]
    end

    Admin --> API
    API --> Services
    Services --> Widgets
    Services --> Data
    Display --> API
    Display --> Widgets
```

### Directory Map

```
userfrosting/
├── src/BuyerKiosk/
│   └── DigitalSign/
│       ├── Constants.php                    # MODIFY: Add new widget types, zone types
│       ├── Slide.php                        # PRESERVE: Existing slide entity
│       ├── CorpSlide.php                    # PRESERVE: Corporate slides
│       ├── LoopItem.php                     # PRESERVE: Legacy loop items
│       ├── StoreLoop.php                    # PRESERVE: Legacy loop management
│       │
│       ├── Domain/                          # NEW: Domain entities
│       │   ├── Zone.php                     # NEW: Display zone entity
│       │   ├── DisplayPairing.php           # NEW: Device pairing code entity
│       │   ├── Page.php                     # NEW: Page entity (replaces slide concept)
│       │   ├── Layout.php                   # NEW: Layout template definition
│       │   ├── LayoutZone.php               # NEW: Zone within a layout
│       │   ├── Playlist.php                 # NEW: Zone playlist
│       │   ├── PlaylistItem.php             # NEW: Item in playlist
│       │   └── Widget/                      # NEW: Widget entities
│       │       ├── Widget.php               # NEW: Abstract widget base
│       │       ├── WidgetConfig.php         # NEW: Widget configuration value object
│       │       ├── QueueWidget.php          # NEW: Current queue widget
│       │       ├── CompletedQueueWidget.php # NEW: Completed queue widget
│       │       ├── WaitTimeWidget.php       # NEW: Wait time display widget
│       │       ├── MediaWidget.php          # NEW: Image/video widget
│       │       ├── WeatherWidget.php        # NEW: Weather display widget
│       │       ├── SocialFeedWidget.php     # NEW: Social feed widget
│       │       ├── SocialMentionsWidget.php # NEW: Customer mentions widget
│       │       ├── QRCodeWidget.php         # NEW: QR code with tracking
│       │       ├── TextWidget.php           # NEW: WYSIWYG text widget
│       │       └── EventWidget.php          # NEW: Upcoming events widget
│       │
│       ├── Services/                        # NEW/EXTEND: Business logic
│       │   ├── SlideTagService.php          # PRESERVE: Existing tag service
│       │   ├── ZoneService.php              # NEW: Zone CRUD and management
│       │   ├── PageService.php              # NEW: Page CRUD and composition
│       │   ├── DisplayPairingService.php    # NEW: Device pairing flow
│       │   ├── WidgetRegistry.php           # NEW: Widget type registry
│       │   ├── PlaylistService.php          # NEW: Playlist management
│       │   ├── LayoutService.php            # NEW: Layout template management
│       │   ├── MigrationService.php         # NEW: Legacy slide migration
│       │   ├── WeatherService.php           # NEW: Weather API integration
│       │   ├── SocialService.php            # NEW: Social media integration
│       │   ├── QRTrackingService.php        # NEW: QR scan analytics
│       │   ├── AnalyticsService.php         # NEW: Usage analytics
│       │   └── DisplayCacheService.php      # NEW: Offline cache management
│       │
│       ├── Repositories/                    # NEW: Data access
│       │   ├── ZoneRepository.php           # NEW: Zone persistence
│       │   ├── DisplayPairingRepository.php # NEW: Pairing code persistence
│       │   ├── PageRepository.php           # NEW: Page persistence
│       │   ├── WidgetRepository.php         # NEW: Widget persistence
│       │   ├── PlaylistRepository.php       # NEW: Playlist persistence
│       │   ├── SocialApprovalRepository.php # NEW: Social content approval
│       │   ├── QRCodeRepository.php         # NEW: QR code definitions
│       │   └── AnalyticsRepository.php      # NEW: Analytics data
│       │
│       ├── Controllers/                     # EXTEND: API endpoints
│       │   ├── LoopController.php           # PRESERVE: Legacy loop API
│       │   ├── UploadController.php         # PRESERVE: Media upload
│       │   ├── SlideController.php          # PRESERVE: Legacy slide API
│       │   ├── SlideScheduleController.php  # PRESERVE: Schedule processing
│       │   ├── ZoneController.php           # NEW: Zone management API
│       │   ├── PageController.php           # NEW: Page management API
│       │   ├── WidgetController.php         # NEW: Widget configuration API
│       │   ├── PlaylistController.php       # NEW: Playlist management API
│       │   ├── SocialController.php         # NEW: Social approval API
│       │   ├── WeatherController.php        # NEW: Weather data API
│       │   ├── QRController.php             # NEW: QR redirect & tracking
│       │   ├── AnalyticsController.php      # NEW: Analytics API
│       │   └── DisplayController.php        # NEW: Display player API
│       │
│       ├── ExternalClients/                 # NEW: Third-party integrations
│       │   ├── WeatherClient.php            # NEW: Weather API client
│       │   ├── MetaGraphClient.php          # NEW: Facebook/Instagram client
│       │   ├── TikTokClient.php             # NEW: TikTok API client
│       │   └── CanvaConnectClient.php       # NEW: Canva integration client
│       │
│       └── Models/                          # EXTEND: ActiveRecord models
│           ├── SlideTag.php                 # PRESERVE: Existing tag model
│           ├── Zone.php                     # NEW: Zone ActiveRecord
│           ├── DisplayPairing.php           # NEW: Display pairing ActiveRecord
│           ├── Page.php                     # NEW: Page ActiveRecord
│           ├── PageWidget.php               # NEW: Widget instance ActiveRecord
│           ├── Playlist.php                 # NEW: Playlist ActiveRecord
│           ├── PlaylistItem.php             # NEW: Playlist item ActiveRecord
│           ├── SocialPost.php               # NEW: Social post ActiveRecord
│           ├── QRCode.php                   # NEW: QR code definition ActiveRecord
│           └── QRScan.php                   # NEW: QR scan events ActiveRecord
│
├── routes/
│   └── groups/
│       ├── digitalsign.php                  # PRESERVE: Existing routes
│       └── digitalsign-v2.php               # NEW: Widget system routes
│
├── templates/themes/default/
│   ├── ds/
│   │   ├── loop.html                        # PRESERVE: Legacy display (deprecated)
│   │   ├── player.html                      # NEW: Modern widget-based player
│   │   ├── register.html                    # NEW: Device registration screen
│   │   ├── snips/                           # PRESERVE: Legacy snippets
│   │   │   ├── img.html
│   │   │   ├── vid.html
│   │   │   └── queue.html
│   │   ├── layouts/                         # NEW: Layout templates
│   │   │   ├── full-screen.html
│   │   │   ├── split-50-50.html
│   │   │   ├── main-sidebar-70-30.html
│   │   │   ├── sidebar-main-30-70.html
│   │   │   ├── three-column.html
│   │   │   └── header-two-column.html
│   │   └── widgets/                         # NEW: Widget render templates
│   │       ├── queue-current.html
│   │       ├── queue-completed.html
│   │       ├── queue-wait-time.html
│   │       ├── media-image.html
│   │       ├── media-video.html
│   │       ├── weather-simple.html
│   │       ├── weather-forecast.html
│   │       ├── weather-alerts.html
│   │       ├── social-feed.html
│   │       ├── social-mentions.html
│   │       ├── qr-code.html
│   │       ├── text-wysiwyg.html
│   │       └── events-upcoming.html
│   │
│   └── admin/
│       └── digitalsign/                     # NEW: Admin templates
│           ├── zones.html                   # NEW: Zone management
│           ├── pages.html                   # NEW: Page editor
│           ├── page-editor.html             # NEW: Visual page composer
│           ├── playlists.html               # NEW: Playlist management
│           ├── media-library.html           # NEW: Enhanced media library
│           ├── social-approval.html         # NEW: Social moderation
│           ├── analytics.html               # NEW: Analytics dashboard
│           └── partials/
│               ├── widget-picker.html       # NEW: Widget selection modal
│               ├── widget-config.html       # NEW: Widget configuration panel
│               └── layout-selector.html     # NEW: Layout template selector
│
├── migrations/input/                        # Database migrations
│   ├── 20251219_001_display_zones.json      # NEW: Zones table
│   ├── 20251219_002_pages.json              # NEW: Pages table
│   ├── 20251219_003_page_widgets.json       # NEW: Page widgets table
│   ├── 20251219_004_playlists.json          # NEW: Playlists table
│   ├── 20251219_005_playlist_items.json     # NEW: Playlist items table
│   ├── 20251219_006_social_posts.json       # NEW: Social posts table
│   ├── 20251219_007_qr_scans.json           # NEW: QR scan events table
│   ├── 20251219_008_display_analytics.json  # NEW: Display analytics table
│   ├── 20251219_009_layout_templates.json   # NEW: Layout templates table
│   ├── 20251219_010_display_pairings.json   # NEW: Device pairing codes table
│   └── 20251219_011_qr_codes.json           # NEW: QR code definitions table

public_html/
├── js/
│   ├── vendor/
│   │   └── alpine.min.js                    # NEW: Alpine.js 3.x (15KB) per ADR-6
│   └── digitalsign/
│       ├── slide-tags.js                    # PRESERVE: Existing tag JS
│       ├── player/                          # NEW: Display player (Alpine.js based)
│       │   ├── player-app.js                # NEW: Alpine.js app initialization
│       │   ├── stores/                      # NEW: Alpine.js reactive stores
│       │   │   ├── playlist-store.js        # NEW: Playlist state management
│       │   │   └── widget-store.js          # NEW: Widget data state
│       │   ├── components/                  # NEW: Alpine.js components (x-data)
│       │   │   ├── layout-renderer.js       # NEW: Layout rendering component
│       │   │   └── widget-wrapper.js        # NEW: Generic widget wrapper
│       │   ├── widgets/                     # NEW: Widget-specific Alpine components
│       │   │   ├── queue-widget.js          # NEW: Queue display logic
│       │   │   ├── weather-widget.js        # NEW: Weather display logic
│       │   │   ├── social-widget.js         # NEW: Social feed logic
│       │   │   └── media-widget.js          # NEW: Media playback logic
│       │   ├── services/                    # NEW: Player services
│       │   │   ├── cache-manager.js         # NEW: Service Worker cache management
│       │   │   └── ably-client.js           # NEW: Ably real-time connection
│       │   └── sw.js                        # NEW: Service Worker for offline
│       └── admin/                           # NEW: Admin JS
│           ├── zone-manager.js
│           ├── page-editor.js               # NEW: Includes zone resizer for hybrid layouts
│           ├── playlist-manager.js
│           ├── widget-configurator.js
│           └── analytics-dashboard.js
│
├── css/
│   └── digitalsign/
│       ├── slide-tags.css                   # PRESERVE: Existing tag CSS
│       ├── player/                          # NEW: Display player styles
│       │   ├── player.css
│       │   ├── layouts.css
│       │   └── widgets.css
│       └── admin/                           # NEW: Admin styles
│           ├── zone-manager.css
│           ├── page-editor.css
│           └── analytics.css
│
└── upload/digitalSign/                      # PRESERVE: Media storage
    ├── corp/
    ├── hipbone/
    ├── {typeNum}/
    └── templates/                           # NEW: Canva template storage
```

### Interface Specifications

#### Data Storage Changes

```yaml
# Store Database (kiosk_{typeNum})

Table: dsDisplayZones (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  name: VARCHAR(100) NOT NULL           # "Checkout", "Waiting Area", "Window"
  slug: VARCHAR(50) NOT NULL UNIQUE     # URL-safe identifier
  description: TEXT NULL
  isDefault: TINYINT(1) DEFAULT 0       # One default zone per store
  enabled: TINYINT(1) DEFAULT 1
  lastSeen: DATETIME NULL               # Last device check-in
  deviceId: VARCHAR(100) NULL           # Registered device identifier
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  INDEX: (enabled), (isDefault)

Table: dsDisplayPairings (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  zoneId: INT NOT NULL                  # FK to dsDisplayZones.id
  deviceId: VARCHAR(100) NOT NULL       # Client-generated UUID stored in localStorage
  pairingCode: VARCHAR(12) NOT NULL     # Human-friendly code shown on display
  expiresAt: DATETIME NOT NULL
  confirmedAt: DATETIME NULL
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  FOREIGN KEY (zoneId) REFERENCES dsDisplayZones(id) ON DELETE CASCADE
  UNIQUE KEY (pairingCode)
  INDEX: (zoneId), (deviceId), (expiresAt)

Table: dsPages (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  name: VARCHAR(255) NOT NULL           # User-friendly name
  layoutId: INT NOT NULL                # FK to dsLayoutTemplates.id
  layoutCustomization: JSON NULL        # Zone size overrides (per ADR-4 hybrid approach)
  themePreset: VARCHAR(50) DEFAULT 'light'  # 'light', 'dark', 'brand', 'minimal'
  enabled: TINYINT(1) DEFAULT 1
  legacySlideId: INT NULL               # FK to dsSlides.id if migrated
  legacySource: TINYINT(1) NULL         # 0=store, 1=corp, 2=hb if migrated
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  INDEX: (enabled), (layoutId), (legacySlideId)

# layoutCustomization example (user adjusted header to 25% height, left column to 60%):
# {
#   "zones": {
#     "header": {"height": 25},
#     "left": {"width": 60},
#     "right": {"width": 40}  # Auto-calculated
#   }
# }

Table: dsPageWidgets (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  pageId: INT NOT NULL                  # FK to dsPages.id
  layoutZoneId: VARCHAR(50) NOT NULL    # Zone identifier within layout (e.g., "header", "left", "right")
  widgetType: VARCHAR(50) NOT NULL      # 'queue-current', 'weather-simple', etc.
  config: JSON NOT NULL                 # Widget-specific configuration
  enabled: TINYINT(1) DEFAULT 1
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  FOREIGN KEY (pageId) REFERENCES dsPages(id) ON DELETE CASCADE
  UNIQUE KEY (pageId, layoutZoneId)     # Enforces PRD rule: exactly one widget per layout zone
  INDEX: (widgetType)

Table: dsPlaylists (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  zoneId: INT NOT NULL                  # FK to dsDisplayZones.id
  name: VARCHAR(100) NOT NULL
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  FOREIGN KEY (zoneId) REFERENCES dsDisplayZones(id) ON DELETE CASCADE
  UNIQUE KEY (zoneId)                   # v1: one playlist per zone (simplifies beyond MySQL partial-index limits)

Table: dsPlaylistItems (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  playlistId: INT NOT NULL              # FK to dsPlaylists.id
  pageId: INT NOT NULL                  # FK to dsPages.id
  position: INT NOT NULL                # Order in playlist
  duration: INT DEFAULT 10              # Seconds to display
  enabled: TINYINT(1) DEFAULT 1
  startDate: DATETIME NULL
  expireDate: DATETIME NULL
  eventId: INT UNSIGNED NULL            # FK to events.id (like dsLoop)
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  FOREIGN KEY (playlistId) REFERENCES dsPlaylists(id) ON DELETE CASCADE
  FOREIGN KEY (pageId) REFERENCES dsPages(id) ON DELETE CASCADE
  INDEX: (playlistId, position), (startDate), (expireDate)

Table: dsSocialPosts (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  platform: VARCHAR(20) NOT NULL        # 'facebook', 'instagram', 'tiktok'
  externalId: VARCHAR(100) NOT NULL     # Platform-specific post ID
  contentType: ENUM('image', 'video', 'text') NOT NULL
  mediaUrl: TEXT NULL                   # URL to media
  thumbnailUrl: TEXT NULL
  caption: TEXT NULL
  authorName: VARCHAR(100) NULL
  authorHandle: VARCHAR(100) NULL
  postedAt: DATETIME NOT NULL           # Original post time
  fetchedAt: DATETIME NOT NULL          # When we fetched it
  status: ENUM('pending', 'approved', 'rejected') DEFAULT 'pending'
  approvedBy: INT NULL                  # FK to users.id
  approvedAt: DATETIME NULL
  keepForever: TINYINT(1) DEFAULT 0     # Don't auto-expire
  expiresAt: DATETIME NULL              # Auto-expire date (30 days default)
  feedType: ENUM('store_feed', 'mentions') NOT NULL
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  UNIQUE KEY (platform, externalId)
  INDEX: (status), (feedType), (expiresAt)

Table: dsQRCodes (NEW)
  qrId: VARCHAR(50) PRIMARY KEY         # Used by /qr/:qrId
  destinationUrl: TEXT NOT NULL         # Final redirect destination
  pageId: INT NULL                      # FK to dsPages.id (for attribution)
  zoneId: INT NULL                      # FK to dsDisplayZones.id (for attribution)
  widgetId: INT NULL                    # FK to dsPageWidgets.id (optional)
  playlistItemId: INT NULL              # FK to dsPlaylistItems.id (optional)
  expiresAt: DATETIME NULL              # Optional rotation/cleanup
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  FOREIGN KEY (pageId) REFERENCES dsPages(id) ON DELETE SET NULL
  FOREIGN KEY (zoneId) REFERENCES dsDisplayZones(id) ON DELETE SET NULL
  FOREIGN KEY (widgetId) REFERENCES dsPageWidgets(id) ON DELETE SET NULL
  FOREIGN KEY (playlistItemId) REFERENCES dsPlaylistItems(id) ON DELETE SET NULL
  INDEX: (pageId), (zoneId), (widgetId), (playlistItemId), (expiresAt)

Table: dsQRScans (NEW)
  id: BIGINT PRIMARY KEY AUTO_INCREMENT
  qrId: VARCHAR(50) NOT NULL            # FK to dsQRCodes.qrId
  scannedAt: DATETIME NOT NULL
  FOREIGN KEY (qrId) REFERENCES dsQRCodes(qrId) ON DELETE CASCADE
  INDEX: (qrId, scannedAt)

Table: dsDisplayAnalytics (NEW)
  id: BIGINT PRIMARY KEY AUTO_INCREMENT
  zoneId: INT NOT NULL                  # FK to dsDisplayZones.id
  pageId: INT NOT NULL                  # FK to dsPages.id
  displayedAt: DATETIME NOT NULL
  durationMs: INT NOT NULL              # How long page was displayed
  deviceId: VARCHAR(100) NULL
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  FOREIGN KEY (zoneId) REFERENCES dsDisplayZones(id) ON DELETE CASCADE
  FOREIGN KEY (pageId) REFERENCES dsPages(id) ON DELETE CASCADE
  INDEX: (zoneId, displayedAt), (pageId, displayedAt)

# Global Database (kiosk_buykiosk)

Table: dsLayoutTemplates (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  name: VARCHAR(100) NOT NULL           # "Full Screen", "50/50 Split", etc.
  slug: VARCHAR(50) NOT NULL UNIQUE     # 'full-screen', 'split-50-50'
  description: TEXT NULL
  zones: JSON NOT NULL                  # Zone definitions with sizes
  thumbnail: VARCHAR(255) NULL          # Preview image path
  isSystem: TINYINT(1) DEFAULT 1        # System templates can't be deleted
  enabled: TINYINT(1) DEFAULT 1
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  INDEX: (enabled), (isSystem)

# Example zones JSON (with hybrid adjustability per ADR-4):
# {
#   "zones": [
#     {
#       "id": "full",
#       "name": "Full Screen",
#       "width": 100, "height": 100, "x": 0, "y": 0,
#       "minWidth": 100, "maxWidth": 100,  # Fixed width (full screen)
#       "minHeight": 100, "maxHeight": 100  # Fixed height (full screen)
#     }
#   ]
# }
# OR for "Header + 2 Columns" (with adjustable zones):
# {
#   "zones": [
#     {
#       "id": "header", "name": "Header",
#       "width": 100, "height": 20, "x": 0, "y": 0,
#       "minHeight": 10, "maxHeight": 30,  # Height adjustable 10-30%
#       "resizable": ["height"]  # Only height can be adjusted
#     },
#     {
#       "id": "left", "name": "Left Column",
#       "width": 50, "height": 80, "x": 0, "y": 20,
#       "minWidth": 30, "maxWidth": 70,  # Width adjustable 30-70%
#       "resizable": ["width"]  # Width adjustable, height follows header
#     },
#     {
#       "id": "right", "name": "Right Column",
#       "width": 50, "height": 80, "x": 50, "y": 20,
#       "linkedTo": "left",  # Width is inverse of left column
#       "resizable": []  # Auto-adjusts based on left column
#     }
#   ],
#   "constraints": {
#     "headerPlusColumns": true  # Columns height = 100 - header height
#   }
# }

Seed Data (Recommended):
  dsLayoutTemplates:
    - full-screen
    - split-50-50
    - main-sidebar-70-30
    - sidebar-main-30-70
    - three-column
    - header-two-column
  dsCorporateTemplates (system presets):
    - full-queue-page: header-two-column + {queue-wait-time, queue-current, queue-completed}

Table: dsCorporateTemplates (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  name: VARCHAR(255) NOT NULL
  pageData: JSON NOT NULL               # Full page definition (layout + widgets)
  themePreset: VARCHAR(50) DEFAULT 'brand'
  storeType: VARCHAR(10) NULL           # NULL = all stores
  createdBy: INT NOT NULL               # FK to users.id
  enabled: TINYINT(1) DEFAULT 1
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  INDEX: (storeType), (enabled)

Table: dsSocialConfig (NEW)
  id: INT PRIMARY KEY AUTO_INCREMENT
  typeNum: VARCHAR(10) NOT NULL         # Store identifier
  platform: VARCHAR(20) NOT NULL        # 'facebook', 'instagram', 'tiktok'
  accountId: VARCHAR(100) NOT NULL      # Platform account ID
  accessToken: TEXT NOT NULL            # Encrypted OAuth token
  refreshToken: TEXT NULL               # Encrypted refresh token
  tokenExpires: DATETIME NULL
  hashtags: JSON NULL                   # Hashtags to monitor for mentions
  enabled: TINYINT(1) DEFAULT 1
  lastFetched: DATETIME NULL
  created_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  updated_at: TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
  UNIQUE KEY (typeNum, platform)
  INDEX: (enabled)
```

#### Internal API Changes

```yaml
# Zone Management API
Endpoint: List Zones
  Method: GET
  Path: /api/:typeNum/signage/zones
  Response:
    success:
      zones: [
        {id, name, slug, description, isDefault, enabled, lastSeen, deviceId}
      ]

Endpoint: Create Zone
  Method: POST
  Path: /api/:typeNum/signage/zones
  Request:
    name: string (required, max 100)
    description: string (optional)
    isDefault: boolean (optional, default false)
  Response:
    success:
      zone: {id, name, slug, ...}

Endpoint: Update Zone
  Method: PUT
  Path: /api/:typeNum/signage/zones/:zoneId
  Request:
    name: string (optional)
    description: string (optional)
    enabled: boolean (optional)

Endpoint: Delete Zone
  Method: DELETE
  Path: /api/:typeNum/signage/zones/:zoneId
  Response:
    success: true
    error: "Cannot delete zone with active playlist"

# Page Management API
Endpoint: List Pages
  Method: GET
  Path: /api/:typeNum/signage/pages
  Query:
    layout: string (filter by layout slug)
    enabled: boolean
  Response:
    success:
      pages: [{id, name, layoutId, themePreset, enabled, widgets: [...]}]

Endpoint: Create Page
  Method: POST
  Path: /api/:typeNum/signage/pages
  Request:
    name: string (required)
    layoutId: int (required)
    themePreset: string (optional, default 'light')
    widgets: [{layoutZoneId, widgetType, config}] (optional)
  Response:
    success:
      page: {id, name, layoutId, widgets: [...]}

Endpoint: Update Page
  Method: PUT
  Path: /api/:typeNum/signage/pages/:pageId
  Request:
    name: string (optional)
    themePreset: string (optional)

Endpoint: Delete Page
  Method: DELETE
  Path: /api/:typeNum/signage/pages/:pageId

# Widget Management API
Endpoint: Add Widget to Page
  Method: POST
  Path: /api/:typeNum/signage/pages/:pageId/widgets
  Request:
    layoutZoneId: string (required, e.g., "header", "left")
    widgetType: string (required, e.g., "queue-current")
    config: object (widget-specific configuration)
  Response:
    success:
      widget: {id, pageId, layoutZoneId, widgetType, config}

Endpoint: Update Widget
  Method: PUT
  Path: /api/:typeNum/signage/widgets/:widgetId
  Request:
    config: object (widget-specific configuration)

Endpoint: Delete Widget
  Method: DELETE
  Path: /api/:typeNum/signage/widgets/:widgetId

Endpoint: Get Widget Types
  Method: GET
  Path: /api/signage/widget-types
  Response:
    success:
      types: [
        {
          type: "queue-current",
          name: "Current Queue",
          description: "Shows customers currently waiting",
          icon: "fa-users",
          configSchema: {...},
          compatibleZones: ["full", "large", "medium"]
        }
      ]

# Playlist Management API
Endpoint: Get Zone Playlist
  Method: GET
  Path: /api/:typeNum/signage/zones/:zoneId/playlist
  Response:
    success:
      playlist: {id, name, items: [{id, pageId, position, duration, enabled, page: {...}}]}

Endpoint: Add to Playlist
  Method: POST
  Path: /api/:typeNum/signage/zones/:zoneId/playlist
  Request:
    pageId: int (required)
    duration: int (optional, default 10)
    position: int (optional, appends if not specified)
    startDate: datetime (optional)
    expireDate: datetime (optional)
  Response:
    success:
      item: {id, pageId, position, duration, ...}

Endpoint: Reorder Playlist
  Method: PUT
  Path: /api/:typeNum/signage/zones/:zoneId/playlist/reorder
  Request:
    items: [{id, position}] (required)
  Response:
    success: true

Endpoint: Update Playlist Item
  Method: PUT
  Path: /api/:typeNum/signage/playlist-items/:itemId
  Request:
    duration: int (optional)
    enabled: boolean (optional)
    startDate: datetime (optional)
    expireDate: datetime (optional)

Endpoint: Remove from Playlist
  Method: DELETE
  Path: /api/:typeNum/signage/playlist-items/:itemId

# Display Player API
Endpoint: Get Display Data
  Method: GET
  Path: /api/:typeNum/signage/display/:zoneSlug
  Response:
    success:
      zone: {id, name}
      playlist: [{
        page: {
          id, name, layoutId,
          layout: {zones: [...]},
          widgets: [{layoutZoneId, widgetType, config, renderData: {...}}],
          themePreset
        },
        duration
      }]
      cacheVersion: string (for cache invalidation)

Endpoint: Register Display Device
  Method: POST
  Path: /api/:typeNum/signage/display/register
  Request:
    deviceId: string (generated client-side UUID)
  Response:
    success:
      zone: {id, name, slug}  # Auto-registers only when store has exactly one enabled zone
    error: "multi_zone_requires_pairing"

Endpoint: Create Display Pairing Code
  Method: POST
  Path: /api/:typeNum/signage/display/pairings
  Request:
    zoneId: int (required)
    deviceId: string (generated client-side UUID)
  Response:
    success:
      pairing: {pairingCode, expiresAt}

Endpoint: Confirm Display Pairing (Admin)
  Method: POST
  Path: /api/:typeNum/signage/zones/:zoneId/pairings/confirm
  Request:
    pairingCode: string (required)
  Response:
    success:
      zone: {id, name, slug, deviceId, lastSeen}

Endpoint: Display Heartbeat
  Method: POST
  Path: /api/:typeNum/signage/display/heartbeat
  Request:
    zoneId: int
    deviceId: string
    currentPageId: int (optional)
  Response:
    success: true
    cacheVersion: string

# Social Media API
Endpoint: Get Pending Social Posts
  Method: GET
  Path: /api/:typeNum/signage/social/pending
  Response:
    success:
      posts: [{id, platform, mediaUrl, thumbnailUrl, caption, authorName, postedAt}]

Endpoint: Approve Social Post
  Method: POST
  Path: /api/:typeNum/signage/social/:postId/approve

Endpoint: Reject Social Post
  Method: POST
  Path: /api/:typeNum/signage/social/:postId/reject

Endpoint: Get Approved Social Posts
  Method: GET
  Path: /api/:typeNum/signage/social/approved
  Query:
    feedType: 'store_feed' | 'mentions'
    limit: int (default 50)
  Response:
    success:
      posts: [{...}]

# Weather API
Endpoint: Get Weather Data
  Method: GET
  Path: /api/:typeNum/signage/weather
  Query:
    type: 'simple' | 'forecast' | 'alerts'
  Response:
    success:
      current: {temp, feelsLike, conditions, icon}
      forecast: [{day, high, low, conditions, icon}] (if type includes forecast)
      alerts: [{title, description, severity}] (if type includes alerts)

# QR Tracking API
Endpoint: QR Redirect
  Method: GET
  Path: /qr/:qrId
  # Looks up qrId in dsQRCodes, logs scan (no IP/userAgent stored), and redirects to destinationUrl.
  # Unknown/expired qrId returns 404 (prevents open-redirect abuse).

Endpoint: Get QR Analytics
  Method: GET
  Path: /api/:typeNum/signage/analytics/qr/:qrId
  Response:
    success:
      totalScans: int
      scansByHour: [{hour, count}]
      scansByDay: [{date, count}]
      scansByZone: [{zoneId, zoneName, count}]

# Analytics API
Endpoint: Get Signage Analytics
  Method: GET
  Path: /api/:typeNum/signage/analytics
  Query:
    startDate: date
    endDate: date
    zoneId: int (optional)
  Response:
    success:
      pageViews: [{pageId, pageName, views, totalDuration}]
      zoneUptime: [{zoneId, zoneName, uptimePercent}]
      qrScans: [{qrId, scans}]
```

#### Application Data Models

```pseudocode
ENTITY: Zone (NEW)
  FIELDS:
    id: int
    name: string
    slug: string
    description: string|null
    isDefault: bool
    enabled: bool
    lastSeen: datetime|null
    deviceId: string|null
    createdAt: datetime
    updatedAt: datetime

  BEHAVIORS:
    getActivePlaylist(): Playlist
    registerDevice(deviceId): void
    updateLastSeen(): void
    isOnline(): bool  # lastSeen within 5 minutes

ENTITY: DisplayPairing (NEW)
  FIELDS:
    id: int
    zoneId: int
    deviceId: string
    pairingCode: string
    expiresAt: datetime
    confirmedAt: datetime|null

  BEHAVIORS:
    isExpired(now): bool
    confirm(): void

ENTITY: Page (NEW)
  FIELDS:
    id: int
    name: string
    layoutId: int
    themePreset: string
    enabled: bool
    legacySlideId: int|null
    legacySource: int|null
    widgets: Widget[]
    createdAt: datetime
    updatedAt: datetime

  BEHAVIORS:
    getLayout(): Layout
    getWidgets(): Widget[]
    addWidget(layoutZoneId, widgetType, config): Widget
    removeWidget(widgetId): void
    isMigrated(): bool  # legacySlideId != null
    render(): array  # Full render data for display

ENTITY: Widget (ABSTRACT, NEW)
  FIELDS:
    id: int
    pageId: int
    layoutZoneId: string
    widgetType: string
    config: WidgetConfig
    enabled: bool

  BEHAVIORS:
    validate(): bool
    getRenderData(): array  # Widget-specific render data
    getConfigSchema(): array  # JSON Schema for config

ENTITY: QueueWidget EXTENDS Widget (NEW)
  CONFIG_SCHEMA:
    displayMode: 'full' | 'compact'
    showWaitTime: bool
    showBuyer: bool
    maxItems: int

  BEHAVIORS:
    getRenderData(): array
      # Returns current queue items from queue service

ENTITY: WeatherWidget EXTENDS Widget (NEW)
  CONFIG_SCHEMA:
    displayMode: 'simple' | 'forecast' | 'alerts'
    location: string|null  # Override store location
    units: 'fahrenheit' | 'celsius'

  BEHAVIORS:
    getRenderData(): array
      # Returns weather data from cache/API

ENTITY: SocialWidget EXTENDS Widget (NEW)
  CONFIG_SCHEMA:
    feedType: 'store_feed' | 'mentions'
    platform: 'facebook' | 'instagram' | 'tiktok' | 'all'
    maxPosts: int
    displayStyle: 'carousel' | 'grid'

  BEHAVIORS:
    getRenderData(): array
      # Returns approved posts from social service

ENTITY: Playlist (NEW)
  FIELDS:
    id: int
    zoneId: int
    name: string
    items: PlaylistItem[]

  BEHAVIORS:
    getActiveItems(): PlaylistItem[]  # enabled=1, within dates
    addItem(pageId, duration, position): PlaylistItem
    removeItem(itemId): void
    reorder(itemPositions: array): void

ENTITY: PlaylistItem (NEW)
  FIELDS:
    id: int
    playlistId: int
    pageId: int
    position: int
    duration: int
    enabled: bool
    startDate: datetime|null
    expireDate: datetime|null
    eventId: int|null

  BEHAVIORS:
    getPage(): Page
    isActiveNow(): bool
    activate(): void
    deactivate(): void

ENTITY: QRCode (NEW)
  FIELDS:
    qrId: string
    destinationUrl: string
    pageId: int|null
    zoneId: int|null
    widgetId: int|null
    playlistItemId: int|null
    expiresAt: datetime|null

  BEHAVIORS:
    isExpired(now): bool

ENTITY: QRScan (NEW)
  FIELDS:
    id: int
    qrId: string
    scannedAt: datetime
```

#### Integration Points

```yaml
# Inter-Component Communication
- from: Display Player
  to: Signage API
  protocol: REST/HTTPS
  endpoints: [/display/:zoneSlug, /display/heartbeat]
  data_flow: "Fetch playlist data, report device status"

- from: Display Player
  to: Ably
  protocol: WebSocket
  channels: [{typeNum}]
  data_flow: "Real-time playlist updates, queue changes"

- from: Admin UI
  to: Signage API
  protocol: REST/HTTPS
  endpoints: [/zones/*, /pages/*, /widgets/*, /playlists/*]
  data_flow: "CRUD operations for signage management"

# External System Integration
Weather_API:
  - provider: OpenWeatherMap (primary) or WeatherAPI (fallback)
  - integration: "REST API calls with API key auth"
  - caching: "Redis, 30 minute TTL"
  - critical_data: [current_temp, conditions, forecast, alerts]

Meta_Graph_API:
  - doc: Facebook/Instagram Graph API
  - integration: "OAuth2 page access tokens, stored per-store"
  - critical_data: [page_posts, instagram_media, user_mentions]
  - rate_limits: "200 calls/user/hour"

TikTok_API:
  - integration: "OAuth2, video content access"
  - critical_data: [user_videos, video_thumbnails]

Canva_Connect:
  - integration: "OAuth2, design export"
  - critical_data: [design_exports, template_library]
  - note: "Requires Canva approval process"

Existing_Queue_System:
  - integration: "Internal PHP service calls"
  - data_flow: "Current queue, completed queue, wait times"
  - real_time: "Ably subscription for updates"

Existing_Event_System:
  - integration: "SignageAdapter pattern preserved"
  - data_flow: "Event-driven slide scheduling"
```

### Implementation Examples

#### Example: Widget Registry Pattern

**Why this example**: The Widget Registry is central to the extensibility of the system. It demonstrates how new widget types can be added without modifying core code.

```php
<?php
// Example: Widget Registry for extensible widget types
// This demonstrates the registration and instantiation pattern

namespace BuyerKiosk\DigitalSign\Services;

class WidgetRegistry
{
    private array $widgets = [];

    public function register(string $type, string $class, array $metadata): void
    {
        // Validate class implements Widget interface
        if (!is_subclass_of($class, Widget::class)) {
            throw new InvalidWidgetException("$class must extend Widget");
        }

        $this->widgets[$type] = [
            'class' => $class,
            'name' => $metadata['name'],
            'description' => $metadata['description'],
            'icon' => $metadata['icon'],
            'configSchema' => $metadata['configSchema'],
            'compatibleZones' => $metadata['compatibleZones'] ?? ['full', 'large', 'medium', 'small'],
        ];
    }

    public function create(string $type, array $config): Widget
    {
        if (!isset($this->widgets[$type])) {
            throw new UnknownWidgetTypeException($type);
        }

        $class = $this->widgets[$type]['class'];
        return new $class($config);
    }

    public function getAvailableTypes(): array
    {
        return array_map(fn($type, $data) => [
            'type' => $type,
            'name' => $data['name'],
            'description' => $data['description'],
            'icon' => $data['icon'],
            'configSchema' => $data['configSchema'],
            'compatibleZones' => $data['compatibleZones'],
        ], array_keys($this->widgets), $this->widgets);
    }
}

// Registration during bootstrap
$registry->register('queue-current', QueueWidget::class, [
    'name' => 'Current Queue',
    'description' => 'Shows customers currently waiting',
    'icon' => 'fa-users',
    'configSchema' => QueueWidget::getConfigSchema(),
    'compatibleZones' => ['full', 'large', 'medium'],
]);
```

#### Example: Offline-First Cache Strategy

**Why this example**: The offline-first architecture is critical for store reliability. This shows the Service Worker caching strategy.

```javascript
// Example: Service Worker for offline display support
// Demonstrates cache strategy for playlist and media

const CACHE_NAME = 'ds-player-v1';
const PLAYLIST_CACHE = 'ds-playlist-v1';
const MEDIA_CACHE = 'ds-media-v1';

self.addEventListener('install', (event) => {
    event.waitUntil(
        caches.open(CACHE_NAME).then((cache) => {
            // Cache core player assets
            return cache.addAll([
                '/js/digitalsign/player/player-core.js',
                '/js/digitalsign/player/layout-renderer.js',
                '/js/digitalsign/player/widget-renderer.js',
                '/css/digitalsign/player/player.css',
            ]);
        })
    );
});

self.addEventListener('fetch', (event) => {
    const url = new URL(event.request.url);

    // Playlist data: Network-first, cache fallback
    if (url.pathname.includes('/api/') && url.pathname.includes('/signage/display/')) {
        event.respondWith(
            fetch(event.request)
                .then((response) => {
                    const clone = response.clone();
                    caches.open(PLAYLIST_CACHE).then((cache) => {
                        cache.put(event.request, clone);
                    });
                    return response;
                })
                .catch(() => caches.match(event.request))
        );
        return;
    }

    // Media files: Cache-first, network fallback
    if (url.pathname.includes('/upload/digitalSign/')) {
        event.respondWith(
            caches.match(event.request).then((cached) => {
                if (cached) return cached;
                return fetch(event.request).then((response) => {
                    const clone = response.clone();
                    caches.open(MEDIA_CACHE).then((cache) => {
                        cache.put(event.request, clone);
                    });
                    return response;
                });
            })
        );
        return;
    }
});

// Handle cache invalidation messages
self.addEventListener('message', (event) => {
    if (event.data.type === 'INVALIDATE_PLAYLIST') {
        caches.delete(PLAYLIST_CACHE);
    }
});
```

#### Example: Widget Render Flow

**Why this example**: Shows how widgets fetch and render data, demonstrating the separation between data fetching and presentation.

```php
<?php
// Example: Weather Widget render flow
// Demonstrates data fetching with caching

namespace BuyerKiosk\DigitalSign\Domain\Widget;

class WeatherWidget extends Widget
{
    public function getRenderData(): array
    {
        $location = $this->config->get('location') ?? $this->getStoreLocation();
        $displayMode = $this->config->get('displayMode', 'simple');

        // Check Redis cache first
        $cacheKey = "weather:{$location}:{$displayMode}";
        $cached = $this->redis->get($cacheKey);

        if ($cached) {
            return json_decode($cached, true);
        }

        // Fetch from weather service
        $weatherService = $this->container->get(WeatherService::class);
        $data = match($displayMode) {
            'simple' => $weatherService->getCurrentConditions($location),
            'forecast' => $weatherService->getForecast($location, 5),
            'alerts' => $weatherService->getConditionsWithAlerts($location),
        };

        // Cache for 30 minutes
        $this->redis->setex($cacheKey, 1800, json_encode($data));

        return [
            'widgetType' => 'weather',
            'displayMode' => $displayMode,
            'units' => $this->config->get('units', 'fahrenheit'),
            'data' => $data,
            'lastUpdated' => date('c'),
        ];
    }

    public static function getConfigSchema(): array
    {
        return [
            'type' => 'object',
            'properties' => [
                'displayMode' => [
                    'type' => 'string',
                    'enum' => ['simple', 'forecast', 'alerts'],
                    'default' => 'simple',
                ],
                'location' => [
                    'type' => 'string',
                    'description' => 'Override location (ZIP or city)',
                ],
                'units' => [
                    'type' => 'string',
                    'enum' => ['fahrenheit', 'celsius'],
                    'default' => 'fahrenheit',
                ],
            ],
        ];
    }
}
```

## Runtime View

### Primary Flow: Display Player Initialization

1. Display device navigates to `/:typeNum/digitalsign/:zoneSlug`
2. Server returns player.html with initial playlist data
3. Player JavaScript initializes:
   - Registers Service Worker for offline support
   - Establishes Ably connection for real-time updates
   - Caches all media assets
4. Layout Renderer loads first page's layout template
5. Widget Renderer instantiates widgets in layout zones
6. Playlist controller starts page rotation loop

```mermaid
sequenceDiagram
    participant Display
    participant Server
    participant ServiceWorker
    participant Ably
    participant Cache

    Display->>Server: GET /:typeNum/digitalsign/:zoneSlug
    Server-->>Display: player.html + initial data

    Display->>ServiceWorker: Register
    ServiceWorker-->>Display: Ready

    Display->>Server: GET /api/.../display/:zoneSlug
    Server-->>Display: Full playlist data

    Display->>Cache: Cache playlist + media
    Cache-->>Display: Cached

    Display->>Ably: Subscribe to {typeNum}
    Ably-->>Display: Connected

    loop Page Rotation
        Display->>Display: Render current page
        Display->>Display: Wait duration seconds
        Display->>Display: Transition to next page
    end

    Note over Display,Ably: Real-time updates
    Ably->>Display: playlist_updated event
    Display->>Server: GET updated playlist
    Display->>Cache: Update cache
```

### Primary Flow: Store Manager Creates Multi-Widget Page

1. Manager navigates to Signage → Pages → Create New
2. Selects layout template from visual gallery
3. For each layout zone:
   - Clicks zone → widget picker opens
   - Selects widget type
   - Configures widget settings
   - Sees live preview update
4. Sets page name and theme
5. Saves page
6. Adds to zone playlist

```mermaid
sequenceDiagram
    actor Manager
    participant AdminUI
    participant PageAPI
    participant WidgetAPI
    participant PageService
    participant WidgetRegistry

    Manager->>AdminUI: Click "Create New Page"
    AdminUI->>AdminUI: Show layout gallery
    Manager->>AdminUI: Select "Header + 2 Columns"

    AdminUI->>PageAPI: POST /pages {layoutId}
    PageAPI->>PageService: createPage()
    PageService-->>PageAPI: Page {id, layoutId}
    PageAPI-->>AdminUI: Page created

    Manager->>AdminUI: Click header zone
    AdminUI->>WidgetAPI: GET /widget-types
    WidgetAPI->>WidgetRegistry: getAvailableTypes()
    WidgetRegistry-->>WidgetAPI: Widget types
    WidgetAPI-->>AdminUI: Show widget picker

    Manager->>AdminUI: Select "Wait Time Widget"
    Manager->>AdminUI: Configure display options
    AdminUI->>WidgetAPI: POST /pages/:id/widgets
    WidgetAPI->>PageService: addWidget()
    PageService-->>WidgetAPI: Widget added
    WidgetAPI-->>AdminUI: Update preview

    Manager->>AdminUI: Save and add to playlist
    AdminUI->>PageAPI: POST /zones/:id/playlist
```

### Error Handling

**Invalid Widget Configuration:**
- Validation runs on save
- Error displayed inline with specific field highlighted
- Page cannot be saved until validation passes
- Example: "QR Code widget requires a destination URL"

**Network Failure (Display):**
- Service Worker serves cached playlist
- Visual indicator shows offline status (subtle corner icon)
- Queue widgets show "Offline - Last updated X ago"
- When online, automatic sync with server

**External API Failure (Weather/Social):**
- Return cached data if available with "Last updated" timestamp
- If no cache, show placeholder: "Weather temporarily unavailable"
- Log error for monitoring
- Retry with exponential backoff (30s, 60s, 120s)

**Playlist Empty:**
- Display default "Welcome to [Store Name]" page
- Include store logo and basic info
- Log warning for admin notification

### Complex Logic: Playlist Date Filtering

```
ALGORITHM: Process Playlist for Display
INPUT: zone, current_time
OUTPUT: active_playlist_items[]

1. FETCH: all playlist_items WHERE playlist.zoneId = zone.id
2. FILTER: enabled = 1 (item is enabled)
3. FILTER: startDate IS NULL OR startDate <= current_time
4. FILTER: expireDate IS NULL OR expireDate > current_time
5. SORT: BY position ASC
7. FOR EACH item:
   a. LOAD: page with widgets
   b. FOR EACH widget:
      - CALL: widget.getRenderData()
      - HANDLE: errors gracefully (show placeholder on failure)
   c. COMPILE: page render data
8. RETURN: active_playlist_items with render data
```

```
ALGORITHM: Expiration Cleanup (Cron, Optional)
INPUT: current_date
OUTPUT: none (side effects: removes expired items)

1. FETCH: all playlist_items WHERE expireDate IS NOT NULL AND expireDate <= current_date
2. FOR EACH item:
   a. DELETE: from playlist
   b. PUBLISH: Ably message {action: "playlist_updated", zoneId}
3. LOG: expirations for analytics
```

## Deployment View

### Single Application Deployment

- **Environment**: Existing BuyerKiosk LAMP stack
- **Configuration**:
  - New `.env` variables for external APIs:
    - `WEATHER_API_KEY`
    - `WEATHER_API_PROVIDER` (openweathermap|weatherapi)
    - `META_APP_ID`, `META_APP_SECRET`
    - `TIKTOK_CLIENT_KEY`, `TIKTOK_CLIENT_SECRET`
    - `CANVA_CLIENT_ID`, `CANVA_CLIENT_SECRET`
  - Redis already configured (existing)
  - Ably already configured (existing)

- **Dependencies**:
  - Weather API account and API key
  - Meta Developer App with Page Public Content Access
  - TikTok Developer Account (for future social integration)
  - Canva Connect Partner account (requires approval)

- **Performance**:
  - Target: < 500ms initial playlist load
  - Target: < 100ms page transition
  - Redis caching for weather (30 min TTL)
  - Redis caching for social posts (5 min TTL)
  - CDN for media assets (existing)

### Migration Strategy

**Phase 1: Schema Migration**
- Run database migrations to create new tables
- No data migration required (new tables)

**Phase 2: Legacy Compatibility Layer**
- Create default zone for each store (auto-create on first access)
- Migrate existing dsLoop entries to playlist items on-demand
- Legacy `/typeNum/digitalsign` URL redirects to default zone

**Phase 3: Feature Enablement**
- Feature flag `SIGNAGE_V2_ENABLED` controls new UI visibility
- Stores opt-in to new system
- Legacy system continues working indefinitely

## Cross-Cutting Concepts

### Pattern Documentation

```yaml
# Existing patterns used
- pattern: docs/patterns/psr4-autoloading.md
  relevance: HIGH
  why: "All new classes follow PSR-4 autoloading"

- pattern: docs/patterns/namespace-structure.md
  relevance: HIGH
  why: "New classes placed in BuyerKiosk\DigitalSign namespace"

- pattern: docs/features/digital-signage-technical-patterns.md
  relevance: HIGH
  why: "Existing patterns for slides, loops, scheduling"

# New patterns created
- pattern: docs/patterns/widget-component-pattern.md (NEW)
  relevance: HIGH
  why: "Defines how widgets are structured and registered"

- pattern: docs/patterns/offline-first-display.md (NEW)
  relevance: MEDIUM
  why: "Service Worker caching strategy for displays"
```

### System-Wide Patterns

**Security:**
- All admin endpoints require `checkAccess('uri_digital_signage')`
- Store endpoints require `checkStoreGroup($typeNum)`
- Social OAuth tokens stored encrypted
- QR tracking anonymized (no IP/userAgent stored)
- Media filenames randomized (existing pattern)

**Error Handling:**
- Controllers return JSON with `success` boolean
- Errors include `error_code` and `message`
- External API failures logged but don't crash display
- Display player shows cached content on any error

**Performance:**
- Redis caching for external API data
- Playlist data cached per-zone
- Media assets served via CDN
- Service Worker for offline support
- Lazy loading of widget render data

**Logging/Auditing:**
- Display heartbeats logged for uptime tracking
- QR scans logged for analytics
- Page displays logged for play counts
- Social approvals logged with user ID

### Implementation Patterns

#### Code Patterns and Conventions
- Follow existing BuyerKiosk naming conventions
- Use PSR-4 autoloading for all new classes
- Controllers extend existing base patterns
- Services use constructor injection
- Repositories use PDO prepared statements

#### State Management Patterns
- Display player uses Alpine.js (lightweight) per ADR-6
- State stored in memory with Service Worker backup
- Ably subscriptions for real-time state updates
- Local storage for device registration

#### Component Structure Pattern

```pseudocode
# Widget Component Pattern
WIDGET: AbstractWidget
  CONSTRUCTOR(config: WidgetConfig)
    VALIDATE: config against schema
    STORE: config

  ABSTRACT getRenderData(): array
    # Implemented by each widget type

  STATIC getConfigSchema(): array
    # JSON Schema for widget configuration

  VALIDATE(): bool
    # Validates current config
```

#### Error Handling Pattern

```pseudocode
# Widget Error Handling
FUNCTION: renderWidgetSafely(widget: Widget): array
  TRY:
    data = widget.getRenderData()
    RETURN: {success: true, data: data}
  CATCH DataFetchException:
    LOG: error with widget type and config
    RETURN: {success: false, error: 'data_unavailable', cached: getCachedData(widget)}
  CATCH ValidationException:
    LOG: validation error
    RETURN: {success: false, error: 'invalid_config'}
  CATCH Exception:
    LOG: unexpected error
    RETURN: {success: false, error: 'unknown'}
```

### Integration Points

- **Queue System**: QueueWidgets call existing queue JSON endpoints; refresh via Ably triggers when available, otherwise fall back to a short polling interval (keeps <5s perceived latency without touching core queue logic)
- **Event System**: SignageAdapter preserved for event-driven pages
- **Ably**: Existing channel pattern extended with new message types
- **Authentication**: Existing UserFrosting session auth
- **Media Upload**: Existing UploadHandler preserved

## Architecture Decisions

- [x] ADR-1 **Widget Registry Pattern**: Use registry pattern for widget types
  - Rationale: Enables adding new widget types without modifying core code
  - Trade-offs: Slightly more complex than hardcoded widgets, but future-proof
  - User confirmed: ✅ 2025-12-19

- [x] ADR-2 **Service Worker for Offline**: Use Service Worker API for offline support
  - Rationale: Modern browser standard, granular cache control, works with existing infrastructure
  - Trade-offs: Requires HTTPS (already have), older browser fallback needed
  - User confirmed: ✅ 2025-12-19

- [x] ADR-3 **JSON Configuration for Widgets**: Store widget config as JSON in database
  - Rationale: Flexible schema, easy to extend, no migrations for new widget settings
  - Trade-offs: Can't query individual config fields efficiently, validation in application layer
  - User confirmed: ✅ 2025-12-19

- [x] ADR-4 **Hybrid Layout System**: Pre-defined templates + adjustable zone sizing
  - Rationale: Balances simplicity with flexibility - users start from templates but can resize zones within constraints
  - Trade-offs: More complex than pure templates, but covers more use cases
  - Implementation: Templates define default zones; users can adjust zone width/height within min/max bounds
  - User confirmed: ✅ 2025-12-19 (modified from PRD's template-only approach)

- [x] ADR-5 **Redis for External Data Caching**: Cache weather/social data in Redis
  - Rationale: Already in stack, TTL support, shared across requests
  - Trade-offs: Additional Redis memory usage, but minimal
  - User confirmed: ✅ 2025-12-19

- [x] ADR-6 **Alpine.js for Display Player**: Lightweight reactive framework (15KB)
  - Rationale: Provides reactivity for widget state management while staying lightweight
  - Trade-offs: New dependency to learn, but simple mental model similar to vanilla JS
  - Benefits: Better memory management for 24/7 displays, cleaner widget state updates, reactive data binding
  - User confirmed: ✅ 2025-12-19 (chosen over Vanilla JS and Vue.js)

- [x] ADR-7 **Additive Schema Changes**: New tables alongside existing, not replacement
  - Rationale: Preserves legacy system, enables gradual migration, rollback safety
  - Trade-offs: Some data duplication during transition, but worth the safety
  - User confirmed: ✅ 2025-12-19

## Quality Requirements

**Performance:**
- Initial playlist load: < 500ms
- Page transition animation: 60fps
- Widget render: < 100ms per widget
- Offline cache sync: < 2 seconds
- Ably message latency: < 1 second

**Reliability:**
- Display uptime target: 99.5%
- Offline operation: Unlimited (with cached content)
- External API failure recovery: < 5 minutes
- Data integrity: No content loss during migration

**Usability:**
- Page creation: < 5 minutes for basic page
- Widget configuration: Discoverable without documentation
- Preview accuracy: 95% match to actual display
- Error messages: Actionable and specific

**Security:**
- OAuth tokens: Encrypted at rest
- QR tracking: No IP/userAgent stored
- Social content: Explicit approval required
- API authentication: Session-based (existing)

## Risks and Technical Debt

### Known Technical Issues
- Video embed field in dsSlides unused (legacy)
- Legacy Cycle2 library may conflict with new player
- Some stores have very large playlists (100+ items)

### Technical Debt
- Legacy dsLoop remains indefinitely for backward compat
- Two display endpoints during migration period
- Social API tokens need regular refresh logic

### Implementation Gotchas
- Ably channel names are case-sensitive
- FFMpeg video thumbnail timing can vary by codec
- Service Workers require HTTPS
- Meta API rate limits per-user, not per-app
- Canva Connect requires lengthy approval process
- Weather API free tiers have call limits

## Test Specifications

### Critical Test Scenarios

**Scenario 1: Create Multi-Widget Page**
```gherkin
Given: Store manager is logged in with signage permissions
And: Default display zone exists
When: Manager creates page with "Header + 2 Columns" layout
And: Adds Wait Time widget to header
And: Adds Current Queue widget to left column
And: Adds Weather widget to right column
And: Saves page and adds to playlist
Then: Page appears in zone playlist
And: All three widgets render on display
```

**Scenario 2: Offline Display Operation**
```gherkin
Given: Display is connected and showing playlist
And: All content is cached in Service Worker
When: Network connection is lost
Then: Display continues showing cached playlist
And: Queue widgets show "Offline" state with last data
And: Weather widget shows cached data with timestamp
And: Visual offline indicator appears
```

**Scenario 3: Social Content Approval**
```gherkin
Given: Store has Instagram connected
And: New post appears mentioning store
When: Post is fetched by social sync job
Then: Post appears in approval queue
And: Manager can preview post content
When: Manager approves post
Then: Post appears in Social widget on display
```

**Scenario 4: Legacy Migration**
```gherkin
Given: Store has existing dsSlides and dsLoop
When: Store accesses new signage system
Then: Default zone is auto-created
And: Existing slides migrated to full-screen pages
And: Playlist matches legacy loop order
And: Legacy URL redirects to new system
```

### Test Coverage Requirements

- **Business Logic**: Widget registry, playlist ordering, schedule processing
- **User Interface**: Page editor, widget configurator, drag-drop reorder
- **Integration Points**: Weather API, Social APIs, Ably messaging
- **Edge Cases**: Empty playlist, offline mode, API failures
- **Performance**: Playlist load time, page transitions
- **Security**: Permission checks, token encryption

---

## Glossary

### Domain Terms

| Term | Definition | Context |
|------|------------|---------|
| Zone | A named display location in a store | Stores can have multiple zones (Checkout, Waiting Area) |
| Page | A single screen of content with layout and widgets | Replaces "slide" concept for multi-widget displays |
| Widget | A self-contained content component | Queue, Weather, Social, etc. |
| Layout | A template defining zone arrangement on a page | Header + 2 Columns, 50/50 Split, etc. |
| Playlist | Ordered list of pages for a zone | Each zone has one active playlist |
| Theme Preset | Visual styling preset | Light, Dark, Brand, Minimal |

### Technical Terms

| Term | Definition | Context |
|------|------------|---------|
| Widget Registry | Central registration of widget types | Enables extensibility |
| Service Worker | Browser API for offline caching | Used for display player offline support |
| Layout Zone | A region within a layout template | Where widgets are placed |
| Widget Config | JSON configuration for widget instance | Stored in dsPageWidgets.config |

### API/Interface Terms

| Term | Definition | Context |
|------|------------|---------|
| Display Data API | Endpoint returning full playlist for display | `/api/:typeNum/signage/display/:zoneSlug` |
| Heartbeat | Periodic device check-in | Used for uptime tracking |
| Cache Version | Version string for cache invalidation | Returned with display data |

---

*Document version: 1.0*
*Last updated: 2025-12-19*
*Status: Complete - All ADRs Confirmed*
