deckhearth/components/ui/Input.js
Randall Stillwell 44c94243c2 refactor(glass): migrate Button.secondary + Input + MobileNav off bespoke glass-surface
Closes the migrate-button-input-mobilenav-to-glass-primitive convoy
(seeded by PR #127). All 3 residual handrolled var(--glass-surface-*)
inline-style usages migrated to either purpose-built utility classes
or the <GlassSurface> primitive. CI allowlist reduced from 6 entries
to 3 (chrome only).

Architect decisions (D1-D3, ratified):

D1 — Button.secondary → new .btn-glass-secondary utility class.
  NOT <GlassSurface>: the primitive sets `background` inline via
  composedStyle, which CSS :hover rules can't override without
  !important. The new class composes the same high-tint
  gradient-border that .glass-panel-strong uses, plus a pure-CSS
  :hover swap (high → mid fill on the padding-box layer).
  Identical visual contract; the hover behavior is now driven by
  CSS, not Tailwind's `hover:bg-[var(...)]` arbitrary class.

D2 — Input → new .glass-input utility class.
  NOT <GlassSurface as="input"> and NOT <GlassSurface as="div"> wrap.
  Reason: <GlassSurface>'s gradient-border trick requires
  `border: 1px solid transparent` to expose the border-box layers,
  which conflicts with <Input>'s conditional error-state
  `1px solid #dc2626` red border. The new class adopts only the
  tint + blur layer; the visible 1px border + focus ring stay in
  JSX (class-controlled, not inline). Same visual contract as
  before for both normal AND error states.

D3 — MobileNavigation → <GlassSurface as="div" tint="mid" blur="mid"
  rim="subtle" elevation="flat" cornerLights="chrome">.
  NOT .page-header-glass (the seed's first recommendation):
  .page-header-glass 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 parallel briefs):
  The seed recommended 3 small parallel-safe briefs (one per file).
  D1 and D2 both need styles/globals.css to gain new utility
  classes, so those 2 changes can't run truly in parallel without
  merge conflicts. Single PR is faster, simpler to review
  end-to-end, and the natural shape for a 2-3 hour convoy with
  tightly-coupled artifacts.

Files changed (4):

styles/globals.css (+50 / -1):
  - Adds .btn-glass-secondary (with :hover variant) — D1.
  - Adds .glass-input — D2.
  - Both classes documented inline with architect-decision references.

components/ui/Button.js (+2 / -10):
  - Replaces inline variantStyle + Tailwind hover arbitrary class
    for `variant === 'secondary'` with `variantClass =
    'btn-glass-secondary font-medium'`. variantStyle now `{}`.
  - Other variants (primary, danger, ghost) UNCHANGED.

components/ui/Input.js (+1 / -7):
  - Adds `glass-input` to the className list.
  - Removes inline `background` + `backdropFilter` +
    `WebkitBackdropFilter` from the input's style block.
  - Conditional `border: inputBorder` stays in JSX (error swap).
  - All other props/behavior preserved.

components/MobileNavigation.js (+11 / -8):
  - Adds `import { GlassSurface } from './ui'`.
  - Replaces the inline-styled backdrop <div> with
    <GlassSurface as="div" ...>. Same className ("absolute inset-0"),
    same visible behavior, plus the chrome-tier corner-light bleed.
  - Comment block updated to reference the convoy + decision.

.github/workflows/ci.yml (+8 / -22):
  - forbidden-patterns Check 7/7 GLASS_ALLOWLIST reduced from 6
    entries to 3 (chrome only). The TODO comments referencing this
    convoy are deleted (work is done).

.convoys/migrate-button-input-mobilenav-to-glass-primitive.md
  (+74 / -3):
  - status: queued → closed, closed: 2026-06-05, prs: [131].
  - Architect ratifications D1-D3 written into front-matter docs.
  - Closeout checklist with all acceptance criteria checked.
  - Note that parent convoy unify-glass-panel-surfaces is now
    fully closed — no residual handrolled glass-surface usage
    outside the 3 chrome blocks.

Verification:
- POSITIVE TEST: post-migration grep with the reduced 3-entry
  allowlist returns 0 violations. 
- grep on raw files: only Layout.js + TopSearchBar.js still match
  the literal regex (GlassSurface.js uses template literal which
  doesn't match — intentional, allowlist is forward-compat).
- YAML parses (python3 yaml.safe_load).
- npm run lint passes (1 pre-existing unrelated warning).
- npm run test:run: 118/118 tests pass.

Visual diff to be verified by reviewer in light + dark mode for:
- <Button variant="secondary"> default + hover state.
- <Input> default + error state.
- Mobile bottom-nav backdrop.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-05 06:23:00 -05:00

109 lines
2.8 KiB
JavaScript

import { forwardRef, useId } from 'react';
const Input = forwardRef(function Input(
{
label,
helperText,
error,
leadingIcon,
trailingIcon,
trailingAction,
id: idProp,
type = 'text',
className = '',
style,
...rest
},
ref
) {
const reactId = useId();
const id = idProp ?? reactId;
const describedBy = [];
if (helperText && !error) describedBy.push(`${id}-helper`);
if (error) describedBy.push(`${id}-error`);
const inputBorder = error
? '1px solid #dc2626'
: '1px solid var(--border)';
return (
<div className={className}>
{label && (
<label
htmlFor={id}
className="block text-sm font-medium mb-2"
style={{ color: 'var(--text-primary)' }}
>
{label}
</label>
)}
<div className="relative">
{leadingIcon && (
<span
aria-hidden="true"
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 inline-flex"
style={{ color: 'var(--text-secondary)' }}
>
{leadingIcon}
</span>
)}
<input
ref={ref}
id={id}
type={type}
aria-invalid={error ? 'true' : undefined}
aria-describedby={describedBy.length > 0 ? describedBy.join(' ') : undefined}
className={[
'glass-input w-full rounded-xl px-4 py-3 transition-all duration-200',
'focus:outline-none focus-visible:ring-2',
leadingIcon ? 'pl-10' : '',
trailingIcon || trailingAction ? 'pr-10' : '',
]
.filter(Boolean)
.join(' ')}
style={{
border: inputBorder,
color: 'var(--text-primary)',
'--tw-ring-color': error ? '#dc2626' : 'var(--accent-ember)',
...style,
}}
{...rest}
/>
{trailingIcon && (
<span
aria-hidden="true"
className="pointer-events-none absolute right-3 top-1/2 -translate-y-1/2 inline-flex"
style={{ color: 'var(--text-secondary)' }}
>
{trailingIcon}
</span>
)}
{trailingAction && (
<span className="absolute right-2 top-1/2 -translate-y-1/2 inline-flex">
{trailingAction}
</span>
)}
</div>
{error && (
<p
id={`${id}-error`}
className="mt-1 text-xs"
style={{ color: '#dc2626' }}
>
{error}
</p>
)}
{!error && helperText && (
<p
id={`${id}-helper`}
className="mt-1 text-xs"
style={{ color: 'var(--text-secondary)' }}
>
{helperText}
</p>
)}
</div>
);
});
export default Input;