Cards and surfaces

Cards and surfaces

The direction: glass, page by page

Signed off 2026-08-02. The dashboard moves to a frosted surface over a brand field, and the rest of the estate follows page by page.

Mock of the whole dashboard in it: docs/styling-review-2026-07/glass-dashboard.html, and the candidates it was chosen from: docs/styling-review-2026-07/card-idiom.html.

Why this and not the alternatives: a rounded box with a hairline and a shadow is what every tool ships, which is why our cards read as generic. A coloured rail on a rounded card is worse, because it is the single most recognisable machine-made pattern going. Glass is a real decision about the product’s surface rather than a decoration applied to a box.

What makes it ours rather than everyone’s is the squared corner: 14px radius on three corners, 3px on the bottom-left, carried down to sidebar items, thumbnails and speech bubbles. Glass on its own is a trend. Glass with a silhouette is an identity.

The corner on a flush surface

Decided 2026-08-04. It was raised for the browser extension Panel, which turned out not to be a flush surface: it sits right: 16px, so it floats clear and takes the normal 14/14/14/3. The rule below stands for anything genuinely flush, and is recorded because it will be right the first time somebody builds one.

A related trap the same investigation turned up: that Panel is top: 0 with height: 100%, so it is flush vertically even though it floats horizontally. Every one of its corners sits on the viewport’s top or bottom edge. Check every edge before deciding a surface floats, not just the one that prompted the question. The fix there is to inset it vertically as well and let the normal corner apply, rather than to invent a variant.

The signature survives, and reads more clearly. The visible corners on a right-flush panel are top-left and bottom-left, and the 3px is the bottom-left. The one corner carrying the signature is the one still on screen. On a normal card it is one squared corner competing with three rounded ones; on a flush panel it is one squared against one rounded, a straight 1:1 contrast.

The rule for a flush surface is 0 14 0 3. Square off the corners that meet the viewport edge rather than rounding them. A radius flush against the edge leaves a sliver of the page showing through the curve, which reads as a rendering fault rather than as a radius.

This is the same signature seen from the side, not a different one. Do not “fix” it back to four corners.

Four things that are true of it, and cost something

1. It is a change to the page, not to the card. backdrop-filter blurs what is behind it, and a flat page tint blurs to itself. Without the gradient field there is no glass, only a pale rectangle. Any page adopting this adopts the field first.

2. Large panels only. Every glass surface is a live blur of what is under it, recomputed on scroll. It belongs on panels, not on 46px rows: thirty blurring shortcut cards cost real frames and the effect is invisible at that size anyway.

3. Text has to be checked against the field, not against white. Measured against the darkest point the field puts under a card, which composites to rgb(236,228,238) at 62% white, a heading holds 10.59 and body text 5.33, but the eyebrow lavender lands at 4.25 and fails. Opaque enough to rescue it is 80% white, which is not glass. So on glass the eyebrow darkens to body ink.

This turned out not to be a one-off. The house muted ink fails on glass too. --ts-color-muted (#6b6880) is calibrated at 5.36:1 on white and 4.69:1 on the page tint, and measures 4.31:1 on glass. That is not a fault in the token: glass is simply darker than either surface it was calibrated against.

So converted pages step it one darker, to #5b5872 (5.48:1), and unconverted pages keep the calibrated value because it is right for the surface they are on. It is set in one place, glass.css, keyed on the body class, so each new page inherits it rather than needing its own contrast patch. The audit page needed one before this rule existed; the SEO page was about to need a second, which is the point at which a patch should become a rule.

The grey reaches the page three ways and all three need covering: the theme variable --paragraph, the design token --ts-color-muted, and Bootstrap’s .text-muted, which needs !important because the utilities are !important.

The general rule: check what sits ON the surface, not just the text in it

This has now caught us twice, in two codebases, from opposite directions:

  • A brand-purple icon on a brand-purple nav band. The text was checked and passed; the icon was assumed fine and was invisible.
  • A brand fill on a dark panel. The text was fine; the fill itself measured 1.28 against the panel and failed, so the state it was signalling could not be seen at all.

Two independent instances of one rule. Text contrast is not the whole check. Anything placed on a coloured surface needs measuring against that surface: icons, fills, borders, dividers, and any state indicator.

The thresholds differ, which is part of why this gets missed: 4.5:1 for text, 3:1 for non-text such as a fill, an icon or a boundary. A state indicator can pass its text check and still fail as an indicator.

There is a console sweep for the interaction-state half of this on Checking contrast. Run it on any page you convert: hover and focus states are the ones that cannot be caught by looking.

4. It is all or nothing per page. Glass on the dashboard beside flat white on the audit page looks broken, not transitional. Convert a whole page or none of it.

On a dark surface, do not build the field

Measured 2026-08-04 for the extension, whose dark panel colour is #3e3e3e.

A dark panel over a dark field has almost no range to work with. Compositing rgba(62,62,62,alpha) over a brand field on #1d1d1d, the difference between the panel over the field’s lightest and darkest points is:

panel alpha field showing through
.62 1.05
.70 1.03
.76 1.03
.88 1.01

1.05 is nothing, and lowering the alpha does not rescue it: .62 is already as good as it gets. Getting real range would need washes bright enough to look garish on dark.

So on dark: flat panel, hairline border, squared corner. Keep the field and the blur for light. Spending a gradient layer, a live blur and a calibration pass on an effect that measures 1.05 buys nothing, and the signature does not depend on the frost. It lives in the corner, the border and the ink discipline, all of which survive on a flat surface.

The active state inverts on dark. On light only a solid brand fill clears the 3:1 state threshold, at 7.09. On dark the same brand fails:

fill on dark vs panel
brand #712F79 1.28 fails
lightened #8f4a97 1.93 fails
lightest primary #b07fb8 3.53 passes

And on that fill, white text fails at 3.19 while dark #1d1d1d text passes at 5.28. So the dark active band is a light fill with dark text: the inverse of light mode, not a recolour of it. Assuming symmetry here ships an invisible state.

Blur is free to drop. Contrast is a function of the composite colour, not the blur radius, so a blur token can go to 0 under load without any of these numbers moving and without re-measuring.

The sidebar floats. Decided 2026-08-03

This was left open, with a note that it might want to stay solid as the one opaque element saying “this is the frame, the rest is content”. Built on a real page, it does not. The sidebar is a floating frosted panel, and it is the largest piece of glass on the page.

It keeps its 250px footprint and only the visible panel is inset, by 12px, so the content beside it does not move. Same 14/3 corner as every other panel, so it reads as part of the same family rather than as chrome that happens to be translucent.

The field has to move to body for this. A frosted panel needs something behind it, and if the field only covers the content area then the sidebar is frosting a flat white strip, which looks like a rendering fault. This is cost one again: it is a change to the page, not to the element.

Inset it with margins, never with top. The header is fixed and its offset varies with the viewer: 0 normally, 40px with the admin toolbar, 80px with its tray open. A hardcoded top clipped the first nav item under the header for admins only, which is a fault you will not see unless you happen to be logged in as one.

Re-ink the text. Cost three bites here harder than anywhere else. Against the darkest point the field puts under the panel, compositing to rgb(236,228,238) at 62% white:

on white on glass verdict
nav link #6b6880 16px 5.9 4.31 fails
section label #858b98 14px 3.42 2.75 already failed, worse
replacement #5b5872 7.4 5.48 passes

16px is not large text, so 4.5 is the bar for all of them. The labels take the same ink as the links rather than a lighter one, because a lighter ink is what failed; they still read as secondary because they are smaller, uppercase and letterspaced, so the hierarchy was never carried by colour alone.

The active item is a band welded to the frame. Updated 2026-08-03 after the first version was tried and rejected.

style.css gives every sidebar link border-left: 3px solid transparent and turns it purple when active. That is the left accent rail this whole direction rejected, and it survived only because it predates the decision. Floating the sidebar is what made it obvious: an edge marker on a panel that no longer has an edge to sit against.

The first replacement was a brighter well. It looked like a white rectangle, and there is a number behind that: it separated from the frosted panel by 1.17:1, against the 3:1 a non-text state indicator needs. It was not merely plain, it was barely perceivable as a state.

A tint does not fix it. Any wash over frosted glass barely shifts the surface: brand at 10% measured 1.17, at 16% measured 1.29. This is worth remembering for any future state on this surface. Only a solid fill clears the threshold, at 7.09, with white text at 8.81.

Solid brand is not a new idea here: the next-steps panel already uses a filled brand block with white text for its dominant state.

The band bleeds to the left edge rather than floating inside the panel. A detached rectangle in the middle of a list says “this one is different”; a band welded to the frame says “this is where you are”. Square on the left, rounded on the right: a relative of the house 14/3 corner rather than the same shape, because the signature belongs to cards and panels and this is a band welded to an edge.

Three things that only showed up on a real page, not in the mock:

  • The link computes an explicit 224px width, so margin-right pushed it past the edge instead of insetting it. It needs width: auto.
  • The icons are <img> files, not inline SVG. Each item ships deselect, sm and active variants, and the active one is brand purple because it was drawn for a pale well. No stroke or fill rule can reach inside an <img>. Use filter: brightness(0) invert(1), which flattens any artwork to white and so cannot be broken by the artwork changing later.
  • Child items indent 30px on the ul, so their band stopped short and its square left edge floated in mid air. Square reads as deliberate only when it is welded to something. Pull the band back to the edge and move the indent onto the label.

A rounded panel wants clipping, not scrolling. Found 2026-08-05, and it was self-inflicted by the change above.

Floating the sidebar gave it a radius, and a long menu spilled past the corner. The obvious fix was overflow-y: auto on the panel. But .sidebar-list-item inside it has carried overflow: auto since style.css:334, so the panel became a scroller wrapped around a scroller. On pages whose sidebar carries extra blocks below the list, the help-support menu or the editor thumbnail panel, the shell overflowed too and drew a second bar beside the inner one. That is why it appeared intermittently rather than everywhere, and why it read as a glitch rather than as a layout bug.

overflow: hidden gives the radius the clipping it actually needed and adds no scroller. The companion is min-height: 0 on the scrolling child, so it absorbs the shrink instead of forcing the shell to overflow: without it the footer below the list gets clipped away rather than staying reachable.

The general rule: before adding overflow to a container, check whether a descendant already scrolls. Reaching for overflow to fix a visual spill is how you get two scrollbars, because clipping and scrolling are different jobs and auto does both.

Worth knowing how this was found, because it is not visible at rest: walk the sidebar’s descendants for computed overflowY of auto or scroll and count how many have scrollHeight > clientHeight. More than one is the bug. A screenshot at a comfortable window size shows nothing at all.

Still open: the header strip is solid and now sits above a floating sidebar. It reads fine, but it is the one piece of chrome not decided either way.

The panel rules name classes, so check the page actually has them

The glass panel rules name .card, .middle-content-block and .ts-card. A page built from a different vocabulary gets the field and the sidebar but keeps its own panels, which reads as a page the conversion forgot rather than as a page that was never listed.

That is exactly what happened to connected accounts. It uses .ts-card, from connected-accounts.css and social-composer.css, and has no .card anywhere on it, so it sat on the glass field with solid white panels and a uniform 12px radius. Adding .ts-card to the panel selector fixed it: the scoped rule outranks the component’s own, so the glass background, radius and shadow replace its white fill and inset hairline while its padding survives.

When a converted page still looks wrong, check what its panels are actually called before assuming the route list is at fault. See Checking buttons for the same trap in the button sweep, where it is worse: there it returns a clean result rather than an obviously wrong page.

How a page is converted

Add its route to _tubespanner_purple_glass_pages() in tubespanner_purple.theme. That adds a ts-glass-page body class and the whole surface follows. That list is the only record of what is converted.

It is a body class rather than a class in the markup because there are 75 page templates, each carrying its own copy of the dashboard wrappers. Converting by editing markup would mean touching 75 files and would guarantee a half-converted page somewhere. The sidebar also sits outside the content wrapper, so a class on the content could never reach it.

Converted so far: the dashboard, the channel audit, and the Discovery and SEO Optimiser page.

Each conversion so far has needed nothing page-specific beyond the route. The two things that did come up, nested panels drawing a second surface and muted text failing contrast, were both fixed as general rules, so the next page should inherit them.

The interim card, for pages not yet converted

Until a page moves, cards stay white and the state colour is an accent, not a surface and not an edge. See Outcome states for where the colour is spent.

  • White surface, 12px radius, thin hairline border
  • Bold ink title, muted detail line
  • Action last, separated by a hairline footer
  • No left accent rail. We use it nowhere else: next-steps rows separate with a bottom divider and the shortcut cards carry their colour in the icon disc. An accent edge is a second copy of a signal a real component already carries
  • No tinted card. The tint tokens are for chips and badges

This is not a lesser design waiting to be replaced. It is what a card looks like when the state is carried by content rather than by decoration, and the glass version keeps every one of these rules on top of a different surface.

Two traps, both found by trialling on a real page

.ts-card is taken. It is defined in css/components/connected-accounts.css and .ts-card-title is used by the social post composer and calendar. A global .ts-card silently restyles them. The house class is .ts-stack-card.

A filled animation outranks a normal declaration. The dashboard tool row animates its cards in with animation-fill-mode: both, ending on transform: none. That value keeps applying for good and beats any transform declared in CSS, so a card idiom using one renders as a plain rectangle and looks like the stylesheet never landed. Use backwards unless the end state is genuinely meant to be permanent.

The corner has two sizes, and that is deliberate

The signature is 14px on three corners, 3px bottom-left. Small surfaces take 12px on three corners, 3px bottom-left.

surface radius
panel: a section of the page 14px 14px 14px 3px
small surface: a shortcut row, a tile 12px 12px 12px 3px

The squared corner is constant. Only the rounded ones step down, because a 46px row with a 14px radius reads as rounder than a 600px panel with the same value: the same number is a larger proportion of a smaller box.

Worth knowing because it looks like drift. A sweep of the dashboard finds 34 surfaces at 12px and 4 at 14px and reports an inconsistency, and QA raised exactly that. It is a two-step scale, not two decisions. The bottom-left is 3px in both, which is the part that actually carries the identity.

The same rule decides the blur: small surfaces stay opaque. A live backdrop-filter on dozens of 46px rows costs a compositing pass each and buys nothing at that size.