diff --git a/Directory.Packages.props b/Directory.Packages.props
index 0cdca7b..f6013d8 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -7,6 +7,7 @@
-->
+
diff --git a/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs b/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs
new file mode 100644
index 0000000..7f34799
--- /dev/null
+++ b/JitHub.Dashboard.Layouts.Tests/CardDeckLayoutMathTests.cs
@@ -0,0 +1,249 @@
+using System.Linq;
+using FluentAssertions;
+using JitHub.Dashboard.Layouts;
+
+namespace JitHub.Dashboard.Layouts.Tests;
+
+public sealed class CardDeckLayoutMathTests
+{
+ private static int ComputeDeckDepth(int itemIndex, int itemCount, int deckMaxVisibleCount)
+ {
+ var visualIndexFromFront = (itemCount - 1) - itemIndex;
+ return Math.Min(Math.Max(0, visualIndexFromFront), deckMaxVisibleCount - 1);
+ }
+
+ [Test]
+ public void Auto_mode_uses_grid_at_or_above_threshold()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Auto,
+ ModeWidthThreshold = 900,
+ CardMinWidth = 300,
+ CardHeight = 200,
+ Spacing = 16,
+ MaxColumns = 3,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(900, 800), itemCount: 5, settings);
+
+ items.Should().HaveCount(5);
+ items.Select(i => i.Transform).All(t => t.RotationDegrees == 0 && t.TranslateY == 0).Should().BeTrue();
+ }
+
+ [Test]
+ public void Auto_mode_uses_deck_below_threshold()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Auto,
+ ModeWidthThreshold = 900,
+ CardMinWidth = 300,
+ CardHeight = 200,
+ DeckMaxVisibleCount = 4,
+ DeckOffsetY = 10,
+ DeckAngleStepDegrees = 2,
+ DeckScaleStep = 0,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(899, 800), itemCount: 6, settings);
+
+ items.Should().HaveCount(6);
+
+ // IMPORTANT: last item is the “front/top” card.
+ items[5].Transform.TranslateY.Should().Be(0);
+
+ // The deck has a small, stable per-item Y jitter.
+ // Validate the expected “10/20/30” spacing envelope with generous bounds.
+ items[4].Transform.TranslateY.Should().BeInRange(8.5, 11.5);
+ items[3].Transform.TranslateY.Should().BeInRange(17.0, 23.0);
+ items[2].Transform.TranslateY.Should().BeInRange(26.0, 34.0);
+
+ // clamped (further back than max visible) – still around the deepest offset.
+ items[1].Transform.TranslateY.Should().BeInRange(26.0, 34.0);
+ items[0].Transform.TranslateY.Should().BeInRange(26.0, 34.0);
+ }
+
+ [Test]
+ public void Grid_layout_centers_gutter_and_uses_fixed_card_size()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Grid,
+ CardMinWidth = 200,
+ CardHeight = 100,
+ Spacing = 20,
+ MaxColumns = 3,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(700, 800), itemCount: 4, settings);
+
+ // 3 columns fit: row width = 3*200 + 2*20 = 640; gutter = (700-640)/2 = 30
+ items[0].Rect.Should().Be(new LayoutRect(30, 0, 200, 100));
+ items[1].Rect.Should().Be(new LayoutRect(250, 0, 200, 100));
+ items[2].Rect.Should().Be(new LayoutRect(470, 0, 200, 100));
+ items[3].Rect.Should().Be(new LayoutRect(30, 120, 200, 100));
+ }
+
+ [Test]
+ public void Grid_layout_respects_max_columns_and_never_overlaps()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Grid,
+ CardMinWidth = 200,
+ CardHeight = 100,
+ Spacing = 16,
+ MaxColumns = 2,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(2000, 800), itemCount: 6, settings);
+ items.Should().HaveCount(6);
+
+ // With maxColumns=2, the 3rd item must start a new row.
+ items[0].Rect.Y.Should().Be(0);
+ items[1].Rect.Y.Should().Be(0);
+ items[2].Rect.Y.Should().Be(116);
+
+ // Basic non-overlap check.
+ for (var i = 0; i < items.Count; i++)
+ {
+ items[i].Rect.IsFinite.Should().BeTrue();
+ items[i].Rect.Width.Should().Be(200);
+ items[i].Rect.Height.Should().Be(100);
+
+ for (var j = i + 1; j < items.Count; j++)
+ {
+ var a = items[i].Rect;
+ var b = items[j].Rect;
+
+ var aRight = a.X + a.Width;
+ var bRight = b.X + b.Width;
+ var aBottom = a.Y + a.Height;
+ var bBottom = b.Y + b.Height;
+
+ var overlapX = Math.Min(aRight, bRight) - Math.Max(a.X, b.X);
+ var overlapY = Math.Min(aBottom, bBottom) - Math.Max(a.Y, b.Y);
+
+ (overlapX > 0 && overlapY > 0).Should().BeFalse();
+ }
+ }
+ }
+
+ [Test]
+ public void Deck_layout_has_stable_depth_envelope_and_z_ordering()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Deck,
+ CardMinWidth = 320,
+ CardHeight = 180,
+ DeckMaxVisibleCount = 4,
+ DeckOffsetY = 10,
+ DeckAngleStepDegrees = 2,
+ DeckScaleStep = 0.02,
+ DeckBaseZ = 8,
+ DeckZStep = 6,
+ };
+
+ const int itemCount = 8;
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(600, 800), itemCount, settings);
+ items.Should().HaveCount(itemCount);
+
+ // All deck cards share the same base rect.
+ items.Select(i => i.Rect).Distinct().Should().HaveCount(1);
+
+ var visibleDepth = Math.Min(itemCount, settings.DeckMaxVisibleCount);
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var depth = ComputeDeckDepth(i, itemCount, settings.DeckMaxVisibleCount);
+ var transform = items[i].Transform;
+
+ transform.IsFinite.Should().BeTrue();
+ items[i].Z.Should().Be(settings.DeckBaseZ + (visibleDepth - 1 - depth) * settings.DeckZStep);
+
+ // Depth 0 is the “front/top” card.
+ if (depth == 0)
+ {
+ transform.TranslateY.Should().Be(0);
+ transform.Scale.Should().Be(1);
+ transform.RotationDegrees.Should().Be(0);
+ }
+ else
+ {
+ // Expected translate Y is depth * offset with small jitter.
+ var expected = depth * settings.DeckOffsetY;
+ transform.TranslateY.Should().BeInRange(expected - 4, expected + 4);
+
+ // Rotation should stay within the depth-based envelope.
+ Math.Abs(transform.RotationDegrees).Should().BeLessThanOrEqualTo(Math.Abs(settings.DeckAngleStepDegrees) * depth);
+
+ // Scale tapers with depth, but should remain positive.
+ transform.Scale.Should().BeGreaterThan(0);
+ transform.Scale.Should().BeLessThan(1);
+ }
+ }
+
+ // The last item is front/top and should have the highest Z.
+ items[^1].Z.Should().Be(items.Max(i => i.Z));
+ }
+
+ [Test]
+ public void Auto_mode_switching_is_stable_for_non_finite_thresholds()
+ {
+ // Non-finite thresholds sanitize to 0, which means any non-negative width selects Grid.
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Auto,
+ ModeWidthThreshold = double.NaN,
+ CardMinWidth = 320,
+ CardHeight = 180,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(1, 1), itemCount: 2, settings);
+ items.Select(i => i.Transform.RotationDegrees).All(r => r == 0).Should().BeTrue();
+ }
+
+ [Test]
+ public void Layout_is_finite_for_common_inputs()
+ {
+ var settings = CardDeckLayoutSettings.Default;
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(1024, 768), itemCount: 25, settings);
+
+ items.Should().HaveCount(25);
+ items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue();
+ }
+
+ [Test]
+ public void Layout_is_finite_for_pathological_inputs()
+ {
+ var settings = CardDeckLayoutSettings.Default with
+ {
+ Mode = CardDeckLayoutMode.Auto,
+ ModeWidthThreshold = double.PositiveInfinity,
+ CardMinWidth = double.NaN,
+ CardHeight = -10,
+ Spacing = double.NegativeInfinity,
+ DeckOffsetY = double.NaN,
+ DeckAngleStepDegrees = double.PositiveInfinity,
+ DeckScaleStep = double.NegativeInfinity,
+ DeckBaseZ = double.NaN,
+ DeckZStep = double.PositiveInfinity,
+ };
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(double.NaN, double.PositiveInfinity), itemCount: 50, settings);
+ items.Should().HaveCount(50);
+ items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue();
+ }
+
+ [Test]
+ public void Layout_is_stable_for_large_item_counts()
+ {
+ var settings = CardDeckLayoutSettings.Default;
+
+ var items = CardDeckLayoutMath.Compute(new LayoutSize(1280, 720), itemCount: 2000, settings);
+ items.Should().HaveCount(2000);
+ items.All(i => i.Rect.IsFinite && i.Transform.IsFinite).Should().BeTrue();
+ }
+}
diff --git a/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj b/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj
new file mode 100644
index 0000000..1baf4eb
--- /dev/null
+++ b/JitHub.Dashboard.Layouts.Tests/JitHub.Dashboard.Layouts.Tests.csproj
@@ -0,0 +1,28 @@
+
+
+
+ net10.0
+ latest
+ enable
+ enable
+ false
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs b/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs
new file mode 100644
index 0000000..bcd577e
--- /dev/null
+++ b/JitHub.Dashboard.Layouts.Tests/LayoutProjectSmokeTests.cs
@@ -0,0 +1,12 @@
+using FluentAssertions;
+
+namespace JitHub.Dashboard.Layouts.Tests;
+
+public sealed class LayoutProjectSmokeTests
+{
+ [Test]
+ public void Project_wires_up()
+ {
+ typeof(JitHub.Dashboard.Layouts.AssemblyMarker).Should().NotBeNull();
+ }
+}
diff --git a/JitHub.Dashboard.Layouts/AssemblyMarker.cs b/JitHub.Dashboard.Layouts/AssemblyMarker.cs
new file mode 100644
index 0000000..24393d3
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/AssemblyMarker.cs
@@ -0,0 +1,5 @@
+namespace JitHub.Dashboard.Layouts;
+
+public static class AssemblyMarker
+{
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckElevation.cs b/JitHub.Dashboard.Layouts/CardDeckElevation.cs
new file mode 100644
index 0000000..d711cf8
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckElevation.cs
@@ -0,0 +1,86 @@
+using System.Numerics;
+using Microsoft.UI.Xaml;
+
+namespace JitHub.Dashboard.Layouts;
+
+public static class CardDeckElevation
+{
+ public static double GetBaseZ(DependencyObject obj)
+ => ReadDoubleOr(obj, BaseZProperty, fallback: double.NaN);
+
+ public static void SetBaseZ(DependencyObject obj, double value)
+ => obj.SetValue(BaseZProperty, value);
+
+ public static void ClearBaseZ(DependencyObject obj)
+ => obj.ClearValue(BaseZProperty);
+
+ public static readonly DependencyProperty BaseZProperty =
+ DependencyProperty.RegisterAttached(
+ "BaseZ",
+ typeof(double),
+ typeof(CardDeckElevation),
+ new PropertyMetadata(double.NaN, OnZChanged));
+
+ public static double GetAnimationOffsetZ(DependencyObject obj)
+ => ReadDoubleOr(obj, AnimationOffsetZProperty, fallback: 0d);
+
+ public static void SetAnimationOffsetZ(DependencyObject obj, double value)
+ => obj.SetValue(AnimationOffsetZProperty, value);
+
+ public static void ClearAnimationOffsetZ(DependencyObject obj)
+ => obj.ClearValue(AnimationOffsetZProperty);
+
+ public static readonly DependencyProperty AnimationOffsetZProperty =
+ DependencyProperty.RegisterAttached(
+ "AnimationOffsetZ",
+ typeof(double),
+ typeof(CardDeckElevation),
+ new PropertyMetadata(0d, OnZChanged));
+
+ private static void OnZChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not UIElement element)
+ {
+ return;
+ }
+
+ var baseZ = ReadDoubleOr(element, BaseZProperty, fallback: double.NaN);
+ if (!double.IsFinite(baseZ))
+ {
+ return;
+ }
+
+ var offsetZ = ReadDoubleOr(element, AnimationOffsetZProperty, fallback: 0d);
+ if (!double.IsFinite(offsetZ))
+ {
+ offsetZ = 0;
+ }
+
+ var targetZ = baseZ + offsetZ;
+ if (!double.IsFinite(targetZ))
+ {
+ targetZ = 0;
+ }
+
+ var t = element.Translation;
+ element.Translation = new Vector3(t.X, t.Y, (float)targetZ);
+ }
+
+ private static double ReadDoubleOr(DependencyObject obj, DependencyProperty property, double fallback)
+ {
+ try
+ {
+ return obj.GetValue(property) switch
+ {
+ double d when double.IsFinite(d) => d,
+ float f when float.IsFinite(f) => f,
+ int i => i,
+ _ => fallback,
+ };
+ }
+ catch
+ {
+ return fallback;
+ }
+ }
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckLayout.cs b/JitHub.Dashboard.Layouts/CardDeckLayout.cs
new file mode 100644
index 0000000..84ac842
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckLayout.cs
@@ -0,0 +1,329 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Media;
+using System.Numerics;
+using System.Runtime.CompilerServices;
+using Windows.Foundation;
+
+namespace JitHub.Dashboard.Layouts;
+
+public sealed class CardDeckLayout : VirtualizingLayout
+{
+ private sealed class BaseZState
+ {
+ public BaseZState(float value) => Value = value;
+ public float Value { get; }
+ }
+
+ private readonly ConditionalWeakTable _baseZBeforeDeck = new();
+
+ public CardDeckLayoutMode Mode
+ {
+ get => (CardDeckLayoutMode)GetValue(ModeProperty);
+ set => SetValue(ModeProperty, value);
+ }
+
+ public static readonly DependencyProperty ModeProperty =
+ DependencyProperty.Register(
+ nameof(Mode),
+ typeof(CardDeckLayoutMode),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(CardDeckLayoutMode.Auto, OnLayoutPropertyChanged));
+
+ public double ModeWidthThreshold
+ {
+ get => (double)GetValue(ModeWidthThresholdProperty);
+ set => SetValue(ModeWidthThresholdProperty, value);
+ }
+
+ public static readonly DependencyProperty ModeWidthThresholdProperty =
+ DependencyProperty.Register(
+ nameof(ModeWidthThreshold),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(900d, OnLayoutPropertyChanged));
+
+ public double CardMinWidth
+ {
+ get => (double)GetValue(CardMinWidthProperty);
+ set => SetValue(CardMinWidthProperty, value);
+ }
+
+ public static readonly DependencyProperty CardMinWidthProperty =
+ DependencyProperty.Register(
+ nameof(CardMinWidth),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(320d, OnLayoutPropertyChanged));
+
+ public double CardHeight
+ {
+ get => (double)GetValue(CardHeightProperty);
+ set => SetValue(CardHeightProperty, value);
+ }
+
+ public static readonly DependencyProperty CardHeightProperty =
+ DependencyProperty.Register(
+ nameof(CardHeight),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(180d, OnLayoutPropertyChanged));
+
+ public double Spacing
+ {
+ get => (double)GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public static readonly DependencyProperty SpacingProperty =
+ DependencyProperty.Register(
+ nameof(Spacing),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(16d, OnLayoutPropertyChanged));
+
+ public int MaxColumns
+ {
+ get => (int)GetValue(MaxColumnsProperty);
+ set => SetValue(MaxColumnsProperty, value);
+ }
+
+ public static readonly DependencyProperty MaxColumnsProperty =
+ DependencyProperty.Register(
+ nameof(MaxColumns),
+ typeof(int),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(3, OnLayoutPropertyChanged));
+
+ public int DeckMaxVisibleCount
+ {
+ get => (int)GetValue(DeckMaxVisibleCountProperty);
+ set => SetValue(DeckMaxVisibleCountProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckMaxVisibleCountProperty =
+ DependencyProperty.Register(
+ nameof(DeckMaxVisibleCount),
+ typeof(int),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(5, OnLayoutPropertyChanged));
+
+ public double DeckOffsetY
+ {
+ get => (double)GetValue(DeckOffsetYProperty);
+ set => SetValue(DeckOffsetYProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckOffsetYProperty =
+ DependencyProperty.Register(
+ nameof(DeckOffsetY),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(8d, OnLayoutPropertyChanged));
+
+ public double DeckAngleStepDegrees
+ {
+ get => (double)GetValue(DeckAngleStepDegreesProperty);
+ set => SetValue(DeckAngleStepDegreesProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckAngleStepDegreesProperty =
+ DependencyProperty.Register(
+ nameof(DeckAngleStepDegrees),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(1.25d, OnLayoutPropertyChanged));
+
+ public double DeckScaleStep
+ {
+ get => (double)GetValue(DeckScaleStepProperty);
+ set => SetValue(DeckScaleStepProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckScaleStepProperty =
+ DependencyProperty.Register(
+ nameof(DeckScaleStep),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(0.02d, OnLayoutPropertyChanged));
+
+ public double DeckBaseZ
+ {
+ get => (double)GetValue(DeckBaseZProperty);
+ set => SetValue(DeckBaseZProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckBaseZProperty =
+ DependencyProperty.Register(
+ nameof(DeckBaseZ),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(8d, OnLayoutPropertyChanged));
+
+ public double DeckZStep
+ {
+ get => (double)GetValue(DeckZStepProperty);
+ set => SetValue(DeckZStepProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckZStepProperty =
+ DependencyProperty.Register(
+ nameof(DeckZStep),
+ typeof(double),
+ typeof(CardDeckLayout),
+ new PropertyMetadata(6d, OnLayoutPropertyChanged));
+
+ protected override Size MeasureOverride(VirtualizingLayoutContext context, Size availableSize)
+ {
+ var items = CardDeckLayoutMath.Compute(
+ new LayoutSize(availableSize.Width, availableSize.Height),
+ context.ItemCount,
+ GetSettings());
+
+ var desiredWidth = availableSize.Width;
+ var desiredHeight = 0d;
+
+ var cardHeight = Math.Max(1, CardHeight);
+ var spacing = Math.Max(0, Spacing);
+
+ if (items.Count > 0)
+ {
+ if (ResolveMode(availableSize.Width) == CardDeckLayoutMode.Grid)
+ {
+ var columns = Math.Max(1, Math.Min(MaxColumns, items.Count));
+ var rows = (int)Math.Ceiling(items.Count / (double)columns);
+ desiredHeight = rows * cardHeight + Math.Max(0, rows - 1) * spacing;
+ }
+ else
+ {
+ var visibleDepth = Math.Min(items.Count, Math.Max(1, DeckMaxVisibleCount));
+ desiredHeight = cardHeight + Math.Max(0, visibleDepth - 1) * Math.Max(0, DeckOffsetY);
+ }
+ }
+
+ for (var i = 0; i < context.ItemCount; i++)
+ {
+ var element = context.GetOrCreateElementAt(i);
+ element.Measure(new Size(Math.Max(0, CardMinWidth), Math.Max(0, CardHeight)));
+ }
+
+ return new Size(desiredWidth, desiredHeight);
+ }
+
+ protected override Size ArrangeOverride(VirtualizingLayoutContext context, Size finalSize)
+ {
+ var mode = ResolveMode(finalSize.Width);
+ var items = CardDeckLayoutMath.Compute(
+ new LayoutSize(finalSize.Width, finalSize.Height),
+ context.ItemCount,
+ GetSettings());
+
+ for (var i = 0; i < items.Count; i++)
+ {
+ var element = context.GetOrCreateElementAt(i);
+ var item = items[i];
+ element.Arrange(new Rect(item.Rect.X, item.Rect.Y, item.Rect.Width, item.Rect.Height));
+
+ ApplyTransform(element, item.Transform);
+
+ if (mode == CardDeckLayoutMode.Deck && double.IsFinite(item.Z))
+ {
+ if (!_baseZBeforeDeck.TryGetValue(element, out _))
+ {
+ _baseZBeforeDeck.Add(element, new BaseZState(element.Translation.Z));
+ }
+
+ CardDeckElevation.SetBaseZ(element, item.Z);
+ }
+ else if (_baseZBeforeDeck.TryGetValue(element, out var baseState))
+ {
+ var t = element.Translation;
+ element.Translation = new Vector3(t.X, t.Y, baseState.Value);
+
+ CardDeckElevation.ClearBaseZ(element);
+ CardDeckElevation.SetAnimationOffsetZ(element, 0);
+ CardDeckElevation.ClearAnimationOffsetZ(element);
+ _baseZBeforeDeck.Remove(element);
+ }
+ }
+
+ return finalSize;
+ }
+
+ private CardDeckLayoutSettings GetSettings() => new(
+ Mode: Mode,
+ ModeWidthThreshold: ModeWidthThreshold,
+ CardMinWidth: CardMinWidth,
+ CardHeight: CardHeight,
+ Spacing: Spacing,
+ MaxColumns: MaxColumns,
+ DeckMaxVisibleCount: DeckMaxVisibleCount,
+ DeckOffsetY: DeckOffsetY,
+ DeckAngleStepDegrees: DeckAngleStepDegrees,
+ DeckScaleStep: DeckScaleStep,
+ DeckBaseZ: DeckBaseZ,
+ DeckZStep: DeckZStep);
+
+ private CardDeckLayoutMode ResolveMode(double width)
+ {
+ if (Mode != CardDeckLayoutMode.Auto)
+ {
+ return Mode;
+ }
+
+ return width >= ModeWidthThreshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck;
+ }
+
+ private static void OnLayoutPropertyChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is CardDeckLayout layout)
+ {
+ layout.InvalidateMeasure();
+ }
+ }
+
+ private static void ApplyTransform(UIElement element, LayoutTransform transform)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ // Important for morph animations:
+ // - Layout needs to set its own transform (deck offsets/rotation/scale)
+ // - A presenter may want to apply an additive animation transform on top
+ // Keep them separate to avoid fighting over the same CompositeTransform.
+ var (layoutTransform, _) = EnsureTransforms(element);
+
+ layoutTransform.TranslateX = transform.TranslateX;
+ layoutTransform.TranslateY = transform.TranslateY;
+ layoutTransform.Rotation = transform.RotationDegrees;
+ layoutTransform.ScaleX = transform.Scale;
+ layoutTransform.ScaleY = transform.Scale;
+ }
+
+ internal static (CompositeTransform Layout, CompositeTransform Animation) EnsureTransforms(UIElement element)
+ {
+ if (element.RenderTransform is TransformGroup group
+ && group.Children.Count >= 2
+ && group.Children[0] is CompositeTransform existingLayout
+ && group.Children[1] is CompositeTransform existingAnimation)
+ {
+ element.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5);
+ return (existingLayout, existingAnimation);
+ }
+
+ // Migrate any existing CompositeTransform into the layout slot.
+ var layout = element.RenderTransform as CompositeTransform ?? new CompositeTransform();
+ var animation = new CompositeTransform();
+
+ var newGroup = new TransformGroup();
+ newGroup.Children.Add(layout);
+ newGroup.Children.Add(animation);
+
+ element.RenderTransform = newGroup;
+ element.RenderTransformOrigin = new Windows.Foundation.Point(0.5, 0.5);
+
+ return (layout, animation);
+ }
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs
new file mode 100644
index 0000000..25a2498
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckLayoutMath.cs
@@ -0,0 +1,288 @@
+namespace JitHub.Dashboard.Layouts;
+
+public static class CardDeckLayoutMath
+{
+ public static IReadOnlyList Compute(LayoutSize available, int itemCount, CardDeckLayoutSettings settings)
+ {
+ if (itemCount < 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(itemCount));
+ }
+
+ if (itemCount == 0)
+ {
+ return Array.Empty();
+ }
+
+ var safeWidth = SanitizeSize(available.Width);
+ var safeHeight = SanitizeSize(available.Height);
+
+ var cardMinWidth = Math.Max(1, SanitizeSize(settings.CardMinWidth));
+ var cardHeight = Math.Max(1, SanitizeSize(settings.CardHeight));
+ var spacing = Math.Max(0, SanitizeSize(settings.Spacing));
+
+ var mode = settings.Mode;
+ if (mode == CardDeckLayoutMode.Auto)
+ {
+ var threshold = Math.Max(0, SanitizeSize(settings.ModeWidthThreshold));
+ mode = safeWidth >= threshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck;
+ }
+
+ return mode switch
+ {
+ CardDeckLayoutMode.Grid => ComputeGrid(
+ width: safeWidth,
+ height: safeHeight,
+ itemCount: itemCount,
+ cardWidth: cardMinWidth,
+ cardHeight: cardHeight,
+ spacing: spacing,
+ maxColumns: Math.Max(1, settings.MaxColumns)),
+
+ CardDeckLayoutMode.Deck => ComputeDeck(
+ width: safeWidth,
+ height: safeHeight,
+ itemCount: itemCount,
+ cardWidth: Math.Min(cardMinWidth, safeWidth <= 0 ? cardMinWidth : safeWidth),
+ cardHeight: cardHeight,
+ deckMaxVisible: Math.Max(1, settings.DeckMaxVisibleCount),
+ deckOffsetY: SanitizeSize(settings.DeckOffsetY),
+ deckAngleStepDegrees: SanitizeSize(settings.DeckAngleStepDegrees),
+ deckScaleStep: SanitizeSize(settings.DeckScaleStep),
+ deckBaseZ: SanitizeSize(settings.DeckBaseZ),
+ deckZStep: SanitizeSize(settings.DeckZStep)),
+
+ _ => ComputeDeck(
+ width: safeWidth,
+ height: safeHeight,
+ itemCount: itemCount,
+ cardWidth: Math.Min(cardMinWidth, safeWidth <= 0 ? cardMinWidth : safeWidth),
+ cardHeight: cardHeight,
+ deckMaxVisible: Math.Max(1, settings.DeckMaxVisibleCount),
+ deckOffsetY: SanitizeSize(settings.DeckOffsetY),
+ deckAngleStepDegrees: SanitizeSize(settings.DeckAngleStepDegrees),
+ deckScaleStep: SanitizeSize(settings.DeckScaleStep),
+ deckBaseZ: SanitizeSize(settings.DeckBaseZ),
+ deckZStep: SanitizeSize(settings.DeckZStep)),
+ };
+ }
+
+ private static IReadOnlyList ComputeGrid(
+ double width,
+ double height,
+ int itemCount,
+ double cardWidth,
+ double cardHeight,
+ double spacing,
+ int maxColumns)
+ {
+ var columns = ComputeColumnCount(width, cardWidth, spacing, maxColumns);
+ var actualRowWidth = columns * cardWidth + (columns - 1) * spacing;
+ var gutterX = Math.Max(0, (width - actualRowWidth) / 2.0);
+
+ var list = new CardDeckLayoutItem[itemCount];
+ for (var i = 0; i < itemCount; i++)
+ {
+ var col = i % columns;
+ var row = i / columns;
+
+ var x = gutterX + col * (cardWidth + spacing);
+ var y = row * (cardHeight + spacing);
+
+ list[i] = new CardDeckLayoutItem(
+ Rect: new LayoutRect(x, y, cardWidth, cardHeight),
+ Transform: new LayoutTransform(0, 0, 0, 1),
+ Z: double.NaN);
+ }
+
+ return list;
+ }
+
+ private static IReadOnlyList ComputeDeck(
+ double width,
+ double height,
+ int itemCount,
+ double cardWidth,
+ double cardHeight,
+ int deckMaxVisible,
+ double deckOffsetY,
+ double deckAngleStepDegrees,
+ double deckScaleStep,
+ double deckBaseZ,
+ double deckZStep)
+ {
+ var x = Math.Max(0, (width - cardWidth) / 2.0);
+ var baseRect = new LayoutRect(x, 0, cardWidth, cardHeight);
+
+ var visibleDepth = Math.Min(itemCount, deckMaxVisible);
+
+ var list = new CardDeckLayoutItem[itemCount];
+ for (var i = 0; i < itemCount; i++)
+ {
+ // IMPORTANT: ItemsRepeater renders later indices on top.
+ // Treat the last item as the “front/top” card (depth=0), so removing it
+ // causes the remaining cards to advance (depth decreases).
+ var visualIndexFromFront = (itemCount - 1) - i;
+ var depth = Math.Min(Math.Max(0, visualIndexFromFront), deckMaxVisible - 1);
+
+ // The previous alternating pattern (0, -a, +2a, -3a, …) looks overly regular.
+ // Use a stable per-item scatter factor to get a more natural “deck” feel,
+ // while keeping the overall rotation envelope proportional to depth.
+ var rotation = ComputeDeckRotationDegrees(i, depth, deckAngleStepDegrees);
+
+ var translateX = ComputeDeckTranslateX(width, cardWidth, i, depth, deckMaxVisible);
+ var translateY = (depth * deckOffsetY) + ComputeDeckTranslateYJitter(i, depth, deckMaxVisible, deckOffsetY);
+
+ var scale = 1.0 - depth * deckScaleStep;
+ if (scale <= 0)
+ {
+ scale = 0.01;
+ }
+
+ var z = deckBaseZ;
+ if (deckZStep > 0 && visibleDepth > 1)
+ {
+ // Depth 0 (top card) should be visually closest (largest Z).
+ // As cards move forward after a dismissal, their depth decreases, so Z increases.
+ z = deckBaseZ + (visibleDepth - 1 - depth) * deckZStep;
+ }
+
+ list[i] = new CardDeckLayoutItem(
+ Rect: baseRect,
+ Transform: new LayoutTransform(translateX, translateY, rotation, scale),
+ Z: z);
+ }
+
+ return list;
+ }
+
+ private static double ComputeDeckTranslateX(double width, double cardWidth, int itemIndex, int depth, int deckMaxVisible)
+ {
+ if (depth <= 0 || deckMaxVisible <= 1)
+ {
+ return 0;
+ }
+
+ var horizontalRoom = Math.Max(0, (width - cardWidth) / 2.0);
+ if (horizontalRoom <= 0)
+ {
+ return 0;
+ }
+
+ // Keep it subtle and within the available gutter.
+ var maxOffsetX = Math.Min(horizontalRoom, Math.Min(16, cardWidth * 0.05));
+ if (maxOffsetX <= 0)
+ {
+ return 0;
+ }
+
+ var depthFactor = depth / (double)(deckMaxVisible - 1);
+ var scatter = HashToSignedUnit(itemIndex * 31 + 101);
+ return scatter * maxOffsetX * depthFactor;
+ }
+
+ private static double ComputeDeckTranslateYJitter(int itemIndex, int depth, int deckMaxVisible, double deckOffsetY)
+ {
+ if (depth <= 0 || deckMaxVisible <= 1)
+ {
+ return 0;
+ }
+
+ // Add a small, tasteful jitter so the stack feels less mechanically spaced.
+ var maxJitter = Math.Min(4, Math.Abs(deckOffsetY) * 0.35);
+ if (maxJitter <= 0)
+ {
+ return 0;
+ }
+
+ var depthFactor = depth / (double)(deckMaxVisible - 1);
+ var scatter = HashToSignedUnit(itemIndex * 37 + 203);
+ return scatter * maxJitter * depthFactor;
+ }
+
+ private static double ComputeDeckRotationDegrees(int itemIndex, int depth, double deckAngleStepDegrees)
+ {
+ if (depth <= 0 || deckAngleStepDegrees == 0)
+ {
+ return 0;
+ }
+
+ // Base rotation grows with depth.
+ var maxAbsRotation = Math.Abs(deckAngleStepDegrees) * depth;
+
+ // Scatter in [-1, +1]. Stable across runs.
+ var scatter = HashToSignedUnit(itemIndex + 17);
+
+ // Avoid cards that end up almost perfectly aligned (looks “too tidy”).
+ // Keep the minimum visible tilt modest.
+ const double minScatterAbs = 0.25;
+ if (Math.Abs(scatter) < minScatterAbs)
+ {
+ scatter = scatter < 0 ? -minScatterAbs : minScatterAbs;
+ }
+
+ // Keep it within the expected envelope.
+ return scatter * maxAbsRotation;
+ }
+
+ private static double HashToSignedUnit(int value)
+ {
+ // A small, fast, deterministic integer hash → [0,1) → [-1,1).
+ unchecked
+ {
+ uint x = (uint)value;
+ x ^= x >> 16;
+ x *= 0x7feb352d;
+ x ^= x >> 15;
+ x *= 0x846ca68b;
+ x ^= x >> 16;
+
+ var unit01 = (x & 0x00FFFFFF) / (double)0x01000000; // [0,1)
+ return (unit01 * 2.0) - 1.0; // [-1,1)
+ }
+ }
+
+ private static int ComputeColumnCount(double width, double cardWidth, double spacing, int maxColumns)
+ {
+ if (width <= 0)
+ {
+ return 1;
+ }
+
+ // How many fixed-width cards fit with spacing between them.
+ // columns*cardWidth + (columns-1)*spacing <= width
+ var denom = cardWidth + spacing;
+ if (denom <= 0)
+ {
+ return 1;
+ }
+
+ var columns = (int)Math.Floor((width + spacing) / denom);
+ if (columns < 1)
+ {
+ columns = 1;
+ }
+
+ if (columns > maxColumns)
+ {
+ columns = maxColumns;
+ }
+
+ return columns;
+ }
+
+ private static double SanitizeSize(double value)
+ {
+ if (!double.IsFinite(value))
+ {
+ return 0;
+ }
+
+ if (value < 0)
+ {
+ return 0;
+ }
+
+ return value;
+ }
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs
new file mode 100644
index 0000000..1bd5b63
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckLayoutMode.cs
@@ -0,0 +1,8 @@
+namespace JitHub.Dashboard.Layouts;
+
+public enum CardDeckLayoutMode
+{
+ Auto = 0,
+ Grid = 1,
+ Deck = 2,
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs b/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs
new file mode 100644
index 0000000..4dfab28
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckLayoutSettings.cs
@@ -0,0 +1,30 @@
+namespace JitHub.Dashboard.Layouts;
+
+public sealed record CardDeckLayoutSettings(
+ CardDeckLayoutMode Mode,
+ double ModeWidthThreshold,
+ double CardMinWidth,
+ double CardHeight,
+ double Spacing,
+ int MaxColumns,
+ int DeckMaxVisibleCount,
+ double DeckOffsetY,
+ double DeckAngleStepDegrees,
+ double DeckScaleStep,
+ double DeckBaseZ,
+ double DeckZStep)
+{
+ public static CardDeckLayoutSettings Default => new(
+ Mode: CardDeckLayoutMode.Auto,
+ ModeWidthThreshold: 900,
+ CardMinWidth: 320,
+ CardHeight: 180,
+ Spacing: 16,
+ MaxColumns: 3,
+ DeckMaxVisibleCount: 5,
+ DeckOffsetY: 8,
+ DeckAngleStepDegrees: 1.25,
+ DeckScaleStep: 0.02,
+ DeckBaseZ: 8,
+ DeckZStep: 6);
+}
diff --git a/JitHub.Dashboard.Layouts/CardDeckPresenter.cs b/JitHub.Dashboard.Layouts/CardDeckPresenter.cs
new file mode 100644
index 0000000..db64ec5
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardDeckPresenter.cs
@@ -0,0 +1,1125 @@
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Media;
+using Microsoft.UI.Xaml.Media.Animation;
+using Windows.Foundation;
+using System.Reflection;
+
+namespace JitHub.Dashboard.Layouts;
+
+///
+/// An that hosts and performs a smooth
+/// morph animation when the effective mode switches (Grid ↔ Deck) on resize.
+///
+/// Uses storyboarded animations against an additive
+/// (cross-platform, Uno-friendly) so it does not fight the layout's own transforms.
+///
+public sealed class CardDeckPresenter : ItemsRepeater
+{
+ public CardDeckPresenter()
+ {
+ _layout = new CardDeckLayout();
+ Layout = _layout;
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+
+ ElementPrepared += OnElementPrepared;
+ ElementClearing += OnElementClearing;
+ LayoutUpdated += OnLayoutUpdated;
+ }
+
+ private readonly CardDeckLayout _layout;
+ private readonly HashSet _realizedIndices = new();
+ private readonly HashSet _swipingIndices = new();
+ private readonly Dictionary _snapshots = new();
+ private readonly Dictionary _lastPassSnapshots = new();
+ private readonly Dictionary _snapshotsByCardId = new();
+ private readonly Dictionary _lastPassSnapshotsByCardId = new();
+ private readonly Dictionary _elementToIndex = new();
+ private readonly Dictionary _cardIdToElement = new();
+ private readonly Dictionary _elementToCardId = new();
+ private readonly Dictionary _activeMorphStoryboards = new();
+ private readonly Dictionary _activeSwipeStoryboards = new();
+ private readonly Dictionary _activeZAnimations = new();
+
+ private bool _isFrameTrackingEnabled;
+ private DateTimeOffset _frameTrackingUntilUtc;
+
+ private bool _isLoaded;
+ private bool _isUnloaded;
+ private LayoutSignature? _lastSignature;
+ private int _lastItemCount;
+ private bool _pendingMorph;
+ private bool _pendingMorphUsesCardId;
+
+ private readonly record struct ZAnimationState(double FromOffsetZ, DateTimeOffset StartedUtc, int DurationMs);
+
+ public Task SwipeAsync(long cardId, CardSwipeDirection direction)
+ => SwipeAsync(cardId, direction, CancellationToken.None);
+
+ public async Task SwipeAsync(long cardId, CardSwipeDirection direction, CancellationToken cancellationToken)
+ {
+ cancellationToken.ThrowIfCancellationRequested();
+
+ if (_isUnloaded || !_isLoaded)
+ {
+ return false;
+ }
+
+ var index = TryFindIndexByCardId(cardId);
+ if (index is null)
+ {
+ return false;
+ }
+
+ var element = TryGetElement(index.Value);
+ if (element is null)
+ {
+ return false;
+ }
+
+ StopMorphStoryboard(element);
+ StopSwipeStoryboard(element, resetToNeutral: false);
+
+ _swipingIndices.Add(index.Value);
+
+ var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element);
+
+ // Pivot around the center for a nicer "toss" feel.
+ animationTransform.CenterX = element.RenderSize.Width / 2;
+ animationTransform.CenterY = element.RenderSize.Height / 2;
+
+ // Keep starting point as-is (supports swiping mid-morph).
+ var fromX = animationTransform.TranslateX;
+ var fromY = animationTransform.TranslateY;
+ var fromR = animationTransform.Rotation;
+ var fromOpacity = element.Opacity;
+
+ var distanceX = Math.Max(ActualWidth, element.RenderSize.Width) + element.RenderSize.Width + 48;
+ var distanceY = Math.Max(ActualHeight, element.RenderSize.Height) + element.RenderSize.Height + 48;
+
+ double toX = 0;
+ double toY = 0;
+ double toR;
+
+ switch (direction)
+ {
+ case CardSwipeDirection.Left:
+ toX = -distanceX;
+ toR = -8;
+ break;
+ case CardSwipeDirection.Right:
+ toX = distanceX;
+ toR = 8;
+ break;
+ case CardSwipeDirection.Up:
+ toY = -distanceY;
+ toR = -6;
+ break;
+ case CardSwipeDirection.Down:
+ toY = distanceY;
+ toR = 6;
+ break;
+ default:
+ toR = 0;
+ break;
+ }
+
+ const int durationMs = 280;
+ var duration = TimeSpan.FromMilliseconds(durationMs);
+ var moveEase = new CubicEase { EasingMode = EasingMode.EaseIn };
+ var opacityEase = new QuadraticEase { EasingMode = EasingMode.EaseIn };
+
+ var storyboard = new Storyboard();
+
+ var xAnim = new DoubleAnimation { From = fromX, To = toX, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(xAnim, animationTransform);
+ Storyboard.SetTargetProperty(xAnim, nameof(CompositeTransform.TranslateX));
+ storyboard.Children.Add(xAnim);
+
+ var yAnim = new DoubleAnimation { From = fromY, To = toY, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(yAnim, animationTransform);
+ Storyboard.SetTargetProperty(yAnim, nameof(CompositeTransform.TranslateY));
+ storyboard.Children.Add(yAnim);
+
+ var rAnim = new DoubleAnimation { From = fromR, To = toR, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(rAnim, animationTransform);
+ Storyboard.SetTargetProperty(rAnim, nameof(CompositeTransform.Rotation));
+ storyboard.Children.Add(rAnim);
+
+ var oAnim = new DoubleAnimation { From = fromOpacity, To = 0, Duration = duration, EasingFunction = opacityEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(oAnim, element);
+ Storyboard.SetTargetProperty(oAnim, nameof(UIElement.Opacity));
+ storyboard.Children.Add(oAnim);
+
+ var tcs = new TaskCompletionSource();
+
+ _activeSwipeStoryboards[element] = storyboard;
+ storyboard.Completed += (_, _) =>
+ {
+ if (_activeSwipeStoryboards.TryGetValue(element, out var current) && ReferenceEquals(current, storyboard))
+ {
+ _activeSwipeStoryboards.Remove(element);
+ }
+
+ _swipingIndices.Remove(index.Value);
+ element.IsHitTestVisible = false;
+ tcs.TrySetResult(true);
+ };
+
+ element.IsHitTestVisible = false;
+ storyboard.Begin();
+
+ using (cancellationToken.Register(() =>
+ {
+ StopSwipeStoryboard(element, resetToNeutral: true);
+ _swipingIndices.Remove(index.Value);
+ tcs.TrySetCanceled(cancellationToken);
+ }))
+ {
+ return await tcs.Task;
+ }
+ }
+
+ public CardDeckLayoutMode Mode
+ {
+ get => (CardDeckLayoutMode)GetValue(ModeProperty);
+ set => SetValue(ModeProperty, value);
+ }
+
+ public static readonly DependencyProperty ModeProperty =
+ DependencyProperty.Register(
+ nameof(Mode),
+ typeof(CardDeckLayoutMode),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(CardDeckLayoutMode.Auto, OnLayoutSettingsChanged));
+
+ public double ModeWidthThreshold
+ {
+ get => (double)GetValue(ModeWidthThresholdProperty);
+ set => SetValue(ModeWidthThresholdProperty, value);
+ }
+
+ public static readonly DependencyProperty ModeWidthThresholdProperty =
+ DependencyProperty.Register(
+ nameof(ModeWidthThreshold),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(900d, OnLayoutSettingsChanged));
+
+ public double CardMinWidth
+ {
+ get => (double)GetValue(CardMinWidthProperty);
+ set => SetValue(CardMinWidthProperty, value);
+ }
+
+ public static readonly DependencyProperty CardMinWidthProperty =
+ DependencyProperty.Register(
+ nameof(CardMinWidth),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(320d, OnLayoutSettingsChanged));
+
+ public double CardHeight
+ {
+ get => (double)GetValue(CardHeightProperty);
+ set => SetValue(CardHeightProperty, value);
+ }
+
+ public static readonly DependencyProperty CardHeightProperty =
+ DependencyProperty.Register(
+ nameof(CardHeight),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(180d, OnLayoutSettingsChanged));
+
+ public double Spacing
+ {
+ get => (double)GetValue(SpacingProperty);
+ set => SetValue(SpacingProperty, value);
+ }
+
+ public static readonly DependencyProperty SpacingProperty =
+ DependencyProperty.Register(
+ nameof(Spacing),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(16d, OnLayoutSettingsChanged));
+
+ public int MaxColumns
+ {
+ get => (int)GetValue(MaxColumnsProperty);
+ set => SetValue(MaxColumnsProperty, value);
+ }
+
+ public static readonly DependencyProperty MaxColumnsProperty =
+ DependencyProperty.Register(
+ nameof(MaxColumns),
+ typeof(int),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(3, OnLayoutSettingsChanged));
+
+ public int DeckMaxVisibleCount
+ {
+ get => (int)GetValue(DeckMaxVisibleCountProperty);
+ set => SetValue(DeckMaxVisibleCountProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckMaxVisibleCountProperty =
+ DependencyProperty.Register(
+ nameof(DeckMaxVisibleCount),
+ typeof(int),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(5, OnLayoutSettingsChanged));
+
+ public double DeckOffsetY
+ {
+ get => (double)GetValue(DeckOffsetYProperty);
+ set => SetValue(DeckOffsetYProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckOffsetYProperty =
+ DependencyProperty.Register(
+ nameof(DeckOffsetY),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(8d, OnLayoutSettingsChanged));
+
+ public double DeckAngleStepDegrees
+ {
+ get => (double)GetValue(DeckAngleStepDegreesProperty);
+ set => SetValue(DeckAngleStepDegreesProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckAngleStepDegreesProperty =
+ DependencyProperty.Register(
+ nameof(DeckAngleStepDegrees),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(1.25d, OnLayoutSettingsChanged));
+
+ public double DeckScaleStep
+ {
+ get => (double)GetValue(DeckScaleStepProperty);
+ set => SetValue(DeckScaleStepProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckScaleStepProperty =
+ DependencyProperty.Register(
+ nameof(DeckScaleStep),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(0.02d, OnLayoutSettingsChanged));
+
+ public double DeckBaseZ
+ {
+ get => (double)GetValue(DeckBaseZProperty);
+ set => SetValue(DeckBaseZProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckBaseZProperty =
+ DependencyProperty.Register(
+ nameof(DeckBaseZ),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(8d, OnLayoutSettingsChanged));
+
+ public double DeckZStep
+ {
+ get => (double)GetValue(DeckZStepProperty);
+ set => SetValue(DeckZStepProperty, value);
+ }
+
+ public static readonly DependencyProperty DeckZStepProperty =
+ DependencyProperty.Register(
+ nameof(DeckZStep),
+ typeof(double),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(6d, OnLayoutSettingsChanged));
+
+ public int MorphDurationMilliseconds
+ {
+ get => (int)GetValue(MorphDurationMillisecondsProperty);
+ set => SetValue(MorphDurationMillisecondsProperty, value);
+ }
+
+ public static readonly DependencyProperty MorphDurationMillisecondsProperty =
+ DependencyProperty.Register(
+ nameof(MorphDurationMilliseconds),
+ typeof(int),
+ typeof(CardDeckPresenter),
+ new PropertyMetadata(420));
+
+ private static void OnLayoutSettingsChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is CardDeckPresenter presenter)
+ {
+ presenter.ApplySettingsToLayout();
+ presenter.InvalidateMeasure();
+ }
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ _isLoaded = true;
+ _isUnloaded = false;
+
+ ApplySettingsToLayout();
+ _lastSignature = ComputeSignature(ActualWidth);
+ _lastItemCount = ItemsSourceView?.Count ?? 0;
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ _isUnloaded = true;
+ _isLoaded = false;
+
+ StopFrameTracking();
+ StopAllMorphStoryboards();
+ StopAllSwipeStoryboards();
+
+ _pendingMorph = false;
+ _pendingMorphUsesCardId = false;
+ _snapshots.Clear();
+ _lastPassSnapshots.Clear();
+ _snapshotsByCardId.Clear();
+ _lastPassSnapshotsByCardId.Clear();
+ _realizedIndices.Clear();
+ _swipingIndices.Clear();
+ _elementToIndex.Clear();
+ _cardIdToElement.Clear();
+ _elementToCardId.Clear();
+ }
+
+ private void OnElementPrepared(ItemsRepeater sender, ItemsRepeaterElementPreparedEventArgs args)
+ {
+ var element = args.Element;
+ if (element is null)
+ {
+ return;
+ }
+
+ _elementToIndex[element] = args.Index;
+ _realizedIndices.Add(args.Index);
+
+ var data = (element as FrameworkElement)?.DataContext;
+ if (TryExtractCardId(data) is long id)
+ {
+ _cardIdToElement[id] = element;
+ _elementToCardId[element] = id;
+ }
+
+ // If an element appears during a morph (virtualization), fade it in subtly.
+ if (_pendingMorph)
+ {
+ TryAnimateAppear(element);
+ }
+ }
+
+ private void OnElementClearing(ItemsRepeater sender, ItemsRepeaterElementClearingEventArgs args)
+ {
+ StopMorphStoryboard(args.Element);
+ StopSwipeStoryboard(args.Element, resetToNeutral: false);
+
+ if (_elementToCardId.TryGetValue(args.Element, out var cardId))
+ {
+ _elementToCardId.Remove(args.Element);
+ if (_cardIdToElement.TryGetValue(cardId, out var current) && ReferenceEquals(current, args.Element))
+ {
+ _cardIdToElement.Remove(cardId);
+ }
+ }
+
+ if (_elementToIndex.TryGetValue(args.Element, out var index))
+ {
+ _realizedIndices.Remove(index);
+ _elementToIndex.Remove(args.Element);
+ }
+ }
+
+ private void OnLayoutUpdated(object? sender, object e)
+ {
+ if (_isUnloaded || !_isLoaded)
+ {
+ return;
+ }
+
+ // Detect mode changes *after* layout has settled, using snapshots from the previous
+ // layout pass. This avoids capturing snapshots too late (e.g., SizeChanged fires
+ // after resize/layout, so deltas become ~0 and no animation is visible).
+ var signature = ComputeSignature(ActualWidth);
+ var itemCount = ItemsSourceView?.Count ?? 0;
+ if (_lastSignature is null)
+ {
+ _lastSignature = signature;
+ _lastItemCount = itemCount;
+ CaptureCurrentLayoutSnapshots(_lastPassSnapshots);
+ CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId);
+ return;
+ }
+
+ var countChanged = _lastItemCount != itemCount;
+ if (!_pendingMorph && (_lastSignature.Value != signature || countChanged))
+ {
+ _lastSignature = signature;
+ _lastItemCount = itemCount;
+ CopySnapshots(_lastPassSnapshots, _snapshots);
+ CopySnapshots(_lastPassSnapshotsByCardId, _snapshotsByCardId);
+ _pendingMorphUsesCardId = countChanged && _snapshotsByCardId.Count > 0;
+ _pendingMorph = _pendingMorphUsesCardId ? _snapshotsByCardId.Count > 0 : _snapshots.Count > 0;
+ }
+
+ if (_pendingMorph)
+ {
+ RunMorphAnimations();
+ }
+
+ CaptureCurrentLayoutSnapshots(_lastPassSnapshots);
+ CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId);
+ }
+
+ private void ApplySettingsToLayout()
+ {
+ _layout.Mode = Mode;
+ _layout.ModeWidthThreshold = ModeWidthThreshold;
+ _layout.CardMinWidth = CardMinWidth;
+ _layout.CardHeight = CardHeight;
+ _layout.Spacing = Spacing;
+ _layout.MaxColumns = MaxColumns;
+ _layout.DeckMaxVisibleCount = DeckMaxVisibleCount;
+ _layout.DeckOffsetY = DeckOffsetY;
+ _layout.DeckAngleStepDegrees = DeckAngleStepDegrees;
+ _layout.DeckScaleStep = DeckScaleStep;
+ _layout.DeckBaseZ = DeckBaseZ;
+ _layout.DeckZStep = DeckZStep;
+ }
+
+ private CardDeckLayoutMode ResolveMode(double width)
+ {
+ if (Mode != CardDeckLayoutMode.Auto)
+ {
+ return Mode;
+ }
+
+ return width >= ModeWidthThreshold ? CardDeckLayoutMode.Grid : CardDeckLayoutMode.Deck;
+ }
+
+ private LayoutSignature ComputeSignature(double width)
+ {
+ var resolvedMode = ResolveMode(width);
+ if (resolvedMode == CardDeckLayoutMode.Grid)
+ {
+ return new LayoutSignature(resolvedMode, ComputeColumnCount(width));
+ }
+
+ return new LayoutSignature(resolvedMode, GridColumns: 0);
+ }
+
+ private int ComputeColumnCount(double width)
+ {
+ if (width <= 0)
+ {
+ return 1;
+ }
+
+ var cardWidth = Math.Max(1, CardMinWidth);
+ var spacing = Math.Max(0, Spacing);
+ var maxColumns = Math.Max(1, MaxColumns);
+
+ var denom = cardWidth + spacing;
+ if (denom <= 0)
+ {
+ return 1;
+ }
+
+ var columns = (int)Math.Floor((width + spacing) / denom);
+ if (columns < 1)
+ {
+ columns = 1;
+ }
+
+ if (columns > maxColumns)
+ {
+ columns = maxColumns;
+ }
+
+ return columns;
+ }
+
+ private static void CopySnapshots(Dictionary source, Dictionary destination)
+ {
+ destination.Clear();
+ foreach (var pair in source)
+ {
+ destination[pair.Key] = pair.Value;
+ }
+ }
+
+ private static void CopySnapshots(Dictionary source, Dictionary destination)
+ {
+ destination.Clear();
+ foreach (var pair in source)
+ {
+ destination[pair.Key] = pair.Value;
+ }
+ }
+
+ private void CaptureCurrentLayoutSnapshots(Dictionary destination)
+ {
+ destination.Clear();
+
+ foreach (var index in _realizedIndices)
+ {
+ var element = TryGetElement(index);
+ if (element is null)
+ {
+ continue;
+ }
+
+ var bounds = GetBoundsRelativeToPresenter(element);
+ var (rotation, scale) = GetLayoutTransform(element);
+ destination[index] = new Snapshot(bounds, rotation, scale, element.Translation.Z);
+ }
+ }
+
+ private void CaptureCurrentLayoutSnapshotsByCardId(Dictionary destination)
+ {
+ destination.Clear();
+
+ foreach (var (cardId, element) in _cardIdToElement)
+ {
+ if (element is null)
+ {
+ continue;
+ }
+
+ var bounds = GetBoundsRelativeToPresenter(element);
+ var (rotation, scale) = GetLayoutTransform(element);
+ destination[cardId] = new Snapshot(bounds, rotation, scale, element.Translation.Z);
+ }
+ }
+
+ private void RunMorphAnimations()
+ {
+ if (!_pendingMorphUsesCardId && _snapshots.Count == 0)
+ {
+ _pendingMorph = false;
+ return;
+ }
+
+ if (_pendingMorphUsesCardId && _snapshotsByCardId.Count == 0)
+ {
+ _pendingMorph = false;
+ _pendingMorphUsesCardId = false;
+ return;
+ }
+
+ var durationMs = Math.Max(0, MorphDurationMilliseconds);
+ StartFrameTracking(durationMs);
+
+ if (_pendingMorphUsesCardId)
+ {
+ foreach (var (cardId, snapshot) in _snapshotsByCardId)
+ {
+ if (!_cardIdToElement.TryGetValue(cardId, out var element) || element is null)
+ {
+ continue;
+ }
+
+ if (_activeSwipeStoryboards.ContainsKey(element))
+ {
+ continue;
+ }
+
+ var newBounds = GetBoundsRelativeToPresenter(element);
+ var (newRotation, newScale) = GetLayoutTransform(element);
+ var newBaseZ = CardDeckElevation.GetBaseZ(element);
+
+ var deltaX = snapshot.Bounds.X - newBounds.X;
+ var deltaY = snapshot.Bounds.Y - newBounds.Y;
+
+ var deltaRotation = snapshot.RotationDegrees - newRotation;
+
+ var scaleRatio = 1.0;
+ if (newScale is > 0 and < double.PositiveInfinity)
+ {
+ scaleRatio = snapshot.Scale / newScale;
+ }
+
+ TryAnimateMorph(element, deltaX, deltaY, deltaRotation, scaleRatio, snapshot.Z, newBaseZ, durationMs);
+ }
+
+ _snapshotsByCardId.Clear();
+ }
+ else
+ {
+ foreach (var (index, snapshot) in _snapshots)
+ {
+ if (_swipingIndices.Contains(index))
+ {
+ continue;
+ }
+
+ var element = TryGetElement(index);
+ if (element is null)
+ {
+ continue;
+ }
+
+ var newBounds = GetBoundsRelativeToPresenter(element);
+ var (newRotation, newScale) = GetLayoutTransform(element);
+ var newBaseZ = CardDeckElevation.GetBaseZ(element);
+
+ var deltaX = snapshot.Bounds.X - newBounds.X;
+ var deltaY = snapshot.Bounds.Y - newBounds.Y;
+
+ var deltaRotation = snapshot.RotationDegrees - newRotation;
+
+ var scaleRatio = 1.0;
+ if (newScale is > 0 and < double.PositiveInfinity)
+ {
+ scaleRatio = snapshot.Scale / newScale;
+ }
+
+ TryAnimateMorph(element, deltaX, deltaY, deltaRotation, scaleRatio, snapshot.Z, newBaseZ, durationMs);
+ }
+
+ _snapshots.Clear();
+ }
+
+ _pendingMorph = false;
+ _pendingMorphUsesCardId = false;
+ }
+
+ private void StartFrameTracking(int durationMs)
+ {
+ // During rapid resizing, the layout can change again while a morph is still in-flight.
+ // Keep refreshing "last pass" snapshots from the actual on-screen (animated) positions,
+ // so subsequent morphs start from the current state and remain continuous.
+ var now = DateTimeOffset.UtcNow;
+ var until = now + TimeSpan.FromMilliseconds(Math.Max(1, durationMs) + 120);
+ if (until > _frameTrackingUntilUtc)
+ {
+ _frameTrackingUntilUtc = until;
+ }
+
+ if (_isFrameTrackingEnabled)
+ {
+ return;
+ }
+
+ _isFrameTrackingEnabled = true;
+ CompositionTarget.Rendering += OnRendering;
+ }
+
+ private void StopFrameTracking()
+ {
+ if (!_isFrameTrackingEnabled)
+ {
+ return;
+ }
+
+ _isFrameTrackingEnabled = false;
+ CompositionTarget.Rendering -= OnRendering;
+ }
+
+ private void OnRendering(object? sender, object e)
+ {
+ if (_isUnloaded || !_isLoaded)
+ {
+ StopFrameTracking();
+ return;
+ }
+
+ if (DateTimeOffset.UtcNow > _frameTrackingUntilUtc)
+ {
+ StopFrameTracking();
+ return;
+ }
+
+ TickZAnimations();
+
+ CaptureCurrentLayoutSnapshots(_lastPassSnapshots);
+ CaptureCurrentLayoutSnapshotsByCardId(_lastPassSnapshotsByCardId);
+ }
+
+ private void TickZAnimations()
+ {
+ if (_activeZAnimations.Count == 0)
+ {
+ return;
+ }
+
+ var now = DateTimeOffset.UtcNow;
+ var elements = _activeZAnimations.Keys.ToArray();
+
+ foreach (var element in elements)
+ {
+ if (element is null)
+ {
+ _activeZAnimations.Remove(element!);
+ continue;
+ }
+
+ if (!_activeZAnimations.TryGetValue(element, out var state))
+ {
+ continue;
+ }
+
+ var durationMs = Math.Max(1, state.DurationMs);
+ var t = (now - state.StartedUtc).TotalMilliseconds / durationMs;
+ if (!double.IsFinite(t))
+ {
+ t = 1;
+ }
+
+ if (t <= 0)
+ {
+ CardDeckElevation.SetAnimationOffsetZ(element, state.FromOffsetZ);
+ continue;
+ }
+
+ if (t >= 1)
+ {
+ CardDeckElevation.SetAnimationOffsetZ(element, 0);
+ _activeZAnimations.Remove(element);
+ continue;
+ }
+
+ // Cubic ease-out: 1 - (1 - t)^3
+ var eased = 1 - Math.Pow(1 - t, 3);
+ var currentOffset = state.FromOffsetZ * (1 - eased);
+ CardDeckElevation.SetAnimationOffsetZ(element, currentOffset);
+ }
+ }
+
+ private void TryAnimateMorph(UIElement element, double deltaX, double deltaY, double deltaRotation, double scaleRatio, double snapshotZ, double newBaseZ, int durationMs)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ // Skip tiny/no-op changes.
+ var shouldAnimateZ = double.IsFinite(newBaseZ) && Math.Abs(snapshotZ - newBaseZ) >= 0.5;
+
+ if (Math.Abs(deltaX) < 0.5
+ && Math.Abs(deltaY) < 0.5
+ && Math.Abs(deltaRotation) < 0.2
+ && (!double.IsFinite(scaleRatio) || Math.Abs(scaleRatio - 1.0) < 0.01)
+ && !shouldAnimateZ)
+ {
+ return;
+ }
+
+ StopMorphStoryboard(element);
+
+ // Ensure we have a dedicated animation CompositeTransform that the layout won't overwrite.
+ var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element);
+
+ // Seed the additive transform to represent “old → new” delta, then animate back to neutral.
+ animationTransform.TranslateX = deltaX;
+ animationTransform.TranslateY = deltaY;
+ animationTransform.Rotation = deltaRotation;
+
+ if (double.IsFinite(scaleRatio) && scaleRatio > 0)
+ {
+ animationTransform.ScaleX = scaleRatio;
+ animationTransform.ScaleY = scaleRatio;
+ }
+ else
+ {
+ animationTransform.ScaleX = 1;
+ animationTransform.ScaleY = 1;
+ }
+
+ var duration = TimeSpan.FromMilliseconds(Math.Max(1, durationMs));
+ var moveEase = new BackEase { Amplitude = 0.35, EasingMode = EasingMode.EaseOut };
+ var rotateEase = new CubicEase { EasingMode = EasingMode.EaseOut };
+
+ var storyboard = new Storyboard();
+
+ var xAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(xAnim, animationTransform);
+ Storyboard.SetTargetProperty(xAnim, nameof(CompositeTransform.TranslateX));
+ storyboard.Children.Add(xAnim);
+
+ var yAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(yAnim, animationTransform);
+ Storyboard.SetTargetProperty(yAnim, nameof(CompositeTransform.TranslateY));
+ storyboard.Children.Add(yAnim);
+
+ var rAnim = new DoubleAnimation { To = 0, Duration = duration, EasingFunction = rotateEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(rAnim, animationTransform);
+ Storyboard.SetTargetProperty(rAnim, nameof(CompositeTransform.Rotation));
+ storyboard.Children.Add(rAnim);
+
+ var sxAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(sxAnim, animationTransform);
+ Storyboard.SetTargetProperty(sxAnim, nameof(CompositeTransform.ScaleX));
+ storyboard.Children.Add(sxAnim);
+
+ var syAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = moveEase, EnableDependentAnimation = true };
+ Storyboard.SetTarget(syAnim, animationTransform);
+ Storyboard.SetTargetProperty(syAnim, nameof(CompositeTransform.ScaleY));
+ storyboard.Children.Add(syAnim);
+
+ if (shouldAnimateZ)
+ {
+ // Layout has already applied the new base Z. Animate an additive offset back to 0.
+ var fromOffsetZ = snapshotZ - newBaseZ;
+ CardDeckElevation.SetAnimationOffsetZ(element, fromOffsetZ);
+ _activeZAnimations[element] = new ZAnimationState(fromOffsetZ, DateTimeOffset.UtcNow, Math.Max(1, durationMs));
+ }
+
+ _activeMorphStoryboards[element] = storyboard;
+ storyboard.Completed += (_, _) => OnMorphStoryboardCompleted(element, storyboard);
+ storyboard.Begin();
+ }
+
+ private void OnMorphStoryboardCompleted(UIElement element, Storyboard completed)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ // IMPORTANT (WinAppSDK): calling Stop() on completion can revert the animated
+ // properties back to their pre-animation values, which looks like a snap-back.
+ // Only detach tracking when the *current* storyboard completes.
+ if (_activeMorphStoryboards.TryGetValue(element, out var current) && ReferenceEquals(current, completed))
+ {
+ _activeMorphStoryboards.Remove(element);
+ }
+ }
+
+ private void StopMorphStoryboard(UIElement element)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ if (_activeMorphStoryboards.TryGetValue(element, out var active))
+ {
+ _activeMorphStoryboards.Remove(element);
+ try
+ {
+ active.Stop();
+ }
+ catch
+ {
+ // Ignore stop failures during teardown/resizing.
+ }
+
+ // Ensure we don't leave a cancelled animation transform in a non-neutral state.
+ var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element);
+ animationTransform.TranslateX = 0;
+ animationTransform.TranslateY = 0;
+ animationTransform.Rotation = 0;
+ animationTransform.ScaleX = 1;
+ animationTransform.ScaleY = 1;
+
+ CardDeckElevation.SetAnimationOffsetZ(element, 0);
+ _activeZAnimations.Remove(element);
+ }
+ }
+
+ private void StopAllMorphStoryboards()
+ {
+ foreach (var element in _activeZAnimations.Keys)
+ {
+ if (element is not null)
+ {
+ CardDeckElevation.SetAnimationOffsetZ(element, 0);
+ }
+ }
+
+ _activeZAnimations.Clear();
+
+ if (_activeMorphStoryboards.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var storyboard in _activeMorphStoryboards.Values)
+ {
+ try
+ {
+ storyboard.Stop();
+ }
+ catch
+ {
+ // Ignore.
+ }
+ }
+
+ _activeMorphStoryboards.Clear();
+ }
+
+ private void StopSwipeStoryboard(UIElement element, bool resetToNeutral)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ if (_activeSwipeStoryboards.TryGetValue(element, out var active))
+ {
+ _activeSwipeStoryboards.Remove(element);
+ try
+ {
+ active.Stop();
+ }
+ catch
+ {
+ // Ignore.
+ }
+ }
+
+ if (resetToNeutral)
+ {
+ var (_, animationTransform) = CardDeckLayout.EnsureTransforms(element);
+ animationTransform.TranslateX = 0;
+ animationTransform.TranslateY = 0;
+ animationTransform.Rotation = 0;
+ animationTransform.ScaleX = 1;
+ animationTransform.ScaleY = 1;
+
+ element.Opacity = 1;
+ element.IsHitTestVisible = true;
+ }
+ }
+
+ private void StopAllSwipeStoryboards()
+ {
+ if (_activeSwipeStoryboards.Count == 0)
+ {
+ return;
+ }
+
+ foreach (var storyboard in _activeSwipeStoryboards.Values)
+ {
+ try
+ {
+ storyboard.Stop();
+ }
+ catch
+ {
+ // Ignore.
+ }
+ }
+
+ _activeSwipeStoryboards.Clear();
+ }
+
+ private int? TryFindIndexByCardId(long cardId)
+ {
+ var view = ItemsSourceView;
+ if (view is null)
+ {
+ return null;
+ }
+
+ for (var i = 0; i < view.Count; i++)
+ {
+ var item = view.GetAt(i);
+ if (TryExtractCardId(item) is long id && id == cardId)
+ {
+ return i;
+ }
+ }
+
+ return null;
+ }
+
+ private static long? TryExtractCardId(object? item)
+ {
+ if (item is null)
+ {
+ return null;
+ }
+
+ // Keep the presenter generic: treat "CardId" as a conventional key.
+ var type = item.GetType();
+ var prop = type.GetProperty("CardId", BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase);
+ if (prop is null)
+ {
+ return null;
+ }
+
+ try
+ {
+ var value = prop.GetValue(item);
+ return value switch
+ {
+ long l => l,
+ int i => i,
+ short s => s,
+ byte b => b,
+ string str when long.TryParse(str, out var parsed) => parsed,
+ _ => null,
+ };
+ }
+ catch
+ {
+ return null;
+ }
+ }
+
+ private void TryAnimateAppear(UIElement element)
+ {
+ if (element is null)
+ {
+ return;
+ }
+
+ element.Opacity = 0;
+ var duration = TimeSpan.FromMilliseconds(160);
+ var ease = new CubicEase { EasingMode = EasingMode.EaseOut };
+
+ var storyboard = new Storyboard();
+ var opacityAnim = new DoubleAnimation { To = 1, Duration = duration, EasingFunction = ease };
+ Storyboard.SetTarget(opacityAnim, element);
+ Storyboard.SetTargetProperty(opacityAnim, nameof(UIElement.Opacity));
+ storyboard.Children.Add(opacityAnim);
+ storyboard.Begin();
+ }
+
+ private Rect GetBoundsRelativeToPresenter(UIElement element)
+ {
+ try
+ {
+ var transform = element.TransformToVisual(this);
+ return transform.TransformBounds(new Rect(0, 0, element.RenderSize.Width, element.RenderSize.Height));
+ }
+ catch
+ {
+ return new Rect(0, 0, 0, 0);
+ }
+ }
+
+ private static (double RotationDegrees, double Scale) GetLayoutTransform(UIElement element)
+ {
+ if (element.RenderTransform is TransformGroup group
+ && group.Children.Count >= 1
+ && group.Children[0] is CompositeTransform layout)
+ {
+ // Layout uses uniform scale.
+ return (layout.Rotation, layout.ScaleX);
+ }
+
+ if (element.RenderTransform is CompositeTransform ct)
+ {
+ // Layout uses uniform scale.
+ return (ct.Rotation, ct.ScaleX);
+ }
+
+ return (0d, 1d);
+ }
+
+ private readonly record struct LayoutSignature(CardDeckLayoutMode Mode, int GridColumns);
+
+ private readonly record struct Snapshot(Rect Bounds, double RotationDegrees, double Scale, double Z);
+}
diff --git a/JitHub.Dashboard.Layouts/CardSwipeDirection.cs b/JitHub.Dashboard.Layouts/CardSwipeDirection.cs
new file mode 100644
index 0000000..3f75977
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/CardSwipeDirection.cs
@@ -0,0 +1,9 @@
+namespace JitHub.Dashboard.Layouts;
+
+public enum CardSwipeDirection
+{
+ Left,
+ Right,
+ Up,
+ Down,
+}
diff --git a/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj b/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj
new file mode 100644
index 0000000..8fb29c7
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/JitHub.Dashboard.Layouts.csproj
@@ -0,0 +1,10 @@
+
+
+
+ net10.0;net10.0-windows10.0.26100
+ latest
+ enable
+ enable
+
+
+
diff --git a/JitHub.Dashboard.Layouts/LayoutPrimitives.cs b/JitHub.Dashboard.Layouts/LayoutPrimitives.cs
new file mode 100644
index 0000000..334aa5d
--- /dev/null
+++ b/JitHub.Dashboard.Layouts/LayoutPrimitives.cs
@@ -0,0 +1,27 @@
+namespace JitHub.Dashboard.Layouts;
+
+public readonly record struct LayoutSize(double Width, double Height);
+
+public readonly record struct LayoutPoint(double X, double Y);
+
+public readonly record struct LayoutRect(double X, double Y, double Width, double Height)
+{
+ public static LayoutRect FromSize(LayoutPoint origin, LayoutSize size) => new(origin.X, origin.Y, size.Width, size.Height);
+
+ public bool IsFinite =>
+ double.IsFinite(X) &&
+ double.IsFinite(Y) &&
+ double.IsFinite(Width) &&
+ double.IsFinite(Height);
+}
+
+public readonly record struct LayoutTransform(double TranslateX, double TranslateY, double RotationDegrees, double Scale)
+{
+ public bool IsFinite =>
+ double.IsFinite(TranslateX) &&
+ double.IsFinite(TranslateY) &&
+ double.IsFinite(RotationDegrees) &&
+ double.IsFinite(Scale);
+}
+
+public sealed record CardDeckLayoutItem(LayoutRect Rect, LayoutTransform Transform, double Z);
diff --git a/JitHub.Data.Caching/ObservableCollectionSync.cs b/JitHub.Data.Caching/ObservableCollectionSync.cs
index b25d661..77c6a1d 100644
--- a/JitHub.Data.Caching/ObservableCollectionSync.cs
+++ b/JitHub.Data.Caching/ObservableCollectionSync.cs
@@ -32,9 +32,11 @@ public static void SyncById(
if (source.Count == 0)
{
- if (target.Count != 0)
+ // Avoid Clear(), which raises a collection Reset and can cause UI flicker.
+ // Remove items individually so virtualization can animate/retain state.
+ while (target.Count > 0)
{
- target.Clear();
+ target.RemoveAt(target.Count - 1);
}
return;
diff --git a/JitHub.Data.Caching/StagedWorkScheduler.cs b/JitHub.Data.Caching/StagedWorkScheduler.cs
new file mode 100644
index 0000000..3025e24
--- /dev/null
+++ b/JitHub.Data.Caching/StagedWorkScheduler.cs
@@ -0,0 +1,75 @@
+namespace JitHub.Data.Caching;
+
+public static class StagedWorkScheduler
+{
+ public static async Task RunAsync(
+ IReadOnlyList items,
+ Func tierSelector,
+ int maxTierInclusive,
+ int maxConcurrency,
+ Func workAsync,
+ CancellationToken ct)
+ {
+ if (items is null)
+ {
+ throw new ArgumentNullException(nameof(items));
+ }
+
+ if (tierSelector is null)
+ {
+ throw new ArgumentNullException(nameof(tierSelector));
+ }
+
+ if (workAsync is null)
+ {
+ throw new ArgumentNullException(nameof(workAsync));
+ }
+
+ if (maxConcurrency <= 0)
+ {
+ throw new ArgumentOutOfRangeException(nameof(maxConcurrency));
+ }
+
+ var planned = items
+ .Select((item, index) => (item, index, tier: tierSelector(item)))
+ .Where(x => x.tier <= maxTierInclusive)
+ .OrderBy(x => x.tier)
+ .ThenBy(x => x.index)
+ .ToArray();
+
+ if (planned.Length == 0)
+ {
+ return;
+ }
+
+ using var semaphore = new SemaphoreSlim(maxConcurrency, maxConcurrency);
+ var tasks = new List(capacity: planned.Length);
+
+ foreach (var (item, _, _) in planned)
+ {
+ ct.ThrowIfCancellationRequested();
+
+ tasks.Add(RunOneAsync(item, semaphore, workAsync, ct));
+ }
+
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+ }
+
+ private static async Task RunOneAsync(
+ TItem item,
+ SemaphoreSlim semaphore,
+ Func workAsync,
+ CancellationToken ct)
+ {
+ await semaphore.WaitAsync(ct).ConfigureAwait(false);
+
+ try
+ {
+ await workAsync(item, ct).ConfigureAwait(false);
+ }
+ finally
+ {
+ semaphore.Release();
+ }
+ }
+}
diff --git a/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs b/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs
new file mode 100644
index 0000000..abad7ce
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Models/ActivitySummary.cs
@@ -0,0 +1,9 @@
+namespace JitHub.GitHub.Abstractions.Models;
+
+public sealed record ActivitySummary(
+ string Id,
+ RepoKey? Repo,
+ string Type,
+ string? ActorLogin,
+ string? Description,
+ DateTimeOffset CreatedAt);
diff --git a/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs b/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs
new file mode 100644
index 0000000..54dc492
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Models/NotificationSummary.cs
@@ -0,0 +1,9 @@
+namespace JitHub.GitHub.Abstractions.Models;
+
+public sealed record NotificationSummary(
+ string Id,
+ RepoKey Repo,
+ string Title,
+ string? Type,
+ DateTimeOffset? UpdatedAt,
+ bool Unread);
diff --git a/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs b/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs
new file mode 100644
index 0000000..beb5acc
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Models/RepositorySnapshot.cs
@@ -0,0 +1,11 @@
+namespace JitHub.GitHub.Abstractions.Models;
+
+public sealed record RepositorySnapshot(
+ RepoKey Repo,
+ bool IsPrivate,
+ string? DefaultBranch,
+ string? Description,
+ DateTimeOffset? UpdatedAt,
+ int StargazersCount,
+ int ForksCount,
+ int WatchersCount);
diff --git a/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs b/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs
new file mode 100644
index 0000000..0b1b1ad
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Models/WorkItemSummary.cs
@@ -0,0 +1,12 @@
+namespace JitHub.GitHub.Abstractions.Models;
+
+public sealed record WorkItemSummary(
+ long Id,
+ RepoKey Repo,
+ int Number,
+ string Title,
+ bool IsPullRequest,
+ string? State,
+ string? AuthorLogin,
+ int CommentCount,
+ DateTimeOffset? UpdatedAt);
diff --git a/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs
index fc4ef5c..170ffe0 100644
--- a/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs
+++ b/JitHub.GitHub.Abstractions/Queries/IssueQuery.cs
@@ -2,4 +2,6 @@ namespace JitHub.GitHub.Abstractions.Queries;
public sealed record IssueQuery(
IssueStateFilter State,
- string? SearchText = null);
+ string? SearchText = null,
+ IssueSortField? Sort = null,
+ IssueSortDirection? Direction = null);
diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs b/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs
new file mode 100644
index 0000000..b6ab846
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Queries/IssueSearchQuery.cs
@@ -0,0 +1,8 @@
+using JitHub.GitHub.Abstractions.Queries;
+
+namespace JitHub.GitHub.Abstractions.Queries;
+
+public sealed record IssueSearchQuery(
+ string Query,
+ IssueSortField? Sort = null,
+ IssueSortDirection? Direction = null);
diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs b/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs
new file mode 100644
index 0000000..cbbbf47
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Queries/IssueSortDirection.cs
@@ -0,0 +1,7 @@
+namespace JitHub.GitHub.Abstractions.Queries;
+
+public enum IssueSortDirection
+{
+ Asc = 0,
+ Desc = 1,
+}
diff --git a/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs b/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs
new file mode 100644
index 0000000..293f1fc
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Queries/IssueSortField.cs
@@ -0,0 +1,8 @@
+namespace JitHub.GitHub.Abstractions.Queries;
+
+public enum IssueSortField
+{
+ Created = 0,
+ Updated = 1,
+ Comments = 2,
+}
diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs
new file mode 100644
index 0000000..3ea49af
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Services/IGitHubActivityService.cs
@@ -0,0 +1,19 @@
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Refresh;
+
+namespace JitHub.GitHub.Abstractions.Services;
+
+public interface IGitHubActivityService
+{
+ Task>> GetMyActivityAsync(
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct);
+
+ Task>> GetRepoActivityAsync(
+ RepoKey repo,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct);
+}
diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs
new file mode 100644
index 0000000..9ae4d4f
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Services/IGitHubIssueSearchService.cs
@@ -0,0 +1,15 @@
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Queries;
+using JitHub.GitHub.Abstractions.Refresh;
+
+namespace JitHub.GitHub.Abstractions.Services;
+
+public interface IGitHubIssueSearchService
+{
+ Task>> SearchAsync(
+ IssueSearchQuery query,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct);
+}
diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs
new file mode 100644
index 0000000..e6549fc
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationPollingService.cs
@@ -0,0 +1,13 @@
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Polling;
+
+namespace JitHub.GitHub.Abstractions.Services;
+
+public interface IGitHubNotificationPollingService
+{
+ Task StartNotificationsPollingAsync(
+ bool unreadOnly,
+ PageRequest page,
+ PollingRequest polling,
+ CancellationToken ct);
+}
diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs
new file mode 100644
index 0000000..eb30ee4
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Services/IGitHubNotificationService.cs
@@ -0,0 +1,14 @@
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Refresh;
+
+namespace JitHub.GitHub.Abstractions.Services;
+
+public interface IGitHubNotificationService
+{
+ Task>> GetMyNotificationsAsync(
+ bool unreadOnly,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct);
+}
diff --git a/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs
new file mode 100644
index 0000000..85a2809
--- /dev/null
+++ b/JitHub.GitHub.Abstractions/Services/IGitHubRepositoryDetailsService.cs
@@ -0,0 +1,12 @@
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Refresh;
+
+namespace JitHub.GitHub.Abstractions.Services;
+
+public interface IGitHubRepositoryDetailsService
+{
+ Task GetRepositoryAsync(
+ RepoKey repo,
+ RefreshMode refresh,
+ CancellationToken ct);
+}
diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs
new file mode 100644
index 0000000..9d64ac3
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Mapping/OctokitActivityEventData.cs
@@ -0,0 +1,11 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHub.GitHub.Octokit.Mapping;
+
+internal sealed record OctokitActivityEventData(
+ string? Id,
+ RepoKey? Repo,
+ string? Type,
+ string? ActorLogin,
+ string? Description,
+ DateTimeOffset CreatedAt);
diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs
index d78b406..8999830 100644
--- a/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs
+++ b/JitHub.GitHub.Octokit/Mapping/OctokitMappings.cs
@@ -72,6 +72,75 @@ public static IssueComment ToIssueComment(OctokitIssueCommentData comment)
UpdatedAt: comment.UpdatedAt);
}
+ internal static WorkItemSummary ToWorkItemSummary(OctokitWorkItemData item)
+ {
+ if (item is null)
+ {
+ throw new ArgumentNullException(nameof(item));
+ }
+
+ return new WorkItemSummary(
+ Id: item.Id,
+ Repo: item.Repo,
+ Number: item.Number,
+ Title: item.Title ?? string.Empty,
+ IsPullRequest: item.IsPullRequest,
+ State: item.State,
+ AuthorLogin: item.AuthorLogin,
+ CommentCount: item.CommentCount,
+ UpdatedAt: item.UpdatedAt);
+ }
+
+ internal static NotificationSummary ToNotificationSummary(OctokitNotificationData notification)
+ {
+ if (notification is null)
+ {
+ throw new ArgumentNullException(nameof(notification));
+ }
+
+ return new NotificationSummary(
+ Id: notification.Id ?? string.Empty,
+ Repo: notification.Repo,
+ Title: notification.Title ?? string.Empty,
+ Type: notification.Type,
+ UpdatedAt: notification.UpdatedAt,
+ Unread: notification.Unread);
+ }
+
+ internal static ActivitySummary ToActivitySummary(OctokitActivityEventData activity)
+ {
+ if (activity is null)
+ {
+ throw new ArgumentNullException(nameof(activity));
+ }
+
+ return new ActivitySummary(
+ Id: activity.Id ?? string.Empty,
+ Repo: activity.Repo,
+ Type: activity.Type ?? string.Empty,
+ ActorLogin: activity.ActorLogin,
+ Description: activity.Description,
+ CreatedAt: activity.CreatedAt);
+ }
+
+ internal static RepositorySnapshot ToRepositorySnapshot(OctokitRepositoryDetailData repo)
+ {
+ if (repo is null)
+ {
+ throw new ArgumentNullException(nameof(repo));
+ }
+
+ return new RepositorySnapshot(
+ Repo: repo.Repo,
+ IsPrivate: repo.IsPrivate,
+ DefaultBranch: repo.DefaultBranch,
+ Description: repo.Description,
+ UpdatedAt: repo.UpdatedAt,
+ StargazersCount: repo.StargazersCount,
+ ForksCount: repo.ForksCount,
+ WatchersCount: repo.WatchersCount);
+ }
+
private static IssueState ParseIssueState(string? state)
{
if (string.Equals(state, "closed", StringComparison.OrdinalIgnoreCase))
diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs
new file mode 100644
index 0000000..ae244b7
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Mapping/OctokitNotificationData.cs
@@ -0,0 +1,11 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHub.GitHub.Octokit.Mapping;
+
+internal sealed record OctokitNotificationData(
+ string Id,
+ RepoKey Repo,
+ string Title,
+ string? Type,
+ DateTimeOffset? UpdatedAt,
+ bool Unread);
diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs
new file mode 100644
index 0000000..f0038a2
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Mapping/OctokitRepositoryDetailData.cs
@@ -0,0 +1,13 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHub.GitHub.Octokit.Mapping;
+
+internal sealed record OctokitRepositoryDetailData(
+ RepoKey Repo,
+ bool IsPrivate,
+ string? DefaultBranch,
+ string? Description,
+ DateTimeOffset? UpdatedAt,
+ int StargazersCount,
+ int ForksCount,
+ int WatchersCount);
diff --git a/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs b/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs
new file mode 100644
index 0000000..0bbc288
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Mapping/OctokitWorkItemData.cs
@@ -0,0 +1,14 @@
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHub.GitHub.Octokit.Mapping;
+
+internal sealed record OctokitWorkItemData(
+ long Id,
+ RepoKey Repo,
+ int Number,
+ string Title,
+ bool IsPullRequest,
+ string? State,
+ string? AuthorLogin,
+ int CommentCount,
+ DateTimeOffset? UpdatedAt);
diff --git a/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs
index 0a36f91..3585bb5 100644
--- a/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs
+++ b/JitHub.GitHub.Octokit/ServiceCollectionExtensions.cs
@@ -21,7 +21,12 @@ public static IServiceCollection AddJitHubGitHubServices(this IServiceCollection
services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
services.AddSingleton();
services.AddSingleton();
diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs
new file mode 100644
index 0000000..65dce05
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Services/CachedGitHubActivityService.cs
@@ -0,0 +1,96 @@
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using JitHub.GitHub.Octokit.Mapping;
+
+namespace JitHub.GitHub.Octokit.Services;
+
+internal sealed class CachedGitHubActivityService : IGitHubActivityService
+{
+ private readonly CacheRuntime _cache;
+ private readonly IGitHubDataSource _dataSource;
+
+ public CachedGitHubActivityService(CacheRuntime cache, IGitHubDataSource dataSource)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ public Task>> GetMyActivityAsync(
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct)
+ => GetAsync(
+ key: GitHubCacheKeys.MyActivity(page),
+ fetchAsync: token => FetchMyAsync(page, token),
+ page,
+ refresh,
+ ct);
+
+ public Task>> GetRepoActivityAsync(
+ RepoKey repo,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct)
+ => GetAsync(
+ key: GitHubCacheKeys.RepoActivity(repo, page),
+ fetchAsync: token => FetchRepoAsync(repo, page, token),
+ page,
+ refresh,
+ ct);
+
+ private async Task>> FetchMyAsync(PageRequest page, CancellationToken ct)
+ {
+ var data = await _dataSource.GetMyActivityAsync(page, ct).ConfigureAwait(false);
+ var items = data.Select(OctokitMappings.ToActivitySummary).ToArray();
+ return new PagedResult>(items, Next: ComputeNext(page, items.Length));
+ }
+
+ private async Task>> FetchRepoAsync(RepoKey repo, PageRequest page, CancellationToken ct)
+ {
+ var data = await _dataSource.GetRepoActivityAsync(repo, page, ct).ConfigureAwait(false);
+ var items = data.Select(OctokitMappings.ToActivitySummary).ToArray();
+ return new PagedResult>(items, Next: ComputeNext(page, items.Length));
+ }
+
+ private async Task>> GetAsync(
+ CacheKey key,
+ Func>>> fetchAsync,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct)
+ {
+ if (refresh == RefreshMode.CacheOnly)
+ {
+ var cached = _cache.GetCached>>(key);
+ return cached.HasValue
+ ? cached.Value!
+ : new PagedResult>(Array.Empty(), Next: null);
+ }
+
+ CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh
+ ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false)
+ : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false);
+
+ return snapshot.HasValue
+ ? snapshot.Value!
+ : new PagedResult>(Array.Empty(), Next: null);
+ }
+
+ private static PageRequest? ComputeNext(PageRequest page, int returnedCount)
+ {
+ if (page.Cursor is not null)
+ {
+ return null;
+ }
+
+ if (page.PageNumber is not null && returnedCount == page.PageSize)
+ {
+ return PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize);
+ }
+
+ return null;
+ }
+}
diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs
new file mode 100644
index 0000000..376278f
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Services/CachedGitHubIssueSearchService.cs
@@ -0,0 +1,58 @@
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Queries;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using JitHub.GitHub.Octokit.Mapping;
+
+namespace JitHub.GitHub.Octokit.Services;
+
+internal sealed class CachedGitHubIssueSearchService : IGitHubIssueSearchService
+{
+ private readonly CacheRuntime _cache;
+ private readonly IGitHubDataSource _dataSource;
+
+ public CachedGitHubIssueSearchService(CacheRuntime cache, IGitHubDataSource dataSource)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ public async Task>> SearchAsync(
+ IssueSearchQuery query,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct)
+ {
+ var key = GitHubCacheKeys.SearchIssues(query, page);
+
+ if (refresh == RefreshMode.CacheOnly)
+ {
+ var cached = _cache.GetCached>>(key);
+ return cached.HasValue ? cached.Value! : new PagedResult>(Array.Empty(), Next: null);
+ }
+
+ Func>>> fetchAsync = async token =>
+ {
+ var data = await _dataSource.SearchIssuesAsync(query, page, token).ConfigureAwait(false);
+ var items = data.Select(OctokitMappings.ToWorkItemSummary).ToArray();
+
+ PageRequest? next = null;
+ if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize)
+ {
+ next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize);
+ }
+
+ return new PagedResult>(items, next);
+ };
+
+ CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh
+ ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false)
+ : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false);
+
+ return snapshot.HasValue
+ ? snapshot.Value!
+ : new PagedResult>(Array.Empty(), Next: null);
+ }
+}
diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs
new file mode 100644
index 0000000..6e1077e
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationPollingService.cs
@@ -0,0 +1,56 @@
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Polling;
+using JitHub.GitHub.Abstractions.Services;
+using JitHub.GitHub.Octokit.Mapping;
+using Microsoft.Extensions.Logging;
+
+namespace JitHub.GitHub.Octokit.Services;
+
+internal sealed class CachedGitHubNotificationPollingService : IGitHubNotificationPollingService
+{
+ private readonly CacheRuntime _cache;
+ private readonly IGitHubDataSource _dataSource;
+ private readonly ILogger _logger;
+
+ public CachedGitHubNotificationPollingService(
+ CacheRuntime cache,
+ IGitHubDataSource dataSource,
+ ILogger logger)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ }
+
+ public Task StartNotificationsPollingAsync(bool unreadOnly, PageRequest page, PollingRequest polling, CancellationToken ct)
+ {
+ _logger.LogInformation(
+ "Start notifications polling: unreadOnly={UnreadOnly} interval={IntervalMs}ms jitterMax={JitterMs}ms",
+ unreadOnly,
+ (long)polling.Interval.TotalMilliseconds,
+ (long)polling.JitterMax.GetValueOrDefault(TimeSpan.Zero).TotalMilliseconds);
+
+ var key = GitHubCacheKeys.MyNotifications(unreadOnly, page);
+ var options = new PollingOptions(polling.Interval, polling.JitterMax);
+
+ return _cache.StartPolling(
+ key,
+ options,
+ fetchAsync: async token =>
+ {
+ var data = await _dataSource.GetMyNotificationsAsync(unreadOnly, page, token).ConfigureAwait(false);
+ var items = data.Select(OctokitMappings.ToNotificationSummary).ToArray();
+
+ PageRequest? next = null;
+ if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize)
+ {
+ next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize);
+ }
+
+ return new PagedResult>(items, next);
+ },
+ ct);
+ }
+}
diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs
new file mode 100644
index 0000000..7d474e5
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Services/CachedGitHubNotificationService.cs
@@ -0,0 +1,59 @@
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Paging;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using JitHub.GitHub.Octokit.Mapping;
+
+namespace JitHub.GitHub.Octokit.Services;
+
+internal sealed class CachedGitHubNotificationService : IGitHubNotificationService
+{
+ private readonly CacheRuntime _cache;
+ private readonly IGitHubDataSource _dataSource;
+
+ public CachedGitHubNotificationService(CacheRuntime cache, IGitHubDataSource dataSource)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ public async Task>> GetMyNotificationsAsync(
+ bool unreadOnly,
+ PageRequest page,
+ RefreshMode refresh,
+ CancellationToken ct)
+ {
+ var key = GitHubCacheKeys.MyNotifications(unreadOnly, page);
+
+ if (refresh == RefreshMode.CacheOnly)
+ {
+ var cached = _cache.GetCached>>(key);
+ return cached.HasValue
+ ? cached.Value!
+ : new PagedResult>(Array.Empty(), Next: null);
+ }
+
+ Func>>> fetchAsync = async token =>
+ {
+ var data = await _dataSource.GetMyNotificationsAsync(unreadOnly, page, token).ConfigureAwait(false);
+ var items = data.Select(OctokitMappings.ToNotificationSummary).ToArray();
+
+ PageRequest? next = null;
+ if (page.Cursor is null && page.PageNumber is not null && items.Length == page.PageSize)
+ {
+ next = PageRequest.FromPageNumber(page.PageNumber.Value + 1, page.PageSize);
+ }
+
+ return new PagedResult>(items, next);
+ };
+
+ CacheSnapshot>> snapshot = refresh == RefreshMode.ForceRefresh
+ ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false)
+ : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false);
+
+ return snapshot.HasValue
+ ? snapshot.Value!
+ : new PagedResult>(Array.Empty(), Next: null);
+ }
+}
diff --git a/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs
new file mode 100644
index 0000000..6a20403
--- /dev/null
+++ b/JitHub.GitHub.Octokit/Services/CachedGitHubRepositoryDetailsService.cs
@@ -0,0 +1,42 @@
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+using JitHub.GitHub.Octokit.Mapping;
+
+namespace JitHub.GitHub.Octokit.Services;
+
+internal sealed class CachedGitHubRepositoryDetailsService : IGitHubRepositoryDetailsService
+{
+ private readonly CacheRuntime _cache;
+ private readonly IGitHubDataSource _dataSource;
+
+ public CachedGitHubRepositoryDetailsService(CacheRuntime cache, IGitHubDataSource dataSource)
+ {
+ _cache = cache ?? throw new ArgumentNullException(nameof(cache));
+ _dataSource = dataSource ?? throw new ArgumentNullException(nameof(dataSource));
+ }
+
+ public async Task GetRepositoryAsync(RepoKey repo, RefreshMode refresh, CancellationToken ct)
+ {
+ var key = GitHubCacheKeys.Repository(repo);
+
+ if (refresh == RefreshMode.CacheOnly)
+ {
+ var cached = _cache.GetCached(key);
+ return cached.HasValue ? cached.Value : null;
+ }
+
+ Func> fetchAsync = async token =>
+ {
+ var data = await _dataSource.GetRepositoryAsync(repo, token).ConfigureAwait(false);
+ return data is null ? null : OctokitMappings.ToRepositorySnapshot(data);
+ };
+
+ var snapshot = refresh == RefreshMode.ForceRefresh
+ ? await _cache.RefreshAsync(key, fetchAsync, ct).ConfigureAwait(false)
+ : await _cache.GetOrRefreshAsync(key, preferCacheThenRefresh: true, fetchAsync, ct).ConfigureAwait(false);
+
+ return snapshot.HasValue ? snapshot.Value : null;
+ }
+}
diff --git a/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs
index 91999d7..abb7a4a 100644
--- a/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs
+++ b/JitHub.GitHub.Octokit/Services/GitHubCacheKeys.cs
@@ -10,10 +10,61 @@ internal static class GitHubCacheKeys
public static CacheKey MyRepositories()
=> CacheKey.Create("github.repos.mine");
+ public static CacheKey Repository(RepoKey repo)
+ => CacheKey.Create(
+ "github.repo.get",
+ userScope: null,
+ ("owner", repo.Owner),
+ ("repo", repo.Name));
+
+ public static CacheKey MyActivity(PageRequest page)
+ {
+ var pageNumber = page.PageNumber?.ToString() ?? string.Empty;
+ var cursor = page.Cursor ?? string.Empty;
+
+ return CacheKey.Create(
+ "github.activity.mine",
+ userScope: null,
+ ("pageSize", page.PageSize.ToString()),
+ ("pageNumber", pageNumber),
+ ("cursor", cursor));
+ }
+
+ public static CacheKey RepoActivity(RepoKey repo, PageRequest page)
+ {
+ var pageNumber = page.PageNumber?.ToString() ?? string.Empty;
+ var cursor = page.Cursor ?? string.Empty;
+
+ return CacheKey.Create(
+ "github.activity.repo",
+ userScope: null,
+ ("owner", repo.Owner),
+ ("repo", repo.Name),
+ ("pageSize", page.PageSize.ToString()),
+ ("pageNumber", pageNumber),
+ ("cursor", cursor));
+ }
+
+ public static CacheKey MyNotifications(bool unreadOnly, PageRequest page)
+ {
+ var pageNumber = page.PageNumber?.ToString() ?? string.Empty;
+ var cursor = page.Cursor ?? string.Empty;
+
+ return CacheKey.Create(
+ "github.notifications.mine",
+ userScope: null,
+ ("unreadOnly", unreadOnly ? "1" : "0"),
+ ("pageSize", page.PageSize.ToString()),
+ ("pageNumber", pageNumber),
+ ("cursor", cursor));
+ }
+
public static CacheKey Issues(RepoKey repo, IssueQuery query, PageRequest page)
{
var state = query.State.ToString();
var search = string.IsNullOrWhiteSpace(query.SearchText) ? string.Empty : query.SearchText.Trim();
+ var sort = query.Sort?.ToString() ?? string.Empty;
+ var direction = query.Direction?.ToString() ?? string.Empty;
var pageNumber = page.PageNumber?.ToString() ?? string.Empty;
var cursor = page.Cursor ?? string.Empty;
@@ -25,6 +76,8 @@ public static CacheKey Issues(RepoKey repo, IssueQuery query, PageRequest page)
("repo", repo.Name),
("state", state),
("search", search),
+ ("sort", sort),
+ ("direction", direction),
("pageSize", page.PageSize.ToString()),
("pageNumber", pageNumber),
("cursor", cursor));
@@ -53,4 +106,24 @@ public static CacheKey IssueComments(RepoKey repo, int issueNumber, PageRequest
("pageNumber", pageNumber),
("cursor", cursor));
}
+
+ public static CacheKey SearchIssues(IssueSearchQuery query, PageRequest page)
+ {
+ var q = string.IsNullOrWhiteSpace(query.Query) ? string.Empty : query.Query.Trim();
+ var sort = query.Sort?.ToString() ?? string.Empty;
+ var direction = query.Direction?.ToString() ?? string.Empty;
+
+ var pageNumber = page.PageNumber?.ToString() ?? string.Empty;
+ var cursor = page.Cursor ?? string.Empty;
+
+ return CacheKey.Create(
+ "github.issues.search",
+ userScope: null,
+ ("q", q),
+ ("sort", sort),
+ ("direction", direction),
+ ("pageSize", page.PageSize.ToString()),
+ ("pageNumber", pageNumber),
+ ("cursor", cursor));
+ }
}
diff --git a/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs
index fdd98fe..b43c8dc 100644
--- a/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs
+++ b/JitHub.GitHub.Octokit/Services/IGitHubDataSource.cs
@@ -9,8 +9,18 @@ internal interface IGitHubDataSource
{
Task> GetMyRepositoriesAsync(CancellationToken ct);
+ Task GetRepositoryAsync(RepoKey repo, CancellationToken ct);
+
+ Task> GetMyActivityAsync(PageRequest page, CancellationToken ct);
+
+ Task> GetRepoActivityAsync(RepoKey repo, PageRequest page, CancellationToken ct);
+
+ Task> GetMyNotificationsAsync(bool unreadOnly, PageRequest page, CancellationToken ct);
+
Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct);
+ Task> SearchIssuesAsync(IssueSearchQuery query, PageRequest page, CancellationToken ct);
+
Task GetIssueAsync(RepoKey repo, int issueNumber, CancellationToken ct);
Task> GetIssueCommentsAsync(RepoKey repo, int issueNumber, PageRequest page, CancellationToken ct);
diff --git a/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs
index 572e3a3..5057488 100644
--- a/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs
+++ b/JitHub.GitHub.Octokit/Services/OctokitGitHubDataSource.cs
@@ -39,6 +39,100 @@ public async Task> GetMyRepositoriesAsync(C
.ToArray();
}
+ public async Task GetRepositoryAsync(RepoKey repo, CancellationToken ct)
+ {
+ var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false);
+
+ var r = await client.Repository.Get(repo.Owner, repo.Name).ConfigureAwait(false);
+
+ return new OctokitRepositoryDetailData(
+ Repo: repo,
+ IsPrivate: r.Private,
+ DefaultBranch: r.DefaultBranch,
+ Description: r.Description,
+ UpdatedAt: r.UpdatedAt,
+ StargazersCount: r.StargazersCount,
+ ForksCount: r.ForksCount,
+ WatchersCount: r.SubscribersCount);
+ }
+
+ public async Task> GetMyActivityAsync(PageRequest page, CancellationToken ct)
+ {
+ if (page.Cursor is not null)
+ {
+ throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider.");
+ }
+
+ var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false);
+
+ var apiOptions = new ApiOptions
+ {
+ PageCount = 1,
+ PageSize = page.PageSize,
+ StartPage = page.PageNumber.GetValueOrDefault(1),
+ };
+
+ // Events API needs a username.
+ var me = await client.User.Current().ConfigureAwait(false);
+ var login = me.Login;
+
+ var eventsClient = client.Activity.Events;
+ var raw = await InvokeToObjectListAsync(eventsClient, "GetAllUserPerformed", login, apiOptions).ConfigureAwait(false);
+
+ return raw.Select(MapActivityEvent).ToArray();
+ }
+
+ public async Task> GetRepoActivityAsync(RepoKey repo, PageRequest page, CancellationToken ct)
+ {
+ if (page.Cursor is not null)
+ {
+ throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider.");
+ }
+
+ var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false);
+
+ var apiOptions = new ApiOptions
+ {
+ PageCount = 1,
+ PageSize = page.PageSize,
+ StartPage = page.PageNumber.GetValueOrDefault(1),
+ };
+
+ var eventsClient = client.Activity.Events;
+ var raw = await InvokeToObjectListAsync(eventsClient, "GetAllForRepository", repo.Owner, repo.Name, apiOptions).ConfigureAwait(false);
+
+ return raw.Select(MapActivityEvent).ToArray();
+ }
+
+ public async Task> GetMyNotificationsAsync(bool unreadOnly, PageRequest page, CancellationToken ct)
+ {
+ if (page.Cursor is not null)
+ {
+ throw new NotSupportedException("Cursor-based pagination is not supported by the Octokit provider.");
+ }
+
+ var client = await _clientFactory.CreateAsync(ct).ConfigureAwait(false);
+
+ var apiOptions = new ApiOptions
+ {
+ PageCount = 1,
+ PageSize = page.PageSize,
+ StartPage = page.PageNumber.GetValueOrDefault(1),
+ };
+
+ var notifications = await GetNotificationsCompatAsync(client, unreadOnly, apiOptions).ConfigureAwait(false);
+
+ return notifications
+ .Select(n => new OctokitNotificationData(
+ Id: TryGetNotificationId(n),
+ Repo: new RepoKey(n.Repository?.Owner?.Login ?? string.Empty, n.Repository?.Name ?? string.Empty),
+ Title: n.Subject?.Title ?? string.Empty,
+ Type: n.Subject?.Type,
+ UpdatedAt: TryGetUpdatedAt(n),
+ Unread: n.Unread))
+ .ToArray();
+ }
+
public async Task> GetIssuesAsync(RepoKey repo, IssueQuery query, PageRequest page, CancellationToken ct)
{
if (page.Cursor is not null)
@@ -55,7 +149,7 @@ public async Task> GetIssuesAsync(RepoKey repo,
StartPage = page.PageNumber.GetValueOrDefault(1),
};
- if (!string.IsNullOrWhiteSpace(query.SearchText))
+ if (!string.IsNullOrWhiteSpace(query.SearchText) || query.Sort is not null || query.Direction is not null)
{
var q = BuildSearchQuery(repo, query);
var request = new SearchIssuesRequest(q)
@@ -64,6 +158,8 @@ public async Task> GetIssuesAsync(RepoKey repo,
Page = page.PageNumber.GetValueOrDefault(1),
};
+ ApplySearchSorting(request, query);
+
var result = await client.Search.SearchIssues(request).ConfigureAwait(false);
return result.Items
@@ -103,6 +199,349 @@ public async Task> GetIssuesAsync(RepoKey repo,
.ToArray();
}
+ private static async Task> GetNotificationsCompatAsync(IGitHubClient client, bool unreadOnly, ApiOptions apiOptions)
+ {
+ // Octokit surface differs across versions. Prefer typed request if available; otherwise fall back to reflection.
+ try
+ {
+ var request = new NotificationsRequest
+ {
+ All = !unreadOnly,
+ Participating = false,
+ };
+
+ var result = await client.Activity.Notifications.GetAllForCurrent(request, apiOptions).ConfigureAwait(false);
+ return result;
+ }
+ catch
+ {
+ // Fallback: try to invoke GetAllForCurrent(...) overloads via reflection.
+ var notificationsClient = client.Activity.Notifications;
+ var type = notificationsClient.GetType();
+
+ var methods = type.GetMethods(System.Reflection.BindingFlags.Public | System.Reflection.BindingFlags.Instance)
+ .Where(m => string.Equals(m.Name, "GetAllForCurrent", StringComparison.Ordinal))
+ .ToArray();
+
+ // Try (ApiOptions)
+ var mApiOnly = methods.FirstOrDefault(m =>
+ {
+ var p = m.GetParameters();
+ return p.Length == 1 && p[0].ParameterType == typeof(ApiOptions);
+ });
+
+ if (mApiOnly is not null)
+ {
+ var task = (Task)mApiOnly.Invoke(notificationsClient, new object?[] { apiOptions })!;
+ await task.ConfigureAwait(false);
+ return ExtractTaskResult>(task) ?? Array.Empty();
+ }
+
+ // Try (object request, ApiOptions)
+ var mReqApi = methods.FirstOrDefault(m =>
+ {
+ var p = m.GetParameters();
+ return p.Length == 2 && p[1].ParameterType == typeof(ApiOptions);
+ });
+
+ if (mReqApi is not null)
+ {
+ var requestType = mReqApi.GetParameters()[0].ParameterType;
+ var req = Activator.CreateInstance(requestType);
+ if (req is not null)
+ {
+ TrySetBoolProperty(req, "All", !unreadOnly);
+ TrySetBoolProperty(req, "Participating", false);
+ }
+
+ var task = (Task)mReqApi.Invoke(notificationsClient, new[] { req, apiOptions })!;
+ await task.ConfigureAwait(false);
+ return ExtractTaskResult>(task) ?? Array.Empty();
+ }
+
+ // Last resort: no apiOptions.
+ var mNoArgs = methods.FirstOrDefault(m => m.GetParameters().Length == 0);
+ if (mNoArgs is not null)
+ {
+ var task = (Task)mNoArgs.Invoke(notificationsClient, Array.Empty
diff --git a/JitHubV3/Presentation/DashboardCardActionModel.cs b/JitHubV3/Presentation/DashboardCardActionModel.cs
new file mode 100644
index 0000000..eb55a20
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardActionModel.cs
@@ -0,0 +1,7 @@
+using System.Windows.Input;
+
+namespace JitHubV3.Presentation;
+
+public sealed record DashboardCardActionModel(
+ string Label,
+ ICommand? Command);
diff --git a/JitHubV3/Presentation/DashboardCardId.cs b/JitHubV3/Presentation/DashboardCardId.cs
new file mode 100644
index 0000000..b58d47f
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardId.cs
@@ -0,0 +1,59 @@
+namespace JitHubV3.Presentation;
+
+internal static class DashboardCardId
+{
+ // NOTE: DashboardViewModel syncs cards by CardId; these must be globally unique.
+
+ public const long RepoRecentlyUpdatedIssues = 20_000_003;
+ public const long RepoSnapshot = 20_000_005;
+
+ public const long RepoRecentActivityEmpty = 20_000_004;
+ public const long MyRecentActivityEmpty = 30_000_004;
+ public const long NotificationsEmpty = 30_000_003;
+
+ private const long RepoRecentActivityItemBase = 20_000_004_000_000;
+ private const long MyRecentActivityItemBase = 30_000_004_000_000;
+ private const long NotificationsItemBase = 30_000_003_000_000;
+
+ private const uint FeedCardIdModulo = 1_000_000;
+
+ public static long NotificationsItem(string notificationId)
+ => FeedItem(NotificationsItemBase, notificationId);
+
+ public static long MyRecentActivityItem(string eventId)
+ => FeedItem(MyRecentActivityItemBase, eventId);
+
+ public static long RepoRecentActivityItem(string eventId)
+ => FeedItem(RepoRecentActivityItemBase, eventId);
+
+ private static long FeedItem(long cardIdBase, string id)
+ {
+ if (id is null)
+ {
+ throw new ArgumentNullException(nameof(id));
+ }
+
+ // Must be stable across processes; do NOT use string.GetHashCode().
+ var hash = StableHash32(id);
+ return cardIdBase + (hash % FeedCardIdModulo);
+ }
+
+ private static uint StableHash32(string value)
+ {
+ // FNV-1a 32-bit
+ unchecked
+ {
+ const uint offsetBasis = 2166136261;
+ const uint prime = 16777619;
+
+ uint hash = offsetBasis;
+ for (var i = 0; i < value.Length; i++)
+ {
+ hash ^= value[i];
+ hash *= prime;
+ }
+
+ return hash;
+ }
+ }
+}
diff --git a/JitHubV3/Presentation/DashboardCardKind.cs b/JitHubV3/Presentation/DashboardCardKind.cs
new file mode 100644
index 0000000..deb84ea
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardKind.cs
@@ -0,0 +1,15 @@
+namespace JitHubV3.Presentation;
+
+public enum DashboardCardKind
+{
+ Unknown = 0,
+ SelectedRepo = 1,
+ RepoIssuesSummary = 2,
+ RepoRecentlyUpdatedIssues = 3,
+ MyAssignedWork = 4,
+ MyReviewRequests = 5,
+ Notifications = 6,
+ MyRecentActivity = 7,
+ RepoRecentActivity = 8,
+ RepoSnapshot = 9,
+}
diff --git a/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs b/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs
new file mode 100644
index 0000000..5e30543
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardKindToAutomationIdConverter.cs
@@ -0,0 +1,31 @@
+using Microsoft.UI.Xaml.Data;
+
+namespace JitHubV3.Presentation;
+
+public sealed class DashboardCardKindToAutomationIdConverter : IValueConverter
+{
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ if (value is DashboardCardKind kind)
+ {
+ return kind switch
+ {
+ DashboardCardKind.SelectedRepo => "DashboardCard.SelectedRepo",
+ DashboardCardKind.RepoIssuesSummary => "DashboardCard.RepoIssuesSummary",
+ DashboardCardKind.RepoRecentlyUpdatedIssues => "DashboardCard.RepoRecentlyUpdatedIssues",
+ DashboardCardKind.MyAssignedWork => "DashboardCard.MyAssignedWork",
+ DashboardCardKind.MyReviewRequests => "DashboardCard.MyReviewRequests",
+ DashboardCardKind.Notifications => "DashboardCard.Notifications",
+ DashboardCardKind.MyRecentActivity => "DashboardCard.MyRecentActivity",
+ DashboardCardKind.RepoRecentActivity => "DashboardCard.RepoRecentActivity",
+ DashboardCardKind.RepoSnapshot => "DashboardCard.RepoSnapshot",
+ _ => "DashboardCard.Unknown",
+ };
+ }
+
+ return "DashboardCard.Unknown";
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
diff --git a/JitHubV3/Presentation/DashboardCardModel.cs b/JitHubV3/Presentation/DashboardCardModel.cs
new file mode 100644
index 0000000..cd085f6
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardModel.cs
@@ -0,0 +1,13 @@
+using System.Collections.Generic;
+
+namespace JitHubV3.Presentation;
+
+public sealed record DashboardCardModel(
+ long CardId,
+ DashboardCardKind Kind,
+ string Title,
+ string? Subtitle,
+ string? Summary,
+ int Importance = 0,
+ IReadOnlyList? Actions = null,
+ int? TintVariant = null);
diff --git a/JitHubV3/Presentation/DashboardCardProviderTier.cs b/JitHubV3/Presentation/DashboardCardProviderTier.cs
new file mode 100644
index 0000000..8e94c99
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardCardProviderTier.cs
@@ -0,0 +1,9 @@
+namespace JitHubV3.Presentation;
+
+public enum DashboardCardProviderTier
+{
+ Local = 0,
+ SingleCallMultiCard = 1,
+ SingleCallSingleCard = 2,
+ MultiCallEnrichment = 3,
+}
diff --git a/JitHubV3/Presentation/DashboardContext.cs b/JitHubV3/Presentation/DashboardContext.cs
new file mode 100644
index 0000000..a111e54
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardContext.cs
@@ -0,0 +1,15 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using JitHub.GitHub.Abstractions.Models;
+
+namespace JitHubV3.Presentation;
+
+public sealed partial class DashboardContext : ObservableObject
+{
+ private RepoKey? _selectedRepo;
+
+ public RepoKey? SelectedRepo
+ {
+ get => _selectedRepo;
+ set => SetProperty(ref _selectedRepo, value);
+ }
+}
diff --git a/JitHubV3/Presentation/DashboardLayoutTestPage.xaml b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml
new file mode 100644
index 0000000..2eecf96
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JitHubV3/Presentation/DashboardLayoutTestPage.xaml.cs b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml.cs
new file mode 100644
index 0000000..4621cf1
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardLayoutTestPage.xaml.cs
@@ -0,0 +1,48 @@
+using JitHub.Dashboard.Layouts;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Controls;
+
+namespace JitHubV3.Presentation;
+
+public sealed partial class DashboardLayoutTestPage : Page
+{
+ public DashboardLayoutTestPage()
+ {
+ InitializeComponent();
+
+ Loaded += OnLoaded;
+ Unloaded += OnUnloaded;
+ DataContextChanged += OnDataContextChanged;
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ WireSwipeDelegate();
+ }
+
+ private void OnDataContextChanged(FrameworkElement sender, DataContextChangedEventArgs args)
+ {
+ WireSwipeDelegate();
+ }
+
+ private void OnUnloaded(object sender, RoutedEventArgs e)
+ {
+ UnwireSwipeDelegate();
+ }
+
+ private void WireSwipeDelegate()
+ {
+ if (DataContext is DashboardLayoutTestViewModel vm)
+ {
+ vm.SwipeRequestAsync = (cardId, direction, ct) => CardPresenter.SwipeAsync(cardId, direction, ct);
+ }
+ }
+
+ private void UnwireSwipeDelegate()
+ {
+ if (DataContext is DashboardLayoutTestViewModel vm)
+ {
+ vm.SwipeRequestAsync = null;
+ }
+ }
+}
diff --git a/JitHubV3/Presentation/DashboardLayoutTestViewModel.cs b/JitHubV3/Presentation/DashboardLayoutTestViewModel.cs
new file mode 100644
index 0000000..2703c16
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardLayoutTestViewModel.cs
@@ -0,0 +1,92 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using System.Collections.ObjectModel;
+using JitHub.Dashboard.Layouts;
+
+namespace JitHubV3.Presentation;
+
+public sealed partial class DashboardLayoutTestViewModel : ObservableObject
+{
+ private readonly INavigator _navigator;
+
+ public Func>? SwipeRequestAsync { get; set; }
+
+ public DashboardLayoutTestViewModel(INavigator navigator)
+ {
+ _navigator = navigator;
+ GoBack = new AsyncRelayCommand(DoGoBack);
+
+ DashboardCardActionModel DismissAction(long cardId)
+ => new("Dismiss", new AsyncRelayCommand(() => DismissCard(cardId, CancellationToken.None)));
+
+ Cards =
+ new ObservableCollection
+ {
+ new DashboardCardModel(
+ 1,
+ DashboardCardKind.SelectedRepo,
+ "Selected Repository",
+ "octocat/Hello-World",
+ "Tap Dismiss to exercise SwipeAsync + removal.",
+ Importance: 100,
+ Actions: [ DismissAction(1) ],
+ TintVariant: 1 % 5),
+ new DashboardCardModel(2, DashboardCardKind.Unknown, "Longer card title that should wrap across multiple lines", "Subtitle that also wraps when the width is constrained", "This is a longer summary to validate multi-line text layout inside a fixed-height card surface.", Actions: [ DismissAction(2) ], TintVariant: 2 % 5),
+ new DashboardCardModel(3, DashboardCardKind.Unknown, "No subtitle", null, "Summary only.", Actions: [ DismissAction(3) ], TintVariant: 3 % 5),
+ new DashboardCardModel(4, DashboardCardKind.Unknown, "No summary", "Only subtitle", null, Actions: [ DismissAction(4) ], TintVariant: 4 % 5),
+ new DashboardCardModel(5, DashboardCardKind.Unknown, "Empty body", null, null, Actions: [ DismissAction(5) ], TintVariant: 0),
+ new DashboardCardModel(6, DashboardCardKind.Unknown, "Metrics", "Stars: 1234 · Forks: 56", "This line includes separators and bullets to test trimming/wrapping.", Actions: [ DismissAction(6) ], TintVariant: 1),
+ new DashboardCardModel(7, DashboardCardKind.Unknown, "Edge cases", "Multiple\nlines", "Newlines in text are not expected but should remain stable.", Actions: [ DismissAction(7) ], TintVariant: 2),
+ new DashboardCardModel(8, DashboardCardKind.Unknown, "Accessibility", "Focus order + hit testing", "Use Tab to move focus between controls and ensure the card layout doesn't trap focus.", Actions: [ DismissAction(8) ], TintVariant: 3),
+ new DashboardCardModel(9, DashboardCardKind.Unknown, "Deck depth", "Card 9", "In Deck mode, cards should stack with rotation and Y offset.", Actions: [ DismissAction(9) ], TintVariant: 4),
+ new DashboardCardModel(10, DashboardCardKind.Unknown, "Deck depth", "Card 10", "In Deck mode, only a few should be visible (clamped).", Actions: [ DismissAction(10) ], TintVariant: 0),
+ new DashboardCardModel(11, DashboardCardKind.Unknown, "Grid fill", "Card 11", "In Grid mode, columns should center with consistent gutters.", Actions: [ DismissAction(11) ], TintVariant: 1),
+ new DashboardCardModel(12, DashboardCardKind.Unknown, "Grid fill", "Card 12", "Resize the window to watch Auto mode switch.", Actions: [ DismissAction(12) ], TintVariant: 2),
+ };
+ }
+
+ private string _title = "Dashboard Layout Test";
+ private CardDeckLayoutMode _selectedMode = CardDeckLayoutMode.Auto;
+
+ public string Title
+ {
+ get => _title;
+ set => SetProperty(ref _title, value);
+ }
+
+ public CardDeckLayoutMode[] Modes { get; } =
+ [
+ CardDeckLayoutMode.Auto,
+ CardDeckLayoutMode.Grid,
+ CardDeckLayoutMode.Deck,
+ ];
+
+ public CardDeckLayoutMode SelectedMode
+ {
+ get => _selectedMode;
+ set => SetProperty(ref _selectedMode, value);
+ }
+
+ public ObservableCollection Cards { get; }
+
+ public ICommand GoBack { get; }
+
+ private async Task DismissCard(long cardId, CancellationToken ct)
+ {
+ var didAnimate = false;
+
+ if (SwipeRequestAsync is not null)
+ {
+ didAnimate = await SwipeRequestAsync(cardId, CardSwipeDirection.Right, ct);
+ }
+
+ // Remove regardless; if animation couldn't run (virtualized), treat as an instant dismissal.
+ var item = Cards.FirstOrDefault(c => c.CardId == cardId);
+ if (item is not null)
+ {
+ Cards.Remove(item);
+ }
+ }
+
+ private Task DoGoBack(CancellationToken ct)
+ => _navigator.NavigateBackAsync(this, cancellation: ct);
+}
diff --git a/JitHubV3/Presentation/DashboardPage.xaml b/JitHubV3/Presentation/DashboardPage.xaml
new file mode 100644
index 0000000..ab83543
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardPage.xaml
@@ -0,0 +1,166 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/JitHubV3/Presentation/DashboardPage.xaml.cs b/JitHubV3/Presentation/DashboardPage.xaml.cs
new file mode 100644
index 0000000..50cdfda
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardPage.xaml.cs
@@ -0,0 +1,58 @@
+namespace JitHubV3.Presentation;
+
+using System.Numerics;
+
+public sealed partial class DashboardPage : ActivatablePage
+{
+ public DashboardPage()
+ {
+ InitializeComponent();
+
+ Loaded += OnLoaded;
+ SizeChanged += (_, __) => UpdateWidthState(useTransitions: true);
+ }
+
+ private void OnLoaded(object sender, RoutedEventArgs e)
+ {
+ UpdateWidthState(useTransitions: false);
+ ApplyComposeElevation();
+ }
+
+ private void ApplyComposeElevation()
+ {
+ if (ComposeBox is null)
+ {
+ return;
+ }
+
+ var elevation = ResolveDoubleResource("DashboardElevationCompose", fallback: 16);
+ ComposeBox.Translation = new Vector3(0, 0, (float)elevation);
+ }
+
+ private void UpdateWidthState(bool useTransitions)
+ {
+ var threshold = ResolveGridToDeckMinWidth();
+ var state = ActualWidth >= threshold ? "Wide" : "Narrow";
+
+ if (StateHost is not null)
+ {
+ VisualStateManager.GoToState(StateHost, state, useTransitions);
+ }
+ }
+
+ private static double ResolveGridToDeckMinWidth()
+ {
+ return ResolveDoubleResource("DashboardGridToDeckMinWidth", fallback: 900);
+ }
+
+ private static double ResolveDoubleResource(string key, double fallback)
+ {
+ var resources = Application.Current?.Resources;
+ if (resources is not null && resources.TryGetValue(key, out var value) && value is double width)
+ {
+ return width;
+ }
+
+ return fallback;
+ }
+}
diff --git a/JitHubV3/Presentation/DashboardViewModel.cs b/JitHubV3/Presentation/DashboardViewModel.cs
new file mode 100644
index 0000000..6534775
--- /dev/null
+++ b/JitHubV3/Presentation/DashboardViewModel.cs
@@ -0,0 +1,476 @@
+using System.Collections.ObjectModel;
+using System.ComponentModel;
+using System.Threading;
+using JitHub.Data.Caching;
+using JitHub.GitHub.Abstractions.Models;
+using JitHub.GitHub.Abstractions.Refresh;
+using JitHub.GitHub.Abstractions.Services;
+
+namespace JitHubV3.Presentation;
+
+public sealed partial class DashboardViewModel : ObservableObject, IActivatableViewModel
+{
+ private const string ReposCacheOperation = "github.repos.mine";
+ private const int CardProviderMaxConcurrency = 4;
+
+ private readonly ILogger _logger;
+ private readonly IDispatcher _dispatcher;
+ private readonly IGitHubRepositoryService _repositoryService;
+ private readonly ICacheEventBus _events;
+ private readonly StatusBarViewModel _statusBar;
+ private readonly IReadOnlyList _cardProviders;
+
+ private CancellationTokenSource? _activeCts;
+ private CancellationTokenSource? _cardsCts;
+ private IDisposable? _subscription;
+ private int _loadGate;
+
+ public DashboardContext Context { get; } = new();
+
+ public ObservableCollection Repositories { get; } = new();
+
+ public ObservableCollection Cards { get; } = new();
+
+ private bool _isLoadingCards;
+ public bool IsLoadingCards
+ {
+ get => _isLoadingCards;
+ set => SetProperty(ref _isLoadingCards, value);
+ }
+
+ private bool _isLoadingRepos;
+ public bool IsLoadingRepos
+ {
+ get => _isLoadingRepos;
+ set => SetProperty(ref _isLoadingRepos, value);
+ }
+
+ private int _repositoryCount;
+ public int RepositoryCount
+ {
+ get => _repositoryCount;
+ set => SetProperty(ref _repositoryCount, value);
+ }
+
+ private long? _selectedRepositoryId;
+ public long? SelectedRepositoryId
+ {
+ get => _selectedRepositoryId;
+ set
+ {
+ if (!SetProperty(ref _selectedRepositoryId, value))
+ {
+ return;
+ }
+
+ OnSelectedRepositoryIdChanged(value);
+ }
+ }
+
+ public IRelayCommand SelectRepoCommand { get; }
+
+ public DashboardViewModel(
+ ILogger logger,
+ IDispatcher dispatcher,
+ IGitHubRepositoryService repositoryService,
+ ICacheEventBus events,
+ StatusBarViewModel statusBar,
+ IEnumerable cardProviders)
+ {
+ _logger = logger;
+ _dispatcher = dispatcher;
+ _repositoryService = repositoryService;
+ _events = events;
+ _statusBar = statusBar;
+ _cardProviders = (cardProviders ?? Enumerable.Empty())
+ .OrderBy(p => p.Priority)
+ .ThenBy(p => p.ProviderId, StringComparer.Ordinal)
+ .ToArray();
+
+ SelectRepoCommand = new RelayCommand(SelectRepo);
+ }
+
+ public string Title { get; } = "Dashboard";
+
+ public async Task ActivateAsync()
+ {
+ Deactivate();
+ _activeCts = new CancellationTokenSource();
+
+ Context.PropertyChanged += OnContextPropertyChanged;
+
+ _statusBar.Set(
+ message: "Dashboard",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown,
+ lastUpdatedAt: null);
+
+ _ = RefreshCardsAsync(RefreshMode.CacheOnly, _activeCts.Token);
+
+ await LoadReposAsync(_activeCts.Token);
+ }
+
+ public void Deactivate()
+ {
+ Context.PropertyChanged -= OnContextPropertyChanged;
+
+ _activeCts?.Cancel();
+ _activeCts?.Dispose();
+ _activeCts = null;
+
+ _cardsCts?.Cancel();
+ _cardsCts?.Dispose();
+ _cardsCts = null;
+
+ _subscription?.Dispose();
+ _subscription = null;
+
+ Interlocked.Exchange(ref _loadGate, 0);
+ IsLoadingRepos = false;
+ IsLoadingCards = false;
+ }
+
+ private void OnContextPropertyChanged(object? sender, PropertyChangedEventArgs e)
+ {
+ if (e.PropertyName != nameof(DashboardContext.SelectedRepo))
+ {
+ return;
+ }
+
+ var ct = _activeCts?.Token;
+ if (ct is null)
+ {
+ return;
+ }
+
+ _ = RefreshCardsAsync(RefreshMode.CacheOnly, ct.Value);
+ }
+
+ private void OnSelectedRepositoryIdChanged(long? value)
+ {
+ if (value is null)
+ {
+ Context.SelectedRepo = null;
+ return;
+ }
+
+ var repo = Repositories.FirstOrDefault(r => r.Id == value.Value);
+ if (repo is null)
+ {
+ return;
+ }
+
+ SelectRepoCommand.Execute(repo);
+ }
+
+ private async Task LoadReposAsync(CancellationToken ct)
+ {
+ if (Interlocked.Exchange(ref _loadGate, 1) == 1)
+ {
+ return;
+ }
+
+ var sawCacheUpdate = false;
+ _subscription?.Dispose();
+ _subscription = _events.Subscribe(e =>
+ {
+ if (e.Kind != CacheEventKind.Updated)
+ {
+ return;
+ }
+
+ if (!string.Equals(e.Key.Operation, ReposCacheOperation, StringComparison.Ordinal))
+ {
+ return;
+ }
+
+ sawCacheUpdate = true;
+ _ = _dispatcher.ExecuteAsync(async _ =>
+ {
+ try
+ {
+ var cached = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.CacheOnly, ct);
+ ObservableCollectionSync.SyncById(
+ Repositories,
+ cached,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+
+ RepositoryCount = Repositories.Count;
+ }
+ catch
+ {
+ // Ignore UI update failures during shutdown/cancellation.
+ }
+ });
+ });
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoadingRepos = true;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: "Loading repositories…",
+ isBusy: true,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown,
+ lastUpdatedAt: null);
+
+ try
+ {
+ _logger.LogInformation("Loading repositories (RefreshMode={RefreshMode})", RefreshMode.PreferCacheThenRefresh);
+ var repos = await _repositoryService.GetMyRepositoriesAsync(RefreshMode.PreferCacheThenRefresh, ct);
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ ObservableCollectionSync.SyncById(
+ Repositories,
+ repos,
+ getId: x => x.Id,
+ shouldReplace: (current, next) => current.UpdatedAt != next.UpdatedAt || current != next);
+
+ RepositoryCount = Repositories.Count;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(
+ message: repos.Count == 0 ? "No repositories" : $"{repos.Count} repositories",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: sawCacheUpdate ? DataFreshnessState.Fresh : DataFreshnessState.Cached,
+ lastUpdatedAt: sawCacheUpdate ? DateTimeOffset.Now : null);
+ }
+ catch (OperationCanceledException)
+ {
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to load repositories");
+ _statusBar.Set(
+ message: "Failed to load repositories",
+ isBusy: false,
+ isRefreshing: false,
+ freshness: DataFreshnessState.Unknown);
+ }
+ finally
+ {
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoadingRepos = false;
+ return ValueTask.CompletedTask;
+ });
+
+ _statusBar.Set(isBusy: false, isRefreshing: false);
+ Interlocked.Exchange(ref _loadGate, 0);
+ }
+ }
+
+ private void SelectRepo(RepositorySummary? repo)
+ {
+ if (repo is null)
+ {
+ return;
+ }
+
+ Context.SelectedRepo = new RepoKey(repo.OwnerLogin, repo.Name);
+ SelectedRepositoryId = repo.Id;
+ _statusBar.Set(message: $"Repo: {repo.OwnerLogin}/{repo.Name}");
+ }
+
+ private async Task RefreshCardsAsync(RefreshMode refresh, CancellationToken ct)
+ {
+ if (_cardProviders.Count == 0)
+ {
+ return;
+ }
+
+ CancellationTokenSource? previousCts;
+ var nextCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
+
+ previousCts = Interlocked.Exchange(ref _cardsCts, nextCts);
+ previousCts?.Cancel();
+ previousCts?.Dispose();
+
+ var localCt = nextCts.Token;
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoadingCards = true;
+ return ValueTask.CompletedTask;
+ });
+
+ try
+ {
+ var plannedProviders = _cardProviders
+ .Select((provider, order) => new PlannedProvider(provider, GetTier(provider), order))
+ .ToArray();
+
+ var cardsByProviderId = new Dictionary>(StringComparer.Ordinal);
+ var cardsLock = new object();
+
+ async Task PublishCardsAsync(CancellationToken publishCt)
+ {
+ List snapshot;
+
+ lock (cardsLock)
+ {
+ snapshot = BuildSnapshot(plannedProviders, cardsByProviderId);
+ }
+
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ ObservableCollectionSync.SyncById(
+ Cards,
+ snapshot,
+ getId: x => x.CardId,
+ shouldReplace: (current, next) => current != next);
+
+ return ValueTask.CompletedTask;
+ });
+ }
+
+ async Task RunProviderAsync(PlannedProvider planned, RefreshMode providerRefresh, CancellationToken providerCt)
+ {
+ providerCt.ThrowIfCancellationRequested();
+
+ try
+ {
+ var cards = await planned.Provider.GetCardsAsync(Context, providerRefresh, providerCt).ConfigureAwait(false);
+
+ var normalized = Normalize(cards);
+ lock (cardsLock)
+ {
+ if (normalized.Count == 0)
+ {
+ cardsByProviderId.Remove(planned.Provider.ProviderId);
+ }
+ else
+ {
+ cardsByProviderId[planned.Provider.ProviderId] = normalized;
+ }
+ }
+
+ await PublishCardsAsync(providerCt).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore cancellation.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Dashboard card provider failed (ProviderId={ProviderId})", planned.Provider.ProviderId);
+ }
+ }
+
+ // Wave A: always run the cheapest/high-yield providers first.
+ await StagedWorkScheduler.RunAsync(
+ plannedProviders,
+ tierSelector: p => (int)p.Tier,
+ maxTierInclusive: (int)DashboardCardProviderTier.SingleCallMultiCard,
+ maxConcurrency: CardProviderMaxConcurrency,
+ workAsync: (p, token) => RunProviderAsync(p, refresh, token),
+ ct: localCt).ConfigureAwait(false);
+
+ if (refresh == RefreshMode.CacheOnly && !localCt.IsCancellationRequested)
+ {
+ // Wave B: background refresh for everything that can hit the network.
+ var backgroundProviders = plannedProviders
+ .Where(p => p.Tier != DashboardCardProviderTier.Local)
+ .ToArray();
+
+ _ = Task.Run(async () =>
+ {
+ try
+ {
+ await StagedWorkScheduler.RunAsync(
+ backgroundProviders,
+ tierSelector: p => (int)p.Tier,
+ maxTierInclusive: (int)DashboardCardProviderTier.MultiCallEnrichment,
+ maxConcurrency: CardProviderMaxConcurrency,
+ workAsync: (p, token) => RunProviderAsync(p, RefreshMode.PreferCacheThenRefresh, token),
+ ct: localCt).ConfigureAwait(false);
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore cancellation.
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Background card refresh failed");
+ }
+ });
+ }
+ else
+ {
+ // If the caller explicitly requested a refresh, finish the remaining tiers as part of this call.
+ var remainingProviders = plannedProviders
+ .Where(p => p.Tier > DashboardCardProviderTier.SingleCallMultiCard)
+ .ToArray();
+
+ await StagedWorkScheduler.RunAsync(
+ remainingProviders,
+ tierSelector: p => (int)p.Tier,
+ maxTierInclusive: (int)DashboardCardProviderTier.MultiCallEnrichment,
+ maxConcurrency: CardProviderMaxConcurrency,
+ workAsync: (p, token) => RunProviderAsync(p, refresh, token),
+ ct: localCt).ConfigureAwait(false);
+ }
+ }
+ catch (OperationCanceledException)
+ {
+ // Ignore cancellation.
+ }
+ finally
+ {
+ await _dispatcher.ExecuteAsync(_ =>
+ {
+ IsLoadingCards = false;
+ return ValueTask.CompletedTask;
+ });
+ }
+ }
+
+ private static DashboardCardProviderTier GetTier(IDashboardCardProvider provider)
+ => provider is IStagedDashboardCardProvider staged
+ ? staged.Tier
+ : DashboardCardProviderTier.SingleCallSingleCard;
+
+ private static IReadOnlyList Normalize(IReadOnlyList? cards)
+ {
+ if (cards is null || cards.Count == 0)
+ {
+ return Array.Empty();
+ }
+
+ return cards
+ .OrderByDescending(c => c.Importance)
+ .ThenBy(c => c.Title, StringComparer.Ordinal)
+ .ThenBy(c => c.CardId)
+ .ToArray();
+ }
+
+ private static List BuildSnapshot(
+ IReadOnlyList providerOrder,
+ IReadOnlyDictionary> cardsByProviderId)
+ {
+ var combined = new List();
+
+ foreach (var planned in providerOrder)
+ {
+ if (!cardsByProviderId.TryGetValue(planned.Provider.ProviderId, out var cards) || cards.Count == 0)
+ {
+ continue;
+ }
+
+ combined.AddRange(cards);
+ }
+
+ return combined;
+ }
+
+ private sealed record PlannedProvider(IDashboardCardProvider Provider, DashboardCardProviderTier Tier, int Order);
+}
diff --git a/JitHubV3/Presentation/IDashboardCardProvider.cs b/JitHubV3/Presentation/IDashboardCardProvider.cs
new file mode 100644
index 0000000..e2d6d14
--- /dev/null
+++ b/JitHubV3/Presentation/IDashboardCardProvider.cs
@@ -0,0 +1,16 @@
+using JitHub.GitHub.Abstractions.Refresh;
+using System.Collections.Generic;
+
+namespace JitHubV3.Presentation;
+
+public interface IDashboardCardProvider
+{
+ string ProviderId { get; }
+
+ int Priority { get; }
+
+ Task> GetCardsAsync(
+ DashboardContext context,
+ RefreshMode refresh,
+ CancellationToken ct);
+}
diff --git a/JitHubV3/Presentation/IStagedDashboardCardProvider.cs b/JitHubV3/Presentation/IStagedDashboardCardProvider.cs
new file mode 100644
index 0000000..2b63784
--- /dev/null
+++ b/JitHubV3/Presentation/IStagedDashboardCardProvider.cs
@@ -0,0 +1,6 @@
+namespace JitHubV3.Presentation;
+
+public interface IStagedDashboardCardProvider : IDashboardCardProvider
+{
+ DashboardCardProviderTier Tier { get; }
+}
diff --git a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs
deleted file mode 100644
index f7e9f0f..0000000
--- a/JitHubV3/Presentation/Infrastructure/BoolToVisibilityConverter.cs
+++ /dev/null
@@ -1,37 +0,0 @@
-using System;
-using Microsoft.UI.Xaml;
-using Microsoft.UI.Xaml.Data;
-
-namespace JitHubV3.Presentation;
-
-public sealed class BoolToVisibilityConverter : IValueConverter
-{
- public object Convert(object value, Type targetType, object parameter, string language)
- {
- var visible = value is bool b && b;
-
- if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase))
- {
- visible = !visible;
- }
-
- return visible ? Visibility.Visible : Visibility.Collapsed;
- }
-
- public object ConvertBack(object value, Type targetType, object parameter, string language)
- {
- if (value is not Visibility v)
- {
- return false;
- }
-
- var result = v == Visibility.Visible;
-
- if (parameter is string s && string.Equals(s, "Invert", StringComparison.OrdinalIgnoreCase))
- {
- result = !result;
- }
-
- return result;
- }
-}
diff --git a/JitHubV3/Presentation/Infrastructure/CardTintVariantToBrushConverter.cs b/JitHubV3/Presentation/Infrastructure/CardTintVariantToBrushConverter.cs
new file mode 100644
index 0000000..90a6118
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/CardTintVariantToBrushConverter.cs
@@ -0,0 +1,51 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+using Microsoft.UI.Xaml.Media;
+
+namespace JitHubV3.Presentation.Infrastructure;
+
+public sealed class CardTintVariantToBrushConverter : IValueConverter
+{
+ private static readonly string[] _paletteKeys =
+ [
+ "DashboardCardTintBrushNeutral",
+ "DashboardCardTintBrushAttention",
+ "DashboardCardTintBrushSuccess",
+ "DashboardCardTintBrushCaution",
+ "DashboardCardTintBrushCritical",
+ ];
+
+ public object? Convert(object value, Type targetType, object parameter, string language)
+ {
+ var variant = value as int?;
+ if (value is int i)
+ {
+ variant = i;
+ }
+
+ if (variant is null)
+ {
+ return null;
+ }
+
+ var normalized = variant.Value;
+ if (normalized < 0)
+ {
+ normalized = -normalized;
+ }
+
+ var index = normalized % _paletteKeys.Length;
+ var key = _paletteKeys[index];
+
+ if (Application.Current?.Resources is { } resources && resources.TryGetValue(key, out var resource))
+ {
+ return resource as Brush;
+ }
+
+ return null;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
diff --git a/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs
new file mode 100644
index 0000000..1cac917
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/CollectionNullOrEmptyToVisibilityConverter.cs
@@ -0,0 +1,43 @@
+using System;
+using System.Collections;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace JitHubV3.Presentation;
+
+public sealed class CollectionNullOrEmptyToVisibilityConverter : IValueConverter
+{
+ public bool Invert { get; set; }
+
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ var hasItems = false;
+
+ if (value is ICollection collection)
+ {
+ hasItems = collection.Count > 0;
+ }
+ else if (value is IEnumerable enumerable)
+ {
+ var enumerator = enumerable.GetEnumerator();
+ try
+ {
+ hasItems = enumerator.MoveNext();
+ }
+ finally
+ {
+ (enumerator as IDisposable)?.Dispose();
+ }
+ }
+
+ if (Invert)
+ {
+ hasItems = !hasItems;
+ }
+
+ return hasItems ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ => throw new NotSupportedException();
+}
diff --git a/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs b/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs
new file mode 100644
index 0000000..2caf367
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/DashboardElevation.cs
@@ -0,0 +1,33 @@
+using System.Numerics;
+using Microsoft.UI.Xaml;
+
+namespace JitHubV3.Presentation.Infrastructure;
+
+public static class DashboardElevation
+{
+ public static double GetZ(DependencyObject obj)
+ => (double)obj.GetValue(ZProperty);
+
+ public static void SetZ(DependencyObject obj, double value)
+ => obj.SetValue(ZProperty, value);
+
+ public static readonly DependencyProperty ZProperty =
+ DependencyProperty.RegisterAttached(
+ "Z",
+ typeof(double),
+ typeof(DashboardElevation),
+ new PropertyMetadata(0d, OnZChanged));
+
+ private static void OnZChanged(DependencyObject d, DependencyPropertyChangedEventArgs e)
+ {
+ if (d is not UIElement element)
+ {
+ return;
+ }
+
+ var z = e.NewValue is double value && double.IsFinite(value) ? value : 0d;
+
+ // Use Translation.Z so ThemeShadow depth is consistent cross-platform.
+ element.Translation = new Vector3(0, 0, (float)z);
+ }
+}
diff --git a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs
index 8f89cd6..408d474 100644
--- a/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs
+++ b/JitHubV3/Presentation/Infrastructure/StatusBarViewModel.cs
@@ -12,7 +12,18 @@ public sealed partial class StatusBarViewModel : ObservableObject
public void AttachToCurrentThread()
{
- _uiContext ??= SynchronizationContext.Current;
+ var current = SynchronizationContext.Current;
+ if (current is null)
+ {
+ return;
+ }
+
+ // Always allow the UI layer (Shell) to re-attach on the real UI thread.
+ // This prevents a first call from a background thread from “poisoning” the context.
+ if (_uiContext is null || !ReferenceEquals(_uiContext, current))
+ {
+ _uiContext = current;
+ }
}
public string? Message
diff --git a/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs b/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs
new file mode 100644
index 0000000..41ba02b
--- /dev/null
+++ b/JitHubV3/Presentation/Infrastructure/StringNullOrWhiteSpaceToVisibilityConverter.cs
@@ -0,0 +1,26 @@
+using System;
+using Microsoft.UI.Xaml;
+using Microsoft.UI.Xaml.Data;
+
+namespace JitHubV3.Presentation;
+
+public sealed class StringNullOrWhiteSpaceToVisibilityConverter : IValueConverter
+{
+ public bool Invert { get; set; }
+
+ public object Convert(object value, Type targetType, object parameter, string language)
+ {
+ var hasValue = value is string s && !string.IsNullOrWhiteSpace(s);
+ if (Invert)
+ {
+ hasValue = !hasValue;
+ }
+
+ return hasValue ? Visibility.Visible : Visibility.Collapsed;
+ }
+
+ public object ConvertBack(object value, Type targetType, object parameter, string language)
+ {
+ throw new NotSupportedException();
+ }
+}
diff --git a/JitHubV3/Presentation/LoginPage.xaml b/JitHubV3/Presentation/LoginPage.xaml
index b6026d7..ea3452f 100644
--- a/JitHubV3/Presentation/LoginPage.xaml
+++ b/JitHubV3/Presentation/LoginPage.xaml
@@ -27,6 +27,16 @@
Command="{Binding Login}"
HorizontalAlignment="Stretch" />
+
+
+
+