Skip to content

Commit a552dc4

Browse files
Davidclaude
authored andcommitted
recall: AnkiDroid-color occlusion masks by default (mono palette kept as fallback)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1ad8f32 commit a552dc4

3 files changed

Lines changed: 216 additions & 55 deletions

File tree

tool/src/main/kotlin/com/dvdutch/recall/api/RenderNode.kt

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,10 +96,10 @@ enum class ShapeState {
9696

9797
/**
9898
* Solid opaque mask for the TESTED shape on the FRONT — the region being asked. Drawn
99-
* with a distinguishing high-contrast border so that, among many hide-all masks, the
100-
* studier can tell exactly WHICH hidden region they must recall. Still fully opaque:
101-
* the answer stays covered. (AnkiDroid renders this pink vs. tan; we do it in
102-
* monochrome — see [maskStyle].)
99+
* in a distinguishing colour so that, among many hide-all masks, the studier can tell
100+
* exactly WHICH hidden region they must recall. Still fully opaque: the answer stays
101+
* covered. (Matches AnkiDroid: salmon `#FF8E8E` tested vs. tan `#FFEBA2` inactive by
102+
* default — see [maskStyle].)
103103
*/
104104
MASKED_TESTED,
105105

tool/src/main/kotlin/com/dvdutch/recall/ui/OcclusionImage.kt

Lines changed: 110 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -39,26 +39,70 @@ import com.thelightphone.sdk.ui.gridUnitsAsDp
3939
*/
4040
private const val OCCLUSION_TAG = "OcclusionImage"
4141

42+
// --- Mono palette (grayscale-only fallback) -----------------------------------
43+
4244
/**
43-
* Solid mask fill for masked shapes ([ShapeState.MASKED] / [ShapeState.MASKED_TESTED]).
44-
* On the black theme a mid/light grey block reads clearly as "something is hidden here"
45-
* while staying monochrome. Fully opaque so the answer region underneath is genuinely
46-
* covered — this is identical for tested and inactive masks; the tested one is set apart
47-
* by its border, never by a lighter/translucent fill.
45+
* Solid mask fill for masked shapes under [MaskPalette.Mono]. A mid/light grey block
46+
* reads clearly as "something is hidden here" while staying monochrome. Fully opaque so
47+
* the answer region underneath is genuinely covered — identical for tested and inactive
48+
* masks; the tested one is set apart by its two-tone ring, never by a lighter fill.
4849
*/
49-
private val MASK_FILL = Color(0xFFBBBBBB)
50+
private val MONO_MASK_FILL = Color(0xFFBBBBBB)
5051

5152
/**
52-
* Outline colour for [ShapeState.REVEALED_OUTLINE] shapes (the tested answer on the
53-
* back). White reads as a highlight ring around the now-visible region on the black
54-
* theme.
53+
* Outline colour for [ShapeState.REVEALED_OUTLINE] under [MaskPalette.Mono]. White reads
54+
* as a highlight ring around the now-visible region on the black theme.
5555
*/
56-
private val OUTLINE_COLOR = Color.White
56+
private val MONO_OUTLINE_COLOR = Color.White
57+
58+
// --- Anki palette (default — matches AnkiDroid / Anki desktop) -----------------
59+
//
60+
// Our base images render in full colour, so the masks match Anki's real IO palette
61+
// rather than a monochrome stand-in. Values are the authoritative Anki defaults
62+
// (Anki desktop / AnkiDroid image-occlusion CSS custom properties), corroborated by
63+
// pixel-sampling the AnkiDroid comparison screenshots:
64+
// --inactive-shape-color: #ffeba2 (tan/cream fill)
65+
// --active-shape-color: #ff8e8e (salmon/red fill — the tested mask on the front)
66+
// --highlight-shape-border: 1px #ff8e8e (red outline of the revealed region on the back)
67+
// AnkiDroid draws the SAME thin dark 1px border on both inactive and active masks; the
68+
// tested/inactive distinction is carried purely by the FILL colour (tan vs salmon), so
69+
// no two-tone ring is used here. The two fills also keep a wide relative-luminance gap
70+
// (see [relativeLuminance]) so tested vs inactive stays distinguishable if the device
71+
// grayscales.
72+
73+
/** Anki inactive-mask fill (`--inactive-shape-color: #ffeba2`). */
74+
private val ANKI_INACTIVE_FILL = Color(0xFFFFEBA2)
75+
76+
/** Anki active/tested-mask fill (`--active-shape-color: #ff8e8e`). */
77+
private val ANKI_TESTED_FILL = Color(0xFFFF8E8E)
78+
79+
/** Anki revealed-region outline colour (`--highlight-shape-border: #ff8e8e`). */
80+
private val ANKI_OUTLINE_COLOR = Color(0xFFFF8E8E)
81+
82+
/** Anki shape border (`--inactive/active-shape-border: 1px #212121`), single-tone. */
83+
private val ANKI_MASK_BORDER = MaskBorder(
84+
outerColor = Color(0xFF212121),
85+
outerWidthPx = 2f,
86+
innerColor = Color(0xFF212121),
87+
innerWidthPx = 2f,
88+
)
5789

5890
/** Outline stroke width, in natural-image pixels (scaled with the image). */
5991
private const val OUTLINE_STROKE_PX = 2f
6092

61-
// --- Mask styling (the single point a future COLOR mode would swap) -----------
93+
// --- Mask styling (the single point the palette is swapped) -------------------
94+
95+
/**
96+
* Which colour palette the occlusion masks are drawn in. [Anki] is the default — it
97+
* matches AnkiDroid / Anki desktop exactly (tan inactive, salmon tested, red revealed
98+
* outline) because our images render in full colour. [Mono] is a grayscale-only fallback
99+
* (mid-grey block + two-tone ring + white outline) retained for a future device-colour
100+
* toggle; there is no settings UI yet.
101+
*/
102+
enum class MaskPalette { Anki, Mono }
103+
104+
/** The default palette used everywhere until a device-colour toggle is added. */
105+
private val DEFAULT_PALETTE = MaskPalette.Anki
62106

63107
/**
64108
* A two-tone ring drawn around the tested mask: a thick [outerColor] stroke with a
@@ -85,33 +129,67 @@ data class MaskStyle(
85129
val border: MaskBorder? = null,
86130
)
87131

88-
/** Border for the tested front mask: heavy black outer ring + white inner ring. */
89-
private val TESTED_BORDER = MaskBorder(
132+
/** Mono tested-mask ring: heavy black outer ring + white inner ring (two-tone). */
133+
private val MONO_TESTED_BORDER = MaskBorder(
90134
outerColor = Color.Black,
91135
outerWidthPx = 6f,
92136
innerColor = Color.White,
93137
innerWidthPx = 2f,
94138
)
95139

96140
/**
97-
* The SINGLE pure mapping from a resolved [ShapeState] to how it must be drawn. Every
98-
* mask/outline styling decision lives here, so a future COLOR mode (pink tested / tan
99-
* inactive, behind a settings toggle) is a one-function swap. Returns `null` for
100-
* [ShapeState.CONTEXT] (draw nothing).
101-
*
102-
* - [ShapeState.MASKED] → plain solid grey block (inactive, hide-all).
103-
* - [ShapeState.MASKED_TESTED] → same solid grey block PLUS a heavy two-tone ring so the
104-
* asked region is unmistakable among many masks (monochrome default).
105-
* - [ShapeState.REVEALED_OUTLINE] → stroke-only white outline (tested answer, back).
106-
* - [ShapeState.CONTEXT] → null (shows through, drawn nothing).
141+
* WCAG relative luminance of [color] in the sRGB space (0 = black, 1 = white). Used to
142+
* prove the [MaskPalette.Anki] fills stay distinguishable by brightness alone if the
143+
* device grayscales — see the luminance-gap assertion in the mask-style tests.
107144
*/
108-
fun maskStyle(state: ShapeState): MaskStyle? = when (state) {
109-
ShapeState.CONTEXT -> null
110-
ShapeState.MASKED -> MaskStyle(filled = true, fill = MASK_FILL)
111-
ShapeState.MASKED_TESTED -> MaskStyle(filled = true, fill = MASK_FILL, border = TESTED_BORDER)
112-
ShapeState.REVEALED_OUTLINE -> MaskStyle(filled = false, fill = OUTLINE_COLOR)
145+
fun relativeLuminance(color: Color): Double {
146+
fun lin(c: Float): Double {
147+
val d = c.toDouble()
148+
return if (d <= 0.03928) d / 12.92 else Math.pow((d + 0.055) / 1.055, 2.4)
149+
}
150+
return 0.2126 * lin(color.red) + 0.7152 * lin(color.green) + 0.0722 * lin(color.blue)
113151
}
114152

153+
/**
154+
* The SINGLE pure mapping from a resolved [ShapeState] to how it must be drawn, under the
155+
* chosen [palette]. Every mask/outline styling decision lives here, so switching palettes
156+
* (once a device-colour toggle exists) is a one-argument swap. [palette] defaults to
157+
* [MaskPalette.Anki]. Returns `null` for [ShapeState.CONTEXT] (draw nothing).
158+
*
159+
* [MaskPalette.Anki] (default — our images are full colour, so match AnkiDroid exactly):
160+
* - [ShapeState.MASKED] → solid tan `#FFEBA2` block + thin dark border.
161+
* - [ShapeState.MASKED_TESTED] → solid salmon `#FF8E8E` block + the SAME thin dark border
162+
* (the tested/inactive distinction is the fill colour, matching AnkiDroid — no ring).
163+
* - [ShapeState.REVEALED_OUTLINE] → stroke-only red `#FF8E8E` outline (revealed answer).
164+
*
165+
* [MaskPalette.Mono] (grayscale-only fallback):
166+
* - [ShapeState.MASKED] → plain solid grey block.
167+
* - [ShapeState.MASKED_TESTED] → same grey block PLUS a heavy two-tone ring.
168+
* - [ShapeState.REVEALED_OUTLINE] → stroke-only white outline.
169+
*
170+
* [ShapeState.CONTEXT] → null (shows through, drawn nothing) in both palettes.
171+
*/
172+
fun maskStyle(state: ShapeState, palette: MaskPalette = DEFAULT_PALETTE): MaskStyle? =
173+
when (palette) {
174+
MaskPalette.Anki -> when (state) {
175+
ShapeState.CONTEXT -> null
176+
ShapeState.MASKED ->
177+
MaskStyle(filled = true, fill = ANKI_INACTIVE_FILL, border = ANKI_MASK_BORDER)
178+
ShapeState.MASKED_TESTED ->
179+
MaskStyle(filled = true, fill = ANKI_TESTED_FILL, border = ANKI_MASK_BORDER)
180+
ShapeState.REVEALED_OUTLINE ->
181+
MaskStyle(filled = false, fill = ANKI_OUTLINE_COLOR)
182+
}
183+
184+
MaskPalette.Mono -> when (state) {
185+
ShapeState.CONTEXT -> null
186+
ShapeState.MASKED -> MaskStyle(filled = true, fill = MONO_MASK_FILL)
187+
ShapeState.MASKED_TESTED ->
188+
MaskStyle(filled = true, fill = MONO_MASK_FILL, border = MONO_TESTED_BORDER)
189+
ShapeState.REVEALED_OUTLINE -> MaskStyle(filled = false, fill = MONO_OUTLINE_COLOR)
190+
}
191+
}
192+
115193
/**
116194
* The pure state mapping behind the "Toggle Masks" peek (AnkiDroid parity). When
117195
* [masksHidden] is true, EVERY resolved [ShapeState] collapses to [ShapeState.CONTEXT] —
@@ -130,8 +208,8 @@ fun effectiveShapeState(state: ShapeState, masksHidden: Boolean): ShapeState =
130208
* The three states an occlusion base image can be in.
131209
*
132210
* This exists to kill a real UX bug: the generic [ImageNodePlaceholder] is a *solid
133-
* grey filled box*, and a masked occlusion shape ([MASK_FILL]) is *also* a solid grey
134-
* box. So a mid-load occlusion card rendered through the shared placeholder was visually
211+
* grey filled box*, and a masked occlusion shape is *also* a solid filled box. So a
212+
* mid-load occlusion card rendered through the shared placeholder was visually
135213
* indistinguishable from an occlusion whose mask never lifts — it read as broken. The
136214
* loading and failed states must therefore be *text in a bordered (unfilled) box*, never
137215
* a solid fill, so they can never be mistaken for a mask.
@@ -486,8 +564,8 @@ private fun polygonPath(points: List<Pair<Float, Float>>): Path = Path().apply {
486564

487565
/**
488566
* The non-drawn states of an occlusion base image: a *bordered, text-labelled, unfilled*
489-
* box — deliberately NOT the solid grey [ImageNodePlaceholder]. A solid grey fill is
490-
* exactly what a [MASK_FILL] mask looks like, so reusing it made a mid-load (or missing)
567+
* box — deliberately NOT the solid grey [ImageNodePlaceholder]. A solid filled box is
568+
* exactly what a mask looks like, so reusing it made a mid-load (or missing)
491569
* occlusion card indistinguishable from an occlusion whose mask never lifts. Text plus an
492570
* outline (no fill) can never be mistaken for a mask.
493571
*
Lines changed: 102 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.dvdutch.recall.ui
22

3+
import androidx.compose.ui.graphics.Color
34
import com.dvdutch.recall.api.ShapeState
45
import kotlin.test.Test
56
import kotlin.test.assertEquals
@@ -9,50 +10,132 @@ import kotlin.test.assertTrue
910

1011
/**
1112
* Unit coverage for [maskStyle], the single pure styling function that maps a resolved
12-
* [ShapeState] onto its fill/outline/border draw spec. Centralising this here is what
13-
* lets a future COLOR mode (pink tested / tan inactive, AnkiDroid-style) be added by
14-
* swapping ONE function — and it makes the load-bearing UX rule ("the tested mask must
15-
* be unmistakable among many inactive masks") unit-testable without a device.
13+
* [ShapeState] onto its fill/outline/border draw spec, across BOTH palettes.
14+
*
15+
* The DEFAULT palette is [MaskPalette.Anki] — it matches the exact colours AnkiDroid /
16+
* Anki desktop draw (inactive tan `#FFEBA2`, tested salmon `#FF8E8E`, revealed red
17+
* outline `#FF8E8E`), because our images render in full colour. The [MaskPalette.Mono]
18+
* palette is kept as a grayscale-only fallback (mid grey block + two-tone ring) for a
19+
* future device-colour toggle. Both must keep the load-bearing UX rule ("the tested mask
20+
* must be unmistakable among many inactive masks"), and the Anki fills must stay
21+
* distinguishable even if the device grayscales — asserted here via a relative-luminance
22+
* gap — so the palette is provably grayscale-safe without a device.
1623
*/
1724
class MaskStyleTest {
1825

26+
// --- Default palette (Anki) ----------------------------------------------
27+
28+
@Test
29+
fun `default palette is Anki`() {
30+
// maskStyle(state) with no palette arg must resolve identically to the Anki palette.
31+
for (state in ShapeState.entries) {
32+
assertEquals(
33+
maskStyle(state, MaskPalette.Anki),
34+
maskStyle(state),
35+
"default maskStyle($state) must equal the Anki palette",
36+
)
37+
}
38+
}
39+
40+
@Test
41+
fun `context draws nothing (both palettes)`() {
42+
assertNull(maskStyle(ShapeState.CONTEXT, MaskPalette.Anki))
43+
assertNull(maskStyle(ShapeState.CONTEXT, MaskPalette.Mono))
44+
}
45+
46+
// --- Anki palette per-state ----------------------------------------------
47+
48+
@Test
49+
fun `anki inactive masked is a solid tan block`() {
50+
val s = maskStyle(ShapeState.MASKED, MaskPalette.Anki)
51+
assertNotNull(s)
52+
assertTrue(s.filled, "inactive mask must be a solid fill")
53+
assertEquals(Color(0xFFFFEBA2), s.fill, "Anki inactive fill is #FFEBA2")
54+
}
55+
56+
@Test
57+
fun `anki tested masked is a solid salmon block (no two-tone ring)`() {
58+
val s = maskStyle(ShapeState.MASKED_TESTED, MaskPalette.Anki)
59+
assertNotNull(s)
60+
assertTrue(s.filled, "tested mask is still a solid fill (region stays hidden)")
61+
assertEquals(Color(0xFFFF8E8E), s.fill, "Anki tested fill is #FF8E8E")
62+
// AnkiDroid draws the SAME thin dark border on tested as on inactive — the
63+
// distinction is the fill colour, not a heavy ring. So no two-tone ring here.
64+
val border = s.border
65+
if (border != null) {
66+
assertEquals(border.outerColor, border.innerColor, "Anki tested border is single-tone")
67+
}
68+
}
69+
70+
@Test
71+
fun `anki tested and inactive fills DIFFER (colour carries the distinction)`() {
72+
assertTrue(
73+
maskStyle(ShapeState.MASKED, MaskPalette.Anki)!!.fill !=
74+
maskStyle(ShapeState.MASKED_TESTED, MaskPalette.Anki)!!.fill,
75+
"Anki distinguishes tested from inactive by fill colour",
76+
)
77+
}
78+
1979
@Test
20-
fun `context draws nothing`() {
21-
assertNull(maskStyle(ShapeState.CONTEXT))
80+
fun `anki revealed outline is stroke-only red`() {
81+
val s = maskStyle(ShapeState.REVEALED_OUTLINE, MaskPalette.Anki)
82+
assertNotNull(s)
83+
assertTrue(!s.filled, "revealed answer is an outline, not a fill")
84+
assertEquals(Color(0xFFFF8E8E), s.fill, "Anki revealed outline is red #FF8E8E")
2285
}
2386

87+
// --- Mono palette per-state (preserved fallback) -------------------------
88+
2489
@Test
25-
fun `inactive masked is a plain filled block with no distinguishing border`() {
26-
val s = maskStyle(ShapeState.MASKED)
90+
fun `mono inactive masked is a plain filled block with no distinguishing border`() {
91+
val s = maskStyle(ShapeState.MASKED, MaskPalette.Mono)
2792
assertNotNull(s)
2893
assertTrue(s.filled, "inactive mask must be a solid fill")
2994
assertNull(s.border, "inactive mask has no distinguishing border")
3095
}
3196

3297
@Test
33-
fun `tested masked is a filled block WITH a heavy distinguishing border`() {
34-
val s = maskStyle(ShapeState.MASKED_TESTED)
98+
fun `mono tested masked is a filled block WITH a heavy two-tone distinguishing border`() {
99+
val s = maskStyle(ShapeState.MASKED_TESTED, MaskPalette.Mono)
35100
assertNotNull(s)
36101
assertTrue(s.filled, "tested mask is still a solid fill (region stays hidden)")
37102
val border = s.border
38-
assertNotNull(border, "tested mask MUST carry a distinguishing border")
103+
assertNotNull(border, "mono tested mask MUST carry a distinguishing border")
39104
assertTrue(border.outerWidthPx > 0f, "border must be visible")
40-
// A two-tone ring (contrasting outer/inner colours) so it pops on both light and
41-
// dark image regions in monochrome.
42105
assertTrue(border.outerColor != border.innerColor, "ring must be two-tone for contrast")
43106
}
44107

45108
@Test
46-
fun `tested and inactive fills are the same (both genuinely hide the region)`() {
47-
// Distinction is carried by the border, not by making the tested fill translucent
48-
// — the answer must stay covered on the front.
49-
assertEquals(maskStyle(ShapeState.MASKED)!!.fill, maskStyle(ShapeState.MASKED_TESTED)!!.fill)
109+
fun `mono tested and inactive fills are the same (both genuinely hide the region)`() {
110+
assertEquals(
111+
maskStyle(ShapeState.MASKED, MaskPalette.Mono)!!.fill,
112+
maskStyle(ShapeState.MASKED_TESTED, MaskPalette.Mono)!!.fill,
113+
)
50114
}
51115

52116
@Test
53-
fun `revealed outline is stroke-only (not filled)`() {
54-
val s = maskStyle(ShapeState.REVEALED_OUTLINE)
117+
fun `mono revealed outline is stroke-only (not filled)`() {
118+
val s = maskStyle(ShapeState.REVEALED_OUTLINE, MaskPalette.Mono)
55119
assertNotNull(s)
56120
assertTrue(!s.filled, "revealed answer is an outline, not a fill")
57121
}
122+
123+
// --- Grayscale-safety: luminance separation ------------------------------
124+
125+
@Test
126+
fun `anki tested vs inactive fills have a healthy relative-luminance gap`() {
127+
// If the device grayscales, tested vs inactive must still be distinguishable by
128+
// brightness alone. Assert a healthy gap so the palette is provably grayscale-safe.
129+
val inactive = relativeLuminance(maskStyle(ShapeState.MASKED, MaskPalette.Anki)!!.fill)
130+
val tested = relativeLuminance(maskStyle(ShapeState.MASKED_TESTED, MaskPalette.Anki)!!.fill)
131+
val gap = kotlin.math.abs(inactive - tested)
132+
assertTrue(gap > 0.15, "Anki tested/inactive luminance gap must exceed 0.15, was $gap")
133+
}
134+
135+
@Test
136+
fun `relativeLuminance matches the sRGB formula for known colours`() {
137+
// Black = 0, white = 1 anchors the WCAG relative-luminance computation.
138+
assertEquals(0.0, relativeLuminance(Color(0xFF000000)), 1e-6)
139+
assertEquals(1.0, relativeLuminance(Color(0xFFFFFFFF)), 1e-6)
140+
}
58141
}

0 commit comments

Comments
 (0)