diff --git a/.specify/feature.json b/.specify/feature.json index 2773329..baec9c2 100644 --- a/.specify/feature.json +++ b/.specify/feature.json @@ -1,3 +1,3 @@ { - "feature_directory": "specs/001-auth-setup" + "feature_directory": "specs/002-shared-ui-primitives" } diff --git a/CLAUDE.md b/CLAUDE.md index c996586..59b8300 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,5 +1,5 @@ For additional context about technologies to be used, project structure, shell commands, and other important information, read the current plan at -`specs/001-auth-setup/plan.md`. +`specs/002-shared-ui-primitives/plan.md`. diff --git a/specs/002-shared-ui-primitives/checklists/requirements.md b/specs/002-shared-ui-primitives/checklists/requirements.md new file mode 100644 index 0000000..0be4a48 --- /dev/null +++ b/specs/002-shared-ui-primitives/checklists/requirements.md @@ -0,0 +1,42 @@ +# Specification Quality Checklist: Shared UI Primitives + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-04-30 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) + _Note: Angular signals/OnPush/ControlValueAccessor references are unavoidable — they ARE + the deliverable for a component library targeting Angular projects._ +- [x] Focused on user value and business needs + _Framed around developer workflows: "developer can use without additional config"._ +- [x] Written for non-technical stakeholders + _Caveated: developer is the primary stakeholder; component API references are appropriate._ +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) + _Note: SC-004 references `ng build` — justified exception for a component library spec._ +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified (dual disabled+loading, empty errorMessage, self-contained spinner ARIA) +- [x] Scope is clearly bounded (3 primitives, single-line only, no icons, dark-theme only) +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows (Button P1, TextInput P2, Spinner P3) +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + _Caveated: see Content Quality note above._ + +## Notes + +- All items pass. No spec updates required before `/speckit-clarify` or `/speckit-plan`. +- The intentional use of Angular/component terminology is consistent with the feature's nature as + a developer-facing component library. Justified deviation from the "technology-agnostic" guideline. diff --git a/specs/002-shared-ui-primitives/contracts/button.md b/specs/002-shared-ui-primitives/contracts/button.md new file mode 100644 index 0000000..cc8a49f --- /dev/null +++ b/specs/002-shared-ui-primitives/contracts/button.md @@ -0,0 +1,110 @@ +# Contract: ButtonComponent + +**Selector**: `app-button` +**File**: `src/app/shared/ui/button/button.component.ts` +**Barrel**: `src/app/shared/ui/index.ts` + +--- + +## Usage + +```html + +Sign In + + +Sign In + + +Cancel + + +Sign In + + +Sign In +``` + +## Input API + +| Input | Type | Default | Description | +|-------|------|---------|-------------| +| `variant` | `'primary' \| 'secondary'` | `'primary'` | Visual style. Primary uses `$color-accent` background; secondary uses transparent background with `$color-border` border. | +| `type` | `'button' \| 'submit'` | `'button'` | HTML `type` attribute. Use `'submit'` to trigger `(ngSubmit)` on the parent form when the button is clicked or Enter is pressed. | +| `loading` | `boolean` | `false` | When `true`: renders an internal Spinner at `size="sm"`, sets native `disabled` on the ` + │ } + └── @if (hasError()) { + {{ errorMessage() }} + } +``` + +### Providers + +```typescript +providers: [ + { provide: NG_VALUE_ACCESSOR, useExisting: TextInputComponent, multi: true }, +] +``` + +### Dependencies + +- Imports: none (no child components) +- `NG_VALUE_ACCESSOR` from `@angular/forms` + +--- + +## Entity 3: SpinnerComponent + +**File**: `src/app/shared/ui/spinner/spinner.component.ts` +**Selector**: `app-spinner` + +### Inputs (Angular `input()` signal API) + +| Input | Type | Default | Required | Description | +|-------|------|---------|----------|-------------| +| `label` | `string` | `'Loading…'` | No | Accessible label for `aria-label` on the `role="status"` element | +| `size` | `'sm' \| 'md'` | `'md'` | No | Visual size: `sm` = 16 × 16 px (used by Button); `md` = 24 × 24 px (standalone) | + +### Internal State + +None. Spinner is fully stateless. + +### Template Structure + +``` +app-spinner (host: display: contents — no visual wrapper) + └── + └── +``` + +`aria-hidden="true"` on the inner ring prevents screen readers from announcing the empty +``. The outer `role="status"` + `aria-label` carries the full announcement. + +When used inside Button's loading state, Button sets `aria-hidden="true"` on the +`` host element so the Spinner's `role="status"` does not double-announce +(Button itself has `aria-busy="true"` for this purpose). + +### SCSS Size Variables + +| Size | Width | Height | Border-width | +|------|-------|--------|-------------| +| `sm` | 16px | 16px | 2px | +| `md` | 24px | 24px | 2px | + +### Dependencies + +None. + +--- + +## Barrel File + +**File**: `src/app/shared/ui/index.ts` + +Exports all three component classes for single-import convenience: + +```typescript +export { ButtonComponent } from './button/button.component'; +export { TextInputComponent } from './text-input/text-input.component'; +export { SpinnerComponent } from './spinner/spinner.component'; +``` + +--- + +## Dependency Graph + +``` +SpinnerComponent (no deps — implement first) + ↓ +ButtonComponent (depends on SpinnerComponent) + ↓ +TextInputComponent (no component deps — can run in parallel with Button after Spinner done) + +barrel index.ts (depends on all three — implement last) +``` + +--- + +## tsconfig Path Alias + +**File**: `tsconfig.json` + +Add to `compilerOptions.paths`: +```json +"@app/*": ["src/app/*"] +``` + +This enables clean imports throughout the app: +```typescript +import { ButtonComponent } from '@app/shared/ui'; +``` diff --git a/specs/002-shared-ui-primitives/plan.md b/specs/002-shared-ui-primitives/plan.md new file mode 100644 index 0000000..0c421ba --- /dev/null +++ b/specs/002-shared-ui-primitives/plan.md @@ -0,0 +1,509 @@ +# Implementation Plan: Shared UI Primitives + +**Branch**: `002-shared-ui-primitives` | **Date**: 2026-04-30 | **Spec**: [spec.md](./spec.md) +**Input**: Feature specification from `specs/002-shared-ui-primitives/spec.md` + +## Summary + +Build three standalone Angular 21 UI primitive components — `ButtonComponent`, +`TextInputComponent`, and `SpinnerComponent` — for reuse across login and chat pages. +Spinner is implemented first (no deps); Button depends on Spinner; TextInput is independent of +both and can run in parallel with Button. A tsconfig path alias (`@app/*`) enables clean barrel +imports. All components use OnPush change detection, signals-first state, `inject()`-based +dependencies, and SCSS design tokens exclusively — zero hard-coded style values. + +## Technical Context + +**Language/Version**: TypeScript 5.9.2, Angular 21.2 +**Primary Dependencies**: @angular/core 21.2, @angular/forms 21.2, sass +**Storage**: N/A — all components are stateless or locally stateful signals +**Testing**: Vitest via `@angular/build:unit-test` (Angular 21 CLI default). No spec files in this feature. +**Target Platform**: Modern browsers (Chrome 120+, Firefox 120+, Safari 17+) +**Project Type**: Angular component library (shared UI primitives within an SPA) +**Performance Goals**: INP < 200 ms, CLS < 0.1, LCP < 2.5 s (Constitution IV); components are OnPush so re-renders are signal-triggered only +**Constraints**: Zero lint errors (SC-005), zero build errors (SC-004), WCAG 2.1 AA with zero AXE violations (SC-002), TypeScript strict mode, no NgModules, no `any` type +**Scale/Scope**: 3 reusable components; ~6 files total + +## Constitution Check + +| Principle | Status | Notes | +|-----------|--------|-------| +| I. SDD | ✅ PASS | spec.md + clarifications complete before implementation | +| II. Angular Signals-First | ✅ PASS | All components: standalone, OnPush, `input()` API, `signal()` + `computed()` for state, `inject()` for DI, no NgModules, no `any`, no decorator-based injection | +| III. Accessible by Default | ✅ PASS | `role="status"` on Spinner, `aria-invalid` + `aria-describedby` on TextInput, `aria-busy` on Button loading, visible focus rings | +| IV. Performance-Governed Delivery | ✅ PASS | OnPush components; CSS-only animation (GPU-composited, no layout/paint); no images; lazy-loaded route architecture unchanged | +| V. Feature-Based Architecture | ✅ PASS | All three components live in `src/app/shared/ui/`; SCSS uses `@use 'tokens'` | + +*No violations. Complexity Tracking table omitted.* + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-shared-ui-primitives/ +├── plan.md # This file +├── research.md # CVA pattern, accessibility decisions, spinner CSS +├── data-model.md # Complete component API specs (inputs, signals, computed) +├── quickstart.md # Developer guide and manual verification steps +├── contracts/ +│ ├── button.md # ButtonComponent contract +│ ├── text-input.md # TextInputComponent contract +│ └── spinner.md # SpinnerComponent contract +└── tasks.md # Phase 2 output (/speckit-tasks — not created here) +``` + +### Source Code + +```text +src/ +├── app/ +│ └── shared/ +│ ├── interceptors/ # Already exists (001-auth-setup) +│ └── ui/ # NEW — created by this feature +│ ├── index.ts # Barrel file — exports all 3 components +│ ├── button/ +│ │ ├── button.component.ts # ButtonComponent class + inline template +│ │ └── button.component.scss # Token-only button styles +│ ├── text-input/ +│ │ ├── text-input.component.ts # TextInputComponent class +│ │ ├── text-input.component.html # External template (non-trivial markup) +│ │ └── text-input.component.scss # Token-only text-input styles +│ └── spinner/ +│ ├── spinner.component.ts # SpinnerComponent class + inline template +│ └── spinner.component.scss # Token-only spinner styles +└── styles/ + └── tokens.scss # Already exists (001-auth-setup) + +tsconfig.json (modified): + compilerOptions.paths: { "@app/*": ["src/app/*"] } # NEW — enables @app/shared/ui imports +``` + +**Structure Decision**: All three components live under `src/app/shared/ui/` per Constitution +Principle V. Each component has its own subdirectory matching the component filename prefix. +Button and Spinner use inline templates (minimal markup). TextInput uses an external `.html` +template (complex multi-element structure). All stylesheets are external `.scss` files +(never inline) as required by Constitution V. + +--- + +## Critical Implementation Details + +This section provides exact, unambiguous implementation instructions for an AI agent. +Reference `data-model.md` for the complete API table and `contracts/` for usage contracts. + +### T-setup: tsconfig.json path alias + +Add `paths` to `tsconfig.json` `compilerOptions` (note: `tsconfig.json` has no `paths` key +currently): + +```json +"compilerOptions": { + "paths": { + "@app/*": ["src/app/*"] + } +} +``` + +**WHY**: Without this, consumers would need deep relative imports like +`'../../../shared/ui/button/button.component'`. The alias gives `'@app/shared/ui'` from +anywhere in the project. + +--- + +### SpinnerComponent — complete implementation + +**File**: `src/app/shared/ui/spinner/spinner.component.ts` + +```typescript +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; + +@Component({ + selector: 'app-spinner', + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrl: './spinner.component.scss', + template: ` + + + + `, +}) +export class SpinnerComponent { + readonly label = input('Loading…'); // "Loading…" + readonly size = input<'sm' | 'md'>('md'); +} +``` + +**File**: `src/app/shared/ui/spinner/spinner.component.scss` + +```scss +@use 'tokens'; + +:host { + display: inline-flex; + align-items: center; + justify-content: center; +} + +.spinner__ring { + border-radius: 50%; + border: 2px solid tokens.$color-bg-elevated; + border-top-color: tokens.$color-accent; + animation: lexico-spin 0.75s linear infinite; + + &--sm { width: 16px; height: 16px; } + &--md { width: 24px; height: 24px; } +} + +@keyframes lexico-spin { + to { transform: rotate(360deg); } +} +``` + +**Key rules**: +- `aria-hidden="true"` MUST be on the inner ring ``, NOT on the outer `role="status"` span. +- Do NOT add `standalone: true` to the `@Component` decorator (Angular 21 default). +- `label()` default value uses the unicode ellipsis character `…` (not three periods) for + correct screen reader pronunciation. + +--- + +### ButtonComponent — complete implementation + +**File**: `src/app/shared/ui/button/button.component.ts` + +```typescript +import { + ChangeDetectionStrategy, + Component, + computed, + input, +} from '@angular/core'; +import { SpinnerComponent } from '../spinner/spinner.component'; + +@Component({ + selector: 'app-button', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [SpinnerComponent], + styleUrl: './button.component.scss', + template: ` + + `, +}) +export class ButtonComponent { + readonly variant = input<'primary' | 'secondary'>('primary'); + readonly type = input<'button' | 'submit'>('button'); + readonly loading = input(false); + readonly disabled = input(false); + + readonly isDisabled = computed(() => this.loading() || this.disabled()); +} +``` + +**File**: `src/app/shared/ui/button/button.component.scss` + +```scss +@use 'tokens'; + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: tokens.$spacing-2; + padding: tokens.$spacing-3 tokens.$spacing-4; + border: 1px solid transparent; + border-radius: tokens.$border-radius-md; + font-family: tokens.$font-family-base; + font-size: tokens.$font-size-md; + font-weight: tokens.$font-weight-medium; + line-height: 1; + cursor: pointer; + transition: opacity 0.15s ease, background-color 0.15s ease; + + &:focus-visible { + outline: 2px solid tokens.$color-accent; + outline-offset: 2px; + } + + &--primary { + background: tokens.$color-accent; + color: tokens.$color-text-primary; + + &:hover:not(:disabled) { opacity: 0.9; } + } + + &--secondary { + background: tokens.$color-bg-surface; + color: tokens.$color-text-primary; + border-color: tokens.$color-border; + + &:hover:not(:disabled) { background: tokens.$color-bg-elevated; } + } + + &--disabled, + &:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + &--loading { + cursor: wait; + } +} +``` + +**Key rules**: +- `[disabled]="isDisabled() || null"`: The `|| null` pattern is REQUIRED. Without it, Angular + emits `disabled="false"` which still disables the button. `null` removes the attribute entirely. +- `aria-hidden="true"` on `` inside the button is REQUIRED to prevent double + announcements (button has `aria-busy="true"` which already handles the loading state). +- The `imports` array MUST contain `SpinnerComponent` (not a module). +- Do NOT use `ngClass` — use `[class]` string binding as shown. + +--- + +### TextInputComponent — complete implementation + +**File**: `src/app/shared/ui/text-input/text-input.component.ts` + +```typescript +import { + ChangeDetectionStrategy, + Component, + computed, + input, + signal, +} from '@angular/core'; +import { + ControlValueAccessor, + NG_VALUE_ACCESSOR, +} from '@angular/forms'; + +@Component({ + selector: 'app-text-input', + changeDetection: ChangeDetectionStrategy.OnPush, + templateUrl: './text-input.component.html', + styleUrl: './text-input.component.scss', + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: TextInputComponent, + multi: true, + }, + ], +}) +export class TextInputComponent implements ControlValueAccessor { + // Signal inputs (replaces @Input decorators) + readonly label = input.required(); + readonly type = input('text'); + readonly errorMessage = input(''); + readonly placeholder = input(''); + + // Internal reactive state + readonly value = signal(''); + readonly showPassword = signal(false); + + // Computed derived state + readonly effectiveType = computed(() => + this.type() === 'password' && this.showPassword() ? 'text' : this.type(), + ); + readonly hasError = computed(() => this.errorMessage().length > 0); + + // Stable unique DOM id for