fix(tabs): draw the selected editor tab in an opaque fill instead of Liquid Glass (#2439) - #2443
Merged
Merged
Conversation
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #2439.
The bug
In light appearance the selected editor tab is indistinguishable from the unselected ones. Dark reads fine, which is what made this look like a tuning problem.
It is not. Measured on the reporter's own macOS 26 screenshots and on this machine's macOS 27 build:
On macOS 27 the selected tab renders darker than its track, so it reads as recessed rather than raised. In the real titlebar-accessory context it also flips sign with window activation: +22 when the window is key, -12 when it is not.
Root cause
The selection was expressed as a Liquid Glass tint. A glass tint's magnitude, and its sign, are a function of what is behind the window and of an undocumented per-release rendering pipeline, and the app controls neither.
Fitted to the reporter's pixels, the law is
selected = track + (255 - track) * alpha. In light appearance there is almost no headroom left, so a white tint cannot lift: 222 + 33 * 0.22 = 229 against a measured 228. In dark there is plenty: 25 + 230 * 0.22 = 76 against a measured 77.Three things follow, all measured rather than reasoned:
-12 / +20 / +52 / +29and dark-21 / +4 / +26 / +13. The selection changes with the desktop picture.Glass.clearon either surface, separateGlassEffectContainers,glassEffectUnion,glassEffectID,.interactive(), a rawNSGlassEffectViewthroughNSViewRepresentable, and siblings. In light appearance every one put the selected tab darker than its track, from -4 to -35. Sibling glass inside one container merges outright: delta exactly 0.The reason is in the SDK.
Glasspublishes exactlyregular,clear,identity,tint(_:)andinteractive(_:), andNSGlassEffectViewStylepublishes onlyRegularandClear. A runtime probe of the system's ownNSTabBarshows why that is not enough: its track is a privateNSSubduedGlassEffectView, and its selected tab is separated from the others by a private_variantonNSGlassEffectView(1 for the selected tab, 13 for the rest). Neither is reachable.The fix
The track and the selected tab become opaque fills. Only the new-tab button stays glass, which is the one surface whose tone carries no state.
That is Apple's own instruction for this shape. WWDC 2025 session 219: "always avoid glass on glass ... When placing elements on top of Liquid Glass, avoid applying the material to both layers. Instead, use fills, transparency, and vibrancy for the top elements." The strip sits in a titlebar accessory that is already the system's glass layer, so these fills are the top layer on it rather than a second pane of it.
It is also what Apple shipped after hitting this exact bug. Safari on Tahoe beta 1 and 2 measured track 247 / selected 242, an inverted 1.045:1. Beta 3 fixed it by subduing the track to 221 and lifting the selection to 247.
The colours are the ones the pre-macOS-26 path already used, so the two paths converge on one palette:
unemphasizedSelectedContentBackgroundColorcontrolColorBoth are within a couple of levels of the system's own tab bar, which measures 232 / 253 in light and 71 / 74 in dark.
Why the sign can no longer invert, by construction rather than by tuning:
controlColoris opaque white in light, so it is the ceiling and no track can rise above it; in dark it is white at alpha 0.247, so it composites over whatever the track resolved to and always lifts.EditorTabStripSurfacesTests.selectionNeverInvertspins exactly that.The selected tab also gains the hairline rim it was missing. A vertical section through the system's own selected segment reads track 236, rim 215, highlight 255, body 242: the fill carries six levels and the edge carries twenty-one. Only the fill was drawn here before, which is why the selection read as flat even at the distance the system uses.
Measured result, real app on macOS 27
Identical across four backdrops, in a key and a background window, and under both Clear and Tinted, because nothing in the pair samples anything any more.
What did not change, and why
cornerRadius = 12on each 24pt tab, exactly half its height, and a corner fit of its 28pt track lands at 12 to 14pt. An in-contentNSSegmentedControluses the shallower(height - 4) / 4, but that is a different control in a different place.NSTabBarruntime probe reports.tertiarySystemFillgives rgb(210) against rgb(220). The direction already matches.controlAccentColoris user-settable: a yellow accent measures 1.155:1 against this track.Also in this commit
EditorTabStripSurfacesTestsran four appearances and covered two.NSAppearance.Name.accessibilityHighContrastAquacarries the raw valueNSAppearanceNameAccessibilityAqua, andNSAppearance(named:)resolves that back to plain Aqua, whichNSAppearance.currentDrawing().nameconfirms from inside the block. Spelling the real name out does not rescue it either: it instantiates in a plain process and returns nil in the test host. Increase Contrast is a system setting rather than an appearance to borrow, so the two dead cases are gone andsolidSurfacesAnswerBothSettingsis where that contract is pinned.EditorTabStripEmphasisloses its two tints and its rim constants, which duplicatedEditorTabStripLayout.hairline.prefersSolidSurfacesno longer threads throughEditorTabStripItem, since only the new-tab button reads it now.Tests
EditorTabStripGlassGuardTestsis a source scan, because aglassEffectcannot be rasterised:cacheDisplayreturns an empty bitmap for the whole hosting view, not merely for the glass. It asserts the strip declares exactly one glass surface and that it belongs tonewTabSurface, and that no surface is drawn as a rounded rectangle.EditorTabStripSurfacesTestsgains the sign guarantee above, a check that a hovered tab never travels further from the track than the selected one, and a check that the tab stays inset inside the track on every side.Verification
generate: PASSbuild(TablePro, Debug): PASStestEditorTabStripSurfacesTestsEditorTabStripGlassGuardTestsEditorTabStripChromeTestsEditorTabStripGestureConventionTestsEditorTabActivationTests: PASS, 36 of 36lintoverTablePro/Views/MainandTableProTests/Views/Main: 0 violationsNo UI automation: the defect is a pixel relationship between two fills, which XCUITest cannot read, and the guard test states the structural property directly instead. No docs change, and no PluginKit ABI surface is touched.