@@ -39,26 +39,70 @@ import com.thelightphone.sdk.ui.gridUnitsAsDp
3939 */
4040private 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). */
5991private 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 *
0 commit comments