Skip to content

feat(linux): give the quick terminal card visible chrome - #15

Merged
melonamin merged 3 commits into
melonamin:linux-portfrom
n2tr2:linux-quick-terminal-card-chrome
Aug 4, 2026
Merged

feat(linux): give the quick terminal card visible chrome#15
melonamin merged 3 commits into
melonamin:linux-portfrom
n2tr2:linux-quick-terminal-card-chrome

Conversation

@n2tr2

@n2tr2 n2tr2 commented Aug 4, 2026

Copy link
Copy Markdown

What

The Linux quick terminal (Ctrl+) and the sized floating session overlay read as a flat region of the window rather than a panel floating above the session behind them. There is no contour to separate card from content, nothing to lift the card off the background, and the square GtkGLArea` paints into the corners the frame is supposed to round off.

before and after

One part of the reported symptom was never broken, and it is worth saying so up front. libadwaita already draws a 1px border and a 12px radius on frame — on this box (libadwaita 1:1.9.2-1, gtk4 1:4.22.4-1), from the stylesheet compiled into /usr/lib/libadwaita-1.so.0:

/* line 1878 */ frame, .frame { border: 1px solid color-mix(in srgb, currentColor var(--border-opacity), transparent); }
/* line 1880 */ frame { border-radius: 12px; }

So this is not "add a missing border". The border was there; it was derived from currentColor, which on a dark card resolves darker than its own surround — measured at luma 27 against a 48 background. A dark line on a dark panel reads as an absence of chrome, not as a contour. What was genuinely missing is the shadow (nothing at all) and the clip.

Root cause

Three separate things, only one of which is a true absence:

  1. Contrast, not existence. The theme border's colour is currentColor-derived, so it goes the wrong way on a dark card: darker than the background it is meant to separate from.
  2. No shadow. .card contributes only a faint box-shadow that is invisible at this scale, so nothing lifts the card off the session.
  3. No clip. GTK_OVERFLOW_VISIBLE is the widget default, so the square, opaque GtkGLArea child paints over the frame's rounded corner arcs, flattening the silhouette the radius describes.

Fix

One host-free CSS constant plus one call at each of the two frame-construction sites.

LinuxQuickCardPolicy.cardCSS emits the single .agterm-quick rule that installAppCSS() interpolates at GTK_STYLE_PROVIDER_PRIORITY_APPLICATION (600), which is what lets it win over the theme's 200 — the same mechanism the pre-existing background-color: #1e2228 already relied on:

.agterm-quick { background-color: #1e2228; border: 1px solid alpha(#ffffff, 0.18); border-radius: 12px; box-shadow: 0 8px 32px alpha(#000000, 0.8); }

border and shadow, 4x

  • Border colour flips polarity at the unchanged 1px width — luma 27 → 74 against the same 48 background. The width matters: the theme already drew 1px, so a colour-only swap leaves the frame's measured chrome untouched, which is what keeps a contentSize(request:chrome:)-style subtraction valid. A negative pin in the unit test forbids a border-width declaration for exactly this reason.
  • Shadow is new — measured from nothing to a falloff reaching 49 px below and 28 px left of the card edge, darkest value 22 against the 48 background. Note the 49 px is bounded by the capture's 50 px bottom margin, so the true tail is ≥49 px: the nominal 8 + 32 = 40 px understates a Gaussian's reach, and the halo resolves only just inside the card's 44/56 px margins.
  • Radius is stated explicitly so the contract does not silently depend on Adwaita continuing to ship 12px.
  • The #1e2228 backing stays opaque — it is what stops the card going see-through when the surface below draws at background-opacity < 1. It paints under the border area, because plain .card (unlike button.card, stylesheet line 378) does not set background-clip: padding-box, so the 18% white composites over the backing.

gtk_widget_set_overflow(W(frame), GTK_OVERFLOW_HIDDEN) at both sites clips the GL child to the padding-box rounded rect. GTK snapshots the CSS background, border, and shadow before pushing that clip, so the card's own chrome is unaffected — only the child stops overdrawing the arcs.

corner clip, 16x

The clip's effect is small, and the screenshot is zoomed 16x to make it visible at all. Tracing the first painted terminal column per row at the corner: before it is 52 on every row; after it is 54, 53, 52 — a 2 px inset over the arc. Real, but a couple of pixels, not a reshaping. Consequences worth knowing: roughly 2–4 px of the corner cells are clipped (the same trade-off macOS's clipShape makes), and gtk_widget_pick stops hit-testing the corner arcs, so those few pixels become click-dead.

Both cards share the agterm-quick class on the same GtkFrame shape, so the floating overlay inherits all of it by construction — no second class, no divergence. .card stays on both frames: it still supplies color: var(--card-fg-color) and the :focus-visible outline.

A second commit adds a CI guard for this change's worst failure mode. GTK drops an unparseable declaration silently, so a typo would present as "chrome simply absent" — indistinguishable from the fix not working, with unit tests still green because they assert a Swift string, not GTK parseability. atspi_smoke.py now routes the app's stderr to an artifact file and test-linux-ui.sh fails the run on Theme parser (error|warning): <data>. The <data> scoping is deliberate and verified: a string-loaded provider's section prints the literal <data> (gtkcssprovider.c passes file = NULL; gtkcsssection.c renders <data> when section->file == NULL), while resource-loaded system stylesheets print a display name — so the guard covers all four of the app's providers and cannot be reddened by a deprecation in GTK's or libadwaita's own CSS. Verified against both the local GTK 4.22.4 and the 4.14.0 source the CI runner uses; the format string is identical in both.

The same commit also extends the AT-SPI overlay scenario. It previously had the suite's only session overlay open call, un-sized, so syncOverlay's floating branch — where one of the two new set_overflow calls lives — never ran in CI. It now exercises both shapes: a sized --size-percent 60 --follow card and the original full overlay, so neither teardown path loses coverage. --follow is load-bearing: without it the frame is created set_visible(0), GTK skips a hidden widget in layout, and the rounded clip never reaches GSK.

Across themes

The three new declarations are hardcoded, not theme-derived — deliberately, but it raises the obvious question of what happens under a theme other than the default, so it is measured rather than argued. The border composites alpha(#ffffff, 0.18) over the frame's own #1e2228 backing, never over the theme, so it lands at luma 73.7 regardless of theme; only what sits behind it changes. Same three sample points as above, one instance per theme:

session theme session bg luma border delta (after) shadow darkening
Kanagawa Dragon 21.4 +52.3 11.2
agterm (default) 46.0 +27.7 24.0
Alabaster (light) 239.0 −165.3 127.0

Two things fall out of this, and both are load-bearing:

The darker the theme, the worse the original bug and the bigger the win. On the default theme the old border sat at luma 27 against 48 — a wrong-polarity line, but a visible one. On Kanagawa Dragon it sat at 27 against 22, a delta of +4.6: not merely wrong-polarity but genuinely invisible. That is the regime the bug was actually reported from, and it is where the fix helps most.

Kanagawa Dragon before and after

The border and the shadow cover for each other across the range. A black shadow has almost no headroom to darken an already-near-black session (11 luma steps on Kanagawa), so there the light border does the separating. On a light theme the arithmetic inverts: the shadow darkens by 127 and carries the card, while the border — still at 73.7 — reads as a dark contour against 239. Note that on a light theme the border was already clearly visible before this change (delta −220), so the fix slightly reduces its contrast there (−220 → −165); both are far above any visibility threshold, and the newly added shadow more than compensates. There is no theme in which the card ends up less separated than it started.

The guard found a real bug on its first run

Worth reporting, because it is the whole argument for the guard existing. build-linux went red on the first CI run of this branch — every AT-SPI scenario passed, and then the new check failed the job on:

Theme parser error: <data>:2:35-36: Expected '{'

Column 35 of line 2 is the comma in @keyframes agterm-blink-pulse { 0%, 100% { … } … } — a line this PR does not touch (the App.swift hunk here is +1/−1 on the .agterm-quick line). GTK gained GTK_CSS_TOKEN_COMMA handling in _gtk_css_keyframes_parse sometime after 4.14: 4.22.4 builds a selector array and loops on the comma (gtkcsskeyframes.c:511-513), while 4.14.0 consumes a single progress value and goes straight to the block (gtkcsskeyframes.c:336-356), so the comma is a parse error there.

Because GTK drops an unparseable @keyframes silently, the agent-status blink pulse has simply been dead on GTK 4.14 — which is Ubuntu 24.04, and therefore the CI runner itself — with nothing anywhere to report it. It parses on the 4.22 most developers run, so it was invisible from a dev box.

The third commit splits it into one selector per keyframe, which parses on both. Verified against the 4.14.0 and 4.22.4 sources, and by loading the fully composed installAppCSS string through a real GtkCssProvider locally. The CSS block is now all-ASCII, deliberately: the comment explaining a 4.14 parser constraint should not itself introduce a non-ASCII character into CSS I cannot run 4.14 against.

Keep-in-sync

Nothing owed, recorded so it is not relitigated:

  • No control command. This is pure visual chrome with no state — no AppActions action, no Command case, no agtermctl subcommand. Both frames are already driven by the existing quick and session.overlay commands, and since nothing new is set, the write→read-back rule on tree is vacuous.
  • No Settings ▸ Interface toggle. The card's own silhouette is not a hideable affordance.
  • No README / site/ / agent-skill impact — no command, flag, keybinding, mode, or model change. The README's quick-terminal screenshot is a macOS capture, so it is not stale.
  • docs/plans/completed/ carries the plan doc, matching the convention already in the branch; it is the bulk of the diff by line count.

Change

feat(linux): give the quick terminal card visible chrome

file +/−
agterm-linux/Sources/AgtermLinux/LinuxQuickCardPolicy.swift +45 / −0
agterm-linux/Tests/AgtermLinuxTests/LinuxQuickCardPolicyTests.swift +24 / −0
agterm-linux/Sources/AgtermLinux/AppController.swift +5 / −4
agterm-linux/Sources/AgtermLinux/App.swift +1 / −1
agterm-linux/Sources/AgtermLinux/AppControllerSurfaces.swift +1 / −0
docs/plans/completed/20260803-linux-quick-terminal-card-chrome.md +595 / −0

test(linux): fail the UI smoke on a GTK CSS parse error

file +/−
agterm-linux/tests/atspi_smoke.py +74 / −2
scripts/test-linux-ui.sh +34 / −0

fix(linux): make the blink keyframe parse on GTK 4.14

file +/−
agterm-linux/Sources/AgtermLinux/App.swift +3 / −1

AppController.swift lands at 993 lines against the 1000-line file_length limit — a net +1, and no limit was touched.

Testing

Run locally:

  • swift test --package-path agterm-linux — 187 tests / 28 suites. One failure: IntegrationServiceTests "Flatpak process environments do not offer a host launcher", which reproduces on an unmodified linux-port checkout on this box (a real agtermctl is installed, so the probe resolves .installed rather than .unavailable). The new Linux quick-terminal card chrome suite passes.
  • swift test --package-path agtermCore — 2040 tests / 84 suites, 3 failures, all in CodexStatusHookTests and all pre-existing here (Manjaro's gnustep-base ships a /usr/bin/plutil that rejects -extract … raw). Confirmed with --filter CodexStatusHookTests; this change touches no file under agtermCore.
  • swift build for both AgtermLinux and agtermctl-linux — clean.
  • swiftlint lint --strict0 violations, 0 serious in 408 files (0.65.0, via mise, with LINUX_SOURCEKIT_LIB_PATH pointed at the toolchain's usr/lib).
  • git diff --check linux-port...HEAD and scripts/check-linux-cli-drift.sh — clean.
  • Visual acceptance on an isolated dev instance on a real Wayland session (GTK 4.22.4 default renderer), confirmed by eye by the maintainer.
  • CSS parse check — zero Theme parser lines on the dev instance's stderr, and zero in every capture run below. This is the check the new CI guard automates.
  • The screenshots above were captured offscreen under Xvfb + llvmpipe software GL, from two binaries built from this branch and from unmodified linux-port, under identical conditions. All pixel values quoted are measured, not eyeballed.

Not run locally — relying on branch CI:

  • scripts/test-linux-ui.sh, the full AT-SPI sweep, including this branch's own additions (the CSS-parse guard and the extended overlay scenario). The first CI run has since exercised it end to end: every scenario passed, including the extended overlay case that drives the new clip under llvmpipe, and the guard correctly failed the job on the pre-existing 4.14 keyframe error described above. It is not runnable end-to-end on this box: the context-menu scenario fails here for an unrelated, pre-existing reason (session rows do not receive pointer events at their reported position, reproducible at a base checkout), and the sweep aborts at the first failing scenario. The guard's shell logic was instead simulated against {empty, clean, app warning, app error, system-stylesheet warning, system-stylesheet error, missing log, missing marker} × {suite exit 0, exit 2}, and python3 -m py_compile / bash -n both pass.
  • The Vulkan GSK path is covered only by the maintainer's visual check, not by an automated capture. Two GSK constructs here have no precedent in the tree: a rounded overflow clip, which forces an offscreen pass over the GL texture node, and a 32px-blur GskOutsetShadowNode. Under Xvfb, an explicit GSK_RENDERER=ngl run is pixel-identical to the sandbox default (and GSK_RENDERER is definitely being read — a bogus value warns), but that sandbox has no Vulkan, so it does not exercise the renderer a desktop session actually picks.

n2tr2 added 2 commits August 4, 2026 07:42
The quick terminal (and the floating session overlay, which shares the
agterm-quick class) rendered with no visible boundary against the session
behind it. libadwaita's frame node already draws a 1px border and a 12px
radius, but the border color is currentColor-derived — invisible dark on
dark — and the square opaque GtkGLArea child painted over the corner arcs,
erasing the rounded silhouette.

Hoist the rule into a host-free LinuxQuickCardPolicy.cardCSS carrying the
contract, and give it light-polarity chrome at the unchanged 1px width
(border 1px solid alpha(#ffffff, 0.18), explicit border-radius 12px, and a
strong offset box-shadow), then clip the GL child to the rounded card with
GTK_OVERFLOW_HIDDEN at both frame-construction sites.

Border, radius and clip match the macOS chrome; the shadow is deliberately
offset and stronger, because a subtle centered one is exactly what proved
invisible dark on dark. The opaque #1e2228 backing and the absence of
padding are preserved, and the border width stays exactly 1px so the
widget's measured chrome is unchanged.
GTK drops an unparseable CSS declaration silently — a Theme parser line
on the app's stderr is the only signal anywhere — and the smoke sent that
stderr to DEVNULL, so a typo in installAppCSS shipped as missing chrome
with every unit test and AT-SPI assertion still green.

Capture each launched instance's stderr into the artifact directory and
fail the run on any Theme parser error or warning; GTK emits the warning
variant from the same call site for a deprecated or unimplemented
construct, which drops the declaration just as silently. Covers the whole
app CSS, not just the quick/overlay card rule.

The match is scoped to the `<data>` section name so it stays OUR CSS:
GTK's default handler prints the same line for every provider with no
connected handler, Adwaita and libadwaita included, and a parse message
from a system stylesheet is one this repo cannot fix. All four of the
app's providers load from a string, whose section has no GFile and prints
`<data>`; a resource- or file-loaded stylesheet prints a display name
instead. Verified against the real parser on GTK 4.22.4 and in the 4.14.0
sources the CI runner ships.

The guard must not fail open, so the runner hands over the exact log path
instead of both sides deriving the same filename, and the sink stamps a
marker the runner requires afterwards — an empty log now fails loudly
rather than reading like a clean one.

Also run the surface-lifetimes overlay through BOTH shapes. A floating
card (--size-percent 60 --follow) is the only place either
GTK_OVERFLOW_HIDDEN call site runs under Xvfb on this branch, and --follow
is load-bearing there: a floating frame on a non-selected session is
hidden and skipped in layout, so the rounded clip would never reach GSK.
The un-sized default overlay then runs the same assertions after the card
tears down, keeping syncOverlay's stack branch covered — this scenario is
the suite's only `session overlay open`, so converting it rather than
pairing it would have left the more common shape with none. Both waits
poll the tree's `overlay` flag, which also proves each teardown ran.
@n2tr2
n2tr2 requested a review from melonamin as a code owner August 4, 2026 06:07
`@keyframes agterm-blink-pulse` used a comma-separated selector list
(`0%, 100%`). GTK gained GTK_CSS_TOKEN_COMMA handling in
_gtk_css_keyframes_parse after 4.14: 4.22 builds a selector array and
loops on the comma, while 4.14 consumes a single progress value and then
expects the block, so the comma is a parse error.

GTK drops an unparseable @Keyframes silently, so the agent-status pulse
has been dead on GTK 4.14 -- Ubuntu 24.04, which is the CI runner -- with
nothing to report it. Splitting into one selector per keyframe parses on
both; verified against the 4.14.0 and 4.22.4 sources and by loading the
composed app CSS through a real GtkCssProvider locally.

Found by the CSS parse guard added in the previous commit, on its first
CI run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@melonamin

Copy link
Copy Markdown
Owner

Thank you — this is a sound Linux-native implementation. The Quick Terminal and floating-overlay chrome is correctly scoped to GTK, the clipping behavior is well justified, and the added CSS validation is useful. Local tests, release build, and lint all pass. Great work.

@melonamin
melonamin merged commit 47b2e96 into melonamin:linux-port Aug 4, 2026
5 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.

2 participants