Skip to main content

blockworx_paint/theme/
mod.rs

1use indexmap::IndexMap;
2use serde::{Deserialize, Serialize};
3use strum::IntoEnumIterator;
4
5use blockworx_geom::WorldPx;
6
7use crate::{Base, Color, Font, Palette, Swatch};
8
9mod style;
10pub use style::Style;
11
12/// A semantic color slot. Drawing code passes a `Role` to every draw call; the
13/// renderer resolves it through the active [`Theme`] (role → [`Base`] → color),
14/// so call sites name *intent*, never a literal color. Add a variant here for
15/// each semantically-distinct color in the app.
16///
17/// `Transparent` must remain the last variant: [`N_ROLES`] is derived from it.
18#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Serialize, Deserialize, strum::EnumIter)]
19pub enum Role {
20    // Shapes (blocks and ports)
21    ShapeFill,
22    ShapeStroke,
23    ShapeTitle,
24    // Washed-out variants shown on a locked block, plus the block's type label
25    // color (unlocked and locked) and the corner lock-hint icon color.
26    LockedShapeFill,
27    LockedShapeTitle,
28    ShapeType,
29    LockedShapeType,
30    LockedHintIcon,
31
32    // Accent (per-block outline) colors: a block's optional `role` picks one of
33    // `Accent0..Accent7`; a block with no role uses `AccentDefault`.
34    AccentDefault,
35    Accent0,
36    Accent1,
37    Accent2,
38    Accent3,
39    Accent4,
40    Accent5,
41    Accent6,
42    Accent7,
43
44    // Text-box annotations
45    TextBoxFill,
46    TextBoxStroke,
47
48    // Boundary areas (outline only, drawn above blocks and routes)
49    AreaStroke,
50
51    // Routes / wires
52    RouteNormal,
53    RouteSelected,
54    RouteHighlighted,
55    RouteInProgress,
56    RouteProposedEndpoint,
57
58    // Pins
59    PinStem,
60    PinText,
61    PinTag,
62    PinStemSelected,
63    PinTagSelected,
64    /// Faint "Add Name"/"Add Type" placeholder shown at an empty pin label slot
65    /// while the pin's shape is selected.
66    PinLabelPlaceholder,
67
68    // Drag / move states
69    DragPreviewStroke,
70    DragActiveFill,
71    DragActiveStroke,
72    EdgeDragPreview,
73    /// A move the destination refuses, so the drag is showing what will not
74    /// be taken. The same base the read-only dot is drawn in: it already
75    /// means "no" here.
76    MoveRefusedStroke,
77    /// A conflict the document already holds — two routing shapes sharing
78    /// cells, which a paste can create and nothing refuses. Amber, not red:
79    /// it is a fault to clear, not a gesture being turned away.
80    StandingConflict,
81
82    // Selection & editing controls
83    SelectionFrame,
84    SelectionFrameOutline,
85    ControlHandleFill,
86    ControlHandleStroke,
87    WaypointFill,
88    ResizeCornerFill,
89    ResizeCornerStroke,
90    ResizeCornerActiveFill,
91    ResizeCornerActiveStroke,
92    ImageDragBox,
93    MarqueeFill,
94
95    // Tool previews
96    NewBlockPreviewStroke,
97    NewPinPreviewFill,
98    NewPinPreviewStroke,
99    /// The grow-out target shown on a pin/anchor when hovering it in the select
100    /// tool (to start a route) or nearing it while routing (to end one).
101    RouteStartTarget,
102
103    // UI chrome
104    CanvasBackground,
105    GridLine,
106    PinDragIndicator,
107
108    // Debug
109    DebugMark,
110
111    /// Thin line shown while dragging when an edge/center/pin-stub lines up with
112    /// another shape's. A base color, not an accent.
113    AlignmentGuide,
114
115    /// The grid-cell size readout shown inside a block's upper-left corner while
116    /// it is being resized. A base color, like the other transient drag hints.
117    SizeReadout,
118
119    /// The liveness dot at the head of the top bar, in its four states:
120    /// recorded, a write in flight, nothing writable, and a scratch session
121    /// with no file behind it. Hues rather than base tones — they say *which*
122    /// state, not how loud — except the scratch one, which is a base tone on
123    /// purpose: it is the absence of a state, not one of them.
124    LiveDot,
125    DotWriting,
126    DotReadOnly,
127    DotScratch,
128
129    /// The top bar while the time machine is open: the amber wash over the
130    /// bar's own glass, the ink the centre's words and the dot take, and the
131    /// word on the one filled control in the shell. The wash carries its own
132    /// alpha, because it goes *over* the fill the bar already has — the bar
133    /// changes state rather than becoming another object.
134    ViewingTint,
135    ViewingInk,
136    ViewingReturnInk,
137
138    /// The toast that says what a file operation did: the slab and the words
139    /// on it. The mockup's is a dark glass plate with white text, which is
140    /// one scheme's answer to a question the palette answers for both — so it
141    /// takes the inverse-video pairing [`Role::TagBadge`] argues for, like
142    /// every other thing in this app that has to be read over whatever is
143    /// behind it.
144    Toast,
145    ToastText,
146
147    /// A named rev's badge in the history list: the chip behind the tag,
148    /// and the text on it. Inverse video off the base ramp — the
149    /// foreground slot filled, the background slot written on it — which
150    /// is the only pairing that clears WCAG AA in every scheme this app
151    /// ships, light and dark. A badge whose text you cannot read is not a
152    /// badge.
153    TagBadge,
154    TagBadgeText,
155
156    /// The initials disc beside a history row. One of five accent slots,
157    /// picked by hashing the author's name, so the same hand keeps the same
158    /// colour down the list without anybody assigning one.
159    ///
160    /// Roles rather than arithmetic on the palette: the avatar wants
161    /// *categorical* colour, which is what the accent ramp is for, and
162    /// naming the five means a scheme can re-point them.
163    AuthorAvatar0,
164    AuthorAvatar1,
165    AuthorAvatar2,
166    AuthorAvatar3,
167    AuthorAvatar4,
168    /// The initials themselves, read over any of the five.
169    AuthorAvatarText,
170
171    /// The outline around a title block, in both homes it has: the editor's
172    /// corner of the canvas and the printed sheet's footer. Sheet furniture,
173    /// not a document object — which is why it does not borrow
174    /// [`Role::AreaStroke`], whose color a user edits to restyle their own
175    /// boundary areas.
176    TitleBlockBorder,
177
178    /// The ring a document step leaves round what it changed. A guide role, so
179    /// a base tone — it points at the drawing for a moment, like
180    /// [`Role::AlignmentGuide`] and [`Role::SizeReadout`] beside it, and is not
181    /// content with an accent of its own.
182    ChangeRing,
183
184    /// The in-place editor where it replaces no label of its own colour: its
185    /// ink and its field. An editor inside a block body takes the label's
186    /// roles instead.
187    EditorText,
188    EditorFill,
189    /// The caret and the selection highlight, every editor's.
190    EditorCaret,
191    EditorSelection,
192    /// The placeholder's ink while the draft is empty.
193    EditorHint,
194    /// The ring round every editor's field, so it reads as a field standing
195    /// over the drawing rather than as more of the drawing.
196    EditorBorder,
197
198    /// Resolves to a fully transparent color (transparent fills, no-op strokes).
199    /// Keep this the last variant — [`N_ROLES`] depends on it.
200    Transparent,
201}
202
203/// The avatar colour a name always lands on. A sum of the bytes is enough:
204/// the requirement is that one name always picks the same colour, not that
205/// two names rarely collide.
206#[must_use]
207pub fn avatar_role(name: &str) -> Role {
208    const WHEEL: [Role; 5] = [
209        Role::AuthorAvatar0,
210        Role::AuthorAvatar1,
211        Role::AuthorAvatar2,
212        Role::AuthorAvatar3,
213        Role::AuthorAvatar4,
214    ];
215    let sum: usize = name.bytes().map(usize::from).sum();
216    WHEEL[sum % WHEEL.len()]
217}
218
219/// Number of [`Role`] variants. Used to size the [`Theme`]'s tone table.
220pub const N_ROLES: usize = Role::Transparent as usize + 1;
221
222/// Map an optional accent index to its accent [`Role`]: `Some(0..=7)` →
223/// [`Role::Accent0`]..[`Role::Accent7`], `None` or out of range → `None`.
224/// Shared by every shape that carries an `Option<u8>` accent `role` (blocks,
225/// ports, routes); each call site supplies its own fallback for the `None` case.
226pub fn accent_role(role: Option<u8>) -> Option<Role> {
227    Some(match role? {
228        0 => Role::Accent0,
229        1 => Role::Accent1,
230        2 => Role::Accent2,
231        3 => Role::Accent3,
232        4 => Role::Accent4,
233        5 => Role::Accent5,
234        6 => Role::Accent6,
235        7 => Role::Accent7,
236        _ => return None,
237    })
238}
239
240/// A stroke described by a world-space width plus a [`Role`] — the app-side,
241/// role-based counterpart to the canvas's
242/// [`PaletteStroke`](crate::PaletteStroke). The [`Style`]
243/// adapter resolves the role to a [`Swatch`] (and hands the width through) when
244/// forwarding to the palette-based renderer. Construct from a `(width, role)`
245/// tuple, which wraps the literal width as a [`WorldPx`]; use
246/// [`RoleStroke::NONE`] for no stroke.
247#[derive(Clone, Copy)]
248pub struct RoleStroke {
249    pub width: WorldPx,
250    pub role: Role,
251}
252
253impl RoleStroke {
254    /// A zero-width, transparent stroke (draws nothing).
255    pub const NONE: RoleStroke = RoleStroke {
256        width: WorldPx::ZERO,
257        role: Role::Transparent,
258    };
259}
260
261impl From<(f32, Role)> for RoleStroke {
262    fn from((width, role): (f32, Role)) -> Self {
263        RoleStroke {
264            width: WorldPx::new(width),
265            role,
266        }
267    }
268}
269
270/// The six canvas text sizes (in px), the font counterpart to the role → base
271/// color mapping. Layered onto [`Theme`] from the embedded `font_sizes.json`
272/// (see [`Theme::from_embedded`]) and tuned live by the font editor
273/// (`--font-editor`). `#[serde(default)]` lets a partial file specify only the
274/// sizes it overrides; the rest fall back to [`FontSizes::default`].
275#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
276#[serde(default)]
277pub struct FontSizes {
278    pub title: f32,
279    pub block_type: f32,
280    pub pin: f32,
281    pub pin_subtitle: f32,
282    pub tag: f32,
283    pub route: f32,
284}
285
286impl Default for FontSizes {
287    fn default() -> Self {
288        use blockworx_geom::grid;
289        Self {
290            title: grid::TITLE_TEXT_SIZE,
291            block_type: grid::BLOCK_TYPE_TEXT_SIZE,
292            pin: grid::PORT_TEXT_SIZE,
293            pin_subtitle: grid::PORT_SUBTITLE_TEXT_SIZE,
294            tag: grid::TAG_TEXT_SIZE,
295            route: grid::ROUTE_TEXT_SIZE,
296        }
297    }
298}
299
300#[derive(Clone)]
301pub struct Theme {
302    // Fonts
303    pub title_font: Font,
304    /// A block's type label, drawn slightly smaller than `title_font`.
305    pub type_font: Font,
306    pub pin_font: Font,
307    /// Second line of a pin/port name, drawn smaller than `pin_font`.
308    pub pin_subtitle_font: Font,
309    pub tag_font: Font,
310    pub route_font: Font,
311    /// The sizes the six font fields above were built from, so the font editor
312    /// can read them back and re-derive the `Font`s on a change.
313    font_sizes: FontSizes,
314
315    /// The 16 base colors every role resolves into.
316    palette: Palette,
317    /// Role → [`Swatch`] mapping (base slot + alpha), indexed by `role as usize`.
318    tones: [Swatch; N_ROLES],
319}
320
321impl Theme {
322    /// The concrete color for a role: its [`Swatch`] resolved against the
323    /// palette. [`Role::Transparent`] and any role mapped to `None` resolve to a
324    /// fully transparent color. For code that paints outside the
325    /// [`Renderer`](crate::Renderer) API — the shell's chrome, the theme
326    /// editor's and role picker's swatches; the render path passes a
327    /// `Swatch`/`PaletteStroke` to the canvas instead.
328    /// The colours an in-place editor is painted in: `ink` and `fill` for
329    /// the text and its field — the editor's own roles, or the roles of the
330    /// label it replaces — and the caret, selection, hint and border every
331    /// editor shares.
332    pub fn editor_colors(&self, ink: Role, fill: Role) -> crate::EditColors {
333        crate::EditColors {
334            text: self.resolve(ink),
335            background: self.resolve(fill),
336            caret: self.resolve(Role::EditorCaret),
337            selection: self.resolve(Role::EditorSelection),
338            hint: self.resolve(Role::EditorHint),
339            border: self.resolve(Role::EditorBorder),
340        }
341    }
342
343    pub fn resolve(&self, role: Role) -> Color {
344        self.palette.resolve(self.swatch(role))
345    }
346
347    /// The palette [`Swatch`] a role resolves to (its base slot plus alpha).
348    /// [`Role::Transparent`] is always the transparent swatch. This is the
349    /// hand-off point to the palette-based canvas: app roles become swatches
350    /// here, and the renderer turns swatches into colors.
351    pub fn swatch(&self, role: Role) -> Swatch {
352        if role == Role::Transparent {
353            return Swatch::TRANSPARENT;
354        }
355        self.tones[role as usize]
356    }
357
358    /// The palette backing this theme, which the shell also derives the host's
359    /// own widget colors from.
360    pub fn palette(&self) -> &Palette {
361        &self.palette
362    }
363
364    /// Swap the backing palette (e.g. when the user picks a different scheme).
365    /// The role → base `tones` table is palette-independent, so every role
366    /// restyles at once.
367    pub fn set_palette(&mut self, palette: Palette) {
368        self.palette = palette;
369    }
370
371    /// The palette slot a role resolves through, or `None` if it draws nothing
372    /// (transparent). Alpha is ignored here.
373    pub fn base_of(&self, role: Role) -> Option<Base> {
374        self.tones[role as usize].base
375    }
376
377    /// Repoint a role at a different palette slot (or `None` for transparent),
378    /// preserving its alpha. [`Role::Transparent`] is fixed, so it is untouched.
379    pub fn set_base(&mut self, role: Role, base: Option<Base>) {
380        if role != Role::Transparent {
381            self.tones[role as usize].base = base;
382        }
383    }
384
385    /// The full role → base mapping, in declaration order, excluding
386    /// [`Role::Transparent`] (which is always transparent). A role with no base
387    /// serializes as `null`. This is what the theme editor writes to `theme.json`.
388    pub fn overrides(&self) -> IndexMap<Role, Option<Base>> {
389        Role::iter()
390            .filter(|r| *r != Role::Transparent)
391            .map(|r| (r, self.base_of(r)))
392            .collect()
393    }
394
395    /// The active canvas font sizes (px).
396    pub fn font_sizes(&self) -> FontSizes {
397        self.font_sizes
398    }
399
400    /// Retune the six canvas fonts to `fs` (rebuilding each `Font`) and record
401    /// the sizes. The font editor writes these to `font_sizes.json` on exit.
402    pub fn set_font_sizes(&mut self, fs: FontSizes) {
403        self.apply_font_sizes(fs);
404    }
405
406    /// Rebuild the six `Font` fields from `fs` and remember the sizes.
407    fn apply_font_sizes(&mut self, fs: FontSizes) {
408        self.title_font = Font::canvas(fs.title);
409        self.type_font = Font::canvas(fs.block_type);
410        self.pin_font = Font::canvas(fs.pin);
411        self.pin_subtitle_font = Font::canvas(fs.pin_subtitle);
412        self.tag_font = Font::canvas(fs.tag);
413        self.route_font = Font::canvas(fs.route);
414        self.font_sizes = fs;
415    }
416
417    /// The compile-time default theme: [`Theme::default`] (fonts, alphas, and a
418    /// fallback base for every role) with the role → base overrides from the
419    /// embedded `theme.json`, then the embedded `font_sizes.json`, layered on
420    /// top. A `null` value maps the role to no color. A malformed file falls back
421    /// to the built-in defaults rather than failing to start.
422    pub fn from_embedded() -> Self {
423        let mut theme = Theme::default();
424        match embedded_overrides() {
425            Ok(overrides) => {
426                for (role, base) in overrides {
427                    theme.set_base(role, base);
428                }
429            }
430            Err(e) => tracing::warn!("theme.json parse error, using built-in defaults: {e}"),
431        }
432        match embedded_font_sizes() {
433            Ok(fs) => theme.apply_font_sizes(fs),
434            Err(e) => tracing::warn!("font_sizes.json parse error, using defaults: {e}"),
435        }
436        theme
437    }
438}
439
440/// The overrides `theme.json` bakes in. One stale role name fails the whole
441/// file, and the app then falls back to the built-in defaults without a word
442/// on screen — so the suite holds it to parsing.
443fn embedded_overrides() -> Result<IndexMap<Role, Option<Base>>, serde_json::Error> {
444    serde_json::from_str(EMBEDDED_THEME)
445}
446
447fn embedded_font_sizes() -> Result<FontSizes, serde_json::Error> {
448    serde_json::from_str(EMBEDDED_FONT_SIZES)
449}
450
451/// The role → base overrides baked into the binary, written by the theme editor
452/// (`--theme-editor`) on exit. See [`Theme::from_embedded`].
453const EMBEDDED_THEME: &str = include_str!("theme.json");
454
455/// The canvas font sizes baked into the binary, written by the font editor
456/// (`--font-editor`) on exit. See [`Theme::from_embedded`].
457const EMBEDDED_FONT_SIZES: &str = include_str!("font_sizes.json");
458
459impl Default for Theme {
460    fn default() -> Self {
461        use Base::{
462            B00, B0A, B0B, B0C, B0D, B0E, B0F, B01, B02, B03, B04, B05, B06, B07, B08, B09,
463        };
464        use Role::{
465            Accent0, Accent1, Accent2, Accent3, Accent4, Accent5, Accent6, Accent7, AccentDefault,
466            AlignmentGuide, AreaStroke, AuthorAvatar0, AuthorAvatar1, AuthorAvatar2, AuthorAvatar3,
467            AuthorAvatar4, AuthorAvatarText, CanvasBackground, ChangeRing, ControlHandleFill,
468            ControlHandleStroke, DebugMark, DotReadOnly, DotScratch, DotWriting, DragActiveFill,
469            DragActiveStroke, DragPreviewStroke, EdgeDragPreview, EditorBorder, EditorCaret,
470            EditorFill, EditorHint, EditorSelection, EditorText, GridLine, ImageDragBox, LiveDot,
471            LockedHintIcon, LockedShapeFill, LockedShapeTitle, LockedShapeType, MarqueeFill,
472            MoveRefusedStroke, NewBlockPreviewStroke, NewPinPreviewFill, NewPinPreviewStroke,
473            PinDragIndicator, PinLabelPlaceholder, PinStem, PinStemSelected, PinTag,
474            PinTagSelected, PinText, ResizeCornerActiveFill, ResizeCornerActiveStroke,
475            ResizeCornerFill, ResizeCornerStroke, RouteHighlighted, RouteInProgress, RouteNormal,
476            RouteProposedEndpoint, RouteSelected, RouteStartTarget, SelectionFrame,
477            SelectionFrameOutline, ShapeFill, ShapeStroke, ShapeTitle, ShapeType, SizeReadout,
478            StandingConflict, TagBadge, TagBadgeText, TextBoxFill, TextBoxStroke, TitleBlockBorder,
479            Toast, ToastText, ViewingInk, ViewingReturnInk, ViewingTint, WaypointFill,
480        };
481
482        // Default every slot to the foreground color, then assign each role.
483        // Indexing by `role as usize` keeps this robust to variant reordering.
484        let mut tones = [Swatch::solid(B05); N_ROLES];
485        let mut set = |role: Role, tone: Swatch| tones[role as usize] = tone;
486
487        // Shapes
488        set(ShapeFill, Swatch::solid(B01));
489        set(ShapeStroke, Swatch::solid(B0D));
490        set(ShapeTitle, Swatch::solid(B0B));
491        set(LockedShapeFill, Swatch::solid(B02));
492        set(LockedShapeTitle, Swatch::solid(B0A));
493        set(ShapeType, Swatch::solid(B0A));
494        set(LockedShapeType, Swatch::solid(B09));
495        set(LockedHintIcon, Swatch::solid(B0A));
496
497        // Accent (per-block outline) colors: default + the 8 accents B08..B0F.
498        set(AccentDefault, Swatch::solid(B04));
499        set(Accent0, Swatch::solid(B08));
500        set(Accent1, Swatch::solid(B09));
501        set(Accent2, Swatch::solid(B0A));
502        set(Accent3, Swatch::solid(B0B));
503        set(Accent4, Swatch::solid(B0C));
504        set(Accent5, Swatch::solid(B0D));
505        set(Accent6, Swatch::solid(B0E));
506        set(Accent7, Swatch::solid(B0F));
507
508        // The in-place editor: the field is the darkest ground, the ink the
509        // foreground, the caret the brightest base, the selection a wash.
510        set(EditorText, Swatch::solid(B05));
511        set(EditorFill, Swatch::solid(B00));
512        set(EditorCaret, Swatch::solid(B07));
513        set(EditorSelection, Swatch::faded(B0D, 0.4));
514        set(EditorHint, Swatch::solid(B04));
515        set(EditorBorder, Swatch::solid(B07));
516
517        // Text boxes
518        set(TextBoxFill, Swatch::faded(B01, 0.35));
519        set(TextBoxStroke, Swatch::solid(B03));
520
521        // Boundary areas: a solid, muted outline that reads as a grouping
522        // frame without competing with block/route strokes.
523        set(AreaStroke, Swatch::solid(B06));
524
525        // Routes
526        set(RouteNormal, Swatch::solid(B0B));
527        // A selected route draws a wider halo in this color behind its resting
528        // wire; B07 (the lightest foreground) reads as a highlight, matching the
529        // `*Selected` pin roles.
530        set(RouteSelected, Swatch::solid(B07));
531        set(RouteHighlighted, Swatch::faded(B0B, 0.3));
532        set(RouteInProgress, Swatch::solid(B0A));
533        set(RouteProposedEndpoint, Swatch::solid(B0F));
534
535        // Pins
536        set(PinStem, Swatch::solid(B0F));
537        set(PinText, Swatch::solid(B05));
538        set(PinTag, Swatch::solid(B0B));
539        set(PinStemSelected, Swatch::solid(B08));
540        set(PinTagSelected, Swatch::solid(B0C));
541        set(PinLabelPlaceholder, Swatch::solid(B04));
542
543        // Drag / move
544        set(DragPreviewStroke, Swatch::solid(B04));
545        set(DragActiveFill, Swatch::solid(B02));
546        set(DragActiveStroke, Swatch::solid(B0F));
547        set(EdgeDragPreview, Swatch::faded(B04, 0.2));
548        set(MoveRefusedStroke, Swatch::solid(B08));
549        set(StandingConflict, Swatch::faded(B0A, 0.55));
550
551        // Selection & controls
552        set(SelectionFrame, Swatch::solid(B0F));
553        set(SelectionFrameOutline, Swatch::solid(B0D));
554        set(ControlHandleFill, Swatch::solid(B07));
555        set(ControlHandleStroke, Swatch::solid(B00));
556        set(WaypointFill, Swatch::faded(B0B, 0.5));
557        set(ResizeCornerFill, Swatch::solid(B0B));
558        set(ResizeCornerStroke, Swatch::solid(B0B));
559        set(ResizeCornerActiveFill, Swatch::solid(B0C));
560        set(ResizeCornerActiveStroke, Swatch::solid(B07));
561        set(ImageDragBox, Swatch::solid(B0D));
562        set(MarqueeFill, Swatch::faded(B0F, 0.1));
563
564        // Tool previews
565        set(NewBlockPreviewStroke, Swatch::solid(B0D));
566        set(NewPinPreviewFill, Swatch::solid(B0D));
567        set(NewPinPreviewStroke, Swatch::solid(B07));
568        set(RouteStartTarget, Swatch::solid(B0B));
569
570        // UI chrome
571        set(CanvasBackground, Swatch::solid(B00));
572        set(GridLine, Swatch::faded(B02, 0.6));
573        set(PinDragIndicator, Swatch::faded(B04, 0.3));
574
575        // Debug
576        set(DebugMark, Swatch::faded(B08, 0.6));
577
578        // Alignment guides — a dim base color, fainter than the shape outline
579        // (a base color, not an accent) so the hints don't dominate.
580        set(AlignmentGuide, Swatch::solid(B07));
581
582        // The resize size readout: the brightest base, so it reads over the
583        // block's active drag fill without competing with an accent.
584        set(SizeReadout, Swatch::solid(B07));
585
586        // The liveness dot and the lens's amber. Two hues off the ramp rather
587        // than two base tones: these say which state the document is in, and
588        // a base tone would only say how loud it is. The wash is
589        // faded because it goes over the bar's own glass — a solid one would
590        // make the bar a different object instead of the same bar tinted.
591        set(LiveDot, Swatch::solid(B0B));
592        set(DotWriting, Swatch::solid(B0A));
593        set(DotReadOnly, Swatch::solid(B08));
594        set(DotScratch, Swatch::solid(B04));
595        set(ViewingTint, Swatch::faded(B09, 0.28));
596        set(ViewingInk, Swatch::solid(B09));
597        set(ViewingReturnInk, Swatch::solid(B00));
598
599        // The toast: the same pairing again, and for the same reason — it
600        // stands over the drawing for two seconds and has to be read in
601        // that time, whatever is under it.
602        set(Toast, Swatch::solid(B07));
603        set(ToastText, Swatch::solid(B00));
604
605        // The title block's frame, on screen and in print: the same weight as
606        // a block outline, so it reads as ruled sheet furniture rather than
607        // as another thing on the drawing.
608        set(TitleBlockBorder, Swatch::solid(B04));
609
610        // What a step changed: the brightest base, so the ring reads as a
611        // hint laid over the drawing rather than as another thing in it.
612        set(ChangeRing, Swatch::solid(B07));
613
614        // A named rev's badge: inverse video, so the chip and its text
615        // stay legible whichever way round the scheme runs.
616        set(TagBadge, Swatch::solid(B05));
617        set(TagBadgeText, Swatch::solid(B00));
618
619        // The avatar's five: the accents the palette leaves for categorical
620        // use, with the darkest base written on them so the initials read on
621        // every one of the five in both schemes.
622        set(AuthorAvatar0, Swatch::solid(B0D));
623        set(AuthorAvatar1, Swatch::solid(B0B));
624        set(AuthorAvatar2, Swatch::solid(B09));
625        set(AuthorAvatar3, Swatch::solid(B0E));
626        set(AuthorAvatar4, Swatch::solid(B0C));
627        set(AuthorAvatarText, Swatch::solid(B00));
628
629        let fs = FontSizes::default();
630        Self {
631            title_font: Font::canvas(fs.title),
632            type_font: Font::canvas(fs.block_type),
633            pin_font: Font::canvas(fs.pin),
634            pin_subtitle_font: Font::canvas(fs.pin_subtitle),
635            tag_font: Font::canvas(fs.tag),
636            route_font: Font::canvas(fs.route),
637            font_sizes: fs,
638            palette: Palette::tokyo_night_moon(),
639            tones,
640        }
641    }
642}
643
644#[cfg(test)]
645mod tests {
646    use super::*;
647
648    /// A role renamed or removed without its `theme.json` entry makes the whole
649    /// file unreadable, and every override silently gives way to the built-in
650    /// defaults (wires and titles in green, 2026-09-21).
651    #[test]
652    fn the_embedded_theme_and_font_sizes_parse() {
653        let overrides = embedded_overrides().expect("theme.json names only roles that exist");
654        assert!(
655            !overrides.is_empty(),
656            "precondition: the file overrides something"
657        );
658        embedded_font_sizes().expect("font_sizes.json parses");
659    }
660
661    /// And what it overrides is what the app draws with: a wire at rest is
662    /// the embedded base, not the built-in default.
663    #[test]
664    fn the_embedded_theme_is_what_the_app_draws_with() {
665        let overrides = embedded_overrides().expect("theme.json parses");
666        let Some(Some(route)) = overrides.get(&Role::RouteNormal) else {
667            panic!("precondition: theme.json overrides RouteNormal");
668        };
669        assert_ne!(
670            Some(*route),
671            Theme::default().base_of(Role::RouteNormal),
672            "precondition: the override differs from the default"
673        );
674        assert_eq!(
675            Theme::from_embedded().base_of(Role::RouteNormal),
676            Some(*route)
677        );
678    }
679
680    /// WCAG relative luminance, for the one claim a palette-independent
681    /// role pair has to make.
682    fn luminance(color: Color) -> f32 {
683        let channel = |v: u8| {
684            let v = f32::from(v) / 255.0;
685            if v <= 0.039_28 {
686                v / 12.92
687            } else {
688                ((v + 0.055) / 1.055).powf(2.4)
689            }
690        };
691        0.2126 * channel(color.r()) + 0.7152 * channel(color.g()) + 0.0722 * channel(color.b())
692    }
693
694    fn contrast(a: Color, b: Color) -> f32 {
695        let (a, b) = (luminance(a), luminance(b));
696        (a.max(b) + 0.05) / (a.min(b) + 0.05)
697    }
698
699    /// The tag badge has to be readable in every scheme, both ways round.
700    #[test]
701    fn the_tag_badge_reads_in_every_scheme_and_both_luminances() {
702        use crate::{Luminance, Scheme};
703        for scheme in Scheme::ALL {
704            for luminance in [Luminance::Dark, Luminance::Light] {
705                let mut theme = Theme::default();
706                theme.set_palette(scheme.palette(luminance));
707                let ratio = contrast(
708                    theme.resolve(Role::TagBadge),
709                    theme.resolve(Role::TagBadgeText),
710                );
711                assert!(
712                    ratio >= 4.5,
713                    "{scheme:?}/{luminance:?}: the tag badge reads at {ratio:.1}:1",
714                );
715            }
716        }
717    }
718
719    #[test]
720    fn locked_and_type_roles_resolve_to_expected_bases() {
721        let theme = Theme::default();
722        assert_eq!(theme.base_of(Role::LockedShapeFill), Some(Base::B02));
723        assert_eq!(theme.base_of(Role::LockedShapeTitle), Some(Base::B0A));
724        assert_eq!(theme.base_of(Role::ShapeType), Some(Base::B0A));
725        assert_eq!(theme.base_of(Role::LockedShapeType), Some(Base::B09));
726        assert_eq!(theme.base_of(Role::LockedHintIcon), Some(Base::B0A));
727    }
728
729    #[test]
730    fn font_sizes_default_matches_grid_constants() {
731        use blockworx_geom::grid;
732        let fs = FontSizes::default();
733        assert_eq!(fs.title, grid::TITLE_TEXT_SIZE);
734        assert_eq!(fs.block_type, grid::BLOCK_TYPE_TEXT_SIZE);
735        assert_eq!(fs.pin, grid::PORT_TEXT_SIZE);
736        assert_eq!(fs.pin_subtitle, grid::PORT_SUBTITLE_TEXT_SIZE);
737        assert_eq!(fs.tag, grid::TAG_TEXT_SIZE);
738        assert_eq!(fs.route, grid::ROUTE_TEXT_SIZE);
739    }
740
741    #[test]
742    fn font_sizes_json_round_trip() {
743        let fs = FontSizes::default();
744        let json = serde_json::to_string(&fs).unwrap();
745        let back: FontSizes = serde_json::from_str(&json).unwrap();
746        assert_eq!(fs, back);
747    }
748
749    #[test]
750    fn font_sizes_partial_json_uses_defaults() {
751        let fs: FontSizes = serde_json::from_str(r#"{"title":20.0}"#).unwrap();
752        let default = FontSizes::default();
753        assert_eq!(fs.title, 20.0);
754        assert_eq!(fs.block_type, default.block_type);
755        assert_eq!(fs.pin, default.pin);
756        assert_eq!(fs.pin_subtitle, default.pin_subtitle);
757        assert_eq!(fs.tag, default.tag);
758        assert_eq!(fs.route, default.route);
759    }
760
761    #[test]
762    fn set_font_sizes_rebuilds_font_ids() {
763        let mut theme = Theme::default();
764        let custom = FontSizes {
765            title: 21.0,
766            block_type: 18.0,
767            pin: 17.0,
768            pin_subtitle: 10.0,
769            tag: 13.0,
770            route: 14.0,
771        };
772        theme.set_font_sizes(custom);
773        assert_eq!(theme.title_font.size, custom.title);
774        assert_eq!(theme.type_font.size, custom.block_type);
775        assert_eq!(theme.pin_font.size, custom.pin);
776        assert_eq!(theme.pin_subtitle_font.size, custom.pin_subtitle);
777        assert_eq!(theme.tag_font.size, custom.tag);
778        assert_eq!(theme.route_font.size, custom.route);
779        assert_eq!(theme.font_sizes(), custom);
780    }
781
782    #[test]
783    fn embedded_font_sizes_parses() {
784        assert!(serde_json::from_str::<FontSizes>(EMBEDDED_FONT_SIZES).is_ok());
785    }
786}