Updated EnvelopeViewer to support configurable quality settings via `PdfViewerOptions` and `appsettings.json`. Added HiDPI/Retina support, smooth zoom transitions, and unlimited zoom with configurable step percentages. Introduced a resizable thumbnail sidebar with localStorage persistence. Simplified initialization and cleanup processes, and documented new features and architecture. Improved user experience and performance compared to the legacy ReportViewer.
465 lines
18 KiB
Markdown
465 lines
18 KiB
Markdown
# EnvelopeGenerator — Copilot Context Notes (English)
|
||
|
||
## Purpose
|
||
A digital document signing system. Senders upload PDFs and place signature annotation fields via PSPDFKit (EnvelopeGenerator.Web). Receivers open the document in a Blazor WASM viewer, confirm each signature field via a checkbox overlay, draw/type/upload their signature, and export the stamped PDF.
|
||
|
||
---
|
||
|
||
## Solution Structure
|
||
|
||
| Project | Target | Description |
|
||
|---|---|---|
|
||
| `EnvelopeGenerator.API` | net8.0 | ASP.NET Core Web API. Receiver auth (cookie), annotation reading, PDF serving. |
|
||
| `EnvelopeGenerator.ReceiverUI` | net8.0 WASM | Blazor WebAssembly. Receiver UI. YARP proxies API calls. |
|
||
| `EnvelopeGenerator.Web` | net7/8/9 | Razor Pages. Sender UI + PSPDFKit annotation placement. |
|
||
| `EnvelopeGenerator.Application` | multi | MediatR CQRS handlers. |
|
||
| `EnvelopeGenerator.Domain` | multi | Domain models, constants, interfaces. |
|
||
| `EnvelopeGenerator.Infrastructure` | multi | EF Core repos, DB context. |
|
||
| `EnvelopeGenerator.PdfEditor` | multi | iText7 utilities. NOT used in ReceiverUI flow. |
|
||
| `EnvelopeGenerator.DependencyInjection` | multi | DI registration helpers. |
|
||
| VB.NET projects (Service/Form/BBTests) | net462 | Legacy. Do NOT touch. |
|
||
|
||
---
|
||
|
||
## Key Files
|
||
|
||
| File | Purpose |
|
||
|---|---|
|
||
| `ReceiverUI/Pages/EnvelopeViewer.razor` | **NEW** PDF.js-based viewer (`/envelope/{key}`). Replaces ReportViewer.razor. Simple read-only PDF viewing with zoom/navigation. |
|
||
| `ReceiverUI/Pages/ReportViewer.razor` | **LEGACY** DevExpress-based signing page (`/receiver/{key}`). Still used for signature workflow. Will be deprecated. |
|
||
| `ReceiverUI/wwwroot/js/pdf-viewer.js` | **NEW** PDF.js wrapper: rendering, zoom, pagination, mouse wheel control. |
|
||
| `ReceiverUI/wwwroot/js/receiver-signature.js` | JS: checkbox overlay, signature pad (draw/type/image). |
|
||
| `ReceiverUI/wwwroot/css/envelope-viewer.css` | **NEW** Styles for EnvelopeViewer.razor (external CSS, not inline). |
|
||
| `ReceiverUI/wwwroot/fake-data/annotations.json` | Dev-mode fake annotations (YARP proxy target). |
|
||
| `ReceiverUI/Models/AnnotationDto.cs` | Annotation position model. All properties non-nullable. |
|
||
| `ReceiverUI/Services/AnnotationService.cs` | Fetches `List<AnnotationDto>` from API or fake-data. |
|
||
| `ReceiverUI/Services/DocumentService.cs` | Fetches PDF bytes from API. |
|
||
| `ReceiverUI/Services/AuthService.cs` | Manages receiver session cookie. |
|
||
| `API/Controllers/AnnotationController.cs` | GET `api/Annotation/{key}` ? annotation list. |
|
||
| `API/Controllers/DocumentController.cs` | GET `api/Document/{key}` ? PDF bytes. |
|
||
|
||
---
|
||
|
||
## AnnotationDto — Coordinate System
|
||
|
||
```
|
||
Unit : 1/100 inch (DX units) — DevExpress XtraReports native
|
||
Origin : Top-left corner of page
|
||
X : increases rightward
|
||
Y : increases downward
|
||
|
||
A4 in DX units: Width = 827, Height = 1169
|
||
|
||
Conversions:
|
||
PSPDFKit (pt, top-left): xDX = xPsPdf * (100/72)
|
||
GDPicture (pt, bottom-left): yDX = (pageHeightPt - yGD - elemHeightPt) * (100/72)
|
||
DX ? PDF points: pt = dx * (72/100)
|
||
```
|
||
|
||
---
|
||
|
||
## EnvelopeViewer (NEW) — PDF.js Read-Only Viewer
|
||
|
||
**Route:** `/envelope/{EnvelopeKey}`
|
||
**Purpose:** Modern, high-performance PDF viewing without signing functionality.
|
||
**Technology:** PDF.js 3.11.174 + custom JavaScript + configurable quality settings
|
||
|
||
### Architecture
|
||
|
||
**Blazor Component (`EnvelopeViewer.razor`):**
|
||
- Fetches PDF via `DocumentService.GetDocumentAsync(EnvelopeKey)`
|
||
- Converts to base64 data URL: `data:application/pdf;base64,{base64}`
|
||
- Initializes PDF.js viewer via JSInterop with `DotNetObjectReference` for callbacks
|
||
- Quality settings loaded from `appsettings.json` via `IOptions<PdfViewerOptions>`
|
||
- Thumbnail sidebar with resizable splitter (150px-400px, localStorage persistence)
|
||
- CSS externalized to `envelope-viewer.css`
|
||
|
||
**JavaScript (`pdf-viewer.js`):**
|
||
```javascript
|
||
window.pdfViewer = {
|
||
qualityOptions, // Configurable from appsettings.json
|
||
setQualityOptions(options), // Dynamic quality update
|
||
initialize(canvasId, pdfDataUrl, dotNetRef),
|
||
renderPage(num),
|
||
renderThumbnail(pageNum, canvasId),
|
||
attachWheelEvent(), // Ctrl+Wheel zoom (configurable step)
|
||
zoomIn(), zoomOut(), // Configurable step percentage
|
||
dispose()
|
||
}
|
||
```
|
||
|
||
**Options (`PdfViewerOptions.cs`):**
|
||
```csharp
|
||
public class PdfViewerOptions {
|
||
public double ThumbnailBaseScale { get; set; } = 0.75; // 0.2-1.5
|
||
public bool ThumbnailEnableHiDPI { get; set; } = true;
|
||
public double ThumbnailMaxDPR { get; set; } = 2.0; // 1.0-3.0
|
||
public bool MainCanvasEnableHiDPI { get; set; } = true;
|
||
public double MainCanvasMaxDPR { get; set; } = 2.0;
|
||
public bool EnableSmoothZoom { get; set; } = true;
|
||
public int ZoomTransitionDuration { get; set; } = 150; // ms
|
||
public double RenderingOpacity { get; set; } = 0.85; // 0.0-1.0
|
||
public int ThumbnailRenderDelay { get; set; } = 50; // ms
|
||
public int ZoomStepPercentage { get; set; } = 5; // 1-50%
|
||
}
|
||
```
|
||
|
||
### Configuration (appsettings.json)
|
||
|
||
**Location:** `EnvelopeGenerator.ReceiverUI/wwwroot/appsettings.json`
|
||
|
||
```json
|
||
{
|
||
"PdfViewer": {
|
||
"ThumbnailBaseScale": 0.75,
|
||
"ThumbnailEnableHiDPI": true,
|
||
"ThumbnailMaxDPR": 2.0,
|
||
"MainCanvasEnableHiDPI": true,
|
||
"MainCanvasMaxDPR": 2.0,
|
||
"EnableSmoothZoom": true,
|
||
"ZoomTransitionDuration": 150,
|
||
"RenderingOpacity": 0.85,
|
||
"ThumbnailRenderDelay": 50,
|
||
"ZoomStepPercentage": 5
|
||
}
|
||
}
|
||
```
|
||
|
||
**Usage:** Edit file ? F5 (browser refresh) ? new settings applied
|
||
|
||
**Presets:**
|
||
- **High Quality**: `ThumbnailBaseScale: 1.0, MaxDPR: 3.0` (powerful devices)
|
||
- **Balanced**: Default values (recommended)
|
||
- **Performance**: `ThumbnailBaseScale: 0.5, EnableHiDPI: false` (mobile/low-end)
|
||
|
||
---
|
||
|
||
### Features
|
||
|
||
1. **HiDPI/Retina Support** ? 4x quality on Retina displays
|
||
2. **Configurable Quality** ? All parameters in appsettings.json
|
||
3. **Unlimited Zoom** ? 50%-300%, configurable step (default 5%)
|
||
4. **Global Ctrl+Wheel Zoom** ? Works anywhere on page
|
||
5. **Thumbnail Sidebar** ? Resizable (150-400px), high-quality rendering
|
||
6. **Smooth Transitions** ? Configurable fade effect
|
||
7. **Responsive Design** ? Desktop/mobile adaptive layout
|
||
|
||
---
|
||
|
||
### Features
|
||
|
||
1. **Unlimited Zoom:**
|
||
- Canvas size not restricted by `max-width`
|
||
- Frame stays fixed, scroll bars appear automatically
|
||
- `text-align: center` for small sizes, full scroll for zoomed views
|
||
|
||
2. **Global Mouse Wheel Zoom:**
|
||
- Event listener on `document.body` (works anywhere on page)
|
||
- `Ctrl + Mouse Wheel` triggers `zoomIn()`/`zoomOut()`
|
||
- Calls `dotNetReference.invokeMethodAsync('OnZoomChanged', scale)` to update Blazor UI
|
||
- `{ passive: false }` to enable `preventDefault()`
|
||
|
||
3. **Render Task Cancellation:**
|
||
- Stores `currentRenderTask` to cancel previous render if new one starts
|
||
- Catches `RenderingCancelledException` to avoid console errors
|
||
- Queue system (`pageNumPending`) for rapid page changes
|
||
|
||
4. **Thumbnail Sidebar:**
|
||
- Left panel with page previews (sequential rendering, 50ms delay)
|
||
- Click to navigate to specific page
|
||
- Active page highlighted with gradient border
|
||
- No header/title (maximizes thumbnail space)
|
||
- Toggle button in toolbar to show/hide
|
||
|
||
5. **Resizable Splitter:**
|
||
- 4px draggable divider between thumbnails and canvas
|
||
- Min width: 150px, Max width: 400px
|
||
- Visual feedback: gradient on hover/active
|
||
- User preference saved to localStorage (`envelopeViewer_thumbnailWidth`)
|
||
- Global mouse events (works anywhere during drag)
|
||
- `col-resize` cursor (?) for intuitive UX
|
||
|
||
6. **Flex Layout:**
|
||
- Thumbnails and canvas in same container (`.pdf-frame`)
|
||
- `display: flex, flex-direction: row, align-items: stretch`
|
||
- Perfect vertical alignment (same top/bottom position)
|
||
- Responsive: column layout on mobile (<768px)
|
||
|
||
7. **Responsive Design:**
|
||
- Desktop: 90% width, 1200px max
|
||
- Mobile: 95% width, adjusted heights, thumbnails collapse to top
|
||
- Adaptive padding and font sizes
|
||
|
||
### Initialization Flow
|
||
|
||
```csharp
|
||
OnInitializedAsync():
|
||
1. Fetch PDF bytes from DocumentService
|
||
2. Convert to base64 data URL
|
||
3. Set _isLoading = false
|
||
|
||
OnAfterRenderAsync(firstRender):
|
||
1. Load saved thumbnail width from localStorage
|
||
2. Create DotNetObjectReference
|
||
3. Send PdfViewerOptions to JavaScript
|
||
4. Initialize PDF.js viewer
|
||
5. Attach splitter resize listeners
|
||
6. Render thumbnails sequentially (configurable delay)
|
||
```
|
||
|
||
**User Interactions:**
|
||
- Zoom: Buttons/Ctrl+Wheel/Slider ? configurable step percentage
|
||
- Pages: Buttons/Input/Thumbnails ? navigate
|
||
- Sidebar: Toggle button ? show/hide thumbnails
|
||
- Splitter: Drag ? resize sidebar (150-400px)
|
||
|
||
**Cleanup:**
|
||
```csharp
|
||
DisposeAsync():
|
||
- Dispose PDF.js viewer
|
||
- Detach event listeners
|
||
- Dispose DotNetObjectReference
|
||
```
|
||
|
||
---
|
||
|
||
### Key Differences from ReportViewer
|
||
|
||
| Feature | EnvelopeViewer (NEW) | ReportViewer (LEGACY) |
|
||
|---------|----------------------|------------------------|
|
||
| Technology | PDF.js + Canvas | DevExpress XtraReports |
|
||
| Route | `/envelope/{key}` | `/receiver/{key}` |
|
||
| Purpose | Read-only viewing | Signature workflow |
|
||
| Dependencies | PDF.js CDN | DevExpress NuGet packages |
|
||
| Zoom | Unlimited, smooth | Report viewer default |
|
||
| Mouse Wheel | Custom Ctrl+Wheel | Browser default |
|
||
| File Size | Minimal (JS + CSS) | Heavy (DX libs) |
|
||
| Maintenance | Simple, standard web | Complex, vendor-specific |
|
||
|
||
---
|
||
|
||
## ReceiverUI Signing Flow (ReportViewer.razor — LEGACY)
|
||
|
||
### On Load (`OnInitializedAsync`)
|
||
1. `AuthService.CheckEnvelopeAccessAsync` ? redirect to login if unauthorized
|
||
2. `AnnotationService.GetAnnotationsAsync` ? fills `_annotations`
|
||
3. `DocumentService.GetDocumentAsync` ? fills `_basePdfBytes` (real mode)
|
||
4. `BuildFreshBaseReport()` ? `XtraReport` for `DxReportViewer`
|
||
|
||
### Signature Popup
|
||
- Tabs: Draw / Text / Image
|
||
- Fields: full name (required), position (optional), place (required)
|
||
- Saved to `_capturedSignature` record
|
||
- If annotations exist ? popup closes ? JS checkbox overlays installed
|
||
|
||
### JS Checkbox Overlay (`receiver-signature.js`)
|
||
- `receiverSignature.installAnnotationCheckboxes(annotations, checkedIds, dotNetRef)`
|
||
- One `.annot-sig-cb-wrapper` div per annotation, absolutely positioned over viewer scroll container
|
||
- Position: `left = pageRect.left + ann.x * scaleX`, `top = pageRect.top + ann.y * scaleY`
|
||
- `scaleX = pagePixelWidth / 827`, `scaleY = pagePixelHeight / 1169`
|
||
- Click ? `dotNetRef.invokeMethodAsync('OnAnnotationToggled', id, checked)`
|
||
|
||
### Apply Signatures ("Unterschriften anwenden" — `SubmitSignaturesAsync`)
|
||
|
||
**Real PDF mode (`_basePdfBytes` is set):**
|
||
- Calls `StampSignaturesOnPdf` using **iText7** directly on PDF bytes
|
||
- Coordinate conversion: `xPt = ann.X * (72/100)`, `yPt = pageHeight - ann.Y * (72/100) - sigHeight` (Y flip: DX top-down ? PDF bottom-up)
|
||
- Returns stamped bytes ? loaded into new `XtraReport` with `XRPdfContent`
|
||
- Viewer refreshed with `ViewerKey++`
|
||
|
||
**Dev/fake mode (`_basePdfBytes` is null):**
|
||
- Falls back to `AddSignatureAtAnnotation` (XtraReports DetailBand + BeforePrint counter)
|
||
- This is intentionally left as a dev fallback only
|
||
|
||
### Export
|
||
- `reportViewer.ExportToAsync(ExportFormat.Pdf)`
|
||
|
||
---
|
||
|
||
## StampSignaturesOnPdf — iText7 Implementation
|
||
|
||
```csharp
|
||
// Located in ReportViewer.razor @code section
|
||
// Called by SubmitSignaturesAsync when _basePdfBytes is available
|
||
|
||
static byte[] StampSignaturesOnPdf(
|
||
byte[] sourcePdfBytes, byte[] signatureImageBytes,
|
||
string signerFullName, string signerPosition, string signaturePlace,
|
||
IReadOnlyList<AnnotationDto> annotations)
|
||
{
|
||
// Opens PDF with PdfReader/PdfWriter
|
||
// For each annotation:
|
||
// pageNum = ann.Page (clamped to totalPages)
|
||
// xPt = ann.X * (72f/100f)
|
||
// imgBottomY = pageHeight - ann.Y * (72f/100f) - sigHeightPt ? Y-axis flip
|
||
// PdfCanvas.AddImageFittedIntoRectangle(imageData, rect, false)
|
||
// Separator line at bottom of image
|
||
// Canvas text block below separator (font: Helvetica 7pt, color: RGB(73,80,87))
|
||
// Returns stamped byte[]
|
||
}
|
||
```
|
||
|
||
---
|
||
|
||
## BuildFreshBaseReport()
|
||
|
||
```csharp
|
||
// Real PDF mode:
|
||
var report = new XtraReport();
|
||
var detail = new DetailBand();
|
||
report.Bands.Add(detail);
|
||
detail.Controls.Add(new XRPdfContent { Source = _basePdfBytes, GenerateOwnPages = true });
|
||
return report;
|
||
|
||
// Dev/fake mode: returns pre-built report from ReportStorage
|
||
```
|
||
|
||
---
|
||
|
||
## NuGet Packages in ReceiverUI
|
||
|
||
| Package | Version | Purpose |
|
||
|---|---|---|
|
||
| `DevExpress.Blazor.Reporting.Viewer` | 25.2.3 | DxReportViewer (LEGACY, used in ReportViewer.razor) |
|
||
| `DevExpress.Blazor.PdfViewer` | 25.2.3 | PDF viewer (not used in EnvelopeViewer) |
|
||
| `DevExpress.Drawing.Skia` | 25.2.3 | Drawing backend |
|
||
| `itext` | 8.0.5 | PDF stamping (iText7) |
|
||
| `SkiaSharp.*` | 3.119.1 | WASM native rendering |
|
||
|
||
**External CDN (EnvelopeViewer):**
|
||
- PDF.js 3.11.174 (via `https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js`)
|
||
- PDF.js Worker (`pdf.worker.min.js`)
|
||
|
||
---
|
||
|
||
## Mistakes History — Do NOT Repeat
|
||
|
||
| Mistake | Why Wrong |
|
||
|---|---|
|
||
| `BottomMarginBand` for per-page signatures | Repeats on every page; Y offset wrong |
|
||
| `imageY = (page-1) * 1169 + ann.Y` | Inflates DetailBand; 35 pages ? 140 pages |
|
||
| `e.Graph?.PrintingSystem` in BeforePrint | `Graph` not on `CancelEventArgs` |
|
||
| `ctrl.Report?.PrintingSystem` | `PrintingSystem` not on `XtraReportBase` in WASM |
|
||
| Adding stamp endpoint to `DocumentController` | Not needed; stamping is done client-side in ReceiverUI |
|
||
| iText7 via API (server-side) | Unnecessary; iText7 runs fine in WASM directly |
|
||
| **PDF.js: Hardcoded quality values** | **Use appsettings.json for configurability** |
|
||
| **PDF.js: Hardcoded zoom step (1%)** | **Too granular; use configurable percentage** |
|
||
|
||
---
|
||
|
||
## DevExpress Article (2023-08-28) — Why It Does NOT Apply
|
||
|
||
The article describes **X.509 cryptographic digital signatures** via `PdfDocumentSigner` + `Pkcs7Signer`.
|
||
Our use case is **visual/image stamping** at specific page coordinates — different problem, different API.
|
||
`XRPdfSignature` in the article requires pre-placed fields in the report designer, not runtime coordinates.
|
||
|
||
---
|
||
|
||
## Change Log
|
||
|
||
| Session | Date | Change |
|
||
|---|---|---|
|
||
| 1–3 | — | Core infrastructure: services, YARP proxy, JS overlay, signature pad |
|
||
| 4 | — | `AddSignatureAtAnnotation` with BottomMarginBand — ? repeated on all pages |
|
||
| 5 | — | `BeforePrint` + `e.Graph?.PrintingSystem` — ? compile error |
|
||
| 6 | — | BeforePrint counter — ? correct pattern, wrong band |
|
||
| 7 | — | Switched to DetailBand — ? correct band |
|
||
| 8 | — | `(page-1)*1169+Y` offset — ? 35?140 page inflation |
|
||
| 9 | — | Fixed: `BoundsF.Y = ann.Y` + counter; created COPILOT_CONTEXT.md |
|
||
| 10 | — | Investigated DevExpress article — not applicable to our case |
|
||
| 10 | — | Added iText7 to ReceiverUI; implemented `StampSignaturesOnPdf` — ? deterministic coordinates, no page count side effects |
|
||
| 10 | — | Split COPILOT_CONTEXT.md into COPILOT_CONTEXT_EN.md and COPILOT_CONTEXT_TR.md |
|
||
| **11** | **2025-01-XX** | **Created EnvelopeViewer.razor with PDF.js 3.11.174 + modern UI** |
|
||
| **11** | **2025-01-XX** | **Implemented configurable quality system (PdfViewerOptions + appsettings.json)** |
|
||
| **11** | **2025-01-XX** | **Added HiDPI/Retina support (4x quality on Retina displays)** |
|
||
| **11** | **2025-01-XX** | **Implemented thumbnail sidebar with resizable splitter (150-400px, localStorage)** |
|
||
| **11** | **2025-01-XX** | **Added smooth zoom transitions with configurable opacity and duration** |
|
||
| **11** | **2025-01-XX** | **Made zoom step configurable (buttons, Ctrl+Wheel, slider use same step)** |
|
||
| **11** | **2025-01-XX** | **Fixed thumbnail canvas alignment (object-fit: contain)** |
|
||
| **11** | **2025-01-XX** | **Fixed thumbnail re-rendering on sidebar toggle** |
|
||
|
||
---
|
||
|
||
## Layout Architecture (EnvelopeViewer)
|
||
|
||
### HTML Structure
|
||
```html
|
||
<div class="pdf-viewer-container">
|
||
<div class="pdf-toolbar">
|
||
<!-- Zoom, page navigation, thumbnail toggle -->
|
||
</div>
|
||
|
||
<div class="pdf-frame">
|
||
@if (_showThumbnails) {
|
||
<div class="pdf-thumbnails" style="width: @(_thumbnailWidth)px">
|
||
<!-- Page previews -->
|
||
</div>
|
||
<div class="pdf-splitter" @onmousedown="OnSplitterMouseDown">
|
||
<!-- Resizable divider -->
|
||
</div>
|
||
}
|
||
<div class="pdf-canvas-wrapper">
|
||
<canvas id="pdf-canvas" class="pdf-canvas"></canvas>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
```
|
||
|
||
### CSS Flexbox Layout
|
||
```css
|
||
.pdf-frame {
|
||
display: flex;
|
||
flex-direction: row; /* Side-by-side */
|
||
align-items: stretch; /* Same height */
|
||
overflow: hidden;
|
||
}
|
||
|
||
.pdf-thumbnails {
|
||
flex-shrink: 0; /* Fixed width */
|
||
width: 260px; /* Dynamic via inline style */
|
||
border-right: none; /* Seamless join with splitter */
|
||
}
|
||
|
||
.pdf-splitter {
|
||
width: 4px;
|
||
cursor: col-resize;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.pdf-canvas-wrapper {
|
||
flex: 1; /* Fill remaining space */
|
||
overflow: auto; /* Scrollable for zoom */
|
||
padding: 2rem;
|
||
text-align: center;
|
||
}
|
||
```
|
||
|
||
### Resizable Splitter Workflow
|
||
```
|
||
1. User hovers splitter ? cursor: col-resize (?)
|
||
2. Mouse down ? OnSplitterMouseDown(e)
|
||
- _isResizing = true
|
||
- Store start position (clientX) and width
|
||
- Add 'resizing' class to body (prevent text selection)
|
||
- Call pdfViewer.startResize()
|
||
|
||
3. Mouse move (global) ? OnSplitterMouseMove(clientX)
|
||
- Calculate delta = clientX - startX
|
||
- newWidth = startWidth + delta
|
||
- Clamp to 150-400px range
|
||
- Update _thumbnailWidth
|
||
- StateHasChanged() for reactive UI
|
||
|
||
4. Mouse up (global) ? OnSplitterMouseUp()
|
||
- _isResizing = false
|
||
- Remove 'resizing' class
|
||
- Save to localStorage("envelopeViewer_thumbnailWidth")
|
||
```
|
||
|
||
### Responsive Behavior
|
||
- **Desktop (>768px)**: Flex row, side-by-side
|
||
- **Mobile (?768px)**: Flex column, thumbnails on top
|
||
- **Thumbnail toggle**: Controlled by `@if (_showThumbnails)` in Razor markup
|
||
|
||
|