The awkward hand-off was not the dwell gate — it was committing at whatever shift the thumb left. quickstep travels to the end target first; settleTo does that now. Dwell restored to match prior art. Records the geometry being measured rather than assumed, and two findings that are not this task's: furniture_at() is a stub so synthetic touch cannot reach the pill, and Origin is two-valued so agent touches carry no audit.
24 KiB
TASK 60 — multitasking is a place, not a transition
Status: 2026-08-07 (latest) — both halves are built and the strand repro passes on hardware. The compositor owns the transition geometry; the shell drives it through four verbs and names the card rect once. Read "One owner" for why, then "Shell half: DONE" for what landed.
One owner — the rebuild, 2026-08-07
The fault, stated once
Two writers own one geometry. The rail scales the real windows through
pose; ZoneOverview scales its cards through scale. Both read
zonePullProgress, and a session was spent trying to make the two numbers
agree — the card ran 0.6 → 1.0 while the pose ran 1.0 → 0.6, so at the
handoff the window was at 0.6 of the glass and the card arrived near full
bleed.
That is DEVICE-STATE-MACHINE.md §1's blind actors in the render path, and
TASK-52 names the same rule for exactly this reason: one attention model, one
clock, effects as strategies over it. Every symptom is the one fault — the
size pop, the doubled frame (viewtop's 3 px #9EC7FF under the card's
radius: 18), and the strand.
The strand is the proof. Casey, 2026-08-07: open btop, swipe to
multitasking, tap Home, reopen multitasking, tap btop — btop comes back as a
small card in the middle of the screen. Every clear-the-pose path built that
day hangs off the rail's drag edge (endPull → clearPose). Those taps are
ZoneOverview's and never touch the rail, so nothing ever runs. A transform
that one surface applies and a different surface must remember to undo will
always have a path that forgets. Note also that pose is TASK-52 #10,
gravity wells — an atmosphere primitive that navigation borrowed.
What the prior art does — quickstep, on this machine
~/Downloads/lineage-trebuchet/quickstep/src/com/android/quickstep/.
- One float.
SwipeUpAnimationLogic.mCurrentShift∈ [0,1] — its own comment: 0 = preview snapshot completely visible, 1 = "preview snapshot completely aligned with the recents view".TaskViewSimulatortransforms the live window onto the real card rect. The card never has to be made to match the window, because the window is placed on the card. There is no second geometry. - The drag length is derived from the destination.
getSwipeUpDestinationAndLength(dp, ctx, TEMP_RECT, …)returns the rect and the length together, andmDragLengthFactor = dp.heightPx / mTransitionDragLength. OurmissionAtis 18% of the panel, a number with no relationship to where the card actually is. - End targets are an enum, not a set of booleans:
HOME, RECENTS, NEW_TASK, LAST_TASK, ALL_APPS(GestureState.java:77). Every gesture resolves to exactly one, andLAST_TASKis a first-class destination — "went back to the app" is a real endpoint that animates, not an abandon branch. - The controller owns the transform and hands it back at the endpoint. Nothing hopefully-undoes anything, which is structurally why Android cannot strand a window and we can.
- Dwell is first-class.
isFling = mGestureStarted && !mIsMotionPaused && |endVelocity| > threshold, and in gestural modemIsMotionPaused → RECENTS(AbsSwipeUpHandler:1121,1322). A pause is never a fling.MIN_PROGRESS_FOR_OVERVIEW = 0.7.
Sailfish/Glacier (~/phone-qml-research/glacier-home) is the QML-side reference
for the same shape and is worth reading before the shell half.
The plan
- One owner of the geometry. The compositor owns the transition: one verb
carrying
shift0→1, where 1 means the window sits exactly on its card rect. The destination rect is named once at gesture start, not re-derived per frame. The shell stops callingposeActiveZonefor navigation and stops scaling cards;ZoneOverviewdraws chrome — label, close control, neighbours — and never a competing transform. - End targets as an enum. One end-target state replaces
missionControlOpen/overviewOpen/zonePullProgress. Entering and leaving become the same machinery whether the gesture came from the pill or from a card tap — which is precisely what the strand repro broke.LastTaskis a destination, so the transform is released by arriving, not by remembering to clear. - Detents from the destination rect, not 18%. Keep the dwell; make "paused is never a fling" explicit rather than emergent.
- One frame. A window inside the transition is furniture in the overview's
composition, so
focus_bordersuppresses itself while a window is owned by the transition and the card carries the chrome.
Compositor half: DONE 2026-08-07 (souveraine-viewtop 3c85397)
Gates green on archdev — 239 tests, cargo fmt, workspace clippy and
--features kms clippy, all -D warnings. Not yet on the phone: needs a CI
package and a session restart.
crates/compositor/src/transition.rs— the state.begin(&[TransitionTarget])/shift_to(0..=1)/commit(EndTarget)/cancel()/forget(id), seven unit tests. Emptybeginrefused;forgeton the last leg ends the flight;forgetis wired to surface destruction besidenavigation.forget.- Wire:
OverviewBegin { to } / OverviewProgress { progress } / OverviewCommit { target } / OverviewCancel, plusTransitionTargetandEndTarget(home | overview | last_zone | zone{zone}), all four in the verb table. - Geometry:
Viewtop::carried(window)returns the lerped origin and scale, andwindow_originandwindow_extentboth consult it, so the render and the hit test inherit the carry with no call-site changes. Anchor is(0,0)on purpose sopose_origin's pivot term vanishes. focus_borderreturns empty while a window is carried — the card owns the frame.
Shell half: DONE 2026-08-07 (souveraine)
services/ZoneTransition.qml is the one owner: the card rect, the four verbs,
the end-target table, and the clock. ZoneOverview and the rail both read it
and neither derives a geometry.
Deleted, not adapted — ViewtopControl.poseActiveZone / clearPose /
_posed, zoneCard.scale, and GlobalStates.zonePullProgress. The card's
inner ColumnLayout went too: its 8 px margin was a second opinion about where
a window sits inside its zone. Panes are now the compositor's own tiling scaled,
so a split zone draws as what it is.
One clock, two strategies. The rail reports travel and interprets nothing.
ZoneTransition derives shift (what the compositor carries on, clamped) and
presence (how present the destination is, rising to the detent then receding
past it, so a home-bound pull never previews somewhere the release will not go).
Two curves is correct; two places was the bug, and putting the second one on
the rail was the first thing tried here and reverted. TASK-52's rule — one
clock, effects as strategies over it — is the same rule as "one owner", a level
up, and pose is a gravity well borrowed from those primitives, so this is her
felt environment, not merely navigation chrome.
What actually made the swipe feel wrong
Not the dwell gate. The release committed where the thumb left it. Lift at 60% of the climb and the carry was released at 60%, so the window jumped to its final state with no travel — two motions with a cut between them. Every attempt to fix this by tuning fades was treating the symptom.
quickstep does not do that (AbsSwipeUpHandler.handleNormalGestureEnd):
float endShift = endTarget.isLauncher ? 1 : 0;
long expectedDuration = Math.abs(Math.round((endShift - currentShift)
* MAX_SWIPE_DURATION * SWIPE_DURATION_MULTIPLIER));
duration = Math.min(MAX_SWIPE_DURATION, expectedDuration);
startShift = currentShift;
ZoneTransition.settleTo() is that, with the same numbers — 350 ms cap,
multiplier min(1/0.7, 1/0.3) from MIN_PROGRESS_FOR_OVERVIEW. Every release
travels to its destination and commits on arrival, including the abandoned
half-swipe (last_zone, end shift 0), which is the commonest gesture of all and
used to snap.
Correcting a claim made earlier in this session: the dwell gate was restored after reading the source. quickstep gates the same way and the "ever" is a latch, not a level:
recentsAttachedToAppWindow = mHasMotionEverBeenPaused || mIsLikelyToStartNewTask;
Removing it went against prior art. A fast unpaused flick showing no destination on its way past is correct.
Measured on hardware (10.10.30.213, viewtop r97.g3c853972)
- The carry lands where it is told: target
(135,280) 270×520logical →(271,561) 539×1035physical at scale 2.0.focus_bordercorrectly absent. - Shell-driven: card
(73.9, 12.0) 392.2×784.4, scale 0.7263; carried window measured(148,89)–(931,1590)against predicted(147.8,82.1)–(932.2,1592.7). - The strand repro passes, three times across three shell builds.
The geometry is measured now, not assumed
ZoneTransition took the card's box from screen.height * 0.78. The overview
PanelWindow respects exclusive zones, so its height changes underneath: read
once as 1040 (something reserving 40 px) and once as 1080. The assumption
was right today and would have drifted silently the moment the dock reserved.
The surface reports its own rect through measuredAt(), and carryState prints
surface beside panelWindow so a card in the wrong place is read, not
theorised — the same reason state exists at all.
Two findings that are not this task's
- A synthesized touch cannot reach the pill.
furniture_at()returnsNoneunconditionally and deliberately (wayland.rs:4490: "until the engine publishes a region layout, nothing is furniture and every touch is session content"), so an agent'stouch_downon the rail lands on the client window behind it. This is the structural argument for "it has to be a verb" below — the touch path cannot drive the pill today. A drawn hand (Casey, 2026-08-07, art in progress) rides on the verbs and does not wait on this. Originis two-valued, so on-device Ani and a remote caller over the socket are indistinguishable, and an agent touch leaves no trail at all —is_evidence()is false by design. Evidence ("does the machine think a human is here") and audit ("who did what") are different questions and only the first is answered. TASK-41's attested producers is where the second lives.
Still unseen on glass: a split zone's card, which is right by construction (each pane is the compositor's reported rect scaled) but has not been exercised.
Shell half: the plan as written (kept for the argument)
Rewrite the pill and ZoneOverview to drive the four verbs instead of posing
windows and scaling cards independently.
ZoneOverviewcomputes each zone card's rect in panel coordinates. That rect — not0.6, not an inset — is theTransitionTargetfor the windows on that zone.SystemGestureRail.onPressed→overview_beginwith those rects.onPositionChanged→overview_progress. Release →overview_commitwith the end target chosen from projected travel and dwell, oroverview_cancel.- Delete
ViewtopControl.poseActiveZone/clearPose/_posedandGlobalStates.zonePullProgress's pose duties, and dropzoneCard.scaleentirely — the compositor is now carrying the real window onto that rect, so a card that also scales is the second writer all over again. The card becomes chrome: label, close control, and the neighbours either side. - Tapping a card is
overview_commit { target: zone }— which is what makes the strand repro pass, because that path now releases like every other.
Today's four shell commits (57208c8, f699f57, 452acea, 72171ac) are the
two-writer design and step 3 deletes most of them. Kept on the phone meanwhile
because the sizes at least agree.
It has to be a verb, because she gives the tour
Casey, 2026-08-07: there is a point where an agent is asked for a tour or a demo
of the phone, "and that does mean even these little gesture steps will be
possible." So the transition is a verb with a caller, not a side effect of a
touch handler — shift and the end target are both reachable from the verb
table, or a tour is impossible and TASK-30's third capability enumeration grows
a fourth.
The principles that already govern her hand govern this too, and they are built
(input.rs):
Origin::{Physical, Agent}, carried from the source, never a caller-set field.AGENT_SLOT_BASE = 1 << 16, so a synthesized contact cannot land on a slot a thumb owns.Origin::is_evidence()— an agent-driven tour must not be evidence. A demo that raisesobserved_confidenceor feeds the idle budget is the machine believing a human is present because she moved a window.- Step-up stays human-only, and already is:
input.rs:432routesOrigin::Physical => Route::LockSurfaceandOrigin::Agent => Route::Withheld("the lock surface takes fingers, not the agent"). The navigation verb must not become a way around that.
Connects to TASK-50 (the same origin machinery, and the inverse mapping pose
owes the hit test) and doctrine §13 (the 60 is hers; the credential is the 40).
2026-08-07 — the cards were zero pixels wide
Reported as "it goes to a blur screen but nothing else", and every signal read
healthy: zones 2, windows 1, subscribed true, loader active, item present, opacity 1, progress 1. The blur was the backdrop; the cards were there and
540 px shorter than nothing — item.w: 0, h: 842.
ZoneOverview took its width from the Column it sits in, and a Column is as
wide as its widest child. In the drawer the search widget supplies that width;
mission control is the cards alone and has no search, so the only child left
was the loader — whose width came from the column, whose width came from the
loader. The cycle resolves to zero. Bound to panelWindow.width instead, which
is what this surface covers anyway (its mask is missionBackdrop, not the
drawer's sheet).
Two things that made it findable, and are worth keeping:
overview missionControlandoverview stateverbs. The one surface reported broken was the only one nothing but a pill swipe could raise, so it could not be inspected or tested.statereports what the cards are drawn from, and readingitem.w: 0besidecolumn.w: 0is what located this in one step after a long time spent on wrong theories.- A false lead worth recording: the compositor was refusing every capture
with
"no client captures the screen behind a lock screen", andgrimhung the same way the cards did. That is correct behaviour behind a lock — the session had been left locked by a test. Capture was never the problem. Checklockedinstatebefore concluding anything from a capture failure.
Cards are inset (26 px sides, 12 top) so neighbouring zones show through — full-bleed reads as "you are looking at that app" rather than "here are the places you can go", and leaves nothing on screen saying there ARE neighbours.
Still open: the swipe in
Casey, 2026-08-07: "the visual style from swiping up into this mode... we lost the consistency... the swipe to this swap is awkward."
The machinery exists and is where to look. ZoneOverview.progress follows
GlobalStates.zonePullProgress while the rail is pulling and the state flag
takes over on commit — this task's own acceptance is "no frame where a window
is scaled by one and laid out by the other." Two concrete suspects:
- Cards start at
scale 0.6 + 0.4 * share, chosen to match the scale the rail'sposehas reached at the multitasking detent. 0.6 is a written-down constant; if the rail's actual pose there is not 0.6, the card pops to a different size at handoff. - The inset above makes the card smaller than the full-bleed window it
replaces at the handoff moment, which may have made that seam worse.
Either the rail's pose should end at the inset card's rect, or the inset
should animate in after the handoff rather than being there at
share = 0.
Needs watching in motion. Screenshots cannot see it.
Original — design, 2026-08-05
Written after building the wrong thing and being told so. Casey, on device:
"Still major issues. We might need to take these concepts we've built back to the workshop. The scaling doesn't follow the selected app. meaning app#2 opened even if focused is not scalling down. Second, all we have is scaling; it's supposed to be showing me all sorta backgrounded zones; including split zones. You've seen macOS. That whole multitasking view is a function of it's own."
Repo: souveraine (shell surfaces), souveraine-viewtop (capture + zone
facts).
What was built, and why it is not the thing
The rail's short swipe scales the live windows on the active zone from 1.0 to
0.6 as the thumb climbs (ViewtopControl.poseActiveZone), then opens
GlobalStates.missionControlOpen. That is a transition — the animation of
leaving an app — and it was mistaken for the destination.
Two failures follow from that mistake, and both were seen on hardware:
- It scales the wrong windows.
poseActiveZoneposes everything whoseworkspacematchesViewtopControl.activeZone, and that list comes from a 2-second poll of{"op":"workspaces"}. Open a second app and the poll has not caught up, so the newly focused window is not in the set being scaled. There is no "focused window" fact in the shell at all — the compositor knows it (Surface::focused) and does not report it in theworkspacesreply. - Scaling one zone is not a view of all of them. Even done perfectly, it shows the zone you are already on, smaller. It cannot show what is backgrounded, which is the entire question multitasking answers.
What exists
| Piece | Where | State |
|---|---|---|
WindowOverview.qml |
modules/souveraine/navigation/ |
One column of equal cards, one per window, live ScreencopyView texture, flick-up to dismiss |
| Zone facts | {"op":"workspaces"} |
count, active, offset (the float the strip is scrolled to), settled, and per window {id, workspace, at, size} |
| Per-window capture | viewtop, since 2026-08-03 | A card can show a window rather than the screen covering it |
pose / unpose |
viewtop | Now genuinely scales content (fixed 2026-08-05 — it had only ever moved it) |
WindowOverview.qml's own header states the gap: "viewtop has them
[workspaces], and they are not Hyprland's: a continuous strip that grows when a
window needs a room and shrinks when the last one leaves… That is a thing this
overview could draw and does not yet." This task is that sentence.
What macOS actually does, since it is the named reference
Mission Control is two levels in one surface: a row of spaces across the top, and the windows of the current space spread below. Neither alone is the feature — the row answers "where else am I?" and the spread answers "what is here?". Dragging a window onto a space in the row moves it there, which is the same gesture doing placement.
Ours differs in one load-bearing way and it must not be papered over: macOS
spaces are a fixed set the user creates; viewtop's zones are minted and
destroyed by need (workspace.rs: "a workspace exists because something is on
it"). A row of zones is therefore a row that grows and shrinks under you, and
home is the one that is always there and always empty.
The questions
Q1. What is a card — a window, or a zone?
Today it is a window. macOS says both, at two levels. A phone has room for one level at a time. Options: zones-as-cards with their windows composited inside (split zones then read correctly, which is Casey's explicit ask); windows-as- cards grouped by zone; or a two-level surface where the zone row is a strip and tapping one spreads its windows. Q's real content: a split zone has two windows and must look like one thing you can switch to, not two.
Q2. Live textures, or posed real windows?
Two mechanisms now exist and they are not the same:
ScreencopyView— a live picture. Cheap to lay out anywhere, but it is not the window; input goes to the card.pose— the real window, scaled, still touchable throughunpose's inverse mapping.
The reference compositor composes client windows as external textures directly
into the shell's scene, which is the third answer and the one the in-process
Flutter shell would make natural (SHELL-BOUNDARY.md). Choosing here decides
whether the overview is a gallery of pictures or the actual desktop, zoomed
out — and whether the transition can be continuous into it.
Q3. Where does the focused window come from?
The shell has no such fact. Surface::focused exists in the compositor's
registry and is not in the workspaces reply. Any answer to Q1 needs it, and it
should be reported rather than inferred — inferring "the last window I saw
open" is the kind of shadow copy §4 spends its whole argument against.
Q4. Is the poll acceptable?
ViewtopControl polls every 2 s. That is already the proximate cause of failure
- A view built on it will be wrong every time something changes faster than two seconds — which is every gesture. Either the compositor pushes zone/window changes, or this surface asks synchronously when it opens and stops guessing in between. Design the push channel; it is owed anyway.
Q5. What does the transition become?
If the destination is a real view, the scale-on-drag is either (a) the first
frames of it, continuous into the cards, or (b) noise that should be deleted.
macOS and Phosh both do (a) — Phosh's home.c is a drag surface travelling
between two states, not a button. (a) is better and harder: it means the
transition and the view share one progress value, which is the reference
overview's whole trick (WindowOverview.qml already cites it: "one progress
driving everything").
Q6. What can you do to a card?
Switch to it, certainly. Close it — the close/kill verbs exist. Move a
window between zones — {"op":"workspace","to":N,"surface":id} exists too, so
drag-a-card-onto-a-zone is reachable today. Decide before building, because it
changes whether cards need to be drag targets.
Acceptance (measured, on the phone)
- Every zone with something on it is represented, and a split zone reads as one destination containing two windows — not as two unrelated cards.
- The view reflects state at the moment it opened, not up to two seconds stale. Open an app, immediately open multitasking: the new app is there.
- Tapping a card goes to that zone. Home is reachable from it.
- Whatever drives the transition and whatever draws the view agree — no frame where a window is scaled by one and laid out by the other. Met by there being one geometry, not by two that were tuned to match.
- The scale-on-drag either continues into the view or is gone. Not both.
- The strand repro passes. btop → multitasking → Home → multitasking → tap btop, and btop is full size. No path leaves a window transformed, including the ones that never touch the rail.
- The gesture is reachable as a verb. An agent can drive
shiftand pick an end target well enough to demo multitasking, and the run leavesOrigin::Agentthroughout: no evidence, no idle-budget credit, no route to the lock surface.
Connects to
TASK-14 (the overview pane and its app grid; "a grid of workspace thumbnails is
dead weight on a phone" is the constraint that made cards windows in the first
place), TASK-55 (the sheet, and ViewtopControl as the one path to scene
verbs), TASK-43 (the compositor's surface model and per-window capture), TASK-52
(atmosphere — pose is a gravity well and this is its first real user),
VIEWTOP-AND-DENIAL.md (external textures into the shell's scene; Q2's third
answer), SHELL-BOUNDARY.md (the in-process shell that would make it natural).