34 KiB
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:
// 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.
// 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):
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:
// 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:
// 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
#999hardcoded color (not token)
Structure:
// 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:
// 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-700orbg-indigo-500. Always use semantic tokens (text-on-surface,bg-primary). - Inline style prop: StepIndicator currently uses
style={{ fontWeight: 'bold' }}andstyle={{ color: '#999' }}. Phase 9 removes ALL inline style props. - Changing DOM structure that tests depend on:
getByLabelTextlinks<label htmlFor>to<input id>. Any floating label pattern MUST keep both attributes intact. - Multiple
placeholdervalues: The floating label trick requiresplaceholder=" "(space). If the field also needs a real placeholder (e.g., "https://..."), the two purposes conflict. Resolution: useplaceholder=" "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
// 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
// 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
/* 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: '#...' }}orstyle={{ fontWeight: ... }}— all must become Tailwind classes
Open Questions
-
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-valueattribute approach
-
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
- What we know: Several tests use
-
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).
- What we know: Tests use
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 behaviorsrc/components/ui/BackendCard.test.tsx— covers COMP-03 elevation class presencesrc/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)