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