Skip to content

Appearance settings rewrite: shared style editor, resolve model, honest previews, skin-referenced colors#232

Merged
sproctor merged 33 commits into
masterfrom
window-settings-in-appearance
Jul 17, 2026
Merged

Appearance settings rewrite: shared style editor, resolve model, honest previews, skin-referenced colors#232
sproctor merged 33 commits into
masterfrom
window-settings-in-appearance

Conversation

@sproctor

Copy link
Copy Markdown
Owner

Summary

Reworks the Appearance settings end to end: a new style model, one shared style editor reused across every section, the base "default text" style promoted out of the presets, honest previews, and skin-referenced colors that follow the skin. Desktop (Jewel) and mobile (Material3) both covered.

The renderer was kept behavior-preserving throughout: a golden characterization suite pins how a style becomes a Compose SpanStyle, and it stays green across every model swap.

The style model (core)

  • StyleLayer — a sparse per-scope override set; every attribute is optional. resolve(layers) takes the first layer that sets each attribute independently, reproducing the old boolean-OR / earliest-color merge for existing data.
  • Tri-state BackgroundFill(color) / None (explicitly transparent) / Unset (inherit). None and unset look identical in game but resolve differently, so they're kept distinct end to end.
  • Weight, not bold — bold is weight == 700; an explicit weight and "bold" can't contradict through the cascade.
  • SourcedStyle / resolveSourced — the source-tracking twin of resolve, so each editor control can show set here / from Global / from skin and gate a per-attribute reset.
  • ColorValue + ref metadata — a color is a frozen literal or a SkinRef(slot) that re-resolves against the skin palette. StyleLayer carries the ref beside the resolved color and resolveRefs(palette) refreshes it at the compose boundary, so the render path stays plain-WarlockColor and untouched.

Shared editor + master-detail UI

  • One TextStyleEditor (Jewel + M3 twins) drives all four sections. Per attribute: control + source tag + reset; a live sample; text color, tri-state background, weight, italic, underline, and per-item fonts.
  • Text styles page (was "Presets"): a Global/character scope selector over the base Default text plus the named presets, each row an honest chip in its own resolved style, drilling in to the editor. Per-preset fonts; the base and monospace font sit above the presets list.
  • Windows / Highlights / Names all adopt the shared editor and scope selector; highlights keep their per-capture-group model, names gain B/I/U they never exposed.
  • A global (all-characters) preset/base layer, mirroring the existing highlight/name idiom.

Base "default text" style

  • Promoted out of the preset map into first-class character settings (color + fonts + italic/underline), cascaded skin → global → character like everything else.
  • Migration: on config load, any legacy presets["default"] is folded into the base and dropped (base values win; the legacy preset fills the rest), per scope.

Honest previews

  • StyleChip — a fixed-width swatch composited on the effective game window background (never the settings panel), full-bleed, with an inset ring only when the background is explicitly set (luminance-derived so it reads on any skin). The item name is always normal, legible UI text — never styled with its own style.
  • Contrast — WCAG ratio against the effective background; warns below ~3:1 with the actual ratio, never blocks.
  • Background picker — explicit Inherit / None / Color rows; Inherit is annotated with its fallback.

Skin-referenced colors

  • Pick from the skin palette → stored as a SkinRef that tracks the skin (no dark-on-light trap on skin switch); type/custom → a frozen literal. The UI labels which is which ("tracks <slot>").
  • Persisted backward-compatibly (nullable, defaulted) for presets, base, names, highlights, and windows; resolved at the render/editor boundary so the golden render tests are unaffected.

Storage / export

  • Per-section human-editable TOML (bold↔weight canonicalized so existing files stay byte-stable).
  • Export now carries the base style + default/mono fonts and the new per-preset weight/font + window flags, all defaulted so older export files still import.

Testing

  • Golden SpanStyle / AnnotatedString characterization + ComposeTextStream re-render tests (unchanged across the rewrite).
  • Unit tests for resolve, resolveSourced, the editor reducer, ColorValue/resolveRefs, contrast, and TOML/mapper round-trips (incl. skin refs and legacy-file load).

Known gaps / follow-ups

  • Export/import carries no color refs — an exported ref degrades to its resolved literal (StyleExport has no ref fields).
  • Windows at render refresh a ref color on save rather than instantly on a skin switch (presets/names/highlights update live); threading the palette through GameViewModel's hot path wasn't worth it.
  • The StyleDefinition façade is retained where it's still load-bearing (render entire-line path, some chrome) rather than fully retired.

Notes

🤖 Generated with Claude Code

@sproctor sproctor left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Automated deep review (xhigh effort)

Ten finder angles swept the ~9k-line diff (62 raw candidates → 31 distinct mechanisms), each surviving candidate got an independent verification pass against the PR head, plus a final gap sweep. 24 mechanisms confirmed, 6 refuted, 4 plausible-but-unproven. The 15 inline comments below are the confirmed findings, ranked most severe first (numbered in each comment).

The dominant theme: the new shared editor and resolve model teach per-attribute cascade semantics (sparse layers, explicit-false overrides, 9-step weights, skin refs) that the persistence layer (non-nullable schema booleans, missing weight fields, whole-map preset merges) and several render paths (names, highlights, window scaffold, window refs) don't implement yet. Most findings are instances of that one gap — closing it at the schema/registry level would fix several at once.

Confirmed but cut by the 15-finding cap (lower severity):

  • Ref-only backgrounds silently dropped on skin switch: SetBackgroundRef's Fill(Unspecified) placeholder round-trips to Unset through the mappers, so the documented "missing slot keeps last resolved color" fallback in resolveRefs is unreachable.
  • Font picker pins inherited family/size/weight as explicit edits on Save (seeded from the resolved sample, saved unconditionally) — flips source tags to "set here" and stops skin tracking.
  • Every Presets-page row shows the explicit-background ring + hex because chipStack includes baseLayers, so resolved.background is never Unset; the list and the drill-in editor disagree for the same preset.
  • Custom skin without a default preset + no user base style → black entry/status text on dark chrome (the old ?: SAFE_DEFAULT_STYLE net survives only as a CompositionLocal default the views bypass).
  • Hot-path allocation regressions (confirmed vs master): toSpanStyle bridge routes through resolve(listOf(toLayer())) per match span; appendStyledStringLeaf concats unconditionally; resolve() allocates a list for background; getEntireLineStyles adds a per-leaf list + re-projection. RenderingBenchmark covers three of these, so a before/after run will show it.
  • resolveSourced() duplicates resolve()'s cascade attribute-for-attribute (drift between editor and renderer would be silent — a toResolved() projection would unify them); ~88% verbatim desktop/mobile duplication across the new settings views; dead API (ColorValue, sourceOf, StyleLayer.toStyleDefinition, PresetRepository.observeForCharacter/saveGlobal); StyleChip uses naive luminance() > 0.5 twice instead of CONTRAST_CROSSOVER_LUMINANCE.

Investigated and refuted (so you don't have to chase them): settings-dialog navigation while open (dialog is document-modal, trigger unreachable); LaunchedEffect empty-map classification race (titles StateFlow present on frame 1, self-heals); entry-style save wiping new fields ('entry' isn't editable via any new path; byte-identical to master); highlight regex recompiles on palette changes (rare events; old code already full-re-rendered); Fill(Unspecified) beating real backgrounds in the cascade (never durable); per-keystroke entry re-resolution (only ~1-2 Hz during roundtime).

🤖 Generated with Claude Code

// Resolve skin-referenced colors against the skin's own palette so a user's palette pick
// renders in the referenced color and follows the skin.
val palette = skinDefaults.presetColorPalette()
(skinDefaults.mapValues { it.value.toLayer() } + savedPresets.mapValues { it.value.resolveRefs(palette) }) - "default"

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1. Preset scope merge is whole-map replacement, but the editor teaches per-attribute cascade [correctness]

skinDefaults + savedPresets here (and global.presets + own.presets in PresetRepository.observeLayersForCharacter) replaces a preset's layer wholesale per name, while the editor saves sparse scope layers and previews a per-attribute cascade (styleEditorModel stacking character→global→skin).

Repro: skin gives speech a color; check only Italic at character scope → the persisted {italic=true} layer replaces the skin layer entirely → speech renders italic in the base color in game, while the editor sample shows colored italic tagged "from skin". The renderer consumes one layer per name (StyledStringHelpers.kt:97), so the merge here must cascade per attribute (or the save must persist the merged layer). Note PresetRepositoryTest.kt:67 currently codifies the replacement behavior the editor contradicts.

* A skin-referenced color resolves against [palette]; with the default (empty) palette a ref falls back
* to the last-resolved color stored beside it, so render stays correct without threading the palette.
*/
fun WindowSettings.getStyle(palette: Map<String, WarlockColor> = emptyMap()): StyleDefinition =

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2. Window skin-referenced colors never take effect in game [correctness]

All five GameViewModel call sites (535/566/583/1231/1290) call getStyle() bare, so the emptyMap() default means textColorRef/backgroundColorRef never resolve on the render path — unlike names/highlights/presets, which WindowRegistryImpl resolves against colorPalette.

Repro: pick a palette slot for a window's text color → SetTextColorRef sets only the ref, setStyleLayer persists textColor=Unspecified + ref → the game window renders unstyled (or the stale pre-pick literal) while the settings preview shows the picked color; skin/dark-mode switches never re-tint windows. Also: the in-game context-menu dialog saves via setStyle (WindowSettingsRepository.kt:134), which nulls both refs outright. Suggest resolving window refs at the WindowRegistryImpl boundary and deleting the empty-palette default.

backgroundColor = background.toWarlockColor(),
entireLine = entireLine == true,
bold = weight == 700,
italic = italic == true,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

3. Explicit boolean-false overrides cannot persist — the checkbox bounces back [correctness]

Write side collapses Boolean? to a plain flag (italic = italic == true), read side maps false → null (if (italic) true else null), so "explicitly off" round-trips to "unset".

Repro: skin sets speech italic; uncheck Italic at character scope → SetItalic(false) → persisted as false → re-read as nullresolve() falls through to the skin's true → the checkbox re-checks itself as soon as the flow re-emits, and the italic keeps rendering. Same collapse in every config type incl. base style (applyBaseStyle) and windows. Root cause: the schema booleans are non-nullable (ConfigSchema.kt), so the TOML can't express the tri-state that StyleLayer/applyEdit/resolve support — StyleEditorTest.setItalicFalseOverridesRatherThanClears pins exactly the semantics persistence breaks. Suggest Boolean? = null schema fields.

existing.copy(
textColor = layer.textColor ?: WarlockColor.Unspecified,
backgroundColor = layer.background.toWarlockColor(),
bold = layer.weight == 700,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4. Any weight other than 700 is silently discarded for windows (and names) [correctness]

bold = layer.weight == 700 with no weight field on WindowStyleConfig discards every other option the always-visible Weight dropdown offers (100–600, 800, 900). Because the window editor's editLayer is rebuilt from the persisted flow, the dropdown visibly snaps back to Default right after picking e.g. Semi Bold; 800/900 lose boldness entirely (== 700, not >=).

NameConfig.withStyle (StyleEditing.kt:37) has the identical unpaired collapse — and the weight/fontFamily/fontSize fields added to NameConfig in this PR are dead (no reader or writer anywhere). Related fork: read side treats >=600 as bold (ResolvedStyle.kt:48, ConfigMappers.kt:154) vs ==700 writes. Presets/highlights survive only because they carry a separate weight field. Suggest persisting weight for windows, wiring NameConfig.weight, and one shared bold↔weight helper.

showSettingsDialog = onOpenWindowSettings,
onClearClick = clearStream,
onCloseClick = onCloseClick,
),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5. Window/base bold, italic, underline are render-dead for stream text [correctness] (anchored to nearest diff line — the issue is at lines 172 and ~262)

style = uiState.style.mergeWith(defaultStyle) (line 172) carries the newly editable window/base bold/italic/underline, but WindowViewContent consumes the merged style only for textColor/backgroundColor; rowTextStyle (~line 262) is TextStyle(color, fontFamily, fontSize, fontWeight) with no fontStyle/textDecoration, and font attributes come only from effectiveFont.

So per-window Italic/Underline (new WindowStyleConfig booleans) and base-style Italic/Underline are persisted and previewed in the editor but never affect game text. Base weight renders only via toFontConfig→LocalDefaultFont, and is dropped in any window with a per-window font override (font ?: replaces the whole FontConfig). The rowTextStyle block is byte-identical to master — new settings surface wired into a render path never extended to consume it.

style = MaterialTheme.typography.bodySmall,
)
FlowRow(horizontalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
palette.toSortedMap().forEach { (slot, color) ->

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

11. toSortedMap() is JVM-only — this breaks the iOS build [build-break]

kotlin.collections.toSortedMap exists only in the JVM stdlib, and mobileMain is a parent source set of iosMain (group("mobile") { withAndroidTarget(); group("ios") { withIos() } }). Same call in ColorRefPickerDialog.kt:60. Latent because CI and release pass -PiosSkip=true, so nothing catches it until someone builds the documented iOS path (-PiosSkip=false) and hits an unresolved reference. Fix: palette.entries.sortedBy { it.key }.

val skin = LocalSkin.current
val isDark = LocalDarkTheme.current
val defaultStyle =
remember(skin, isDark, savedPresets) { (skin.toPresets(isDark) + savedPresets)["default"] ?: StyleDefinition() }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

12. Windows page computes its "default text" from the folded-away presets["default"] [correctness]

After this PR's own load-time migration, savedPresets can never contain "default", and it's single-scope (global skipped), so defaultStyle degenerates to the skin default. The game's real base is WindowRegistryImpl.baseStyle (character + global base via observeBaseStyle, + skin). A user's custom base text style therefore never appears in this page's window samples and inherited values — the honest-preview goal is defeated on exactly the page built for it. Mobile WindowsView.kt:53 is identical. Suggest reusing the base-style cascade here (both views currently lack characterSettingsRepository).

monospace -> monoFont?.weight?.let { FontWeight(it) }
else -> null
},
weight?.let { FontWeight(it) }

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

13. Unvalidated weight from TOML crashes the settings UI and silently swallows stream lines [correctness]

FontWeight(it) requires 1..1000. The new per-style weight schema fields accept any Int from hand-edited TOML (an explicitly supported flow — the config watcher exists for it, and the value parses so the reload-error catch never fires), and nothing clamps between decode and construction (FontPickerState clamps size but not weight).

With weight = 0/1200 on a preset: opening Text styles/Highlights crashes composition via StyleChip.kt:83 (the uncaught-exception workaround in Main.kt only swallows known Compose bugs), and on the render path StreamWorkQueue's catch turns every line using the style into a logged error that never appears in the window. Suggest clamping/validating at the mapper (weight?.coerceIn(1, 1000) or reject on load).

showFont: Boolean = true,
showEntireLine: Boolean = false,
showMonospace: Boolean = false,
windowBackground: Color = Color(0xFF1E1F22),

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

14. Highlights/names previews and contrast warnings composite against a hardcoded dark background [correctness]

The windowBackground = Color(0xFF1E1F22) default feeds both the preview composite and the low-contrast warning, and the highlights/names call sites on both platforms never pass the real base background (list rows hardcode SAFE_DEFAULT_STYLE's too). On a light skin, a dark-navy highlight previews nearly invisible with a spurious "Low contrast" warning while sitting at ~12:1 in game; light colors preview fine yet are unreadable in game with no warning. The Presets page resolves and passes the real base — the other two pages should get the same value.

) {
Column(modifier.fillMaxWidth()) {
OptionRow(
selected = current == Background.Unset,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

15. "Inherit" can never show as selected when any lower layer sets a background [correctness]

selected = current == Background.Unset where callers pass current = sample.background — the resolved cascade value, which is a Fill whenever the skin/base sets a background. Round-trip proof: pick Inherit (writes Unset to the edit layer), reopen the dialog → Color is highlighted with the inherited hex, contradicting the choice just made. The editor already has editLayer.background in hand (used for the tracks-slot tag) — the selection state should be driven from the sparse layer. Mobile TextStyleEditor/BackgroundPickerDialog identical.

sproctor and others added 29 commits July 15, 2026 02:25
Split the flat settings nav into a General item (no header) plus
Appearance, Game, and Account sections via a new SettingsGroup, and
give every page a leading icon (SettingsPage.icon), rendered by both
the mobile (Material3 ListItem) and desktop (Jewel) navs.

Adds a hand-authored group.xml (two-person) drawable for the Characters
page, since the existing Material Symbols set had no multi-person glyph.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
…and Windows

Per-window color/font settings now live in the main Settings dialog instead of a
standalone popup. A window header's "..." / right-click "Window settings" action
opens Settings focused on that window's editor, routed via a GameViewModel
SharedFlow event collected by the app host.

Add a Windows settings section (Shown / configured-not-shown / other-windows
buckets + add-by-id, live show/hide via a WindowSettingsLiveContext) on desktop
(Jewel) and mobile (M3). Split the Appearance page into separate Presets and
Windows nav pages and drop the multi-line preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Introduce the pure appearance model alongside StyleDefinition (not yet wired):
Background (tri-state Fill/None/Unset), StyleLayer (sparse, weight-not-bold,
per-item font), ResolvedStyle, resolve(), and StyleDefinition<->layer adapters.
toLayer() maps "off" booleans to null so first-set-wins reproduces the old
boolean-OR merge for existing data.

Add golden characterization tests pinning toSpanStyle/toAnnotatedString output
and preset/window/name TOML round-trips, as the safety net for the upcoming
renderer migration onto resolve().

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PresetStyleConfig/HighlightStyleConfig/NameConfig gain nullable weight,
fontFamily, and fontSize; WindowStyleConfig gains bold/italic/underline for the
shared style editor. All additive with defaults, so pre-existing TOML still
parses and a bold-only preset stays byte-stable (the nullable fields drop when
unset). toStyleDefinition folds an explicit weight back into bold so a hand-set
weight still renders during the transition; per-item font family/size are stored
but not yet applied (the renderer migration wires them next).

Deferred to the UI phase: explicit tri-state None background storage and
applying per-preset skin fonts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the StyleDefinition -> SpanStyle choke point with a ResolvedStyle ->
SpanStyle primitive that can carry per-item font family/size/weight and a
tri-state background, and rebuild each leaf's span from resolve() over the leaf's
named-style layers. StyleDefinition.toSpanStyle now delegates through the same
path, so the highlight / name / entire-line / link call sites are unchanged and
every existing render stays byte-identical (the P0 golden tests pass unmodified).

Wiring the preset map to StyleLayer (so per-item preset fonts and tri-state None
actually flow to the renderer) rides with the settings UI in a later phase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
PresetRepository gains observeForCharacter (a character's presets layered over the
global ones, character winning per key) and saveGlobal, mirroring the existing
highlight/name global idiom. The renderer's preset resolution becomes
skin -> global -> character. With no global presets saved, behavior is identical
to before.

Window styling globalization rides with the windows scope UI in the next phase
(its geometry stays per-character in SQLite, so only the styling half globalizes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the editor-side model: StyleScope, Sourced<T>, SourcedStyle, and
resolveSourced() (the source-tracking twin of resolve(), for "set here /
inherited from …" tags + per-attribute reset). Add tri-state Background <->
WarlockColor helpers (transparent argb 0 == None) and the preset
config<->StyleLayer mappers, with reverse canonicalization (weight 700 -> bold,
no weight field) so bold-only TOML stays byte-stable. Not wired yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
CharacterSettingsConfig gains defaultTextColor / defaultBackgroundColor /
defaultItalic / defaultUnderline beside defaultFont / monoFont — the color half of
the former presets["default"] (defaultFont being the font/weight half). Add
CharacterSettingsRepository.observeBaseStyle / saveBaseStyle that assemble and
split a base StyleLayer, plus the config<->StyleLayer base mappers. Not yet wired
into the renderer or the editor.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Remove "default" from the rendered presets map and from WarlockStyle.presets.
Add WindowRegistry.baseStyle: the resolved base ("default text") color + font +
weight + italic/underline, cascaded character -> global -> skin (from
CharacterSettingsConfig, with a legacy "default"-preset fallback so older configs
keep their default color until the P6 destructive migration). Provide it as
LocalBaseStyle, with the base font half via LocalDefaultFont; the ~10 chrome
sites that read presets["default"] now read the resolved base style.

Behavior-preserving: the P0 golden render tests pass unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The stream renderer keyed presets by dense StyleDefinition, which cannot
carry per-item fonts or the tri-state (Fill/None/Unset) background. Retype
the whole preset render path to Map<String, StyleLayer> so those reach the
renderer:

- WindowRegistry.presets + WindowRegistryImpl (via the new
  PresetRepository.observeLayersForCharacter), ComposeTextStream, and the
  StyledStringHelpers/AnnotatedStringHelpers.markLinks choke points now
  speak StyleLayer; appendStyledStringLeaf drops its per-leaf .toLayer().
- LocalStyleMap becomes Map<String, StyleLayer>; a resolvedStyle(key) helper
  projects a preset back to a dense StyleDefinition for the input-line chrome
  that still consumes the legacy type. Dialog link lookups read the layer's
  nullable textColor directly.
- getEntireLineStyles reads layer.entireLine and projects to StyleDefinition,
  keeping the entire-line/highlight flatten path (still StyleDefinition-based)
  unchanged.

Behavior-preserving: existing presets carry no fonts, so resolve() output
equals the old StyleDefinition span. Golden StyleRenderCharacterization and
ComposeTextStream tests stay green; Android + benchmark source sets updated.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The shared appearance editor needs a pure, testable core: a StyleEdit per
control, StyleLayer.applyEdit to fold an edit into the edited scope's own
layer (concrete color/background sets; boolean sets explicitly so an inherited
true can be overridden to false; Reset/null clears to inherit), SourcedStyle
.sourceOf(attribute) to drive the per-control "set here"/reset state, and
sampleStyle(stack) for the live preview. No Compose dependency; unit-tested
beside StyleResolveTest.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The desktop Appearance -> Presets page becomes a scope-aware master-detail:
a Global/character selector over a list of the base "default text" plus the
named presets (each row drawn in its own resolved style), drilling in to a new
shared DesktopTextStyleEditor.

- DesktopTextStyleEditor (desktop/components): one reusable Jewel editor for a
  single style at one scope. Per attribute it shows the control, a "set here"/
  "from Global"/"from skin" tag, and a reset enabled only when the value was set
  at the edit scope; a live sample renders the resolved cascade. Text color,
  tri-state background (fill + None), weight, italic, underline, and per-item
  font (family/size/weight, sharing the one weight field with the Weight row);
  optional entire-line/monospace rows for reuse by other sections. Edits fold
  through StyleLayer.applyEdit and are handed back as a whole layer to persist.
- core: styleEditorModel(characterLayer, globalLayer, skinLayer) assembles the
  cascade for one item (a null character layer = editing the global scope, which
  then omits any character overrides), plus StyleEditorModel. Unit-tested.
- PresetRepository: observeScopeLayers (one scope's own presets as layers) and
  saveLayer (persist a preset as a sparse StyleLayer).

The base "default text" item edits CharacterSettings' base style; presets edit
their own layer; monospace font (not a style layer) keeps its own row. Global
scope writes the all-characters layer. Mobile Presets still uses the old view.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mirror the desktop rebuild on Material3: a mobile TextStyleEditor (the M3 twin
of DesktopTextStyleEditor - same attribute rows, source tags, per-attribute
reset, tri-state background, per-item font, live sample) and a scope-aware
master-detail PresetsView (base "default text" + named presets each drawn in
their own resolved style, drilling in to the editor; monospace font kept as its
own row). Both platforms now edit the same StyleLayer scopes via styleEditorModel.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…sets

The page now covers the base text style, the monospace font, and the named
presets, so "Presets" was too narrow: rename the settings page to "Text styles"
(SettingsPage.TextStyles). Move the "Default text" and "Monospace font" rows
above a "Presets" heading on both platforms, so the base style and fonts read
as distinct from the named-preset list rather than sitting inside it.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the Names dialog's hex color text fields with the shared style editor
(color pickers + tri-state background + weight + italic + underline), which also
surfaces the bold/italic/underline that NameConfig already stored but the dialog
never exposed. Names carry no font, so the editor runs with showFont = false;
text + sound stay as-is. A commonMain StyleEditing helper maps NameConfig to/from
the editor's StyleLayer, shared by both platforms.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace each highlight group's hex color fields + bold/italic/underline/mono
checkboxes with the shared style editor (color pickers, tri-state background,
weight dropdown, italic/underline, monospace). Group styles are still stored as
StyleDefinition, projected to/from the editor's StyleLayer via the StyleEditing
helper. entire-line / match-partial / ignore-case / sound stay as-is. Drops the
mobile-local ColorTextField (now unused after Names + Highlights moved to pickers).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The per-window settings row now edits its style with the shared editor instead
of two bare color-picker buttons, so a window can override weight, italic, and
underline (not just colors). Those flags already existed on WindowStyleConfig
but were never surfaced; thread them through WindowSettings, observeWindowSettings,
setStyle, and getStyle so they persist and render (via uiState.style.mergeWith).

The editor gets a single-layer `sourced` (tags read "set here"/"default", since
a window has no cascade) but a two-layer `sample` (window over the character
default) for a realistic preview. Fonts stay as the separate normal/mono pickers
(showFont = false); name filter, revert, show/hide, and the window buckets are
unchanged. Per-window global scope is left for later.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ields

Base migration (the correctness piece): when a config loads, fold any lingering
presets["default"] into the character/global base style and drop it, so the base
text editor is authoritative and the orphaned preset is cleaned up. Base values
the user has already set win; the legacy preset only fills what the base leaves
unset. Runs per scope in CharacterConfigStore.load() alongside id generation, and
persists the rewritten presets/settings sections. Tested (fold + drop, persistence,
and base-wins).

Export: per-preset weight/fontFamily/fontSize and per-window bold/italic/underline
were editable but silently dropped on export. Add them to StyleExport /
WindowSettingsExport (all defaulted, so older export files still load and the JSON
stays forward/backward compatible) and wire the preset + window export/import
sites. Round-trip + legacy-load tests added.

Still not exported (pre-existing, separate task): the base style itself and the
default/monospace fonts live in character settings, which the export only carries
as typeahead + script prefix.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The base ("default text") style and the default/monospace fonts live in the
character settings, which the export never carried, so they were lost on
export/import. Add an optional BaseStyleExport block to CharacterExport
(textColor/backgroundColor/italic/underline + font + monoFont) populated on
export and applied on import.

Backward compatible: the block is nullable and defaults to null, so an export
written before it existed (no `baseStyle` key) still deserializes. Import treats
null as "no base in this file": REPLACE keeps the target character's base style
and fonts (the prior behavior), MERGE changes nothing. A present block wins
wholesale on REPLACE and per-set-attribute on MERGE. Round-trip + legacy-load
tests added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The preset list rendered each item's name in that item's own style on the
settings panel surface, which both lied about legibility (game text sits on the
window background, not the panel) and could make the row itself unreadable. Split
the two jobs:

- New shared StyleChip (commonMain): a fixed-width swatch that composites the
  resolved style over the effective *game window* background, never the panel.
  Two distinct boundaries - an outer panel hairline and, only when the background
  is explicitly set (Fill or None), a luminance-derived inset ring - so a
  background equal to the window color is still visible and unset vs None vs Fill
  stay distinguishable. Fixed sample text ("Aa" / a phrase), never the item name.
- Preset rows (both platforms): the name is now normal, always-legible UI text
  (never its own style); the chip carries the style; a muted trailing label shows
  the tri-state background (no bg / none / #hex). Chips resolve the item over the
  base so unset attributes inherit like in game.
- The shared editor's large preview now uses StyleChip over the real window
  background and shows a WCAG contrast warning with the actual ratio when text vs
  effective background drops below 3:1 - warn, never block.
- core: contrastRatio / relativeLuminance util next to resolve, unit-tested.

Decision (skin-ref vs literal, spec section 6): colors stay literal for now;
ColorValue/SkinRef would ripple through StyleLayer/ResolvedStyle/resolve/storage
and is deferred. Names/highlights/windows list swatches not yet on StyleChip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the panel-composited swatches in the Names and Highlights lists (both
platforms) with the shared StyleChip, so those rows preview honestly on the game
window background with the tri-state ring, matching the presets list. The item
name/pattern was already legible in these lists; only the swatch changes. They
have no per-scope base handy, so the chip composites against the default game
background (SAFE_DEFAULT_STYLE) - a fine v1 per the spec. Window list rows are
disclosure headers with no swatch; their editor already shows the chip.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Replace the editor's background control (a Fill color button + a None checkbox)
with a dedicated picker that presents the three states as explicit, selectable
rows: Inherit, None, Color. Inherit is annotated with what it falls back to
("inherits the window background" / "inherits none" / "inherits #hex") so it is
distinguishable from None, which looks identical in game but does not pick up a
later global background. Color opens the standard color picker. The row now shows
the current state's label (no bg / none / #hex) and opens the dialog.

The presets pages compute the true fallback (resolve of the layers below the
edited scope) and pass it; the flat editors (names/highlights/windows) default to
"inherits the window background", which is exactly what they do. New
DesktopBackgroundPickerDialog (Jewel) + BackgroundPickerDialog (M3) twin; the
inherit annotation helper is shared in commonMain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Foundation for palette colors that track the skin (spec section 6). ColorValue =
Literal (frozen) | SkinRef (resolves against a skin palette). StyleLayer carries
textColorRef/backgroundRef beside the resolved color, and resolveRefs(palette)
refreshes the colors from the current skin while keeping the ref, so the render
path keeps reading plain WarlockColor and stays untouched (golden tests green).
The reducer gains SetTextColorRef, and a literal color / reset clears the ref.
Unit-tested. Config storage, the compose resolve boundary, and the palette picker
UI follow in later commits.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Store the skin-palette slot beside the color in the config so a ref survives
save/reload: PresetStyleConfig gains textColorRef/backgroundColorRef and
CharacterSettingsConfig (base) gains defaultTextColorRef/defaultBackgroundColorRef,
threaded through the toStyleLayer / toPresetStyleConfig / base mappers. All
nullable with null defaults, so older files still parse and a literal-only preset
stays byte-stable. Round-trip tests added. Names/highlights/windows keep literal
colors for now. Compose resolve boundary + palette picker UI follow.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Wire skin-referenced text colors end to end for presets + base. A skin color
palette is derived from the skin's presets (name -> text color, name+Bg ->
background); resolveRefs is applied where the skin is known - WindowRegistryImpl
(so refs render and follow the skin) and the presets pages (so the editor/chip
show the resolved color while keeping the ref).

The text-color control now opens a palette picker: clicking a skin swatch stores
a SkinRef that tracks the skin; "Custom color" opens the standard picker and
stores a frozen literal. The row shows "tracks <slot>" when a ref is active.
DesktopColorRefPickerDialog (Jewel) + ColorRefPickerDialog (M3). Render path
stays WarlockColor-based; golden tests unchanged.

Background-fill refs and the names/highlights/windows sections keep literals for
now (the model + config already carry backgroundRef for a later pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Extend skin-referencing to the background fill. The background picker now shows
the skin palette below the Fill/None/Inherit rows: a palette swatch stores a
SetBackgroundRef (tracks the skin), while the custom color picker still stores a
frozen literal. The background row shows "tracks <slot>" when a ref is active.

resolveRefs now resolves a background ref to a Fill of the slot color regardless
of the cached background, so the ref survives a save/reload even when the stored
fill color was dropped to unspecified. Reducer gains SetBackgroundRef (a literal
fill / reset still clears the ref). Unit-tested. Both preset text and background
colors now track the skin end to end.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
NameConfig gains textColorRef/backgroundColorRef; the NameConfig<->StyleLayer
helpers carry them, so the Names editor's palette picker persists a skin ref. The
name render path in WindowRegistryImpl resolves the ref against the skin palette
(names render color only), and the Names editor + list chip resolve for display -
so a name's palette color follows the skin like presets do. Round-trip tested.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
WindowStyleConfig + WindowSettings gain textColorRef/backgroundColorRef, threaded
through observeWindowSettings. WindowSettings.toStyleLayer() carries the refs for
the shared editor; the windows editor resolves them for display and persists via
a new setStyleLayer (setStyle still clears refs, so Revert resets them).
getStyle(palette) resolves a ref against the skin, falling back to the last
resolved color when no palette is passed - so the render path is correct without
threading the palette (window colors follow the skin live in settings; at render
they refresh on save). Round-trip tested.

With this, all four Appearance sections (presets, names, highlights*, windows)
carry skin refs. (*highlights: their per-group styles still route through the
StyleDefinition-based Highlight model; left literal-only.)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Convert the Highlight model's per-group styles from Map<Int, StyleDefinition> to
Map<Int, StyleLayer>, so a highlight color can carry a skin ref. HighlightStyleConfig
gains textColorRef/backgroundColorRef; the config<->model mappers (and the legacy
DB migration + WSL/Wrayth importers) go through StyleLayer. The render path in
WindowRegistryImpl resolves each group's ref against the skin palette and flattens
to the dense StyleDefinition that ViewHighlight/applyStyleAtRange still consume, so
the hot path and its golden tests are unchanged. The highlight editor now stores
StyleLayers directly (dropping the toLayer/toStyleDefinition round-trips) and its
palette picker persists refs; the list chip resolves for display. Round-trip tested.

All four Appearance sections - presets, names, highlights, windows - now carry
skin-referenced colors that follow the skin.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the confirmed findings from the automated deep review on #232.
The dominant theme was that the new editor and resolve model taught
per-attribute cascade semantics that the persistence layer and several
render paths did not implement yet.

Cascade / persistence:
- Merge presets per attribute (new mergeLayers) across skin -> global ->
  character, instead of replacing a preset's layer wholesale per name.
- Make the tri-state style booleans nullable in the config schema so an
  explicit "off" round-trips instead of decaying to "inherit".
- Persist weight for windows, and wire NameConfig's weight/font fields,
  which were previously dead.
- Carry a color's skin ref through foldLegacyDefaultPreset, and fold at
  every config entry point rather than only on load().
- Clamp font weights to 1..1000 when decoding a section, so hand-edited
  TOML cannot crash composition or swallow stream lines.

Render paths:
- Resolve window skin refs at the registry boundary and thread the skin
  palette to GameViewModel's getStyle() call sites.
- Keep names and highlights sparse end to end so their weight, per-item
  font, and bold/italic/underline actually render.
- Consume italic/underline in the stream row text style.

Export / import:
- Carry skin refs (and weight/font for highlights and names) through
  StyleExport, BaseStyleExport, and WindowSettingsExport.
- Map a legacy presets["default"] into baseStyle when importing an export
  that predates the base-style field.
- Stop mergeBase keeping a stale target ref that outranked an imported
  color.

Editors:
- Drive the background picker's selection from the sparse edit layer, so
  Inherit can show as selected.
- Composite the highlights/names previews and contrast warnings against
  the real resolved base background instead of a hardcoded dark color.
- Compute the Windows page's default text from the real base cascade
  rather than the folded-away presets["default"].

Also fixes the iOS build: toSortedMap() is JVM-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sproctor
sproctor force-pushed the window-settings-in-appearance branch from 30eae82 to 747d0b8 Compare July 15, 2026 11:27
sproctor and others added 3 commits July 15, 2026 11:47
The chip drew two: an always-on outer hairline bounding it against the
settings panel, and an inset ring drawn only when the background was
explicitly set. The conditional ring made some rows read as double-bordered
and others not.

Keep the hairline, which is present in every state and does the actual
bounding. The explicit-vs-inherited distinction the ring encoded is already
reported textually by backgroundLabel() (no bg / none / hex), so no
information is lost.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Every style editor showed the same hardcoded speech line ("You hear
Lyrena say, ...") no matter what was being edited, so the preview said
nothing about the preset in front of you.

Give each preset the in-game line it actually applies to, restoring the
per-preset lines the pre-rewrite Appearance preview had. echo/error/link
had no line there, so those come from where the client emits them.

The style applies to the fragment it really marks, not the whole line:
bold marks the creature name, speech the "You say" verb. StyleSample
carries prefix/styled/suffix and the chip renders the surrounding text in
the base style. Backgrounds follow the same split - a whole-line style
fills the chip, a fragment style paints behind its fragment only, leaving
the line on the window background as it would be in game.

Windows, names, and highlights style arbitrary text, so they get a pangram
instead: it exercises every letter, which is what makes a font or weight
change legible in a one-line preview.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Three desktop Appearance/settings tweaks:

- The active nav page tinted with the neutral border color at low alpha,
  which barely showed. Use the accent (focused-outline) color instead so
  the selection reads clearly.
- SettingsListScaffold no longer repeats the page title; the settings-dialog
  nav already shows it. Dropped the param and its eight call-site arguments.
- The Presets page gains a "Base" heading over the base-text and monospace
  rows, matching the "Presets" heading below.

Also simplify the base-text preview to the generic pangram rather than its
own room-description line.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@sproctor
sproctor merged commit 9ddb65e into master Jul 17, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant