Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .specify/feature.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"feature_directory": "specs/001-auth-setup"
"feature_directory": "specs/002-shared-ui-primitives"
}
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<!-- SPECKIT START -->
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`.
<!-- SPECKIT END -->
42 changes: 42 additions & 0 deletions specs/002-shared-ui-primitives/checklists/requirements.md
Original file line number Diff line number Diff line change
@@ -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.
110 changes: 110 additions & 0 deletions specs/002-shared-ui-primitives/contracts/button.md
Original file line number Diff line number Diff line change
@@ -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
<!-- Primary action (default) -->
<app-button (click)="submit()">Sign In</app-button>

<!-- Form submit button (triggers form's ngSubmit on Enter) -->
<app-button type="submit" [loading]="isSubmitting()">Sign In</app-button>

<!-- Secondary action -->
<app-button variant="secondary" (click)="cancel()">Cancel</app-button>

<!-- Disabled state -->
<app-button [disabled]="!form.valid">Sign In</app-button>

<!-- Loading state (during async operation) -->
<app-button [loading]="isLoading()">Sign In</app-button>
```

## 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 `<button>`, and sets `aria-busy="true"`. Click events are suppressed. |
| `disabled` | `boolean` | `false` | When `true`: sets native `disabled` on the `<button>`. Click events are suppressed. No spinner is shown. |

## Output API

None. Consumers bind `(click)` on the host element directly.

## Slots (Content Projection)

| Slot | Description |
|------|-------------|
| Default `<ng-content>` | Button label text. May be plain text or inline elements. MUST contain visible text for accessibility. |

## CSS Classes Applied to Internal `<button>` Element

| Class | Condition |
|-------|-----------|
| `btn` | Always |
| `btn--primary` | When `variant() === 'primary'` |
| `btn--secondary` | When `variant() === 'secondary'` |
| `btn--loading` | When `loading() === true` |
| `btn--disabled` | When `isDisabled() === true` |

## Accessibility Contract

| Attribute | Value | Condition |
|-----------|-------|-----------|
| `type` | `"button"` or `"submit"` | Always (from `type` input) |
| `disabled` | present | When `loading() || disabled()` |
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Rewrite the disabled contract row so the Markdown table stays valid.

The || inside the code span is being parsed as extra table separators, which is why markdownlint reports the row as having too many columns. Rephrase it to avoid pipe characters in the cell.

📝 Proposed wording fix
-| `disabled` | present | When `loading() || disabled()` |
+| `disabled` | present | When `loading` or `disabled` is true |
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
| `disabled` | present | When `loading() || disabled()` |
| `disabled` | present | When `loading` or `disabled` is true |
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 62-62: Table column count
Expected: 3; Actual: 5; Too many cells, extra data will be missing

(MD056, table-column-count)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/002-shared-ui-primitives/contracts/button.md` at line 62, The table row
for the `disabled` contract is breaking Markdown because the code span contains
a pipe character; update the cell to avoid '||' — e.g. replace the cell content
"When `loading() || disabled()`" with a Markdown-safe phrase such as "When
loading() or disabled()" or "When `loading()` or `disabled()`" so the `disabled`
row (the `disabled` contract) no longer contains an unescaped pipe and the table
remains valid.

| `aria-busy` | `"true"` | When `loading() === true` |
| Focus ring | visible, ≥ 3:1 contrast | When focused via keyboard |
| Internal Spinner | `aria-hidden="true"` | When `loading() === true` — the button's `aria-busy` already announces loading state |

## Token Usage

| Token | Applied To |
|-------|-----------|
| `$color-accent` | Primary button background |
| `$color-bg-surface` | Secondary button background |
| `$color-text-primary` | Button text color |
| `$color-border` | Secondary button border |
| `$border-radius-md` | Button border radius |
| `$spacing-3` (`12px`) | Vertical padding |
| `$spacing-4` (`16px`) | Horizontal padding |
| `$font-size-md` | Button label font size |
| `$font-weight-medium` | Button label font weight |

## Behavior Contracts

### Loading state
1. When `[loading]="true"` is set, the `<button>` receives `disabled` attribute and `aria-busy="true"`.
2. A `<app-spinner size="sm" aria-hidden="true">` appears inside the button, before the projected content.
3. Clicking the button while loading has no effect (native `disabled` blocks events).
4. When `[loading]` returns to `false`, `disabled` and `aria-busy` are removed; the Spinner disappears.

### Disabled vs Loading precedence
- If both `[disabled]="true"` AND `[loading]="true"`, the button is `disabled`; the Spinner is shown (loading visual takes precedence).

### Type="submit" behavior
- When `type="submit"`, clicking the button (or pressing Enter while any form control inside the `<form>` is focused) submits the form and triggers the form's `(ngSubmit)` handler.
- `type="submit"` combined with `[loading]="true"` is safe: the `disabled` attribute prevents form re-submission while the operation is in flight.

## Import

```typescript
import { ButtonComponent } from '@app/shared/ui';
// or
import { ButtonComponent } from '../../../shared/ui';
```

## File Structure

```
src/app/shared/ui/button/
├── button.component.ts # Component class + template (inline)
└── button.component.scss # Component styles
```
98 changes: 98 additions & 0 deletions specs/002-shared-ui-primitives/contracts/spinner.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Contract: SpinnerComponent

**Selector**: `app-spinner`
**File**: `src/app/shared/ui/spinner/spinner.component.ts`
**Barrel**: `src/app/shared/ui/index.ts`

---

## Usage

```html
<!-- Default (standalone page-level loading) -->
<app-spinner />

<!-- Custom accessible label -->
<app-spinner label="Loading chat history…" />

<!-- Small (used inside Button — consumers rarely set this directly) -->
<app-spinner size="sm" />

<!-- Conditionally shown while data loads -->
@if (isLoading()) {
<app-spinner label="Loading messages…" />
}
```

## Input API

| Input | Type | Default | Description |
|-------|------|---------|-------------|
| `label` | `string` | `'Loading…'` | Accessible label text set on `aria-label` of the `role="status"` element. Screen readers announce this text when the Spinner is inserted into the DOM (via `aria-live="polite"` implied by `role="status"`). |
| `size` | `'sm' \| 'md'` | `'md'` | Visual size. `'sm'` = 16×16 px (used internally by Button). `'md'` = 24×24 px (standalone). |

## Output API

None. Spinner is stateless; it emits no events.

## Visual Specification

| Size | Width | Height | Ring border-width | Animation |
|------|-------|--------|------------------|-----------|
| `sm` | 16px | 16px | 2px | 0.75s linear spin |
| `md` | 24px | 24px | 2px | 0.75s linear spin |

Ring colors:
- Track (rest of ring): `$color-bg-elevated`
- Active arc (top segment): `$color-accent`
- Animation: `transform: rotate(0 → 360deg)`, GPU-composited, no layout/paint

## Accessibility Contract

| Element | Role / Attribute | Value |
|---------|-----------------|-------|
| Outer `<span>` | `role="status"` | Implies `aria-live="polite"` — announces when inserted |
| Outer `<span>` | `aria-label` | Value of `label()` input |
| Inner `<span>` (ring) | `aria-hidden` | `"true"` — purely decorative |

**When conditionally rendered** (`@if`): As soon as the `<app-spinner>` is inserted into the
DOM, its `role="status"` causes screen readers to queue an announcement of the `aria-label`
value after the current utterance completes.

**When used inside Button**: Button sets `aria-hidden="true"` on the `<app-spinner>` host
element so the Spinner does not announce. The `<button aria-busy="true">` handles the
loading announcement instead.

## Token Usage

| Token | Applied To |
|-------|-----------|
| `$color-bg-elevated` | Spinner ring track (inactive arc) |
| `$color-accent` | Spinner ring active arc (top) |

Spinner uses no spacing, typography, or border-radius tokens (the ring shape is defined by
the circular `border-radius: 50%` and fixed pixel dimensions).

## File Structure

```
src/app/shared/ui/spinner/
├── spinner.component.ts # Component class (inline template)
└── spinner.component.scss # Component styles
```

Spinner uses an inline template because the template is minimal (2 elements).

## Composition Note

`ButtonComponent` imports `SpinnerComponent` and renders it internally:

```html
<!-- Inside button.component.html -->
@if (loading()) {
<app-spinner size="sm" label="Loading…" aria-hidden="true" />
}
```
Comment on lines +78 to +95
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tag the fenced examples with a language.

Both fenced blocks are missing a language identifier, which will keep markdownlint failing here. Use text for these examples so the contract stays lint-clean.

Suggested fix
-```
+```text
 src/app/shared/ui/spinner/
 ├── spinner.component.ts        # Component class (inline template)
 └── spinner.component.scss      # Component styles
-```
+```

-```
+```text
 <!-- Inside button.component.html -->
 `@if` (loading()) {
   <app-spinner size="sm" label="Loading…" aria-hidden="true" />
 }
-```
+```
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
```
src/app/shared/ui/spinner/
├── spinner.component.ts # Component class (inline template)
└── spinner.component.scss # Component styles
```
Spinner uses an inline template because the template is minimal (2 elements).
## Composition Note
`ButtonComponent` imports `SpinnerComponent` and renders it internally:
```html
<!-- Inside button.component.html -->
@if (loading()) {
<app-spinner size="sm" label="Loading…" aria-hidden="true" />
}
```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 78-78: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/002-shared-ui-primitives/contracts/spinner.md` around lines 78 - 95,
The two fenced code blocks showing the spinner directory listing and the
ButtonComponent snippet are missing a language tag; update the markdown examples
to use a language identifier (use "text") for the directory block that
references spinner.component.ts and spinner.component.scss and for the HTML
snippet that demonstrates the ButtonComponent usage (the block showing the `@if`
(loading()) { <app-spinner ... /> } inside button.component.html) so
markdownlint stops flagging them.


This is the only internal composition relationship among the three primitives.
`SpinnerComponent` MUST be implemented before `ButtonComponent`.
125 changes: 125 additions & 0 deletions specs/002-shared-ui-primitives/contracts/text-input.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
# Contract: TextInputComponent

**Selector**: `app-text-input`
**File**: `src/app/shared/ui/text-input/text-input.component.ts`
**Barrel**: `src/app/shared/ui/index.ts`
**Implements**: `ControlValueAccessor`

---

## Usage

```typescript
// In the consuming component:
readonly emailCtrl = new FormControl('', [Validators.required, Validators.email]);
readonly passwordCtrl = new FormControl('', [Validators.required, Validators.minLength(8)]);
```

```html
<!-- Email field -->
<app-text-input
label="Email"
type="email"
placeholder="you@example.com"
[formControl]="emailCtrl"
[errorMessage]="emailCtrl.errors?.['required'] ? 'Email is required' : ''"
/>

<!-- Password field with show/hide toggle -->
<app-text-input
label="Password"
type="password"
[formControl]="passwordCtrl"
[errorMessage]="passwordCtrl.touched && passwordCtrl.invalid ? 'Password must be 8+ characters' : ''"
/>

<!-- Plain text without form control (uncontrolled) -->
<app-text-input label="Search" placeholder="Type to search…" />
```

## Input API

| Input | Type | Default | Required | Description |
|-------|------|---------|----------|-------------|
| `label` | `string` | — | **Yes** | Visible label text above the input. Also used as the programmatic accessible name via the associated `<label>` element. |
| `type` | `string` | `'text'` | No | HTML input type. Supports all native values (`'text'`, `'email'`, `'password'`, `'search'`, etc.). When `'password'`, a show/hide toggle button appears. |
| `errorMessage` | `string` | `''` | No | Error message displayed below the input. When non-empty: error text renders, `aria-invalid="true"` is set on the `<input>`, and `aria-describedby` links to the error element. When empty or `undefined`: no error container is rendered (prevents CLS). |
| `placeholder` | `string` | `''` | No | Native `placeholder` attribute forwarded to the `<input>` element. |

## Reactive Forms Integration

`TextInputComponent` implements Angular's `ControlValueAccessor`. It is usable with
any directive that provides `NG_VALUE_ACCESSOR`:

- `[formControl]="ctrl"` — bind to a standalone `FormControl`
- `[formControlName]="'fieldName'"` — bind inside a `[formGroup]`
- `[(ngModel)]="value"` — bind via template-driven forms (not recommended per constitution)

**No manual wiring required.** The component registers itself via `NG_VALUE_ACCESSOR` in its
`providers` array.

## Output API

None. The component pushes value changes to the bound `FormControl` via CVA callbacks.

## Accessibility Contract

| Attribute / Element | Value | Condition |
|--------------------|-------|-----------|
| `<label for="inputId">` | `label()` | Always |
| `<input id="inputId">` | — | Always — stable unique id per instance |
| `aria-invalid` on `<input>` | `"true"` | When `errorMessage()` is non-empty |
| `aria-describedby` on `<input>` | `"${inputId}-error"` | When `errorMessage()` is non-empty |
| `<span id="${inputId}-error" role="alert">` | `errorMessage()` | When `errorMessage()` is non-empty |
| Toggle button `aria-label` | `"Show password"` / `"Hide password"` | When `type === 'password'` |
| Toggle button `type` | `"button"` | Always (prevents form submission) |
| Focus visible | ring, ≥ 3:1 contrast | When `<input>` is focused via keyboard |

## Password Toggle Behavior

When `type="password"`:
1. A toggle button appears at the right edge of the input wrapper.
2. The toggle button has `type="button"` (never submits the form).
3. `aria-label` alternates between `"Show password"` and `"Hide password"` based on
current toggle state.
4. Activating the toggle switches the `<input type>` between `"password"` and `"text"`.
5. The input value is preserved across toggles (no value reset).

## Token Usage

| Token | Applied To |
|-------|-----------|
| `$color-text-primary` | Label text, input text |
| `$color-text-secondary` | Placeholder text |
| `$color-text-disabled` | Disabled input text |
| `$color-bg-surface` | Input background |
| `$color-border` | Input border (default state) |
| `$color-accent` | Input border (focus state) |
| `$border-radius-md` | Input border radius |
| `$spacing-2` (`8px`) | Input vertical padding |
| `$spacing-3` (`12px`) | Input horizontal padding |
| `$spacing-1` (`4px`) | Gap between label and input, error and input |
| `$font-size-sm` | Error message font size |
| `$font-size-md` | Input and label font size |

## Error rendering rule

```
errorMessage() === '' or undefined → @if block not rendered (zero height, zero CLS)
errorMessage() !== '' → <span role="alert"> renders with error text
```

The error span has `role="alert"` so that when it is inserted into the DOM (which happens
when `errorMessage` changes from empty to non-empty), screen readers announce it immediately
(`aria-live="assertive"` implied by `role="alert"`).

## File Structure

```
src/app/shared/ui/text-input/
├── text-input.component.ts # Component class + template (external)
├── text-input.component.html # External template
└── text-input.component.scss # Component styles
```
Comment on lines +107 to +123
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Tag the fenced examples with a language.

These blocks will keep tripping the markdownlint fenced-code-language rule. Add a language identifier such as text so the docs stay lint-clean.

Suggested fix
-```
+```text
 errorMessage() === '' or undefined  →  `@if` block not rendered (zero height, zero CLS)
 errorMessage() !== ''               →  <span role="alert"> renders with error text
-```
+```

-```
+```text
 src/app/shared/ui/text-input/
 ├── text-input.component.ts        # Component class + template (external)
 ├── text-input.component.html      # External template
 └── text-input.component.scss      # Component styles
-```
+```
🧰 Tools
🪛 markdownlint-cli2 (0.22.1)

[warning] 107-107: Fenced code blocks should have a language specified

(MD040, fenced-code-language)


[warning] 118-118: Fenced code blocks should have a language specified

(MD040, fenced-code-language)

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@specs/002-shared-ui-primitives/contracts/text-input.md` around lines 107 -
123, The markdown examples in the documentation use fenced code blocks without a
language tag which triggers markdownlint; update the two fenced blocks showing
the errorMessage behavior and the File Structure to include a language
identifier (e.g., ```text) so the linter stops flagging them—edit the examples
in text-input.md where the blocks contain "errorMessage() === '' ..." and the
tree listing "src/app/shared/ui/text-input/ ..." and change the opening
backticks to ```text for each block.


Note: TextInput uses an external template (not inline) because the template is non-trivial.
Loading