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
111 changes: 111 additions & 0 deletions specs/app.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
---
module: app
version: 1
status: active
files:
- src/app/app.ts
- src/app/app.html
- src/app/app.scss
- src/app/app.routes.ts
- src/app/app.config.ts
depends_on:
- shell
- welcome
- spec-store-service
---

# App

## Purpose

Root application module providing bootstrap configuration and route definitions. The `App` component is a minimal shell that renders a `<router-outlet>` and triggers initial data loading via `SpecStoreService.loadAll()` on init. Routes are defined in `app.routes.ts` and application-level providers in `app.config.ts`.

## Public API

### Exported Classes

| Class | Description |
|-------|-------------|
| `App` | Root component — renders `<router-outlet>`, loads all specs on init |

### Exported Constants

| Constant | Type | Description |
|----------|------|-------------|
| `routes` | `Routes` | Application route configuration array |
| `appConfig` | `ApplicationConfig` | Provider configuration for `bootstrapApplication()` |

### Route Configuration

| Path | Component | Loading | Description |
|------|-----------|---------|-------------|
| `''` | `ShellComponent` | Eager | Root layout wrapper with sidebar and content area |
| `'' (child)` | `WelcomeComponent` | Lazy | Default landing page (no spec selected) |
| `'edit/:id' (child)` | `EditorPageComponent` | Lazy | Spec editor page for a given spec ID |

## Invariants

1. `App.ngOnInit()` calls `store.loadAll()` to hydrate all specs from IndexedDB before the user can interact
2. `ShellComponent` is eagerly loaded as the root route and wraps all child routes
3. `WelcomeComponent` and `EditorPageComponent` are lazy-loaded via dynamic `import()`
4. `appConfig` provides `provideRouter(routes)` and `provideBrowserGlobalErrorListeners()` — no other providers
5. The root component fills the full viewport height (`height: 100vh`)
6. All routes are children of the `ShellComponent` route, ensuring the sidebar is always present

## Behavioral Examples

### Scenario: Application bootstrap

- **Given** the app starts
- **When** `App.ngOnInit()` runs
- **Then** `store.loadAll()` is called to load all specs from IndexedDB

### Scenario: Navigate to root

- **Given** the app has bootstrapped
- **When** the URL is `/`
- **Then** `ShellComponent` renders with `WelcomeComponent` in its `<router-outlet>`

### Scenario: Navigate to editor

- **Given** the app has bootstrapped
- **When** the URL is `/edit/42`
- **Then** `ShellComponent` renders with `EditorPageComponent` in its `<router-outlet>`, loading spec ID 42

### Scenario: Unknown route

- **Given** the URL is `/nonexistent`
- **When** Angular evaluates routes
- **Then** no route matches (no wildcard/redirect configured)

## Error Cases

| Condition | Behavior |
|-----------|----------|
| `store.loadAll()` rejects | Error propagates unhandled (no try/catch in `ngOnInit`) |
| Unknown route path | Angular's default behavior — no route match, outlet remains empty |

## Dependencies

### Consumes

| Module | What is used |
|--------|-------------|
| `shell` | `ShellComponent` — root route layout |
| `welcome` | `WelcomeComponent` — lazy-loaded default child route |
| `editor-page` | `EditorPageComponent` — lazy-loaded editor child route |
| `spec-store-service` | `SpecStoreService.loadAll()` — initial data hydration |
| `@angular/router` | `provideRouter`, `RouterOutlet`, `Routes` |
| `@angular/core` | `ApplicationConfig`, `provideBrowserGlobalErrorListeners` |

### Consumed By

| Module | What is used |
|--------|-------------|
| `main.ts` | `bootstrapApplication(App, appConfig)` |

## Change Log

| Date | Author | Change |
|------|--------|--------|
| 2026-03-01 | CorvidAgent | Initial spec |
118 changes: 118 additions & 0 deletions specs/components/markdown-editor.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
---
module: markdown-editor
version: 1
status: active
files:
- src/app/components/markdown-editor/markdown-editor.ts
- src/app/components/markdown-editor/markdown-editor.html
- src/app/components/markdown-editor/markdown-editor.scss
depends_on:
- spec-models
---

# Markdown Editor

## Purpose

A thin wrapper around CodeMirror 6 that provides a full-document markdown editing experience. Accepts content as an input signal, creates a CodeMirror `EditorView` on first render, and keeps the editor in sync with external content changes. Emits content changes on user edits. Manages the `EditorView` lifecycle — creates it lazily in an effect and destroys it on component teardown.

## Public API

### Exported Classes

| Class | Description |
|-------|-------------|
| `MarkdownEditorComponent` | Angular standalone component wrapping CodeMirror 6 |

### Component Inputs

| Input | Type | Required | Description |
|-------|------|----------|-------------|
| `content` | `string` | Yes | The markdown content to display in the editor |

### Component Outputs

| Output | Type | Description |
|--------|------|-------------|
| `contentChange` | `string` | Emitted with the full document text when the user edits content |

## Invariants

1. The `EditorView` is created lazily — only when the effect first runs and the host element is available
2. Once created, the `EditorView` is reused; content changes dispatch transactions rather than recreating the editor
3. A `suppressUpdate` flag prevents feedback loops: when the user types, `contentChange` emits, which may cause the parent to update the `content` input, which would dispatch back into the editor — the flag breaks this cycle
4. External content updates are only dispatched if the new value differs from `EditorView.state.doc.toString()`
5. `ngOnDestroy` calls `view.destroy()` to clean up the CodeMirror instance
6. The editor includes: line numbers, active line highlighting, line wrapping, undo/redo history, markdown syntax highlighting, One Dark theme
7. The editor container has `aria-label="Markdown editor"` for accessibility
8. The editor fills 100% of its host's height via CSS and a custom CodeMirror theme extension

## Behavioral Examples

### Scenario: Initial render creates editor

- **Given** the component mounts with `content` set to `"# Hello"`
- **When** the effect runs
- **Then** a CodeMirror `EditorView` is created with `"# Hello"` as the document and attached to the host `<div>`

### Scenario: User types in editor

- **Given** the editor is initialized with some content
- **When** the user types additional text
- **Then** the `updateListener` fires, sets `suppressUpdate = true`, and emits `contentChange` with the full document text

### Scenario: External content update syncs to editor

- **Given** the editor shows `"# Hello"`
- **When** the parent changes the `content` input to `"# World"`
- **Then** the effect dispatches a transaction replacing the full document, and the editor shows `"# World"`

### Scenario: Identical external update is a no-op

- **Given** the editor shows `"# Hello"`
- **When** the parent sets `content` to `"# Hello"` (same value)
- **Then** no transaction is dispatched

### Scenario: Suppress flag prevents echo

- **Given** the user just typed, setting `suppressUpdate = true`
- **When** the effect runs from the resulting `content` input change
- **Then** the effect skips the dispatch and resets `suppressUpdate` to `false`

### Scenario: Component destroyed

- **Given** the editor is active
- **When** the component is destroyed
- **Then** `ngOnDestroy` calls `view.destroy()` and the CodeMirror instance is cleaned up

## Error Cases

| Condition | Behavior |
|-----------|----------|
| Host element not yet in DOM | Effect runs but `viewChild.required` guarantees availability; Angular throws if missing |
| Component destroyed before editor created | `ngOnDestroy` safely calls `this.view?.destroy()` (no-op if null) |

## Dependencies

### Consumes

| Module | What is used |
|--------|-------------|
| `@codemirror/state` | `EditorState` |
| `@codemirror/view` | `EditorView`, `keymap`, `lineNumbers`, `highlightActiveLine` |
| `@codemirror/commands` | `defaultKeymap`, `history`, `historyKeymap` |
| `@codemirror/lang-markdown` | `markdown` language support |
| `@codemirror/theme-one-dark` | `oneDark` theme |
| `@codemirror/language` | `syntaxHighlighting`, `defaultHighlightStyle` |

### Consumed By

| Module | What is used |
|--------|-------------|
| `editor-page` | Used for full-document markdown editing mode |

## Change Log

| Date | Author | Change |
|------|--------|--------|
| 2026-03-01 | CorvidAgent | Initial spec |
125 changes: 125 additions & 0 deletions specs/components/shell.spec.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
---
module: shell
version: 1
status: active
files:
- src/app/components/shell/shell.ts
- src/app/components/shell/shell.html
- src/app/components/shell/shell.scss
depends_on:
- spec-list
---

# Shell

## Purpose

Provides the root application layout: a responsive sidebar for spec navigation and a main content area rendered via `<router-outlet>`. On desktop (≥768 px) the sidebar is always visible. On mobile (<768 px) it becomes a full-screen overlay with dialog semantics, backdrop click-to-dismiss, Escape key handling, and focus management.

## Public API

### Exported Classes

| Class | Description |
|-------|-------------|
| `ShellComponent` | Angular standalone component — root layout shell |

### Signals

| Signal | Type | Description |
|--------|------|-------------|
| `sidebarOpen` | `WritableSignal<boolean>` | Whether the sidebar is visible (default `true`) |
| `isMobile` | `WritableSignal<boolean>` | `true` when `window.innerWidth < 768` |
| `isDialog` | `Signal<boolean>` | Computed: `isMobile() && sidebarOpen()` |

### Methods

| Method | Description |
|--------|-------------|
| `toggleSidebar()` | Toggles `sidebarOpen` signal |
| `onResize()` | `@HostListener('window:resize')` — updates `isMobile` |
| `onEscape()` | `@HostListener('keydown.escape')` — closes sidebar when `isDialog()` is true |

## Invariants

1. The sidebar has `role="dialog"` and `aria-modal="true"` when `isDialog()` is true; otherwise `role="complementary"` and no `aria-modal`
2. The main content area gets `inert` attribute when the sidebar is in dialog mode, preventing interaction behind the overlay
3. On `NavigationEnd`, the sidebar auto-closes only when `window.innerWidth < 768`
4. Escape key closes the sidebar only when it is in dialog mode (mobile + open)
5. When the sidebar opens on mobile, focus moves to the close button; when it closes, focus returns to the previously focused element
6. The mobile breakpoint is 768 px — values below are mobile, values at or above are desktop
7. The mobile menu button's `aria-expanded` reflects the current `sidebarOpen` state
8. Backdrop click and close button click both call `toggleSidebar()`

## Behavioral Examples

### Scenario: Desktop layout — sidebar always visible

- **Given** the viewport width is ≥768 px
- **When** the shell renders
- **Then** the sidebar is visible with `role="complementary"` and the main content is interactive (no `inert`)

### Scenario: Mobile sidebar opens as dialog

- **Given** the viewport width is <768 px and the sidebar is closed
- **When** the user taps the hamburger menu button
- **Then** the sidebar opens with `role="dialog"`, `aria-modal="true"`, and main content gets `inert`

### Scenario: Navigation auto-closes sidebar on mobile

- **Given** the viewport width is <768 px and the sidebar is open
- **When** a `NavigationEnd` event fires (user selected a spec)
- **Then** the sidebar closes automatically

### Scenario: Navigation keeps sidebar on desktop

- **Given** the viewport width is ≥768 px and the sidebar is open
- **When** a `NavigationEnd` event fires
- **Then** the sidebar remains open

### Scenario: Escape closes mobile dialog

- **Given** `isDialog()` is true (mobile + sidebar open)
- **When** the user presses Escape
- **Then** the sidebar closes

### Scenario: Escape does nothing on desktop

- **Given** the viewport is desktop width and the sidebar is open
- **When** the user presses Escape
- **Then** the sidebar remains open

### Scenario: Backdrop click closes sidebar

- **Given** the sidebar is open
- **When** the user clicks the backdrop overlay
- **Then** `toggleSidebar()` is called and the sidebar closes

## Error Cases

| Condition | Behavior |
|-----------|----------|
| Close button not found in DOM | Focus management silently skips (optional chaining on `closeBtn?.focus()`) |
| No previously focused element | Focus restoration is skipped (`previousFocus` is null) |

## Dependencies

### Consumes

| Module | What is used |
|--------|-------------|
| `spec-list` | `SpecListComponent` — rendered inside the sidebar |
| `@angular/router` | `RouterOutlet`, `Router`, `NavigationEnd` |
| `@angular/core` | `signal`, `computed`, `effect`, `HostListener`, `ViewChild`, `ElementRef` |

### Consumed By

| Module | What is used |
|--------|-------------|
| `app.routes` | Root route component wrapping all child routes |

## Change Log

| Date | Author | Change |
|------|--------|--------|
| 2026-03-01 | CorvidAgent | Initial spec |
Loading