Skip to content

feat: KDE Plasma (Breeze) theme preset and native KDE theming - #17

Merged
jacquetc merged 60 commits into
mainfrom
feat/theme-kde
Oct 19, 2026
Merged

jacquetc merged 60 commits into
mainfrom
feat/theme-kde

Conversation

@jacquetc

Copy link
Copy Markdown
Collaborator

Adds a fourth sibling design-language preset, KDE Plasma Breeze (Plasma 6.8), and makes Native mode on a KDE desktop use it. Released as 0.20.0.

What it adds

  • teksilo-theme-kde (umbrella feature theme-kde, prelude kde): kde::light(), kde::dark(), light_with_accent / dark_with_accent (Plasma's accent rules), from_scheme (any KDE .colors scheme), from_desktop and native_theme. Breeze chrome for 18 widget styles, Breeze metrics for 14 recipe styles, KdePalette, PlasmaAccent, optional bundled-fonts (Noto Sans, Hack) and system-fonts.
  • teksilo-kde-scheme: a pure, I/O-free model of KDE's configuration format and colour schemes. The platform reader and the preset share it.
  • Native mode on KDE: reads the full KDE configuration cascade (including kdedefaults), the accent and the fonts, decides dark from the colours, and follows changes live through OsThemeWatch. TeksiloAppBuilder::native_theme(..) installs a factory, and native_theme_kde() installs the Breeze one.
  • Widget catalog: KDE Light and KDE Dark in the switcher, and --theme kde-light|kde-dark|system.
  • Framework: a PaintProjection hook, and rendered row, table, tab and scroll-thumb metrics on macOS and Fluent. In separate commits, fixes to the other presets: generic high contrast with an accent-aware lift, RTL and vertical sliders, and menu metrics that follow density.

Breaking

  • OsThemeColors is #[non_exhaustive] and has ten new fields. Build it with new().
  • MacOsMenuItemStyle and FluentMenuItemStyle are no longer unit values.
  • TabRecipe::tool_window_height and TAB_TOOL_WINDOW_HEIGHT are removed.

Behaviour changes

  • Rows, tables, tabs and scroll thumbs now render their declared heights on macOS and Fluent.
  • Native mode on KDE reads more of the desktop.
  • Generic high-contrast accent values have changed.
  • See CHANGELOG.md [Unreleased] for the full list.

Licensing

KDE's colour behaviour is reimplemented from a written specification (engineering/docs/kde-breeze-spec.md). No KDE code was translated, and clean_room_vocabulary guards the source. Three Breeze .colors files are vendored verbatim with their LGPL headers as test fixtures only, and are excluded from the published crate. See NOTICE. A legal review of the KDE section of NOTICE is recommended.

Verification

  • Colour maths, palettes and the accent application match reference data that Plasma 6.8 and KF 6.31 produce in a container (tools/kde_golden).
  • tools/reader/kde_visual_check.sh measures the running catalog in a private session against Plasma's size hints. Every control is within 1 px of its intended height. A scheme or accent change repaints without a rebuild.
  • skribisto builds unchanged against the branch. skribisto-pro and teksilo-designer give the same errors on dev and on this branch.
  • Known gaps that need new public API are listed in the crate's "Known limitations".

EmbeddedInterRegistrar::new(), EmbeddedInterRegistrar::with_size() and
TypesetterBridge::register_default_font each wrote their own include_bytes!
for Inter, Inter Italic, JetBrains Mono and JetBrains Mono Italic. Every
include_bytes! is its own allocation and only LTO merges identical ones, so
a build without LTO carried those faces three times: rich-text-editor built
with lto = false went from 58.26 MB to 53.00 MB, and Skribisto's debug binary
holds two copies today. Release builds with fat LTO are unchanged.

Each face is now one static in embedded_fonts.rs that every site names.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Add a prose specification of the KDE colour-scheme model (KConfig
grammar and cascade, the colour sets and roles, the colour maths, state
effects, accent application) and of the Breeze 6.8 widget metrics,
painting rules, typography and motion, with a ledger tracing every value
to a pinned upstream file and line.

Add tools/kde_golden: a Docker image of Plasma 6.8.0 / Frameworks 6.31.0
and a driver over the public KF6 and Qt API that regenerates reference
vectors for the colour maths, scheme resolution, accent application,
control sizes and painted pixels. It does not run in CI.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
ComponentStyleSlots::NAMES, the Debug impl and the PartialEq impl now
expand the same for_every_slot! roster as installed() and
unchanged_against(). Debug and PartialEq destructure Self without a rest
pattern, so a slot added to the struct and left out of the roster fails
to compile instead of being silently skipped by a hand-written list.
Their output is unchanged: Debug prints whether each slot is filled, in
declaration order, and equality compares each slot's Rc by pointer.

The macOS, Fluent and Material 3 slot tests become set equality: the
installed slots and a <PRESET>_UNFILLED list, which gives a reason for
every slot the preset leaves to the widget's default, must together be
NAMES with nothing in both. Core tests check NAMES for duplicates and
against a fully populated value built from a struct literal.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Add PaintState { window_active, high_contrast } and the PaintProjection
theme extension (fn(&Theme, PaintState) -> Theme), following the
DensityProjection pattern. Theme::for_paint_state consults the hook and
otherwise performs the existing generic chain, inactive projection then
high contrast, so a theme without a hook paints exactly as before.
Theme::paint_colors is the colours-only form for colours resolved on
every paint.

The paint walker now asks for_paint_state. The two production bypasses
that baked ColorTokens at build time, window_resolution_colors and the
gradient fill in RecipeButtonStyle, resolve through paint_colors with
the real high-contrast flag: WidgetTree keeps the preference in a
Signal<bool>, exposed as high_contrast_signal(), and BuildContext gains
prefers_high_contrast(), high_contrast_signal() and paint_state(). A
Filled button's fill and a button's focus border therefore take the
high-contrast palette, as the paint-resolved accent controls did.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A theme can now draw its focus ring partly or wholly inside the control
(KDE Breeze uses offset -1 with width 2). Every consumer of
ShapeTokens::focus_ring_offset goes through one geometry:

- ShapeTokens::focus_ring_envelope() is offset + width floored at zero,
  so the space a control reserves outside itself never goes negative.
- ShapeTokens::focus_ring_stroke_distance() is the signed distance to the
  stroke's centre line.
- teksilo_core::styles::FocusRingStroke grows or shrinks the control rect
  around its centre and floors size and radius at zero, so a deep negative
  offset collapses the ring instead of inverting it.

Envelope-model widgets (segmented control, breadcrumb, radio tile group)
now place the ring around their visual rather than relative to their
bounds, which only agreed with the visual while the envelope was the
unclamped offset + width. The tab ring stays inside the tab: its inset is
floored at half the stroke so a negative offset cannot spill into the
neighbouring tab, and every offset at or below zero draws the same ring,
flush with the tab edge. The token doc and the changelog say so.

The breadcrumb painter asked is_interactive() after build had moved the
action out, so a built crumb never painted its hover and focus state, its
ring or its accent colour, and never advertised Click or Focus to
assistive technology; paint and accessibility now read the recorded
navigates flag.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
StandardListItem and StandardTreeItem floored their height on the
STANDARD_ITEM_MIN_HEIGHT_* constants through dp(), whatever the active
style declared, so the macOS (24) and Fluent (40) row heights were
written on the recipe and never rendered.

StandardItemStyle gains two defaulted metrics accessors,
min_height_single_line and min_height_two_line, taking the density's
InputTokens. The defaults restate today's ladder (dp(28) and dp(44) as
targets) and are pinned to the module constants by
the_standard_item_trait_defaults_restate_the_module_constants.
RecipeStandardItemStyle answers from its recipe, and the macOS and
Fluent structural styles answer from their own recipe at the given
density. No field is added to StandardItemStyleConfig, which
downstream builds as a struct literal.

The row parks the style it resolved in build() and asks it in
layout_response, so its floor and its chrome come from one style. The
notification log's Shrinkable floor (one two-line row) asks the same
resolved style. ListView and TreeView need no change: auto-measured
rows report the new floor through the row's own layout_response, and a
uniform view keeps the item_height the app chose; neither read the
StandardItem constants. A view without auto_item_height also keeps its
own default row height (ListView 32, TreeView 28, both raised to the
density's target size), so a default-height TreeView under macOS stays
at 28. Following the style there is a separate follow-up.

Tests: macOS and Fluent preset_metrics lay out a single-line and a
two-line StandardListItem at every density and assert the preset's
heights (macOS 24/32/44 and 40/40/44, Fluent 40/40/44 and 60), with
IntUI as the unchanged control, plus the installed slot's accessors.
The macOS file also lays out a StandardTreeItem pair (depth 1 and 2) and
asserts the same heights, which pins the tree row's delegation to the
list row it wraps. The notification log gains
the_compressed_list_floors_at_the_styles_two_line_row_height: a style
declaring 90 dp two-line rows floors the compressed list at 90 (it
measured 44 with the old constant).
density_projection pins the trait defaults and checks the recipe style
answers from its own recipe.

density-projection-gaps.md loses the two StandardItemRecipe rows from
section 2: unread 51 -> 49, real gap 49 -> 47, section 2 fourteen ->
twelve; the projected total (103) the guard pins is unchanged.

Conformance census re-run: totals unchanged (481; per theme and density
identical), and no ALLOW_LIST entry moved. A row at 24 dp or more is at
or above the conformance floor, so no row enters the census.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
TableView and TreeTableView laid out their rows from
recipe_table_style::ROW_HEIGHT (28) and their header from HEADER_HEIGHT
(32) whatever the active TableStyle declared, at every density, so the
macOS (24) and Fluent (40) table heights were written on the recipe and
never rendered, and no theme's table followed the density ladder.

TableStyle gains two defaulted metrics accessors, row_height and
header_height, taking the density's InputTokens. The defaults are the
module constants put through dp() as targets, which is exactly what the
shipped RecipeTableStyle answers at every density and exactly today's
constants at Compact; the_table_trait_defaults_restate_the_module_constants
pins both. RecipeTableStyle answers from its recipe. No config struct
gains a field.

Both views read the style's heights first thing in build()
(adopt_style_heights): an app's row_height(..) / header_height(..) still
wins, and a uniform view with no row height of its own re-makes its
RowMetrics when the style's row height differs, so the virtualization
offsets, the visible range and the scroll step all use it. Exact and
auto-measured modes are unchanged. A standalone TableHeader given a free
height takes the style's header height too.

The macOS and Fluent table styles now raise their 24 and 40 dp heights
to the density's target size, as the list rows they share a rhythm with
already do (macOS 24/32/44, Fluent 40/40/44). IntUI is unchanged at
Compact and follows its recipe's ladder above it (rows 28/32/44, header
32/32/44).

Tests: macOS and Fluent preset_metrics mount a TableView and a
TreeTableView at every density and assert the laid-out header band, the
first row's offset under it, the row height and the row pitch, with
IntUI as the control, plus the installed slot's accessors; the preset
unit tests check the table row and the list row agree at every density.
table_view and tree_table_view tests cover a style declaring 37/41 dp,
the view's own heights winning over it, the IntUI ladder, and a
standalone header taking its style's height. density_projection pins
the defaults and checks the recipe style answers from its own recipe.

density-projection-gaps.md loses the two TableRecipe rows from section
2: unread 49 -> 47, real gap 47 -> 45, section 2 twelve -> ten.

Conformance census re-run: 481 -> 477. The tree table's chevron reaches
22 x 32 at Comfortable (it reached 22 x 28 beside 28 dp rows) and the pin
moves with it; at Compact under Fluent it now clears the floor beside
40 dp rows; four Comfortable rows go. One new row: under macOS at
Compact the fixture's twelve 24 dp rows overflow the viewport by 12 dp,
leaving an 11.5 dp scroll-bar track region below the thumb; it gets an
allow-list entry on the Equivalent exception (thumb drag, wheel,
PageDown), so the list counts 15 entries, 6 on an exception. The seed
floor becomes 7 * 114 (Fluent is the smallest preset). The outset test's
table half builds its own fixture with an app's 28 dp rows and 32 dp
header: with style-driven 44 dp Touch rows the shared fixture reached
32 dp with and without won_through_outset, so it no longer
discriminated; the new fixture still measures 32 with and 18 without.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
TabBar laid out its strip from recipe_tab_style::TAB_EDITOR_HEIGHT (50)
and every TabHeader padded its content by TAB_PADDING_HORIZONTAL (12)
whatever the active TabStyle declared, at every density, so the macOS
(26 dp, 12 dp) and Fluent (32 dp, 8 dp) tab metrics were written on the
recipe and never rendered.

TabStyle gains two defaulted metrics accessors, editor_height and
padding_horizontal, taking the density's InputTokens. The defaults are
the module constants put through dp() as a target and spacing(), which
is exactly what the shipped RecipeTabStyle answers at every density and
exactly today's constants at Compact;
the_tab_trait_defaults_restate_the_module_constants pins both.
RecipeTabStyle answers from its recipe. No config struct gains a field.

The bar resolves its style once at the top of build() (per-call override,
then theme slot, then the shipped recipe, through one resolve_tab_style
helper) and uses it for the scroll viewport's height, the header row's
per-tab extent and the bar chrome. Each header resolves the same style,
parks it, and asks it for its height and its padding in layout_response
and for its padding in build(), so a header's natural width and the
Padding it builds agree. An app's tab_bar_height(..) still wins. TabWidget
reaches the style through its bar.

The Fluent tab height is raised to the density's target size, as the
macOS one already was (Fluent 32/32/44, macOS 26/32/44). Int UI keeps
its 50 dp tabs at every density; its header padding now follows its
recipe's spacing ladder (12/13.8/15.6), so Int UI tabs are wider above
Compact.

TabRecipe::tool_window_height and TAB_TOOL_WINDOW_HEIGHT had no reader
anywhere and are deleted; the macOS and Fluent factories stop setting
the field, and the macOS unit tests that compared it now compare the tab
height. No downstream repository names either.

Tests: macOS and Fluent preset_metrics mount a two-tab TabBar at every
density and assert the strip height, the tab height and the header
padding (the content row's inset inside its Padding, on both edges),
with Int UI as the control, plus the installed slot's accessors.
tab_widget tests cover a style declaring 37 dp tabs and 30 dp padding
reaching a horizontal bar and a vertical bar's pills, tab_bar_height
winning over the style, a TabWidget taking the theme slot's height, and
the Int UI padding ladder. density_projection pins the defaults and
checks the recipe style answers from its own recipe.

density-projection-gaps.md loses the three TabRecipe rows from section 2:
projected 103 -> 102, unread 47 -> 44, real gap 45 -> 42, section 2
ten -> seven. density-inventory.md marks the TAB_TOOL_WINDOW_HEIGHT row
deleted in place. The tab_widget and bar catalog pages are regenerated.

Conformance census re-run: totals unchanged (477; per theme and density
identical), and no ALLOW_LIST entry moved. Every fixture tab is at or
above the floor (macOS 26 at Compact, Fluent 32); the docking fixture's
38 dp compact strip is unchanged, its headers only change width.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
ScrollBar::build resolved its style and then recomputed the thumb floor
from scroll_bar::SCROLLBAR_MIN_THUMB_LENGTH (24) put through dp(),
whatever the active ScrollBarStyle declared, so the macOS (28 dp) and
Fluent (30 dp, WinUI's ScrollBarHorizontalThumbMinWidth) floors were
written on the recipe and never rendered.

ScrollBarStyle gains one defaulted metrics accessor, min_thumb_length,
taking the density's InputTokens. The default is the module constant put
through dp() as a target, which is exactly what the shipped
RecipeScrollBarStyle answers at every density and exactly today's 24 dp
at Compact; the_scroll_bar_trait_default_restates_the_module_constant
pins it, and also pins the recipe module's constant equal to the widget
module's (the floor a bar uses before its first build).
RecipeScrollBarStyle answers from its recipe. No config struct gains a
field: ScrollBarStyleConfig::min_thumb_length already carried the
resolved floor to make_body.

The bar now asks the style it resolved for the floor (an app's
min_thumb_length(..) still wins) and stores it where its geometry
helpers and event closures read it, so the painted thumb, the hit test,
the drag and the reported target region all use the style's number.

The macOS and Fluent floors were already raised to the density's target
size, so only Compact moves: macOS 24 -> 28, Fluent 24 -> 30; both are
32 at Comfortable and 44 at Touch, as Int UI is.

Tests: macOS and Fluent preset_metrics mount a long document's bar
(viewport ratio 0.02) at every density and assert the reported thumb
length (macOS 28/32/44, Fluent 30/32/44), with Int UI (24/32/44) as the
control, plus the installed slot's accessor. scroll_bar touch tests
cover a style declaring a 37 dp floor reaching both the config and the
mounted thumb at every density, per call and from the theme slot, an
explicit floor winning over the style, and a style floor longer than the
track clamped to it. density_projection pins the default and checks the
recipe style answers from its own recipe.

density-projection-gaps.md loses the ScrollBarRecipe row from section 2:
unread 44 -> 43, real gap 42 -> 41, section 2 seven -> six; the
projected total (102) the guard pins is unchanged. docs/scroll-area.md
describes the accessor; the scroll_bar catalog page is regenerated.

Conformance census re-run: totals unchanged (477; output identical to
the run with the old floor), and no ALLOW_LIST entry moved. No fixture
bar's thumb sits at its floor at Compact, and the one scroll-bar row
(the macOS table track remainder, 11.5 dp) is unaffected.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
StandardItemStyle::selection_emphasis() returns a SelectionEmphasis:
ViewFocusedAndWindowActive (the default, the rule every row followed
before) or WindowActive, which mutes a selected row only when its
window goes inactive, as Qt's per-window colour groups do. The row
composes the emphasis signal for its label, subtitle and chevron from
the hook, and the shipped recipe and the macOS style build their chrome
condition through SelectionEmphasis::emphasised, so fill and text agree.

MenuItemStyle::pressed_label_role() gives the role a menu row's label
and shortcut take while pressed. It wins over highlighted_label_role on
a pressed row and defaults to None, so existing styles are unchanged.

Both hooks are defaulted trait methods; no config struct gains a field.
Documented on the traits, in docs/window-activation.md and in the
agent reference.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
OsThemeColors gains ten optional colours a desktop can report (view
background and text, alternate row, inactive text, links, error, warning,
success and focus), a `new(scheme)` constructor and PartialEq, and is now
non_exhaustive so a reader can learn a new colour without breaking
anyone. ColorTokens::from_os_colors maps each one when present and lifts
a supplied foreground to WCAG's floor (4.5:1 text, 3:1 focus and status)
on the surfaces it sits on; a report without them gives the same tokens.

The lift and its measurement live in the new teksilo_tokens::contrast
module (composite_contrast, lift_until_readable). MotionTokens::scaled
applies a desktop animation-speed factor to every animation duration but
leaves the tooltip dwell delays alone.

The three platform constructions move to OsThemeColors::new, and the GTK
path fills the new fields from the CSS names it already parses
(theme_base_color, theme_text_color, link_color, error_color, ...).
GTK has no alternate-row or secondary-text name, so those stay None
there; the KDE reader is left as it was, since K5 replaces it.

BREAKING CHANGE: OsThemeColors is #[non_exhaustive]; build it with
OsThemeColors::new or Default outside teksilo-tokens.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New crate teksilo-kde-scheme, MPL-2.0, depending on teksilo-tokens and
thiserror only. It does no I/O: the environment comes in as a getter and
files as text. Everything is implemented from the prose of
engineering/docs/kde-breeze-spec.md (sections a, b, c and j).

config: KConfig::parse never fails and reports ParseDiagnostics. It
covers nested groups, [$i] on a key, a group or a whole file, [$d], [$e]
(expanded on string reads through an injected ExpansionEnv; $(cmd) reads
back as (cmd) and nothing is executed), ignored locale keys, escapes,
comments, CRLF, a byte order mark kept in the first line and reported,
lossy UTF-8, the 1.5 MiB line and 100-error limits, and the last
duplicate winning unless an earlier one was immutable. KConfig::cascade
merges layers lowest priority first with the immutable-entry,
immutable-group (default and user layer) and file-immutable rules.

locations: KdeConfigLocations::from_env resolves the XDG homes and
directory lists with their defaults, prepends kdedefaults in a KDE
session, and lists the kdeglobals and breezerc cascades, data-file
candidates and the b.8 scheme-file fallback. No KDE_COLOR_SCHEME_PATH
variable is read. is_kde_session follows b.10.

color: KdeColor keeps 16-bit channels and converts to Color at the
boundary; it parses r,g,b[,a], the 3, 6, 8, 9 and 12 digit hex forms and
the SVG 1.1 keywords, with empty, invalid and malformed values absent.

scheme: ColorSet, ColorState, ColorRole, SetColors, StateEffect, WmColors
and KdeColorScheme, with the c.3 defaults, the per-key Header fallback to
Window, [Inactive] subgroups replacing their set, contrast 7 and
frameContrast 0.2 clamped. breeze_light, breeze_dark, breeze_classic and
kcolorscheme_defaults are Rust constants; to_kconfig_text round-trips
through parse. A parsed scheme keeps its source configuration, outside
equality, for operations that need the text as written.

fonts: KdeFont::parse_qfont reads the 2, 10/11 and 16/17 field forms,
cutting longer strings to 17 fields and mapping legacy weights.
desktop: KdeDesktop gathers the scheme and its origin, the user accent,
five fonts, AnimationDurationFactor, widgetStyle and the breezerc style
settings from already-read layers. forceFontDPI is not read.

The three breeze v6.8.0 .colors files are vendored verbatim as test
fixtures (sha256 as in spec o.11) with the LGPL-2.0 text beside them.
They and tests/breeze_files.rs, the one test that reads them, are
excluded from the package, so the published tests still compile. NOTICE
gains section 1.5, and TRADEMARKS.md lists KDE, Plasma and Breeze.

Tests: grammar and malformed-input fixtures, cascade and location
fixtures (this machine's kdedefaults layout, Kiosk immutability, an
injected environment), scheme defaults, Header fallback, [Inactive]
replacement, constants equal to the fixtures, Qt 5 and Qt 6 font
strings, legacy weights at the ends of the i32 range,
AnimationDurationFactor absent, 0 and 2, and three proptests (parse
never panics; every reader survives and the scheme text round trips;
font strings with extreme numeric fields never panic).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New module math, implemented from section d (and the a.3 conventions)
of engineering/docs/kde-breeze-spec.md, in f64 with the specified
single-precision steps and 16-bit storage: the HCY space (gamma 2.2,
Rec. 709 weights) with an unwrapped forward hue and a wrapping inverse,
luma and KDE's luma contrast ratio (kept apart from WCAG's), the
premultiplied mix, lighten, darken and shade, the luma-preserving tint
with its twelve bisection steps, the five shade roles, Qt's HSV lighter
and darker on 16-bit integers, the 8-bit alpha blend with its
truncation and translucent-background alpha, black or white text on a
background, an accent laid over a background, and the OKLab tint with
Qt's single-precision sRGB curve, its near-identity matrix and the
half-precision storage of out-of-gamut results (FloatColor).

lift_luma_until is Teksilo's own helper, not KDE's: it keeps a colour's
HCY hue, chroma and alpha and steps its luma toward black or white until
the WCAG contrast from teksilo_tokens::contrast reaches a floor on every
backdrop, returning the first passing step or the best one reachable.

Tests: every record of tools/kde_golden/vectors/math.json except
overlay (a painter composition the specification does not describe),
reals within 1e-6 and colours equal in all 16-bit channels and the hex;
the OKLab tint against every tinted colour of the KdeGoldenTintLight
accent cases; the specification's worked examples and the ten swatch
selections; unit tests for the lift; and two proptests (the HCY round
trip on every 8-bit colour, and the lift never lowering the worst
contrast). Where d.10 fixes the chroma amount at 0, shade_role takes it
as a parameter applied to every shade, which math.json's scheme_shade
records with chroma 0.5 confirm.

tests/clean_room_vocabulary.rs fails, naming file and line, on upstream
identifiers in an extensible list of scanned directories, skipping the
fixtures, its own word list and comment lines citing doc.qt.io. It also
fails when a scanned directory holds no file, when math.rs or the test
itself is not among the files read, or when a file cannot be read. It and
golden_math.rs read files outside the crate and are excluded from the
package. .claude/CLAUDE.review.md gains the matching checklist item.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New modules effects and palette, implemented from sections c.2, c.5, e,
f and i of engineering/docs/kde-breeze-spec.md, on top of the scheme
model and math.

effects: background_chain (intensity, then colour) and foreground_chain
(contrast toward the set's background before any effect, then the
background chain), apply_state_effect over a set's twelve colours, and
tint_toward_selection. A disabled effect returns its inputs untouched, so
nothing is re-quantised. KdeColorScheme::state_colors picks each (state,
set)'s source: the stored set for Active; the [Inactive] subgroup or the
set, then the Inactive effects; with ChangeSelectionColor, the Window
set (its [Inactive] subgroup when present) with both backgrounds tinted
0.4 toward the stored Selection background; and for Disabled the set,
the Selection set being the Window set without a tint.

palette: ResolvedSet holds 8 foregrounds, 8 backgrounds (the six
computed ones tinted 0.3 toward their foreground), 2 decorations and 5
shades of BackgroundNormal at the contrast value (contrast x 0.1).
ResolvedPalette (PartialEq) holds 3 states by 7 sets and reads the
twenty PaletteRoles, the tooltip roles always from the Active state.
KdeColorScheme gains contrast_value, resolve, is_dark (the 8-bit grey
(11 r + 16 g + 5 b) / 32 of the window background below 192) and
os_theme_colors (accent and selection from Selection BackgroundNormal,
focus from View DecorationFocus, the view, link and status colours from
the View set, the window, button and tooltip colours from theirs, the
scheme preference from is_dark).

Tests: tests/golden_resolve.rs reads every scheme of
tools/kde_golden/vectors/resolve.json from its embedded input, resolves
it and compares every state, set and role, and every palette role, at 8
bits (7041 colours), failing on an empty list. The one documented
divergence (b.9: an empty or unknown colour is the key's default, where
KDE keeps an invalid colour) is pinned to its two occurrences, checked to
give the default, and only what derives from it is skipped. It also
checks the recorded contrast values, the stock constants against their
vector inputs, is_dark on all thirteen schemes, and a Twilight-like
scheme composed from the Breeze vectors (Breeze Dark's window over
Breeze Light's views) being dark. tests/palette.rs checks the Disabled
chain, the inactive selection tint and its subgroup and switch, the
computed backgrounds, the shades, is_dark at the threshold, and
os_theme_colors on Breeze Light and Dark against the specification's
worked examples. The golden test reads files outside the crate and is
excluded from the package.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New module accent, implemented from sections g and h and answer n.13 of
engineering/docs/kde-breeze-spec.md, on top of the scheme model and math.

KdeColorScheme::with_accent builds the configuration Plasma writes for a
scheme and an accent and reads the result from it, so the accented
scheme's source() is that configuration. The seven colour groups are
processed in the specified order: ForegroundActive and both decorations
take the accent, ForegroundLink the accent made readable against the
group's own background (readable_link, ratio 5, one lighten or darken
step); every other key is tinted in OKLab when the scheme has a
TintFactor and copied as text otherwise. The Selection backgrounds become
the accent at 0.7 over the View background with black or white text, the
Button alternate background the accent at 0.7 over the Button
background. [Inactive] subgroups are tinted or copied, the Header one
from the Window group, and an accent title bar recolours the Header
group. [WM] is the scheme's own or its resolved Header colours, then the
tinted or accented window background; [KDE] contrast and frameContrast
are written as read or with 7 and 0.2; every [ColorEffects:*] key is
copied, empty when absent. ColorSchemeHash is not written; Name and
ColorScheme are copied so the result keeps its identity.

Decisions where the specification is silent: a colour the procedure
reads from the scheme (a background for the link, the selection, the
button or the title bar) is black when the scheme lacks it, as g.4 says
for the link; a written colour uses the b.9 form (r,g,b, or r,g,b,a when
translucent). A built-in scheme, which has no source, is accented as the
configuration its to_kconfig_text writes; to_kconfig_text now leaves out
[ColorEffects:Disabled] Enable when it is true, as the stock files do,
so accenting breeze_light or breeze_dark is exactly accenting its
vendored file. Applying an accent twice is not idempotent, since the
second pass reads the accented scheme; this is documented and tested.

ACCENT_SWATCHES holds the ten swatches of section h in order. KdeColor
gains From<Color>, so with_accent takes either colour type. KdeDesktop
applies a user AccentColor that is not transparent to the scheme file
(or built-in Breeze Light) it reads when kdeglobals has no [Colors:View]
group (n.13), the accented configuration taking the file's place over
kdeglobals.

Tests: tests/golden_accent.rs reproduces all 40 cases of
tools/kde_golden/vectors/accent.json key by key in both directions,
[General] aside, failing on an empty case list; checks the cases cover
every swatch on every scheme and that the vector inputs of the stock
schemes are the vendored files; and checks breeze_light and breeze_dark
accented equal their stock files accented, configuration included.
tests/accent.rs covers the g.10 worked examples, the ten swatches
through the selection rule of g.11, non-idempotence, a transparent
accent, text copies, untinted absent keys, Breeze Classic's title bar
without a Header group, [WM] defaults and tinting, [KDE] and the effect
groups. tests/cascade.rs covers the accent in the no-colours fallback.
The golden test reads files outside the crate and is excluded from the
package.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New module linux_portal reads org.freedesktop.portal.Settings on
org.freedesktop.portal.Desktop /org/freedesktop/portal/desktop with
ReadOne, and falls back to the deprecated Read, whose reply wraps the
value in one more variant, when the portal answers UnknownMethod. It
decodes a u32 (color-scheme, contrast, reduced-motion) and the (ddd)
accent-color tuple, which is rejected when a component is outside
0..=1 or NaN, as the portal specification says. A boolean property is
read with org.freedesktop.DBus.Properties.Get, which the AT-SPI
ScreenReaderEnabled read now uses on the same bus name, path,
interface and property.

The reads are blocking and answer None on any failure. One session
connection per process is made on the first read and reused; a
connection that fails at the socket level is dropped and made again on
the next read. Connecting (socket, authentication, Hello) and every
reply are bounded by 500 ms: a running portal answers in milliseconds
and a cold D-Bus activation usually within a few hundred, a missed
value falls back to the desktop's configuration, and busctl waited up
to sd-bus's 25 s default. A destination that let a read time out, or a
bus that could not be connected to, is left alone for 5 s, so the
burst of startup reads pays a hung portal once rather than once per
setting, while a later re-query tries again.

os_theme and accessibility_prefs call the new reads. read_portal_u32,
read_portal_rgb, read_dbus_bool_property, parse_busctl_bool and their
tests are deleted from linux_helpers, which no longer starts busctl;
read_gsettings stays a subprocess. The module has the cfg of
linux_helpers (target_os = "linux"), whose callers are Linux-only.

zbus keeps its one feature, async-io (its executor and reactor);
blocking-api is not enabled since the reads drive the async API with
async_io::block_on. async-io 2.6 becomes a direct dependency for the
timer that bounds connecting, which zbus's method timeout does not
cover. Both are the versions already in Cargo.lock, so no crate is
added and zbus stays a single version.

Tests: pure decode tests (u32, the nested Read reply, accent tuples in
and out of range, wrong types and shapes, booleans) and the quiet
period. A real-bus test, run only with TEKSILO_DBUS_TESTS=1 and
otherwise skipped with a message, starts a private dbus-daemon on a
configuration that activates no service, serves a fake portal and
AT-SPI status object with zbus, and checks the reads, error and
wrong-type replies, a timeout and the quiet period after it, the
ReadOne-to-Read fallback on a portal with only Read, and an absent
bus. The bus address is passed to the reader explicitly. Checked with
cargo check on x86_64-pc-windows-gnu and aarch64-apple-darwin.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New module os_theme/kde (Linux) reads a KDE Plasma desktop through the
shared model of teksilo-kde-scheme, implemented from sections b, i and j
and answer n.13 of engineering/docs/kde-breeze-spec.md. discover_kde
reads every readable file of the kdeglobals and breezerc cascades and the
first readable scheme-file candidate, all through an injected read
closure, and hands them to KdeDesktop::from_layers, which already
chooses the colours (the [Colors:*] groups when kdeglobals has a
[Colors:View] group, else the look-and-feel colours, the scheme named by
[General] ColorScheme or Breeze Light, with a valid, non-transparent
[General] AccentColor applied) and reads the fonts,
AnimationDurationFactor, the widget style and breezerc. It answers None
when no file of the kdeglobals cascade can be read. kde_prefers_dark
reads only the kdeglobals cascade and the scheme file and answers
KdeColorScheme::is_dark; kde_prefers_reduced_motion reads only the
kdeglobals cascade. forceFontDPI is not read (j.3). Both readers also
take an environment getter, which [$e] entries expand in, so nothing
reads the process environment but the two helpers the real queries pass
in.

os_theme gains query_native_desktop and NativeDesktop { os, kde }
(non_exhaustive, Debug, Clone, PartialEq, new). On a KDE session the
colour report is the discovered scheme's os_theme_colors, accent and
light or dark preference included; the portal's preference and accent
are used only when no kdeglobals can be read. Elsewhere the report is
unchanged. query_os_theme_colors returns its os field.
query_color_scheme keeps the portal first; its KDE fallback asks
kde_prefers_dark instead of looking for "dark" in the scheme name.
query_kde, ini_value, ini_color, dirs_kdeglobals and their test are
deleted; the colours that test read are pinned by a discover_kde test.

detect_desktop recognises a KDE session first, by
teksilo_kde_scheme::is_kde_session, which now compares the
XDG_CURRENT_DESKTOP names ignoring ASCII case, as the platform's
detection always did; spec b.10 says so. A desktop name that only
contains KDE (X-KDE-Other) is no longer KDE.

accessibility_prefs: reduced motion is on when any source asks for it,
read in order and each only while no earlier one asked: the portal's
reduced-motion of 1, a KDE session's AnimationDurationFactor of exactly
0, GNOME's enable-animations of false. That is how the portal and
gsettings sources were already combined. Text scale is unchanged.

teksilo-platform depends on teksilo-kde-scheme on every target, since
the crate is pure, and re-exports nothing of it; NativeDesktop carries
its KdeDesktop.

Tests over in-memory fixture trees and environments, never the real
home or process environment: a full kdeglobals (colours, fonts, factor,
widget style, breezerc, the stored accent not applied again); the window
colours of the deleted test; this machine's layout (ColorScheme only in
kdedefaults and /etc/xdg), outside a KDE session and with a listed
XDG_CONFIG_DIRS; the data home before the data dirs; look-and-feel
colours before the named scheme; built-in Breeze Light; an immutable
group in /etc/xdg; AccentColor valid (scheme file and built-in, decimal
and hex), transparent, invalid and absent; kdeglobals over the portal
accent; kde_prefers_dark on light, dark, a light scheme named dark and a
Twilight-like scheme; reduced motion from factor 0, 1, 0.25 and absent
and the combination of the three sources; the KDE session predicate.
Checked with cargo check and clippy on x86_64-pc-windows-gnu and
aarch64-apple-darwin.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
ThemeMode::Native built its theme in three places, each laying
ColorTokens::from_os_colors over IntUI: the window manager's
apply_os_theme and its window-creation path, and the widget previewer's
Native canvas theme. They now share one resolution, and every caller
produces the theme it produced before for every desktop report.

presets::intui::from_os(os, dark) is the IntUI light or dark preset
with the colours of the report. New module teksilo-app native_theme:
NativeThemeRequest (non_exhaustive, with new) carries the NativeDesktop,
whether the theme is to be dark and the density wanted;
NativeThemeFactory is an app's own answer to one; resolve_native_theme
asks the factory and falls back to presets::intui::from_os when there
is none or it answers None. It sets no id and applies no density, which
the callers keep doing as before. TeksiloAppBuilder::native_theme stores
a factory, which the window manager owns. The three types and the
function are re-exported from teksilo-app's root and the umbrella
prelude.

The callers read query_native_desktop and keep their own rules. On an
appearance change a desktop stating a light or dark preference decides
dark; one stating none is answered from the winit hint (light without
one) with a report of that preference alone, so its colours are still
not used. A new window is dark exactly when the desktop states dark,
and still ignores the hint. Both are stamped "system" and asked at
Compact, the density every preset carries and these themes always had.
The previewer passes no factory and asks at the density it applies
afterwards; its unused CanvasTheme::theme is folded into a private
resolve.

Tests keep the three old computations as reference functions and
compare them, by Theme's PartialEq plus colours, input tokens and id,
with the new paths over Light, Dark and NoPreference reports, bare,
accent only, with the window, button, selection and tooltip colours,
and with the view and semantic colours too, for no hint and both hint
values, and in the previewer at every density. A factory answering Some
wins (the window manager still stamps "system"), one answering None
falls back to the same themes, and a desktop with no preference is
asked for with the hint's preference. intui::from_os keeps the preset
and takes the report's colours.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Every theme the window manager made under ThemeMode::Native or
ThemeMode::FollowSystem was at the Compact density the presets carry,
so an app at the comfortable or touch density had its input tokens
reset when a window opened, on an OS light or dark change and on
follow_system_theme, the last two without a rebuild. A window opening
in Native mode on a desktop stating no preference was always light,
while an appearance change followed the winit hint. And every re-read
set the theme on every window, changed or not.

native_theme now carries the one resolution all of these paths use.
OsThemeSource is the mode's reading of the OS: FollowSystem with dark,
or Native with the desktop as read and dark, which a desktop stating a
light or dark preference decides and one stating none takes from the
hint (light without one). OsThemeInputs adds the density. resolve_os_theme
gives the IntUI preset, or resolve_native_theme's theme asked at that
density, projected with Theme::with_density and stamped "system". A
desktop stating no preference is asked for with its report stating the
hint's preference and keeping its colours: on macOS and Windows the
report has none, so the themes are those of before, and on a Linux
desktop with accent or GTK colours and no preference a window opening
keeps the theme it had, and the live paths now keep those colours too
instead of dropping them.

The window manager and each ManagedWindow remember the OsThemeInputs
their theme was last resolved from; WindowManager::set_theme forgets
them. OsThemeUpdate brings one reading to every window at its own
tree's density and to the manager's theme at its density, resolving
each density once, and gives no theme where the inputs are unchanged,
so nothing is set. apply_os_theme, follow_system_theme and the winit
ThemeChanged FollowSystem arm all go through it; the window-creation
path resolves at the manager's density with the window's hint and
records what it used, so the next identical reading sets nothing. The
desktop reads are taken through OsQueries, the platform's by default,
so the paths are tested on fixed answers.

Tests: every source at Compact, Comfortable and Touch equals the
Compact theme given to with_density, and a factory theme's own density
projection is used; a desktop stating a preference resolves as before
at Compact; FollowSystem gives the IntUI presets; no preference follows
the hint with and without colours. The window manager keeps Compact and
Touch on a window opening, an OS change and follow_system_theme, a new
window on no preference follows both hint values, and a window opening
and a live change give the same theme and inputs. With headless trees
at two densities, a reading sets each tree's theme once per change of
desktop, light or dark state or density, sets nothing when unchanged
(the theme signal's generation), and asks the factory once per density.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Under ThemeMode::Native and ThemeMode::FollowSystem the theme was read
when a window opened and again only on winit's light or dark signal, so
a new accent, colour scheme or animation speed on the desktop reached
the application only after a restart.

os_theme::OsThemeWatch listens on the session bus, on a thread of its
own with its own zbus connection, to the portal's SettingChanged for
the org.freedesktop.appearance and org.kde.kdeglobals.<Group>
namespaces, to org.kde.KGlobalSettings notifyChange, and to
org.kde.kconfig.notify ConfigChanged on /kdeglobals and /breezerc,
matched on interface, member and path from any sender. What a KDE
signal says changed is not decoded. The first signal opens a 150 ms
window, and at its end one os_theme::OsThemeChanged is posted through
AppEventPoster::post_external, so every signal is followed by a
payload posted no earlier than it. Dropping the watch stops its thread
and joins it, and nothing is posted after the drop returns. Elsewhere
than on Linux OsThemeWatch::new gives None. files_changed compares the
modification times of the kdeglobals and breezerc cascade files with
those last seen.

The window manager starts the watch while the mode follows the OS
(from resumed, set_theme_mode and follow_system_theme, once the event
proxy is installed) and drops it when a theme request makes the mode
Manual. The loop answers the payload ahead of the app's handlers: the
desktop is read again, the existing apply path sets a theme only where
the inputs changed (apply_os_source now says whether it set one), the
accessibility preferences are refreshed, among them reduced motion from
the animation factor, and every window is redrawn when anything
changed. When a window takes focus, a changed cascade file prompts the
same reading, for a session with no bus or a missed signal.

Tests: the signal filter; the debounce on injected instants (a burst
gives one payload, separate bursts one each, a change at the end of a
window is covered by it, every change of a pseudo-random sequence is
followed by a payload within the window); the thread over a fake
channel source (one payload per burst, nothing after drop, a source
that cannot open ends the thread); the file times in a tempfile
directory (written, made and removed files of both cascades, nothing
for another file); the payload routed to a re-query that applies a new
accent, a dark scheme and a dark preference, sets nothing when
unchanged, leaves a manual theme alone and hands any other payload
back. On a private dbus-daemon, gated on TEKSILO_DBUS_TESTS=1 and run
here, each listened signal gives one payload, a burst of all of them
one, ignored namespaces, paths and interfaces none, and nothing after
drop. Checked with cargo check and clippy on x86_64-pc-windows-gnu;
on aarch64-apple-darwin teksilo-platform with its tests (teksilo-app
needs a macOS C toolchain for zstd-sys, absent here).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
New crate teksilo-theme-kde, MPL-2.0, on teksilo-core, teksilo-tokens,
teksilo-canvas, teksilo-widgets and teksilo-kde-scheme (re-exported as
scheme). It builds a Teksilo theme from any KDE colour scheme in the
Breeze design language of Plasma 6.8 (BREEZE_VERSION). It installs no
widget style yet: every slot keeps its recipe default, and KDE_UNFILLED
gives the reason for each.

light() and dark() are from_scheme of Breeze Light and Breeze Dark;
light_with_accent and dark_with_accent pass the accent through
KdeColorScheme::with_accent, so Breeze's own blue still gives Plasma's
lighter selection (119,198,239). The id is kde.light or kde.dark by the
scheme's is_dark. build, reproject and install_styles follow the macOS
crate: reproject swaps in the generic density ladder ([unpublished]).

palette: KdePalette, a ThemeExtension (Any + Send + Sync, PartialEq by
content), holds Arc<ResolvedPalette>, the frame contrast clamped to 0..1,
BreezeColors per state (frame outline, separator, frame background,
focus and hover with their outlines, button and slider outlines,
shadow) and four precomputed ColorTokens: active, inactive, high
contrast and both. PlasmaAccent lists the ten swatches with [derived]
ids, color(), as_str() and FromStr.

color (private): every ColorTokens field assigned in one struct literal
with its source tag. Lifts, each a rule through lift_luma_until, with a
module-doc entry, a comment at the assignment and premise and pass
tests: D1 text on the accent (the scheme's text when it passes, else
Plasma's black-or-white rule; selection fills and accent washes move in
luma for the primary text, so Breeze Dark's selection darkens), D2
inactive text, D3 links, D4 status colours, D5 the focus decoration, D6
the strong outline, D9 the scroll-bar thumbs, which follow spec k.5.11
(no track, 0.175 idle, hover colour at 0.5). Text is lifted against
every surface including the hover and press washes of the state's and
the active accent, so an inactive Breeze window keeps its text colours.
search_match_*, scrim and chart_palette keep Int UI's.

Projections, through PaintProjection: an inactive window paints the
scheme's Inactive state (for Breeze, the window tinted 0.4 toward the
selection; the focus ring unchanged; a scheme with inactive effects on
gets them); high contrast rebuilds the scheme at frame contrast 1 and
contrast 10 with 7:1 text, 4.5:1 non-text, secondary grades equal to
the primary ones and the highlight moved in HCY, white text on it in a
light scheme. The selection fill is held to its text: carrying the
window's text at 7:1, it cannot also reach 4.5:1 on the window in any
scheme, which the module doc, a premise test and a known limitation
record. A theme whose colours the app changed gets the generic
projection instead.

shape, typography, motion, geometry: radius 5, pen 1, focus ring 2 at
-1, the k.6 shadows; Qt's 13 px and 11 px faces with Qt's line heights
(18, 15, Hack 15) from spec l.2; spec m durations with Linear easing;
BreezeGeometry::for_line_height with every k.4 height. Where the
specification changes the plan: the separator mixes by the frame
contrast, not 0.2; the small line box is 15. The switch colour curve is
the exact cubic Bezier of t cubed, (1/3, 0, 2/3, 0), rather than the
common approximation (0.55, 0.055, 0.675, 0.19), which strays 0.03 from
it; spec m now says so.

Tests: the macOS unit list adapted; assert_same_theme (Theme
destructured without a rest pattern) with from_scheme(breeze_light())
equal to light(); a table pinning every ColorTokens field; slot
partition; inactive and high-contrast projections; the bezier bounds;
geometry exact at 10 pt and at most 3 px short of Qt's sizes at 8 to 14
pt; no_unverified_tags. tests/contrast.rs sweeps every declared pair
over Breeze Light, Dark and Classic, Oxygen, Oxygen Cold and Oxygen Dark
(read from the golden vectors, so the file is excluded from the
package), the ten accents on both and nine adversarial schemes, each
shown to defeat its target colour without the lifts, plus a proptest over random coherent schemes in
all four paint states; tests/high_contrast.rs checks 7:1 and 4.5:1 for
every swatch, appearance and window state. The clean-room scan now
covers this crate. NOTICE 1.5 and TRADEMARKS.md name the preset.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The preset now installs three structural styles, in a styles module laid
out like the macOS preset's: button, icon_button and split_button, built
on one painter, chrome::BreezeButtonChrome, with their dimensions in
styles::metrics. The three slots leave KDE_UNFILLED.

The painter follows spec k.5.1 and k.5.2. A frame is the node inset by 1
(room for the shadow) and stroked with the 1 px pen at radius 4.5. The
push button is a flat Button-colour fill in the button outline, the
outline turning to the highlight on hover or keyboard focus and the fill
moving 30 % toward it while pressed. The default button, in an active
window only, fills Mix(Button, Highlight, 0.2) and outlines
Mix(Highlight, button outline, 0.5). The flat button paints nothing at
rest, the highlight outline with no fill under the pointer, the highlight
at 0.3 pressed and the button text at 0.125 checked. The 1 px shadow is
shadow_xs, black at 0.125 one pixel down, only in an active window and
never on a flat, pressed, checked or disabled button. Keyboard focus adds
the 2 px focus frame from 1 px inside to 1 px outside the node in the
focus_ring token (D5, opaque).

Colours are resolved at paint time from the theme the walker hands the
widget, so the inactive and high-contrast projections apply. Where the
specification and the plan differ, the specification is followed: Breeze
outlines and fills with the active window's Highlight whatever the window
state, so while the theme paints its KdePalette's own accent the
highlight and pressed fill come from the palette's active tokens for the
same contrast preference; a theme whose accent the app changed paints
its own. The neutral colours come from KdePalette: BreezeColors gains
button and button_text, and KdePalette::breeze_colors gives the
combinations of the high-contrast scheme too (frame contrast 1, so the
outline is the button text). Without the palette the frame paints the
content surface, primary text and border roles.

Variants: Filled is the default button, Plain, Tinted, Outlined and
Destructive the push button, Ghost and Link the flat button; Filled and
Destructive label in the primary text. IconButton is the auto-raise tool
button at 24 (the density's target size, [unpublished]), 28, 34, 44 and
60 for its five sizes, through dp(.., Target); every rung clears the
conformance floor, so no conformance box is built. The framed tool
button (32, 38) has no IconButton form and stays geometry data. A
SplitButton has the push button's frame; its Filled and Destructive
variants are filled with the accent ([unpublished]), because the widget
labels them in the on-accent text with no style hook and Breeze Dark's
default-button fill would not carry it. A push button and a split button
are the body line box plus 8 px above and below, at least 84 wide, 34 at
Compact and Comfortable and 44 at Touch, the horizontal gutter scaled by
the density's spacing.

Tests: chrome rules per kind and state, the Breeze Light and Dark values
of every rule against Plasma's pixel samples, the backdrop-window
highlight, high contrast, the palette-less fallback, the frame and focus
geometry; tests/mount.rs mounts every variant, size and swatch, light
and dark, swaps light, dark, Int UI and back, drops KdePalette, and
checks the face, outline, shadow, default blend, hover outline, pressed
fill, inactive window, flat and tool-button states and the focus frame
against tools/kde_golden/vectors/pixels.json; tests/preset_metrics.rs
measures Button, IconButton and SplitButton in a tappable row at all
three densities against sizes.json, with the conformance gate. Both read
the vectors, so they are excluded from the package.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ze does

The preset installs four more structural styles: toggle, checkbox, radio
and text_input. The four slots leave KDE_UNFILLED. The check box, radio
button and switch share their colours through styles::indicator, and
their dimensions are in styles::metrics.

Check box and radio button, spec k.5.6 and k.5.7: a 20 px indicator
holding a 16 px box, stroked as 15 px at 2.5 with radius 4 (a disc for
the radio), filled with the button colour, darkened 110 % while pressed.
Checked or partial, the outline is the current window's highlight and the
box is filled again with the highlight at 0.3; the check is a polyline
through (6, 10), (9, 13), (14.5, 7.5) with a 2 px pen and mitre joins in
the view's text, the partial state three 2 px squares 4 px apart, the
radio dot 6 px. Hover adds a 1 px ring of the focus colour on the
outline. The mark draws along its length and the dot grows over 100 ms,
linearly. Keyboard focus is the 2 px focus frame outside the box, in the
focus_ring token (D5). The body is laid out 18 px wide, so the widgets'
6 px gap puts the label at x 24, where Qt measures it.

D8: Qt's labelled check box and radio row is 22 px; the node is raised to
the density's target (24 at Compact) by the widgets' existing MinSize and
the indicator is still painted at 20, centred.

Switch, spec k.5.9 and k.8 (the desktop QtQuick style): a 36 x 18
indicator, a 36 x 12 track at radius 6 filled with the button colour
moved 10 % toward the button text, the highlight at 0.5 up to the
handle's trailing edge when on, an 18 px handle in the button colour
outlined in the button outline or, hovered or keyboard focused, the
button set's hover decoration. The handle sits at clamp(p x 36 - 9, 0,
18), mirrored right to left, and moves over min(100 ms, 18 px at 200 px a
second), so 90 ms, on the handle easing; its outline changes over 100 ms
on the colour easing. The node is raised to the target on both axes,
the paint centred. A focus frame is added around the indicator.

Line edit, spec k.5.3: the view fill on the node inset by 0.5 at radius
4.5, no shadow inset; the outline turns to the focus colour while focused
at the same 1 px width, to the hover colour under the pointer (TextInput
never reports hover, so only the rule is tested). 32 px at Compact
through dp(.., Target), text inset 6 through spacing(); cfg.min_height
replaces the height; Bare returns the editor untouched; Underline keeps
the bottom edge; a validation level tints the outline.

D6: the outline of an empty box, disc, switch track and field at rest is
border_strong, the frame outline lifted to 3:1, with a premise test that
Breeze's own fails. A checked switch has no mark, so its track outline,
Breeze Light's highlight at 2.2:1 on the window, is the highlight lifted
in luma to 3:1 (4.5:1 under high contrast) on the control surfaces and
the handle's face; Breeze Dark's highlight passes and is kept. Where the
specification and the plan differ, the specification is followed: the
switch's colours, borders and checked fill are k.5.9's, not the plan's
fallback values, and its handle travels in 90 ms rather than 100 ms
(k.8). Breeze paints only the fill of a line edit shorter than the line
box plus 12 px; the frame is kept at every height, for D6.

Tests: unit tests of each paint rule, geometry and timing, the Breeze
Light colours against Plasma's samples, the lifts and their premises,
the backdrop window and the palette-less fallback; tests/mount.rs mounts
every state and variant, light and dark, in the swap, the palette-less
and the swatch tests, and checks the box face, outline, wash, mark,
partial squares, hover ring, pressed face, backdrop highlight and focus
frame, the radio disc, dot and focus frame, the switch track, handle,
outlines and focus frame in both directions, the field fill, outlines and
disabled colours, and the disabled check box and radio, against
pixels.json; the right-to-left box, disc, label and squares; with each
Plasma accent, a hover ring in the focus colour rather than the
highlight; and, on the tree's simulated clock, the handle reaching 18 px
at 90 ms, its outline turning over 100 ms, the mark drawing, the squares
appearing at each third, the dot growing, and all of them snapping under
reduced motion. tests/preset_metrics.rs measures the check box and radio
row (D8, label at 24), the switch node and the field frame at all three
densities against sizes.json, with the conformance gate;
tests/row_hit.rs presses the painted box of a check box in a selected
and an unselected list row at Compact and Touch;
tests/text_input_min_height.rs checks the min_height override.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The preset installs three more structural styles: combo_box, spin_box and
slider. The three slots leave KDE_UNFILLED. Their dimensions are in
styles::metrics (BreezeComboMetrics, BreezeSpinMetrics and the slider
constants), and the combo and spin arrows share one painter,
styles::arrow, the 10 px chevron of spec k.5.17: ends a quarter and three
quarters across the square on its centre line, apex a quarter of the side
away, 1 px pen, square caps, mitre join, nudged along the way it points
by 0.5 for a side of 10, in the arrow colour of a text role (the text
moved 15 % toward its background).

Combo box, spec k.4 and k.5.4: the push button frame of P2, "pressed"
meaning the popup is open, 32 px at Compact through dp(.., Target), the
label 6 px inside the leading edge, the arrow centred in a 20 px box 2 px
inside the trailing edge, the label plus 38 px when no width is proposed
(gaps through spacing(), the box and the margin fixed), mirrored right to
left. The arrow is the arrow colour of the button text, the window text
while keyboard focused and not hovered, the disabled colours when
disabled. Keyboard focus adds the 2 px focus frame (D5), as on buttons.
Underline draws the line edit's bottom edge and Plain no frame, both
[unpublished].

Spin box, spec k.4 and k.5.5: the line edit frame of P3 (D6 outline at
rest, D5 focus, the hover colour while the pointer is over any part), the
field 6 px inside the leading edge, a 20 px column 2 px inside the
trailing edge, min(inner height, 20) tall and centred, split into halves
of floor(h / 2): 20 x 10 and 20 x 10 at every density, as Qt's sub
rectangles at (78, 6) and (78, 16) of a 100 x 32 spin box. The SpinBox's
own step buttons are placed on those halves, so they keep their press and
hold-to-repeat; their chrome is painted at zero opacity and the style
draws the chevrons, in the arrow colour of the view text, the hover
colour on the half under the pointer, with no button chrome. The halves
are the only conformance failures the row reports; the Equivalent pin is
left to P12.

Slider, spec k.4 and k.5.10: 20 thick, the 6 px groove the full length,
filled and stroked on itself inset by half the pen at radius 3, the
window text at 0.14 over the window outlined in the window text at fc,
and from the minimum to the handle centre the highlight at 0.7 outlined
in the highlight at fc; the 20 px handle box placed by the value over the
length less 20, painted as a 17 px circle in the button colour with a
1 px outline (focus over hover over rest) and a shadow ring at its bottom
right unless dragged; disabled, only the groove's background in the
disabled colours. Tick marks are 1 px lines 8 long, 2 px from the groove,
23 thick in all, the highlight up to the value. thumb_diameter and
thumb_diameter_for are the 20 px box at every density, the travel the
Slider maps the pointer over; the node is raised to the target across the
groove (24, 32, 44) and is 84 long when unbounded, Qt's size hint.
Right to left the handle, fill and ticks mirror.

A vertical groove is filled from its top down to the handle, as k.5.10
says Breeze fills a vertical slider that is not inverted; a Slider puts a
vertical slider's minimum at the bottom, as Qt does, so the fill is on
the maximum side.

Departures, documented in each module: D6 lifts the slider handle's
outline at rest to border_strong and the ticks to border_strong and the
lifted highlight; the handle outlines in the hover colour while the
pointer is anywhere on the slider (the style is told only that); ticks
are drawn below or to the right, a Slider naming no side; the spin box's
20 x 10 halves are under the 24 px target size, the value reachable by
keyboard, wheel and AT actions. The styles are not told a spin box's
limits or a combo box's item count, so an arrow at the limit and the
arrow of an empty combo box keep their colour. The crate's known
limitations list the arrows, the spin box halves, the tick side and the
slider's hover. BreezeFieldChrome gains a public constructor, used by
the spin box and the Underline combo box.

Tests: unit tests of the arrow points against Plasma's pixels, the arrow
colours, the combo arrow box and spin halves at Qt's sub rectangles and
right to left, the slider geometry against Qt's groove and handle
rectangles, its travel, RTL, the vertical fill from the top and tick placement, its Breeze Light
colours against pixels.json, the D6 lifts and their premises, and the
palette-less fallbacks; tests/mount.rs mounts every variant and state,
light and dark, in the swap, the palette-less and the swatch tests, and
checks the combo face, outline, shadow, arrow, hover and focus outline,
focused arrow and focus frame, and the mirrored arrow and label; the spin
fill, lifted outline, the two arrows and nothing else, the step buttons on
the halves, the hovered half and frame and a press stepping the value,
the disabled colours and the mirrored column; the slider groove, fill,
handle face, lifted outline and shadow ring against pixels.json, hover,
focus outline and focus frame, no shadow while dragged, the disabled
slider, right to left the mirrored handle and fill, a press putting the
painted handle under the pointer, and the mirrored ticks, and a vertical
slider's fill from the top to the handle and its press.
tests/preset_metrics.rs measures the combo box, spin box (with its two
halves) and slider (with and without ticks, both orientations) at all
three densities against sizes.json, with the conformance gate.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The preset installs three more structural styles: menu_item, popover and
tooltip. The three slots leave KDE_UNFILLED. The menu dimensions are in
styles::metrics (BreezeMenuMetrics), and the popover and the tooltip share
one frame painter, styles::popup.

Menu item, spec k.4 and k.5.13: 29 px at Compact, max(H, 20, 16) + 1 and
the 4 px item margin above and below, through dp(.., Target), so 32 and
44 above it; a separator 5 px, scaled by spacing(); the rows 4 px inside
the frame, MenuList's own inset. The row is laid out as Breeze lays out a
menu with icons, since MenuItem reserves its leading column on every row:
the icon column 12 px inside the row (the 4 px margin and the 8 px text
margin), 16 wide, the text 3 px after it, and a 27 px trailing column (3
after the text, the 20 px arrow box, the 4 px margin); the shortcut gap is
the 16 px accelerator space. The style stores a MenuItemRecipe built from
the theme's input tokens and typography at install time, so metrics()
follows the density the theme is projected to; the row arithmetic is the
shipped RecipeMenuItemStyle's, its own highlight held off, and the row is
floored at its height. Highlighted (hovered, reached by the keyboard, or
a trigger whose submenu is open), the row is washed with the view's focus
decoration at 0.3 and outlined at radius 4.5 on the row inset by half the
pen; a disabled row is never highlighted; held down it is filled and
outlined, and pressed_label_role (G3) turns its label and shortcut to
TextRole::OnAccent.

Popover and tooltip, spec k.5.13, k.5.18 and k.6: a rounded frame at
radius 4.5 on the node inset by half the pen, the frame background in the
frame outline (surface_raised and border) or the tooltip background in
its outline (tooltip_bg and tooltip_border), over both layers of the
Large window shadow, shadow_lg and shadow_inner_lg, on the 5 px window.
A menu's frame has no padding; a popover's and a tooltip's content lies
4 px inside it through spacing(), so a one-line body tooltip is 26 px.
The Menu variant's node is a bare GenericContainer, the others a named
dialog, the tooltip frame hidden, as the framework's surfaces are.

Departures, documented in each module: D5 lifts the highlight outline,
Breeze Light's 57,154,204 at 2.9:1 on the frame background, in luma to
3:1 (4.5:1 under high contrast) on the window, view and frame background,
with a premise test, Breeze Dark's kept; D1 fills a pressed row with
accent rather than the focus colour, the colour text_on_accent is chosen
for, the two being the same in the stock schemes; a popup draws no caret,
a shadow on every side, and is always rounded and opaque; the frame
outline is the passive frame's, not lifted. The crate's known limitations
list what the menu widgets build themselves: the shortcut keeps the
framework's shortcut role, not Breeze's window text at 0.7 (MenuItem gives
its style no hook for it); a separator line runs the row width; a section
title is the app's MenuList::header widget, which
BreezeMenuMetrics::section_height (28) can size; a menu without icons
keeps the icon column; a checkable row shows the framework's check mark;
TooltipWidget writes in the small style, so its tooltip is the small line
box plus 8.

Tests: unit tests of the menu metrics at every density and font size,
the recipe and metrics, the state precedence and paint rules, Breeze
Light's and Dark's wash and outline against Plasma's pixels, the D5 lift
and its premise including high contrast, the label on the wash and the
pressed fill with every Plasma accent in both window states, the
palette-less fallback, the popup colours against pixels.json and the
popover and tooltip padding; lib.rs reprojects the menu metrics through
with_density; tests/mount.rs mounts menus, tooltips and every popover
variant, light and dark, hovers and presses every row, swaps themes and
drops KdePalette, mounts every swatch, and checks the menu panel, the
tooltip and the popover faces and outlines against pixels.json with both
shadow layers, the rows in the 4 px margin, the hovered row's exact wash
and outline, the pressed row's fill and flipped label, the disabled row,
and no caret; tests/preset_metrics.rs measures the menu rows, separator,
margin and label inset at all three densities against sizes.json, with
the conformance gate.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ars as Breeze does

The preset installs five more structural styles: standard_item, table,
tab, scroll_bar and progress_bar. The five slots leave KDE_UNFILLED. Their
dimensions are in styles::metrics (BreezeRowMetrics, BreezeTableMetrics,
BreezeTabMetrics, BreezeScrollBarMetrics, BreezeProgressMetrics), all
built from geometry and rendered through the row, table, tab and thumb
accessors.

Two framework hooks close what those accessors could not reach, both
defaulted so an existing style needs no change:
StandardItemStyle::min_height_decorated, the floor of a single-line row
with a check box or a leading slot (StandardListItem records whether it
has one before its build takes them), and ProgressBarStyle::thickness and
paints_busy_indicator, the bar's thickness when the app sets none and
whether the style draws an indeterminate bar's motion, in which case
ProgressBar mounts the style alone in either orientation and draws no
sweep. The ProgressBar::thickness doc and its catalog page say the default
is now the style's.

Rows, spec k.4, k.5.16 and the C0 outcome: 24 px of text, 26 with an icon
or a check box, 42 with a subtitle (two line boxes and the margins,
[derived]), through dp(.., Target); content 7 px inside the leading and
trailing edges (Qt's check box at x 7) and 1 px inside the top and bottom;
the shipped recipe's row arithmetic with its chrome held off and its
unread gaps left at their defaults. The selection lies inside the 2 px and
1 px item margins, stroked at radius 4.5: selected, accent fill and
outline with TextRole::OnAccent glyphs (D1); hovered and selected,
accent_hover, Qt's lighter(110); hovered, surface_hover outlined in the
highlight at 0.8; disabled and selected, the disabled highlight. The leaf
is hit_transparent, the frame places it and the row at full bounds so the
selection spans the row's width, and selection_emphasis is WindowActive:
a backdrop window paints Plasma's inactive highlight, 194,224,245 in
Breeze Light. Keyboard focus on a focused view adds the D5 focus frame
around the selection.

Tables, k.4 and k.5.15: D7 raises Qt's 23 px row (with its grid line) to
24, then through the ladder; the header is 30, its sections the button
colour, moved 20 % toward the hover colour when hovered and toward the
focus colour while resizing or sorted down, with a 1 px bottom and
trailing line in the window text at the frame contrast, mirrored right to
left, label padding 6. Sorted down is Qt's indicator, which its header
view sets for an ascending sort and Breeze, inverting the indicator,
draws as an up arrow, so the ascending column is the one shown pressed. make_row_background and make_sort_indicator are Breeze's (square cells,
the 10 px chevron), though the views paint their own band and arrow.

Tabs, k.4 and k.5.14: 30 px, padding 8; the selected tab a 3 px highlight
bar on its outer edge, outer corners at radius 5, the window colour below
it and the frame outline down its two sides, open toward the page, the
outline stroked at radius 4.5 so it and the face keep turning through
the bar's corner below the bar (rows 3 and 4 of Plasma's sample); an
unselected tab the window darkened 120 %, hovered the window moved 20 %
toward the hover colour; the bar follows active_indicator and the layout
direction; disabled tabs paint the disabled colours; focus draws the focus
frame flush inside. The strip is the shipped RecipeTabStyle's with these
metrics. Where the specification gives the selected tab the frame
background, Plasma's samples (pixels.json, document mode on and off) show
the window colour, which is used, as measured.

Scroll bars, k.5.11 and n.20: no track; a permanent bar's 1 px separator
on its leading edge in the view text at the frame contrast; the 8 px
handle centred in what it leaves (7 px from the separator in a 21 px bar),
painted 7 wide at radius 4 and floored at 20 px (a grab, so at every
density), filled with the D9-lifted thumb tokens and outlined in the
window text or the hover colour at the frame contrast; the widget's own
thumb arithmetic, mirrored right to left.

Progress bars, k.5.12: 26 px, Qt's hint, around a 6 px groove at radius 3,
the window text at 0.14 and the contents floor(value x length) long in the
highlight at 0.7, each outlined at the frame contrast, from the right in a
right-to-left window and from the bottom when vertical; a busy bar's 28 px
stripes, 14 of the highlight and 14 of it moved 70 % to the window, move
one period every 800 ms, linearly, and stand still under reduced motion.

Departures and limits, documented in the modules and the crate's known
limitations: the bar's own 96 px minimum tab width replaces Breeze's 80,
and the 34 px static tab is recorded but not laid out (a TabStyle is not
told); scroll bars keep the widget's thickness rather than 21 px and turn
to the hover colour over the whole bar; the views paint the table's
selection band, which mutes on view focus too; a row cannot mark the
current unselected row; a short progress contents keeps its length.

Tests: unit tests of every metric at every density and at 12 pt, the D7
premise, the row, header, tab, scroll and progress colours against
pixels.json in Breeze Light and Dark, the label on the selection with
every Plasma accent, the bar edge for each orientation, position and
direction, the separator and handle placement, the busy stripes and the
palette-less fallbacks; lib.rs reprojects the five slots through
with_density and measures a rendered row of 24, 32 and 44;
StandardListItem and ProgressBar tests cover the new hooks and their
defaults. tests/mount.rs mounts every row, table, tab bar, scroll bar
variant and progress bar, light and dark, in the swap, the palette-less
and the swatch tests, and checks the selected row (highlight, opaque
outline, OnAccent glyphs, filling 200 and 480 px rows), the unselected row
painting nothing, the hovered and backdrop rows, the 3 px tab bar with its
face and sides and the unselected and hovered tabs, a vertical tab's bar
in both directions, the scroll bar's separator, handle and hover with no
track, the progress groove and contents in both directions, the busy
stripes' direction and speed on the simulated clock, and the header
section at rest, hovered, resizing and sorted, with its trailing line
right to left; a scheme whose focus and hover colours differ tells the
pressed mix from the hovered one.
tests/preset_metrics.rs measures list and tree rows (24, 26, 26, 42 at
Compact; 32, 32, 34, 42; 44, 44, 46, 44) against sizes.json with the
conformance gate, the installed row floors, D7 (table and tree table rows
24 under a 30 px header), tabs (30 and padding 8, rising with the
density), the 20 px thumb floor and the 26 px progress bar.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The preset installs fourteen more slots: card, panel, dialog, snackbar,
toast, banner, link, badge, calendar, search_field, avatar, splitter,
list_container and segmented_control. Each is the stock Recipe*Style built
with Breeze's numbers, in the new styles::recipes module, with a
_for(&InputTokens) factory that install_styles calls with theme.input, so
the density projection rebuilds them, and a zero-argument Compact twin.

Values, spec k.1, k.4, k.5 and k.7: a card, a panel and a banner are
framed at the 5 px frame radius in the 1 px pen with their content 6 px
inside, the child margin, scaled by the density's spacing; a dialog's
content lies spacing(10.0, tokens) inside it, Breeze's dialog margin, and
the dialog, the snackbar and the toast round at the 5 px window radius;
the banner's and the toast's glyph gap is the 6 px layout spacing; a badge
rounds at the frame radius, Breeze drawing no pill; the segmented
control's frame is the 5 px radius and the 1 px pen; the link's focus
ring is Breeze's focus frame (k.5.8), a 2 px stroke centred on the link's
bounds whose centre line rounds at 6, so its inner edge rounds at 5 and its
outer at 7, and its underline is the pen; a calendar day and today's ring
round at 5 in the pen; a search suggestion's text lies 16 px across
and 4 px down inside its row, a menu item without icons (k.5.13); the
splitter is its 1 px handle and the presence dot's outline the pen.

A field is set only when Breeze has a number for it and the widget
renders it. The segmented control's height and paddings, the calendar's
cell size, header height, outer padding, section and cell gaps, the
search field's row height, panel padding and input gap, the toast's and
the banner's title gaps and the avatar sizes are listed as unrendered in
density-projection-gaps.md and stay the stock projection, as do the
fields nothing reads (the glyph sizes, the search field's radii, the
calendar's nav icon and arrow radius, the avatar's ring). The calendar's
header gap stays the recipe's too: Breeze's 6 px would leave the month
title below 24 px at Touch, and its nav arrow is already Breeze's
tool-button rule around the widget's 12 px chevron. Plan deviation: the
plan lists only the dialog margin as a value; the spec gives the rest.

KDE_UNFILLED keeps ten slots, each with its final reason: radio_tile,
chart, color_picker, date_edit, rich_text_editor, drop_zone, drop_target,
grid_view, web_view and text_selection.

Tests: tests/density_gaps.rs parses density-projection-gaps.md sections 1
to 3 and asserts that no recipe literal in the crate's sources sets a
listed field (a liveness fixture checks the scan) and that every factory
building a listed recipe, segmented_control and the row recipe included,
gives each listed field the stock value at every density. preset_metrics
measures, at every density, the panel's and the card's 6 px insets and
5 px stroked frames, the dialog's 10 px margin, the banner's insets and
gap, the suggestion row gutters, the calendar's today ring, stock header
gap and zero conformance failures, the segmented control's frame and its
stock 30 px height, the link's 1 px underline and its focus ring, the open
snackbar's and the toast's 5 px radius and the toast's 6 px row gaps.
mount.rs mounts every recipe-styled widget, the open snackbar and a toast
surface included, light, dark and without the palette and checks the
badge's radius; lib.rs checks the slot set and reprojects the search
field and calendar metrics; the recipes module unit-tests every value.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
from_desktop(&KdeDesktop, TargetDensity) builds the Breeze theme of a
desktop as it is configured, and native_theme gives it only for a desktop
using the Breeze widget style: widgetStyle absent, empty or "breeze" in
any ASCII case (spec j.4). Fusion, Oxygen, Kvantum and every other style
get None, so the native-theme seam falls back to IntUI in the desktop's
colours.

Colours: the desktop's scheme, through KdePalette::from_scheme, the
user's accent already in it.

Fonts (spec j.1, l.2): the general font becomes body and body_bold, the
smallest readable font small, small_bold and tiny, the fixed font mono,
in the desktop's own families. The glyph size is the whole pixel size Qt
draws the font at (a font stored in pixels keeps its pixel size), the
line box Qt's for Noto Sans and Hack and round(P x 1.362) for any other
family; the weights stay the slots' 400 and 600. The control heights
follow the body line box through geometry_of. The menu and toolbar fonts
have no text style and ride on the theme as KdeFontRoles, a new
extension; menu rows are sized for the taller of the menu font's and the
body's line box (menu_row_typography), since MenuItem writes its label
in the body style.

Animation speed (spec j.2, m): motion::desktop_motion applies
AnimationDurationFactor as Kirigami does: the long duration
max(1, round(200 f)) is duration_normal and duration_collapse, its half
rounded down duration_fast, its double duration_slow; the busy period and
the tooltip delays are not scaled. A factor of 0 gives 0 and 1 ms
transitions and BreezeStyleConfig::prefers_reduced_motion reports it.
Plan deviation: the plan scales every non-zero duration with
MotionTokens::scaled; the specification derives the short duration from
the long one and leaves the busy period alone, and a test pins the
difference.

breezerc (spec j.6, k.5.13, k.6): BreezeStyleConfig, a new extension in
breeze_config.rs, carries BreezeSettings and the factor on every theme
(the defaults on the stock ones). Under MenuOpacity below 100 a menu's
panel (the new BreezePopupKind::Menu, the popover style's Menu variant)
is filled at that opacity and outlined in the window text at 25 %;
ShadowSize, ShadowStrength and ShadowColor give the window shadow,
shadow_lg and shadow_inner_lg, through shape::breeze_shape_for (None
paints no shadow); ProgressBarBusyStepDuration is the sweep token and the
busy bar's period, and ProgressBarAnimated off stands the stripes still.
The other keys are carried and change nothing, each reason listed on
BreezeStyleConfig.

The seam's contract, documented on native_theme: resolve_native_theme
returns a factory's theme as it comes, and the window manager's
resolution then stamps the id "system" and projects it onto the window's
density; the theme itself carries kde.light or kde.dark and the density
it was asked at.

Tests: tests/native.rs covers the five fonts, a Qt 5 font string, the
line boxes of known and unknown families, the button and menu heights
they give, a menu font smaller than the body and the menu metrics across
a density projection; factors 2, 0 and absent; MenuOpacity on the
resolved colours and on a mounted MenuList's painted panel, with the
popover staying opaque; the shadow size, colour, strength and None; the
busy bar's 400 ms period on the simulated clock and its stripes standing
still; Fusion, Oxygen, Kvantum and others to None, Breeze in any case,
empty and absent to Some at the density asked; and the theme composing
through teksilo-app's resolve_native_theme with a factory, before and
after the window manager's id and density, with the fallback for a
Fusion desktop and for a report without KDE settings (teksilo-app and
teksilo-platform as dev-dependencies, acyclic). Unit tests cover
BreezeStyleConfig, desktop_motion, desktop_typography, breeze_shape_for
and the menu panel colours; lib.rs checks every theme carries both
extensions and that the default desktop is the stock theme in its own
faces.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Two features, both off by default, put Plasma's own faces in the
typography (spec l.1, l.3). bundled-fonts embeds them and adds
font_registrar(), a FontRegistrar for TeksiloAppBuilder::register_fonts
that registers them under "Noto Sans" and "Hack", shares the static bytes
rather than copying them, and makes upright Noto Sans at the 13 px body
size the default face. system-fonts names the same families without
embedding anything, for a desktop that has them installed. Without either
the families stay Inter and JetBrains Mono. The constants
NOTO_SANS_FAMILY and HACK_FAMILY live in the new fonts module, which
documents the three builds; known_metrics names them too.

The faces, from the releases spec o.10 names, each archive checked
against its recorded sha256 before anything was taken from it:
- Noto Sans v2.015 (NotoSans-v2.015.zip): the full variable upright face,
  NotoSans[wdth,wght].ttf, whose sha256 is the ledger's, and its italic,
  as NotoSans-Variable.ttf and NotoSans-Italic-Variable.ttf (renamed so
  no axis tag reaches a file name), with the release's OFL.txt.
- Hack v3.003 (Hack-v3.003-ttf.zip): Regular, whose sha256 is the
  ledger's, Bold, Italic and Bold Italic. The archive carries no licence
  file, so hack-LICENSE.txt is the project's LICENSE.md at tag v3.003.
All six faces fit P10's budget easily: 5.6 MB uncompressed, about 3.2 MiB
packaged against the 9 MiB guard, so the italics are in. The variable
Noto Sans draws the bold styles at 600 rather than at a bold.
tools/kde_golden/vectors/fonts.sha256 records the Ubuntu packages the
reference image measured with (fonts-noto-core 20201225, fonts-hack
3.003-3), not the upstream releases, and neither hash matches a bundled
file; the faces' head and hhea metrics are the ones the specification
gives (o.10, n.7), and a test reads them from the files.

Line heights: unchanged in every build. The spec's rule (l.2) computes
every line box from Noto Sans and Hack's metrics whatever family draws
them, so the monospace box is Qt's 15 px from Hack's hhea (a natural
1.1640625 em at the 13 px face) with or without the feature. Plan
deviation: the plan computes it from hhea only when bundled.

NOTICE: 2.6 Noto Sans (OFL-1.1) and 2.7 Hack (MIT and the Bitstream
Vera licence), each with the release tag, the archive and file sha256
and, for Hack, the Bitstream naming condition; section 2's Reserved Font
Name sentence now speaks of the OFL faces only, and section 3 names the
two faces under teksilo-theme-kde's system-fonts as free fonts used from
the machine.

tests/fonts.rs: every file under fonts/ matches its recorded sha256 and
nothing else is there; the faces' units per em, ascender, descender and
line gap equal NOTO_SANS_METRICS and HACK_METRICS; the sizes and line
boxes are the same in every build. With a font feature, the stock themes
and their font roles name Noto Sans and Hack and a desktop at Plasma's
defaults has the stock typography exactly; without one, they keep Inter
and JetBrains Mono. Under bundled-fonts the registrar's faces answer
every weight and style the styles ask for, Hack is monospaced and Noto
Sans is the default at 13 px. sha2 joins the workspace as a test-only
dependency, already in the lockfile. The crate was built, linted and
tested with bundled-fonts,system-fonts, each alone, and neither.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…atures

A new step in the check job's debug leg, modelled on the teksilo-corpus
package size guard, packages teksilo-theme-kde and teksilo-kde-scheme
together (teksilo-theme-kde depends on the other at the same version)
with --no-verify --allow-dirty and fails when either .crate, measured with
wc -c, is above 9 MiB. A per-crate --list then asserts that every tracked
file under teksilo-theme-kde's fonts/ is in its package, with at least one
face and one licence so an emptied directory cannot pass, and that neither
package carries a fixtures/, tools/ or vectors/ path.

Measured locally: teksilo-theme-kde 3344469 bytes (3.19 MiB), with its six
faces and two licences; teksilo-kde-scheme 79347 bytes (0.08 MiB).

Plan deviation: the package step adds --exclude-lockfile. Cargo resolves a
package's Cargo.lock against the crates.io index with only the packages
named on the command line supplied locally, so on a release tag, where
the Release workflow runs ci.yml before it publishes, teksilo-core and the
other workspace crates at the new version are not on crates.io yet and the
guard would fail every release. Packaged with its lockfile today,
teksilo-theme-kde is 3385002 bytes: the lockfile adds about 40 KB,
inside the margin between the guard and crates.io's 10 MB.

test-linux gains a step that checks (all targets) and tests
teksilo-theme-kde with --features bundled-fonts,system-fonts, which no
workspace step turns on; the workspace steps already cover the build
without either.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
teksilo-target-conformance dev-depends on teksilo-theme-kde and puts
kde.light in ALL_THEMES and ROSTER, and kde.dark in the dark roster, so
the SC 2.5.8 gate sweeps five presets. Light and dark agree on every
KDE row.

The census grows from 477 to 584 rows; KDE's 107 (40 at Compact, 34 at
Comfortable, 33 at Touch) is now the smallest preset's total. The seed
floor is computed from the roster rather than written as a literal:
four size seeds and one theme seed per other preset, 8 x 107. The
allow-list keeps its fifteen entries, six on an SC 2.5.8 exception and
nine escalated; what moved are their pins, each one what the census
reports:
- StepButton: the recipe's 18 x 13 halves now name the four recipe
  presets; Breeze's halves are pinned at 20 x 10 at every density, and
  at 20 x 10 reaching 28 x 10 for the two the miss-only pass widens in
  the ColorPicker at Comfortable, under the same Equivalent.
- The SpinBox field's 20 dp reach at Compact no longer names KDE, whose
  32 dp field clears the floor.
- The table's scroll-bar track remainder is pinned under KDE at 12 x
  16.875 reaching 31 x 16.875 at Compact: 24 dp rows under a 30 dp
  header overflow the 270 dp viewport by 18 dp.
Comments that counted themes say five (ten with dark), and the window
frame and calendar entries give KDE's figures, measured: its top-strip
button is shadowed at Touch (44.9 x 24.0 of 109 x 44) and its calendar
title is 27.2 dp there.

engineering/docs/density-inventory.md: section 4 gains the 121 KDE
constants, the indicator ones as fixed at Breeze's 20 dp with the node
floored to 24 by the widget's own MinSize; section 1 gains KDE's four
MinSize sites, all at or above 24 at Compact, so DECLARED_EXCEPTIONS is
unchanged; the section 4 census grep sweeps teksilo-theme-kde (257 with
it), and the KDE rows are counted apart from the P02 totals. The
inventory's class for a unitless constant is spelt with a dash, so the
KDE alphas and the switch's speed are classed Decoration with a
treatment that says they are not a dimension.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
ColorTokens::for_high_contrast branched on surface luminance and wrote
IntUI's teal literals into whatever preset was active. Measured with the
old projection: black text on macOS Aqua's accent 3.87:1 (3.03 on the
pressed fill), on Fluent light's 3.70:1, the IntUI ring 4.21:1 on the Aqua
window, and every dark preset (Dark Aqua, Fluent dark, Material 3 dark)
painted #4FCCE0. IntUI itself missed its own targets: black text on its
light hover and pressed fills 6.33:1 and 4.64:1, primary text on its dark
selection 4.96:1, and on an inactive window black text on the light accent
6.24:1.

The projection is now a rule over the palette, built on
teksilo_tokens::contrast, with every move a lift_until_readable step
toward black or white in sRGB, which keeps the HSV hue:
- text_on_accent is the palette's own when it reaches 7:1 on the accent,
  otherwise the better of black and white. accent, accent_hover and
  accent_pressed are each lifted until it reaches 7:1, then on to 4.5:1
  on surface_main, surface_content and surface_raised when the text keeps
  7:1. Where the text and the surfaces are opposite in tone (black text on
  a light accent) the accent is held to its text and the ring diverges,
  the light-mode divergence the doc already explained.
- focus_ring and border_focused are lifted to 4.5:1 on the surfaces, a
  translucent one gaining opacity when its alpha cannot get there; a
  neutral ring (Fluent's 89 % black) stays neutral.
- surface_selected and surface_selected_inactive (the fill a row keeps
  in an unfocused view or an inactive window), each composited opaque
  over surface_content, move off it to 1.5:1 and stop where text_primary
  would fall below 7:1; one the text already fails on moves toward the
  surface until it reads.
- text_secondary is lifted, gaining opacity as the ring does (macOS's 58 %
  black), to 7:1 on the surfaces and on both projected selections.

The rule does not reproduce IntUI's old values: its light accent is kept
(black text 8.50:1), its ring becomes #0C7D8E where it was #0A7B8B, its
dark accent stays #19BDD4 where #4FCCE0 replaced it, its selections
become #BDD6DA and #21434D, and its light unfocused selection moves from
1.24:1 to 1.5:1 off white (the dark one, at 1.74:1, is kept). All meet the same thresholds. KDE keeps its
own PaintProjection; its fallback for app-changed colours calls the new
projection, and its tests pass unchanged.

Tests: the tokens unit tests measure every documented pair for both IntUI
bases, active and inactive, and pin the kept accent, the hue of a lifted
one, the light-mode divergence, the neutral ring, the opacity gain and the
selection rule for both selection fills. tests/high_contrast.rs in
teksilo-theme-macos (both appearances and the eight System Settings
accents), teksilo-theme-fluent (the default accent and four others) and
teksilo-theme-material3 measure the same pairs through
Theme::for_paint_state, active and inactive, and check the accent keeps
its hue. prop_color.rs gains two properties over random accent families
and focus colours on both bases: every documented floor is met and no
other token moves, and the hue of what is lifted is kept (8192 cases each,
under the safe-run protocol). The Filled button test pinned IntUI dark's
accent changing under high contrast; that accent already meets its floors
and is now kept, so the test gives the theme IntUI dark's pressed fill
(black text 4.40:1), which the projection lifts.

Docs: the PaintProjection and for_paint_state docs and
docs/window-activation.md describe the lift; the accessibility audit's
section 5.6 is marked fixed, with G13 and the 11.7(a) row updated.
CHANGELOG: a Fixed entry, a behaviour change for all four presets.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A horizontal Slider reads its pointer and its arrow keys from the
leading edge, the right one under right to left, and the stock
RecipeSliderStyle and the KDE slider paint from the same edge. The macOS
and Fluent styles painted from the left in every direction: in a
right-to-left window the knob sat mirrored from the value, slid away
from the pointer dragging it, and the arrow that raised the value moved
it towards the minimum.

slider_axis in both styles now takes the layout direction. A horizontal
axis swaps its start and end under right to left, so the knob and the
tick marks, both placed by interpolating between them, mirror; the fill
runs from the start, the minimum's end of the track, to the knob. The
track itself and a vertical axis are the same in either direction.

Tests: a unit test in each style pins the mirrored horizontal axis and
the unchanged vertical one. tests/slider_rtl.rs in teksilo-theme-macos
and teksilo-theme-fluent mounts the real Slider under both appearances:
the same value paints the knob and the fill mirrored about the slider's
centre in LTR and RTL (0, 0.3, 0.75 and 1); a press and a leftward drag
under RTL raise the value and leave the knob under the pointer;
ArrowLeft raises the value and moves the knob left with the fill
growing from the right end, ArrowRight the opposite; and a vertical
slider paints the same in both directions. The first three fail on the
previous styles.

CHANGELOG: a Fixed entry, a behaviour change for both presets.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
MenuItem builds its icon and chevron columns, MenuSeparator its height and
MenuList its max_visible_items cap from MenuItemStyle::metrics, before any
style runs. The macOS and Fluent styles laid their rows out from the
build context's input tokens but reported the Compact recipe from
metrics(), which has no theme to read. At Touch a separator kept 11 dp
(macOS) or 3 dp (Fluent) and the trailing column 10 or 11 dp inside rows
laid out for Touch, and a Fluent menu capped its height by 37 dp rows
painted 44 dp tall.

MacOsMenuItemStyle and FluentMenuItemStyle now store the MenuItemRecipe
they lay out, as the KDE menu style does: for_tokens builds it from a
density's InputTokens, Default is Compact, and new takes any recipe.
install_styles builds each from theme.input, so the DensityProjection
re-runs it on a density switch, and make_body and metrics() read the same
recipe. The Compact recipes are unchanged. macOS rows stay AppKit's 22 dp
at every density, the preset's decision that the conformance gate pins;
only the gaps around them scale.

Tests: a unit test in each style pins the metrics at the three densities;
each lib's reprojection test checks the installed slot's metrics at every
density and back to Compact. preset_metrics.rs in both crates mounts a
MenuList in in_tappable_row at all three densities and measures the rows,
the separator and the submenu row's chevron column against the density's
values, and Fluent's rows clear the conformance gate; the separator and
chevron checks fail on the previous styles. teksilo-theme-fluent takes
teksilo-target-conformance as a path dev-dependency for in_tappable_row,
as teksilo-theme-macos does. The conformance census is unchanged (584
rows, no pin moved). The density inventory's line numbers for the two
files are updated.

CHANGELOG: a Fixed entry, a behaviour change for both presets, and a
Changed entry, breaking: the two styles are no longer unit structs.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A vertical Slider puts its minimum at the bottom and its maximum at the
top: set_value_from_position takes 1 - t on that axis, thumb_rect puts the
thumb there, and RecipeSliderStyle and the KDE slider paint it there. The
macOS and Fluent styles painted value 0 at the top with the fill running
down, so the knob sat opposite to where a press put the value, a drag
moved it away from the pointer, and ArrowUp, which raises the value,
moved it down.

slider_axis in both styles now starts a vertical axis at the bottom end
of the track and ends it at the top. The knob and the tick marks are
placed by interpolating between the two, so they follow; the fill runs
from the start, the minimum's end of the track, to the knob on both axes,
one code path instead of a separate vertical one that grew from the top.
The Slider has no inversion of its own, so the fill always rises from the
bottom. The horizontal axis, in either direction, is unchanged. The doc
comments on both slider_axis functions now describe the widget's real
mapping.

Tests: the unit test the_value_axis_runs_the_same_way_the_widget_maps_the_pointer
in each style now mounts the real Slider under that style at five values,
in both orientations and both directions, and checks that the axis puts
the knob's centre on the thumb the widget reports through target_regions.
tests/slider_vertical.rs in teksilo-theme-macos and teksilo-theme-fluent
mounts a vertical Slider under both appearances: the painted knob centre
equals the widget's thumb centre at 0, 0.25, 0.5, 0.8 and 1 in LTR and
RTL, with the minimum at the bottom; the fill runs from the track's bottom
end up to the knob, as long as the value; a press a tenth of the way down
sets 90 % with the knob under the pointer, and a downward drag lowers the
value with the knob following; ArrowUp raises the value and moves the
painted knob up with the fill growing from the same bottom end, ArrowDown
the opposite. All of them, and the travel unit test, fail with the
previous vertical axis restored. The S2 tests in slider_rtl.rs still pass.

CHANGELOG: a Fixed entry, a behaviour change for both presets.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…catalog

The umbrella crate gains the theme-kde feature, with the optional
dependency on teksilo-theme-kde and an entry in the docs.rs feature list.
The prelude re-exports the preset as kde, beside material3, fluent and
macos, and the comment that counts the presets now says four. Like Material
3's, the preset's font features (bundled-fonts, system-fonts) are not
forwarded.

kde_native_install.rs adds TeksiloAppBuilderKdeExt, re-exported from the
prelude, whose native_theme_kde installs kde_native_theme as the builder's
native-theme factory. TeksiloAppBuilder gains configured_native_theme, which
returns that factory, and the tests check the installed factory through it. The factory asks kde::native_theme with the request's
desktop and density, so it answers for a KDE desktop on the Breeze widget
style and with None for Fusion, Oxygen or Kvantum, for a KDE desktop with
no settings and for any other desktop; the seam then falls back to IntUI in
the desktop's colours. tests/native_kde.rs checks the install (the factory answers Breeze and
declines Fusion, and a later native_theme replaces it) and checks Breeze's shapes and the
requested density for a Breeze desktop (widgetStyle absent, Breeze,
breeze), None for Fusion and for a desktop without KDE settings, and the
kde.light and kde.dark ids.

tools/extract_widget_api.py and its embedded copy gain CrateSpecs for
teksilo-theme-kde and teksilo-kde-scheme, and the umbrella re-export
table lists TeksiloAppBuilderKdeExt.

The widget catalog enables theme-kde. The title-bar switcher list moves to
switcher_themes(), ten rows in the order IntUI, Material 3, Fluent, macOS
and KDE, each light then dark; KDE Light and KDE Dark are plain labels like
the existing ones, which are lit! names and not Fluent messages.
--theme gains kde-light, kde-dark and system, in the doc comment, the error
text, --help and the tooltip, and theme_from_name, theme_from_id and the
test list know the KDE pair, so a persisted KDE pick survives a restart.
--theme system is answered by startup_theme before theme_from_name, so it
raises no unknown-theme warning, and ThemeRestore::FollowSystem makes the
persistence slot call follow_system_theme whatever the last run saved. The
slot's restore decision moves into ThemeRestore::action so it is testable.
The builder calls native_theme_kde, and --theme system also selects
ThemeMode::Native (startup_theme_mode, applied after .theme(), which
resets the mode to Manual), so the first window is built through the
factory and shows Breeze's chrome on a Breeze desktop. Picking System in
the switcher at runtime, or restoring a saved system, still only re-tints. The re-tint caveat and the switcher comment
name KDE.

Tests: switcher_offers_every_preset_light_and_dark pins the ten ids in
order, no duplicates, each in OFFERED and restorable through theme_from_id,
and each label's Light or Dark matching the theme's appearance; further
tests cover the KDE names and ids, --theme system never naming a preset and
forcing the follow-system restore, and the named-flag and saved-theme
restores.

The --help text keeps the m3 alias note on its own line.

CHANGELOG: Added entries for the feature, native_theme_kde (Native mode
only) and the catalog.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…mirror

New guide docs/native-theme.md, added to the book: what ThemeMode::Native
reads on Linux (KDE Plasma, GNOME, Cinnamon), macOS and Windows, what the KDE
reader takes from a Plasma desktop, NativeThemeRequest and
resolve_native_theme, native_theme_kde and why it answers only for a Breeze
desktop, the live watch, and the known limitation that switching re-tints but
keeps the shapes the tree was built with.

Guides: the styling system lists the fourth sibling preset and how its
colours come from a KDE colour scheme; the widgets overview, density guide
and milestones name KDE; the reactive theme guide gains a section on themes
that follow the desktop; the window-activation guide shows the KDE preset as
the PaintProjection example, with scheme-driven muting and
SelectionEmphasis::WindowActive. The README Themes paragraph now keeps
"Native mode adopts the live desktop colours" apart from "the KDE preset",
its acknowledgments name Breeze, and docs/features.md and
docs/project-information.md are regenerated from it.

Rustdoc: the ThemeSwitcher and presets module docs list the KDE crate, and
docs/widgets/theme_switcher.md is regenerated from the former (only that page).

Agent docs: CLAUDE.md gains the teksilo-kde-scheme and teksilo-theme-kde
entries, the tokens -> kde-scheme -> {platform, theme-kde} flow, the KDE
paragraph and a Native mode paragraph under Theming, key files, and KDE in the
window-activation and status sections; CLAUDE.review.md gets its crate block,
flow line and Native mode notes. The teksilo skill and the app guide (three
copies, identical) cover the theme-kde feature, ThemeMode and
native_theme_kde, the skill is copied over cargo-teksilo's embedded copy, and
the condensed brief in cargo-teksilo's setup.rs lists the feature.

Engineering docs: the specification folds in what the P11 pixel comparison
measured against Plasma, each tagged [measured-qt]: the item-row selection
radius is 5 (k.5.16), the wash on a marked check box or radio button is one
opaque sRGB composite and its outline is laid twice (k.5.6, k.5.7), the check
mark has square caps (k.5.6), the arrow nudge follows the arrow's direction
(k.5.17), with four rows in the Qt-behaviour ledger. Only those statements
change. The styling migration record gains the KDE preset and the two style
hooks, the accessibility audit counts five presets and the allow-list as the
test pins it (fifteen entries, six on an exception), and the density records
name KDE and the test that holds it to the unrendered-field list.

Pre-existing dashes on the touched mirror lines are replaced, so no added line
carries one. CHANGELOG: a Documentation entry under Added.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The sibling-preset lists and counts in the styling guide, the reactive-theme
guide, the target-audit and conformance docs, the density and hover
censuses and a few code comments named only Material 3, Fluent and macOS,
or said three or four presets where there are now five themes. They now
include KDE or avoid the count.

THEME_OWNED_PREFIXES in the target-audit allow-list lint now includes
"Breeze", the prefix KDE gives its private widgets, so an allow-list path
across a Breeze type is reported as going stale like the other presets. The
lint's test covers the new prefix.

The OsThemeColors fields added for desktop reports are marked Breaking in
the changelog, as the struct is now non-exhaustive.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
L1-1: when a kdeglobals layer held a bare [$i] line, the no-colours
fallback cascaded the scheme file on top of a merge whose whole-file lock
stopped every higher layer, so the file was never read. The colours were
KDE's compiled defaults, the accent was not applied, and scheme_origin still
named the scheme file. The specification did not say how the lock meets the
b.8 scheme-file layer; b.8 now states Teksilo's rule [derived]: the lock
suppresses the higher kdeglobals layers only. KConfig gains a crate-private
without_file_lock, and from_layers lifts the lock before cascading the
scheme file, so the file is read and accented and its origin is reported
truthfully, while every entry the lock made immutable (and every group a
[$i] header locked) still wins over the file. n.13 says the accent applies
under the lock too, and from_layers documents it. The new cascade test
a_file_immutable_kdeglobals_still_reads_the_scheme_file_under_its_locked_entries
pins Breeze Dark accented under a locked ColorScheme and AccentColor, a
locked frameContrast winning over the accented file, and the user layer
staying unread.

L1-4: the specification contradicted itself on fonts and [$e]. j.1, the
section on font keys, reads them as strings so expansion applies, and the
code and fonts_are_read_as_strings_expanded_and_unparseable_ones_keep_the_default
follow it; b.11 now names only colours, numbers and booleans as never
expanded and points at j.1. KdeFonts::read says so in its comment. No code
behaviour changes.

CHANGELOG: the KDE native-theme Platform entry says a kdeglobals locked as a
whole still takes its named scheme and accent.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…cent Classic too

L2-1: the clean-room vocabulary guard skipped every forbidden word on a
comment line, or any line starting with '#', that contained "doc.qt.io",
so a KDE-only identifier (KHCY, KColorScheme, tintHelper, ...) next to a
Qt documentation link, or inside a #[doc = "..."] attribute naming it,
passed unreported. A `//` comment citing doc.qt.io now exempts only the Qt
words (the new QT_WORDS list, QColor and qreal), every KDE word is still
reported there, and the bare '#' prefix is gone, as no scanned file has
'#' comments. The module doc says so. The new test
a_qt_documentation_comment_exempts_only_the_qt_words checks the three
comment forms, code before a comment and an attribute, and every KDE word
alone or beside a Qt word on a citing line; the old assertion that pinned
the gap is removed.

L2-3: accenting_a_breeze_constant_is_accenting_its_stock_file covered
Breeze Light and Breeze Dark only, while with_accent documents the
equivalence for all three stock schemes. Breeze Classic, the one with no
[Colors:Header] group, the legacy title-bar key and its own [WM] colours,
is now in the loop, with the vendored BreezeClassic.colors fixture, and
the assertion messages name the scheme. Dropping the [WM] group or the
title-bar flag from the text written for a scheme without a Header group
fails it now and passed it before.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…again

L3-1: when the watch's bus connection closed (the bus daemon exited or
restarted) inside a debounce window, `run` returned on `Heard::Closed`
before looking at the window, so the change it had heard was never posted,
and the watch thread ended for good. `run` now posts a window still open
at a close, unless the watch is stopping (the new `Debounce::take_open`).
`Running::start` takes a retry interval and an `FnMut` opener: after a
close it tries to open the source again every `RECONNECT_EVERY` (1 s)
until it can or the watch is dropped, pausing on the stop flag's new
condition variable so a drop still joins at once, and runs the reopened
source with a window already open, so the desktop is read again for what
nobody heard while disconnected. A bus that cannot be reached when the
watch starts is still not retried. The module doc says all of this; the
specification is silent on bus loss. New tests:
a_window_open_when_the_source_closes_is_posted_then,
a_window_open_when_a_stopped_watch_closes_posts_nothing,
a_pause_ends_at_once_when_the_watch_stops,
a_closed_source_is_posted_for_and_opened_again,
a_watch_waiting_to_reopen_is_dropped_promptly, and the opt-in real-bus
a_bus_that_goes_away_is_posted_for_and_reached_again, which kills a
private dbus-daemon 20 ms after a ConfigChanged and restarts it at the
same address (PrivateBus gains start_restartable, kill and restart).

L3-2: the cost note on `refresh_accessibility_preferences` still described
`busctl` subprocesses. It now says the contrast, reduced-motion and
screen-reader flags are zbus reads on one kept connection, bounded by the
500 ms call timeout with a 5 s quiet period per silent bus or service, and
that the text scale and GNOME fallbacks still run `gsettings`.
docs/a11y/explore-by-touch.md no longer names a missing `busctl` as a
failure cause, and says the zbus decoders are what is tested without a
bus, with the reads tested against a private dbus-daemon.

CHANGELOG: the OsThemeWatch entry says what happens when the bus closes.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ce, and test the watch

L4-1: under ThemeMode::FollowSystem a window that opened decided light or
dark from the winit hint alone, while a change heard later asked the
desktop's light or dark preference first and used the hint only when it
stated none. winit gives no hint on Linux, so every FollowSystem window
opened light on a dark desktop, the app turned dark at the next desktop
signal, and a window opened after that came up light and reset the
manager's theme. Both paths now decide through the new
OsQueries::follow_system_dark. The FollowSystem skip in
a_window_opened_and_a_live_change_agree is gone, the expectation in
a_window_opened_keeps_the_density is corrected, and two new tests,
a_window_opened_under_follow_system_reads_the_desktops_preference and
a_second_window_keeps_the_dark_a_live_change_set, fail without the fix.

L4-2: the test for the watch stopping under Manual could not fail, as a
manager with no event proxy never starts one. The manager now starts its
watch through an injectable DesktopWatchStart behind a small DesktopWatch
trait (the platform's OsThemeWatch in an app), and the accessibility
preferences are read through a new OsQueries::accessibility, so
route_os_theme_payload_from is the whole of route_os_theme_payload. The
part of drain_pending_theme_requests after the requests are taken is
apply_theme_requests. New tests, each red with its mechanism removed:
the_watch_runs_while_the_theme_follows_the_os_and_stops_under_manual,
an_explicit_theme_pick_stops_the_watch, following_the_system_starts_the_watch,
the_platform_watch_waits_for_the_event_proxy and
a_desktop_change_reads_the_accessibility_preferences_again.

L4-3: the previewer's PNG export named a Native canvas from the portal's
answer, while the canvas was resolved from query_native_desktop, and its
comment named a CanvasTheme::theme() that no longer exists. The name is
now read from the theme that was rendered (theme_label), and the new test
a_native_export_is_named_for_the_theme_it_rendered pins it.

CHANGELOG: a Fixed entry for the FollowSystem window.
Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ainted pair

L5-1: under the OS increase contrast preference the Material 3 Filled
button painted its hover and pressed fills as state layers mixed at paint
time over the lifted accent, which moved them back toward their own label:
5.79:1 and 5.26:1 in the light theme, 6.77:1 and 6.32:1 in the dark one,
below the 7:1 the CHANGELOG promised. The recipe now paints
SurfaceRole::AccentHover and AccentPressed, which the palette already
defines as the same 8 % and 12 % on-primary layers, so the projection lifts
them too and the normal colours are unchanged. The unit test
filled_button_hover_and_pressed_are_the_m3_state_layers pins the normal
fills, and filled_button_label_reads_7_to_1_in_every_state_under_high_contrast
measures the painted fill in every state, light and dark, active and
inactive; it fails with the state-layer recipe restored. The high-contrast
CHANGELOG entry says so.

L5-2: ColorTokens::from_os_colors took a supplied view_fg as the editor
text and caret unchecked. apply_os_view_colors now lifts editor_fg to
4.5:1 and editor_caret to 3:1 on editor_bg whenever a view colour is
supplied, and editor_code_block_fg to 4.5:1 on the code-block tint that
view_bg retints. New test from_os_colors_view_fg_is_lifted_on_the_view
(a #BBBBBB view text on white was 1.92:1).

L5-3: view_bg replaced surface_content without checking the window text
painted on it (1.29:1 for a #202020 view in a light scheme). view_bg now
becomes the content surface only where text_primary can reach 4.5:1 on it
and on surface_main, with text_primary and text_secondary lifted as far
as that needs; a view in the window's opposite tone colours the editor
alone, whose text is checked by the L5-2 lift. The specification is
silent on how Teksilo's tokens treat a mixed-tone scheme. New tests
from_os_colors_a_view_in_the_windows_tone_lifts_the_window_text and
from_os_colors_a_view_in_the_opposite_tone_stays_the_editors; the dark
case of from_os_colors_view_bg_overrides_the_content_surface now uses a
dark view, as it asserted a light view becoming a dark scheme's content
surface. The from_os_colors doc table and the OsThemeColors CHANGELOG
entry describe both.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ctive effects and alpha included

L6-1: the D1 lift of a hover or press wash and of the selection picked
whichever end of the luma scale gave the higher minimum contrast, so under
a mid-grey window text (118,118,118 in Breeze Light, or an inactive window
shaded to 118,128,138) a light fill crossed the text and went black: black
item hover, pressed, accent-subtle and selection surfaces in a light
window. The washes and the selection fills now move only toward the
window's end of the scale (the new teksilo_kde_scheme::math::
lift_luma_toward, which lift_luma_until now calls), and the primary text
is lifted first (D10 below), so the fill can always reach the floor on the
window's side. New test
d1_a_wash_or_selection_stays_on_the_windows_side_of_a_mid_grey_text
(greys 110 to 130 active, Shade 0.2, 0.25 and 0.4 inactive), and math
tests for the directed lift.

L6-2: in an inactive window whose scheme's Inactive effects shade, lighten
or darken the backgrounds toward the middle of the scale, the D2, D3 and
D4 lifts missed 4.5:1 and the high-contrast text missed 7:1 (Breeze Light
Shade 0.4, Breeze Dark Shade 0.1 under high contrast, the golden
SyntheticInactiveEffects). Two new rules, both Teksilo's (the
specification has no rule for either): D11 moves each surface text is set
on away from the window text, all in the window's direction and the
tooltip by its own text, until black or white reaches the text floor on
it, so every lift can succeed; D10 lifts the window, view and tooltip text
to the floor where they fall short, toward their own end of the scale, in
both projections. Stock Breeze keeps every surface and text in every
paint state (tested). The deterministic sweep now runs all four paint
states and takes every golden scheme, a mid-grey text, intensity effects
on Breeze Light and Dark and translucent colours as adversarial schemes,
each shown to fail without the lifts; arb_scheme generates random
Inactive effects (its two shrunk failures during the work are kept in
contrast.proptest-regressions). New tests
d10_a_scheme_text_that_passes_is_kept_and_one_that_fails_is_lifted and
d11_an_inactive_window_shaded_toward_the_middle_is_moved_back. The crate
doc's claims now hold; a dark view in a light window is listed as a known
limitation, as D11 moves it toward the window's tone.

L6-3: the lifts kept a scheme colour's alpha, so a translucent negative
foreground or focus decoration stayed translucent and short of its floor,
and the golden SyntheticValueForms' transparent positive foreground was a
1:1 text_success. Every lifted colour is now composited over the surface
it is lifted on first before it moves, and text_on does the same with the
selection text. New test
every_lifted_token_is_opaque_even_from_a_translucent_scheme_colour, and
d5_the_focus_ring_passes_and_stays_opaque covers the translucent schemes
in every paint state.

CHANGELOG: the KDE preset and teksilo-kde-scheme entries say so.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… keep a hovered control's high-contrast boundary

L7-2: the rich_text_editor slot was listed as unfilled with a reason that
did not match the code: RichTextEditor reads style_slots.rich_text_editor,
and with the slot empty the stock recipe framed an editable editor in
BorderRole::Default (Breeze's raw frame outline, 1.48:1 on the Breeze
Light window) at radius 4, switching to a 2 px border on focus. The preset
now installs styles::rich_text_editor::BreezeRichTextEditorStyle, which
frames an editable editor with the line edit's own BreezeFieldChrome
(k.5.20 frames a text area in the input-frame outline at radius 4.5):
border_strong at rest, border_focused while focused, a 1 px pen, the view
fill or the editor's own background, and the text 6 px inside the frame
(the line edit's inset; the specification is silent on where a text
area's text starts). A read-only viewer stays frameless through the
stock recipe. BreezeFieldChrome gained a fill override for the
background. rich_text_editor is gone from KDE_UNFILLED. New test
an_editable_rich_text_editor_is_framed_as_the_line_edit, red without the
slot.

L7-3: under high contrast, a hovered field, check box, radio button,
switch or slider handle was outlined in the scheme's unlifted hover or
focus decoration in place of its lifted outline (2.18:1 on the Breeze
Light window, against 13.33:1 at rest), the check box's ring stroked
over the outline itself. The new color::high_contrast_boundary lifts that
decoration to 4.5:1 on the window, view and frame background and on the
control's own face under high contrast, and keeps the scheme's colour in
the normal projection. BreezeFieldColors::hover, BreezeIndicatorColors::
button_hover and focus, and BreezeSliderColors::hover go through it. New
tests in tests/high_contrast.rs:
a_hovered_control_keeps_a_four_and_a_half_to_one_boundary (every swatch,
active and inactive, red without the lift),
the_schemes_hover_decorations_fall_short_on_a_light_window (the premise)
and the_normal_projection_keeps_the_schemes_hover_colour.

L7-4: the known limitations said a tab is always 30 px tall; it is 30 at
Compact, 32 at Comfortable and 44 at Touch, and never Breeze's 34 px
static-bar height, which the list now says.

L7-5: Breeze's taller first row (first_row_extra) was computed and never
applied, and nothing said so. The known limitations, standard_item's
departures and the field's own doc now say a row is not told its index,
so the first row is 24 px like the others where Breeze's is 25.
known_limitations_state_the_tab_and_first_row_heights_the_styles_give
checks both statements against the heights the styles give, and is red
with the old wording.

CHANGELOG: the KDE preset entry says so.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… fixtures

L8-1: NOTICE section 1.5 and the comment above teksilo-kde-scheme's
exclude list said tests/breeze_files.rs was the one test that reads the
LGPL-2.0-or-later Breeze fixtures, but tests/golden_accent.rs also embeds
BreezeLight.colors, BreezeDark.colors and BreezeClassic.colors with
include_str!. The package was already right (golden_accent.rs is in the
exclude list); the licensing record was not. Both now name
tests/breeze_files.rs and tests/golden_accent.rs. New test
the_licensing_record_names_every_test_that_reads_the_fixtures in
tests/breeze_files.rs finds every tests/*.rs that names the fixtures
directory and checks that each is in the exclude list, in the comment
above it and in NOTICE section 1.5; it is red with either old wording.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…ces D11 carries, lift a hovered spin arrow under high contrast

R-1: D11 moved the colour tokens' surfaces back from the middle of the
scale, but the Breeze styles painted their faces from BreezeColors, which
was computed from the scheme's raw palette. In an inactive window of
Breeze Dark with Inactive Lighten 0.4 a push button's label was 2.31:1 on
its face, in both projections; with Shade 0.1 under high contrast 6.15:1.
color::Surfaces now holds the carried window, view, button and frame
background of a colour state, and both color::tokens and
BreezeColors::compute (which takes a high_contrast flag for the floor)
read them, so frame_background is surface_raised, frame_outline is
border and the button colour is the surface the primary text is lifted
on. The button text is lifted the same way (D10) on the button colour.
The Disabled state keeps the scheme's colours. The faces the styles mix
from the button colour are moved as an accent wash is until the primary
text reaches the floor (color::face_under_primary_text): the pressed,
default and checked push-button faces (BreezeButtonColors gains
default_face and checked_face fields in place of the two methods) and
the hovered and pressed header sections. The marks the styles draw on
their own faces are lifted to the non-text floor (color::mark_on_faces):
the header sort arrow, the combo box arrow on its face at rest and open,
and a check box's or radio button's mark on its washed box, now one
colour at rest and one pressed (BreezeIndicatorColors::text_pressed,
BoxPaint::mark). Stock Breeze keeps every colour. New test
every_face_a_style_paints_carries_its_text_for_every_scheme_in_every_paint_state
in tests/contrast.rs sweeps those faces over every scheme of the sweep
in the four paint states (174 failures before), and the property now
checks them too; the_combinations_are_made_from_the_surfaces_the_window_paints
in palette.rs checks the premise and that the combinations match the
tokens. The crate, colour mapping, palette, indicator and combo box
documentation and the CHANGELOG entry say so.

R-2: a spin box's hovered half drew its chevron, its only visual, in the
scheme's unlifted hover colour under high contrast (2.49:1 on the Breeze
Light view, against 9.41:1 at rest). BreezeSpinArrowColors::hover now
goes through high_contrast_boundary against the view. The spin box is
in a_hovered_control_keeps_a_four_and_a_half_to_one_boundary (red
without the lift), the premise test checks the hover colour on the view
and the normal projection keeps the scheme's colour.

R-3: known_limitations_state_the_tab_and_first_row_heights_the_styles_give
had been inserted between a_density_switch_reprojects_the_rows_and_bars
and its doc comment. It now sits above that comment with a doc comment
of its own. New test
the_limitations_and_row_density_tests_carry_their_own_documentation
reads lib.rs and is red with the old order.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
A tab bar paints an unselected tab's title in TextRole::Secondary (the
idle_text_role default of TabBar and TabWidget) on BreezeTabColors'
unselected face, which k.5.14 and d.11 fix as the Window colour darkened
120 percent, and hovered on the window moved 20 percent toward the hover
colour. D2 lifted text_secondary on the token surfaces only, so stock
Breeze Light gave 3.58:1 on the unselected tab in an active window,
hovered unselected tabs with several accents about 4.16:1, and inactive
and high-contrast states of other schemes less.

D2 now also lifts the secondary text on the two unselected-tab faces,
computed from the same colour state as the tab style computes them
(color::tab_unselected_face and color::tab_hovered_face, which
BreezeTabColors::resolve now calls too, so the face and the backdrop
cannot drift; the duplicated RECESSED_FACTOR is gone). Under high contrast
the secondary text is the primary text lifted toward its own end on the
same surfaces. Where black or white could not reach the floor on a face
(a window D11 had carried just to the floor, then darkened or mixed toward
a light hover), D11 now moves the window on until it can
(color::carry_window); the faces keep the specification's rule, computed
from the moved window. Stock Breeze keeps every surface.

Breeze Light's text_secondary goes from 90,100,111 to 76,85,94: 4.52:1 on
the unselected tab, 2.01:1 from the primary text (2.54:1 before). Breeze
Dark (1.77:1) and Classic (1.89:1) do not change. The crate and colour
mapping documentation (D2, D11, the high-contrast projection) and the tab
style say so; the CHANGELOG entry says unselected tab titles are readable.

New tests in tests/contrast.rs:
d2_premise_breeze_lights_inactive_text_fails_on_its_unselected_tab (the
vendor InactiveText is 2.51:1 there),
d2_secondary_text_passes_on_the_unselected_tab_faces_in_every_paint_state
over Breeze Light, Dark, Classic, the golden schemes, the ten accents in
both appearances and the adversarial schemes, and the painted-face sweep
mounts a TabBar and measures its unselected title's glyphs against the
face the frame records, at rest and hovered, so the sweep and the
property over random schemes cover the pair end to end. With the fix
removed the two sweeps and the property fail (92 failures, Breeze Light
at 3.58:1); with only the window carry removed six pairs still fail.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
… one opaque fill

F-1: a scheme may write its Button background with an alpha, and D11
carries it with that alpha, so BreezeColors.button and
BreezeIndicatorColors.button stayed translucent. BoxPaint::composited_fill
then returned None, paint_box laid the face and the 30 % highlight wash as
two layers over whatever lay behind the box, and mark_on_marked_face lifted
the mark against the bare face's RGB. Breeze Dark with the Button
background at alpha 0.8 left the mark 4.39:1 (Inactive Lighten 0.4) and
4.31:1 (Shade 0.1) on the box as painted in an inactive window under high
contrast, under the 4.5:1 floor.

BreezeIndicatorColors::resolve now makes the button colour opaque over the
window (color::opaque_over, over surface_main) before anything is computed
from it, with and without the palette: the pressed face, the hover and
focus decorations and the checked outline are made from the opaque one, a
box and a disc are one opaque fill wherever the control sits, with the
wash composited over it as for stock Breeze, and the mark is lifted on the
colour the box shows. The specification is silent on a translucent face;
the indicator documentation says what Teksilo does. The BreezeColors
documentation no longer says every colour but the shadow is opaque: a
translucent scheme surface stays translucent, a lifted text is opaque.
The CHANGELOG entry for the check box says so. Stock Breeze keeps every
colour.

The face sweep in tests/contrast.rs measures the box as painted (one
opaque fill, or a translucent face and the wash over the window) in place
of expecting an opaque face, and two adversarial schemes, Breeze Dark with
a translucent Button background under Inactive Lighten 0.4 and Shade 0.1,
join every sweep; MarkOnCheckedBoxEnhanced records that the first one's
own mark falls short. New unit test
a_translucent_button_colour_paints_one_opaque_box_its_mark_reads_on in
styles/indicator.rs. With the fix removed both are red (4.39:1 and 4.31:1
in the sweep, an alpha of 0.8 in the unit test).

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
tools/reader/kde_visual_check.sh builds the debug widget-catalog and
teksilo-automation-mcp into this checkout's target directory, then runs
itself again through private_session.sh, so the catalog and
plasma-apply-colorscheme only ever run in the private session. The driver,
kde_visual_check.py (stdlib only, run with python3 -I), refuses to run
outside it, with XDG_CONFIG_HOME unset, equal to ~/.config or outside the
private runtime directory, and fails if the user's kdeglobals changed.

For kde-light, kde-dark and system it seeds the private configuration with
a Breeze scheme through plasma-apply-colorscheme, starts the catalog with
XDG_CURRENT_DESKTOP=KDE and KDE_FULL_SESSION=true, attaches over the
automation bridge's endpoint descriptor and finds each control in
layout_tree by its type name: push button, line edit frame, spin box,
combo box, check box row, list and tree rows, the ListView, TreeView and
TableView rows, table header, tab, the scroll bars of a ScrollArea and of
a ScrollBar, and a row of a menu opened from a menu bar. It prints each
height beside the sizes.json entry it uses, the specification's k.4 size
and the preset's height with its documented deviation (D8 check row 24;
the 12 and 8 px scroll bars), and fails on a control more than 1 px off
the preset's height or missing from the catalog. Rows whose height the
catalog itself sets (TableView 28, TreeView 28, ListView 32) are INFO,
and the two controls the catalog has no instance of (a row with an icon
or a check box, a table at the preset's D7 row height) are named with the
reason. It checks the title-bar theme switcher's entry and that the push
button is painted by BreezeButtonChrome. In system mode it then applies
BreezeDark and the accent #e93a9a while the catalog runs, polls
screenshots with a deadline until the button turns dark and the selected
tab's changed pixels move toward the accent, and checks that the layout
node ids and every active node's bounds are unchanged.

The scheme seed goes through the other Breeze scheme first: with no
kdeglobals, plasma-apply-colorscheme takes the system default for the
scheme in use and writes nothing when asked for it, and that default
differs between machines. docs/native-theme.md says what the check does
and what it needs.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The private session keeps the desktop's XDG_CONFIG_DIRS, which on Plasma
lists ~/.config/kdedefaults (written by the Global Theme settings) before
/etc/xdg, and the catalog reads every kdeglobals of it (b.7). A widget
style, a font or a default scheme there decided what the check measured: a
kdedefaults with widgetStyle=Fusion turned system mode to the generic
geometry (push button 24, no Breeze line-edit frame). And the scheme seed
did not write the scheme "whatever the default is", as the previous commit
said: plasma-apply-colorscheme writes no ColorScheme key when asked for the
scheme the cascade defaults to, so with a Breeze Light default (Plasma's
stock one, or an empty XDG_CONFIG_DIRS) kde-light and system failed before
measuring anything. The check passed here only because this machine's
kdedefaults names BreezeDark.

The driver now gives plasma-apply-colorscheme and the catalog an
XDG_CONFIG_DIRS of one private directory, whose kdeglobals names
BreezeClassic, a scheme no mode seeds, and sets KDE_SKIP_KDERC. The seed is
one call and is always written. Both reviewer environments (an empty
XDG_CONFIG_DIRS dir before /etc/xdg; one with widgetStyle=Fusion) now pass.

The live change in system mode proved only that the tree paints in the new
colours: a screenshot renders the tree offscreen. The catalog now runs with
TEKSILO_IDLE_TRACE=1 and the driver counts the frames presented to the
window in the 2 s after each change with no bridge call, against the same
span with nothing changed: 3 and 2 frames after the scheme and accent
changes, 1 with nothing changed and 1 when the scheme already in use is
applied again. The repaint times, which timed screenshots, are gone, and
the docs say what is and is not shown.

The radio button row (24, D8), the switch (24, raised from 36 x 18), the
slider (24, raised from 20), the progress bar (26) and a one-line tooltip
(its small text line box plus 2 x 4, the known limitation) are measured;
the docs say the table row is INFO.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The known limitation and the tooltip module doc blamed the small text
style's line box (15 + 8 = 23). The visual check measured 21.31 px with
the fixed presets and 22.98 px in system mode instead: TextWidget does
not read TextStyle::line_height, the text backend lays a single line out
at the face's own natural height (Inter at 11 px: 13.31; Noto Sans at
11 px: 14.98), and the tooltip is that plus 2 x 4 px.

The docs now say so, and that every preset shares it: matching Plasma's
26 px would take the tooltip text in the body style or TextWidget
honouring line_height, both framework changes. A new mount test pins the
relation (frame = text node height + 8, with the text node the
backend's own line and not the style's line box), and the visual check's
expectation string no longer says "line box".

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…es in `status`

`cargo teksilo update` brings what this tool installed to the app's release:
the probe harness, the project's agent instructions, and the
teksilo-automation-mcp and cargo-teksilo-fmt binaries on PATH, reinstalled with
`cargo install` into the root they came from. It never installs what is
absent. It lists the changes and asks once; `-y` skips the question, `-i` asks
per action and prints the equivalent manual command, `--dry-run` only lists.
`--agent` limits it to the project's agent copies, `--user` covers the ones
under the home directory instead. A tool older than the app is refused,
because it would write an older release's files over the ones it refreshes.

A prompt nobody answers hangs an agent or a CI job until something kills it.
`init` and `update` now treat `$CI` and `$CLAUDECODE` like a stdin that is not
a terminal: they print the listing and exit with an error naming `-y`, and
`-i` refuses before printing anything. The shipped skill and brief tell an
agent to run `--dry-run`, show the user the list and pass `-y` once they
agree, and `update` ends by saying to restart the agent session or the MCP
server when it replaced what they run.

`status` gains a `Tool` line and a `Fmt` line, and prints the install command
for any of the tool, the automation server or the formatter that is older than
the app, a patch-level difference included.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
The Unreleased section had grown to 586 lines, most of it from the KDE
Plasma preset branch, and read as API documentation: the
teksilo-kde-scheme entry alone walked through every type and function,
and the preset entry described each control's pixel geometry.

It is now 211 lines in the voice of the released sections: each bullet
says what changed for a user and names the items they would call. Every
Breaking item keeps its own bullet with what to change (OsThemeColors,
the macOS and Fluent menu item styles, the tab tool-window height), as
does every Behaviour change, and every user-visible fix stays. Fixes to
the unreleased KDE preset and OsThemeWatch are folded into the entries
that introduce them, and the per-control details now live only in the
crate documentation the preset entry points to.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…lost

A review of the rewrite found five places where condensing changed or
dropped what a user needs to know, and each was checked against the code.

The GTK bullet said Native mode follows any CSS theme's focus colour; it
reads only Breeze GTK's theme_button_decoration_focus_breeze, so the
bullet now limits focus to Breeze GTK. The KDE accent bullet regains
that the view, link, status and focus colours are read from the scheme,
a behaviour change on a KDE desktop with another widget style. The style
hooks bullet names ProgressBarStyle::paints_busy_indicator, which is not
a size, and StandardItemStyle::min_height_decorated. The native_theme
bullet names configured_native_theme() and presets::intui::from_os, both
new public API. The portal bullet says the settings are now read without
busctl installed and from portals older than version 2 of the Settings
interface, through the Read fallback.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Brings in cargo teksilo update (415e71b) for the 0.20.0 release. The
only conflict was CHANGELOG.md [Unreleased]: its cargo-teksilo entries
go under Added and Changed beside the KDE preset's.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
…m separator

The help line for an outdated tool joined the literal "crates/cargo-teksilo"
onto the checkout, so on Windows it suggested
`cargo install --path /src/teksilo\crates/cargo-teksilo`, mixing separators.
Join one component at a time, as resolve.rs does, and build the test's
expected path the same way so the Windows run checks the real separator.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
Path::join inserted a backslash on Windows, which reached the
$QT_DATA_HOME expansion of [$e] entries. The module already treats XDG
paths as POSIX paths on every host.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
XDG paths must start with `/`, so a Windows scratch directory is not
one and the cascade fell back to /etc/xdg. The watch reads these files
on Linux alone.

Signed-off-by: Cyril Jacquet <cyril.jacquet@ferntech.eu>
@jacquetc
jacquetc merged commit ce918d3 into main Oct 19, 2026
34 of 36 checks 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