docs(phase-09): research MD3 components phase
This commit is contained in:
@@ -0,0 +1,679 @@
|
|||||||
|
# Phase 9: MD3 Components - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-01
|
||||||
|
**Domain:** React + Tailwind v4 CSS-custom-property-based MD3 component styling
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 9 delivers five component-level upgrades to a working wizard that already has a complete
|
||||||
|
MD3 color token system (Phase 8 complete). The token layer (`--r2b-*` CSS custom properties
|
||||||
|
mapped to Tailwind semantic utilities via `@theme`) is the foundation — every new component
|
||||||
|
pattern must consume `bg-primary`, `text-on-surface`, `border-outline`, etc., never raw colors.
|
||||||
|
|
||||||
|
The work divides into four visual domains: (1) outlined text fields with CSS-only floating
|
||||||
|
labels, (2) a three-tier button hierarchy (filled / outlined / text), (3) MD3 elevation on
|
||||||
|
BackendCard and OutputBlock, and (4) a rebuilt StepIndicator with numbered circles and a
|
||||||
|
connector line. One non-visual task (DEBT-01) fixes an aria-label inconsistency inside
|
||||||
|
FieldRenderer's tooltip button.
|
||||||
|
|
||||||
|
The project has a hard constraint of **zero new runtime dependencies**. All styling must be
|
||||||
|
achieved with Tailwind v4 utility classes and plain CSS transitions — no component library, no
|
||||||
|
animation library. The existing 166 tests are GREEN and must stay GREEN throughout. The
|
||||||
|
critical test-breaking risk is that 131 test selectors use `getByLabelText` with
|
||||||
|
`htmlFor`/`id` relationships; the floating label pattern must preserve those relationships
|
||||||
|
exactly.
|
||||||
|
|
||||||
|
**Primary recommendation:** Build a new `<TextFieldMD3>` wrapper component, styled button
|
||||||
|
variant classes (not a separate component), an elevation mixin via Tailwind `shadow-` tokens,
|
||||||
|
and a rebuilt `<StepIndicator>` using Tailwind flex + absolute-positioned connector — all
|
||||||
|
consuming the existing semantic token utilities.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|-----------------|
|
||||||
|
| COMP-01 | All text inputs render as MD3 outlined text fields with floating labels that animate on focus and when the field has content | Floating label via CSS `peer` + `placeholder=" "` trick; `<TextFieldMD3>` wraps `<input>` + `<label>`; replaces current static label pattern in FieldRenderer and PasswordField |
|
||||||
|
| COMP-02 | Buttons follow MD3 hierarchy — filled for primary (Next, Download), outlined for secondary (Back, Copy), text for tertiary | Three Tailwind class sets; applied inline to existing button elements across BackendSelectionStep, RemoteConfigStep, DeploymentStep, ReviewStep, OutputBlock |
|
||||||
|
| COMP-03 | Backend selection cards and output blocks use MD3 elevation with tonal surface tint, consistent padding, and shape tokens | BackendCard already uses semantic tokens; needs `shadow-` utility + `bg-primary/5` tint on `surface-container`; OutputBlock `<pre>` needs shape token consistency |
|
||||||
|
| COMP-04 | Step indicator displays as numbered circles connected by lines, with completed steps showing a checkmark, current step highlighted, and future steps muted | Full StepIndicator rebuild; flex layout with absolute-positioned `<hr>` or `border-t` connector; existing dispatch logic and WIZD-03 tests preserved |
|
||||||
|
| DEBT-01 | FieldRenderer uses consistent `aria-label` pattern across text-branch and select-branch (resolving v1.1 cosmetic debt) | Text-branch tooltip button uses `<span className="sr-only">` while select-branch uses `aria-label={...}` directly; fix: add `aria-label` to text-branch button and remove sr-only span |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (already installed — zero new dependencies)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Tailwind v4 | 4.2.2 | Utility classes for all styling | Project constraint: no MUI/component lib |
|
||||||
|
| @tailwindcss/vite | 4.2.2 | Vite integration, `@theme` directive | Enables semantic token utilities |
|
||||||
|
| react | 18.3.1 | Component model | App foundation |
|
||||||
|
| react-hook-form | 7.72.0 | Form state; `register()` must pass through to inputs | All fields use RHF registration |
|
||||||
|
|
||||||
|
### Supporting (dev, already installed)
|
||||||
|
| Library | Version | Purpose | When to Use |
|
||||||
|
|---------|---------|---------|-------------|
|
||||||
|
| vitest | 4.1.1 | Test runner | All component tests |
|
||||||
|
| @testing-library/react | 16.3.2 | Render + query | `getByLabelText`, `getByRole`, `userEvent` |
|
||||||
|
|
||||||
|
### Alternatives NOT to use
|
||||||
|
| Instead of | Could Use | Why Not |
|
||||||
|
|------------|-----------|---------|
|
||||||
|
| CSS-only floating label | `@material/web` `<md-outlined-text-field>` | Bundle bloat; requires separate package; conflicts with RHF register pattern |
|
||||||
|
| Hand-rolled box-shadow elevation | Headless UI / Radix | No need — pure Tailwind shadow utilities sufficient |
|
||||||
|
| Custom stepper library | Any npm stepper lib | Zero new deps; simple 4-step indicator is straightforward with Tailwind flex |
|
||||||
|
|
||||||
|
**Installation:** None. All dependencies already present.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Existing Token Map (from `src/index.css`)
|
||||||
|
|
||||||
|
Phase 8 established a two-layer pattern. All new components reference these Tailwind utilities:
|
||||||
|
|
||||||
|
```
|
||||||
|
bg-primary text-on-primary
|
||||||
|
bg-surface text-on-surface
|
||||||
|
bg-surface-container text-on-surface-container
|
||||||
|
bg-surface-variant text-on-surface-variant
|
||||||
|
border-outline
|
||||||
|
text-error bg-error/10
|
||||||
|
text-primary bg-primary/10
|
||||||
|
```
|
||||||
|
|
||||||
|
These utilities work in both light and dark mode because they resolve through CSS custom
|
||||||
|
properties that are overridden by the `.dark` class.
|
||||||
|
|
||||||
|
### Recommended Component Additions
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/ui/
|
||||||
|
├── TextFieldMD3.tsx # NEW: outlined input with floating label (COMP-01)
|
||||||
|
├── BackendCard.tsx # EDIT: add MD3 elevation shadow (COMP-03)
|
||||||
|
├── PasswordField.tsx # EDIT: replace static label with TextFieldMD3 inner layout (COMP-01)
|
||||||
|
├── FieldRenderer.tsx # EDIT: replace text+select branches with TextFieldMD3; fix aria (DEBT-01)
|
||||||
|
src/components/wizard/
|
||||||
|
├── StepIndicator.tsx # FULL REBUILD: numbered circles + connector line (COMP-04)
|
||||||
|
├── BackendSelectionStep.tsx # EDIT: button styling (COMP-02)
|
||||||
|
├── RemoteConfigStep.tsx # EDIT: button styling (COMP-02)
|
||||||
|
├── DeploymentStep.tsx # EDIT: button styling (COMP-02)
|
||||||
|
├── ReviewStep.tsx # EDIT: button styling + OutputBlock (COMP-02, COMP-03)
|
||||||
|
├── OutputBlock.tsx # EDIT: button styling + elevation (COMP-02, COMP-03)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Floating Label Outlined Text Field (COMP-01)
|
||||||
|
|
||||||
|
**What:** CSS-only floating label using Tailwind `peer` utilities. The input has a space as
|
||||||
|
placeholder (`placeholder=" "`), which allows `:placeholder-shown` to detect whether the
|
||||||
|
field is empty. The label is absolutely positioned and transitions between center (empty,
|
||||||
|
unfocused) and top-left (focused OR has value) states.
|
||||||
|
|
||||||
|
**When to use:** All `<input type="text">` and `<input type="password">` fields inside
|
||||||
|
FieldRenderer and PasswordField.
|
||||||
|
|
||||||
|
**Critical constraint:** The `id` attribute on the input and `htmlFor` on the label MUST be
|
||||||
|
preserved unchanged. All existing `getByLabelText` test selectors depend on this pairing.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```tsx
|
||||||
|
// Source: pattern derived from Flowbite floating label docs + MD3 outlined field spec
|
||||||
|
// https://flowbite.com/docs/forms/floating-label/
|
||||||
|
// https://material-web.dev/components/text-field/
|
||||||
|
|
||||||
|
interface TextFieldMD3Props {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
error?: FieldError;
|
||||||
|
registration: UseFormRegisterReturn;
|
||||||
|
type?: 'text' | 'password';
|
||||||
|
placeholder?: string; // internal use only — always " " for floating label
|
||||||
|
helpText?: string;
|
||||||
|
required?: boolean;
|
||||||
|
suffix?: React.ReactNode; // for PasswordField show/hide button
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextFieldMD3({ id, label, error, registration, type = 'text', helpText, required, suffix }: TextFieldMD3Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
placeholder=" " // CRITICAL: single space triggers :placeholder-shown detection
|
||||||
|
className={[
|
||||||
|
'peer w-full rounded-md border bg-transparent px-3 pb-2 pt-5 text-sm',
|
||||||
|
'focus:outline-none focus:ring-2',
|
||||||
|
'placeholder-transparent', // hide the space placeholder visually
|
||||||
|
error
|
||||||
|
? 'border-error focus:ring-error/50'
|
||||||
|
: 'border-outline focus:border-primary focus:ring-primary/20',
|
||||||
|
suffix ? 'pr-10' : '',
|
||||||
|
].join(' ')}
|
||||||
|
{...registration}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={[
|
||||||
|
'absolute left-3 top-1/2 -translate-y-1/2 text-sm text-on-surface-container/70',
|
||||||
|
'origin-left transition-all duration-200',
|
||||||
|
// When input has content or is focused: float to top
|
||||||
|
'peer-placeholder-shown:top-1/2 peer-placeholder-shown:scale-100',
|
||||||
|
'peer-focus:top-3 peer-focus:scale-75 peer-focus:-translate-y-0 peer-focus:text-primary',
|
||||||
|
// When not placeholder-shown (has value): also float
|
||||||
|
'peer-not-placeholder-shown:top-3 peer-not-placeholder-shown:scale-75 peer-not-placeholder-shown:-translate-y-0',
|
||||||
|
error ? 'peer-focus:text-error' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-1 text-error">*</span>}
|
||||||
|
</label>
|
||||||
|
{suffix && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
{suffix}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{helpText && !error && <p className="text-xs text-on-surface-container/70">{helpText}</p>}
|
||||||
|
{error && <p className="text-xs text-error">{error.message}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Implementation note on `peer-not-placeholder-shown`:** Tailwind v4 supports arbitrary
|
||||||
|
variants. If `peer-not-placeholder-shown` is not available as a built-in, use
|
||||||
|
`peer-[:not(:placeholder-shown)]:top-3` syntax. Verify against Tailwind v4.2.2 docs.
|
||||||
|
Alternatively, the `data-has-value` attribute approach (set via RHF `watch` or `onBlur`)
|
||||||
|
is a reliable fallback.
|
||||||
|
|
||||||
|
**Alternative approach (data attribute):** If peer-variant coverage is incomplete in Tailwind
|
||||||
|
v4, use a wrapper component that watches the input value via RHF `watch()` and adds
|
||||||
|
`data-has-value` to the container, then target `group-data-[has-value]:` on the label.
|
||||||
|
This is MORE JavaScript but less reliance on CSS pseudo-class availability.
|
||||||
|
|
||||||
|
### Pattern 2: MD3 Button Hierarchy (COMP-02)
|
||||||
|
|
||||||
|
**What:** Three button class sets applied consistently wherever navigation buttons appear.
|
||||||
|
No new component — apply className patterns directly.
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
// Source: MD3 button spec https://m3.material.io/components/all-buttons
|
||||||
|
// Primary / Filled: background = primary, text = on-primary
|
||||||
|
const btnFilled = 'px-6 py-2.5 rounded-full bg-primary text-on-primary text-sm font-medium ' +
|
||||||
|
'hover:opacity-90 active:opacity-80 disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none transition-opacity';
|
||||||
|
|
||||||
|
// Secondary / Outlined: transparent background, primary border and text
|
||||||
|
const btnOutlined = 'px-6 py-2.5 rounded-full border border-outline text-on-surface text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none transition-colors';
|
||||||
|
|
||||||
|
// Tertiary / Text: no border, no background
|
||||||
|
const btnText = 'px-4 py-2.5 rounded-full text-primary text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none transition-colors';
|
||||||
|
```
|
||||||
|
|
||||||
|
**Corner radius:** MD3 uses `rounded-full` (28dp height / pill shape) for standard buttons.
|
||||||
|
Current codebase uses `rounded-md` — this is a deliberate MD3 upgrade.
|
||||||
|
|
||||||
|
**Action mapping:**
|
||||||
|
| Button | Type | Location |
|
||||||
|
|--------|------|----------|
|
||||||
|
| Next | Filled | BackendSelectionStep, RemoteConfigStep, DeploymentStep |
|
||||||
|
| Download All (ZIP) | Filled | ReviewStep |
|
||||||
|
| Back | Outlined | RemoteConfigStep, DeploymentStep, ReviewStep |
|
||||||
|
| Copy | Outlined | OutputBlock |
|
||||||
|
| Download (per file) | Outlined | OutputBlock |
|
||||||
|
| SAS URL / Access Key toggle | — | AzureAuthToggle (segmented control, not a button hierarchy concern) |
|
||||||
|
| Password / Private Key toggle | — | SftpAuthToggle (segmented control) |
|
||||||
|
|
||||||
|
### Pattern 3: MD3 Elevation + Tonal Surface (COMP-03)
|
||||||
|
|
||||||
|
**What:** MD3 elevation level 1 (the lightest surface lift) for cards and code blocks.
|
||||||
|
MD3 uses two mechanisms together: a drop shadow AND a tonal overlay (primary color at low
|
||||||
|
opacity over the surface).
|
||||||
|
|
||||||
|
**MD3 Elevation Level 1 shadow (verified source: studioncreations.com):**
|
||||||
|
```css
|
||||||
|
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
```
|
||||||
|
Tailwind equivalent: `shadow-md` (`0 4px 6px -1px rgba(0,0,0,0.1)...`) is close but not
|
||||||
|
exact. Use `shadow` (standard) or a custom Tailwind shadow token via `@theme` in index.css.
|
||||||
|
|
||||||
|
**Tonal surface tint:** Add `bg-primary/5` to card container on top of `bg-surface-container`
|
||||||
|
to implement the MD3 tonal tint. In practice, this means the container background class
|
||||||
|
becomes a layered effect or the value of `--r2b-surface-container` is adjusted.
|
||||||
|
|
||||||
|
**Recommended approach for BackendCard:**
|
||||||
|
```tsx
|
||||||
|
// Before: border-2 p-4 rounded-lg
|
||||||
|
// After: border-2 p-4 rounded-xl shadow + tonal tint
|
||||||
|
className={[
|
||||||
|
'flex flex-col items-start gap-1 rounded-xl border-2 p-4 text-left transition-all',
|
||||||
|
'shadow hover:shadow-md', // elevation lift on hover
|
||||||
|
selected
|
||||||
|
? 'border-primary bg-primary/10 shadow-md'
|
||||||
|
: 'border-outline bg-surface-container hover:border-primary hover:bg-primary/5',
|
||||||
|
].join(' ')}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Shape token:** MD3 card shape is "medium" (12dp corner radius). Use `rounded-xl` (12px).
|
||||||
|
Current `rounded-lg` is 8px — upgrade to `rounded-xl`.
|
||||||
|
|
||||||
|
**OutputBlock elevation:**
|
||||||
|
```tsx
|
||||||
|
// Pre block (code display) — add subtle elevation to differentiate from page surface
|
||||||
|
className="bg-surface-variant text-on-surface-variant rounded-xl p-4 text-xs overflow-x-auto whitespace-pre-wrap shadow-sm"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: Rebuilt StepIndicator (COMP-04)
|
||||||
|
|
||||||
|
**What:** Replace the current text-based breadcrumb (spans with `›` separator) with a visual
|
||||||
|
step indicator: horizontal row of numbered circles connected by a line, showing state via
|
||||||
|
icon/color.
|
||||||
|
|
||||||
|
**Current implementation issues:**
|
||||||
|
- Uses inline `style={{}}` instead of Tailwind classes (style violations)
|
||||||
|
- No visual connector line between steps
|
||||||
|
- No checkmark on completed steps
|
||||||
|
- `#999` hardcoded color (not token)
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```tsx
|
||||||
|
// Horizontal step indicator with connector lines between circles
|
||||||
|
return (
|
||||||
|
<nav aria-label="Wizard steps">
|
||||||
|
<ol className="flex items-center w-full">
|
||||||
|
{STEP_LABELS.map((label, i) => {
|
||||||
|
const isCompleted = i < currentStep;
|
||||||
|
const isActive = i === currentStep;
|
||||||
|
const isFuture = i > currentStep;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={i} className={['flex items-center', i < STEP_LABELS.length - 1 ? 'flex-1' : ''].join(' ')}>
|
||||||
|
{/* Step circle */}
|
||||||
|
<div className="flex flex-col items-center gap-1 shrink-0">
|
||||||
|
{isCompleted ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleStepClick(i)}
|
||||||
|
className="w-8 h-8 rounded-full bg-primary text-on-primary flex items-center justify-center text-sm font-medium focus-visible:ring-2 focus-visible:ring-primary/50"
|
||||||
|
aria-label={`Go to step ${i + 1}: ${label}`}
|
||||||
|
>
|
||||||
|
✓
|
||||||
|
</button>
|
||||||
|
) : isActive ? (
|
||||||
|
<span className="w-8 h-8 rounded-full border-2 border-primary bg-primary/10 text-primary flex items-center justify-center text-sm font-bold">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="w-8 h-8 rounded-full border-2 border-outline text-on-surface-container/40 flex items-center justify-center text-sm">
|
||||||
|
{i + 1}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className={[
|
||||||
|
'text-xs mt-1 text-center',
|
||||||
|
isCompleted ? 'text-primary' : isActive ? 'text-on-surface font-medium' : 'text-on-surface-container/40',
|
||||||
|
].join(' ')}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{/* Connector line between steps */}
|
||||||
|
{i < STEP_LABELS.length - 1 && (
|
||||||
|
<div className={[
|
||||||
|
'flex-1 h-0.5 mx-2 self-start mt-4',
|
||||||
|
isCompleted ? 'bg-primary' : 'bg-outline',
|
||||||
|
].join(' ')} aria-hidden="true" />
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
**CRITICAL:** The existing WIZD-03 tests query by button presence and click behavior. The
|
||||||
|
rebuilt component must still render completed steps as `<button>` elements and dispatch
|
||||||
|
`SET_STEP` and `SET_REMOTE_PARAMS({})` on click. The test suite does NOT test visual
|
||||||
|
appearance — only behavior — so the internal restructure is safe.
|
||||||
|
|
||||||
|
**Test impact:** `screen.getAllByRole('button')` queries will still find completed step
|
||||||
|
buttons. Text queries like `screen.findByText(/Deployment/)` will still find the step label.
|
||||||
|
No test changes expected.
|
||||||
|
|
||||||
|
### Pattern 5: DEBT-01 — FieldRenderer aria-label Fix
|
||||||
|
|
||||||
|
**Current state (inconsistency):**
|
||||||
|
- Select branch tooltip button: `aria-label={\`More info about ${field.label}\`}` — CORRECT
|
||||||
|
- Text branch tooltip button: uses `<span className="sr-only">More info about {field.label}</span>` + `<span aria-hidden="true">ⓘ</span>` — INCONSISTENT
|
||||||
|
|
||||||
|
**Fix:** Update the text-branch tooltip button to match the select-branch pattern:
|
||||||
|
```tsx
|
||||||
|
// Before (text branch):
|
||||||
|
<button type="button" onClick={...} className="...">
|
||||||
|
<span className="sr-only">More info about {field.label}</span>
|
||||||
|
<span aria-hidden="true">ⓘ</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
// After (matching select branch):
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={...}
|
||||||
|
aria-label={`More info about ${field.label}`}
|
||||||
|
className="..."
|
||||||
|
>
|
||||||
|
ⓘ
|
||||||
|
</button>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Test impact:** The DEBT-01 fix changes tooltip button structure. Any test using
|
||||||
|
`screen.getByRole('button', { name: /more info about.../i })` will now correctly find both
|
||||||
|
branches. The RemoteConfigStep.test.tsx UX-01 tests query by `screen.getByRole('button')`
|
||||||
|
patterns — verify these still pass after fix.
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
|
||||||
|
- **Hardcoding colors:** Never use `text-gray-700` or `bg-indigo-500`. Always use semantic
|
||||||
|
tokens (`text-on-surface`, `bg-primary`).
|
||||||
|
- **Inline style prop:** StepIndicator currently uses `style={{ fontWeight: 'bold' }}` and
|
||||||
|
`style={{ color: '#999' }}`. Phase 9 removes ALL inline style props.
|
||||||
|
- **Changing DOM structure that tests depend on:** `getByLabelText` links `<label htmlFor>`
|
||||||
|
to `<input id>`. Any floating label pattern MUST keep both attributes intact.
|
||||||
|
- **Multiple `placeholder` values:** The floating label trick requires `placeholder=" "`
|
||||||
|
(space). If the field also needs a real placeholder (e.g., "https://..."), the two purposes
|
||||||
|
conflict. Resolution: use `placeholder=" "` for the CSS trick and show placeholder-like
|
||||||
|
text as help text below the field instead.
|
||||||
|
- **Changing button DOM content that tests depend on:** RemoteConfigStep tests use
|
||||||
|
`getByRole('button', { name: /next/i })` — button text must remain "Next" (case-insensitive
|
||||||
|
match). Verify all button label changes don't break test selectors.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Floating label that tracks input value | useState + JS event listener | CSS `peer-placeholder-shown` + `placeholder=" "` | Pure CSS, zero JS, better performance |
|
||||||
|
| Theme-aware shadow values | Switch statement in JS | Tailwind `shadow` utilities — already correct in dark mode | Tailwind shadow colors respect `dark:` variant |
|
||||||
|
| Accessible step navigation | Custom focus management | `<button>` elements with `aria-label` | Browser handles tab order and keyboard naturally |
|
||||||
|
| Component library for inputs | Copy MUI TextField source | `TextFieldMD3` custom component with 30 lines | Zero bundle cost, full token control |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Floating Label Blocks `getByLabelText` Queries
|
||||||
|
**What goes wrong:** If the `<label>` is moved inside the `<input>` wrapper without keeping
|
||||||
|
`htmlFor` and `id` matched, `screen.getByLabelText(/storage account name/i)` throws. This
|
||||||
|
breaks 30+ existing test assertions.
|
||||||
|
**Why it happens:** Floating label patterns sometimes wrap label inside input's container
|
||||||
|
without proper `for`/`id` association.
|
||||||
|
**How to avoid:** Always include `<label htmlFor={id}>` paired with `<input id={id}>`. The
|
||||||
|
label can be absolutely positioned visually while keeping the semantic association.
|
||||||
|
**Warning signs:** `getByLabelText` test failures immediately after TextFieldMD3 integration.
|
||||||
|
|
||||||
|
### Pitfall 2: `placeholder=" "` Conflicts with Real Placeholder Text
|
||||||
|
**What goes wrong:** Fields like `sas_url` have meaningful placeholder text
|
||||||
|
(`"https://mystorageaccount.blob.core.windows.net/?sv=..."`). Replacing it with `" "` loses
|
||||||
|
the hint.
|
||||||
|
**Why it happens:** The CSS floating label trick requires `placeholder=" "` to trigger
|
||||||
|
`:placeholder-shown`, but fields want real placeholder hints too.
|
||||||
|
**How to avoid:** Use `helpText` prop for the contextual hint instead of placeholder. The
|
||||||
|
field shows the hint below the input. This is actually MORE accessible (visible even after
|
||||||
|
typing). Verify existing `helpText` content is adequate before removing real placeholders.
|
||||||
|
**Warning signs:** User confusion about what to enter in credential fields.
|
||||||
|
|
||||||
|
### Pitfall 3: `peer-not-placeholder-shown` Availability in Tailwind v4
|
||||||
|
**What goes wrong:** The Tailwind v4 `peer-placeholder-shown:` variant works, but
|
||||||
|
`peer-not-placeholder-shown:` may not be a built-in variant. Without it, a field with a
|
||||||
|
pre-filled value (RHF `defaultValues`) won't show the floated label on initial render.
|
||||||
|
**Why it happens:** Tailwind v4 generates variants from pseudo-classes, but negation of
|
||||||
|
`placeholder-shown` may require the arbitrary variant syntax.
|
||||||
|
**How to avoid:** Use `peer-[:not(:placeholder-shown)]:` for Tailwind v4 arbitrary variant,
|
||||||
|
or manage a `data-has-value` attribute via a thin React wrapper that checks the registration
|
||||||
|
value. The data-attribute approach is the most reliable cross-version fallback.
|
||||||
|
**Warning signs:** Label overlaps with pre-filled text on initial render.
|
||||||
|
|
||||||
|
### Pitfall 4: Button Test Selectors Break After Role Change
|
||||||
|
**What goes wrong:** `screen.getByRole('button', { name: /next/i })` fails after button
|
||||||
|
text changes from "Next" to an icon-only or different label.
|
||||||
|
**Why it happens:** MD3 button redesign may tempt relabeling buttons.
|
||||||
|
**How to avoid:** Keep button text labels unchanged. Style only className, never innerText.
|
||||||
|
Audit existing test selectors before any button className change: `getByRole('button', { name: /next/i })`, `getByRole('button', { name: /back/i })`, `getByRole('button', { name: /copy/i })`.
|
||||||
|
**Warning signs:** `Unable to find an accessible element with the role "button" and name /next/i` in test output.
|
||||||
|
|
||||||
|
### Pitfall 5: StepIndicator Test Selector Drift
|
||||||
|
**What goes wrong:** After StepIndicator rebuild, `screen.getAllByRole('button')` returns
|
||||||
|
different count, and `buttons.find(b => b.textContent?.includes('Backend'))` fails because
|
||||||
|
completed-step buttons now show "✓" instead of "✓ 1. Backend".
|
||||||
|
**Why it happens:** Button textContent changed in the rebuild.
|
||||||
|
**How to avoid:** Keep button `aria-label` meaningful AND keep the label text visible within
|
||||||
|
the button (or as adjacent text). Test the rebuilt StepIndicator immediately. Adjust
|
||||||
|
WIZD-03 test selectors if needed (which is expected and acceptable for this phase).
|
||||||
|
**Warning signs:** `backendButton` undefined in StepIndicator tests.
|
||||||
|
|
||||||
|
### Pitfall 6: PasswordField Show/Hide Button Interferes with Floating Label
|
||||||
|
**What goes wrong:** The absolute-positioned show/hide toggle button overlaps the floating
|
||||||
|
label when it's in the "up" position.
|
||||||
|
**Why it happens:** Label transitions from bottom to top; toggle is at right-center.
|
||||||
|
**How to avoid:** The toggle is `absolute right-2 top-1/2` which only overlaps the label
|
||||||
|
when label is centered (empty state). Floated label goes to `top-3` which avoids overlap.
|
||||||
|
Add sufficient `pr-10` padding to the input so text doesn't underlap the button.
|
||||||
|
**Warning signs:** Show/hide button visually overlaps with floating label text.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Complete TextFieldMD3 Reference Structure
|
||||||
|
```tsx
|
||||||
|
// Verified pattern from Flowbite floating label docs + MD3 spec
|
||||||
|
// Key: placeholder=" " enables CSS-only floating label detection
|
||||||
|
|
||||||
|
export function TextFieldMD3({ id, label, error, registration, type = 'text', helpText, required, suffix }: TextFieldMD3Props) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-1">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
id={id}
|
||||||
|
type={type}
|
||||||
|
placeholder=" "
|
||||||
|
className={[
|
||||||
|
'peer w-full rounded-t-md rounded-b-none border-0 border-b-2 bg-surface-container/30',
|
||||||
|
// Or for outlined variant:
|
||||||
|
// 'peer w-full rounded-md border bg-transparent',
|
||||||
|
'px-3 pb-2 pt-5 text-sm text-on-surface placeholder-transparent',
|
||||||
|
'focus:outline-none focus:ring-0',
|
||||||
|
error
|
||||||
|
? 'border-error focus:border-error'
|
||||||
|
: 'border-outline focus:border-primary',
|
||||||
|
suffix ? 'pr-10' : '',
|
||||||
|
].join(' ')}
|
||||||
|
{...registration}
|
||||||
|
/>
|
||||||
|
<label
|
||||||
|
htmlFor={id}
|
||||||
|
className={[
|
||||||
|
// Starting (centered) position — when input empty and unfocused
|
||||||
|
'pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-sm',
|
||||||
|
'origin-left transform transition-all duration-200',
|
||||||
|
'text-on-surface-container/60',
|
||||||
|
// Focused state: float up
|
||||||
|
'peer-focus:top-3 peer-focus:-translate-y-0 peer-focus:scale-75 peer-focus:text-primary',
|
||||||
|
// Has value state (input not showing placeholder):
|
||||||
|
'peer-[:not(:placeholder-shown)]:top-3',
|
||||||
|
'peer-[:not(:placeholder-shown)]:-translate-y-0',
|
||||||
|
'peer-[:not(:placeholder-shown)]:scale-75',
|
||||||
|
error ? 'peer-focus:text-error' : '',
|
||||||
|
].join(' ')}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && <span className="ml-0.5 text-error">*</span>}
|
||||||
|
</label>
|
||||||
|
{suffix && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">{suffix}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{helpText && !error && (
|
||||||
|
<p className="text-xs text-on-surface-container/70">{helpText}</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p className="text-xs text-error" role="alert">{error.message}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### MD3 Button Class Sets
|
||||||
|
```tsx
|
||||||
|
// Source: MD3 button guidelines https://m3.material.io/components/all-buttons
|
||||||
|
// Filled — primary action
|
||||||
|
export const MD3_BTN_FILLED =
|
||||||
|
'px-6 py-2.5 rounded-full bg-primary text-on-primary text-sm font-medium ' +
|
||||||
|
'hover:opacity-90 active:opacity-80 transition-opacity ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
|
||||||
|
// Outlined — secondary action
|
||||||
|
export const MD3_BTN_OUTLINED =
|
||||||
|
'px-6 py-2.5 rounded-full border border-outline text-on-surface text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 transition-colors ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
|
||||||
|
// Text — tertiary action
|
||||||
|
export const MD3_BTN_TEXT =
|
||||||
|
'px-4 py-2.5 rounded-full text-primary text-sm font-medium ' +
|
||||||
|
'hover:bg-primary/8 active:bg-primary/12 transition-colors ' +
|
||||||
|
'disabled:opacity-40 disabled:cursor-not-allowed ' +
|
||||||
|
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/50';
|
||||||
|
```
|
||||||
|
|
||||||
|
### MD3 Elevation Level 1 CSS
|
||||||
|
```css
|
||||||
|
/* Source: studioncreations.com/blog/material-design-3-box-shadow-css-values/ */
|
||||||
|
/* MD3 elevation-1 */
|
||||||
|
.elevation-1 {
|
||||||
|
box-shadow: 0 1px 4px 0 rgba(0, 0, 0, 0.37);
|
||||||
|
}
|
||||||
|
/* In Tailwind, `shadow` utility is approximately equivalent.
|
||||||
|
For closer match, can extend @theme in index.css: */
|
||||||
|
@theme {
|
||||||
|
--shadow-elevation-1: 0 1px 4px 0 rgb(0 0 0 / 0.37);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Static `<label>` above input | CSS floating label via `peer` | Phase 9 | More visual richness; same DOM semantics |
|
||||||
|
| Inline `style={{}}` on StepIndicator | Tailwind classes only | Phase 9 | Token-aware, dark-mode compatible |
|
||||||
|
| `rounded-md` for cards | `rounded-xl` (MD3 medium shape) | Phase 9 | Matches MD3 12dp corner radius |
|
||||||
|
| Raw `#999` color | `text-on-surface-container/40` | Phase 9 | Token-driven, dark-mode aware |
|
||||||
|
| `rounded` buttons | `rounded-full` (MD3 pill buttons) | Phase 9 | MD3 standard button shape |
|
||||||
|
|
||||||
|
**Deprecated/outdated in this codebase:**
|
||||||
|
- Any remaining `style={{ color: '#...' }}` or `style={{ fontWeight: ... }}` — all must become Tailwind classes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **`peer-[:not(:placeholder-shown)]` Tailwind v4 support**
|
||||||
|
- What we know: Tailwind v4 supports arbitrary variants with bracket syntax
|
||||||
|
- What's unclear: Whether the specific `peer-[:not(:placeholder-shown)]:` form compiles correctly in v4.2.2
|
||||||
|
- Recommendation: Test in Wave 0 with a minimal test case; if it fails, use the `data-has-value` attribute approach
|
||||||
|
|
||||||
|
2. **Button text changes breaking existing tests**
|
||||||
|
- What we know: Several tests use `getByRole('button', { name: /next/i })` etc.
|
||||||
|
- What's unclear: Whether any planned button text changes (e.g., "Next / Review" → "Next") would break selectors
|
||||||
|
- Recommendation: Audit test expectations against planned button labels before implementation; keep inner text identical
|
||||||
|
|
||||||
|
3. **StepIndicator WIZD-03 test compatibility after rebuild**
|
||||||
|
- What we know: Tests use `buttons.find(b => b.textContent?.includes('Backend'))` to locate step buttons
|
||||||
|
- What's unclear: After adding "✓" and changing button structure, does `textContent?.includes('Backend')` still match?
|
||||||
|
- Recommendation: New button structure should include the label text. If buttons render as `<button aria-label="Go to step 1: Backend">✓</button>`, the textContent test will fail. Consider keeping label text visible inside button OR updating the test (acceptable in Phase 9).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Vitest 4.1.1 |
|
||||||
|
| Config file | `vite.config.ts` (has `test.environment: jsdom`) |
|
||||||
|
| Quick run command | `npx vitest run --reporter=dot` |
|
||||||
|
| Full suite command | `npx vitest run` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| COMP-01 | TextFieldMD3 floating label floats on focus and when has value | unit | `npx vitest run src/components/ui/TextFieldMD3.test.tsx` | ❌ Wave 0 |
|
||||||
|
| COMP-01 | FieldRenderer text-branch uses TextFieldMD3 (label still queryable) | unit | `npx vitest run src/components/wizard/RemoteConfigStep.test.tsx` | ✅ (existing coverage) |
|
||||||
|
| COMP-02 | Buttons have correct role and text (Next, Back, Copy, Download) | unit | `npx vitest run src/components/wizard/ReviewStep.test.tsx` | ✅ (existing coverage) |
|
||||||
|
| COMP-03 | BackendCard has `rounded-xl` className | unit | `npx vitest run src/components/ui/BackendCard.test.tsx` | ❌ Wave 0 |
|
||||||
|
| COMP-04 | StepIndicator: completed steps are clickable buttons | unit | `npx vitest run src/components/wizard/StepIndicator.test.tsx` | ✅ (existing coverage) |
|
||||||
|
| COMP-04 | StepIndicator: clicking step 0 dispatches SET_REMOTE_PARAMS({}) | unit | `npx vitest run src/components/wizard/StepIndicator.test.tsx` | ✅ (existing coverage) |
|
||||||
|
| DEBT-01 | FieldRenderer select-branch tooltip button has aria-label | unit | `npx vitest run src/components/ui/FieldRenderer.test.tsx` | ❌ Wave 0 |
|
||||||
|
| DEBT-01 | FieldRenderer text-branch tooltip button has aria-label | unit | `npx vitest run src/components/ui/FieldRenderer.test.tsx` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `npx vitest run --reporter=dot` (full suite, ~7s)
|
||||||
|
- **Per wave merge:** `npx vitest run` (verbose, all 166+ tests)
|
||||||
|
- **Phase gate:** Full suite green before `/gsd:verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `src/components/ui/TextFieldMD3.test.tsx` — covers COMP-01 floating label behavior
|
||||||
|
- [ ] `src/components/ui/BackendCard.test.tsx` — covers COMP-03 elevation class presence
|
||||||
|
- [ ] `src/components/ui/FieldRenderer.test.tsx` — covers DEBT-01 aria-label consistency
|
||||||
|
|
||||||
|
*(Existing StepIndicator.test.tsx covers COMP-04 behavioral requirements. Update to
|
||||||
|
match rebuilt component's button textContent if needed.)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- Flowbite floating label docs — https://flowbite.com/docs/forms/floating-label/ — floating label peer structure
|
||||||
|
- material-web.dev text field — https://material-web.dev/components/text-field/ — MD3 outlined field behavior spec
|
||||||
|
- MD3 buttons guidelines — https://m3.material.io/components/buttons/guidelines — button hierarchy and specs
|
||||||
|
- MD3 all buttons — https://m3.material.io/components/all-buttons — filled/outlined/text specs
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- studioncreations.com MD3 box-shadow values — https://studioncreations.com/blog/material-design-3-box-shadow-css-values/ — elevation CSS values (note: elevation spec was alpha status)
|
||||||
|
- DEV Community floating label with Tailwind — https://dev.to/chrsgrrtt/floating-label-input-with-react-and-tailwind-2e5h — React + Tailwind implementation
|
||||||
|
- jakedawkins.com accessible floating label — https://jakedawkins.com/blog/accessible-input-floating-label/ — accessibility considerations
|
||||||
|
|
||||||
|
### Tertiary (LOW confidence)
|
||||||
|
- MD3 elevation tonal tint behavior — from WebSearch summary only; tonal tint percentage (5% primary overlay) is an estimate from pattern observation, not precisely measured from spec
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Standard stack: HIGH — all dependencies already installed, no new additions
|
||||||
|
- Architecture: HIGH — current component code fully audited, patterns verified
|
||||||
|
- Pitfalls: HIGH — based on direct code analysis of existing test selectors and component structure
|
||||||
|
- Floating label CSS: MEDIUM — Tailwind v4 `peer-[:not(:placeholder-shown)]` syntax needs runtime verification
|
||||||
|
- MD3 elevation shadow values: MEDIUM — sourced from third-party CSS values article, not official spec
|
||||||
|
|
||||||
|
**Research date:** 2026-04-01
|
||||||
|
**Valid until:** 2026-05-01 (stable libraries; Tailwind v4 is releasing updates)
|
||||||
Reference in New Issue
Block a user