Skip to content

feat(Button): glass edge on outline and primary buttons - #1615

Draft
tenphi wants to merge 16 commits into
mainfrom
claude/focused-brahmagupta-9pqdlm
Draft

tenphi wants to merge 16 commits into
mainfrom
claude/focused-brahmagupta-9pqdlm

Conversation

@tenphi

@tenphi tenphi commented Nov 4, 2026 •

Copy link
Copy Markdown
Member

Describe changes

A prototype "glass" edge for UI Kit's Button. The edge becomes one ring decoration, plus matching fill shading, and that one definition works for every variant, state, theme and scheme. Button sizes don't change.

🎨 The edge

  • Outline (outline, outline-2) and primary buttons draw their edge as a 1bw ring in their own fill color: a little lighter along the top and bottom, darker along the sides.
  • The ring's colors are the fill itself, moved in OKLCH lightness: oklch(from <fill> calc(l ± Δ) c h).
    • $glass-edge-lift (.04) controls how much lighter the top and bottom are.
    • $glass-edge-shade (.08) controls how much darker the sides are.
    • High contrast doubles both.
  • On the light outline, the sides land on the old #border color (L 0.899 vs 0.898).
  • Fill shading: the fill darkens a little toward the top and bottom ($glass-fill-shade, .02), right where the ring is light, so the ring's light edges stand out against it.
    • It's a background-image over the fill color, built the same way (fill lightness minus Δ, fading to the transparent fill).
    • It follows hover and pressed, and sits under the label.
  • Buttons sit on a soft outer shadow ($glass-halo, $glass-accent-halo) that collapses while pressed or disabled. Brand fills also glow softly in their own color (0 0 1.5px).
  • Validation (invalid / valid) keeps its colored border, because the ring steps aside. The focus ring is unchanged.
  • clear and link buttons are unchanged.
  • All glass tokens are exported as GLASS_TOKENS, next to SHADOW_TOKENS. This is the one public API addition, and it's recorded in src/__snapshots__/public-api.md.

🔀 ButtonSplit

  • Outline halves no longer overlap by -1bw. Each draws its own ring, and two rings stacked on one pixel read as a heavier seam than two side by side. The group is 1px wider per seam.
  • Primary halves keep main's 1bw gap (fix(Button): remove the visible primary border, divide split halves with a gap #1623), which shows the background between the two rings.
  • The hover and focus-visible z-index rules stay, for the focus ring and the halo.

🧪 Why relative color, not a blend mode

Measured over the real fills, as OKLab lightness change, top / sides:

Technique ☀️ outline ☀️ primary 🌙 outline 🌙 primary
plain alpha (white/black 12%) +0.003 / −0.090 +0.054 / −0.050 +0.104 / −0.018 +0.058 / −0.046
soft-light +0.010 / −0.021 +0.178 / −0.214 +0.224 / −0.145 +0.188 / −0.217
overlay 30% +0.006 / −0.006 +0.106 / −0.109 +0.044 / −0.046 +0.102 / −0.106
relative color +0.021 / −0.080 +0.040 / −0.080 +0.040 / −0.080 +0.040 / −0.080
  • Blend modes act mostly on mid-tones. They vanish on the near-white outline fill and overpower saturated fills, a swing of about 20×.
  • Plain alpha does the reverse.
  • Relative color is the only option that stays constant. Its one exception is the light outline's top, which can only go up to white.

📐 Implementation

  • glassEdge() routes the variant's own fill map through a #glass-fill token (fill: '#glass-fill'), so the ring, the fill shading and the glow all derive from it, states included.
  • ::before (Edge) sits at inset: -1bw with a 1bw padding, cut to a ring with mask-composite: exclude.
  • At 1bw it can inherit the radius, so ButtonSplit's corners and the new shape prop (feat(Button): add a shape prop #1625) work with no special casing, and so do consumer radius overrides.
  • The 1bw border stays, but transparent, so button sizes are identical.
  • This replaces the PR's earlier macOS-fitted layers: hairline ring, sheen, highlight lines, inner shade, dark tint, and their accent twins.

🌐 Browser support: relative color syntax needs Chrome/Edge 119+, Safari 18+ or Firefox 128+. Older browsers drop the gradients, so they show no ring and no shading. The outer shadow still outlines the button.

Checklist
  • Pipeline is passed
  • Tests are passed successfully (full local suite: 212 files, 5526 passed, 1 skipped)
  • Changeset(s) is(are) added
  • Commit message follows commit guidelines

Closes: N/A

Other information

  • 🧪 Prototype scope: tuned on the default theme. The other themes use the same ring and shading, derived from their own fills, but haven't been reviewed by eye.
  • 🧯 Flaky test, not from this PR: ItemActionsWrapper.browser.test.tsx ("leaves a measured row alone…") fails intermittently on main too. Details are in the PR comment.
  • 🔍 To review, open Storybook → Actions / Button and Actions / Button.Split, in light, dark and high contrast.
  • ➕ No new tests or stories: this is a visual-only change with no new props, and the existing stories show every type and state.
  • 🔀 main was merged in rather than rebased. The latest merge resolved the ButtonSplit margin conflict with fix(Button): remove the visible primary border, divide split halves with a gap #1623 as described above.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH

Port the glass edge from tenphi.me: a dark shade along the sides and a
light tint along the top and bottom, drawn as inset shadows.

- New global tokens: `$glass-shadow` for neutral surfaces and
  `$glass-accent-shadow` for brand fills, which keep their color in both
  schemes (the same split tenphi.me uses for badges). The accent glow is
  stronger and softer so it reads on a saturated fill.
- Outline, outline-2 and primary buttons replace the solid border with
  the glass rim, plus a small drop shadow that collapses while pressed.
- The 1bw border stays (transparent, the fill paints under it), and the
  rim is drawn on a `::before` overlay reaching over it, so the layout
  doesn't change. An inset shadow on the button itself would sit 1bw in
  from the edge.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
A blurred inset shadow ramps along every edge of the box, so the
`0 1px 2px` highlight also crept down the sides and read as a glow all
around a primary button. A negative spread of half the blur pushes the
side ramp past the edge, leaving the glow on the top and bottom only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Main now generates the tasty config token lists, so the glass tokens are
listed in tasty.config.generated.ts instead of by hand in tasty.config.ts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
…ark mode

The dark scheme drops the side shade on brand fills and thins the top and
bottom glow to the neutral $glass-tint geometry. The shade stays as a
transparent layer, so the shadow keeps its layer count and still animates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
@changeset-bot

changeset-bot Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 1e532f4

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
@cube-dev/ui-kit Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@vercel

vercel Bot commented Nov 4, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cube-ui-kit Ready Ready Preview Nov 9, 2026 6:30pm UTC

Request Review

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📦 NPM canary release

Deployed canary version 0.0.0-canary-2db08f7.

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

🧪 Storybook is successfully deployed!

@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

🏋️ Size limit report

Name Size Change
All 633.76 KB +0.53% 🔺
Tree shaking (just a Button) 160.96 KB · 25.4% of All +0.25% 🔺

Compared against main at d29a8bf — run 42668868916, 2026-11-09T17:52:42Z.

Third-party packages

✅ No third-party package entered or left the bundle, and none grew more than 10 KB past its approved size.


To see which modules changed, download the size-limit-statoscope-report artifact from this run and open report.html.

Replace the small drop shadow with $glass-halo, a 0 0 .75x shadow in the
border color in light mode and in near-black in dark mode. It stands in for
the solid border the glass edge replaces, on neutral and brand surfaces
alike, and still collapses while pressed or disabled.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Outline buttons get a very faint black halo in dark mode ($glass-halo, .2),
and brand fills a lighter one than before ($glass-accent-halo, .5 instead
of .8). Light mode keeps the border-colored halo on both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Fitted to macOS light, dark and disabled screenshots with a pixel-level
harness. The edge is now five layers: a hairline ring colored by a vertical
gradient (light at the top and bottom, dark at the sides), crisp highlight
lines, a vertical sheen that can't leak down the sides, a large inner shade
under the glow, and the outer shadow. In dark mode a translucent tint lifts
outline buttons off the page. Disabled buttons keep a flat ring only.

The ring is laid out at double size and scaled by half, because Chromium
rounds a thinner border or padding up to 1px, twice the ring's width on a
2x screen.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
The fitted macOS light highlight is wide and solid white, which barely
shows on the white macOS fill. On the button's gray light fill, darker
still when hovered, it read as one big glow at the top. Light mode now
takes dark mode's shape: a thin translucent line at the top, a fainter one
at the bottom, short sheens and a lighter inner shade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
The light-mode fix moved the sheen and highlight to both the top and bottom
edges, and the inner shade is light mode only.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Primary buttons (and the other accent-glass variants) add a 0 0 1.5px
shadow in their resting fill color, so the fill bleeds softly past its
edge. The color comes from each variant's own fill, so every theme glows
in its own color. It goes transparent while pressed or disabled, keeping
the layer count so the shadow still animates.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH

tenphi commented Nov 5, 2026

Copy link
Copy Markdown
Member Author

CI note: Browser tests / Compiler on (2/2) failed on 30e595e, and it isn't caused by this PR.

What fails

  • src/components/actions/ItemActionsWrapper.browser.test.tsx › "ItemActionsWrapper in a hidden container › leaves a measured row alone while its container closes and reopens".
  • The assertion expect(changes).toEqual([]) saw 2 attribute mutations on the wrapper/trigger while the container closed and reopened.

Why it isn't this PR's

  • The test renders ItemButton, not Button, and this PR only changes Button's glass styles and tokens.
  • It fails intermittently on plain main too (97ee45c): 1 of 3 runs of the same shard (UIKIT_REACT_COMPILER=on vitest --config vitest.browser.config.ts --shard=2/2).
  • On this branch it failed 1 of 2 shard runs.
  • Run on its own, it passes 5 of 5 times, with the compiler on and off.
  • The test arrived with fix(ItemActionsWrapper): re-measure the actions run when its size changes #1613. The same job passed on this PR's previous head, 0b24d4e.

Fix: none on main yet. I'm capturing which attributes change when it fails, to propose a fix separately rather than widen this PR. The next push re-runs the job.


Generated by Claude Code

Replace the macOS-fitted layers (hairline ring, sheen, highlight lines,
inner shade, dark tint and their accent twins) with a single decoration:
a 1bw ring on ::before, drawn over the transparent border, in the button's
own fill color. Its top and bottom are the fill lifted in OKLCH lightness
($glass-edge-lift) and its sides the fill shaded ($glass-edge-shade), so
the same ring reads on a near-white chip, a dark one and any brand fill.

Blend modes were measured and left out: overlay and soft-light act mostly
on mid-tones, so they vanish on the near-white outline fill and overpower
saturated ones; plain alpha does the reverse.

The fill goes through #glass-fill (the variant's own fill map), so the ring
and the brand glow derive from it. At 1bw the ring inherits the radius, so
the doubled radius map and the consumer-override limitation are gone. The
outer shadows and the brand glow stay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
The fill now darkens a little toward the top and bottom edges, right where
the edge ring is light, so the ring's light edges stand out against it. It
is a background-image over the fill color, built from #glass-fill like the
ring (fill lightness minus $glass-fill-shade, fading to the fill itself),
so it works on every variant, state, theme and scheme and follows hover and
pressed. Default .04.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
The top and bottom fill shading read as too intense at .04. At .02 the
ring's light edges still stand out against it, without visible banding.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Every bordered button type now draws its own glass edge ring, so two
rings stacked on one pixel read as a heavier seam than two side by side.
The hover and focus z-index rules stay for the focus ring and halo.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH
Resolves the ButtonSplit margin conflict with #1623: outline halves keep
this branch's no-overlap margin (each draws its own glass edge ring), and
primary halves keep main's 1bw gap. Records the GLASS_TOKENS export in
the new public API snapshot and mentions it in the changeset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Gr8S7GVQTMvbnBDXC4EodH

This branch was successfully deployed

2 active deployments
Chromatic staging — 1e532f43 Deployed Nov 9, 2026 by tenphi via Prepare Storybook for review & tests #4336
Preview — 1e532f43 Deployed Nov 9, 2026 by vercel[bot]
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