Skip to content

Add DeepZoom static preview services and samples#379

Open
mattleibow wants to merge 39 commits intomainfrom
feature/deep-zoom-static-preview
Open

Add DeepZoom static preview services and samples#379
mattleibow wants to merge 39 commits intomainfrom
feature/deep-zoom-static-preview

Conversation

@mattleibow
Copy link
Collaborator

Summary

Extracts the core DeepZoom static rendering system. This is the minimal set of services needed to load and render Deep Zoom collections and images without gestures, animations, or custom controls.

What's included

  • DeepZoom collection/image source parsers — DZC/DZI XML parsing (SKDeepZoomCollectionSource, SKDeepZoomImageSource)
  • Tile infrastructure — cache (SKDeepZoomTileCache), scheduler (SKDeepZoomTileScheduler), and HTTP/file fetchers
  • Controller (SKDeepZoomController) — orchestrates loading and delegates to tile services
  • Viewport (SKDeepZoomViewport) — centered-fit geometry (aspect ratio preserved, no cropping/stretching)
  • Renderer (SKDeepZoomRenderer) — draws best-resolution tiles onto SKCanvas
  • 436 unit tests in a dedicated SkiaSharp.Extended.DeepZoom.Tests project
  • Blazor sample page (Pages/DeepZoom.razor) — minimal static preview using SKCanvasView
  • MAUI sample page (Demos/DeepZoom/DeepZoomPage) — same concept, uses app-package assets
  • Documentationdeep-zoom.md, deep-zoom-blazor.md, deep-zoom-maui.md

What's NOT included (intentionally)

  • ❌ No gesture system (SKGestureTracker, SKGestureDetector, etc.)
  • ❌ No animation system (SKAnimationSpring, SKAnimationTimer, etc.)
  • ❌ No custom MAUI SKDeepZoomView control
  • ❌ No user interaction beyond canvas size

Architecture

The sample page passes a URI to SKDeepZoomController, which loads the DZI/DZC, manages tiles, and fires InvalidateRequired when new tiles are ready. On each paint, the page calls SetControlSize() then Render(). The only input is the canvas size — the image is always centered-fit.

Tests

dotnet test tests/SkiaSharp.Extended.DeepZoom.Tests/  # 436 pass
dotnet test tests/SkiaSharp.Extended.Tests/           # 188 pass (unchanged from main)
dotnet build samples/SkiaSharpDemo.Blazor/            # Build succeeded

Related: #378

Extract the core DeepZoom rendering system from PR #378:
- DeepZoom collection/image source parsers (DZC/DZI XML)
- Tile cache, scheduler, and HTTP/file fetchers
- Controller (orchestrates loading), viewport (centered-fit geometry), renderer (draws tiles)
- 436 unit tests (animation tests excluded — no dependency on gestures/animations)
- Blazor sample page (Deep Zoom Preview) with testgrid DZI static asset
- MAUI sample page (Deep Zoom Preview) with testgrid DZI app-package asset
- Documentation: deep-zoom.md, deep-zoom-blazor.md, deep-zoom-maui.md
- Solution file updated to include DeepZoom test project
- DeepZoom added to MAUI ExtendedDemos and Blazor NavMenu

No gestures, animations, or custom controls included.
This is a minimal static preview system for gigapixel images.
Images render centered-fit (aspect ratio preserved, no cropping/stretching).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
@github-actions
Copy link

github-actions bot commented Mar 13, 2026

📖 Documentation Preview

The documentation for this PR has been deployed and is available at:

🔗 View Staging Documentation

🔗 View Staging Blazor Sample

This preview will be updated automatically when you push new commits to this PR.


This comment is automatically updated by the documentation staging workflow.

… add README

- Blazor: remove duplicated testgrid assets from wwwroot/deepzoom/
- Blazor: load DZI directly from GitHub raw URL (no local duplication)
- Blazor: switch SKCanvasView → SKGLView for hardware-accelerated rendering
- Controller: SetControlSize() now refits viewport when canvas size changes,
  ensuring centered-fit works correctly in any aspect ratio (portrait/landscape)
- DeepZoom: add README.md with architecture overview and Mermaid diagrams
  showing component relationships, tile pipeline, and rendering flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- SKDeepZoomTileRequest: correct fields are {TileId, Priority}, not IsFallback/FallbackParent
- SKDeepZoomCollectionSubImage.Source is string? (URI path), not SKDeepZoomImageSource
- Remove incorrect inheritance arrow from SubImage → ImageSource

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
Blazor (DeepZoom.razor):
- Canvas now fills available viewport height using flexbox + calc(100vh)
- Added collapsible debug inspector sidebar showing: image source info
  (dimensions, tile size, levels, format, aspect ratio), viewport state
  (control size, origin, scale, zoom), tile cache stats (count/max/pending),
  and a live tile table with level/col/row/priority/cached status
- Added PendingTileCount property to SKDeepZoomController for inspector
- Inspector refreshes every 250ms during tile loading
- Window resize triggers canvas invalidate via JS interop
- Toggle button (fixed bottom-right) shows/hides the inspector

MAUI (DeepZoomPage.xaml.cs):
- Removed AppPackageFetcher and embedded testgrid assets
- Now fetches DZI and tiles from GitHub raw URL (same as Blazor)
- Removed TestGrid/ from Resources/Raw/

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Add deepZoomUnregisterResize JS function to remove the resize handler;
  call it in DisposeAsync so listeners don't accumulate on page navigation
- Switch Blazor page from IDisposable → IAsyncDisposable to cleanly await
  the JS interop call during disposal
- Use parameterless SKDeepZoomHttpTileFetcher() in both Blazor and MAUI
  samples so the fetcher owns and disposes its HttpClient (_ownsClient=true)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Blazor: zoom slider (0.1–10×) in toolbar + mouse drag to pan
  - Reset view button returns to fit-to-view
  - Inspector zoom label auto-syncs with viewport state
- MAUI: Slider in toolbar for zoom + PanGestureRecognizer for drag pan
  - Toolbar shows current zoom level label
- Both samples call controller.Pan() and controller.SetZoom() APIs
- Bare minimum interaction for testing tile loading at various zoom levels

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Remove pan direction inversion in both Blazor and MAUI samples
- Fix MAUI pan to compute frame-to-frame delta from TotalX/TotalY

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 13, 2026
- Remove MaxViewportWidth = fitWidth from FitToView() so users can
  zoom out beyond the initial fit without snapping back
- Track _userHasZoomed in controller; SetControlSize no longer resets
  the viewport to fit when the user has manually adjusted zoom/pan
- ResetView() and Load() clear the flag to restore auto-refit behaviour
- All pan/zoom methods set the flag

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
FitToView() no longer sets MaxViewportWidth to the fit width,
so users can zoom out past the initial fit level. Update the test
to assert double.MaxValue and update the comment accordingly.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Zoom range:
- Blazor and MAUI slider max increased 10x → 50x (step 0.05)
- Native 1:1 pixel zoom for 8192px image on 1500px canvas is ~5.46x,
  so 50x allows substantial upscaling to inspect tile quality

Inspector - new 'Level Selection' section:
- Current level and max level (e.g. '13 / 13 ⚠️')
- Level dimensions (e.g. '8192 × 8192 px')
- Tile actual size (e.g. '256 × 256 px' - the stored file dimensions)
- Tile on screen size (pixels the tile occupies at current zoom)
- Native 1:1 zoom indicator
- Warning message when at max level: 'Max detail — native resolution
  reached, zooming further upscales tiles'

Level selection explanation (why it stays at level 13 from zoom 2.73+):
The testgrid is 8192×8192, max level = 13. GetOptimalLevel selects the
lowest level where levelWidth > controlWidth/viewportWidth. For a 1500px
canvas, level 13 (8192px) is selected at zoom ≥ 8192/1500/~2 ≈ 2.73x.
Beyond that there are no higher levels, so it stays at 13 — correct.

Testgrid DZI has Overlap=0 (tiles were generated without overlap pixels).
The generate-dzi.cs script already defaults to overlap=1 for new images.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Inspector bug fix:
- tilePxH was computed as (TileSize * Scale / levelH) * AspectRatio which
  is wrong for non-square images. Correct formula divides by AspectRatio:
  tilePxH = TileSize * Scale / levelH / AspectRatio

Testgrid regeneration:
- Recreated 8192x8192 source image as a colored grid pattern with cell
  labels (using PIL in a helper script)
- Ran scripts/generate-dzi.cs with --overlap 1 to generate all 14 levels
  (0-13) with 1px tile overlap as the user requested
- testgrid.dzi now has Overlap='1' instead of Overlap='0'
- Also committed scripts/generate-dzi.cs to this branch for reproducibility

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Previously the ⚠️ appeared whenever level 13 was selected, even at zoom
2.73x where it is the correct level and no upscaling is happening.

Now 'isUpscaling' is true only when zoom > nativeZoom (the 1:1 pixel
mapping threshold, ≈ imageWidth / controlWidth). This means the warning
appears only when the renderer would need a higher level than maxLevel
but none exists — i.e. tiles are genuinely being upscaled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Both Blazor and MAUI samples now bake the current git branch into the
assembly at build time via AssemblyMetadataAttribute('GitBranch', ...).
At runtime the URL is constructed from the embedded branch name, so:
- main builds fetch tiles from main
- PR builds fetch tiles from their own branch

Auto-detection: A 'DetectGitBranch' MSBuild target runs 'git rev-parse
--abbrev-ref HEAD' before the build. Falls back to 'main' if git is not
available or the property is explicitly overridden by CI via -p:GitBranch.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
Previous approach added AssemblyAttribute items inside a target's
<ItemGroup> before GenerateAssemblyInfo — but GenerateAssemblyInfo
had already captured the item list at evaluation time, so GitBranch
was always 'main'.

New approach: a 'DetectAndWriteGitBranch' target (BeforeTargets=CoreCompile)
writes a small generated .cs file to the intermediate output directory and
adds it to the Compile item group at target execution time. This is picked
up by CoreCompile (the actual C# compiler), so the branch value is correct.

Also fixed:
- MAUI csproj had unclosed <PropertyGroup> tag (Bug 1)
- Detached HEAD fallback ('HEAD' → 'main') for CI shallow checkouts (Bug 3)

Verification on branch 'feature/deep-zoom-static-preview':
  obj/.../GitBranchInfo.cs contains 'feature/deep-zoom-static-preview'
  The DLL contains the correct branch name at offset 90216

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 14, 2026
- Reads GitHub Actions (GITHUB_HEAD_REF, GITHUB_REF_NAME) env vars
- Reads Azure DevOps (BUILD_SOURCEBRANCH) env var with refs/heads/ stripping
- Falls back to local git, then to 'main'
- Config embedded as resource, read at runtime via GetManifestResourceStream
- Removes AssemblyMetadataAttribute/GitBranchInfo.cs approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
5.5ch was too narrow for the new 0.01-100 zoom range,
still causing layout shift at 9.99->10.00 transitions.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Gradient: diagonal warm (peach, 55α) → cool (blue, 55α) tint across full image
- Computed per-tile using image-space coordinates for seamless consistency at all zoom levels
- Color blocks: 1024×1024px (4×4 tiles at max zoom), no borders, pastel palette
- Level labels: 'L{level}' in top-left corner of each tile
- Close PR #381 (testgrid work consolidated here)
- Regenerate all 21,853 tiles

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Debug borders: SKColor(128,128,128,80) → SKColor(60,60,60,160) — darker gray at 63% alpha
- Testgrid gradient: alpha 55 → 110 (~43%), peach/blue tones slightly deepened
- Regenerate all 21,853 testgrid tiles with the stronger gradient

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Add ISKDeepZoomTileCache interface with async PutAsync() for extensible caching
- SKDeepZoomTileCache implements ISKDeepZoomTileCache (PutAsync calls Put synchronously)
- SKDeepZoomController accepts ISKDeepZoomTileCache? in constructor (default: built-in LRU)
- SKDeepZoomRenderer and SKDeepZoomTileScheduler use ISKDeepZoomTileCache throughout
- Controller LoadTileAsync now awaits cache.PutAsync() enabling delay decorators

- Add DelayTileCache in Blazor sample: wraps any ISKDeepZoomTileCache and adds
  configurable random delay (0-2500ms) to PutAsync to simulate slow tile delivery
- Blazor inspector gets 'Cache Delay' section: enable checkbox + min/max sliders
  (0-2500ms range, 10ms step)

- Convert SKDeepZoomTileId to readonly record struct (Level, Col, Row positional)
- Convert SKDeepZoomViewportState to readonly record struct (ViewportWidth, OriginX, OriginY)
- Both have custom GetHashCode for netstandard2.0 compatibility (no HashCode.Combine)
- Add Polyfills.cs with IsExternalInit shim for netstandard2.0 record support
- Set LangVersion=latest in SkiaSharp.Extended.csproj

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
…city

- Add Put(id, bitmap) to ISKDeepZoomTileCache for sync operations (used by tests)
- DelayTileCache.Put() delegates to inner cache without delay (delay only on PutAsync)
- Override ToString() on SKDeepZoomTileId record struct to preserve compact format '(L,C,R)'
  (record structs auto-generate verbose 'SKDeepZoomTileId { Level = L, ... }' format)
- Replace hardcoded 1024 in Blazor inspector with CacheCapacity constant

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
…cache support

- Remove ShowDebugStats from renderer and controller (inspector replaces it)
- Rename SKDeepZoomTileCache → SKDeepZoomMemoryTileCache for clarity
- Add TryGetAsync to ISKDeepZoomTileCache for async tiered cache support
- Controller LoadTileAsync checks TryGetAsync before network fetch
- Add BrowserStorageTileCache: sessionStorage-backed L2 cache via JS interop
- Redesign inspector cache section: cache mode (memory/browser), size slider,
  delay controls, clear button
- Add RebuildControllerCache() to swap cache at runtime without page reload
- Fix test compilation: rename all SKDeepZoomTileCache → SKDeepZoomMemoryTileCache
- Fix Record.Exception() calls for xUnit v3 compatibility
- All 436 tests pass

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Save TileSource BEFORE RebuildControllerCache so image is preserved
  after cache type/size changes
- Fix double-dispose: _delayCache.Dispose() cascades to inner cache;
  _browserCache no longer disposed separately
- Add _innerCache field to clearly track ownership in cache chain
- Cap BrowserStorageTileCache._memIndex at 512 entries (tiles beyond
  the cap remain in sessionStorage, accessible via TryGetAsync)
- PutAsync only populates memIndex on successful sessionStorage write
- Remove ShowDebugStats reference from README (feature removed)
- Rename SKDeepZoomTileCache → SKDeepZoomMemoryTileCache in README

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Restructure overview (deep-zoom.md): architecture diagram, quick start,
  image source sections, programmatic navigation, events table
- New deep-zoom-controller.md: full controller API, viewport coordinate
  system, zoom semantics, NativeZoom, SKDeepZoomImageSource/CollectionSource
- New deep-zoom-caching.md: ISKDeepZoomTileCache interface, SKDeepZoomMemoryTileCache,
  tiered cache-aside pattern, custom cache examples, SKDeepZoomTileId docs
- New deep-zoom-fetching.md: ISKDeepZoomTileFetcher, built-in fetchers,
  custom fetcher examples (app package, auth headers, pre-loaded)
- Update deep-zoom-blazor.md: current API (IAsyncDisposable, DZC loading,
  custom cache, pan/zoom wiring)
- Update deep-zoom-maui.md: current API, pan/zoom gestures, DZC loading
- Add Deep Zoom section to toc.yml with all 6 sub-pages
- Rewrite README.md: concise class reference table, minimal usage snippet,
  architecture notes, coordinate system explanation
- Add XML doc comments to SKDeepZoomFileTileFetcher and SKDeepZoomTileRequest

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Fix cumulative TotalX/Y bug in MAUI pan gesture example: track delta
  with _lastPanX/_lastPanY instead of passing TotalX/Y directly
- Add SKDeepZoomViewportState section to controller docs (with/record,
  save/restore, use for undo/redo and navigation history)
- Add SKDeepZoomCollectionSubImage section (properties table)
- Add SKDeepZoomDisplayRect section (sparse DZI regions)
- Add SKDeepZoomTileRequest section (scheduler output, priority ordering)
- Add EnableLodBlending to renderer options table in controller docs
- Add constructor XML doc comment to SKDeepZoomCollectionSubImage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
…lending, reorganize docs

- Create ISKDeepZoomRenderer: pluggable renderer interface with Dispose()
- SKDeepZoomRenderer implements ISKDeepZoomRenderer; removes ShowTileBorders
  (debug feature moves to sample layer via decorator pattern)
- SKDeepZoomController accepts ISKDeepZoomRenderer? in constructor
- SKDeepZoomController.Renderer type changed to ISKDeepZoomRenderer
- SKDeepZoomController.ShowTileBorders removed (use DebugBorderRenderer)
- Add SKDeepZoomRenderer.GetTileDestRect(public) for decorator use
- Create DebugBorderRenderer in Blazor sample: wraps any ISKDeepZoomRenderer,
  adds ShowTileBorders + forwards EnableLodBlending to core renderer
- Add EnableLodBlending checkbox to inspector Debug section
- Move docs to docs/docs/deep-zoom/ subfolder:
    deep-zoom.md → deep-zoom/index.md
    deep-zoom-controller.md → deep-zoom/controller.md
    deep-zoom-caching.md → deep-zoom/caching.md
    deep-zoom-fetching.md → deep-zoom/fetching.md
    deep-zoom-blazor.md → deep-zoom/blazor.md
    deep-zoom-maui.md → deep-zoom/maui.md
- Fix all internal doc links to use new relative paths
- Rewrite toc.yml: Deep Zoom as nested items section (visible in nav)
- Update controller.md: document ISKDeepZoomRenderer, decorator pattern,
  EnableLodBlending semantics (LOD blending explanation)
- Update tests: ShowTileBorders_* → EnableLodBlending_* on renderer

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
- Remove ShowTileBorders_DefaultIsFalse test (property removed from core)
- Rename ShowTileBorders_DrawsBorders → Render_WithBordersEnabled_DrawsTiles
- Remove renderer.ShowTileBorders = true assignments from 2 more tests
- Remove duplicate Render_EmptyCache_DoesNotThrow (exact duplicate)
- Rename controller Render_WithTileBorders_DoesNotThrow →
  Render_WithController_DoesNotThrow; remove ShowTileBorders = true
- All 434 tests pass; library, Blazor sample, and MAUI sample all build

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
The renderer had an else-if in Pass 2 that still drew fallback tiles when
EnableLodBlending=false, making both states visually identical.

Fix: remove the erroneous else-if block. When EnableLodBlending=false,
Pass 1 (fallback pass) is skipped and Pass 2 leaves missing tiles blank.

Visual difference is now observable by:
1. Open the Deep Zoom demo in Blazor
2. Enable Demo Cache, set delay min=500ms max=1500ms
3. Clear the cache, then zoom in to a high zoom level
4. LOD ON: blurry scaled-up parent tiles appear immediately as
   placeholders; sharpen tile-by-tile as hi-res loads
5. LOD OFF: white/blank rectangles where tiles are still loading;
   tiles pop in abruptly when loaded

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
Root cause: OnCacheDelayChanged was calling Cache.Clear() whenever the user
enabled tile delay. This removed the lower-level tiles that LOD blending needs
as fallbacks, making LOD on/off indistinguishable.

Fix:
- Remove Cache.Clear() from OnCacheDelayChanged — delay changes only affect
  future PutAsync calls; existing tiles must stay as potential LOD fallbacks
- Add live debug info panel: shows LOD state on renderer, tiles cached,
  pending fetches (proves the toggle is wired through to the renderer)
- Add 'Zoom to Max (LOD demo)' button: zooms to native 1:1 resolution so
  hi-res tiles must load — hold existing cache to preserve LOD fallbacks
- Add dz-debug-info CSS class for the info panel

How to see the LOD difference now:
1. Load an image, let fit-view tiles render (no delay needed at this stage)
2. Enable 'Simulate tile delay' and set to 1000-2000ms
3. Click 'Zoom to Max' (do NOT clear cache first)
4. While hi-res tiles load, toggle 'Enable LOD Blending':
   ON  = blurry lower-res tiles fill in immediately, then sharpen
   OFF = white/blank rectangles until tiles pop in

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
Sample page changes:
- Remove 'Zoom to Max (LOD demo)' button and OnLodDemo handler
- Remove live debug info panel (.dz-debug-info) and CSS
- Restore OnInvalidateRequired to simple canvas invalidate (no StateHasChanged)
- Keep the OnCacheDelayChanged fix (no Cache.Clear when enabling delay)

Docs (docs/docs/deep-zoom/controller.md):
- Expand 'Renderer and LOD Blending' section with full explanation
- Document the two-pass rendering strategy (Pass 1: fallbacks, Pass 2: exact)
- Add 'when to use each mode' guidance with use-case table
- Explain when LOD OFF is preferable: scientific/medical imaging,
  pixel-accurate rendering, load-performance testing, bandwidth-constrained apps
- Add tip on how to see the difference interactively

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
github-actions bot pushed a commit that referenced this pull request Mar 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant