diff --git a/src/PostIt.Tests/SignaturePadControlTests.cs b/src/PostIt.Tests/SignaturePadControlTests.cs
new file mode 100644
index 00000000..691ae547
--- /dev/null
+++ b/src/PostIt.Tests/SignaturePadControlTests.cs
@@ -0,0 +1,188 @@
+using System;
+using System.Linq;
+using PostIt.Controls;
+using PostIt.Models;
+using Xunit;
+
+namespace PostIt.Tests;
+
+///
+/// Targeted tests for and
+/// .
+///
+/// The control exposes internal test hooks so we can drive
+/// the buffer without standing up a headless XAML tree just to
+/// deliver synthetic pointer events. The headless surface is used
+/// only to assert that the control's pointer handlers are wired
+/// when a template is applied; see
+/// .
+///
+public class SignaturePadControlTests
+{
+ // --- SignaturePadData (pure) ---------------------------------------
+
+ [Fact]
+ public void Data_empty_array_is_empty()
+ {
+ var d = new SignaturePadData(Array.Empty());
+ Assert.True(d.IsEmpty);
+ Assert.Equal(0, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_single_dot_is_one_stroke_with_k_equals_one()
+ {
+ var d = new SignaturePadData(new[] { 1, 5_000, 5_000 });
+ Assert.False(d.IsEmpty);
+ Assert.Equal(1, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_two_strokes_are_independent()
+ {
+ var d = new SignaturePadData(new[]
+ {
+ 2, 100, 100, 200, 200,
+ 1, 9_000, 9_000,
+ });
+ Assert.Equal(2, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_malformed_payload_does_not_throw_on_read()
+ {
+ // k=0 at the head would underflow the walker. The reader
+ // short-circuits instead of throwing.
+ var d = new SignaturePadData(new[] { 0, 1, 2, 3 });
+ Assert.Equal(0, d.StrokeCount);
+ }
+
+ [Fact]
+ public void Data_constructor_rejects_null()
+ {
+ Assert.Throws(() => new SignaturePadData(null!));
+ }
+
+ // --- SignaturePadControl (buffer / events) -------------------------
+
+ [Fact]
+ public void New_control_has_empty_buffer()
+ {
+ var pad = new SignaturePadControl();
+ Assert.Empty(pad.Strokes);
+ Assert.True(pad.Snapshot().IsEmpty);
+ }
+
+ [Fact]
+ public void Snapshot_returns_a_distinct_array_each_call()
+ {
+ var pad = new SignaturePadControl();
+ pad.AppendPointForTest(1_000, 2_000);
+ pad.AppendPointForTest(3_000, 4_000);
+ pad.SealStrokeForTest();
+
+ var first = pad.Snapshot();
+ var second = pad.Snapshot();
+
+ // Distinct array instances — the consumer of the first
+ // snapshot can hold onto it after the control mutates.
+ Assert.NotSame(first.Strokes, second.Strokes);
+ // Same logical content (no mutation in between).
+ Assert.Equal(first.Strokes, second.Strokes);
+
+ pad.AppendPointForTest(5_000, 6_000);
+ pad.SealStrokeForTest();
+
+ var third = pad.Snapshot();
+ Assert.NotEqual(first.Strokes, third.Strokes);
+ }
+
+ [Fact]
+ public void Clear_empties_buffer_and_raises_redraw()
+ {
+ var pad = new SignaturePadControl();
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+ Assert.NotEmpty(pad.Strokes);
+
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.Clear();
+
+ Assert.Empty(pad.Strokes);
+ Assert.True(pad.Snapshot().IsEmpty);
+ Assert.Equal(1, redraws);
+ }
+
+ [Fact]
+ public void SealStrokeForTest_raises_redraw()
+ {
+ var pad = new SignaturePadControl();
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.AppendPointForTest(1, 1);
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.Equal(1, redraws);
+ }
+
+ [Fact]
+ public void SealStrokeForTest_with_no_pending_points_is_a_no_op()
+ {
+ var pad = new SignaturePadControl();
+ int redraws = 0;
+ pad.RedrawRequested += (_, _) => redraws++;
+ pad.SealStrokeForTest();
+ Assert.Equal(0, redraws);
+ }
+
+ [Fact]
+ public void Two_sealed_strokes_produce_two_length_prefixes()
+ {
+ var pad = new SignaturePadControl();
+ // Stroke 0: one point.
+ pad.AppendPointForTest(1_000, 1_000);
+ pad.SealStrokeForTest();
+ // Stroke 1: two points.
+ pad.AppendPointForTest(2_000, 2_000);
+ pad.AppendPointForTest(3_000, 3_000);
+ pad.SealStrokeForTest();
+
+ var s = pad.Strokes;
+ // Layout: [k0, x0, y0, k1, x1, y1, x2, y2]
+ Assert.Equal(1, s[0]);
+ Assert.Equal(1_000, s[1]);
+ Assert.Equal(1_000, s[2]);
+ Assert.Equal(2, s[3]);
+ Assert.Equal(2_000, s[4]);
+ Assert.Equal(2_000, s[5]);
+ Assert.Equal(3_000, s[6]);
+ Assert.Equal(3_000, s[7]);
+ }
+
+ [Fact]
+ public void StrokeCompleted_fires_on_seal()
+ {
+ var pad = new SignaturePadControl();
+ int events = 0;
+ pad.StrokeCompleted += (_, _) => events++;
+ pad.AppendPointForTest(1, 1);
+ pad.SealStrokeForTest();
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.Equal(2, events);
+ }
+
+ [Fact]
+ public void StrokeCompleted_carries_a_snapshot_with_k_count()
+ {
+ var pad = new SignaturePadControl();
+ SignaturePadData? captured = null;
+ pad.StrokeCompleted += (_, d) => captured = d;
+ pad.AppendPointForTest(1, 1);
+ pad.AppendPointForTest(2, 2);
+ pad.SealStrokeForTest();
+ Assert.NotNull(captured);
+ Assert.Equal(1, captured!.StrokeCount);
+ }
+}
diff --git a/src/PostIt/PostIt/Controls/SignaturePadControl.cs b/src/PostIt/PostIt/Controls/SignaturePadControl.cs
new file mode 100644
index 00000000..87949d30
--- /dev/null
+++ b/src/PostIt/PostIt/Controls/SignaturePadControl.cs
@@ -0,0 +1,204 @@
+using System;
+using System.Collections.Generic;
+using Avalonia;
+using Avalonia.Controls.Primitives;
+using Avalonia.Input;
+using PostIt.Models;
+
+namespace PostIt.Controls;
+
+///
+/// Pointer-driven capture surface that records a signature as a list
+/// of strokes, each stroke being a length-prefixed sequence of (x, y)
+/// coordinates normalised to [0, CoordinateMax].
+///
+/// The control is render-agnostic: it does not draw anything. The
+/// host view templates a (typically a
+/// Border) as PART_CaptureArea for pointer capture,
+/// and binds a separate visual layer (e.g. a Canvas) to
+/// for redraw. Keeping the control headless of
+/// rendering makes it usable from a headless test where no
+/// composition happens.
+///
+/// Wire format (see ):
+/// int[] = [k0, x0, y0, ..., k1, x0, y0, ...]
+/// with x, y ∈ [0, 10_000].
+///
+/// Threading: pointer events are dispatched on the UI thread, which
+/// is the only thread that ever mutates . The
+/// buffer is safe to read from any thread as long as no read
+/// straddles a pointer event — for cross-thread transfer use
+/// , which copies.
+///
+public class SignaturePadControl : TemplatedControl
+{
+ ///
+ /// Styled property pointing at the
+ /// that receives pointer events. Set it in the control's
+ /// template (PART_CaptureArea).
+ ///
+ public static readonly StyledProperty CaptureAreaProperty =
+ AvaloniaProperty.Register(nameof(CaptureArea));
+
+ public InputElement? CaptureArea
+ {
+ get => GetValue(CaptureAreaProperty);
+ set => SetValue(CaptureAreaProperty, value);
+ }
+
+ ///
+ /// Captured strokes in wire form. Exposed as a read-only view
+ /// over the internal buffer. The buffer only mutates on the UI
+ /// thread, between pointer events.
+ ///
+ public IReadOnlyList Strokes => _strokes;
+
+ ///
+ /// Raised when the user finishes a stroke (pointer release).
+ /// The argument is a snapshot of the buffer at release time.
+ ///
+ public event EventHandler? StrokeCompleted;
+
+ ///
+ /// Raised when the buffer changes: at the end of every stroke
+ /// and on . Mid-stroke points do not raise
+ /// this event (pointer-move is too dense); bind a separate
+ /// visual layer if you need a live preview.
+ ///
+ public event EventHandler? RedrawRequested;
+
+ private readonly List _strokes = new(capacity: 256);
+ private int _pendingPoints; // number of (x, y) pairs awaiting a length prefix
+ private bool _capturing;
+
+ protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
+ {
+ base.OnApplyTemplate(e);
+
+ if (CaptureArea is { } previous)
+ {
+ previous.PointerPressed -= OnCapturePressed;
+ previous.PointerMoved -= OnCaptureMoved;
+ previous.PointerReleased -= OnCaptureReleased;
+ }
+
+ if (CaptureArea is { } area)
+ {
+ area.PointerPressed += OnCapturePressed;
+ area.PointerMoved += OnCaptureMoved;
+ area.PointerReleased += OnCaptureReleased;
+ }
+ }
+
+ private void OnCapturePressed(object? sender, PointerPressedEventArgs e)
+ {
+ if (!e.GetCurrentPoint(CaptureArea).Properties.IsLeftButtonPressed) return;
+ e.Pointer.Capture(CaptureArea);
+ _capturing = true;
+ _pendingPoints = 0;
+ AppendPoint(e.GetPosition(CaptureArea));
+ }
+
+ private void OnCaptureMoved(object? sender, PointerEventArgs e)
+ {
+ if (!_capturing) return;
+ AppendPoint(e.GetPosition(CaptureArea));
+ }
+
+ private void OnCaptureReleased(object? sender, PointerReleasedEventArgs e)
+ {
+ if (!_capturing) return;
+ AppendPoint(e.GetPosition(CaptureArea));
+ _capturing = false;
+
+ if (_pendingPoints == 0)
+ {
+ // Press + immediate release without movement yields no
+ // point at all (the press fired AppendPoint, so this
+ // branch is unreachable — kept for clarity if a future
+ // change skips the press append).
+ return;
+ }
+
+ // Seal the current stroke by inserting its length at the
+ // head of its slice. The slice is the trailing
+ // 2 * _pendingPoints entries.
+ int sliceStart = _strokes.Count - 2 * _pendingPoints;
+ _strokes.Insert(sliceStart, _pendingPoints);
+ _pendingPoints = 0;
+
+ StrokeCompleted?.Invoke(this, Snapshot());
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ private void AppendPoint(Point p)
+ {
+ var (nx, ny) = Normalise(p);
+ _strokes.Add(nx);
+ _strokes.Add(ny);
+ _pendingPoints++;
+ }
+
+ private (int x, int y) Normalise(Point p)
+ {
+ if (CaptureArea is null) return (0, 0);
+ var bounds = CaptureArea.Bounds;
+ double w = bounds.Width;
+ double h = bounds.Height;
+ if (w <= 0 || h <= 0) return (0, 0);
+ int nx = (int)Math.Round(Math.Clamp(p.X / w, 0.0, 1.0) * SignaturePadData.CoordinateMax);
+ int ny = (int)Math.Round(Math.Clamp(p.Y / h, 0.0, 1.0) * SignaturePadData.CoordinateMax);
+ return (nx, ny);
+ }
+
+ ///
+ /// Forget every captured stroke. Raises .
+ ///
+ public void Clear()
+ {
+ _strokes.Clear();
+ _pendingPoints = 0;
+ _capturing = false;
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+
+ ///
+ /// Defensive copy of the current buffer wrapped in a
+ /// . Cheap; call only when the
+ /// view needs to ship the data off (e.g. to a backend).
+ ///
+ public SignaturePadData Snapshot() => new(_strokes.ToArray());
+
+ // --- Test-only surface (visible to PostIt.Tests) -------------------
+
+ ///
+ /// Test hook: append a single normalised point without going
+ /// through the pointer pipeline. Does not raise
+ /// .
+ ///
+ internal void AppendPointForTest(int x, int y)
+ {
+ _strokes.Add(x);
+ _strokes.Add(y);
+ _pendingPoints++;
+ }
+
+ ///
+ /// Test hook: seal the currently-pending stroke with a length
+ /// prefix. Mirrors what does at
+ /// pointer release time, including the
+ /// and
+ /// events, so test scenarios observe the same notification
+ /// contract as production. Idempotent: a second call without
+ /// intermediate appends is a no-op.
+ ///
+ internal void SealStrokeForTest()
+ {
+ if (_pendingPoints == 0) return;
+ int sliceStart = _strokes.Count - 2 * _pendingPoints;
+ _strokes.Insert(sliceStart, _pendingPoints);
+ _pendingPoints = 0;
+ StrokeCompleted?.Invoke(this, Snapshot());
+ RedrawRequested?.Invoke(this, EventArgs.Empty);
+ }
+}
diff --git a/src/PostIt/PostIt/Models/SignaturePadData.cs b/src/PostIt/PostIt/Models/SignaturePadData.cs
new file mode 100644
index 00000000..9a2c0e48
--- /dev/null
+++ b/src/PostIt/PostIt/Models/SignaturePadData.cs
@@ -0,0 +1,81 @@
+namespace PostIt.Models;
+
+///
+/// Serialized form of a signature captured by
+/// .
+///
+/// Wire format (length-prefixed, normalised):
+///
+/// int[] = [k0, x00, y00, x01, y01, ..., x0_{k0-1}, y0_{k0-1},
+/// k1, x10, y10, x11, y11, ..., x1_{k1-1}, y1_{k1-1},
+/// ...]
+///
+///
+/// - k_i — number of (x, y) pairs in stroke i.
+/// - x, y — coordinates normalised to [0, CoordinateMax]
+/// (inclusive) on the control's client area.
+/// is 10_000 by default — a 4-decimal fixed-point fraction of
+/// the surface, which is enough to discriminate 0.01% of the diagonal
+/// on any reasonable screen and stays well inside int.
+/// - Total array length is even: each stroke contributes
+/// 1 + 2 * k_i integers, and 1 + 2k is always odd.
+/// Sum of 1 + 2k_i over strokes is therefore odd * N, which
+/// is odd when N is odd and even when N is even — so the overall
+/// "size pair" property is not enforced, only the per-stroke shape
+/// is. If the consumer needs a strictly even total, pad the last
+/// stroke with a duplicate terminal point (or use
+/// to drop the array entirely).
+///
+///
+/// Empty signature (no strokes) is represented by an empty array
+/// (length 0). A single dot — pen down + pen up at the same point —
+/// is a single stroke with k = 1: [1, x, y].
+///
+public sealed class SignaturePadData
+{
+ ///
+ /// Upper bound of normalised coordinates. 10_000 means a
+ /// surface unit is represented as 0.0001 of the whole.
+ ///
+ public const int CoordinateMax = 10_000;
+
+ ///
+ /// Raw payload. See for the layout.
+ /// Never null; an empty array means "no strokes".
+ ///
+ public int[] Strokes { get; }
+
+ public SignaturePadData(int[] strokes)
+ {
+ if (strokes is null) throw new System.ArgumentNullException(nameof(strokes));
+ Strokes = strokes;
+ }
+
+ /// True if no stroke has been captured.
+ public bool IsEmpty => Strokes.Length == 0;
+
+ ///
+ /// Number of distinct strokes (pen-down / pen-up cycles).
+ /// Returns 0 when is true.
+ ///
+ public int StrokeCount
+ {
+ get
+ {
+ if (Strokes.Length == 0) return 0;
+ int n = 0;
+ int i = 0;
+ while (i < Strokes.Length)
+ {
+ int k = Strokes[i];
+ // Defensive: a malformed entry is treated as 0 so we
+ // never throw on read. The capture side never produces
+ // these, this is only for robustness on the wire.
+ if (k <= 0) return n;
+ i += 1 + 2 * k;
+ n++;
+ }
+ return n;
+ }
+ }
+}