Two faults in the first cut.
cacheBuffer was height * 2, and height is -1 until the first layout
pass, so ListView refused it outright and said so on every open. Clamped
at zero.
The strip was vertical. TASK-14 asks for "horizontally swiped live app
cards" and the reference shell's carousel says the same thing in its own
comment — the whole strip slides in horizontally. On a phone the thumb
travels sideways, and a vertical list fights the flick-up that dismisses
a card, so the two gestures were competing for the same axis. Horizontal
with snap-one-item, cards filling the viewport.
OverviewWidget draws a grid of workspaces and places windows in them by
Hyprland coordinates — HyprlandData.windowList, monitorData,
Hyprland.monitorFor. Under viewtop none of it resolves, and more to the
point viewtop has no workspaces at all: one space, a tiling layout. The
old overview could not be repaired by repointing it at another data
source, because the thing it draws does not exist. It rendered an empty
frame, which reads as the overview being broken.
WindowOverview lists what does exist. Cards from
ToplevelManager.toplevels — the same list the dock and TaskbarApps
already read, so there is one idea of what is open — each holding a
live ScreencopyView of one window, which only became possible when the
compositor started serving a per-window capture source; until then a
card could have shown nothing but the screen it was covering.
The motion is the reference shell's, read out of
overview_window_card.dart rather than invented: stagger at index * 45 ms
capped at the fifth card, intro scale 0.86 to 1.0, dismiss past 32% of
the card height, 56 px of downward rubber-banding, settle under 200 ms.
The cap is the part worth keeping — without it the tenth card starts
half a second after the first and the overview feels like it is loading
rather than opening.
Captures run only while the overview is up. A live capture per window is
a render of that window every frame, and leaving them going behind a
closed overview is battery spent drawing what nobody can see.
Suppressing the dock whenever oskOpen was true, above effectivePinned so
it applied in every state, hid the dock permanently on the device.
oskOpen is not "the keyboard is on screen". GlobalStates' own comment
says squeekboard hides itself whenever input-method focus drops and that
a hold re-asserts it, and the journal shows exactly that: self-showed /
self-hid every couple of seconds, settling at Visible=true with no
keyboard in front of the user. Gating a persistent surface on a flag
that flaps turns a cosmetic overlap into a dock nobody can reach.
The empty-desktop check keeps its oskOpen term, where it always was: a
spuriously-true flag there costs a reveal that was cosmetic anyway,
which is a different price from hiding a pinned dock.
The real signal is the keyboard's exclusive zone, which the compositor
already applies — an unpinned dock declares zone 0 and is placed above
the keyboard for free, the same mechanism that fixed the pill. The
pinned case, where both reserve space and the reservations stack, wants
fixing where the zones are arbitrated.
Two bugs with one cause — the bottom edge was being reasoned about
twice, by hand, against a compositor that is gone.
The rail set ExclusionMode.Ignore (exclusive zone -1: ignore what
everyone else reserved, anchor to the whole screen) and then computed
its own lift from the keyboard's height, probed by shelling out to
`hyprctl -j layers`. Under viewtop there is no hyprctl, so every probe
threw a JSON.parse on empty output and logged it in a loop every second
or two, forever, while the margin stayed at a hardcoded fallback
measured against a keyboard that is not the one running. A 200px
fallback against some other height is exactly "the pill spawns in the
middle of the keyboard". Zone 0 with Normal — the idiom SidebarRight and
ReloadPopup already use — reserves nothing and respects what others
reserved, so the compositor anchors the rail to the top of the
keyboard's exclusive zone. Correct at any height, any rotation, across a
swap, with nothing to go stale. The probe, its retry timer and
probeOskHeight() are deleted rather than ported: a second measurement of
a number the protocol already carries is what this class of bug is made
of.
The dock consulted oskOpen only in its empty-desktop branch, below
effectivePinned, so a PINNED dock ignored the keyboard and sat under it.
DockManifest.dockState() has always reported "suppressed-by-osk" and
_blockReason() has always refused mutations with "osk-open" — the
manifest promised a suppression the dock never performed, so the agent's
view of the screen and the screen disagreed. The check moves above
pinning and applies in every state, staying below dockRevealPulse
because an explicit reveal is the one thing that should outrank the
keyboard.
Verified on the phone: the probe loop is gone from the journal. The
resulting pill and dock positions want eyes on the glass.
Two things that worked under Hyprland and stopped when viewtop took the
session, both for the same reason: the binding lived in hyprland.lua and
the daemon was never in the path.
dt2w: note_input recorded the wake and returned Restore, which is
brightness. So a double tap reported to the machine and the screen stayed
dark. It now returns Unblank first — the brightness a panel comes back at
means nothing until the panel is back. Only for the deliberate wakes
(DoubleTapToWake, Squeeze) and only when the panel is actually dark. The
power button is excluded on purpose: apply_gesture already wakes on its
resolved tap, and emitting a second unblank here is precisely how the
wake loop happened in the compositor this afternoon.
Volume: Action::Volume through the executor table, per §12 — a small
daemon reading a signal and calling wpctl is the eighth blind actor. It
fires on the DOWN edge rather than from a recognised gesture, because
BUTTON_MULTI_TAP_WINDOW is 300ms and a volume key that lags a third of a
second behind the press feels broken. The recogniser still sees the
edges, so a future binding table gets volume hold-to-ramp without this
changing.
Shell: HyprlandData did a bare JSON.parse on hyprctl output, which throws
on every refresh when there is no hyprctl — six exceptions a pass, and
every consumer of monitorData.scale got undefined. That is why the region
selector rendered as a sliver: the geometry was not wrong, it was NaN.
Absence is now a latched state, and RegionSelection/OverviewWidget fall
back to screen.devicePixelRatio. Hyprland stays preferred where it exists.
105 tests.
LockedHint was never set on this device, so sessiond's `locked` (which
comes from logind per doctrine §4) was permanently false, request_blank()
timed out LOCK_ACK_BUDGET on every blank, and the panel went dark on a
session nobody could confirm was locked. LOCK-DPMS-LESSONS §1 held in the
code and not on the glass — every blank recorded blank-without-lock.
Three faults, measured 2026-08-02:
- Session.qml wrote the hint through /session/auto, which is the caller's
own session. The shell is not in the session that owns the seat:
viewtop in logind 66 (seat0/tty1), qs in 70. Written to one session,
read from another.
- lockhint.rs resolved via User.Display, which an ssh login is enough to
point at a seatless remote session — and which then went stale when
that session ended. Both sides now ask seat0 for its active session.
- The report was dropped outright. hasLoginctl starts false and only
flips when a Process probe returns; secure=true landed 30s earlier, so
the one edge that mattered hit the guard and was never retried, because
the shell locks once at boot and never changes again. Fourth
edge-vs-level bug after locked_ack, ChargeRate and bootBloomActive.
Verified on hardware: LockedHint now goes yes ~2s after a power tap.
`shell_ready` blocks in the daemon for up to 5s waiting for its
lock-session thread to drop its Wayland connection, because the
compositor refuses a second locker while the first is alive
(server.rs `wait_timeout_while`). The shell's timer was 3s, so it gave
up on a handshake sessiond was still answering, assumed locked, and
asked for a lock that had not been released — straight into TASK-48's
"Tried to show lockscreen surfaces without active lock".
Under Hyprland the release usually lands in milliseconds and the race is
won. Measured against viewtop on blueline today it loses every time: the
shell crash-looped every 11 seconds and the session never came up.
Same latent bug, a compositor that exposes it.
7s, so the daemon's own deadline expires first. Its answer for that case
is a refusal (`lock session did not release in time`), and hearing a real
refusal beats inventing a verdict — "assuming locked" is the shell
holding state the authority owns.
The 5s retry timer only arms after this one fires, so nothing overlaps.
PanelLoader is a LazyLoader gated on Config.ready, behind a second one.
On reload both are shut when quickshell propagates, so LazyLoader has no
item to hand its successor and the new WlSessionLock never adopts the live
compositor lock. Lock is a direct child of ShellRoot now, as the boot-order
doc already asked.
Ungated means Config may not have loaded, and touchKeypad reads false from
the adapter defaults — which would bind the desktop surface on the phone and
cannot be changed once the lock is active. The choice rides the reload too.
sessiond admits shell_ready from the lease holder's own pid; the shell
carries its lock request through the reload so WlSessionLock adopts the
existing lock instead of taking the unlock branch on it.
It read "On" for an hour through a dead tunnel — NM said activated while wg had
368 B received against 5.3 KiB sent. Now off/connecting/limited/online, decided
by a 2s probe of the far-side resolver rather than by the interface being up.
Sailfish models the same distinction (Online/Limited/Connecting); Android won't
let an unvalidated network win at all.
GroupButton called it unguarded while releaseAction two lines up was guarded.
altAction is null for every toggle without a menu, so a long-press threw. Only
showed once the toggles that had been filtered out of the panel started
rendering.
TASK-08(f)/TASK-19: state, panel, evidence, confidence, per-source health, the
sensors_degraded flag and the recent decision trail were legible only through
forensic.jsonl. DeviceEvidence gains a read path (polled only while watched);
the Device page renders it. Readout only — the confidence gates are still
computed and never branched on, so controls over them would lie.
It is not a git repo, it gets edited on the device, and this path had no
snapshot while the local compose path has had one all along. Now it snapshots
to ~/souveraine-surfaces/.quickshell-previous, copies by checksum, and only
reports phone-only files instead of destroying them.
Tier 2 was written, deployed and mounted by nothing — its own header named the
gap. The chat sidebar hosts it now: ticker long-press, or the psychology
indicator by the input box, which works when no pass is running.
The hand-written allowlist in AndroidQuickPanel had drifted from it, so the
phone rendered 12 of its 17 configured toggles and said nothing about the rest.
Adds the missing fullScreenshot and waydroidTerminate, and a deploy guard so
the phone chooser can't fall behind the base one again.
selectAgent cleared conversationId even when the agent hadn't changed, and the
60s inventory poll re-selects the persisted agent forever — so any message sent
a minute after the last one opened a fresh conversation. 27 of them yesterday,
four messages each. An empty curl body no longer counts as "no conversations".
The old wiuf profile shared its address and private key with the laptop, so
WireGuard could only hold one of them, and the key is no longer listed
server-side — it sent and never received. wiufph is 10.8.0.6, port 51822,
keepalive 25. Proven off-LAN: handshake 8s, gitea 200 in 0.126s.
Deliberate, never automatic. The autonomous gate it replaces recycled the
tunnel 652 times in 90 minutes and could not be switched off, because
'nmcli connection down' fired the dispatcher that brought it back up.
Reads NM for state rather than tracking a bool. No privilege needed: polkit
already lets the seat user activate a system connection. Registered in the
ii-phone chooser as well as ii-base, or the phone overlay wins and the
delegate never appears.
The ticker instantiation in AiChat referenced qs.modules.souveraine.
subconscious, but those files were absent from the deploy MANIFEST, so a
deploy left the import unresolved. Add SubconsciousTicker/EventPanel +
a qmldir mirroring the dial/selection modules.
The subconscious three-tier surface (SubconsciousTicker + SubconsciousEventPanel)
is Souveraine-original, not upstream ii. It lived under ii-base/modules/ii only
because it had never been re-homed, so the ii -> souveraine shell migration
silently stranded it: the files survived, but the running shell (qs -c souveraine)
never loaded them, and the feature dropped off the device.
Relocated into modules/souveraine/subconscious/, decoupled from the ii overlay
system it was riveted to:
- SubconsciousTicker: was clean of ii imports but opened the panel through the
ii OverlayContext singleton. Now emits requestOpenPanel(); a host connects it.
- SubconsciousEventPanel: was a StyledOverlayWidget (qs.modules.ii.overlay). Now
plain content a host instantiates; the pin/close/drag affordances were the
overlay host's job, not the content's.
The model (services/Ai.qml) was already substrate-neutral and never moved; only
the views were stranded. GAP NAMED: no souveraine overlay host mounts these yet,
so they do not reach the glass until one exists — the ii overlay host was
intentionally not ported. The dangling ii references (AiChat, OverlayContext,
OverlayWidgetDelegateChooser) are removed.
Speech resolves voice per active agent (agents[].voice_id from the list),
with the system voice as fallback — two agents on a host need not match.
Souveraine.qml carries voice_id into the agents map and adds a turn clock
(turnStartedAt/turnElapsedMs) + a 60s agent-inventory refresh. Ai.qml
declares pendingFilePath and imports qs.modules.common.functions so
StringUtils.ttsClean no longer aborts the stream-finished handler.
The masked-fullscreen version was completely untappable on device. Rather than
keep guessing at the mask, the window is now sized to the card and moved by
margins, so every pixel of it is the chip. Also stops an invisible fullscreen
surface sitting over the app's own selection UI.
Two bugs from the first on-device run.
The card sized itself from a layout it also centered, which resolved to zero, so
mask claimed a zero-size input region: the chip painted but no tap landed. Card
now sizes from the layout's implicit size and the background is a plain Item
child, not layout-managed.
wl-paste --watch replays the existing selection on start, so every watcher
restart (every unlock) resurrected a stale selection that could never be cleared
because the primary buffer never changed again. The first emission after a start
now only sets the baseline, and a dismissed selection is remembered so a re-read
cannot revive it.
Overlay layer following DialHost, keyboardFocus None throughout, and an input
mask limited to the card so taps elsewhere reach the app underneath. Anchors to
the pointer hint above the touch point, clamped on screen.
Chip shows a character count, never a preview: the surface floats over the app
that owns the selection and the content may be a password, so it stays ambient
and discloses nothing. Read Aloud is live via Speech; agent and reference
actions render with the reason they cannot act yet.
Adds DeviceEvidence, reporting input to the state machine's existing
Request::Input with an intent label, so a new input surface is not another
isolated actor per DEVICE-STATE-MACHINE 1. Opt-in: nothing loads or watches
until Config.options.selection.enable.
Verified on blueline that Hyprland advertises primary-selection plus both
data-control managers, so wl-paste --primary --watch sees every selection with
no per-app hooks. Settles TASK-18's selection-detection question as
compositor-level; viewtop is not needed.
The protocol gives no selection rectangle, so anchor is the pointer position at
selection time. Opt-in by default: the watcher observes every highlight on the
device, including passwords, so it is killed while locked and selection text is
never persisted, logged, or trailed.
bootBloomActive defaults true on every scene construction and bootDismissed
resets with it, but the only thing that cleared them was the secure EDGE. A
scene reload during an already-secure lock never moves `secure`, so nothing
cleared the bloom and the phone sat under a full-screen white overlay until the
shell was restarted. Casey hit it and reported it as "the hyprland error
screen"; `grim` returned a solid white 9KB frame, and hyprctl configerrors was
empty the whole time.
Third instance of this exact shape: locked_ack after a sessiond restart, the
ChargeRate stale-scene reload, and now this. Check the level at construction as
well as the edge.
Deliberately not a timeout — a bloom outliving its reason is a bug to locate.
A scene reload re-runs SessiondBridge while the outgoing connection is still
open, so the reload's shell_ready races its own predecessor's EOF and sessiond
refuses it — correctly, since the lease is genuinely held. The bug was the
shell then giving up for good: it read the refusal as "another shell owns the
lease, we are not the authority", cleared nothing, and left registerRetry
idle. A second later that EOF landed, shell_alive dropped, and sessiond spent
the rest of the session believing there was no shell — while a live one sat on
a connected socket.
Observed 2026-07-29: refused 09:14:35, predecessor closed 09:16:36, and
`{"op":"status"}` reported shell_alive=false for 90 minutes afterwards.
Now the refusal sets needsRegistration and lets the 5s retry run. If the lease
really is another live shell's, each retry is refused again for free — and we
take it the moment that shell dies. Verified on hardware: retrying (1) ->
registered -> locked_ack sent, sessiond back to phase=released.
It existed only in archdev's checkout — never added, not gitignored, absent
from the laptop. Two sections had gone stale against the code and are corrected
in the same change: modules/ is no longer "coming", and deploy.sh composes the
whole config rather than swapping Ai.qml.
~/souveraine-surfaces/quickshell on the phone is not a git repo and was the
only copy of five files. Brought back verbatim:
- Gestures.qml, SystemGestureRail.qml: TASK-38 osk-swap detent (DUMP §5, §6)
- OnScreenKeyboard.qml: showOsk asks the bus instead of pgrep+sleep 1
- DockAppButton.qml, DockStack.qml: suffix-tolerant AppSearch.resolveEntry
resolveEntry itself had been added to the phone's live ~/.config/quickshell/ii
tree, which deploy.sh rsyncs from ii-base — the next deploy would have deleted
it and left the two dock callers referring to nothing. It lands in ii-base here.
Every failure path in shellReady answered cb(false) — no lock owed — so a
socket that was not up, a duplicate handshake, or a 3s timeout all concluded
the session was open. The timeout logged 'proceeding without sessiond' and left
the phone unlocked at exactly the moment the authority was unreachable, while
the daemon side of the same contract retakes the lock on heartbeat EOF
'whether or not the session was locked at the time. Fail closed.'
Not knowing the session is locked is not knowing it is not. All of those now
assume locked. Refusals still answer false only for 'already registered', which
means another shell holds the lease and this process is not the authority.
A timed-out handshake is also retried instead of abandoned: a shell that merely
started while sessiond was restarting stayed unregistered for its whole life,
so sessiond saw no heartbeat and raised its fallback surface over ours.
SessionEvents likewise retries session-path resolution rather than disabling
lock-signal monitoring for the life of the process, and logs the gap as an
error while it is open.
onScreenLockSecureChanged is an edge. After a sessiond restart our lock surface
is already secure, so the edge is in the past and the ack owed by the new
handoff was never sent — sessiond logged 'shell never confirmed its lock after
handoff' and retook the lock 15s later.
SessionEvents asked loginctl for -p ObjectPath, which is not a property — it
returned empty on every boot, so external lock signals were never monitored and
the shell had no unlock ingress. Resolve the graphical session the way
lockhint.rs does; not GetSession(auto) (this shell is a session-less user unit)
and not the /session/auto alias (PropertiesChanged only fires on the concrete
path).
SessiondBridge read root.registered to decide whether to re-register on
reconnect, but the disconnect branch had already cleared it, so the shell never
re-registered after a sessiond restart. Latch it.
Session.qml and Lock.qml both registered target 'session'; quickshell keeps
whichever loads first and drops the other. Lock.qml's handler is a strict
superset, so the duplicate goes.
The page asked the same question twice because two daemons answer it, and
split the blank budget by held-vs-resting — which makes the user arbitrate a
guess the accelerometer is making. Held-ness belongs in the confidence
arithmetic as an adjustment to one budget, not as a second budget here.
The fullscreen overlay sat above squeekboard's layer and owned the whole
screen's input region, so taps in the keyboard area hit the dialog. It now
yields the bottom third while the OSK is up. Exclusive keyboard focus was
tried and reverted — it stopped the keyboard taking touch entirely.
UPower's ChargeType (fork, read off the charger) reaches the lock line as
fast/slow charging. Discharging with the cable in is the charger resting
between top-ups, not a drain — it now reads Charged/Plugged in.
squeekboard hides itself when input-method focus drops, so the password
field was left with no keyboard. A hold re-asserts through that and
releases on dismiss; a manual close drops the hold.
IdleConfig: ConfigSelectionArray presets (30s/1/2/5/10 min for the shell's
timers, 15s/30s/1/5 min/Never for the lock screen) instead of seconds
spinboxes. Nobody reasons about a lock screen in 5s increments; the policy
struct already said "iOS Auto-Lock shape". Wire values stay seconds.
Header comment corrected — hypridle no longer owns screen-off.
set_panel(false) clears pending_blank. A panel darkened by any other route
left it armed, so the next tick emitted a second Blank or hit the
blank-without-lock error path for a blank nobody was waiting on.
SetPolicy refuses dim_grace >= lock_blank_after. dim_at saturates to zero, so
the panel dimmed the instant it went idle with no lit period.
Verified by rendering the page, not by parse alone.
SessiondBridge: handle pushed {"directive":"lock"} on the heartbeat line and
raise the lock surface. Unknown directives are console.error — a silent drop
leaves the daemon blanking unlocked when its budget expires.
SessiondPolicy: new singleton, reads/writes DeviceStatePolicy over its own
short-lived connection (not the bridge socket — that EOF is shell-death
detection).
IdleConfig: new "Lock screen (device authority)" section bound to it. SetPolicy
had zero callers, so the old spinboxes moved a JSON file the daemon never read.
Says so in the error colour when sessiond is unreachable.
New file needs a deploy.sh manifest line or the services qmldir fails whole.
LOCK-DPMS-LESSONS §1 is "Ordering: lock, then off". It held only because
hypridle's 300s lock listener fired before its own 600s screen-off listener —
two numbers in a config file, not a guarantee. Anything that skipped the lock
(idle inhibitor, native coordinator off, dead shell) still met the blank, and
the panel went dark on an unlocked session with nothing said about it.
Every path to a dark panel now routes through request_blank():
- locked -> Blank, unchanged, no added round trip
- unlocked -> Action::Lock, blank withheld, pending_blank deadline armed
- ack lands -> Blank
- budget expires -> Blank anyway + error-security in the forensic trail
The panel fails open because a lit unlocked phone in a pocket is worse than a
dark one (§1); the *claim* fails closed because doctrine §8 forbids pretending
the session locked. Input inside the ack window cancels the blank outright.
Action::Lock does not go through the executor table — the lock is not a shell
command. While a live shell owns steady state the directive goes down the
heartbeat connection and the shell raises its rich surface; with no shell alive
sessiond raises its own. Same relationship DEVICE-STATE-MACHINE §6 gives the
DPMS executor: the authority decides, the surface executes. A directive that
cannot be delivered takes the lock here rather than timing out into a dark
unlocked screen, and is recorded either way.
Policy gains lock_ack_budget (2s, refuses 0) and unlocked_blank_after (None by
default — the shell's IdleCoordinator owns the unlocked idle timer through
ext-idle-notify per doctrine §5, and a second one here would recreate the
competing-owner disease). Both are on the GetPolicy/SetPolicy seam so Settings
is a view over the owning daemon, per TASK-19.
Unknown directives are loud on the shell side: an older shell silently
dropping one would leave the daemon waiting out its budget and blanking
unlocked.
38 tests pass, 5 new covering the ordering.