+ │ ├──
+ │ └── @if (type() === 'password') {
+ │
...
+ │ }
+ └── @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: `
+
+ @if (loading()) {
+
+ }
+
+
+ `,
+})
+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 / association
+ static #idCounter = 0;
+ readonly inputId = `text-input-${++TextInputComponent.#idCounter}`;
+
+ // CVA callbacks — Angular DI infrastructure, NOT signals
+ #onChange: (value: string) => void = () => {};
+ #onTouched: () => void = () => {};
+
+ // ControlValueAccessor implementation
+ writeValue(value: string): void {
+ this.value.set(value ?? '');
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.#onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.#onTouched = fn;
+ }
+
+ // Template event handlers (protected — callable from template, not from outside)
+ protected onInput(event: Event): void {
+ const val = (event.target as HTMLInputElement).value;
+ this.value.set(val);
+ this.#onChange(val);
+ }
+
+ protected onBlur(): void {
+ this.#onTouched();
+ }
+
+ protected togglePasswordVisibility(): void {
+ this.showPassword.update((v) => !v);
+ }
+}
+```
+
+**File**: `src/app/shared/ui/text-input/text-input.component.html`
+
+```html
+
+
{{ label() }}
+
+
+
+
+ @if (type() === 'password') {
+
+ @if (showPassword()) {
+ 🙈
+ } @else {
+ 👁
+ }
+
+ }
+
+
+ @if (hasError()) {
+
{{ errorMessage() }}
+ }
+
+```
+
+**Note on emoji icons in the password toggle**: The emoji placeholder (`👁` / `🙈`) is used
+as a stand-in for the actual icon implementation. A future design pass will replace these
+with SCSS-styled SVG icons or CSS pseudo-elements. The `aria-hidden="true"` on the emoji
+`` ensures they are ignored by screen readers; the button's `aria-label` carries the
+accessible name.
+
+**File**: `src/app/shared/ui/text-input/text-input.component.scss`
+
+```scss
+@use 'tokens';
+
+.text-input {
+ display: flex;
+ flex-direction: column;
+ gap: tokens.$spacing-1;
+
+ &__label {
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-md;
+ font-weight: tokens.$font-weight-medium;
+ color: tokens.$color-text-primary;
+ }
+
+ &__field-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ &__input {
+ width: 100%;
+ padding: tokens.$spacing-2 tokens.$spacing-3;
+ background: tokens.$color-bg-surface;
+ border: 1px solid tokens.$color-border;
+ border-radius: tokens.$border-radius-md;
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-md;
+ color: tokens.$color-text-primary;
+ outline: none;
+ transition: border-color 0.15s ease;
+
+ &::placeholder { color: tokens.$color-text-secondary; }
+
+ &:focus { border-color: tokens.$color-accent; }
+
+ &[aria-invalid='true'] {
+ border-color: #e53e3e; // INTENTIONAL: no error-color token exists yet;
+ // replace when an error token is added to tokens.scss
+ }
+ }
+
+ &__toggle {
+ position: absolute;
+ right: tokens.$spacing-2;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: tokens.$spacing-1;
+ color: tokens.$color-text-secondary;
+ line-height: 1;
+
+ &:focus-visible {
+ outline: 2px solid tokens.$color-accent;
+ border-radius: tokens.$border-radius-sm;
+ }
+ }
+
+ &__error {
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-sm;
+ color: #e53e3e; // Same as above — replace when error token added
+ }
+}
+```
+
+**Key rules**:
+- `input.required()` MUST be used for `label` (not `input()`). The
+ `required` form triggers a compile-time error if a consumer omits the `label` attribute.
+- The `providers` array with `NG_VALUE_ACCESSOR` MUST be in the `@Component` decorator —
+ this is what makes the component work with `[formControl]` and `formControlName`.
+- `writeValue` MUST call `this.value.set(value ?? '')` — the `?? ''` guards against `null`
+ which Angular Reactive Forms sends when a control is reset.
+- `role="alert"` on the error `` ensures screen readers announce the error immediately
+ when it appears (not politely — errors are urgent).
+- The two `#e53e3e` hard-coded color values are flagged as intentional exceptions pending
+ the addition of an `$color-error` token in a future `tokens.scss` update.
+
+---
+
+### Barrel File
+
+**File**: `src/app/shared/ui/index.ts`
+
+```typescript
+export { ButtonComponent } from './button/button.component';
+export { TextInputComponent } from './text-input/text-input.component';
+export { SpinnerComponent } from './spinner/spinner.component';
+```
+
+No type-only exports are needed (TypeScript infers component types from the class definitions).
diff --git a/specs/002-shared-ui-primitives/quickstart.md b/specs/002-shared-ui-primitives/quickstart.md
new file mode 100644
index 0000000..7940ef6
--- /dev/null
+++ b/specs/002-shared-ui-primitives/quickstart.md
@@ -0,0 +1,186 @@
+# Developer Quickstart: Shared UI Primitives
+
+**Branch**: `002-shared-ui-primitives` | **Date**: 2026-04-30
+
+---
+
+## Prerequisites
+
+- Feature `001-auth-setup` is merged or present locally (provides SCSS tokens and
+ `ReactiveFormsModule` at root)
+- Node.js 22+, npm 11+, Angular CLI 21
+
+---
+
+## Running the App
+
+```bash
+npm install
+npm start
+# http://localhost:4200
+```
+
+---
+
+## Running Tests
+
+```bash
+npm test
+```
+
+Tests use Vitest via `@angular/build:unit-test`. Test files are co-located as `*.spec.ts`.
+This feature introduces no spec files (manual verification only — see sections below).
+
+---
+
+## Building for Production
+
+```bash
+ng build --configuration production
+```
+
+Zero errors and zero budget warnings expected after implementing all three primitives.
+
+---
+
+## Using the Primitives in a New Component
+
+### 1. Import from the barrel
+
+```typescript
+import { ButtonComponent, TextInputComponent, SpinnerComponent } from '@app/shared/ui';
+// or with relative path:
+import { ButtonComponent, TextInputComponent } from '../../shared/ui';
+```
+
+Add to the component's `imports` array (no module setup needed — all are standalone):
+
+```typescript
+@Component({
+ imports: [ButtonComponent, TextInputComponent],
+ // ...
+})
+export class LoginComponent { ... }
+```
+
+### 2. Button
+
+```html
+
+Sign In
+
+
+
+
+
+Cancel
+```
+
+### 3. TextInput
+
+```typescript
+readonly emailCtrl = new FormControl('', [Validators.required, Validators.email]);
+```
+
+```html
+
+```
+
+### 4. Spinner
+
+```html
+
+@if (isLoading()) {
+
+}
+
+
+
+```
+
+---
+
+## Path Alias Setup
+
+`tsconfig.json` should contain (added by this feature's T001 task):
+
+```json
+"compilerOptions": {
+ "paths": {
+ "@app/*": ["src/app/*"]
+ }
+}
+```
+
+This enables `import { ButtonComponent } from '@app/shared/ui'` throughout the codebase.
+
+---
+
+## Manual Verification: SC-001 — Zero-config import
+
+1. Add `ButtonComponent` to any existing component's `imports` array.
+2. Add `Hello ` to its template.
+3. Run `ng serve`. Confirm the button renders with the accent-color background and correct
+ typography in the browser. No configuration beyond the `imports` addition should be needed.
+
+---
+
+## Manual Verification: SC-002 — AXE zero violations
+
+1. Run `npm start`.
+2. Navigate to any page that renders the primitives.
+3. Open Chrome DevTools → **Console** tab.
+4. Install the [axe DevTools browser extension](https://www.deque.com/axe/devtools/) if not present.
+5. Run AXE scan. Confirm **zero violations** for:
+ - Button: focus ring visible, `type` attribute present, `aria-busy` during loading.
+ - TextInput: `` associated, `aria-invalid` when error present, toggle button labeled.
+ - Spinner: `role="status"`, `aria-label` present, inner ring `aria-hidden="true"`.
+
+---
+
+## Manual Verification: SC-003 — Token-only styles
+
+1. Open any of these files in the browser DevTools:
+ - `button.component.scss`
+ - `text-input.component.scss`
+ - `spinner.component.scss`
+2. Search for any hex color values (e.g., `#`, `rgb(`) or pixel-size values that are NOT
+ from token variables. There should be **zero** hard-coded color or typography values.
+ (Pixel dimensions for spinner ring size are acceptable as they match the token scale.)
+
+---
+
+## Manual Verification: SC-004 — TextInput Reactive Forms integration
+
+1. Create a test component with a `FormControl`:
+ ```typescript
+ readonly ctrl = new FormControl('initial value');
+ ```
+2. Bind: ` `
+3. Type in the input. Run `console.log(ctrl.value)` in DevTools. Confirm it matches the
+ typed value in real time.
+4. Set `ctrl.setValue('programmatic')` in DevTools console. Confirm the input displays
+ "programmatic" without page reload.
+
+---
+
+## Manual Verification: Password Toggle
+
+1. Add ` `
+2. Type text into the field — characters should be masked.
+3. Click the eye/toggle button — characters should be revealed.
+4. Click again — characters should be re-masked.
+5. Confirm the toggle button has an accessible `aria-label` of "Show password" /
+ "Hide password" (inspect via DevTools → Accessibility tab).
diff --git a/specs/002-shared-ui-primitives/research.md b/specs/002-shared-ui-primitives/research.md
new file mode 100644
index 0000000..49c7b80
--- /dev/null
+++ b/specs/002-shared-ui-primitives/research.md
@@ -0,0 +1,285 @@
+# Research: Shared UI Primitives
+
+**Branch**: `002-shared-ui-primitives` | **Date**: 2026-04-30
+**Input**: Technical decisions required by `spec.md` and constitution Principles II–IV
+
+---
+
+## Decision 1: ControlValueAccessor integration pattern in Angular 21
+
+**Decision**: Use the `NG_VALUE_ACCESSOR` injection token declared in the component's `providers`
+array. Implement `ControlValueAccessor` methods (`writeValue`, `registerOnChange`,
+`registerOnTouched`) directly as class methods. Store CVA callbacks as private class fields
+(not signals — they are Angular infrastructure callbacks, not reactive state).
+
+**Rationale**:
+- Angular 21 no longer supports constructor injection (`@Inject`, constructor parameters).
+ All dependencies use `inject()`. However, `NG_VALUE_ACCESSOR` does not need `inject()` —
+ it is declared in `providers` and Angular's DI resolves it automatically.
+- Injecting `NgControl` via `inject(NgControl, { optional: true, self: true })` is a valid
+ alternative but tightly couples the component to knowing it's inside a form. The
+ `NG_VALUE_ACCESSOR` provider approach is the canonical Angular pattern and is form-agnostic.
+- `writeValue` / `registerOnChange` / `registerOnTouched` are NOT Angular signals — they
+ are DI-managed callbacks provided by the parent form. Wrapping them in signals adds
+ unnecessary complexity. They are stored as private class fields (fat-arrow functions
+ initialized to no-ops).
+- The internal value (what the user has typed) IS a signal (`signal('')`), since
+ it drives template re-render under `OnPush`.
+
+**Implementation pattern** (exact code to follow):
+```typescript
+import { ChangeDetectionStrategy, Component, signal, computed, input } from '@angular/core';
+import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
+
+@Component({
+ selector: 'app-text-input',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: TextInputComponent,
+ multi: true,
+ },
+ ],
+ // ...
+})
+export class TextInputComponent implements ControlValueAccessor {
+ // Angular signal inputs (replaces @Input decorators — Angular 21)
+ 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: resolves the live type attribute
+ // When type is "password" and toggle is on, renders as "text" to reveal chars
+ readonly effectiveType = computed(() =>
+ this.type() === 'password' && this.showPassword() ? 'text' : this.type(),
+ );
+
+ // Unique DOM id for association
+ // Static counter ensures uniqueness even if multiple instances exist on the same page
+ static #idCounter = 0;
+ readonly inputId = `text-input-${++TextInputComponent.#idCounter}`;
+
+ // CVA callbacks — NOT signals (Angular DI infrastructure)
+ #onChange: (value: string) => void = () => {};
+ #onTouched: () => void = () => {};
+
+ // ControlValueAccessor implementation
+ writeValue(value: string): void {
+ this.value.set(value ?? '');
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.#onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.#onTouched = fn;
+ }
+
+ // Template event handlers
+ protected onInput(event: Event): void {
+ const val = (event.target as HTMLInputElement).value;
+ this.value.set(val);
+ this.#onChange(val);
+ }
+
+ protected onBlur(): void {
+ this.#onTouched();
+ }
+
+ protected togglePasswordVisibility(): void {
+ this.showPassword.update((v) => !v);
+ }
+}
+```
+
+**Alternatives considered**:
+- `inject(NgControl, { optional: true, self: true })` — rejected because it requires a
+ constructor-like initialization block (`afterNextRender` or `effect`) to set
+ `ngControl.valueAccessor = this`, which is less clean.
+- Using `model()` signal for two-way binding — rejected because `model()` does NOT
+ interoperate with `ReactiveFormsModule`. CVA is the only supported integration path.
+
+---
+
+## Decision 2: Button disabled / loading accessibility semantics
+
+**Decision**:
+- For `disabled` input: apply native HTML `disabled` attribute to the `` element.
+- For `loading` input: apply native `disabled` + `aria-busy="true"` to the `` element.
+- Both states prevent click events natively (no JS event filtering needed).
+- The `isDisabled` computed combines both: `computed(() => this.disabled() || this.loading())`.
+
+**Rationale**:
+- Native `disabled` on `` is universally supported and removes the element from the
+ natural tab order. For temporary states (loading), this is acceptable because the user's
+ focus is typically on waiting for the operation to complete.
+- `aria-busy="true"` on the button while loading signals to screen readers that the element
+ is currently processing — this is the correct ARIA 1.2 pattern for in-progress actions.
+- Alternative (`aria-disabled` + `pointer-events: none`) keeps the button focusable during
+ loading. Rejected because it requires extra CSS and JS to prevent click propagation, and
+ ARIA 1.1 screen readers may announce disabled buttons as clickable.
+
+**Implementation**:
+```html
+
+```
+Note: `[disabled]="false"` adds the attribute as `disabled="false"` which is STILL disabled.
+Use `isDisabled() || null` — Angular will remove the attribute entirely when the value is `null`.
+
+---
+
+## Decision 3: Spinner implementation — pure CSS vs SVG vs CDK
+
+**Decision**: Pure CSS border animation. No SVG, no Angular CDK, no third-party dependency.
+
+**Implementation**:
+```scss
+// In spinner.component.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); }
+}
+```
+
+Component template:
+```html
+
+
+
+```
+
+**Rationale**:
+- CSS `border-top` + `rotate` animation is GPU-accelerated (triggers compositor layer only,
+ zero layout/paint — CLS-safe).
+- `aria-hidden="true"` on the visual ring prevents screen readers from reading the empty
+ `` as an element. The outer `role="status"` + `aria-label` carries the announcement.
+- `role="status"` implies `aria-live="polite"` — when this element is inserted into the DOM
+ (via `@if`), screen readers will announce the label after the current utterance completes.
+
+**Alternatives considered**:
+- SVG circle with `stroke-dasharray` — visually richer but heavier; overkill for a simple
+ spinner.
+- Angular CDK `ProgressSpinner` — adds dependency and diverges from token-only styling goal.
+
+---
+
+## Decision 4: Button loading state uses SpinnerComponent internally
+
+**Decision**: `ButtonComponent` imports and renders `SpinnerComponent` in its template when
+`loading()` is `true`. The Spinner renders at `size="sm"` with `label="Loading…"`.
+
+**Implementation**:
+```html
+
+
+ @if (loading()) {
+
+ }
+
+
+```
+
+Note: `aria-hidden="true"` on the Spinner inside the Button suppresses its `role="status"`
+announcement because the `aria-busy="true"` on the `` already announces loading
+state to screen readers. Without this, the screen reader would announce "Loading…" twice.
+
+**Dependency ordering**: Spinner MUST be implemented and tested before Button. Tasks for
+Spinner are Phase 3 (P3-priority primitive) but they must be completed before Button tasks
+begin.
+
+---
+
+## Decision 5: TextInput unique ID generation
+
+**Decision**: Use a static class-level counter to generate unique `inputId` values per instance.
+
+```typescript
+static #idCounter = 0;
+readonly inputId = `text-input-${++TextInputComponent.#idCounter}`;
+```
+
+This is set as a class field (not a signal) because the ID never changes after construction —
+no reactive subscription needed. The `` and ` ` both reference this via
+`[for]="inputId"` and `[id]="inputId"`.
+
+**Rationale**: Avoids `crypto.randomUUID()` (not universally available in all environments)
+and `Math.random()` (could theoretically collide under SSR). Counter always produces unique
+sequential IDs within a browser session. Deterministic in tests (reset between test suites).
+
+---
+
+## Decision 6: No unit tests in this feature
+
+**Decision**: No spec (`*.spec.ts`) files are created by this feature. Manual verification
+steps in `quickstart.md` cover acceptance scenarios.
+
+**Rationale**: Per spec — "no automated tests requested." The constitution does not mandate
+unit tests for component libraries. AXE verification (SC-002) is a manual browser check per
+`quickstart.md`.
+
+**Note for future features**: When the login form feature (issue #4) is implemented, it will
+exercise these components in a full page context where AXE automation can be run.
+
+---
+
+## Decision 7: Barrel file strategy
+
+**Decision**: Single barrel at `src/app/shared/ui/index.ts` re-exporting all three component
+classes. No type re-exports (TS types are inferred from the components themselves).
+
+```typescript
+export { ButtonComponent } from './button/button.component';
+export { TextInputComponent } from './text-input/text-input.component';
+export { SpinnerComponent } from './spinner/spinner.component';
+```
+
+**Usage in a consumer**:
+```typescript
+import { ButtonComponent, TextInputComponent } from '@app/shared/ui';
+```
+
+This requires `paths` configuration in `tsconfig.json` (`"@app/*": ["src/app/*"]`) — this
+is a setup task in Phase 1. Without the alias, consumers import via relative path:
+```typescript
+import { ButtonComponent } from '../../shared/ui';
+```
+
+**Decision on tsconfig path alias**: Add `@app/*` → `src/app/*` alias in `tsconfig.json`
+to avoid deep relative imports throughout the codebase.
diff --git a/specs/002-shared-ui-primitives/spec.md b/specs/002-shared-ui-primitives/spec.md
new file mode 100644
index 0000000..6b94d56
--- /dev/null
+++ b/specs/002-shared-ui-primitives/spec.md
@@ -0,0 +1,186 @@
+# Feature Specification: Shared UI Primitives
+
+**Feature Branch**: `002-shared-ui-primitives`
+**Created**: 2026-04-30
+**Status**: Draft
+**Input**: User description: "Build reusable primitives used across login and chat pages."
+
+## User Scenarios & Testing *(mandatory)*
+
+### User Story 1 - Button Component (Priority: P1)
+
+A developer building the login form or chat page needs a button that handles primary actions
+(e.g., "Sign in", "Send") and secondary actions (e.g., "Cancel"). The button handles the
+loading state automatically — when a loading flag is set, the button disables itself and shows
+a spinner, preventing double-submission.
+
+**Why this priority**: Buttons are the most frequently used interactive primitive. Every form
+action and chat operation depends on them. No feature UI can be built without this.
+
+**Independent Test**: Add `Sign In `
+to any component. Confirm it renders with the accent-color background, correct typography, and
+transitions to a spinner state when `[loading]="true"` — all without additional configuration.
+
+**Acceptance Scenarios**:
+
+1. **Given** a page with ``, **When** it renders, **Then** the
+ button displays with the accent color background and the correct token-based typography.
+2. **Given** a primary button with `[loading]="true"`, **When** it renders, **Then** the button
+ shows a spinner, is non-interactive, and does not emit click events.
+3. **Given** a button focused via keyboard, **When** it receives focus, **Then** a visible focus
+ ring appears meeting WCAG 2.4.7 minimum contrast (≥ 3:1).
+4. **Given** a button with `[disabled]="true"`, **When** the user clicks or keyboard-activates it,
+ **Then** no action triggers and the cursor indicates the disabled state.
+
+---
+
+### User Story 2 - Text Input Component (Priority: P2)
+
+A developer building the login form needs a text input that renders a label above it, displays
+validation error messages below it, and integrates with Angular Reactive Forms without manual
+`ControlValueAccessor` wiring in the consuming component.
+
+**Why this priority**: The login form depends on accessible, Reactive-Forms-compatible inputs.
+Without this primitive the login feature cannot meet accessibility or form validation requirements.
+
+**Independent Test**: Add ``
+inside a `[formGroup]`. Confirm the label is visually and programmatically associated, the control
+value updates on input, and the error message appears/disappears with `errorMessage` binding.
+
+**Acceptance Scenarios**:
+
+1. **Given** a TextInput with a `label` input, **When** it renders, **Then** a `` element
+ is visible and correctly associated with the input via `for`/`id` attributes.
+2. **Given** a TextInput bound to a Reactive Forms control, **When** the user types, **Then**
+ the control value updates in real time.
+3. **Given** a TextInput with a non-empty `errorMessage`, **When** it renders, **Then** the error
+ message appears below the input with `aria-invalid="true"` on the input element.
+4. **Given** a TextInput with `type="password"`, **When** it renders, **Then** a show/hide toggle
+ button is present with an accessible label, and activating it reveals or masks the text.
+
+---
+
+### User Story 3 - Spinner Component (Priority: P3)
+
+A developer needs a visual loading indicator to display while an HTTP request is in flight — for
+example, while the login API call is pending or while chat history is loading. The spinner must
+announce its loading state to screen readers without requiring additional ARIA markup from the
+consuming component.
+
+**Why this priority**: Login and chat both involve async operations. A spinner provides visual
+feedback that prevents users from thinking the app has frozen.
+
+**Independent Test**: Add ` ` to any template. Confirm an animated visual indicator
+renders and an accessible announcement is present (verify `role="status"` or `aria-label` via
+AXE inspection).
+
+**Acceptance Scenarios**:
+
+1. **Given** ` ` in a template, **When** the page renders, **Then** an animated
+ loading indicator is visible.
+2. **Given** the Spinner rendered in the DOM, **When** a screen reader navigates to it, **Then**
+ it announces a loading state via `role="status"` and an accessible label.
+3. **Given** ` `, **When** it renders, **Then** the custom label
+ is used as the accessible name instead of the default "Loading…".
+
+---
+
+### Edge Cases
+
+- What if the Button receives both `[disabled]="true"` and `[loading]="true"` simultaneously? The
+ button MUST remain non-interactive; the loading spinner takes visual precedence.
+- What if `errorMessage` is an empty string or undefined? The TextInput MUST NOT render an empty
+ error container to avoid layout shift (CLS).
+- What if the Spinner renders without a containing element with a live region? The Spinner MUST
+ include its own `role="status"` so it is self-contained and requires no additional ARIA from
+ the consumer.
+- What if a consumer sets `[loading]="true"` inside the button's click handler? The button MUST
+ ignore subsequent click events while already in the loading state.
+
+## Requirements *(mandatory)*
+
+### Functional Requirements
+
+- **FR-001**: A Button primitive MUST be provided as a standalone component at
+ `src/app/shared/ui/button/button.component.ts`.
+- **FR-002**: Button MUST support a `variant` input accepting `"primary"` | `"secondary"`,
+ defaulting to `"primary"`.
+- **FR-003**: Button MUST support a `loading` boolean input; when `true`, the button MUST be
+ non-interactive and render the Spinner component internally at `size="sm"`.
+- **FR-004**: Button MUST support a `disabled` boolean input that prevents interaction and applies
+ correct accessible disabled semantics (`aria-disabled` or native `disabled`).
+- **FR-005**: Button MUST support a `type` input accepting `"button"` | `"submit"`, defaulting to
+ `"button"` to prevent accidental form submission; setting `type="submit"` enables native form
+ submission (Enter key submits the form, assistive-technology form mode works correctly).
+- **FR-006**: A TextInput primitive MUST be provided as a standalone component at
+ `src/app/shared/ui/text-input/text-input.component.ts`.
+- **FR-007**: TextInput MUST implement Angular's `ControlValueAccessor` interface so it integrates
+ directly with Reactive Forms without per-consumer boilerplate.
+- **FR-008**: TextInput MUST support a `label` string input rendered as an accessible ``
+ element programmatically associated with the input field.
+- **FR-009**: TextInput MUST support a `type` string input (`"text"`, `"email"`, `"password"`, etc.)
+ defaulting to `"text"`.
+- **FR-010**: TextInput MUST support an `errorMessage` string input; when non-empty, the error
+ MUST display below the input and `aria-invalid="true"` MUST be set on the input element.
+- **FR-011**: TextInput with `type="password"` MUST include a show/hide toggle with an accessible
+ label; toggling MUST switch the input type between `"password"` and `"text"`.
+- **FR-012**: A Spinner primitive MUST be provided as a standalone component at
+ `src/app/shared/ui/spinner/spinner.component.ts`.
+- **FR-013**: Spinner MUST include `role="status"` and an accessible label (default: `"Loading…"`)
+ readable by screen readers without any additional ARIA from the consuming component.
+- **FR-014**: Spinner MUST support an optional `label` string input to override the default
+ accessible label.
+- **FR-014a**: Spinner MUST support a `size` input accepting `"sm"` | `"md"`, defaulting to
+ `"md"`; Button's loading state renders Spinner at `size="sm"`.
+- **FR-015**: All three primitives MUST use design tokens from `src/styles/tokens.scss` exclusively
+ for colors, spacing, border radius, and typography — no hard-coded values in component stylesheets.
+- **FR-016**: All three primitives MUST declare `changeDetection: ChangeDetectionStrategy.OnPush`.
+- **FR-017**: All three primitives MUST be exported from a barrel file at
+ `src/app/shared/ui/index.ts`.
+- **FR-018**: TextInput MUST support an optional `placeholder` string input passed through to the
+ native input element's `placeholder` attribute.
+
+### Key Entities
+
+- **Button**: Interactive action trigger. Inputs: `variant` (`"primary"` | `"secondary"`),
+ `type` (`"button"` | `"submit"`, default `"button"`), `loading` (boolean), `disabled` (boolean).
+ Emits native click events unless loading or disabled.
+- **TextInput**: Form-bound text entry field. Implements `ControlValueAccessor`. Inputs: `label`
+ (string), `type` (string), `errorMessage` (string), `placeholder` (string). Internal signal
+ tracks password visibility state.
+- **Spinner**: Stateless loading indicator. Inputs: `label` (optional string), `size` (`"sm"` | `"md"`,
+ default `"md"`). No internal state. Used standalone and internally by Button when loading.
+
+## Success Criteria *(mandatory)*
+
+### Measurable Outcomes
+
+- **SC-001**: A developer can import and use any of the three primitives in a new component with
+ zero configuration beyond adding it to the component's `imports` array.
+- **SC-002**: All three primitives pass zero AXE accessibility violations when rendered in
+ isolation and combined on a form page.
+- **SC-003**: Each primitive renders exclusively with design tokens — zero hard-coded color,
+ spacing, or typography values appear in component stylesheets.
+- **SC-004**: The application compiles with zero errors and zero warnings after adding all three
+ primitives (`ng build --configuration production`).
+- **SC-005**: Zero ESLint rule violations across all files introduced by this feature.
+
+## Clarifications
+
+### Session 2026-04-30
+
+- Q: Should Button support a `type` input to allow `"submit"` for native form submission (Enter key, screen reader form mode)? → A: Yes — Button MUST support a `type` input accepting `"button"` | `"submit"`, defaulting to `"button"`. FR-005 updated accordingly.
+- Q: Should Button's loading indicator reuse the Spinner component or implement its own CSS animation? → A: Button uses Spinner internally at `size="sm"`; Spinner gains a `size` input (`"sm"` | `"md"`, default `"md"`). FR-003 and FR-014a updated accordingly.
+
+## Assumptions
+
+- Primitives are purely presentational — no API calls, routing, or business logic.
+- Icon-only button variant is out of scope for this feature; a future `IconButton` primitive can
+ extend or compose the Button.
+- The TextInput is single-line only; multi-line (textarea) support is a separate future primitive.
+- Dark theme is the only visual mode; no light/dark toggle is needed.
+- `stylePreprocessorOptions.includePaths` is already configured (feature `001-auth-setup`), so
+ `@use 'tokens'` works in component stylesheets without path prefixes.
+- All primitives live under `src/app/shared/ui/` per Constitution Principle V.
+- No third-party component library; all primitives are implemented from scratch using SCSS tokens.
+- Reactive Forms is already provided at root level (feature `001-auth-setup`).
diff --git a/specs/002-shared-ui-primitives/tasks.md b/specs/002-shared-ui-primitives/tasks.md
new file mode 100644
index 0000000..df72fab
--- /dev/null
+++ b/specs/002-shared-ui-primitives/tasks.md
@@ -0,0 +1,389 @@
+# Tasks: Shared UI Primitives
+
+**Input**: Design documents from `specs/002-shared-ui-primitives/`
+**Prerequisites**: plan.md, spec.md, research.md, data-model.md, contracts/
+
+**Tests**: Not included — no automated tests requested in spec. Manual verification steps per
+story are provided in `quickstart.md`.
+
+**Organization**: Tasks are grouped by user story to enable independent implementation and
+testing of each story.
+
+> ⚠️ **Implementation Order Note**: US3 (Spinner, P3) is placed before US1 (Button, P1) because
+> Button has an internal composition dependency on Spinner. Spinner MUST exist before Button can
+> compile. TextInput (US2, P2) is independent of both and may run in parallel with US1 after
+> Phase 1 is complete.
+
+## Format: `[ID] [P?] [Story] Description`
+
+- **[P]**: Can run in parallel (different files, no shared dependencies)
+- **[Story]**: Which user story this task belongs to (US1, US2, US3)
+- Exact file paths are included in every description
+
+---
+
+## Phase 1: Setup (Build Configuration)
+
+**Purpose**: Add `@app/*` TypeScript path alias — unblocks clean barrel imports across all
+three components and all future consumers.
+
+- [x] T001 Update `tsconfig.json` — add `"paths": { "@app/*": ["src/app/*"] }` inside the
+ existing `compilerOptions` object. The file currently has no `paths` key. Place it after
+ `"module": "preserve"`. This enables `import { ButtonComponent } from '@app/shared/ui'`
+ throughout the codebase.
+
+**Checkpoint**: Build configuration ready — `@app/shared/ui` barrel imports will resolve.
+
+---
+
+## Phase 2: User Story 3 — Spinner Component (Priority: P3) ⚡ Unblocks US1
+
+**Goal**: A stateless animated loading indicator that is self-contained for accessibility
+(`role="status"` + `aria-label`), renders at two sizes (`sm` = 16 px, `md` = 24 px), and uses
+only design tokens for colors.
+
+> **Why before US1?** ButtonComponent imports SpinnerComponent internally. Spinner MUST be
+> implemented and TypeScript-resolvable before Button tasks begin.
+
+**Independent Test**: Add ` ` to any component template, run `ng serve`, confirm
+an animated ring is visible in the browser. Run AXE from DevTools — confirm `role="status"` and
+`aria-label="Loading…"` are present and no violations are reported.
+
+- [x] T002 [P] [US3] Create `src/app/shared/ui/spinner/spinner.component.ts` — standalone
+ `SpinnerComponent` (do NOT set `standalone: true`), `changeDetection: ChangeDetectionStrategy.OnPush`,
+ selector `app-spinner`, `styleUrl: './spinner.component.scss'`, two signal inputs:
+ `readonly label = input('Loading…')` and `readonly size = input<'sm' | 'md'>('md')`.
+ Inline template:
+ ```html
+
+
+
+ ```
+ Imports from `@angular/core`: `ChangeDetectionStrategy`, `Component`, `input`.
+
+- [x] T003 [P] [US3] Create `src/app/shared/ui/spinner/spinner.component.scss` — use
+ `@use 'tokens'` (no path prefix). Define:
+ - `: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; }`
+ - `.spinner__ring--sm { width: 16px; height: 16px; }`
+ - `.spinner__ring--md { width: 24px; height: 24px; }`
+ - `@keyframes lexico-spin { to { transform: rotate(360deg); } }`
+ Zero hard-coded color values; all colors come from tokens.
+
+**Checkpoint**: US3 complete — ` ` renders animated ring, passes AXE scan.
+
+---
+
+## Phase 3: User Story 1 — Button Component (Priority: P1) 🎯 MVP
+
+**Goal**: A standalone button supporting `primary`/`secondary` variants, a `type` input for
+native form submission, a `loading` state that renders the Spinner at `sm` size and prevents
+interaction, and a `disabled` state — all accessible and token-styled.
+
+**Dependencies**: Requires T002, T003 (Phase 2) — Button imports SpinnerComponent.
+
+**Independent Test**: Add `Sign In `
+to any component, run `ng serve`. Confirm: accent-color background, correct typography, spinner
+appears and button becomes non-interactive when `[loading]="true"`, disabled styling when
+`[disabled]="true"`. AXE scan shows zero violations.
+
+- [x] T004 [P] [US1] Create `src/app/shared/ui/button/button.component.ts` — standalone
+ `ButtonComponent`, `changeDetection: ChangeDetectionStrategy.OnPush`, selector `app-button`,
+ `styleUrl: './button.component.scss'`, `imports: [SpinnerComponent]`. Four signal inputs:
+ `readonly variant = input<'primary' | 'secondary'>('primary')`,
+ `readonly type = input<'button' | 'submit'>('button')`,
+ `readonly loading = input(false)`,
+ `readonly disabled = input(false)`.
+ One computed: `readonly isDisabled = computed(() => this.loading() || this.disabled())`.
+ Inline template:
+ ```html
+
+ @if (loading()) {
+
+ }
+
+
+ ```
+ CRITICAL: `[disabled]="isDisabled() || null"` — the `|| null` removes the attribute when false
+ (binding to `false` would add `disabled="false"` which still disables the button).
+ CRITICAL: `aria-hidden="true"` on `` prevents double screen reader announcement
+ (button already has `aria-busy="true"`).
+ Imports from `@angular/core`: `ChangeDetectionStrategy`, `Component`, `computed`, `input`.
+ Import `SpinnerComponent` from `'../spinner/spinner.component'`.
+
+- [x] T005 [P] [US1] Create `src/app/shared/ui/button/button.component.scss` — use `@use 'tokens'`.
+ Define base `.btn` class with: `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 ring: `&:focus-visible { outline: 2px solid tokens.$color-accent; outline-offset: 2px; }`.
+ Primary modifier: `.btn--primary { background: tokens.$color-accent; color: tokens.$color-text-primary; }
+ .btn--primary:hover:not(:disabled) { opacity: 0.9; }`.
+ Secondary modifier: `.btn--secondary { background: tokens.$color-bg-surface; color: tokens.$color-text-primary;
+ border-color: tokens.$color-border; } .btn--secondary:hover:not(:disabled) { background: tokens.$color-bg-elevated; }`.
+ Disabled state: `.btn--disabled, .btn:disabled { opacity: 0.5; cursor: not-allowed; }`.
+ Loading state: `.btn--loading { cursor: wait; }`.
+ Zero hard-coded color values allowed.
+
+**Checkpoint**: US1 complete — Button renders correctly, loading spinner appears and blocks
+interaction, `type="submit"` triggers form `(ngSubmit)`, AXE scan passes.
+
+---
+
+## Phase 4: User Story 2 — TextInput Component (Priority: P2)
+
+**Goal**: A standalone Reactive-Forms-compatible text input with an accessible ``,
+error message display with `aria-invalid`, a password show/hide toggle, and zero manual wiring
+required from consumers.
+
+**Dependencies**: Requires T001 (Phase 1) for path alias. Independent of US1 and US3.
+
+**Independent Test**: Create a `FormControl`, bind ` `, run `ng serve`. Confirm: label renders
+above input, typing updates `ctrl.value` in DevTools console, error message appears with red
+styling and `aria-invalid="true"`. With `type="password"`, confirm toggle reveals/masks text.
+AXE scan passes.
+
+- [x] T006 [P] [US2] Create `src/app/shared/ui/text-input/text-input.component.ts` — standalone
+ `TextInputComponent`, `changeDetection: ChangeDetectionStrategy.OnPush`, selector `app-text-input`,
+ `templateUrl: './text-input.component.html'`, `styleUrl: './text-input.component.scss'`,
+ `providers: [{ provide: NG_VALUE_ACCESSOR, useExisting: TextInputComponent, multi: true }]`,
+ `implements ControlValueAccessor`.
+ Signal inputs (use `input.required()` for label):
+ `readonly label = input.required()`,
+ `readonly type = input('text')`,
+ `readonly errorMessage = input('')`,
+ `readonly placeholder = input('')`.
+ Internal signals: `readonly value = signal('')`, `readonly showPassword = signal(false)`.
+ Computed: `readonly effectiveType = computed(() => this.type() === 'password' && this.showPassword() ? 'text' : this.type())`,
+ `readonly hasError = computed(() => this.errorMessage().length > 0)`.
+ Stable DOM id: `static #idCounter = 0; readonly inputId = \`text-input-\${++TextInputComponent.#idCounter}\``.
+ Private CVA callbacks (NOT signals): `#onChange: (value: string) => void = () => {}`,
+ `#onTouched: () => void = () => {}`.
+ CVA methods: `writeValue(value: string): void { this.value.set(value ?? ''); }`,
+ `registerOnChange(fn: (value: string) => void): void { this.#onChange = fn; }`,
+ `registerOnTouched(fn: () => void): void { this.#onTouched = fn; }`.
+ Protected handlers: `protected onInput(event: Event): void { const val = (event.target as HTMLInputElement).value; this.value.set(val); this.#onChange(val); }`,
+ `protected onBlur(): void { this.#onTouched(); }`,
+ `protected togglePasswordVisibility(): void { this.showPassword.update(v => !v); }`.
+ Imports from `@angular/core`: `ChangeDetectionStrategy`, `Component`, `computed`, `input`, `signal`.
+ Imports from `@angular/forms`: `ControlValueAccessor`, `NG_VALUE_ACCESSOR`.
+
+- [x] T007 [P] [US2] Create `src/app/shared/ui/text-input/text-input.component.html` — external
+ template. Structure:
+ ```html
+
+
{{ label() }}
+
+
+ @if (type() === 'password') {
+
+ @if (showPassword()) {
+ 🙈
+ } @else {
+ 👁
+ }
+
+ }
+
+ @if (hasError()) {
+
{{ errorMessage() }}
+ }
+
+ ```
+ CRITICAL: `[attr.aria-invalid]="hasError() ? 'true' : null"` must be on the ` `, not the host.
+ CRITICAL: `role="alert"` on the error span causes immediate screen reader announcement.
+ CRITICAL: `type="button"` on the toggle button prevents form submission when clicked.
+ CRITICAL: `aria-hidden="true"` on emoji spans — screen readers use the button's `aria-label` instead.
+
+- [x] T008 [P] [US2] Create `src/app/shared/ui/text-input/text-input.component.scss` — use
+ `@use 'tokens'`. BEM structure:
+ `.text-input { display: flex; flex-direction: column; gap: tokens.$spacing-1; }`.
+ `&__label { font-family: tokens.$font-family-base; font-size: tokens.$font-size-md;
+ font-weight: tokens.$font-weight-medium; color: tokens.$color-text-primary; }`.
+ `&__field-wrapper { position: relative; display: flex; align-items: center; }`.
+ `&__input { width: 100%; padding: tokens.$spacing-2 tokens.$spacing-3;
+ background: tokens.$color-bg-surface; border: 1px solid tokens.$color-border;
+ border-radius: tokens.$border-radius-md; font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-md; color: tokens.$color-text-primary; outline: none;
+ transition: border-color 0.15s ease; }`.
+ `&__input::placeholder { color: tokens.$color-text-secondary; }`.
+ `&__input:focus { border-color: tokens.$color-accent; }`.
+ `&__input[aria-invalid='true'] { border-color: #e53e3e; }` — NOTE: `#e53e3e` is an intentional
+ hard-coded exception. No `$color-error` token exists yet in `tokens.scss`. Add a comment:
+ `// TODO: replace with tokens.$color-error once that token is added`.
+ `&__toggle { position: absolute; right: tokens.$spacing-2; background: transparent; border: none;
+ cursor: pointer; padding: tokens.$spacing-1; color: tokens.$color-text-secondary; line-height: 1; }`.
+ `&__toggle:focus-visible { outline: 2px solid tokens.$color-accent; border-radius: tokens.$border-radius-sm; }`.
+ `&__error { font-family: tokens.$font-family-base; font-size: tokens.$font-size-sm;
+ color: #e53e3e; }` — same intentional exception as above.
+
+**Checkpoint**: US2 complete — TextInput binds to `[formControl]`, label is associated, error
+message appears with `aria-invalid`, password toggle works, AXE scan passes.
+
+---
+
+## Phase 5: Polish & Cross-Cutting Concerns
+
+**Purpose**: Barrel export, lint, production build verification.
+
+- [x] T009 Create `src/app/shared/ui/index.ts` — barrel file exporting all three components:
+ ```typescript
+ export { ButtonComponent } from './button/button.component';
+ export { TextInputComponent } from './text-input/text-input.component';
+ export { SpinnerComponent } from './spinner/spinner.component';
+ ```
+ This file is the only export surface consumers should import from (via `@app/shared/ui`).
+ No type-only exports needed — Angular component types are inferred from the class.
+
+- [x] T010 [P] Run `ng lint` from repo root — verify zero lint violations across all files
+ introduced by this feature: `tsconfig.json`, `spinner.component.ts`, `spinner.component.scss`,
+ `button.component.ts`, `button.component.scss`, `text-input.component.ts`,
+ `text-input.component.html`, `text-input.component.scss`, `index.ts`.
+ Fix any violations before marking complete (SC-005).
+
+- [x] T011 [P] Run `ng build --configuration production` from repo root — verify zero errors
+ and zero budget warnings. Confirm the three new component files are included in the production
+ bundle (visible as lazy chunks or in the stats output). Confirm the `@app/*` path alias
+ resolves correctly in the production build (SC-004).
+
+**Checkpoint**: All success criteria met — feature complete and production-build-clean.
+
+---
+
+## Dependencies & Execution Order
+
+### Phase Dependencies
+
+- **Phase 1 (Setup)**: No dependencies — start immediately
+- **Phase 2 (US3/Spinner)**: Depends on Phase 1; provides Spinner to Phase 3
+- **Phase 3 (US1/Button)**: Depends on Phase 2 (imports `SpinnerComponent`)
+- **Phase 4 (US2/TextInput)**: Depends on Phase 1 only — can overlap with Phases 2 and 3
+- **Phase 5 (Polish)**: Depends on Phases 2, 3, 4 all complete
+
+### User Story Dependency Conflict Resolution
+
+Button (US1, P1) cannot be built before Spinner (US3, P3) due to internal composition.
+The implementation order is therefore:
+
+```
+Phase 2: US3 (Spinner) ──► Phase 3: US1 (Button)
+Phase 1 ──► ──► Phase 5 (Polish)
+ Phase 4: US2 (TextInput, independent) ──►
+```
+
+### Within Each Phase
+
+- T002 + T003 (Spinner TS + SCSS): Different files — fully parallel
+- T004 + T005 (Button TS + SCSS): Different files — fully parallel (both need Spinner from Phase 2)
+- T006 + T007 + T008 (TextInput TS + HTML + SCSS): Different files — fully parallel
+- T009 (barrel): Depends on T002, T004, T006 (component classes must exist)
+- T010 + T011 (lint + build): Different operations — fully parallel, both need T009
+
+---
+
+## Parallel Execution Examples
+
+### Phase 2 — Spinner (2 files, launch simultaneously)
+
+```
+Task T002: "Create src/app/shared/ui/spinner/spinner.component.ts"
+Task T003: "Create src/app/shared/ui/spinner/spinner.component.scss"
+```
+
+### Phase 3 — Button (2 files, launch simultaneously after Phase 2)
+
+```
+Task T004: "Create src/app/shared/ui/button/button.component.ts"
+Task T005: "Create src/app/shared/ui/button/button.component.scss"
+```
+
+### Phase 4 — TextInput (3 files, launch simultaneously after Phase 1)
+
+```
+Task T006: "Create src/app/shared/ui/text-input/text-input.component.ts"
+Task T007: "Create src/app/shared/ui/text-input/text-input.component.html"
+Task T008: "Create src/app/shared/ui/text-input/text-input.component.scss"
+```
+
+### Phase 5 — Polish (after all components + barrel exist)
+
+```
+Task T010: "Run ng lint"
+Task T011: "Run ng build --configuration production"
+```
+
+---
+
+## Implementation Strategy
+
+### MVP First (User Story 1 — Button)
+
+1. Complete Phase 1: Setup (`tsconfig.json`)
+2. Complete Phase 2: Spinner (required by Button)
+3. Complete Phase 3: Button (P1 deliverable)
+4. **STOP and VALIDATE**: Confirm `` renders a
+ spinner, is non-interactive, and passes AXE.
+5. US1 is independently shippable as the MVP.
+
+### Incremental Delivery
+
+1. Setup → Path alias active
+2. US3 (Spinner) → Animated spinner + self-contained ARIA → **Unblocks Button**
+3. US1 (Button) → Primary/secondary, loading, disabled → **MVP — first shippable UI primitive**
+4. US2 (TextInput) → Reactive Forms integration, error display, password toggle → **Login form unblocked**
+5. Polish → Barrel export, lint, production build → **Branch ready to merge**
+
+### Parallel Team Strategy
+
+With two developers after Phase 1 + 2 complete:
+
+- Developer A: US1 — T004, T005 (Button)
+- Developer B: US2 — T006, T007, T008 (TextInput)
+- After both complete: T009 (barrel), then T010, T011 (lint + build)
+
+---
+
+## Notes
+
+- `[P]` tasks touch different files and have no shared in-flight dependencies
+- `[Story]` label maps each task to its user story for traceability and independent testing
+- Do NOT set `standalone: true` in component decorators — it is the Angular 21 default
+- Do NOT use `@Input`, `@Output`, or constructor injection — use `input()`, `output()`, `inject()`
+- `[disabled]="isDisabled() || null"` is intentional — `|| null` removes the attribute when false; binding to `false` would still add `disabled="false"` which browsers treat as disabled
+- `aria-hidden="true"` on `` inside Button is intentional — Button's `aria-busy="true"` handles the loading announcement; without this the screen reader would announce "Loading…" twice
+- The two `#e53e3e` hard-coded color values in `text-input.component.scss` are intentional exceptions — no `$color-error` token exists yet; they are commented with `TODO: replace with tokens.$color-error`
+- `input.required()` MUST be used for TextInput's `label` input — this enforces a compile-time error if a consumer omits the required label (critical for accessibility)
+- `static #idCounter = 0` in TextInputComponent is a class-level counter ensuring unique IDs per instance — resets between test suite runs
diff --git a/src/app/shared/ui/button/button.component.scss b/src/app/shared/ui/button/button.component.scss
new file mode 100644
index 0000000..484d73d
--- /dev/null
+++ b/src/app/shared/ui/button/button.component.scss
@@ -0,0 +1,54 @@
+@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: 3px solid tokens.$color-accent;
+ outline-offset: 3px;
+ box-shadow: 0 0 0 5px tokens.$color-bg-elevated;
+ }
+
+ &--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;
+ }
+}
diff --git a/src/app/shared/ui/button/button.component.ts b/src/app/shared/ui/button/button.component.ts
new file mode 100644
index 0000000..3eb99da
--- /dev/null
+++ b/src/app/shared/ui/button/button.component.ts
@@ -0,0 +1,30 @@
+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: `
+
+ @if (loading()) {
+
+ }
+
+
+ `,
+})
+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());
+}
diff --git a/src/app/shared/ui/index.ts b/src/app/shared/ui/index.ts
new file mode 100644
index 0000000..13d8b15
--- /dev/null
+++ b/src/app/shared/ui/index.ts
@@ -0,0 +1,3 @@
+export { ButtonComponent } from './button/button.component';
+export { TextInputComponent } from './text-input/text-input.component';
+export { SpinnerComponent } from './spinner/spinner.component';
diff --git a/src/app/shared/ui/spinner/spinner.component.scss b/src/app/shared/ui/spinner/spinner.component.scss
new file mode 100644
index 0000000..daee4a5
--- /dev/null
+++ b/src/app/shared/ui/spinner/spinner.component.scss
@@ -0,0 +1,36 @@
+@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);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .spinner__ring {
+ animation: none;
+ }
+}
diff --git a/src/app/shared/ui/spinner/spinner.component.ts b/src/app/shared/ui/spinner/spinner.component.ts
new file mode 100644
index 0000000..e7ec799
--- /dev/null
+++ b/src/app/shared/ui/spinner/spinner.component.ts
@@ -0,0 +1,21 @@
+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…');
+ readonly size = input<'sm' | 'md'>('md');
+}
diff --git a/src/app/shared/ui/text-input/text-input.component.html b/src/app/shared/ui/text-input/text-input.component.html
new file mode 100644
index 0000000..5e18c83
--- /dev/null
+++ b/src/app/shared/ui/text-input/text-input.component.html
@@ -0,0 +1,40 @@
+
+
{{ label() }}
+
+
+
+
+ @if (type() === 'password') {
+
+ @if (showPassword()) {
+ 🙈
+ } @else {
+ 👁
+ }
+
+ }
+
+
+ @if (hasError()) {
+
{{ errorMessage() }}
+ }
+
diff --git a/src/app/shared/ui/text-input/text-input.component.scss b/src/app/shared/ui/text-input/text-input.component.scss
new file mode 100644
index 0000000..f6b9747
--- /dev/null
+++ b/src/app/shared/ui/text-input/text-input.component.scss
@@ -0,0 +1,69 @@
+@use 'tokens';
+
+.text-input {
+ display: flex;
+ flex-direction: column;
+ gap: tokens.$spacing-1;
+
+ &__label {
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-md;
+ font-weight: tokens.$font-weight-medium;
+ color: tokens.$color-text-primary;
+ }
+
+ &__field-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+ }
+
+ &__input {
+ width: 100%;
+ padding: tokens.$spacing-2 tokens.$spacing-3;
+ background: tokens.$color-bg-surface;
+ border: 1px solid tokens.$color-border;
+ border-radius: tokens.$border-radius-md;
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-md;
+ color: tokens.$color-text-primary;
+ outline: none;
+ transition: border-color 0.15s ease;
+
+ &::placeholder {
+ color: tokens.$color-text-secondary;
+ }
+
+ &:focus-visible {
+ border-color: tokens.$color-accent;
+ outline: 2px solid tokens.$color-accent;
+ outline-offset: 2px;
+ }
+
+ &[aria-invalid='true'] {
+ border-color: tokens.$color-error;
+ }
+ }
+
+ &__toggle {
+ position: absolute;
+ right: tokens.$spacing-2;
+ background: transparent;
+ border: none;
+ cursor: pointer;
+ padding: tokens.$spacing-1;
+ color: tokens.$color-text-secondary;
+ line-height: 1;
+
+ &:focus-visible {
+ outline: 2px solid tokens.$color-accent;
+ border-radius: tokens.$border-radius-sm;
+ }
+ }
+
+ &__error {
+ font-family: tokens.$font-family-base;
+ font-size: tokens.$font-size-sm;
+ color: tokens.$color-error;
+ }
+}
diff --git a/src/app/shared/ui/text-input/text-input.component.ts b/src/app/shared/ui/text-input/text-input.component.ts
new file mode 100644
index 0000000..252f01c
--- /dev/null
+++ b/src/app/shared/ui/text-input/text-input.component.ts
@@ -0,0 +1,64 @@
+import { ChangeDetectionStrategy, Component, computed, forwardRef, 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: forwardRef(() => TextInputComponent),
+ multi: true,
+ },
+ ],
+})
+export class TextInputComponent implements ControlValueAccessor {
+ readonly label = input.required();
+ readonly type = input('text');
+ readonly errorMessage = input('');
+ readonly placeholder = input('');
+
+ readonly value = signal('');
+ readonly showPassword = signal(false);
+
+ readonly effectiveType = computed(() =>
+ this.type() === 'password' && this.showPassword() ? 'text' : this.type(),
+ );
+ readonly hasError = computed(() => this.errorMessage().length > 0);
+
+ static #idCounter = 0;
+ readonly inputId = `text-input-${++TextInputComponent.#idCounter}`;
+
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ #onChange: (value: string) => void = () => {};
+ // eslint-disable-next-line @typescript-eslint/no-empty-function
+ #onTouched: () => void = () => {};
+
+ writeValue(value: string): void {
+ this.value.set(value ?? '');
+ }
+
+ registerOnChange(fn: (value: string) => void): void {
+ this.#onChange = fn;
+ }
+
+ registerOnTouched(fn: () => void): void {
+ this.#onTouched = fn;
+ }
+
+ protected onInput(event: Event): void {
+ const val = (event.target as HTMLInputElement).value;
+ this.value.set(val);
+ this.#onChange(val);
+ }
+
+ protected onBlur(): void {
+ this.#onTouched();
+ }
+
+ protected togglePasswordVisibility(): void {
+ this.showPassword.update((v) => !v);
+ }
+}
diff --git a/src/styles/tokens.scss b/src/styles/tokens.scss
index e684ebf..5c0d2d6 100644
--- a/src/styles/tokens.scss
+++ b/src/styles/tokens.scss
@@ -6,6 +6,7 @@ $color-text-primary: #ececec;
$color-text-secondary: #8e8ea0;
$color-text-disabled: #565869;
$color-accent: #004068;
+$color-error: #e53e3e;
$color-border: #2e2e2e;
// Border radius
diff --git a/tsconfig.json b/tsconfig.json
index 2ab7442..ff40be9 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -10,10 +10,13 @@
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
- "experimentalDecorators": true,
"importHelpers": true,
"target": "ES2022",
- "module": "preserve"
+ "module": "preserve",
+ "baseUrl": ".",
+ "paths": {
+ "@app/*": ["src/app/*"]
+ }
},
"angularCompilerOptions": {
"enableI18nLegacyMessageIdFormat": false,