diff --git a/IMPLEMENTATION.md b/IMPLEMENTATION.md
index bdd36be..ba55fbc 100644
--- a/IMPLEMENTATION.md
+++ b/IMPLEMENTATION.md
@@ -55,7 +55,7 @@ Each type lives in `src/FrameProcessor/Domain/`. Tests in `tests/FrameProcessor.
- `record(int Width, int Height)`.
- `Resolution ForOrientation(Orientation o)` — swaps when portrait.
-### [ ] 1.5 `PaletteEntry`
+### [x] 1.5 `PaletteEntry`
- `record(string Name, Color DisplayColor, Color DeviceColor)` using `SixLabors.ImageSharp.Color`.
- JSON converter parses hex strings into `Color` at deserialize time (don't store hex strings on the type).
diff --git a/src/FrameProcessor/Domain/PaletteEntry.cs b/src/FrameProcessor/Domain/PaletteEntry.cs
new file mode 100644
index 0000000..3091118
--- /dev/null
+++ b/src/FrameProcessor/Domain/PaletteEntry.cs
@@ -0,0 +1,118 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using SixLabors.ImageSharp;
+
+namespace FrameProcessor.Domain;
+
+///
+/// One palette entry mapping a display color (what the eye sees on the panel) to
+/// a device color (the RGB value firmware expects in the input PNG).
+/// Hex strings from configuration are parsed once into values;
+/// the entry does not retain the original strings.
+///
+[JsonConverter(typeof(PaletteEntryJsonConverter))]
+public sealed record PaletteEntry(string Name, Color DisplayColor, Color DeviceColor);
+
+internal sealed class PaletteEntryJsonConverter : JsonConverter
+{
+ public override PaletteEntry Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ if (reader.TokenType != JsonTokenType.StartObject)
+ {
+ throw new JsonException("Palette entry must be a JSON object.");
+ }
+
+ string? name = null;
+ Color? displayColor = null;
+ Color? deviceColor = null;
+
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonTokenType.EndObject)
+ {
+ if (name is null)
+ {
+ throw new JsonException("Palette entry is missing required field 'name'.");
+ }
+
+ if (displayColor is null)
+ {
+ throw new JsonException("Palette entry is missing required field 'color'.");
+ }
+
+ if (deviceColor is null)
+ {
+ throw new JsonException("Palette entry is missing required field 'deviceColor'.");
+ }
+
+ return new PaletteEntry(name, displayColor.Value, deviceColor.Value);
+ }
+
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException();
+ }
+
+ var propertyName = reader.GetString();
+ if (!reader.Read())
+ {
+ throw new JsonException("Unexpected end of palette entry.");
+ }
+
+ switch (propertyName)
+ {
+ case "name":
+ var n = reader.GetString();
+ if (string.IsNullOrWhiteSpace(n))
+ {
+ throw new JsonException("Palette entry 'name' must be a non-empty string.");
+ }
+
+ name = n;
+ break;
+ case "color":
+ displayColor = ParseHex(reader.GetString(), "color");
+ break;
+ case "deviceColor":
+ deviceColor = ParseHex(reader.GetString(), "deviceColor");
+ break;
+ default:
+ reader.Skip();
+ break;
+ }
+ }
+
+ throw new JsonException("Unexpected end of palette entry.");
+ }
+
+ public override void Write(Utf8JsonWriter writer, PaletteEntry value, JsonSerializerOptions options)
+ {
+ writer.WriteStartObject();
+ writer.WriteString("name", value.Name);
+ writer.WriteString("color", ToRgbHex(value.DisplayColor));
+ writer.WriteString("deviceColor", ToRgbHex(value.DeviceColor));
+ writer.WriteEndObject();
+ }
+
+ private static Color ParseHex(string? s, string field)
+ {
+ if (string.IsNullOrWhiteSpace(s))
+ {
+ throw new JsonException($"Palette entry '{field}' must be a non-empty hex string.");
+ }
+
+ if (!Color.TryParseHex(s, out var color))
+ {
+ throw new JsonException($"'{s}' is not a valid hex color for '{field}'. Expected '#RRGGBB'.");
+ }
+
+ return color;
+ }
+
+ private static string ToRgbHex(Color color)
+ {
+ // ImageSharp's Color.ToHex() returns 8 characters (RRGGBBAA); SPEC writes 6-digit '#RRGGBB'.
+ var hex = color.ToHex();
+ return "#" + hex[..6];
+ }
+}
diff --git a/tests/FrameProcessor.Tests/PaletteEntryTests.cs b/tests/FrameProcessor.Tests/PaletteEntryTests.cs
new file mode 100644
index 0000000..7a32b31
--- /dev/null
+++ b/tests/FrameProcessor.Tests/PaletteEntryTests.cs
@@ -0,0 +1,111 @@
+using System.Text.Json;
+using FrameProcessor.Domain;
+using SixLabors.ImageSharp;
+using SixLabors.ImageSharp.PixelFormats;
+
+namespace FrameProcessor.Tests;
+
+public class PaletteEntryTests
+{
+ [Fact]
+ public void JsonDeserialize_ParsesHexIntoColor()
+ {
+ const string json = """{ "name": "black", "color": "#1F2226", "deviceColor": "#000000" }""";
+
+ var entry = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(entry);
+ Assert.Equal("black", entry!.Name);
+ Assert.Equal(new Rgba32(0x1F, 0x22, 0x26, 0xFF), entry.DisplayColor.ToPixel());
+ Assert.Equal(new Rgba32(0x00, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel());
+ }
+
+ [Fact]
+ public void JsonDeserialize_HexWithoutHashIsAccepted()
+ {
+ const string json = """{ "name": "red", "color": "62201E", "deviceColor": "FF0000" }""";
+
+ var entry = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(entry);
+ Assert.Equal(new Rgba32(0x62, 0x20, 0x1E, 0xFF), entry!.DisplayColor.ToPixel());
+ Assert.Equal(new Rgba32(0xFF, 0x00, 0x00, 0xFF), entry.DeviceColor.ToPixel());
+ }
+
+ [Fact]
+ public void JsonSerialize_EmitsHashPrefixedSixDigitHex()
+ {
+ var entry = new PaletteEntry(
+ "yellow",
+ Color.FromPixel(new Rgba32(0xC1, 0xBB, 0x1E, 0xFF)),
+ Color.FromPixel(new Rgba32(0xFF, 0xFF, 0x00, 0xFF)));
+
+ var json = JsonSerializer.Serialize(entry);
+
+ Assert.Contains("\"name\":\"yellow\"", json);
+ Assert.Contains("\"color\":\"#C1BB1E\"", json);
+ Assert.Contains("\"deviceColor\":\"#FFFF00\"", json);
+ }
+
+ [Fact]
+ public void JsonRoundTrip_PreservesValues()
+ {
+ var original = new PaletteEntry(
+ "blue",
+ Color.FromPixel(new Rgba32(0x23, 0x3F, 0x8E, 0xFF)),
+ Color.FromPixel(new Rgba32(0x00, 0x00, 0xFF, 0xFF)));
+
+ var json = JsonSerializer.Serialize(original);
+ var roundTripped = JsonSerializer.Deserialize(json);
+
+ Assert.Equal(original, roundTripped);
+ }
+
+ [Theory]
+ [InlineData("""{ "color": "#000000", "deviceColor": "#000000" }""")]
+ [InlineData("""{ "name": "x", "deviceColor": "#000000" }""")]
+ [InlineData("""{ "name": "x", "color": "#000000" }""")]
+ public void JsonDeserialize_MissingField_Throws(string json)
+ {
+ Assert.Throws(() => JsonSerializer.Deserialize(json));
+ }
+
+ [Theory]
+ [InlineData("""{ "name": "x", "color": "not-a-color", "deviceColor": "#000000" }""")]
+ [InlineData("""{ "name": "x", "color": "#000000", "deviceColor": "zzz" }""")]
+ [InlineData("""{ "name": "x", "color": "", "deviceColor": "#000000" }""")]
+ public void JsonDeserialize_InvalidHex_Throws(string json)
+ {
+ Assert.Throws(() => JsonSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void JsonDeserialize_EmptyName_Throws()
+ {
+ const string json = """{ "name": "", "color": "#000000", "deviceColor": "#000000" }""";
+
+ Assert.Throws(() => JsonSerializer.Deserialize(json));
+ }
+
+ [Fact]
+ public void JsonDeserialize_UnknownFields_AreIgnored()
+ {
+ const string json = """{ "name": "black", "color": "#000000", "deviceColor": "#000000", "extra": 42 }""";
+
+ var entry = JsonSerializer.Deserialize(json);
+
+ Assert.NotNull(entry);
+ Assert.Equal("black", entry!.Name);
+ }
+
+ [Fact]
+ public void Equality_BasedOnNameAndColors()
+ {
+ var a = new PaletteEntry("black", Color.Black, Color.Black);
+ var b = new PaletteEntry("black", Color.Black, Color.Black);
+ var c = new PaletteEntry("white", Color.Black, Color.Black);
+
+ Assert.Equal(a, b);
+ Assert.NotEqual(a, c);
+ }
+}