feat(qt): reduceMotion seam and a coarse skeleton tick

The owner ruled 3-C: build the seam here so the kiosk port consumes it
instead of having to invent it. The earlier plan was to wait for kiosk to
define it, but kiosk is not implemented - it is a contract - so "wait for
kiosk" meant waiting on a date that does not exist.

The shape is no longer a guess, which was the only objection to building it
early. Two independent sources fix it: the kiosk contract mandates "FORCED
reduce-motion while in kiosk", and the reference computes the flag as
kiosk_profile || !use_gpu_renderer.

QbzShell.reduceMotion is derived, never a preference - do not add a Settings
row for it. The existing play-indicator toggle is a preference and a
different thing; binding them would make "animate the play indicator" change
this dropdown's pulse rate.

Only the kiosk half is wired, and the omission is deliberate rather than
partial: the renderer tier has no honest source here. It is a QML-side fact,
this port runs on the GPU in every real session, and the one place a probe
would report software is the offscreen smoke, which forces it by definition.
Wiring it would turn reduce-motion on exactly where it means nothing. The
kiosk literal is a named const with a pointer to their contract; flipping it
is one line on their side.

The cortinilla skeleton is the ONE consumer: its single host Timer drops
from 700ms to 2000ms. No new timer, no per-delegate animation - the
reference's own note records that this pulse was the main cost of opening
the cortinilla on weak GPUs.

Giving that Column an id also fixes a real scope bug on the way past. The
Timer read an unqualified `parent`, and Timer derives from QtObject, which
has no parent property - so it resolved up the scope chain, out of the block
entirely. The sibling rectangles' parent.parent[.parent] chains are now
explicit too.

EqualizerBars.qml and the immersive contract's D11 both recorded that Qt had
no reduce-motion twin. Both are corrected: the seam exists, those surfaces
are simply not consumers yet, and wiring each is its own one-liner.

Contract: qbz-nix-docs/qt-frontend/2026-08-03-cortinilla-parity, commit C4b
(section 5.g G1-G5, ruling R10; D-BUG-1 folded in).

Gate: build clean, 258/258 tests, both QML audits OK, offscreen smoke
0 complaints + home published. AC-39: exactly one behavioural consumer.

cc
This commit is contained in:
vicrodh committed 2026-08-03 16:48:07 -06:00
1 parent 033b838b98
commit 4e1703c436
3 files changed
+58 -7

No files matched your search

@@ -16,7 +16,10 @@
// (Svelte keyframes 0/100% -> 0.3, 50% -> 1.0). The loop runs only while
// the item is visible — every mount site gates visibility on npPlaying
// (ImmersiveTrackInfo.slint:85,:100), so a paused player animates nothing.
// Slint's reduce-motion coarse clock has NO Qt twin (D11) — not ported.
// Reduce-motion: the seam EXISTS now — `QbzShell.reduceMotion`, built by the
// cortinilla-parity contract (§5.g). This surface is simply not a consumer
// yet; wiring it is a one-liner (throttle the tick when it is true) and its
// own change, not a side effect of someone else's commit.
import QtQuick
+15 -6
View File
@@ -193,15 +193,24 @@ Item {
// --- Loading skeleton (no cached instant-paint — one clean
// apply, Cortinilla.slint) -------------------------------
Column {
id: skeleton
visible: QbzSearch.cortinillaLoading
width: parent.width
topPadding: 4
property bool pulse: false
// ONE host Timer flipping ONE bool for all five rows —
// never a per-delegate animation. Under reduce-motion it
// drops to a ~8fps coarse tick, which is what the
// reference switches its pulse's time source to
// (Cortinilla.slint:128-131). That file records why: this
// pulse was the main cost of opening the cortinilla on
// weak GPUs, because every frame was a full-window
// repaint.
Timer {
interval: 700
running: parent.visible
interval: QbzShell.reduceMotion ? 2000 : 700
running: skeleton.visible
repeat: true
onTriggered: parent.pulse = !parent.pulse
onTriggered: skeleton.pulse = !skeleton.pulse
}
Repeater {
model: 5
@@ -215,7 +224,7 @@ Item {
height: 40
radius: 4
color: theme.surfaceElevated
opacity: parent.parent.pulse ? 0.48 : 0.16
opacity: skeleton.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Column {
@@ -227,7 +236,7 @@ Item {
height: 11
radius: 3
color: theme.surfaceElevated
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
opacity: skeleton.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
Rectangle {
@@ -235,7 +244,7 @@ Item {
height: 9
radius: 3
color: theme.surfaceElevated
opacity: parent.parent.parent.pulse ? 0.48 : 0.16
opacity: skeleton.pulse ? 0.48 : 0.16
Behavior on opacity { NumberAnimation { duration: 650; easing.type: Easing.InOutQuad } }
}
}
+39
View File
@@ -135,6 +135,19 @@ pub mod qbz_shell {
// "ambient"). Live — toggling applies immediately (pure QML
// layering). Combined in QML with npHasTrack for the D4 no-track
// rule.
/// Reduce-motion: throttle every continuous animation to a coarse
/// tick instead of the display rate.
///
/// DERIVED, never a user preference — the reference computes it as
/// `kiosk_profile || !use_gpu_renderer`
/// (`qbz-nix/crates/qbz/src/main.rs:8597`) and there is no Settings
/// row for it in either frontend. Do not add one: the play-indicator
/// animation toggle is a *preference* and is a different thing.
///
/// Built by the cortinilla-parity contract (§5.g, ruling R10) so the
/// KIOSK port can consume it rather than having to invent it. See
/// `set_reduce_motion` for the two halves and why one is dormant.
#[qproperty(bool, reduce_motion)]
#[qproperty(i32, ambient_mode)]
// Album-art triad (ambient_qt.rs), pushed on track change.
#[qproperty(QString, ambient_primary)]
@@ -399,6 +412,7 @@ pub struct QbzShellRust {
window_maximized: bool,
window_min_width: f32,
window_min_height: f32,
reduce_motion: bool,
ambient_mode: i32,
ambient_primary: QString,
ambient_secondary: QString,
@@ -460,6 +474,7 @@ impl Default for QbzShellRust {
window_maximized: crate::settings_qt::window_maximized(),
window_min_width: crate::settings_qt::WINDOW_MIN_WIDTH,
window_min_height: crate::settings_qt::WINDOW_MIN_HEIGHT,
reduce_motion: reduce_motion_at_boot(),
ambient_mode: crate::settings_qt::app_background_mode(),
// The Slint ImmersiveState default triad (pre-artwork colors).
ambient_primary: QString::from("#00dcc8"),
@@ -526,6 +541,30 @@ static QT_THREAD: OnceLock<CxxQtThread<QbzShell>> = OnceLock::new();
/// Queue a shell-bridge mutation onto the Qt event loop (no-op before
/// boot registers the thread).
/// Kiosk profile — NOT PORTED YET. The kiosk contract
/// (`qbz-nix-docs/qt-frontend/2026-08-02-kiosk-port/00-CONTRACT.md:244`)
/// mandates "FORCED reduce-motion while in kiosk", so when that lands it
/// replaces this literal with the real profile flag and the seam is already
/// tested. One line, on their side.
const KIOSK_PROFILE_ACTIVE: bool = false;
/// The boot value of [`reduce_motion`].
///
/// The reference is `kiosk_profile || !use_gpu_renderer`. Only the FIRST half
/// is wired here, deliberately:
///
/// The renderer-tier half has no honest source in this port. The tier is a
/// QML-side fact (`GraphicsInfo.api`), not a Rust one, and this port runs on
/// the GPU in every real session (OpenGL RHI, measured 2026-07-29). The one
/// place where a tier probe WOULD report software is the offscreen smoke,
/// which forces it by definition — so wiring it would turn reduce-motion on
/// exactly where it means nothing and stay off everywhere it would matter.
/// If a genuine software-tier session ever appears (a remote desktop, a
/// llvmpipe box), that is when the second half earns its probe.
fn reduce_motion_at_boot() -> bool {
KIOSK_PROFILE_ACTIVE
}
pub(crate) fn ui(f: impl FnOnce(Pin<&mut QbzShell>) + Send + 'static) {
if let Some(thread) = QT_THREAD.get() {
let _ = thread.queue(f);