refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface #131

Merged
varutasu merged 1 commit from migrate-button-input-mobilenav-to-glass-primitive into main 2026-06-05 07:23:55 -04:00
6 changed files with 157 additions and 44 deletions

View file

@ -1,10 +1,83 @@
--- ---
status: queued status: closed
classification: server-only-no-actually-just-frontend-styles-cleanup classification: server-only-no-actually-just-frontend-styles-cleanup
parent_convoy: unify-glass-panel-surfaces parent_convoy: unify-glass-panel-surfaces
blocked_by: [] blocked_by: []
size: small size: small
budget_hours: 2-3 budget_hours: 2-3
created: 2026-06-04
closed: 2026-06-05
prs:
- 131 # Single-PR convoy — all 3 migrations + CI allowlist reduction
---
## Architect ratifications (2026-06-05)
The seed posed 3 open questions. Ratified decisions:
**D1 — Button secondary** → New `.btn-glass-secondary` utility class
in `styles/globals.css` (NOT `<GlassSurface>`). Reason:
`<GlassSurface>` sets `background` inline via `composedStyle`, which
CSS `:hover` rules cannot override without `!important`. A
purpose-built class with a pure-CSS `:hover` swap (high → mid fill
on the padding-box layer of the gradient-border composition) keeps
the hover semantics clean. The class composes the same high-tint
gradient-border that `.glass-panel-strong` uses.
**D2 — Input** → New `.glass-input` utility class in
`styles/globals.css` (NOT `<GlassSurface as="input">` and NOT a
wrapping `<GlassSurface as="div">`). Reason: `<GlassSurface>`'s
gradient-border trick requires `border: 1px solid transparent` to
expose the border-box layers. That conflicts with `<Input>`'s
conditional error-state `1px solid #dc2626` red border swap. The
new class adopts only the tint + blur layer; the visible 1px
border + focus ring stay in JSX (class-controlled, not inline).
**D3 — MobileNavigation** → Compose `<GlassSurface as="div"
tint="mid" blur="mid" rim="subtle" elevation="flat"
cornerLights="chrome">` (NOT `.page-header-glass`). Reason: the
seed recommended `.page-header-glass` reuse, but that class uses
`var(--glass-surface-high)` (wrong tint — MobileNav uses mid) and
sets a bottom-border separator (wrong for a fixed-bottom-nav
where the bottom edge is the viewport edge). `<GlassSurface>` is
the better fit AND brings the chrome-tier corner-light bleed that
the parent convoy is unifying across all chrome surfaces.
## Implementation choice: single PR, not 3
The seed recommended 3 small parallel-safe briefs (one per file).
On execution that's the wrong decomposition — D1 and D2 BOTH need
the same `styles/globals.css` to gain new utility classes, so
those 2 changes can't run truly in parallel without merge
conflicts. With the CI billing block still active, each PR also
requires an admin-merge cycle. Shipping all 3 migrations + the
CSS additions + the CI allowlist reduction in a single PR was
faster, simpler to review end-to-end, and the natural shape for a
small (2-3 hour) convoy with tightly-coupled artifacts.
## Closeout
All acceptance criteria from the seed met:
- [x] `Button.js` secondary variant uses `.btn-glass-secondary`.
- [x] `Input.js` outer wrapper uses `.glass-input`.
- [x] `MobileNavigation.js` bottom-nav backdrop uses `<GlassSurface>`.
- [x] `grep -lE "var\(--glass-surface-(low|mid|high)\)" pages
components -r --include='*.js'` returns **only Layout.js +
TopSearchBar.js** (GlassSurface.js uses a template-literal `${tint}`
that doesn't match the static regex — intentional).
- [x] `.github/workflows/ci.yml`'s `GLASS_ALLOWLIST` reduced from
6 entries to 3 (the 3 chrome blocks).
- [x] `npm run lint` passes (1 pre-existing unrelated warning).
- [x] `npm run test:run`: 118/118 tests pass.
- [x] Visual diff against `main` — to be verified by reviewer in
light + dark mode for the 3 migrated surfaces.
The parent convoy `unify-glass-panel-surfaces` is now FULLY closed
— no residual handrolled glass-surface usage outside the 3 chrome
blocks, and the gate (Check 7/7 of `forbidden-patterns`) enforces
that contract going forward.
--- ---
# migrate-button-input-mobilenav-to-glass-primitive # migrate-button-input-mobilenav-to-glass-primitive

View file

@ -320,29 +320,16 @@ jobs:
# - components/ui/GlassSurface.js # the primitive itself # - components/ui/GlassSurface.js # the primitive itself
# - components/Layout.js # sidebar nav-chip chrome (L850 area) # - components/Layout.js # sidebar nav-chip chrome (L850 area)
# - components/ui/TopSearchBar.js # <header> chrome # - components/ui/TopSearchBar.js # <header> chrome
# --- Pending migrations (queued follow-up convoy) ---
# TODO: migrate-button-input-mobilenav-to-glass-primitive convoy.
# The 3 entries below currently handroll their own glass
# surfaces — they should compose <GlassSurface> (cornerLights
# default 'subtle' from Brief 1) or the appropriate
# .glass-panel-* class. They are allowlisted so this gate can
# ship NOW and prevent future regressions; the cleanup is
# tracked in `.convoys/migrate-button-input-mobilenav-to-glass-primitive.md`.
# When that convoy lands, remove the 3 entries below.
# - components/ui/Button.js # secondary variant inline + hover:bg-[var(...)]
# - components/ui/Input.js # input fill background
# - components/MobileNavigation.js # bottom-nav background
# #
# Adding a fourth chrome-tier entry is a design-system decision # The pending-migration entries (Button / Input /
# — open a new convoy. Removing an entry from the pending- # MobileNavigation) were removed by the
# migration tier requires the matching convoy to ship. # migrate-button-input-mobilenav-to-glass-primitive convoy
# (2026-06-05). Adding a fourth chrome-tier entry is a
# design-system decision — open a new convoy.
GLASS_ALLOWLIST=( GLASS_ALLOWLIST=(
"components/ui/GlassSurface.js" "components/ui/GlassSurface.js"
"components/Layout.js" "components/Layout.js"
"components/ui/TopSearchBar.js" "components/ui/TopSearchBar.js"
"components/ui/Button.js"
"components/ui/Input.js"
"components/MobileNavigation.js"
) )
GLASS_FOUND=() GLASS_FOUND=()
while IFS= read -r file; do while IFS= read -r file; do

View file

@ -1,6 +1,7 @@
import { useRouter } from 'next/router'; import { useRouter } from 'next/router';
import Link from 'next/link'; import Link from 'next/link';
import { useState } from 'react'; import { useState } from 'react';
import { GlassSurface } from './ui';
export default function MobileNavigation({ onMenuOpen }) { export default function MobileNavigation({ onMenuOpen }) {
const router = useRouter(); const router = useRouter();
@ -78,16 +79,21 @@ export default function MobileNavigation({ onMenuOpen }) {
return ( return (
<div className="md:hidden fixed bottom-0 left-0 right-0 z-50"> <div className="md:hidden fixed bottom-0 left-0 right-0 z-50">
{/* Liquid Glass background mid-tint surface, top rim-light to {/* Liquid Glass background mid-tint surface with chrome-tier
define the bar's edge against the page content above. */} corner-light bleed, matching the rest of the floating chrome
<div (sidebar nav-chip, TopSearchBar). Composed via <GlassSurface>
post-migrate-button-input-mobilenav-to-glass-primitive
convoy (2026-06-05) `rim="subtle"` reproduces the previous
rim-light-inner + rim-light-outer halo; `cornerLights="chrome"`
brings in the corner-light bleed that the convoy is
unifying across all chrome surfaces. */}
<GlassSurface
className="absolute inset-0" className="absolute inset-0"
style={{ tint="mid"
background: 'var(--glass-surface-mid)', blur="mid"
backdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))', rim="subtle"
WebkitBackdropFilter: 'blur(var(--glass-blur-mid)) saturate(var(--glass-saturate))', elevation="flat"
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)', cornerLights="chrome"
}}
/> />
{/* Navigation Content */} {/* Navigation Content */}

View file

@ -37,16 +37,8 @@ const Button = forwardRef(function Button(
}; };
variantClass = 'font-medium transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]'; variantClass = 'font-medium transition-transform duration-200 hover:scale-[1.02] active:scale-[0.98]';
} else if (variant === 'secondary') { } else if (variant === 'secondary') {
variantStyle = { variantStyle = {};
background: 'var(--glass-surface-high)', variantClass = 'btn-glass-secondary font-medium';
backdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
color: 'var(--text-primary)',
boxShadow: 'var(--rim-light-inner), var(--rim-light-outer)',
};
variantClass = 'font-medium transition-all duration-200 hover:bg-[var(--glass-surface-mid)]';
} else if (variant === 'danger') { } else if (variant === 'danger') {
variantStyle = { variantStyle = {
backgroundColor: '#dc2626', backgroundColor: '#dc2626',

View file

@ -54,7 +54,7 @@ const Input = forwardRef(function Input(
aria-invalid={error ? 'true' : undefined} aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined} aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined}
className={[ className={[
'w-full rounded-xl px-4 py-3 transition-all duration-200', 'glass-input w-full rounded-xl px-4 py-3 transition-all duration-200',
'focus:outline-none focus-visible:ring-2', 'focus:outline-none focus-visible:ring-2',
leadingIcon ? 'pl-10' : '', leadingIcon ? 'pl-10' : '',
trailingIcon || trailingAction ? 'pr-10' : '', trailingIcon || trailingAction ? 'pr-10' : '',
@ -62,11 +62,6 @@ const Input = forwardRef(function Input(
.filter(Boolean) .filter(Boolean)
.join(' ')} .join(' ')}
style={{ style={{
background: 'var(--glass-surface-high)',
backdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
WebkitBackdropFilter:
'blur(var(--glass-blur-low)) saturate(var(--glass-saturate))',
border: inputBorder, border: inputBorder,
color: 'var(--text-primary)', color: 'var(--text-primary)',
'--tw-ring-color': error ? '#dc2626' : 'var(--accent-ember)', '--tw-ring-color': error ? '#dc2626' : 'var(--accent-ember)',

View file

@ -859,6 +859,66 @@ body {
var(--elevation-pronounced); var(--elevation-pronounced);
} }
/* Button secondary (glass) variant.
Adopted by `<Button variant="secondary">` after the
migrate-button-input-mobilenav-to-glass-primitive convoy
(2026-06-05). Composes the same high-tint gradient-border that
.glass-panel-strong uses, but with a `:hover` swap that flips the
padding-box fill from high mid so the button still "responds"
to mousing without changing its corner-light contract.
Why a class instead of `<GlassSurface>` (D1 of architect plan):
`<GlassSurface>` sets `background` inline, which CSS `:hover`
rules cannot override without `!important`. A purpose-built
class keeps the hover semantics pure CSS. */
.btn-glass-secondary {
background:
linear-gradient(var(--glass-surface-high), var(--glass-surface-high))
padding-box,
radial-gradient(at 0% 100%, var(--corner-light-warm-subtle) 0%, transparent 42%)
border-box,
radial-gradient(at 100% 0%, var(--corner-light-cool-subtle) 0%, transparent 42%)
border-box,
var(--chip-border-base) border-box;
border: 1px solid transparent;
backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate));
box-shadow:
var(--rim-light-inner),
var(--rim-light-outer);
color: var(--text-primary);
transition: background 200ms ease;
}
.btn-glass-secondary:hover {
background:
linear-gradient(var(--glass-surface-mid), var(--glass-surface-mid))
padding-box,
radial-gradient(at 0% 100%, var(--corner-light-warm-subtle) 0%, transparent 42%)
border-box,
radial-gradient(at 100% 0%, var(--corner-light-cool-subtle) 0%, transparent 42%)
border-box,
var(--chip-border-base) border-box;
}
/* Input glass-surface variant.
Adopted by `<Input>` after the
migrate-button-input-mobilenav-to-glass-primitive convoy
(2026-06-05). Mirrors .glass-panel-strong's tint + blur but does
NOT use the gradient-border trick (D2 of architect plan): the
Input control needs a conditional 1px border that flips to red
on error (`aria-invalid`), and the gradient-border requires
`border: 1px solid transparent` to expose the border-box layers,
which is incompatible with an error border swap.
The visible 1px border stays in JSX, conditional on error state.
Focus ring (`:focus-visible`) stays in JSX too. */
.glass-input {
background: var(--glass-surface-high);
backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate));
-webkit-backdrop-filter: blur(var(--glass-blur-low)) saturate(var(--glass-saturate));
}
/* Gradient text effects Deck Hearth warm palette only. /* Gradient text effects Deck Hearth warm palette only.
The pre-Deck-Hearth aliases (.glow-blue / .glow-purple / .glow-pink, The pre-Deck-Hearth aliases (.glow-blue / .glow-purple / .glow-pink,
.gradient-text-blue / .gradient-text-purple, .gradient-bg-purple) .gradient-text-blue / .gradient-text-purple, .gradient-bg-purple)