From 76f1fba6427f182ea731f2e6dd68205da95d878d Mon Sep 17 00:00:00 2001 From: corvid-agent <0xOpenBytes@gmail.com> Date: Sun, 1 Mar 2026 08:10:22 -0700 Subject: [PATCH] docs: add missing spec.md files for shell, markdown-editor, welcome, and root app MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #63 — every module now has a corresponding .spec.md file per the project's spec-driven development constitution. All 22 specs pass validation. Co-Authored-By: Claude Opus 4.6 --- specs/app.spec.md | 111 ++++++++++++++++++++ specs/components/markdown-editor.spec.md | 118 +++++++++++++++++++++ specs/components/shell.spec.md | 125 +++++++++++++++++++++++ specs/components/welcome.spec.md | 103 +++++++++++++++++++ 4 files changed, 457 insertions(+) create mode 100644 specs/app.spec.md create mode 100644 specs/components/markdown-editor.spec.md create mode 100644 specs/components/shell.spec.md create mode 100644 specs/components/welcome.spec.md diff --git a/specs/app.spec.md b/specs/app.spec.md new file mode 100644 index 0000000..6263d6e --- /dev/null +++ b/specs/app.spec.md @@ -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 `` 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 ``, 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 `` + +### Scenario: Navigate to editor + +- **Given** the app has bootstrapped +- **When** the URL is `/edit/42` +- **Then** `ShellComponent` renders with `EditorPageComponent` in its ``, 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 | diff --git a/specs/components/markdown-editor.spec.md b/specs/components/markdown-editor.spec.md new file mode 100644 index 0000000..b8bd2fa --- /dev/null +++ b/specs/components/markdown-editor.spec.md @@ -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 `
` + +### 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 | diff --git a/specs/components/shell.spec.md b/specs/components/shell.spec.md new file mode 100644 index 0000000..47acd91 --- /dev/null +++ b/specs/components/shell.spec.md @@ -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 ``. 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` | Whether the sidebar is visible (default `true`) | +| `isMobile` | `WritableSignal` | `true` when `window.innerWidth < 768` | +| `isDialog` | `Signal` | 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 | diff --git a/specs/components/welcome.spec.md b/specs/components/welcome.spec.md new file mode 100644 index 0000000..aacd565 --- /dev/null +++ b/specs/components/welcome.spec.md @@ -0,0 +1,103 @@ +--- +module: welcome +version: 1 +status: active +files: + - src/app/components/welcome/welcome.ts + - src/app/components/welcome/welcome.html + - src/app/components/welcome/welcome.scss +depends_on: + - spec-store-service +--- + +# Welcome + +## Purpose + +Landing page displayed when no spec is selected. Presents the application title, tagline, and two primary actions: creating a new spec and importing existing `.spec.md` files. Also displays three feature cards describing the application's capabilities. After a successful create or import, navigates the user to the editor. + +## Public API + +### Exported Classes + +| Class | Description | +|-------|-------------| +| `WelcomeComponent` | Angular standalone component — application landing page | + +### Methods + +| Method | Returns | Description | +|--------|---------|-------------| +| `onCreateSpec()` | `Promise` | Creates a new spec via the store, then navigates to `/edit/:id` | +| `onImport()` | `Promise` | Opens a file picker for `.md` / `.spec.md` files, imports them via the store, then navigates to the last imported spec | + +## Invariants + +1. "Create New Spec" calls `store.createSpec()` and navigates to `/edit/:id` with the new spec's ID +2. "Import" opens a native file input restricted to `.md` and `.spec.md` extensions with `multiple` enabled +3. After import, the user is navigated to the last spec in `store.allSpecs()` (the most recently added) +4. If `createSpec()` returns an object without an `id`, no navigation occurs +5. If no files are selected in the file picker (user cancels), no import is attempted +6. If `importMarkdownFiles` returns `0` (no specs imported), no navigation occurs +7. The layout is responsive — actions stack vertically and features switch to single-column on mobile (<768 px) + +## Behavioral Examples + +### Scenario: Create new spec + +- **Given** the user is on the welcome page +- **When** the user clicks "Create New Spec" +- **Then** `store.createSpec()` is called, and the app navigates to `/edit/:id` + +### Scenario: Import single file + +- **Given** the user is on the welcome page +- **When** the user clicks "Import .spec.md Files" and selects one file +- **Then** the file is read as text, `store.importMarkdownFiles()` is called with the file data, and the app navigates to the editor for the imported spec + +### Scenario: Import multiple files + +- **Given** the user selects 3 `.spec.md` files +- **When** the import completes with count > 0 +- **Then** the app navigates to the last spec in `store.allSpecs()` + +### Scenario: Import cancelled + +- **Given** the user clicks import +- **When** the user cancels the file picker (no files selected) +- **Then** nothing happens — no import call, no navigation + +### Scenario: Import yields zero specs + +- **Given** the user selects files that fail to parse +- **When** `importMarkdownFiles` returns `0` +- **Then** no navigation occurs + +## Error Cases + +| Condition | Behavior | +|-----------|----------| +| `createSpec()` returns object without `id` | Navigation is skipped (guarded by `if (spec.id)`) | +| File read fails | Unhandled — `file.text()` rejection would propagate | +| `allSpecs()` is empty after import | `last` is `undefined`, navigation skipped due to `last?.id` guard | + +## Dependencies + +### Consumes + +| Module | What is used | +|--------|-------------| +| `spec-store-service` | `SpecStoreService` — `createSpec()`, `importMarkdownFiles()`, `allSpecs()` | +| `@angular/router` | `Router` — `navigate()` | + +### Consumed By + +| Module | What is used | +|--------|-------------| +| `app.routes` | Lazy-loaded as the default child route of `ShellComponent` | + +## Change Log + +| Date | Author | Change | +|------|--------|--------| +| 2026-03-01 | CorvidAgent | Initial spec |