Skip to content

fix(tabs): draw the selected editor tab in an opaque fill instead of Liquid Glass (#2439) - #2443

Merged
datlechin merged 1 commit into
mainfrom
fix/tab-strip-light-selection
Aug 26, 2026
Merged

fix(tabs): draw the selected editor tab in an opaque fill instead of Liquid Glass (#2439)#2443
datlechin merged 1 commit into
mainfrom
fix/tab-strip-light-selection

Conversation

@datlechin

Copy link
Copy Markdown
Member

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:

build appearance track selected delta contrast
macOS 26, reporter light 222 228 +6 1.058:1
macOS 27, this machine light 232 220 -12 sign inverted
macOS 26, reporter dark 25 77 +53 2.096:1
macOS 27, this machine dark 61 122 +60 2.503:1

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:

  • Backdrop. Over four backdrops on macOS 27 the shipped tints give light -12 / +20 / +52 / +29 and dark -21 / +4 / +26 / +13. The selection changes with the desktop picture.
  • The Clear/Tinted setting. macOS 26.1 added a Liquid Glass appearance option with no public API to read it. Under Tinted the glass track moves from rgb(232) to rgb(242), costing 43 percent of the remaining margin.
  • No arrangement fixes it. Twenty arrangements were rendered and sampled on macOS 27: nested tints, untinted, Glass.clear on either surface, separate GlassEffectContainers, glassEffectUnion, glassEffectID, .interactive(), a raw NSGlassEffectView through NSViewRepresentable, 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. Glass publishes exactly regular, clear, identity, tint(_:) and interactive(_:), and NSGlassEffectViewStyle publishes only Regular and Clear. A runtime probe of the system's own NSTabBar shows why that is not enough: its track is a private NSSubduedGlassEffectView, and its selected tab is separated from the others by a private _variant on NSGlassEffectView (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:

light dark
track, unemphasizedSelectedContentBackgroundColor 220 70
selected, controlColor 255 116

Both 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: controlColor is 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.selectionNeverInverts pins 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

appearance before after
light track 232, selected 220 (-12, inverted) track 220, selected 255 (+35, 1.371:1)
dark track 61, selected 122 track 70, selected 116 (+46, 2.019:1)

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

  • Shape stays a capsule. The system's own tab bar reports cornerRadius = 12 on each 24pt tab, exactly half its height, and a corner fit of its 28pt track lands at 12 to 14pt. An in-content NSSegmentedControl uses the shallower (height - 4) / 4, but that is a different control in a different place.
  • Geometry stays 36 / 28 / 24 / 8 / 4 / 2pt, which is what the NSTabBar runtime probe reports.
  • Hover is untouched. The system darkens a hovered tab in light rather than lightening it: its bar measures rgb(220) under the pointer against a rgb(232) track, and tertiarySystemFill gives rgb(210) against rgb(220). The direction already matches.
  • No accent underline. Apple removed one from Safari's Tahoe tab bar in beta 3 because it confused users, and controlAccentColor is user-settable: a yellow accent measures 1.155:1 against this track.

Also in this commit

EditorTabStripSurfacesTests ran four appearances and covered two. NSAppearance.Name.accessibilityHighContrastAqua carries the raw value NSAppearanceNameAccessibilityAqua, and NSAppearance(named:) resolves that back to plain Aqua, which NSAppearance.currentDrawing().name confirms 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 and solidSurfacesAnswerBothSettings is where that contract is pinned.

EditorTabStripEmphasis loses its two tints and its rim constants, which duplicated EditorTabStripLayout.hairline. prefersSolidSurfaces no longer threads through EditorTabStripItem, since only the new-tab button reads it now.

Tests

EditorTabStripGlassGuardTests is a source scan, because a glassEffect cannot be rasterised: cacheDisplay returns 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 to newTabSurface, and that no surface is drawn as a rounded rectangle.

EditorTabStripSurfacesTests gains 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: PASS
  • build (TablePro, Debug): PASS
  • test EditorTabStripSurfacesTests EditorTabStripGlassGuardTests EditorTabStripChromeTests EditorTabStripGestureConventionTests EditorTabActivationTests: PASS, 36 of 36
  • lint over TablePro/Views/Main and TableProTests/Views/Main: 0 violations
  • Before and after captured from a running Debug build against a throwaway sandbox, in both appearances, and sampled with a CoreGraphics probe. The numbers above are from those captures.

No 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.

@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@datlechin
datlechin merged commit 6580e52 into main Aug 26, 2026
8 checks passed
@datlechin
datlechin deleted the fix/tab-strip-light-selection branch August 26, 2026 06:14
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.

Active/inactive tabs still look too similar in light mode

1 participant