Port of contract section 8. Three Slint blocks, faithfully:
- main.rs:7855-7876 kiosk_profile_active() / resolved_shell_screen()
- main.rs:8586-8618 kiosk entry (forced reduce-motion; fullscreen ONLY via
QBZ_KIOSK_FULLSCREEN, windowed otherwise -- incident 2026-07-11: a user who
toggles kiosk on the desktop and restarts would be trapped, since the kiosk
shell has no titlebar control and neither Esc nor F11 leave fullscreen)
- main.rs:11767-11780 the live toggle, in the reference's order: persist,
flip the flag, then swap the screen. The flag first means the shell that
mounts next reads a correct kioskProfile on its FIRST frame.
"kiosk" is a SIBLING screen of "shell", not a mode inside it, which is how
app.slint:38 models it (AppScreen { splash, login, shell, kiosk }, sibling
mounts at :145 and :205). Both shells read the same bridges, so the toggle
tears nothing down and playback keeps running.
The toggle reads the CURRENT profile rather than re-resolving: resolving
would let a QBZ_PROFILE env override pin the reading and a user on a kiosk
image could never toggle out (the reference says so at :11764-11765).
ViewModeMenu's delegate called QbzImmersive.openFromMenu() unconditionally,
so flipping any row's `live` would have made it open Immersive. Each row now
carries its own action. Miniplayer stays the one inert row.
The small bar's view button becomes the toggle itself in kiosk
(PlayerBarSmall.slint:746-750): every other flyout row is gated
!kiosk-profile there, so the menu would carry a single row.
reduce_motion's kiosk half is now live -- shell_bridge.rs had the seam
pre-built with a `false` literal and a note saying one line, on their side.
Also: the gear icon. NavRail tile 7 uses settings.svg; this port only had
settings-2.svg, which is two sliders, a different glyph.
288 tests (4 new), 9 warnings = baseline, both QML audits OK.
cc
The owner's clue was the whole diagnosis: the same image looked fine at
220px in the hover preview and bad at 150px in a card. So not the source,
and not the URL variant either -- every Qt producer picks exactly what
Slint picks, `large` (600px) into a 200px cell, 3x headroom.
`artwork_qt` was producing the right derivative and `RoundedImage` was
drawing the untouched 600px original next to it. `Context2D.drawImage`
does not filter, so every card was a nearest-neighbour downscale: measured
on a card cropped out of the running binary, RMSE 0.000 against a
point-sampled reference. The preview looks right because it is a plain
Image with smooth+mipmap and never touches this path.
Three defects were latent behind that dead derivative: `thumbnail(w,h)`
does not preserve aspect (46 square covers on disk written as 32x28),
`to_rgb8` matted transparent logos onto black, and the file was JPEG q75 --
more damage than the resample it was meant to cure.
Qobuz playlist art is 800x380, and both the header and the card were
stretching it to fill a square. It now keeps its ratio, and the uncovered
area takes a gradient derived from the image itself, reusing the ambient
colour pipeline rather than starting a second one.
cc
**Icons follow the theme.** The bakes are frozen dark-palette hexes, so on light
themes secondary, muted and accent were simply wrong, and accent was wrong on
any theme whose accent is not #4285f4. Slint recolours at render time, which
this path cannot do — so the tint is generated once per colour into the cache
and loaded through the same Image+svg path the bakes already use. No effect, no
shader, so it cannot hit the "renders nothing on software" failure.
The sweep found the part the diagnosis had missed: "primary" was two meanings
welded together — max-contrast-on-theme-surface, and literal white for glyphs on
hosts that are dark under every theme. Remapping it globally would have painted
dark glyphs on dark scrims in fourteen places. The vocabulary is split instead,
with "primary" left as an alias so nothing changes until it is migrated
deliberately.
**The album header gradient was Slint's fallback, not its design.** Slint paints
a blurred artwork field and only falls back to the flat two-band gradient when
that image is unavailable; the port shipped the fallback. The field needs no
shader — it is a 128x128 image, so the pipeline ports verbatim: the cover down
to 8x8, back up to 128, blur, colour adjust, vignette. The 8x8 step is the look,
not an optimization; everything after it works from eight pixels of colour,
which is why it reads as atmosphere rather than as a blurred cover. It comes out
of the same decode that already produced the flat tint.
**Covers were point-sampled.** Canvas drawImage does not filter when it scales,
measured at RMSE 0.000 against a nearest-neighbour reference — a 600px cover in
a 200px cell throws away eight of every nine pixels, and the error is aliasing,
not softness. Rust now produces a derivative at the drawn device size so the
draw is 1:1 and there is no resample at all.
**The now-playing context stack.** It had no Qt counterpart: the model carried
context_kind and context_id, some builders stamped them, and nothing ever read
them back. The album path only appeared to work by coincidence, because the
glyph was hardcoded to open the album.
Plus: Discover's missing rails with real play history, Recommendations on the
integrations, queue drag reorder, and the playlist detail collage.
cc
Two fronts of the modularization, both gated green before this commit.
QML leaves the flat qml/ directory for seven modules: cards, controls,
rows, settings, shell, theme, views. Only Main.qml and LoginScreen.qml
stay at the root. build.rs matches the tree exactly (55 files), relative
imports and asset paths were re-anchored for the new depth.
The QbzBridge God-object starts breaking up: QbzSession, QbzShell,
QbzPlayer, QbzQueue and QbzHome are now their own #[cxx_qt::bridge]
singletons, each with its own OnceLock<CxxQtThread> and ui() hop, booted
in order from Main.qml. Bridge files stay directly under src/ because
cxx-qt-build only accepts rust_files from one directory (QTBUG-93443).
bridge.rs keeps 5.5 domains (library, album/artist, settings,
integrations, search, playlist) and dies once they are split out.
cc