diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 8f1a00b..921b18e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -12,6 +12,7 @@ - Test bundles are built via `build/build-test.js`. - Dependency security overrides live in `package.json` under `overrides` (immutable pinned to 5.1.5+). - GitHub workflows should stay triggerable for PRs and use job-level docs-only gating rather than workflow-level path filters so required checks do not remain pending. +- GitLab merge request validation now lives in `.gitlab-ci.yml`; keep the workflow limited to `merge_request_event`, preserve the same docs-only short-circuit used by GitHub PR checks, and keep Cypress screenshot artifacts on failure. - Release automation is handled by `release-please`; keep `release-please-config.json` and `.release-please-manifest.json` aligned with the latest published tag, keep root tags in the existing plain `v*` format, and prefer Conventional Commit subjects (`fix:`, `feat:`, `deps:`) so release PRs are generated correctly. - Merged release PRs are finalized by `.github/workflows/release.yml`: the workflow detects `chore(main): release ink x.y.z` merges, creates the canonical `v*` tag plus a compatibility `ink-v*` tag, publishes the GitHub release from `v*`, and clears stale `autorelease:*` labels so the next release PR is not blocked. - Protected-branch repos should prefer a `RELEASE_PLEASE_TOKEN` PAT for the release workflow so bot-created release PRs trigger the normal PR checks. @@ -31,3 +32,4 @@ - Document Linter panel behavior now includes a `Rerun on change` toggle plus clickable section line links back into the editor. Preserve `handleEditorChanged()` in `src/app/document-linter/document-linter.ts` and keep the panel's line-jump buttons wired to the textarea selection logic when changing the panel UI. - Cogito and the Document Linter are mutually exclusive panels. Opening one should close the other so the editor split layout stays stable and never tries to render both sidebars together. - Editor view mode lives in `src/app/editor-preview.ts` and is wired through the main editor header in `ink.template.html`. Keep the Source/Split/Preview toggle in sync with `editorSplit`, `editorPane`, and `previewPane`, and preserve the `ink-editor-view-mode` localStorage key when changing that flow. +- Demo capture is recorded by `build/record-demo.js` using headless Chrome DevTools so Cypress UI is never present; use `npm run demo:record` for a slow, full-viewport app-only `assets/demo/ink-demo.webm` plus MP4 when conversion is available, and `npm run demo:gif` for the optional ffmpeg GIF. diff --git a/README.md b/README.md index ae207a0..00ac6db 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Ink is a functional and minimalistic webapp to write documents in markdown and e ![GitHub Pages](https://img.shields.io/badge/Deployed%20on-GitHub%20Pages-222222?logo=github&logoColor=white) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +## Quick Demo + +![](assets/demo/ink-demo.gif) + ## Repo Structure The structure of the repo is as follows: @@ -32,6 +36,8 @@ build/ assemble-single-file.js compile-and-assemble.js build-test.js + record-demo.js + export-demo-assets.js generate-favicons.js watch.js inject.js (Compatibility alias) @@ -40,6 +46,10 @@ assets/ branding/ logo.svg favicon.svg + demo/ + ink-demo.webm + ink-demo.mp4 + ink-demo.gif tests/ qunit/ cypress/ @@ -69,6 +79,24 @@ Canonical build entrypoint: `build/compile-and-assemble.js` (used by `npm run bu `npm run build` also regenerates favicon assets before assembling `ink-app.html`. +## Demo Capture + +The project can record the first-use Ink flow as a reusable video asset: + +```bash +npm run demo:record +``` + +The command starts the local test server, launches headless Chrome against the full Ink viewport, and writes a clean app-only recording to `assets/demo/ink-demo.webm`. When macOS `avconvert` can convert the browser recording, it also writes `assets/demo/ink-demo.mp4`. The demo shows opening a workspace, creating `getting-started.md`, writing markdown slowly, saving, and switching to the rendered preview. + +To generate a GIF from the recorded video, install `ffmpeg` and run: + +```bash +npm run demo:gif +``` + +GIF output is written to `assets/demo/ink-demo.gif`. Keep the MP4 as the canonical demo asset because it is smaller and clearer than a full-app GIF. + ## Workflow Cheat Sheet Use this sequence when working on the app: diff --git a/assets/demo/ink-demo.gif b/assets/demo/ink-demo.gif new file mode 100644 index 0000000..3177831 Binary files /dev/null and b/assets/demo/ink-demo.gif differ diff --git a/assets/demo/ink-demo.webm b/assets/demo/ink-demo.webm new file mode 100644 index 0000000..14b606a Binary files /dev/null and b/assets/demo/ink-demo.webm differ diff --git a/package.json b/package.json index d3d7f56..db3456a 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,8 @@ "watch": "node build/watch.js", "build:favicon": "node build/generate-favicons.js", "build:test": "node build/build-test.js", + "demo:record": "start-server-and-test \"npm run serve:test\" http://127.0.0.1:4173/ink-app.html \"node build/record-demo.js\"", + "demo:gif": "node build/export-demo-assets.js --gif", "test:qunit": "npm run build:test && qunit \"tests/qunit/**/*.test.js\"", "serve:test": "http-server . -p 4173 -s", "test:cypress": "start-server-and-test \"npm run serve:test\" http://127.0.0.1:4173/ink-app.html \"unset ELECTRON_RUN_AS_NODE; cypress run\"", diff --git a/repomix-output.xml b/repomix-output.xml index c797739..e1f5bad 100644 --- a/repomix-output.xml +++ b/repomix-output.xml @@ -295,9 +295,13 @@ assets/ favicon.svg logo.svg white-bg-logo.svg + demo/ + ink-demo.gif + ink-demo.webm cypress/ e2e/ cogito-mode.cy.js + demo.cy.js document-linter.cy.js editor-flow.cy.js editor-view-mode.cy.js @@ -341,6 +345,12 @@ openspec/ design.md proposal.md tasks.md + add-demo-video-capture/ + specs/ + demo-capture/ + spec.md + proposal.md + tasks.md add-document-linter/ specs/ document-linter/ @@ -509,6 +519,7 @@ tests/ user.test.js utils.test.js .gitignore +.gitlab-ci.yml .nycrc .release-please-manifest.json .replit @@ -27500,6 +27511,274 @@ ${t.trim()} + +function createDemoFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); + + return { + kind: "file", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + }; +} + +function createDemoDirectoryHandle(name) { + const entries = new Map(); + + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createDemoFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createDemoDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + }; +} + +const isDemoRecording = + globalThis.Cypress?.env("INK_DEMO_RECORDING") === true || + globalThis.Cypress?.env("INK_DEMO_RECORDING") === "true"; +const demoPauseMs = isDemoRecording ? 1100 : 150; +const demoTypeDelayMs = isDemoRecording ? 48 : 0; + +function hideCypressRunnerChrome() { + if (!isDemoRecording) { + return; + } + + const topDocument = window.top?.document; + if (!topDocument || topDocument.getElementById("ink-demo-hide-cypress-ui")) { + return; + } + + const style = topDocument.createElement("style"); + style.id = "ink-demo-hide-cypress-ui"; + style.textContent = ` + .reporter-wrap, + .reporter, + .runner > header, + [data-cy="reporter"], + [data-testid="reporter"] { + display: none !important; + width: 0 !important; + } + + .runner, + .runner-container, + .iframe-container, + .iframes-container { + inset: 0 !important; + left: 0 !important; + right: 0 !important; + width: 100vw !important; + max-width: none !important; + } + + .aut-iframe, + iframe.aut-iframe { + width: 100vw !important; + height: 100vh !important; + } + `; + topDocument.head.appendChild(style); +} + +function installDemoPointer(win) { + if (!isDemoRecording || win.document.getElementById("inkDemoPointer")) { + return; + } + + const style = win.document.createElement("style"); + style.id = "inkDemoPointerStyles"; + style.textContent = ` + #inkDemoPointer { + position: fixed; + z-index: 99999; + width: 22px; + height: 22px; + border-radius: 999px; + background: #f8fafc; + border: 3px solid #2563eb; + box-shadow: 0 0 0 8px rgb(37 99 235 / 18%); + transform: translate(-50%, -50%); + transition: left 650ms ease, top 650ms ease, box-shadow 180ms ease, scale 180ms ease; + pointer-events: none; + } + + #inkDemoPointer.is-clicking { + scale: 0.78; + box-shadow: 0 0 0 16px rgb(37 99 235 / 12%); + } + `; + + const pointer = win.document.createElement("div"); + pointer.id = "inkDemoPointer"; + pointer.setAttribute("aria-hidden", "true"); + pointer.style.left = "80px"; + pointer.style.top = "80px"; + + win.document.head.appendChild(style); + win.document.body.appendChild(pointer); +} + +function demoWait(multiplier = 1) { + return cy.wait(Math.round(demoPauseMs * multiplier)); +} + +function moveDemoPointerTo(selector) { + if (!isDemoRecording) { + return cy.wrap(null, { log: false }); + } + + return cy.get(selector).then(($element) => { + const rect = $element[0].getBoundingClientRect(); + const left = rect.left + rect.width / 2; + const top = rect.top + Math.min(rect.height / 2, 28); + + return cy.window({ log: false }).then((win) => { + const pointer = win.document.getElementById("inkDemoPointer"); + if (!pointer) { + return null; + } + pointer.style.left = `${left}px`; + pointer.style.top = `${top}px`; + return cy.wait(750, { log: false }); + }); + }); +} + +function clickForDemo(selector) { + return moveDemoPointerTo(selector) + .then(() => cy.get(selector).click({ force: true })) + .then(() => { + if (!isDemoRecording) { + return null; + } + return cy.window({ log: false }).then((win) => { + const pointer = win.document.getElementById("inkDemoPointer"); + pointer?.classList.add("is-clicking"); + return cy.wait(220, { log: false }).then(() => { + pointer?.classList.remove("is-clicking"); + }); + }); + }) + .then(() => demoWait()); +} + +describe("ink demo capture", () => { + const workspaceName = "Ink Demo Workspace"; + const fileStem = "getting-started"; + const markdown = [ + "# Getting started with Ink", + "", + "Open a workspace, create a markdown note, and write with the preview beside you.", + "", + "## Draft", + "", + "- Keep notes in plain markdown", + "- Save when the draft is ready", + "- Use preview to check structure", + ].join("\n"); + + beforeEach(() => { + hideCypressRunnerChrome(); + cy.viewport(1440, 900); + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createDemoDirectoryHandle(workspaceName); + + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + cy.window().then((win) => { + installDemoPointer(win); + }); + demoWait(); + }); + + it("records the first workspace note and preview flow", () => { + clickForDemo("[data-action=\"open-workspace\"]"); + cy.get("#workspaceName").should("contain", workspaceName); + demoWait(); + + clickForDemo("[data-action=\"new-note\"]"); + cy.get("#currentFilename").should("contain", `${fileStem}.md`); + demoWait(); + + moveDemoPointerTo("#editor"); + cy.get("#editor").clear(); + cy.get("#editor").type(markdown, { delay: demoTypeDelayMs }); + cy.get("#preview").find("h1").should("contain", "Getting started with Ink"); + demoWait(); + + clickForDemo("[data-action=\"save\"]"); + cy.get("#statusBadge").should("contain", "Saved"); + demoWait(); + + clickForDemo("#editorViewPreviewBtn"); + cy.get("#preview").find("h2").should("contain", "Draft"); + demoWait(1.6); + }); +}); + + export {}; @@ -27515,6 +27794,87 @@ export {}; } + +## ADDED Requirements +### Requirement: User-Controlled Left Menu Visibility +The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + +#### Scenario: User collapses left menu +- **WHEN** the user activates the menu toggle while the menu is expanded +- **THEN** the left menu transitions to a collapsed state +- **AND** the main editor area gains additional horizontal space + +#### Scenario: User expands left menu +- **WHEN** the user activates the menu toggle while the menu is collapsed +- **THEN** the left menu returns to an expanded state +- **AND** primary menu content becomes visible again + +### Requirement: Left Menu Toggle Accessibility +The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + +#### Scenario: Keyboard operation +- **WHEN** a keyboard-only user focuses the menu toggle and activates it +- **THEN** the menu state changes between expanded and collapsed +- **AND** focus remains in a predictable location for continued interaction + +#### Scenario: Assistive state exposure +- **WHEN** the menu state changes through the toggle control +- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed + +### Requirement: Deterministic Initial Menu State +The application SHALL define a deterministic initial left menu state when a new session loads. + +#### Scenario: Initial layout state +- **WHEN** a user opens the application in a new session +- **THEN** the left menu starts in the documented default state +- **AND** the editor layout reflects that state without requiring user interaction + + + +# Change: Add User-Controlled Collapsible Left Menu + +## Why +The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. + +## What Changes +- Add support for collapsing and expanding the left menu from within the UI. +- Add a persistent toggle control so users can switch menu state on demand. +- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. +- Preserve keyboard and pointer accessibility for the menu toggle interaction. +- Define expected default menu behavior at app start. + +## Impact +- Affected specs: `editor-layout` +- Affected code: + - `ink.template.html` + - `src/app.ts` + - `src/styles.scss` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + + + +## 1. UI Controls and State +- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. +- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. +- [x] 1.3 Define and implement the default menu state on initial load. + +## 2. Layout and Styling +- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. +- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. +- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + +## 3. Accessibility and Interaction +- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. +- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. + +## 4. Verification +- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. +- [x] 4.2 Verify editor content area width increases when the menu is collapsed. +- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. +- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. + + ## ADDED Requirements ### Requirement: Componentized Source Inputs @@ -27680,6 +28040,55 @@ The current project state does not provide a stable, repeatable workflow for mai - [x] 3.3 Confirm final app output remains a single self-contained HTML file. + +## ADDED Requirements + +### Requirement: Automated Demo Capture +The project SHALL provide a repeatable command that records the Ink onboarding authoring flow as a video asset. + +#### Scenario: Record core authoring flow +- **WHEN** a maintainer runs the demo recording command +- **THEN** the demo recorder opens Ink in a deterministic browser session +- **AND** the recording shows opening a workspace, creating a markdown file, typing content, saving, and inspecting the preview +- **AND** the recording excludes Cypress runner controls so only the Ink app flow is visible +- **AND** the visible actions are paced slowly enough for a first-time viewer to follow +- **AND** the reusable video asset is written under `assets/demo/` + +### Requirement: GIF Export +The project SHALL provide a documented command to convert the recorded demo video into a GIF when ffmpeg is available. + +#### Scenario: Generate GIF from recorded video +- **GIVEN** a recorded demo video exists under `assets/demo/` +- **WHEN** a maintainer runs the GIF export command with ffmpeg installed +- **THEN** the project writes `assets/demo/ink-demo.gif` +- **AND** the command fails with an actionable message when ffmpeg is missing + + + +# Change: Add automated demo video capture + +## Why +Ink needs a repeatable way to show first-time users the core authoring flow without manually recording the app each release. + +## What Changes +- Add a deterministic browser demo recorder that opens a workspace, creates a markdown note, writes content, saves it, and shows the rendered preview. +- Add npm scripts to record the demo video and export reusable demo assets. +- Document the demo regeneration workflow for README and future agent maintenance. + +## Impact +- Affected specs: demo-capture +- Affected code: `build/record-demo.js`, `cypress/e2e/demo.cy.js`, `build/export-demo-assets.js`, `package.json`, `README.md`, `.github/copilot-instructions.md` + + + +## 1. Implementation +- [x] 1.1 Add a deterministic Chrome recorder and Cypress validation spec for the core Ink authoring flow. +- [x] 1.2 Add an asset export script that converts the recorded app-only video to GIF with ffmpeg. +- [x] 1.3 Add npm scripts for recording and GIF generation. +- [x] 1.4 Document demo asset generation in README and agent guidance. +- [x] 1.5 Run focused validation for the demo workflow and project checks. + + ## ADDED Requirements @@ -27976,3559 +28385,3762 @@ The repository currently lacks consistent branding assets in project documentati - [x] 4.3 Verify documentation describes how to refresh logo-derived assets when the SVG changes. - + ## ADDED Requirements -### Requirement: Prioritized Maintainability Refactor Plan -The project SHALL define a maintainability refactor plan with explicit High, Medium, and Low priority tiers based on engineering risk and value. -#### Scenario: Plan is prioritized and actionable -- **WHEN** a maintainer reviews the approved change proposal -- **THEN** the proposal includes clearly separated High, Medium, and Low priority work -- **AND** each tier contains concrete codebase targets +### Requirement: Mobile Browser Detection +The system SHALL detect when the browser does not support the File System Access API. -### Requirement: Evidence-Based Refactor Scope -Refactor recommendations MUST be grounded in current repository evidence and MUST avoid unnecessary changes. +#### Scenario: Browser lacks File System Access API +- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL detect this and enable in-memory workspace mode +- **AND** the system SHALL NOT throw an error blocking app usage -#### Scenario: Recommendations are justified -- **WHEN** a recommendation proposes delete, rename, or restructure actions -- **THEN** it references observable repository state (for example dead code, missing script targets, or duplicated logic) -- **AND** the proposal explicitly identifies areas that should remain unchanged when already aligned +#### Scenario: Browser supports File System Access API +- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` +- **THEN** the system SHALL allow opening a real folder as usual +- **AND** no in-memory mode SHALL be activated -### Requirement: Behavior-Preserving Refactor Guardrails -Maintainability refactors SHALL preserve existing user-visible behavior unless a separate feature change is approved. +### Requirement: In-Memory Workspace Mode +The system SHALL provide a temporary in-memory workspace when FS API is unavailable. -#### Scenario: Refactor keeps current behavior stable -- **WHEN** maintainability refactor tasks are implemented -- **THEN** build and test workflows continue to pass -- **AND** core authoring flow behavior (open workspace, create note, edit, save) remains unchanged +#### Scenario: User creates note in temporary session +- **WHEN** the user clicks "New Note" in temporary session mode +- **AND** enters a note name +- **THEN** a new note SHALL be created in memory +- **AND** the user CAN edit the note content +- **AND** the user CAN save (persist to memory only) -### Requirement: Tooling and Documentation Consistency -Documented commands and scripts MUST map to files and behavior that exist in the repository. +#### Scenario: User edits note in temporary session +- **WHEN** the user modifies the editor content +- **AND** the note has unsaved changes +- **THEN** the dirty indicator SHALL show unsaved status +- **AND** the user CAN click Save to persist changes to memory -#### Scenario: Script/docs mismatch is resolved -- **WHEN** package scripts or README commands are documented -- **THEN** each command resolves to existing code paths -- **AND** stale or broken command references are removed or restored - +### Requirement: Export as JSON +The system SHALL provide a way to download all notes as a JSON file. - -## Context -Ink is intentionally lightweight and single-file at runtime. The refactor must preserve behavior while making source code easier for LLMs to read, regenerate, and safely modify. +#### Scenario: User exports all notes as JSON +- **WHEN** the user clicks "Export JSON" button +- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` +- **AND** the file SHALL contain a JSON object with notes array +- **AND** each note SHALL have `name`, `path`, and `content` fields -## Goals / Non-Goals -- Goals: - - Reduce cognitive load in `src/app.ts` by splitting by feature boundaries. - - Remove stale/dead paths that create false complexity. - - Improve naming so build and runtime responsibilities are explicit. - - Keep behavior and user workflow unchanged. -- Non-Goals: - - Framework migration. - - UX redesign. - - New product features. +#### Scenario: JSON export contains multiple notes +- **WHEN** the user has created multiple notes in temporary session +- **AND** clicks Export JSON +- **THEN** the exported JSON SHALL include all notes +- **AND** the order SHALL be preserved -## Decisions -- Decision: Refactor in phases (High -> Medium -> Low) with behavior-preserving checkpoints. - - Rationale: minimizes regression risk and keeps scope manageable. -- Decision: Keep a thin composition entrypoint and move concerns into small modules. - - Rationale: supports predictable regeneration and targeted testing. -- Decision: Do not force changes where current code is already clear (for example `src/tags.ts`). - - Rationale: avoids churn and preserves momentum. +### Requirement: Export as Markdown +The system SHALL provide a way to download individual notes as .md files. -## Risks / Trade-offs -- Risk: File splits can break implicit state assumptions. - - Mitigation: define explicit typed state contracts and test around open/save/refresh flows. -- Risk: Renaming scripts can disrupt local habits. - - Mitigation: keep temporary aliases and document migration in README. +#### Scenario: User exports current note as Markdown +- **WHEN** the user clicks "Export Markdown" button while a note is open +- **THEN** the browser SHALL download a file with the note's filename +- **AND** the file SHALL contain the note's raw markdown content +- **AND** the file SHALL have `.md` extension -## Migration Plan -1. Remove dead code and duplicate timers in `src/app.ts`. -2. Introduce module boundaries for workspace scan, editor operations, and rendering. -3. Rename build scripts/files with compatibility aliases. -4. Re-run build + QUnit + Cypress and update docs. +### Requirement: Temporary Session UI Indicator +The system SHALL display a clear indicator when operating in temporary session mode. -## Open Questions -- Should `sync:from-ink-app` be restored (by adding script) or removed from docs/scripts? -- Should `src/storage.ts` remain a test fixture in `src/` or move to a dedicated test-support path? +#### Scenario: Temporary session is active +- **WHEN** the app is running in temporary session mode +- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" +- **AND** the indicator SHALL inform users their data is not persisted +- **AND** export buttons SHALL be prominently visible + +#### Scenario: No indicator shown in normal mode +- **WHEN** the app is running with real file system access +- **THEN** no temporary session indicator SHALL be displayed +- **AND** export buttons MAY be hidden or disabled - -# Change: LLM-Aligned Maintainability Refactor Plan + +# Change: Add Mobile Fallback Support for Browsers Without File System Access API ## Why -The current codebase works, but it has several maintainability risks that reduce predictability and regenerability for LLM-driven development. The biggest issue is concentration of behavior in one large file, plus stale scripts and dead code paths that make intent harder to reason about. +Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. ## What Changes -- Create a phased refactor plan aligned to project coding principles: clarity, pragmatism, rigor. -- Prioritize only high-value changes and explicitly avoid churn where behavior is already clear and stable. -- Define concrete rename/restructure/delete targets backed by repository evidence. -- Add acceptance criteria so refactor work can be verified without changing product behavior. - -### High Priority -- Split `src/app.ts` into small feature modules with a thin entrypoint (`main` + feature-oriented files). -- Remove duplicated auto-refresh behavior (currently both `startAutoRefresh()` timer and a separate global `setInterval` run every 10s). -- Fix stale/broken tooling contract: `sync:from-ink-app` references `build/sync-from-ink-app.js`, but the file is missing. -- Remove dead fields/utilities in `src/app.ts`: - - `searchScanDebounceTimer` - - `lastContentSearchToken` - - `sleep()` - - `humanDate()` - - `debounce()` - -### Medium Priority -- Rename ambiguous build scripts for intent clarity: - - `build/inject.js` -> `build/assemble-single-file.js` - - `build/build.js` -> `build/compile-and-assemble.js` -- Extract File System Access API handling into a dedicated module (permissions, handles, scan traversal) to reduce coupling with UI rendering. -- Replace broad `any` usage in app state with narrow interfaces for notes, tree nodes, and workspace handles. -- Remove redundant DOM lookups in `src/app.ts` where elements are already captured once. - -### Low Priority -- Decide whether test-only storage logic should be renamed for intent: - - `src/storage.ts` -> `src/test-support/storage-fixture.ts` (if kept test-only) -- Decide whether generated test bundles should stay committed: - - `dist/test/*` can be generated during `npm run build:test` and excluded from source control. -- Tighten Cypress scaffolding comments/config only if it improves signal-to-noise (avoid cosmetic edits). - -### Keep As-Is (No Change Needed) -- `src/tags.ts` is already small, explicit, and testable. -- Build pipeline remains lightweight and appropriate for single-file distribution constraints. -- Existing QUnit and Cypress coverage should be retained and expanded only around touched refactor areas. +- Detect when File System Access API is unavailable +- Enable in-memory workspace mode for temporary editing +- Add "Export as JSON" button to download all notes as a JSON file +- Add "Export as Markdown" button to download individual notes as .md files +- Show informative UI about temporary nature of the session +- Preserve existing functionality for desktop browsers with FS API support ## Impact -- Affected specs: `codebase-maintainability` -- Affected code: - - `src/app.ts` - - `src/storage.ts` (rename/rehome decision) - - `build/build.js` - - `build/inject.js` - - `package.json` - - `README.md` - - `Makefile` (if command wrappers change) - - `tests/qunit/*` - - `cypress/*` +- Affected specs: mobile-support (new capability) +- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles +- No breaking changes to existing desktop functionality - -## 1. High Priority (Safety + Clarity) -- [x] 1.1 Remove duplicate auto-refresh scheduling and keep one refresh control path. -- [x] 1.2 Remove dead fields/utilities from `src/app.ts` (`searchScanDebounceTimer`, `lastContentSearchToken`, `sleep`, `humanDate`, `debounce`). -- [x] 1.3 Resolve broken `sync:from-ink-app` contract by either restoring `build/sync-from-ink-app.js` or removing stale script/docs. -- [x] 1.4 Split `src/app.ts` into a small entrypoint plus feature modules with explicit interfaces. - -## 2. Medium Priority (Structure + Naming) -- [x] 2.1 Rename build scripts/files for clearer responsibility and keep compatibility aliases during migration. -- [x] 2.2 Extract File System Access API logic into a dedicated module. -- [x] 2.3 Replace broad `any` usage in app state/types with narrower interfaces. -- [x] 2.4 Remove redundant element re-queries in app initialization. + +## 1. Implementation +- [x] 1.1 Update `src/app/fs-api.ts` - Add function to check if FS API is available +- [x] 1.2 Update `src/app/bootstrap.ts` - Modify `openWorkspace()` to enter in-memory mode when FS API unavailable +- [x] 1.3 Add in-memory workspace state management (notes stored in memory, not on disk) +- [x] 1.4 Add "Export as JSON" button to UI (download all notes) +- [x] 1.5 Add "Export as Markdown" button to UI (download single note) +- [x] 1.6 Add UI indicator showing "Temporary Session" mode +- [x] 1.7 Update `ink-app.html` with new export buttons +- [x] 1.8 Update `src/styles.scss` with temporary session styles +- [x] 1.9 Test in-memory mode works without errors +- [x] 1.10 Test export JSON downloads correctly +- [x] 1.11 Test export Markdown downloads correctly -## 3. Low Priority (Cleanup) -- [x] 3.1 Decide whether to rename/rehome `src/storage.ts` as test-support-only code. -- [x] 3.2 Decide whether `dist/test/*` should be generated-only and excluded from source control. (Decision: keep committed for now to preserve current repository pattern with generated distribution artifacts under `dist/`.) -- [x] 3.3 Clean minor Cypress scaffolding noise only when it improves maintainability. +## 2. Cypress Tests +- [x] 2.1 Create `cypress/e2e/mobile-fallback.cy.js` +- [x] 2.2 Test: When FS API unavailable, app shows temporary session mode +- [x] 2.3 Test: User can create and edit notes in memory +- [x] 2.4 Test: Export JSON button downloads valid JSON file +- [x] 2.5 Test: Export Markdown button downloads valid .md file -## 4. Validation -- [x] 4.1 `npm run build` succeeds and produces a working `ink-app.html`. -- [x] 4.2 `npm run test:qunit` passes. -- [x] 4.3 `npm run test:cypress` passes. -- [x] 4.4 README and script references match actual files/commands. +## 3. QUnit Tests +- [x] 3.1 Create `tests/qunit/mobile-fallback.test.js` +- [x] 3.2 Test: isFileSystemApiAvailable() returns correct values based on browser +- [x] 3.3 Test: In-memory notes can be created and retrieved +- [x] 3.4 Test: JSON export formats notes correctly +- [x] 3.5 Test: Markdown export includes frontmatter and content - -# OpenSpec Instructions + +## ADDED Requirements -Instructions for AI coding assistants using OpenSpec for spec-driven development. +### Requirement: ARIA Support for Menu Bar +The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. -## TL;DR Quick Checklist +#### Scenario: Menu bar has proper ARIA role +- **WHEN** a screen reader encounters the menu bar +- **THEN** the menu bar has role="menubar" attribute +- **AND** screen readers announce it as a menu bar -- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) -- Decide scope: new capability vs modify existing capability -- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) -- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability -- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement -- Validate: `openspec validate [change-id] --strict` and fix issues -- Request approval: Do not start implementation until proposal is approved +#### Scenario: Menu items have proper ARIA attributes +- **WHEN** a screen reader encounters menu items +- **THEN** each menu item has role="menuitem" attribute +- **AND** menu items with dropdowns have aria-haspopup="true" +- **AND** aria-expanded indicates dropdown state -## Three-Stage Workflow +#### Scenario: Dropdown menus have proper ARIA attributes +- **WHEN** a dropdown menu is opened +- **THEN** the dropdown has role="menu" attribute +- **AND** each dropdown item has role="menuitem" attribute +- **AND** aria-expanded="true" on the parent menu item -### Stage 1: Creating Changes -Create proposal when you need to: -- Add features or functionality -- Make breaking changes (API, schema) -- Change architecture or patterns -- Optimize performance (changes behavior) -- Update security patterns +### Requirement: Keyboard Accessibility +The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. -Triggers (examples): -- "Help me create a change proposal" -- "Help me plan a change" -- "Help me create a proposal" -- "I want to create a spec proposal" -- "I want to create a spec" +#### Scenario: Tab navigation support +- **WHEN** a user navigates using Tab key +- **THEN** focus moves to menu bar items in logical order +- **AND** focused items are visually indicated -Loose matching guidance: -- Contains one of: `proposal`, `change`, `spec` -- With one of: `create`, `plan`, `make`, `start`, `help` +#### Scenario: Arrow key navigation +- **WHEN** a user navigates using arrow keys +- **THEN** Left/Right arrows move between top-level menu items +- **AND** Up/Down arrows move between dropdown menu items +- **AND** Home/End keys move to first/last items -Skip proposal for: -- Bug fixes (restore intended behavior) -- Typos, formatting, comments -- Dependency updates (non-breaking) -- Configuration changes -- Tests for existing behavior +#### Scenario: Enter and Space key activation +- **WHEN** a user presses Enter or Space on a focused menu item +- **THEN** the menu item is activated +- **AND** dropdowns open or actions are executed as appropriate -**Workflow** -1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. -2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. -3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. -4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. +#### Scenario: Escape key handling +- **WHEN** a user presses Escape key +- **THEN** open dropdowns are closed +- **AND** focus returns to the parent menu item +- **AND** no other application functionality is affected -### Stage 2: Implementing Changes -Track these steps as TODOs and complete them one by one. -1. **Read proposal.md** - Understand what's being built -2. **Read design.md** (if exists) - Review technical decisions -3. **Read tasks.md** - Get implementation checklist -4. **Implement tasks sequentially** - Complete in order -5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses -6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality -7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved +### Requirement: Visual Focus Indicators +The menu bar SHALL provide clear visual indicators for keyboard focus and active states. -### Stage 3: Archiving Changes -After deployment, create separate PR to: -- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` -- Update `specs/` if capabilities changed -- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) -- Run `openspec validate --strict` to confirm the archived change passes checks +#### Scenario: Focus indicators for menu items +- **WHEN** a menu item receives keyboard focus +- **THEN** a clear visual focus indicator is displayed +- **AND** the focus indicator meets WCAG contrast requirements -## Before Any Task +#### Scenario: Active state indicators +- **WHEN** a menu item is activated or a dropdown is open +- **THEN** clear visual indicators show the active state +- **AND** the indicators are distinguishable from focus indicators -**Context Checklist:** -- [ ] Read relevant specs in `specs/[capability]/spec.md` -- [ ] Check pending changes in `changes/` for conflicts -- [ ] Read `openspec/project.md` for conventions -- [ ] Run `openspec list` to see active changes -- [ ] Run `openspec list --specs` to see existing capabilities +#### Scenario: Hover state indicators +- **WHEN** a user hovers over menu items with a mouse +- **THEN** visual indicators show hover state +- **AND** hover indicators are consistent with focus indicators -**Before Creating Specs:** -- Always check if capability already exists -- Prefer modifying existing specs over creating duplicates -- Use `openspec show [spec]` to review current state -- If request is ambiguous, ask 1–2 clarifying questions before scaffolding +### Requirement: Screen Reader Announcements +The menu bar SHALL provide appropriate announcements for screen reader users. -### Search Guidance -- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) -- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) -- Show details: - - Spec: `openspec show --type spec` (use `--json` for filters) - - Change: `openspec show --json --deltas-only` -- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` +#### Scenario: Menu item descriptions +- **WHEN** a screen reader focuses on a menu item +- **THEN** the item's purpose is clearly announced +- **AND** any associated keyboard shortcuts are announced -## Quick Start +#### Scenario: Dropdown state announcements +- **WHEN** a dropdown menu state changes +- **THEN** screen readers announce the state change +- **AND** aria-expanded attribute is updated appropriately -### CLI Commands +#### Scenario: Menu navigation announcements +- **WHEN** a user navigates between menu items +- **THEN** screen readers announce the current position +- **AND** the total number of items is announced when appropriate -```bash -# Essential commands -openspec list # List active changes -openspec list --specs # List specifications -openspec show [item] # Display change or spec -openspec validate [item] # Validate changes or specs -openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) +### Requirement: High Contrast and Zoom Support +The menu bar SHALL support high contrast modes and browser zoom functionality. -# Project management -openspec init [path] # Initialize OpenSpec -openspec update [path] # Update instruction files +#### Scenario: High contrast mode support +- **WHEN** high contrast mode is enabled +- **THEN** menu bar maintains proper contrast ratios +- **AND** all interactive elements remain visible and usable -# Interactive mode -openspec show # Prompts for selection -openspec validate # Bulk validation mode +#### Scenario: Browser zoom support +- **WHEN** browser zoom is applied +- **THEN** menu bar layout adjusts appropriately +- **AND** no content is clipped or overlapping +- **AND** all functionality remains accessible -# Debugging -openspec show [change] --json --deltas-only -openspec validate [change] --strict -``` +### Requirement: Error Handling and Feedback +The menu bar SHALL provide appropriate feedback for accessibility-related errors. -### Command Flags +#### Scenario: Disabled menu item feedback +- **WHEN** a user attempts to activate a disabled menu item +- **THEN** appropriate feedback is provided +- **AND** screen readers announce the item as disabled -- `--json` - Machine-readable output -- `--type change|spec` - Disambiguate items -- `--strict` - Comprehensive validation -- `--no-interactive` - Disable prompts -- `--skip-specs` - Archive without spec updates -- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) +#### Scenario: Invalid keyboard input handling +- **WHEN** a user provides invalid keyboard input +- **THEN** the application handles it gracefully +- **AND** no unexpected behavior occurs + -## Directory Structure + +## ADDED Requirements -``` -openspec/ -├── project.md # Project conventions -├── specs/ # Current truth - what IS built -│ └── [capability]/ # Single focused capability -│ ├── spec.md # Requirements and scenarios -│ └── design.md # Technical patterns -├── changes/ # Proposals - what SHOULD change -│ ├── [change-name]/ -│ │ ├── proposal.md # Why, what, impact -│ │ ├── tasks.md # Implementation checklist -│ │ ├── design.md # Technical decisions (optional; see criteria) -│ │ └── specs/ # Delta changes -│ │ └── [capability]/ -│ │ └── spec.md # ADDED/MODIFIED/REMOVED -│ └── archive/ # Completed changes -``` +### Requirement: Keyboard Shortcuts for Common Operations +The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. -## Creating Change Proposals +#### Scenario: New Note shortcut +- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) +- **THEN** the createNewNote functionality is triggered +- **AND** the same behavior as clicking "New Note" menu item occurs -### Decision Tree +#### Scenario: Open Workspace shortcut +- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) +- **THEN** the openWorkspace functionality is triggered +- **AND** the same behavior as clicking "Open Workspace" menu item occurs -``` -New request? -├─ Bug fix restoring spec behavior? → Fix directly -├─ Typo/format/comment? → Fix directly -├─ New feature/capability? → Create proposal -├─ Breaking change? → Create proposal -├─ Architecture change? → Create proposal -└─ Unclear? → Create proposal (safer) -``` +#### Scenario: Save shortcut +- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) +- **THEN** the saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking "Save" menu item occurs -### Proposal Structure +#### Scenario: Refresh shortcut +- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) +- **THEN** the rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking "Refresh" menu item occurs -1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) +#### Scenario: Export JSON shortcut +- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) +- **THEN** the exportAsJson functionality is triggered +- **AND** the same behavior as clicking "Export JSON" menu item occurs -2. **Write proposal.md:** -```markdown -# Change: [Brief description of change] +#### Scenario: Export Markdown shortcut +- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) +- **THEN** the exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking "Export Markdown" menu item occurs -## Why -[1-2 sentences on problem/opportunity] +### Requirement: Keyboard Navigation of Menu Bar +The menu bar SHALL support full keyboard navigation using standard accessibility patterns. -## What Changes -- [Bullet list of changes] -- [Mark breaking changes with **BREAKING**] +#### Scenario: Tab navigation to menu bar +- **WHEN** a user presses Tab to navigate to the menu bar +- **THEN** focus moves to the first menu item +- **AND** the menu item is visually indicated as focused -## Impact -- Affected specs: [list capabilities] -- Affected code: [key files/systems] -``` +#### Scenario: Arrow key navigation between menus +- **WHEN** a user presses Left/Right arrow keys while focused on menu bar +- **THEN** focus moves between menu items (File, Edit, Import/Export) +- **AND** the focused menu item is visually indicated -3. **Create spec deltas:** `specs/[capability]/spec.md` -```markdown -## ADDED Requirements -### Requirement: New Feature -The system SHALL provide... +#### Scenario: Enter key to open dropdown +- **WHEN** a user presses Enter while focused on a menu item +- **THEN** the dropdown menu opens +- **AND** focus moves to the first menu item in the dropdown -#### Scenario: Success case -- **WHEN** user performs action -- **THEN** expected result +#### Scenario: Escape key to close dropdowns +- **WHEN** a user presses Escape while a dropdown is open +- **THEN** the dropdown closes +- **AND** focus returns to the parent menu item -## MODIFIED Requirements -### Requirement: Existing Feature -[Complete modified requirement] +#### Scenario: Up/Down arrow navigation in dropdowns +- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu +- **THEN** focus moves between menu items in the dropdown +- **AND** the focused item is visually indicated -## REMOVED Requirements -### Requirement: Old Feature -**Reason**: [Why removing] -**Migration**: [How to handle] -``` -If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. +### Requirement: Shortcut Display in Menu Items +Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. -4. **Create tasks.md:** -```markdown -## 1. Implementation -- [ ] 1.1 Create database schema -- [ ] 1.2 Implement API endpoint -- [ ] 1.3 Add frontend component -- [ ] 1.4 Write tests -``` +#### Scenario: Shortcut key display +- **WHEN** a user views the menu bar +- **THEN** menu items that have keyboard shortcuts display the shortcut keys +- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") -5. **Create design.md when needed:** -Create `design.md` if any of the following apply; otherwise omit it: -- Cross-cutting change (multiple services/modules) or a new architectural pattern -- New external dependency or significant data model changes -- Security, performance, or migration complexity -- Ambiguity that benefits from technical decisions before coding +#### Scenario: Platform-appropriate shortcut display +- **WHEN** the application runs on different platforms +- **THEN** shortcut keys are displayed using platform-appropriate modifiers +- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" -Minimal `design.md` skeleton: -```markdown -## Context -[Background, constraints, stakeholders] +### Requirement: Shortcut Key Conflict Resolution +The application SHALL handle keyboard shortcut conflicts appropriately. -## Goals / Non-Goals -- Goals: [...] -- Non-Goals: [...] +#### Scenario: Browser shortcut precedence +- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality +- **THEN** the application prevents the default browser behavior +- **AND** the application's shortcut functionality is executed instead -## Decisions -- Decision: [What and why] -- Alternatives considered: [Options + rationale] +#### Scenario: Focus-based shortcut activation +- **WHEN** keyboard shortcuts are pressed +- **THEN** shortcuts are only active when the application has focus +- **AND** shortcuts do not interfere with other applications + -## Risks / Trade-offs -- [Risk] → Mitigation + +## ADDED Requirements -## Migration Plan -[Steps, rollback] +### Requirement: Menu Bar Structure +The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. -## Open Questions -- [...] -``` +#### Scenario: Menu bar is visible and accessible +- **WHEN** the application loads +- **THEN** a horizontal menu bar is displayed at the top of the application window +- **AND** the menu bar contains File, Edit, and Import/Export menu items -## Spec File Format +#### Scenario: Menu items are properly labeled +- **WHEN** a user views the menu bar +- **THEN** each menu item has clear, descriptive text labels +- **AND** menu items follow standard desktop application conventions -### Critical: Scenario Formatting +### Requirement: File Menu Functionality +The File menu SHALL provide access to workspace and file management operations. -**CORRECT** (use #### headers): -```markdown -#### Scenario: User login success -- **WHEN** valid credentials provided -- **THEN** return JWT token -``` +#### Scenario: New Note menu item +- **WHEN** a user clicks "New Note" in the File menu +- **THEN** the existing createNewNote functionality is triggered +- **AND** the same behavior as clicking the "New Note" button occurs -**WRONG** (don't use bullets or bold): -```markdown -- **Scenario: User login** ❌ -**Scenario**: User login ❌ -### Scenario: User login ❌ -``` +#### Scenario: New Folder menu item +- **WHEN** a user clicks "New Folder" in the File menu +- **THEN** the existing createNewFolder functionality is triggered +- **AND** the same behavior as clicking the "New Folder" button occurs -Every requirement MUST have at least one scenario. +#### Scenario: Open Workspace menu item +- **WHEN** a user clicks "Open Workspace" in the File menu +- **THEN** the existing openWorkspace functionality is triggered +- **AND** the same behavior as clicking the "Open Workspace" button occurs -### Requirement Wording -- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) +#### Scenario: Close Workspace menu item +- **WHEN** a user clicks "Close Workspace" in the File menu +- **THEN** the workspace is closed and UI returns to initial state +- **AND** all workspace-specific UI elements are reset -### Delta Operations +### Requirement: Edit Menu Functionality +The Edit menu SHALL provide access to document editing and view operations. -- `## ADDED Requirements` - New capabilities -- `## MODIFIED Requirements` - Changed behavior -- `## REMOVED Requirements` - Deprecated features -- `## RENAMED Requirements` - Name changes +#### Scenario: Save menu item +- **WHEN** a user clicks "Save" in the Edit menu +- **THEN** the existing saveCurrentNote functionality is triggered +- **AND** the same behavior as clicking the "Save" button occurs -Headers matched with `trim(header)` - whitespace ignored. +#### Scenario: Refresh menu item +- **WHEN** a user clicks "Refresh" in the Edit menu +- **THEN** the existing rescanWorkspace functionality is triggered +- **AND** the same behavior as clicking the "Refresh" button occurs -#### When to use ADDED vs MODIFIED -- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. -- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. -- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. +#### Scenario: Sort toggle menu item +- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu +- **THEN** the existing sort functionality is triggered +- **AND** the same behavior as clicking the "Sort" button occurs -Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. +#### Scenario: Collapse Sidebar menu item +- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu +- **THEN** the existing setSidebarCollapsed functionality is triggered +- **AND** the same behavior as clicking the sidebar toggle button occurs -Authoring a MODIFIED requirement correctly: -1) Locate the existing requirement in `openspec/specs//spec.md`. -2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). -3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. -4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. +### Requirement: Import/Export Menu Functionality +The Import/Export menu SHALL provide access to data export operations. -Example for RENAMED: -```markdown -## RENAMED Requirements -- FROM: `### Requirement: Login` -- TO: `### Requirement: User Authentication` -``` +#### Scenario: Export JSON menu item +- **WHEN** a user clicks "Export JSON" in the Import/Export menu +- **THEN** the existing exportAsJson functionality is triggered +- **AND** the same behavior as clicking the "Export JSON" button occurs -## Troubleshooting +#### Scenario: Export Markdown menu item +- **WHEN** a user clicks "Export Markdown" in the Import/Export menu +- **THEN** the existing exportAsMarkdown functionality is triggered +- **AND** the same behavior as clicking the "Export MD" button occurs -### Common Errors +### Requirement: Menu Dropdown Behavior +Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. -**"Change must have at least one delta"** -- Check `changes/[name]/specs/` exists with .md files -- Verify files have operation prefixes (## ADDED Requirements) +#### Scenario: Mouse interaction with dropdowns +- **WHEN** a user hovers over or clicks a menu item +- **THEN** the dropdown menu opens +- **AND** clicking outside the menu closes it -**"Requirement must have at least one scenario"** -- Check scenarios use `#### Scenario:` format (4 hashtags) -- Don't use bullet points or bold for scenario headers +#### Scenario: Keyboard navigation of menus +- **WHEN** a user navigates using arrow keys +- **THEN** focus moves between menu items +- **AND** pressing Enter activates the focused menu item +- **AND** pressing Escape closes open dropdowns -**Silent scenario parsing failures** -- Exact format required: `#### Scenario: Name` -- Debug with: `openspec show [change] --json --deltas-only` +### Requirement: Backward Compatibility +The menu bar SHALL not interfere with existing button functionality. -### Validation Tips +#### Scenario: Buttons continue to work +- **WHEN** a user clicks existing buttons +- **THEN** the same functionality is triggered as before +- **AND** menu items provide alternative access to the same functions -```bash -# Always use strict mode for comprehensive checks -openspec validate [change] --strict +#### Scenario: Menu and button state synchronization +- **WHEN** a menu item is clicked +- **THEN** any related button states are updated appropriately +- **AND** the application maintains consistent state across all interfaces + -# Debug delta parsing -openspec show [change] --json | jq '.deltas' + +# Design: Office-Style Menu Bar Implementation -# Check specific requirement -openspec show [spec] --json -r 1 -``` +## Context -## Happy Path Script +The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. -```bash -# 1) Explore current state -openspec spec list --long -openspec list -# Optional full-text search: -# rg -n "Requirement:|Scenario:" openspec/specs -# rg -n "^#|Requirement:" openspec/changes +## Goals / Non-Goals -# 2) Choose change id and scaffold -CHANGE=add-two-factor-auth -mkdir -p openspec/changes/$CHANGE/{specs/auth} -printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md -printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md +### Goals +- Provide familiar File, Edit, and Import/Export menu structure +- Add keyboard shortcuts for common operations +- Maintain full backward compatibility with existing buttons +- Ensure accessibility with proper ARIA attributes +- Keep implementation simple and maintainable +- Follow existing code patterns and architecture -# 3) Add deltas (example) -cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' -## ADDED Requirements -### Requirement: Two-Factor Authentication -Users MUST provide a second factor during login. +### Non-Goals +- Replace existing button interface (menus complement, don't replace) +- Add complex menu features like toolbars or ribbons +- Implement undo/redo functionality (not currently in scope) +- Add theming or customization options +- Support for nested submenus beyond basic dropdowns -#### Scenario: OTP required -- **WHEN** valid credentials are provided -- **THEN** an OTP challenge is required -EOF +## Decisions -# 4) Validate -openspec validate $CHANGE --strict -``` +### Menu Structure Decision +**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. -## Multi-Capability Example +**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. -``` -openspec/changes/add-2fa-notify/ -├── proposal.md -├── tasks.md -└── specs/ - ├── auth/ - │ └── spec.md # ADDED: Two-Factor Authentication - └── notifications/ - └── spec.md # ADDED: OTP email notification -``` +**Alternatives considered**: +- Contextual menus only: Rejected because it reduces discoverability +- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar +- Hybrid approach: Rejected for complexity -auth/spec.md -```markdown -## ADDED Requirements -### Requirement: Two-Factor Authentication -... -``` +### Keyboard Shortcuts Decision +**Decision**: Implement standard desktop application shortcuts: +- Ctrl/Cmd+N: New Note +- Ctrl/Cmd+O: Open Workspace +- Ctrl/Cmd+S: Save +- F5: Refresh +- Ctrl/Cmd+Shift+S: Export JSON +- Ctrl/Cmd+Shift+M: Export Markdown -notifications/spec.md -```markdown -## ADDED Requirements -### Requirement: OTP Email Notification -... -``` +**Rationale**: These follow established conventions from applications like VS Code, Notepad++, and other desktop editors. -## Best Practices +**Alternatives considered**: +- Custom shortcuts: Rejected for discoverability +- No shortcuts: Rejected because power users expect them -### Simplicity First -- Default to <100 lines of new code -- Single-file implementations until proven insufficient -- Avoid frameworks without clear justification -- Choose boring, proven patterns +### Integration Approach Decision +**Decision**: Extend existing InkApp class methods rather than creating new menu-specific functions. -### Complexity Triggers -Only add complexity with: -- Performance data showing current solution too slow -- Concrete scale requirements (>1000 users, >100MB data) -- Multiple proven use cases requiring abstraction +**Rationale**: This maintains code reuse and ensures consistent behavior between button clicks and menu selections. The existing methods already handle edge cases and error conditions properly. -### Clear References -- Use `file.ts:42` format for code locations -- Reference specs as `specs/auth/spec.md` -- Link related changes and PRs +**Alternatives considered**: +- Separate menu handlers: Rejected because it would duplicate logic +- Event delegation: Rejected because direct method calls are simpler -### Capability Naming -- Use verb-noun: `user-auth`, `payment-capture` -- Single purpose per capability -- 10-minute understandability rule -- Split if description needs "AND" +### Accessibility Decision +**Decision**: Implement full ARIA support with proper roles, labels, and keyboard navigation. -### Change ID Naming -- Use kebab-case, short and descriptive: `add-two-factor-auth` -- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` -- Ensure uniqueness; if taken, append `-2`, `-3`, etc. +**Rationale**: Essential for screen reader users and keyboard-only navigation. Follows WCAG guidelines for menu widgets. -## Tool Selection Guide +**Alternatives considered**: +- Basic accessibility: Rejected because it doesn't meet modern standards +- No accessibility: Rejected as unacceptable -| Task | Tool | Why | -|------|------|-----| -| Find files by pattern | Glob | Fast pattern matching | -| Search code content | Grep | Optimized regex search | -| Read specific files | Read | Direct file access | -| Explore unknown scope | Task | Multi-step investigation | +## Risks / Trade-offs -## Error Recovery +### Risk: Layout Complexity +**Risk**: Adding menu bar could complicate the existing layout and cause responsive design issues. -### Change Conflicts -1. Run `openspec list` to see active changes -2. Check for overlapping specs -3. Coordinate with change owners -4. Consider combining proposals +**Mitigation**: +- Use CSS Grid/Flexbox for flexible layout +- Test thoroughly on different screen sizes +- Maintain existing responsive behavior -### Validation Failures -1. Run with `--strict` flag -2. Check JSON output for details -3. Verify spec file format -4. Ensure scenarios properly formatted +### Risk: Keyboard Shortcut Conflicts +**Risk**: New shortcuts might conflict with browser or OS shortcuts. -### Missing Context -1. Read project.md first -2. Check related specs -3. Review recent archives -4. Ask for clarification +**Mitigation**: +- Use standard shortcuts that don't conflict +- Add proper event.preventDefault() handling +- Test on different platforms -## Quick Reference +### Risk: Performance Impact +**Risk**: Additional DOM elements and event listeners could impact performance. -### Stage Indicators -- `changes/` - Proposed, not yet built -- `specs/` - Built and deployed -- `archive/` - Completed changes +**Mitigation**: +- Use event delegation where possible +- Minimize DOM manipulation +- Follow existing performance patterns -### File Purposes -- `proposal.md` - Why and what -- `tasks.md` - Implementation steps -- `design.md` - Technical decisions -- `spec.md` - Requirements and behavior +## Migration Plan -### CLI Essentials -```bash -openspec list # What's in progress? -openspec show [item] # View details -openspec validate --strict # Is it correct? -openspec archive [--yes|-y] # Mark complete (add --yes for automation) -``` +### Phase 1: Core Menu Structure +1. Add HTML structure and basic CSS +2. Implement menu toggle functionality +3. Add basic keyboard navigation -Remember: Specs are truth. Changes are proposals. Keep them in sync. - +### Phase 2: Menu Item Implementation +1. Implement File menu items +2. Implement Edit menu items +3. Implement Import/Export menu items - -/** - * Auth UI Controller - * Manages auth-related UI and connects auth modules with the app - * @module auth/auth-controller - */ +### Phase 3: Polish and Testing +1. Add comprehensive tests +2. Implement accessibility features +3. Add keyboard shortcuts +4. Performance optimization -import type { DomRefs, GitHubUser } from "./types"; -import type { GitHubAuthManager } from "../auth/github"; -import type { GitHubUserManager } from "../auth/user"; +### Rollback Plan +If issues arise, the menu bar can be easily disabled by: +1. Removing menu HTML structure +2. Removing menu-specific CSS +3. Removing menu event handlers +4. All existing functionality remains intact -export interface AuthController { - initialize: () => void; - getCurrentUser: () => GitHubUser | null; - isAuthenticated: () => boolean; - getToken: () => string | null; -} +## Open Questions -export function createAuthController( - els: DomRefs, - authManager: GitHubAuthManager, - userManager: GitHubUserManager, - showToast: (message: string, options?: { persist?: boolean }) => void, -): AuthController { - let currentUser: GitHubUser | null = null; +1. **Should we add a Help menu?** - Currently not planned, but could be added later +2. **Should menu items be disabled when not applicable?** - Yes, following existing button patterns +3. **Should we support right-click context menus?** - Not in initial implementation, could be added later +4. **Should we add menu animations?** - No, keeping it simple and fast + - function updateAuthUI(authenticated: boolean): void { - if (authenticated) { - els.loginBtn.style.display = "none"; - els.userMenu.style.display = "inline-flex"; - } else { - els.loginBtn.style.display = "inline-flex"; - els.userMenu.style.display = "none"; - currentUser = null; - els.userAvatar.src = ""; - els.userName.textContent = ""; - } - } + +# Change: Add Office-Style Menu Bar - function updateUserUI(user: GitHubUser | null): void { - if (user) { - currentUser = user; - els.userAvatar.src = user.avatarUrl; - els.userAvatar.alt = `${user.login}'s avatar`; - els.userName.textContent = user.name || user.login; - } else { - currentUser = null; - els.userAvatar.src = ""; - els.userAvatar.alt = ""; - els.userName.textContent = ""; - } - } +## Why - function handleAuthError(error: Error): void { - els.authError.textContent = error.message; - els.authError.style.display = "block"; - els.authStatus.textContent = ""; - } +The ink markdown note-taking application currently lacks a proper office-style menu bar (File, Edit, Import/Export) that users expect from desktop applications. This makes common operations like opening workspaces, creating files/folders, and exporting content less discoverable and accessible. Adding a menu bar will improve user experience by providing familiar navigation patterns and keyboard shortcuts. - function handleAuthStep(message: string | null): void { - if (message) { - els.authStatus.textContent = message; - els.authError.style.display = "none"; - } else { - els.authStatus.textContent = ""; - } - } +## What Changes - async function startLogin(): Promise { - try { - els.authError.style.display = "none"; +- **BREAKING**: Add a new menu bar component with File, Edit, and Import/Export sections +- Integrate existing functionality (open workspace, create files/folders, export) into menu items +- Add keyboard shortcuts for common operations (Ctrl/Cmd+N, Ctrl/Cmd+O, Ctrl/Cmd+S, etc.) +- Maintain existing button functionality while adding menu alternatives +- Add accessibility attributes and proper ARIA labeling +- Update UI layout to accommodate menu bar - // Start the OAuth flow - this will redirect to GitHub - await authManager.startAuthFlow(); +## Impact - // Note: Code execution stops here as the browser redirects to GitHub - // The callback will be handled when the user returns - } catch (error) { - if (error instanceof Error) { - handleAuthError(error); - } else { - handleAuthError(new Error(String(error))); - } - } - } +- Affected specs: `menu-bar`, `keyboard-shortcuts`, `accessibility` +- Affected code: `src/app/bootstrap.ts`, `src/app/dom.ts`, `src/app/types.ts`, `ink-app.html` +- New files: Menu component implementation, CSS styles, comprehensive tests +- User experience: Improved discoverability of features, familiar desktop application patterns +- Backward compatibility: All existing functionality preserved, menu adds new access points + - function handleLogout(): void { - authManager.logout(); - userManager.clearCache(); - updateAuthUI(false); - updateUserUI(null); - showToast("Logged out successfully."); - } + +# 1. Implementation - /** - * Handles OAuth callback - called when returning from GitHub - */ - async function handleCallback(): Promise { - try { - const success = await authManager.handleCallback(); +## 1.1 Menu Bar Structure +- [x] 1.1.1 Add menu bar HTML structure to ink-app.html +- [x] 1.1.2 Create CSS styles for menu bar layout and dropdowns +- [x] 1.1.3 Add ARIA attributes for accessibility (role="menubar", aria-haspopup, etc.) - if (success) { - const token = authManager.getToken(); - if (token) { - const user = await userManager.fetchUser(token); - updateUserUI(user); - showToast("Successfully logged in with GitHub!", { persist: false }); - } - } +## 1.2 File Menu Implementation +- [x] 1.2.1 Implement "New Note" menu item (Ctrl/Cmd+N) +- [x] 1.2.2 Implement "New Folder" menu item +- [x] 1.2.3 Implement "Open Workspace" menu item (Ctrl/Cmd+O) +- [x] 1.2.4 Implement "Close Workspace" menu item +- [x] 1.2.5 Implement "Exit" menu item - return success; - } catch (error) { - if (error instanceof Error) { - handleAuthError(error); - } else { - handleAuthError(new Error(String(error))); - } - return false; - } - } +## 1.3 Edit Menu Implementation +- [x] 1.3.1 Implement "Save" menu item (Ctrl/Cmd+S) +- [x] 1.3.2 Implement "Save As" menu item +- [x] 1.3.3 Implement "Refresh" menu item (F5) +- [x] 1.3.4 Implement "Sort" toggle menu item +- [x] 1.3.5 Implement "Collapse Sidebar" menu item - function handleModalClose(): void { - els.authModal.style.display = "none"; - } +## 1.4 Import/Export Menu Implementation +- [x] 1.4.1 Implement "Export JSON" menu item +- [x] 1.4.2 Implement "Export Markdown" menu item +- [ ] 1.4.3 Implement "Import JSON" menu item (if needed) - function initialize(): void { - // Check if this is a callback (URL has code param) - const url = new URL(window.location.href); - const isCallback = url.searchParams.has("code") || url.searchParams.has("error"); +## 1.5 Keyboard Shortcuts +- [x] 1.5.1 Add global keyboard event listener for menu shortcuts +- [x] 1.5.2 Implement Ctrl/Cmd+E for new note +- [x] 1.5.3 Implement Ctrl/Cmd+Shift+O for open workspace +- [x] 1.5.4 Implement Ctrl/Cmd+S for save +- [x] 1.5.5 Implement Ctrl/Cmd+L for refresh +- [x] 1.5.6 Add visual indicators for keyboard shortcuts in menu items - if (isCallback) { - // Handle the OAuth callback - handleCallback().then(() => { - // Clean up URL after handling - }).catch(() => { - // Error already handled in handleCallback - }); - } +## 1.6 Integration with Existing Code +- [x] 1.6.1 Update InkApp class to handle menu events +- [x] 1.6.2 Update DOM references in dom.ts to include menu elements +- [x] 1.6.3 Update types.ts with new menu-related types +- [x] 1.6.4 Ensure menu items call existing InkApp methods +- [x] 1.6.5 Maintain backward compatibility with existing buttons - authManager.subscribe({ - onStateChange: (state) => { - updateAuthUI(state.isAuthenticated); - }, - onError: handleAuthError, - onAuthStep: handleAuthStep, - }); +## 1.7 Testing +- [ ] 1.7.1 Add Cypress tests for menu bar functionality +- [ ] 1.7.2 Add QUnit tests for menu component logic +- [ ] 1.7.3 Test keyboard shortcuts with Cypress +- [ ] 1.7.4 Test accessibility with screen reader simulation +- [ ] 1.7.5 Test menu dropdown behavior and state management - els.loginBtn.addEventListener("click", () => { - startLogin(); - }); +## 1.8 Documentation and Polish +- [ ] 1.8.1 Update README.md with new menu features +- [ ] 1.8.2 Add keyboard shortcut documentation +- [ ] 1.8.3 Test responsive design for mobile/tablet +- [x] 1.8.4 Ensure proper error handling and user feedback +- [x] 1.8.5 Code review and cleanup + - els.logoutBtn.addEventListener("click", () => { - handleLogout(); - }); + +## ADDED Requirements +### Requirement: Prioritized Maintainability Refactor Plan +The project SHALL define a maintainability refactor plan with explicit High, Medium, and Low priority tiers based on engineering risk and value. - els.authModalCloseBtn.addEventListener("click", () => { - handleModalClose(); - }); +#### Scenario: Plan is prioritized and actionable +- **WHEN** a maintainer reviews the approved change proposal +- **THEN** the proposal includes clearly separated High, Medium, and Low priority work +- **AND** each tier contains concrete codebase targets - els.authModal.addEventListener("click", (event) => { - if (event.target === els.authModal) { - handleModalClose(); - } - }); +### Requirement: Evidence-Based Refactor Scope +Refactor recommendations MUST be grounded in current repository evidence and MUST avoid unnecessary changes. - document.addEventListener("keydown", (event) => { - if (event.key === "Escape" && els.authModal.style.display !== "none") { - handleModalClose(); - } - }); +#### Scenario: Recommendations are justified +- **WHEN** a recommendation proposes delete, rename, or restructure actions +- **THEN** it references observable repository state (for example dead code, missing script targets, or duplicated logic) +- **AND** the proposal explicitly identifies areas that should remain unchanged when already aligned - if (authManager.restoreSession()) { - const token = authManager.getToken(); - if (token) { - userManager.fetchUser(token).then((user) => { - updateUserUI(user); - }).catch(() => { - authManager.logout(); - userManager.clearCache(); - }); - } - } - } +### Requirement: Behavior-Preserving Refactor Guardrails +Maintainability refactors SHALL preserve existing user-visible behavior unless a separate feature change is approved. - return { - initialize, - getCurrentUser: () => currentUser, - isAuthenticated: () => authManager.isAuthenticated(), - getToken: () => authManager.getToken(), - }; -} +#### Scenario: Refactor keeps current behavior stable +- **WHEN** maintainability refactor tasks are implemented +- **THEN** build and test workflows continue to pass +- **AND** core authoring flow behavior (open workspace, create note, edit, save) remains unchanged + +### Requirement: Tooling and Documentation Consistency +Documented commands and scripts MUST map to files and behavior that exist in the repository. + +#### Scenario: Script/docs mismatch is resolved +- **WHEN** package scripts or README commands are documented +- **THEN** each command resolves to existing code paths +- **AND** stale or broken command references are removed or restored - -/** - * GitHub OAuth Authentication Module - * Implements RFC 7636 PKCE OAuth Web Flow for browser-based apps - * @module auth/github - */ + +## Context +Ink is intentionally lightweight and single-file at runtime. The refactor must preserve behavior while making source code easier for LLMs to read, regenerate, and safely modify. -import type { AuthState, AuthEventMap, AuthListeners } from "../app/types"; +## Goals / Non-Goals +- Goals: + - Reduce cognitive load in `src/app.ts` by splitting by feature boundaries. + - Remove stale/dead paths that create false complexity. + - Improve naming so build and runtime responsibilities are explicit. + - Keep behavior and user workflow unchanged. +- Non-Goals: + - Framework migration. + - UX redesign. + - New product features. -const GITHUB_CLIENT_ID = "Ov23liedFdRiJdiifvVX"; -const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; -const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; -const STORAGE_KEY = "ink_github_token"; -const CODE_VERIFIER_KEY = "ink_github_code_verifier"; +## Decisions +- Decision: Refactor in phases (High -> Medium -> Low) with behavior-preserving checkpoints. + - Rationale: minimizes regression risk and keeps scope manageable. +- Decision: Keep a thin composition entrypoint and move concerns into small modules. + - Rationale: supports predictable regeneration and targeted testing. +- Decision: Do not force changes where current code is already clear (for example `src/tags.ts`). + - Rationale: avoids churn and preserves momentum. -const DEFAULT_POLL_INTERVAL_MS = 5000; -const MIN_POLL_INTERVAL_MS = 1000; -const MAX_POLL_INTERVAL_MS = 60000; -const BACKOFF_MULTIPLIER = 2; -const MAX_RETRIES = 10; +## Risks / Trade-offs +- Risk: File splits can break implicit state assumptions. + - Mitigation: define explicit typed state contracts and test around open/save/refresh flows. +- Risk: Renaming scripts can disrupt local habits. + - Mitigation: keep temporary aliases and document migration in README. -/** - * Error types for auth operations - */ -export enum AuthErrorType { - NetworkError = "network_error", - AccessDenied = "access_denied", - InvalidRequest = "invalid_request", - ServerError = "server_error", - InvalidState = "invalid_state", - Timeout = "timeout", - Unknown = "unknown", -} +## Migration Plan +1. Remove dead code and duplicate timers in `src/app.ts`. +2. Introduce module boundaries for workspace scan, editor operations, and rendering. +3. Rename build scripts/files with compatibility aliases. +4. Re-run build + QUnit + Cypress and update docs. -/** - * Custom error class for auth operations - */ -export class AuthError extends Error { - constructor( - public readonly type: AuthErrorType, - message: string, - public readonly retryAfterMs?: number, - ) { - super(message); - this.name = "AuthError"; - } -} +## Open Questions +- Should `sync:from-ink-app` be restored (by adding script) or removed from docs/scripts? +- Should `src/storage.ts` remain a test fixture in `src/` or move to a dedicated test-support path? + -interface TokenResponse { - access_token?: string; - token_type?: string; - scope?: string; - error?: string; - error_description?: string; -} + +# Change: LLM-Aligned Maintainability Refactor Plan -/** - * Generates a cryptographically random string for PKCE code_verifier - * Per RFC 7636: 43-128 characters from [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" - */ -export function generateCodeVerifier(): string { - const array = new Uint8Array(64); - crypto.getRandomValues(array); - // Base64url encode and strip padding, then filter to valid chars - const base64 = btoa(String.fromCharCode(...array)); - return base64 - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, "") - .slice(0, 128); -} +## Why +The current codebase works, but it has several maintainability risks that reduce predictability and regenerability for LLM-driven development. The biggest issue is concentration of behavior in one large file, plus stale scripts and dead code paths that make intent harder to reason about. -/** - * Generates code_challenge from code_verifier using S256 method - * Per RFC 7636: BASE64URL(SHA256(code_verifier)) - */ -export async function generateCodeChallenge(verifier: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(verifier); - const hash = await crypto.subtle.digest("SHA-256", data); - return btoa(String.fromCharCode(...new Uint8Array(hash))) - .replace(/\+/g, "-") - .replace(/\//g, "_") - .replace(/=/g, ""); -} +## What Changes +- Create a phased refactor plan aligned to project coding principles: clarity, pragmatism, rigor. +- Prioritize only high-value changes and explicitly avoid churn where behavior is already clear and stable. +- Define concrete rename/restructure/delete targets backed by repository evidence. +- Add acceptance criteria so refactor work can be verified without changing product behavior. -/** - * Creates an exponential backoff delay with jitter - */ -export function calculateBackoff( - attempt: number, - baseIntervalMs: number = DEFAULT_POLL_INTERVAL_MS, -): number { - const exponentialDelay = baseIntervalMs * Math.pow(BACKOFF_MULTIPLIER, attempt); - const jitter = Math.random() * 1000; - return Math.min(exponentialDelay + jitter, MAX_POLL_INTERVAL_MS); -} +### High Priority +- Split `src/app.ts` into small feature modules with a thin entrypoint (`main` + feature-oriented files). +- Remove duplicated auto-refresh behavior (currently both `startAutoRefresh()` timer and a separate global `setInterval` run every 10s). +- Fix stale/broken tooling contract: `sync:from-ink-app` references `build/sync-from-ink-app.js`, but the file is missing. +- Remove dead fields/utilities in `src/app.ts`: + - `searchScanDebounceTimer` + - `lastContentSearchToken` + - `sleep()` + - `humanDate()` + - `debounce()` -/** - * Parses GitHub API JSON response - */ -async function parseJsonResponse(response: Response): Promise { - const json = await response.json(); - return json as T; -} +### Medium Priority +- Rename ambiguous build scripts for intent clarity: + - `build/inject.js` -> `build/assemble-single-file.js` + - `build/build.js` -> `build/compile-and-assemble.js` +- Extract File System Access API handling into a dedicated module (permissions, handles, scan traversal) to reduce coupling with UI rendering. +- Replace broad `any` usage in app state with narrow interfaces for notes, tree nodes, and workspace handles. +- Remove redundant DOM lookups in `src/app.ts` where elements are already captured once. -function sleep(ms: number): Promise { - return new Promise((resolve) => setTimeout(resolve, ms)); -} +### Low Priority +- Decide whether test-only storage logic should be renamed for intent: + - `src/storage.ts` -> `src/test-support/storage-fixture.ts` (if kept test-only) +- Decide whether generated test bundles should stay committed: + - `dist/test/*` can be generated during `npm run build:test` and excluded from source control. +- Tighten Cypress scaffolding comments/config only if it improves signal-to-noise (avoid cosmetic edits). -/** - * Creates the auth state manager - */ -export function createAuthManager() { - const listeners: AuthListeners = { - onStateChange: null, - onError: null, - onAuthStep: null, - }; +### Keep As-Is (No Change Needed) +- `src/tags.ts` is already small, explicit, and testable. +- Build pipeline remains lightweight and appropriate for single-file distribution constraints. +- Existing QUnit and Cypress coverage should be retained and expanded only around touched refactor areas. - let state: AuthState = { - isAuthenticated: false, - isLoading: false, - error: null, - authStep: null, - }; +## Impact +- Affected specs: `codebase-maintainability` +- Affected code: + - `src/app.ts` + - `src/storage.ts` (rename/rehome decision) + - `build/build.js` + - `build/inject.js` + - `package.json` + - `README.md` + - `Makefile` (if command wrappers change) + - `tests/qunit/*` + - `cypress/*` + - let currentToken: string | null = null; + +## 1. High Priority (Safety + Clarity) +- [x] 1.1 Remove duplicate auto-refresh scheduling and keep one refresh control path. +- [x] 1.2 Remove dead fields/utilities from `src/app.ts` (`searchScanDebounceTimer`, `lastContentSearchToken`, `sleep`, `humanDate`, `debounce`). +- [x] 1.3 Resolve broken `sync:from-ink-app` contract by either restoring `build/sync-from-ink-app.js` or removing stale script/docs. +- [x] 1.4 Split `src/app.ts` into a small entrypoint plus feature modules with explicit interfaces. - function emitStateChange(): void { - if (listeners.onStateChange) { - listeners.onStateChange({ ...state }); - } - } +## 2. Medium Priority (Structure + Naming) +- [x] 2.1 Rename build scripts/files for clearer responsibility and keep compatibility aliases during migration. +- [x] 2.2 Extract File System Access API logic into a dedicated module. +- [x] 2.3 Replace broad `any` usage in app state/types with narrower interfaces. +- [x] 2.4 Remove redundant element re-queries in app initialization. - function emitError(error: AuthError): void { - if (listeners.onError) { - listeners.onError(error); - } - } +## 3. Low Priority (Cleanup) +- [x] 3.1 Decide whether to rename/rehome `src/storage.ts` as test-support-only code. +- [x] 3.2 Decide whether `dist/test/*` should be generated-only and excluded from source control. (Decision: keep committed for now to preserve current repository pattern with generated distribution artifacts under `dist/`.) +- [x] 3.3 Clean minor Cypress scaffolding noise only when it improves maintainability. - function emitAuthStep(message: string | null): void { - state.authStep = message; - if (listeners.onAuthStep) { - listeners.onAuthStep(message); - } - emitStateChange(); - } +## 4. Validation +- [x] 4.1 `npm run build` succeeds and produces a working `ink-app.html`. +- [x] 4.2 `npm run test:qunit` passes. +- [x] 4.3 `npm run test:cypress` passes. +- [x] 4.4 README and script references match actual files/commands. + - function setState(updates: Partial): void { - state = { ...state, ...updates }; - emitStateChange(); - } + +# OpenSpec Instructions - /** - * Retrieves token from localStorage - */ - function getStoredToken(): string | null { - try { - return localStorage.getItem(STORAGE_KEY); - } catch { - return null; - } - } +Instructions for AI coding assistants using OpenSpec for spec-driven development. - /** - * Stores token in localStorage - */ - function storeToken(token: string): void { - try { - localStorage.setItem(STORAGE_KEY, token); - currentToken = token; - } catch { - throw new AuthError( - AuthErrorType.Unknown, - "Failed to store token securely.", - ); - } - } +## TL;DR Quick Checklist - /** - * Clears stored token from localStorage - */ - function clearStoredToken(): void { - try { - localStorage.removeItem(STORAGE_KEY); - currentToken = null; - } catch { - // Ignore storage errors on logout - } - } +- Search existing work: `openspec spec list --long`, `openspec list` (use `rg` only for full-text search) +- Decide scope: new capability vs modify existing capability +- Pick a unique `change-id`: kebab-case, verb-led (`add-`, `update-`, `remove-`, `refactor-`) +- Scaffold: `proposal.md`, `tasks.md`, `design.md` (only if needed), and delta specs per affected capability +- Write deltas: use `## ADDED|MODIFIED|REMOVED|RENAMED Requirements`; include at least one `#### Scenario:` per requirement +- Validate: `openspec validate [change-id] --strict` and fix issues +- Request approval: Do not start implementation until proposal is approved - /** - * Stores code_verifier in sessionStorage (not localStorage for security) - */ - function storeCodeVerifier(verifier: string): void { - try { - sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); - } catch { - throw new AuthError( - AuthErrorType.Unknown, - "Failed to store verification code.", - ); - } - } +## Three-Stage Workflow - /** - * Retrieves and clears code_verifier from sessionStorage - */ - function getAndClearCodeVerifier(): string | null { - try { - const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY); - sessionStorage.removeItem(CODE_VERIFIER_KEY); - return verifier; - } catch { - return null; - } - } +### Stage 1: Creating Changes +Create proposal when you need to: +- Add features or functionality +- Make breaking changes (API, schema) +- Change architecture or patterns +- Optimize performance (changes behavior) +- Update security patterns - /** - * Generates authorization URL with PKCE parameters - * Uses localhost for development, configurable for production - */ - async function buildAuthorizationUrl(codeChallenge: string): Promise { - const redirectUri = getRedirectUri(); - const params = new URLSearchParams({ - client_id: GITHUB_CLIENT_ID, - redirect_uri: redirectUri, - scope: "read:user", - code_challenge: codeChallenge, - code_challenge_method: "S256", - state: generateState(), - }); - return new URL(`${GITHUB_AUTHORIZE_URL}?${params.toString()}`); - } +Triggers (examples): +- "Help me create a change proposal" +- "Help me plan a change" +- "Help me create a proposal" +- "I want to create a spec proposal" +- "I want to create a spec" - /** - * Gets redirect URI based on environment - */ - function getRedirectUri(): string { - return window.location.origin + window.location.pathname; - } +Loose matching guidance: +- Contains one of: `proposal`, `change`, `spec` +- With one of: `create`, `plan`, `make`, `start`, `help` - /** - * Generates random state parameter for CSRF protection - */ - function generateState(): string { - const array = new Uint8Array(16); - crypto.getRandomValues(array); - return Array.from(array) - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - } +Skip proposal for: +- Bug fixes (restore intended behavior) +- Typos, formatting, comments +- Dependency updates (non-breaking) +- Configuration changes +- Tests for existing behavior - /** - * Starts the OAuth PKCE flow - * Redirects browser to GitHub authorization page - */ - async function startAuthFlow(): Promise { - setState({ isLoading: true, error: null }); - emitAuthStep("Preparing authentication..."); +**Workflow** +1. Review `openspec/project.md`, `openspec list`, and `openspec list --specs` to understand current context. +2. Choose a unique verb-led `change-id` and scaffold `proposal.md`, `tasks.md`, optional `design.md`, and spec deltas under `openspec/changes//`. +3. Draft spec deltas using `## ADDED|MODIFIED|REMOVED Requirements` with at least one `#### Scenario:` per requirement. +4. Run `openspec validate --strict` and resolve any issues before sharing the proposal. - try { - // Generate PKCE code_verifier and code_challenge - const codeVerifier = generateCodeVerifier(); - const codeChallenge = await generateCodeChallenge(codeVerifier); +### Stage 2: Implementing Changes +Track these steps as TODOs and complete them one by one. +1. **Read proposal.md** - Understand what's being built +2. **Read design.md** (if exists) - Review technical decisions +3. **Read tasks.md** - Get implementation checklist +4. **Implement tasks sequentially** - Complete in order +5. **Confirm completion** - Ensure every item in `tasks.md` is finished before updating statuses +6. **Update checklist** - After all work is done, set every task to `- [x]` so the list reflects reality +7. **Approval gate** - Do not start implementation until the proposal is reviewed and approved - // Store verifier in sessionStorage for later token exchange - storeCodeVerifier(codeVerifier); +### Stage 3: Archiving Changes +After deployment, create separate PR to: +- Move `changes/[name]/` → `changes/archive/YYYY-MM-DD-[name]/` +- Update `specs/` if capabilities changed +- Use `openspec archive --skip-specs --yes` for tooling-only changes (always pass the change ID explicitly) +- Run `openspec validate --strict` to confirm the archived change passes checks - // Build authorization URL - const authUrl = await buildAuthorizationUrl(codeChallenge); +## Before Any Task - emitAuthStep("Redirecting to GitHub..."); +**Context Checklist:** +- [ ] Read relevant specs in `specs/[capability]/spec.md` +- [ ] Check pending changes in `changes/` for conflicts +- [ ] Read `openspec/project.md` for conventions +- [ ] Run `openspec list` to see active changes +- [ ] Run `openspec list --specs` to see existing capabilities - // Redirect browser to GitHub - window.location.href = authUrl.toString(); - } catch (e) { - setState({ isLoading: false }); - if (e instanceof AuthError) { - emitError(e); - throw e; - } - const error = new AuthError( - AuthErrorType.NetworkError, - `Failed to start authentication: ${String(e)}`, - ); - emitError(error); - throw error; - } - } +**Before Creating Specs:** +- Always check if capability already exists +- Prefer modifying existing specs over creating duplicates +- Use `openspec show [spec]` to review current state +- If request is ambiguous, ask 1–2 clarifying questions before scaffolding - /** - * Handles the OAuth callback - * Called when user is redirected back from GitHub - */ - async function handleCallback(): Promise { - const url = new URL(window.location.href); +### Search Guidance +- Enumerate specs: `openspec spec list --long` (or `--json` for scripts) +- Enumerate changes: `openspec list` (or `openspec change list --json` - deprecated but available) +- Show details: + - Spec: `openspec show --type spec` (use `--json` for filters) + - Change: `openspec show --json --deltas-only` +- Full-text search (use ripgrep): `rg -n "Requirement:|Scenario:" openspec/specs` - // Check for error in URL (user denied access or other errors) - const error = url.searchParams.get("error"); - const errorDescription = url.searchParams.get("error_description"); +## Quick Start - if (error) { - if (error === "access_denied") { - const authError = new AuthError( - AuthErrorType.AccessDenied, - "Authorization denied. Please try again.", - ); - setState({ isLoading: false, error: authError.message }); - emitError(authError); - clearUrlParams(); - return false; - } +### CLI Commands - const authError = new AuthError( - AuthErrorType.InvalidRequest, - errorDescription || `Authentication error: ${error}`, - ); - setState({ isLoading: false, error: authError.message }); - emitError(authError); - clearUrlParams(); - return false; - } +```bash +# Essential commands +openspec list # List active changes +openspec list --specs # List specifications +openspec show [item] # Display change or spec +openspec validate [item] # Validate changes or specs +openspec archive [--yes|-y] # Archive after deployment (add --yes for non-interactive runs) - // Get authorization code - const code = url.searchParams.get("code"); - if (!code) { - const authError = new AuthError( - AuthErrorType.InvalidState, - "Missing authorization code in callback.", - ); - setState({ isLoading: false, error: authError.message }); - emitError(authError); - clearUrlParams(); - return false; - } +# Project management +openspec init [path] # Initialize OpenSpec +openspec update [path] # Update instruction files - // Get code_verifier from sessionStorage - const codeVerifier = getAndClearCodeVerifier(); - if (!codeVerifier) { - const authError = new AuthError( - AuthErrorType.InvalidState, - "Verification code expired. Please try again.", - ); - setState({ isLoading: false, error: authError.message }); - emitError(authError); - clearUrlParams(); - return false; - } +# Interactive mode +openspec show # Prompts for selection +openspec validate # Bulk validation mode - setState({ isLoading: true }); - emitAuthStep("Exchanging code for token..."); +# Debugging +openspec show [change] --json --deltas-only +openspec validate [change] --strict +``` - try { - const token = await exchangeCodeForToken(code, codeVerifier); +### Command Flags - storeToken(token); - setState({ - isAuthenticated: true, - isLoading: false, - error: null, - authStep: null, - }); +- `--json` - Machine-readable output +- `--type change|spec` - Disambiguate items +- `--strict` - Comprehensive validation +- `--no-interactive` - Disable prompts +- `--skip-specs` - Archive without spec updates +- `--yes`/`-y` - Skip confirmation prompts (non-interactive archive) - clearUrlParams(); - return true; - } catch (e) { - if (e instanceof AuthError) { - setState({ isLoading: false, error: e.message }); - emitError(e); - clearUrlParams(); - return false; - } +## Directory Structure - const authError = new AuthError( - AuthErrorType.NetworkError, - `Token exchange failed: ${String(e)}`, - ); - setState({ isLoading: false, error: authError.message }); - emitError(authError); - clearUrlParams(); - return false; - } - } +``` +openspec/ +├── project.md # Project conventions +├── specs/ # Current truth - what IS built +│ └── [capability]/ # Single focused capability +│ ├── spec.md # Requirements and scenarios +│ └── design.md # Technical patterns +├── changes/ # Proposals - what SHOULD change +│ ├── [change-name]/ +│ │ ├── proposal.md # Why, what, impact +│ │ ├── tasks.md # Implementation checklist +│ │ ├── design.md # Technical decisions (optional; see criteria) +│ │ └── specs/ # Delta changes +│ │ └── [capability]/ +│ │ └── spec.md # ADDED/MODIFIED/REMOVED +│ └── archive/ # Completed changes +``` - /** - * Clears sensitive params from URL after callback - */ - function clearUrlParams(): void { - // Use replaceState to avoid polluting browser history - const cleanUrl = window.location.pathname + window.location.hash; - window.history.replaceState({}, document.title, cleanUrl); - } +## Creating Change Proposals - /** - * Exchanges authorization code for access token - */ - async function exchangeCodeForToken( - code: string, - codeVerifier: string, - ): Promise { - const response = await fetch(GITHUB_TOKEN_URL, { - method: "POST", - headers: { - "Content-Type": "application/json", - Accept: "application/json", - }, - body: JSON.stringify({ - client_id: GITHUB_CLIENT_ID, - code: code, - code_verifier: codeVerifier, - grant_type: "authorization_code", - }), - }); +### Decision Tree - if (!response.ok) { - const authError = new AuthError( - AuthErrorType.ServerError, - `Token request failed: ${response.status}`, - ); - throw authError; - } +``` +New request? +├─ Bug fix restoring spec behavior? → Fix directly +├─ Typo/format/comment? → Fix directly +├─ New feature/capability? → Create proposal +├─ Breaking change? → Create proposal +├─ Architecture change? → Create proposal +└─ Unclear? → Create proposal (safer) +``` - const data = await parseJsonResponse(response); +### Proposal Structure - if (data.error) { - if (data.error === "access_denied") { - throw new AuthError( - AuthErrorType.AccessDenied, - "Authorization denied. Please try again.", - ); - } +1. **Create directory:** `changes/[change-id]/` (kebab-case, verb-led, unique) - if (data.error === "expired_token") { - throw new AuthError( - AuthErrorType.Timeout, - "Authorization expired. Please try again.", - ); - } +2. **Write proposal.md:** +```markdown +# Change: [Brief description of change] - throw new AuthError( - AuthErrorType.InvalidRequest, - data.error_description || `Authentication error: ${data.error}`, - ); - } +## Why +[1-2 sentences on problem/opportunity] - if (!data.access_token) { - throw new AuthError( - AuthErrorType.ServerError, - "No access token in response.", - ); - } +## What Changes +- [Bullet list of changes] +- [Mark breaking changes with **BREAKING**] - return data.access_token; - } +## Impact +- Affected specs: [list capabilities] +- Affected code: [key files/systems] +``` - /** - * Checks if user has existing valid session - */ - function restoreSession(): boolean { - const token = getStoredToken(); - if (token) { - currentToken = token; - setState({ - isAuthenticated: true, - isLoading: false, - error: null, - authStep: null, - }); - return true; - } - return false; - } +3. **Create spec deltas:** `specs/[capability]/spec.md` +```markdown +## ADDED Requirements +### Requirement: New Feature +The system SHALL provide... - /** - * Logs out the user and clears all stored data - */ - function logout(): void { - clearStoredToken(); - // Clear any stored code verifier - try { - sessionStorage.removeItem(CODE_VERIFIER_KEY); - } catch { - // Ignore - } - setState({ - isAuthenticated: false, - isLoading: false, - error: null, - authStep: null, - }); - } +#### Scenario: Success case +- **WHEN** user performs action +- **THEN** expected result - /** - * Gets current access token - */ - function getToken(): string | null { - return currentToken; - } +## MODIFIED Requirements +### Requirement: Existing Feature +[Complete modified requirement] - /** - * Checks if currently authenticated - */ - function isAuthenticated(): boolean { - return state.isAuthenticated; - } +## REMOVED Requirements +### Requirement: Old Feature +**Reason**: [Why removing] +**Migration**: [How to handle] +``` +If multiple capabilities are affected, create multiple delta files under `changes/[change-id]/specs//spec.md`—one per capability. - /** - * Gets current auth state - */ - function getState(): AuthState { - return { ...state }; - } +4. **Create tasks.md:** +```markdown +## 1. Implementation +- [ ] 1.1 Create database schema +- [ ] 1.2 Implement API endpoint +- [ ] 1.3 Add frontend component +- [ ] 1.4 Write tests +``` - /** - * Registers event listeners - */ - function subscribe(listenersMap: Partial): () => void { - if (listenersMap.onStateChange) { - listeners.onStateChange = listenersMap.onStateChange; - } - if (listenersMap.onError) { - listeners.onError = listenersMap.onError; - } - if (listenersMap.onAuthStep) { - listeners.onAuthStep = listenersMap.onAuthStep; - } +5. **Create design.md when needed:** +Create `design.md` if any of the following apply; otherwise omit it: +- Cross-cutting change (multiple services/modules) or a new architectural pattern +- New external dependency or significant data model changes +- Security, performance, or migration complexity +- Ambiguity that benefits from technical decisions before coding - return () => { - if (listenersMap.onStateChange) listeners.onStateChange = null; - if (listenersMap.onError) listeners.onError = null; - if (listenersMap.onAuthStep) listeners.onAuthStep = null; - }; - } +Minimal `design.md` skeleton: +```markdown +## Context +[Background, constraints, stakeholders] - return { - startAuthFlow, - handleCallback, - restoreSession, - logout, - getToken, - isAuthenticated, - getState, - subscribe, - }; -} +## Goals / Non-Goals +- Goals: [...] +- Non-Goals: [...] -export type GitHubAuthManager = ReturnType; - +## Decisions +- Decision: [What and why] +- Alternatives considered: [Options + rationale] - -/** - * GitHub User Info Module - * Fetches and caches user profile from GitHub API - * User data is cached in memory only - NOT persisted to localStorage - * @module auth/user - */ +## Risks / Trade-offs +- [Risk] → Mitigation -import type { GitHubUser } from "../app/types"; +## Migration Plan +[Steps, rollback] -const GITHUB_API_USER_URL = "https://api.github.com/user"; +## Open Questions +- [...] +``` -interface GitHubApiUserResponse { - login: string; - id: number; - avatar_url: string; - name: string | null; -} +## Spec File Format -/** - * Creates a user info manager with in-memory caching - */ -export function createUserManager() { - let cachedUser: GitHubUser | null = null; +### Critical: Scenario Formatting - /** - * Fetches user profile from GitHub API - * Caches result in memory for subsequent calls - */ - async function fetchUser(token: string): Promise { - if (cachedUser) { - return cachedUser; - } +**CORRECT** (use #### headers): +```markdown +#### Scenario: User login success +- **WHEN** valid credentials provided +- **THEN** return JWT token +``` - const response = await fetch(GITHUB_API_USER_URL, { - headers: { - Authorization: `Bearer ${token}`, - Accept: "application/vnd.github+json", - "X-GitHub-Api-Version": "2022-11-28", - }, - }); +**WRONG** (don't use bullets or bold): +```markdown +- **Scenario: User login** ❌ +**Scenario**: User login ❌ +### Scenario: User login ❌ +``` - if (!response.ok) { - throw new Error(`Failed to fetch user profile: ${response.status}`); - } +Every requirement MUST have at least one scenario. - const data: GitHubApiUserResponse = await response.json(); +### Requirement Wording +- Use SHALL/MUST for normative requirements (avoid should/may unless intentionally non-normative) - cachedUser = { - login: data.login, - id: data.id, - avatarUrl: data.avatar_url, - name: data.name, - }; +### Delta Operations - return cachedUser; - } +- `## ADDED Requirements` - New capabilities +- `## MODIFIED Requirements` - Changed behavior +- `## REMOVED Requirements` - Deprecated features +- `## RENAMED Requirements` - Name changes - /** - * Clears cached user data - * Called on logout - */ - function clearCache(): void { - cachedUser = null; - } +Headers matched with `trim(header)` - whitespace ignored. - /** - * Gets cached user without fetching - */ - function getCachedUser(): GitHubUser | null { - return cachedUser; - } +#### When to use ADDED vs MODIFIED +- ADDED: Introduces a new capability or sub-capability that can stand alone as a requirement. Prefer ADDED when the change is orthogonal (e.g., adding "Slash Command Configuration") rather than altering the semantics of an existing requirement. +- MODIFIED: Changes the behavior, scope, or acceptance criteria of an existing requirement. Always paste the full, updated requirement content (header + all scenarios). The archiver will replace the entire requirement with what you provide here; partial deltas will drop previous details. +- RENAMED: Use when only the name changes. If you also change behavior, use RENAMED (name) plus MODIFIED (content) referencing the new name. - return { - fetchUser, - clearCache, - getCachedUser, - }; -} +Common pitfall: Using MODIFIED to add a new concern without including the previous text. This causes loss of detail at archive time. If you aren’t explicitly changing the existing requirement, add a new requirement under ADDED instead. -export type GitHubUserManager = ReturnType; - +Authoring a MODIFIED requirement correctly: +1) Locate the existing requirement in `openspec/specs//spec.md`. +2) Copy the entire requirement block (from `### Requirement: ...` through its scenarios). +3) Paste it under `## MODIFIED Requirements` and edit to reflect the new behavior. +4) Ensure the header text matches exactly (whitespace-insensitive) and keep at least one `#### Scenario:`. - -export interface KeyValueStorage { - readonly length: number; - key(index: number): string | null; - getItem(key: string): string | null; - setItem(key: string, value: string): void; - removeItem(key: string): void; -} +Example for RENAMED: +```markdown +## RENAMED Requirements +- FROM: `### Requirement: Login` +- TO: `### Requirement: User Authentication` +``` -const STORAGE_PREFIX = "ink.workspace."; +## Troubleshooting -function workspaceKey(name: string): string { - return `${STORAGE_PREFIX}${name}`; -} +### Common Errors -function normalizeWorkspaceName(name: string): string { - return name.trim(); -} +**"Change must have at least one delta"** +- Check `changes/[name]/specs/` exists with .md files +- Verify files have operation prefixes (## ADDED Requirements) -function parseWorkspace(value: string | null): Record { - if (!value) { - return {}; - } +**"Requirement must have at least one scenario"** +- Check scenarios use `#### Scenario:` format (4 hashtags) +- Don't use bullet points or bold for scenario headers - try { - const parsed = JSON.parse(value) as unknown; - if (!parsed || typeof parsed !== "object") { - return {}; - } +**Silent scenario parsing failures** +- Exact format required: `#### Scenario: Name` +- Debug with: `openspec show [change] --json --deltas-only` - const files: Record = {}; - for (const [key, content] of Object.entries(parsed as Record)) { - if (typeof key === "string" && typeof content === "string") { - files[key] = content; - } - } +### Validation Tips - return files; - } catch { - return {}; - } -} +```bash +# Always use strict mode for comprehensive checks +openspec validate [change] --strict -function writeWorkspace(storage: KeyValueStorage, workspace: string, files: Record): void { - storage.setItem(workspaceKey(workspace), JSON.stringify(files)); -} +# Debug delta parsing +openspec show [change] --json | jq '.deltas' -export function listWorkspaces(storage: KeyValueStorage): string[] { - const workspaces: string[] = []; +# Check specific requirement +openspec show [spec] --json -r 1 +``` - for (let index = 0; index < storage.length; index += 1) { - const key = storage.key(index); - if (!key || !key.startsWith(STORAGE_PREFIX)) { - continue; - } +## Happy Path Script - workspaces.push(key.slice(STORAGE_PREFIX.length)); - } +```bash +# 1) Explore current state +openspec spec list --long +openspec list +# Optional full-text search: +# rg -n "Requirement:|Scenario:" openspec/specs +# rg -n "^#|Requirement:" openspec/changes - return workspaces.sort((a, b) => a.localeCompare(b)); -} +# 2) Choose change id and scaffold +CHANGE=add-two-factor-auth +mkdir -p openspec/changes/$CHANGE/{specs/auth} +printf "## Why\n...\n\n## What Changes\n- ...\n\n## Impact\n- ...\n" > openspec/changes/$CHANGE/proposal.md +printf "## 1. Implementation\n- [ ] 1.1 ...\n" > openspec/changes/$CHANGE/tasks.md -export function ensureWorkspace(storage: KeyValueStorage, workspaceName: string): string { - const normalizedName = normalizeWorkspaceName(workspaceName); - if (!normalizedName) { - throw new Error("Workspace name cannot be empty."); - } +# 3) Add deltas (example) +cat > openspec/changes/$CHANGE/specs/auth/spec.md << 'EOF' +## ADDED Requirements +### Requirement: Two-Factor Authentication +Users MUST provide a second factor during login. - const key = workspaceKey(normalizedName); - if (storage.getItem(key) === null) { - writeWorkspace(storage, normalizedName, {}); - } +#### Scenario: OTP required +- **WHEN** valid credentials are provided +- **THEN** an OTP challenge is required +EOF - return normalizedName; -} +# 4) Validate +openspec validate $CHANGE --strict +``` -export function listFiles(storage: KeyValueStorage, workspaceName: string): string[] { - const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); - return Object.keys(files).sort((a, b) => a.localeCompare(b)); -} +## Multi-Capability Example -export function createFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { - const normalizedFileName = fileName.trim(); - if (!normalizedFileName) { - throw new Error("File name cannot be empty."); - } +``` +openspec/changes/add-2fa-notify/ +├── proposal.md +├── tasks.md +└── specs/ + ├── auth/ + │ └── spec.md # ADDED: Two-Factor Authentication + └── notifications/ + └── spec.md # ADDED: OTP email notification +``` - const workspace = ensureWorkspace(storage, workspaceName); - const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); - if (!(normalizedFileName in files)) { - files[normalizedFileName] = ""; - writeWorkspace(storage, workspace, files); - } +auth/spec.md +```markdown +## ADDED Requirements +### Requirement: Two-Factor Authentication +... +``` - return normalizedFileName; -} +notifications/spec.md +```markdown +## ADDED Requirements +### Requirement: OTP Email Notification +... +``` -export function readFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { - const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); - return files[fileName] ?? ""; -} +## Best Practices -export function saveFile( - storage: KeyValueStorage, - workspaceName: string, - fileName: string, - content: string, -): void { - const workspace = ensureWorkspace(storage, workspaceName); - const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); - files[fileName] = content; - writeWorkspace(storage, workspace, files); -} - +### Simplicity First +- Default to <100 lines of new code +- Single-file implementations until proven insufficient +- Avoid frameworks without clear justification +- Choose boring, proven patterns - -export function normalizeTag(value: string): string { - return (value || "") - .trim() - .replace(/^#+/, "") - .replace(/[^\w\-/]+/g, "") - .toLowerCase(); -} +### Complexity Triggers +Only add complexity with: +- Performance data showing current solution too slow +- Concrete scale requirements (>1000 users, >100MB data) +- Multiple proven use cases requiring abstraction -export function extractFrontMatter(text: string): string { - if (!text.startsWith("---")) { - return ""; - } +### Clear References +- Use `file.ts:42` format for code locations +- Reference specs as `specs/auth/spec.md` +- Link related changes and PRs - const end = text.indexOf("\n---", 3); - if (end === -1) { - return ""; - } +### Capability Naming +- Use verb-noun: `user-auth`, `payment-capture` +- Single purpose per capability +- 10-minute understandability rule +- Split if description needs "AND" - return text.slice(3, end).trim(); -} +### Change ID Naming +- Use kebab-case, short and descriptive: `add-two-factor-auth` +- Prefer verb-led prefixes: `add-`, `update-`, `remove-`, `refactor-` +- Ensure uniqueness; if taken, append `-2`, `-3`, etc. -export function parseFrontmatterTags(frontMatter: string): Set { - const tags = new Set(); - const lines = frontMatter.split("\n"); +## Tool Selection Guide - for (const line of lines) { - const inlineListMatch = line.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i); - if (!inlineListMatch) { - continue; - } +| Task | Tool | Why | +|------|------|-----| +| Find files by pattern | Glob | Fast pattern matching | +| Search code content | Grep | Optimized regex search | +| Read specific files | Read | Direct file access | +| Explore unknown scope | Task | Multi-step investigation | - const parts = inlineListMatch[1] - .split(",") - .map((part) => normalizeTag(part.replace(/["']/g, ""))); +## Error Recovery - for (const part of parts) { - if (part) { - tags.add(part); - } - } - } +### Change Conflicts +1. Run `openspec list` to see active changes +2. Check for overlapping specs +3. Coordinate with change owners +4. Consider combining proposals - let inTagsBlock = false; - for (const line of lines) { - if (/^\s*tags\s*:\s*$/i.test(line)) { - inTagsBlock = true; - continue; - } +### Validation Failures +1. Run with `--strict` flag +2. Check JSON output for details +3. Verify spec file format +4. Ensure scenarios properly formatted - if (!inTagsBlock) { - continue; - } - - const item = line.match(/^\s*-\s*(.+)\s*$/); - if (item) { - const tag = normalizeTag(item[1].replace(/["']/g, "")); - if (tag) { - tags.add(tag); - } - continue; - } - - if (line.trim() !== "" && !/^\s+/.test(line)) { - inTagsBlock = false; - } - } +### Missing Context +1. Read project.md first +2. Check related specs +3. Review recent archives +4. Ask for clarification - return tags; -} +## Quick Reference -export function parseTags(text: string): Set { - const tags = new Set(); +### Stage Indicators +- `changes/` - Proposed, not yet built +- `specs/` - Built and deployed +- `archive/` - Completed changes - const frontMatter = extractFrontMatter(text); - if (frontMatter) { - const frontMatterTags = parseFrontmatterTags(frontMatter); - for (const tag of frontMatterTags) { - tags.add(tag); - } - } +### File Purposes +- `proposal.md` - Why and what +- `tasks.md` - Implementation steps +- `design.md` - Technical decisions +- `spec.md` - Requirements and behavior - const inlineMatches = text.match(/\B#([a-zA-Z0-9][\w\-/]{1,50})\b/g); - if (inlineMatches) { - for (const match of inlineMatches) { - const tag = normalizeTag(match); - if (tag) { - tags.add(tag); - } - } - } +### CLI Essentials +```bash +openspec list # What's in progress? +openspec show [item] # View details +openspec validate --strict # Is it correct? +openspec archive [--yes|-y] # Mark complete (add --yes for automation) +``` - return tags; -} +Remember: Specs are truth. Changes are proposals. Keep them in sync. - -import QUnit from "qunit"; -import { - createFile, - ensureWorkspace, - listFiles, - listWorkspaces, - readFile, - saveFile, -} from "../../dist/test/storage.js"; + +/** + * Auth UI Controller + * Manages auth-related UI and connects auth modules with the app + * @module auth/auth-controller + */ -class MemoryStorage { - constructor() { - this.values = new Map(); - } +import type { DomRefs, GitHubUser } from "./types"; +import type { GitHubAuthManager } from "../auth/github"; +import type { GitHubUserManager } from "../auth/user"; - get length() { - return this.values.size; - } +export interface AuthController { + initialize: () => void; + getCurrentUser: () => GitHubUser | null; + isAuthenticated: () => boolean; + getToken: () => string | null; +} - key(index) { - return Array.from(this.values.keys())[index] ?? null; +export function createAuthController( + els: DomRefs, + authManager: GitHubAuthManager, + userManager: GitHubUserManager, + showToast: (message: string, options?: { persist?: boolean }) => void, +): AuthController { + let currentUser: GitHubUser | null = null; + + function updateAuthUI(authenticated: boolean): void { + if (authenticated) { + els.loginBtn.style.display = "none"; + els.userMenu.style.display = "inline-flex"; + } else { + els.loginBtn.style.display = "inline-flex"; + els.userMenu.style.display = "none"; + currentUser = null; + els.userAvatar.src = ""; + els.userName.textContent = ""; + } } - getItem(key) { - return this.values.has(key) ? this.values.get(key) : null; + function updateUserUI(user: GitHubUser | null): void { + if (user) { + currentUser = user; + els.userAvatar.src = user.avatarUrl; + els.userAvatar.alt = `${user.login}'s avatar`; + els.userName.textContent = user.name || user.login; + } else { + currentUser = null; + els.userAvatar.src = ""; + els.userAvatar.alt = ""; + els.userName.textContent = ""; + } } - setItem(key, value) { - this.values.set(key, value); + function handleAuthError(error: Error): void { + els.authError.textContent = error.message; + els.authError.style.display = "block"; + els.authStatus.textContent = ""; } - removeItem(key) { - this.values.delete(key); + function handleAuthStep(message: string | null): void { + if (message) { + els.authStatus.textContent = message; + els.authError.style.display = "none"; + } else { + els.authStatus.textContent = ""; + } } -} -QUnit.module("storage"); + async function startLogin(): Promise { + try { + els.authError.style.display = "none"; -QUnit.test("ensureWorkspace trims name and rejects empty names", (assert) => { - const storage = new MemoryStorage(); + // Start the OAuth flow - this will redirect to GitHub + await authManager.startAuthFlow(); - assert.throws( - () => ensureWorkspace(storage, " "), - /Workspace name cannot be empty\./, - ); + // Note: Code execution stops here as the browser redirects to GitHub + // The callback will be handled when the user returns + } catch (error) { + if (error instanceof Error) { + handleAuthError(error); + } else { + handleAuthError(new Error(String(error))); + } + } + } - const name = ensureWorkspace(storage, " project-a "); - assert.strictEqual(name, "project-a"); - assert.deepEqual(listWorkspaces(storage), ["project-a"]); -}); + function handleLogout(): void { + authManager.logout(); + userManager.clearCache(); + updateAuthUI(false); + updateUserUI(null); + showToast("Logged out successfully."); + } -QUnit.test("createFile rejects empty names and creates file once", (assert) => { - const storage = new MemoryStorage(); + /** + * Handles OAuth callback - called when returning from GitHub + */ + async function handleCallback(): Promise { + try { + const success = await authManager.handleCallback(); - ensureWorkspace(storage, "notes"); + if (success) { + const token = authManager.getToken(); + if (token) { + const user = await userManager.fetchUser(token); + updateUserUI(user); + showToast("Successfully logged in with GitHub!", { persist: false }); + } + } - assert.throws( - () => createFile(storage, "notes", " \n "), - /File name cannot be empty\./, - ); + return success; + } catch (error) { + if (error instanceof Error) { + handleAuthError(error); + } else { + handleAuthError(new Error(String(error))); + } + return false; + } + } - createFile(storage, "notes", "daily.md"); - createFile(storage, "notes", "daily.md"); + function handleModalClose(): void { + els.authModal.style.display = "none"; + } - assert.deepEqual(listFiles(storage, "notes"), ["daily.md"]); - assert.strictEqual(readFile(storage, "notes", "daily.md"), ""); -}); + function initialize(): void { + // Check if this is a callback (URL has code param) + const url = new URL(window.location.href); + const isCallback = url.searchParams.has("code") || url.searchParams.has("error"); -QUnit.test("listWorkspaces and listFiles are sorted", (assert) => { - const storage = new MemoryStorage(); + if (isCallback) { + // Handle the OAuth callback + handleCallback().then(() => { + // Clean up URL after handling + }).catch(() => { + // Error already handled in handleCallback + }); + } - ensureWorkspace(storage, "zeta"); - ensureWorkspace(storage, "alpha"); - ensureWorkspace(storage, "beta"); + authManager.subscribe({ + onStateChange: (state) => { + updateAuthUI(state.isAuthenticated); + }, + onError: handleAuthError, + onAuthStep: handleAuthStep, + }); - createFile(storage, "alpha", "z.md"); - createFile(storage, "alpha", "a.md"); - createFile(storage, "alpha", "m.md"); + els.loginBtn.addEventListener("click", () => { + startLogin(); + }); - assert.deepEqual(listWorkspaces(storage), ["alpha", "beta", "zeta"]); - assert.deepEqual(listFiles(storage, "alpha"), ["a.md", "m.md", "z.md"]); -}); + els.logoutBtn.addEventListener("click", () => { + handleLogout(); + }); -QUnit.test("readFile returns empty string when file does not exist", (assert) => { - const storage = new MemoryStorage(); - ensureWorkspace(storage, "empty"); + els.authModalCloseBtn.addEventListener("click", () => { + handleModalClose(); + }); - assert.strictEqual(readFile(storage, "empty", "missing.md"), ""); - assert.strictEqual(readFile(storage, "missing-workspace", "missing.md"), ""); -}); + els.authModal.addEventListener("click", (event) => { + if (event.target === els.authModal) { + handleModalClose(); + } + }); -QUnit.test("saveFile creates missing workspace and persists content", (assert) => { - const storage = new MemoryStorage(); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape" && els.authModal.style.display !== "none") { + handleModalClose(); + } + }); - saveFile(storage, "new-workspace", "created.md", "# Hello"); + if (authManager.restoreSession()) { + const token = authManager.getToken(); + if (token) { + userManager.fetchUser(token).then((user) => { + updateUserUI(user); + }).catch(() => { + authManager.logout(); + userManager.clearCache(); + }); + } + } + } - assert.deepEqual(listWorkspaces(storage), ["new-workspace"]); - assert.deepEqual(listFiles(storage, "new-workspace"), ["created.md"]); - assert.strictEqual(readFile(storage, "new-workspace", "created.md"), "# Hello"); -}); + return { + initialize, + getCurrentUser: () => currentUser, + isAuthenticated: () => authManager.isAuthenticated(), + getToken: () => authManager.getToken(), + }; +} + -QUnit.test("integration flow: workspace -> create file -> save -> read", (assert) => { - const storage = new MemoryStorage(); + +import type { PermissionCapableHandle } from "./types"; - ensureWorkspace(storage, "workspace-a"); - createFile(storage, "workspace-a", "notes.md"); - saveFile(storage, "workspace-a", "notes.md", "# Ink\n\nSaved content"); +export function isFileSystemApiAvailable(): boolean { + return Boolean(window.showDirectoryPicker && window.FileSystemHandle); +} - assert.strictEqual( - readFile(storage, "workspace-a", "notes.md"), - "# Ink\n\nSaved content", - ); -}); +export function isInMemoryMode(): boolean { + return !isFileSystemApiAvailable(); +} -QUnit.test("integration flow: data is isolated between workspaces", (assert) => { - const storage = new MemoryStorage(); +export async function ensurePermission( + handle: PermissionCapableHandle | null, + mode: "read" | "readwrite" = "read", +): Promise { + if (!handle) { + return false; + } - createFile(storage, "workspace-a", "shared.md"); - createFile(storage, "workspace-b", "shared.md"); + if (!handle.queryPermission || !handle.requestPermission) { + return true; + } - saveFile(storage, "workspace-a", "shared.md", "A"); - saveFile(storage, "workspace-b", "shared.md", "B"); + const descriptor = { mode }; + const current = await handle.queryPermission(descriptor); + if (current === "granted") { + return true; + } - assert.strictEqual(readFile(storage, "workspace-a", "shared.md"), "A"); - assert.strictEqual(readFile(storage, "workspace-b", "shared.md"), "B"); -}); + const requested = await handle.requestPermission(descriptor); + return requested === "granted"; +} - -import QUnit from "qunit"; -import { createUserManager } from "../../dist/test/user.js"; + +/** + * GitHub OAuth Authentication Module + * Implements RFC 7636 PKCE OAuth Web Flow for browser-based apps + * @module auth/github + */ -QUnit.module("auth/user", (hooks) => { - let mockFetch; - let originalFetch; +import type { AuthState, AuthEventMap, AuthListeners } from "../app/types"; - hooks.beforeEach(function () { - originalFetch = globalThis.fetch; - mockFetch = async () => ({ - ok: true, - status: 200, - async json() { - return { - login: "testuser", - id: 12345, - avatar_url: "https://avatars.githubusercontent.com/u/12345", - name: "Test User", - }; - }, - }); - globalThis.fetch = mockFetch; - }); +const GITHUB_CLIENT_ID = "Ov23liedFdRiJdiifvVX"; +const GITHUB_AUTHORIZE_URL = "https://github.com/login/oauth/authorize"; +const GITHUB_TOKEN_URL = "https://github.com/login/oauth/access_token"; +const STORAGE_KEY = "ink_github_token"; +const CODE_VERIFIER_KEY = "ink_github_code_verifier"; - hooks.afterEach(function () { - globalThis.fetch = originalFetch; - }); +const DEFAULT_POLL_INTERVAL_MS = 5000; +const MIN_POLL_INTERVAL_MS = 1000; +const MAX_POLL_INTERVAL_MS = 60000; +const BACKOFF_MULTIPLIER = 2; +const MAX_RETRIES = 10; - QUnit.test("fetchUser returns user data", async function (assert) { - const userManager = createUserManager(); - const user = await userManager.fetchUser("test_token"); +/** + * Error types for auth operations + */ +export enum AuthErrorType { + NetworkError = "network_error", + AccessDenied = "access_denied", + InvalidRequest = "invalid_request", + ServerError = "server_error", + InvalidState = "invalid_state", + Timeout = "timeout", + Unknown = "unknown", +} - assert.strictEqual(user.login, "testuser"); - assert.strictEqual(user.id, 12345); - assert.strictEqual(user.avatarUrl, "https://avatars.githubusercontent.com/u/12345"); - assert.strictEqual(user.name, "Test User"); - }); +/** + * Custom error class for auth operations + */ +export class AuthError extends Error { + constructor( + public readonly type: AuthErrorType, + message: string, + public readonly retryAfterMs?: number, + ) { + super(message); + this.name = "AuthError"; + } +} - QUnit.test("fetchUser caches result", async function (assert) { - const userManager = createUserManager(); - let fetchCalls = 0; +interface TokenResponse { + access_token?: string; + token_type?: string; + scope?: string; + error?: string; + error_description?: string; +} - globalThis.fetch = async () => { - fetchCalls++; - return { - ok: true, - status: 200, - async json() { - return { - login: "testuser", - id: 12345, - avatar_url: "https://avatars.githubusercontent.com/u/12345", - name: "Test User", - }; - }, - }; - }; +/** + * Generates a cryptographically random string for PKCE code_verifier + * Per RFC 7636: 43-128 characters from [A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~" + */ +export function generateCodeVerifier(): string { + const array = new Uint8Array(64); + crypto.getRandomValues(array); + // Base64url encode and strip padding, then filter to valid chars + const base64 = btoa(String.fromCharCode(...array)); + return base64 + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, "") + .slice(0, 128); +} - await userManager.fetchUser("test_token"); - await userManager.fetchUser("test_token"); +/** + * Generates code_challenge from code_verifier using S256 method + * Per RFC 7636: BASE64URL(SHA256(code_verifier)) + */ +export async function generateCodeChallenge(verifier: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(verifier); + const hash = await crypto.subtle.digest("SHA-256", data); + return btoa(String.fromCharCode(...new Uint8Array(hash))) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=/g, ""); +} - assert.strictEqual(fetchCalls, 1, "Should only make one fetch call due to caching"); - }); +/** + * Creates an exponential backoff delay with jitter + */ +export function calculateBackoff( + attempt: number, + baseIntervalMs: number = DEFAULT_POLL_INTERVAL_MS, +): number { + const exponentialDelay = baseIntervalMs * Math.pow(BACKOFF_MULTIPLIER, attempt); + const jitter = Math.random() * 1000; + return Math.min(exponentialDelay + jitter, MAX_POLL_INTERVAL_MS); +} - QUnit.test("clearCache removes cached user", async function (assert) { - const userManager = createUserManager(); - await userManager.fetchUser("test_token"); +/** + * Parses GitHub API JSON response + */ +async function parseJsonResponse(response: Response): Promise { + const json = await response.json(); + return json as T; +} - assert.ok(userManager.getCachedUser() !== null); +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} - userManager.clearCache(); +/** + * Creates the auth state manager + */ +export function createAuthManager() { + const listeners: AuthListeners = { + onStateChange: null, + onError: null, + onAuthStep: null, + }; - assert.strictEqual(userManager.getCachedUser(), null); - }); + let state: AuthState = { + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null, + }; - QUnit.test("getCachedUser returns null before fetch", function (assert) { - const userManager = createUserManager(); - assert.strictEqual(userManager.getCachedUser(), null); - }); + let currentToken: string | null = null; - QUnit.test("fetchUser throws on API error", async function (assert) { - const userManager = createUserManager(); + function emitStateChange(): void { + if (listeners.onStateChange) { + listeners.onStateChange({ ...state }); + } + } - globalThis.fetch = async () => ({ - ok: false, - status: 401, - async json() { - return {}; - }, - }); + function emitError(error: AuthError): void { + if (listeners.onError) { + listeners.onError(error); + } + } + + function emitAuthStep(message: string | null): void { + state.authStep = message; + if (listeners.onAuthStep) { + listeners.onAuthStep(message); + } + emitStateChange(); + } + + function setState(updates: Partial): void { + state = { ...state, ...updates }; + emitStateChange(); + } + /** + * Retrieves token from localStorage + */ + function getStoredToken(): string | null { try { - await userManager.fetchUser("invalid_token"); - assert.ok(false, "Should have thrown"); - } catch (error) { - assert.ok(error.message.includes("Failed to fetch user profile")); + return localStorage.getItem(STORAGE_KEY); + } catch { + return null; } - }); + } - QUnit.test("fetchUser uses Authorization header", async function (assert) { - const userManager = createUserManager(); - let capturedHeaders = null; + /** + * Stores token in localStorage + */ + function storeToken(token: string): void { + try { + localStorage.setItem(STORAGE_KEY, token); + currentToken = token; + } catch { + throw new AuthError( + AuthErrorType.Unknown, + "Failed to store token securely.", + ); + } + } - globalThis.fetch = async (url, options) => { - capturedHeaders = new Headers(options?.headers); - return { - ok: true, - status: 200, - async json() { - return { - login: "testuser", - id: 12345, - avatar_url: "https://avatars.githubusercontent.com/u/12345", - name: "Test User", - }; - }, - }; - }; + /** + * Clears stored token from localStorage + */ + function clearStoredToken(): void { + try { + localStorage.removeItem(STORAGE_KEY); + currentToken = null; + } catch { + // Ignore storage errors on logout + } + } - await userManager.fetchUser("test_token_xyz"); + /** + * Stores code_verifier in sessionStorage (not localStorage for security) + */ + function storeCodeVerifier(verifier: string): void { + try { + sessionStorage.setItem(CODE_VERIFIER_KEY, verifier); + } catch { + throw new AuthError( + AuthErrorType.Unknown, + "Failed to store verification code.", + ); + } + } - assert.strictEqual(capturedHeaders?.get("Authorization"), "Bearer test_token_xyz"); - assert.strictEqual(capturedHeaders?.get("Accept"), "application/vnd.github+json"); - }); -}); - + /** + * Retrieves and clears code_verifier from sessionStorage + */ + function getAndClearCodeVerifier(): string | null { + try { + const verifier = sessionStorage.getItem(CODE_VERIFIER_KEY); + sessionStorage.removeItem(CODE_VERIFIER_KEY); + return verifier; + } catch { + return null; + } + } - -{ - "folders": [ - { - "path": "." - } - ], - "settings": {} -} - + /** + * Generates authorization URL with PKCE parameters + * Uses localhost for development, configurable for production + */ + async function buildAuthorizationUrl(codeChallenge: string): Promise { + const redirectUri = getRedirectUri(); + const params = new URLSearchParams({ + client_id: GITHUB_CLIENT_ID, + redirect_uri: redirectUri, + scope: "read:user", + code_challenge: codeChallenge, + code_challenge_method: "S256", + state: generateState(), + }); + return new URL(`${GITHUB_AUTHORIZE_URL}?${params.toString()}`); + } - -MIT License + /** + * Gets redirect URI based on environment + */ + function getRedirectUri(): string { + return window.location.origin + window.location.pathname; + } -Copyright (c) 2026 Federico Viscioletti + /** + * Generates random state parameter for CSRF protection + */ + function generateState(): string { + const array = new Uint8Array(16); + crypto.getRandomValues(array); + return Array.from(array) + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + } -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: + /** + * Starts the OAuth PKCE flow + * Redirects browser to GitHub authorization page + */ + async function startAuthFlow(): Promise { + setState({ isLoading: true, error: null }); + emitAuthStep("Preparing authentication..."); -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. + try { + // Generate PKCE code_verifier and code_challenge + const codeVerifier = generateCodeVerifier(); + const codeChallenge = await generateCodeChallenge(codeVerifier); -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. - + // Store verifier in sessionStorage for later token exchange + storeCodeVerifier(codeVerifier); - - - - - - - Declarative WebMCP Demo - - - -

Declarative WebMCP Demo

-

This page exposes a simple note-creation tool using only HTML form attributes.

-
- + // Get authorization code + const code = url.searchParams.get("code"); + if (!code) { + const authError = new AuthError( + AuthErrorType.InvalidState, + "Missing authorization code in callback.", + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } - + // Get code_verifier from sessionStorage + const codeVerifier = getAndClearCodeVerifier(); + if (!codeVerifier) { + const authError = new AuthError( + AuthErrorType.InvalidState, + "Verification code expired. Please try again.", + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } - + setState({ isLoading: true }); + emitAuthStep("Exchanging code for token..."); - -
- - -
+ try { + const token = await exchangeCodeForToken(code, codeVerifier); - -#!/bin/sh -# Pre-commit hook to run lint and update repomix output + storeToken(token); + setState({ + isAuthenticated: true, + isLoading: false, + error: null, + authStep: null, + }); -echo "Running ESLint..." -npm run lint || exit 1 + clearUrlParams(); + return true; + } catch (e) { + if (e instanceof AuthError) { + setState({ isLoading: false, error: e.message }); + emitError(e); + clearUrlParams(); + return false; + } -echo "Running repomix..." -npx repomix@latest + const authError = new AuthError( + AuthErrorType.NetworkError, + `Token exchange failed: ${String(e)}`, + ); + setState({ isLoading: false, error: authError.message }); + emitError(authError); + clearUrlParams(); + return false; + } + } -# Add the repomix output -git add repomix-output.xml - + /** + * Clears sensitive params from URL after callback + */ + function clearUrlParams(): void { + // Use replaceState to avoid polluting browser history + const cleanUrl = window.location.pathname + window.location.hash; + window.history.replaceState({}, document.title, cleanUrl); + } - -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); + /** + * Exchanges authorization code for access token + */ + async function exchangeCodeForToken( + code: string, + codeVerifier: string, + ): Promise { + const response = await fetch(GITHUB_TOKEN_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + Accept: "application/json", + }, + body: JSON.stringify({ + client_id: GITHUB_CLIENT_ID, + code: code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + }), + }); - return { - kind: "file", - name, - async queryPermission() { - return "granted"; - }, - async requestPermission() { - return "granted"; - }, - async getFile() { - return new File([content], name, { type: "text/markdown", lastModified }); - }, - async createWritable() { - return { - async write(nextContent) { - content = String(nextContent); - lastModified = Date.now(); - }, - async close() {}, - }; - }, - __read() { - return content; - }, - }; -} + if (!response.ok) { + const authError = new AuthError( + AuthErrorType.ServerError, + `Token request failed: ${response.status}`, + ); + throw authError; + } -function createFakeDirectoryHandle(name) { - const entries = new Map(); + const data = await parseJsonResponse(response); - return { - kind: "directory", - name, - async queryPermission() { - return "granted"; - }, - async requestPermission() { - return "granted"; - }, - async *entries() { - for (const entry of entries.entries()) { - yield entry; - } - }, - async getFileHandle(fileName, options = {}) { - const existing = entries.get(fileName); - if (existing) { - return existing; - } - if (!options.create) { - throw new Error(`File not found: ${fileName}`); - } - const handle = createFakeFileHandle(fileName); - entries.set(fileName, handle); - return handle; - }, - async getDirectoryHandle(dirName, options = {}) { - const existing = entries.get(dirName); - if (existing) { - return existing; + if (data.error) { + if (data.error === "access_denied") { + throw new AuthError( + AuthErrorType.AccessDenied, + "Authorization denied. Please try again.", + ); } - if (!options.create) { - throw new Error(`Directory not found: ${dirName}`); + + if (data.error === "expired_token") { + throw new AuthError( + AuthErrorType.Timeout, + "Authorization expired. Please try again.", + ); } - const handle = createFakeDirectoryHandle(dirName); - entries.set(dirName, handle); - return handle; - }, - __entries: entries, - }; -} -describe("cogito mode", () => { - const workspaceName = "workspace-cogito"; - const fileStem = "cogito-note"; - const markdown = "The project should prioritize local-first writing workflows."; + throw new AuthError( + AuthErrorType.InvalidRequest, + data.error_description || `Authentication error: ${data.error}`, + ); + } - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); + if (!data.access_token) { + throw new AuthError( + AuthErrorType.ServerError, + "No access token in response.", + ); + } - win.prompt = (message) => { - if (message.includes("New note name")) { - return fileStem; - } - return null; - }; + return data.access_token; + } - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; - win.__fakeWorkspace = root; - win.__cogitoCreateEngineCalls = []; - win.__cogitoCompletions = []; - win.__INK_TEST_WEBLLM__ = { - async CreateMLCEngine(modelId, options = {}) { - win.__cogitoCreateEngineCalls.push(modelId); - options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); + /** + * Checks if user has existing valid session + */ + function restoreSession(): boolean { + const token = getStoredToken(); + if (token) { + currentToken = token; + setState({ + isAuthenticated: true, + isLoading: false, + error: null, + authStep: null, + }); + return true; + } + return false; + } - return { - chat: { - completions: { - async create(payload) { - win.__cogitoCompletions.push(payload); - return { - choices: [ - { - message: { - content: JSON.stringify({ - questions: [ - "What problem does local-first editing solve here?", - "Which user evidence supports this workflow choice?", - "How will you measure whether local-first is working?", - ], - }), - }, - }, - ], - }; - }, - }, - }, - }; - }, - }; - }, + /** + * Logs out the user and clears all stored data + */ + function logout(): void { + clearStoredToken(); + // Clear any stored code verifier + try { + sessionStorage.removeItem(CODE_VERIFIER_KEY); + } catch { + // Ignore + } + setState({ + isAuthenticated: false, + isLoading: false, + error: null, + authStep: null, }); - }); + } - it("opens the panel, switches model, generates questions, and inserts one into the editor", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); - cy.get("#editor").clear().type(markdown); + /** + * Gets current access token + */ + function getToken(): string | null { + return currentToken; + } - cy.get("#cogitoToggleBtn") - .should("have.attr", "aria-expanded", "false") - .click() - .should("have.attr", "aria-expanded", "true"); - cy.get("#cogitoPanel").should("be.visible"); - cy.get(".split").should("have.class", "with-cogito"); + /** + * Checks if currently authenticated + */ + function isAuthenticated(): boolean { + return state.isAuthenticated; + } - cy.get("#cogitoDeepBtn").click().should("have.class", "active"); - cy.get("#cogitoLiteBtn").should("not.have.class", "active"); + /** + * Gets current auth state + */ + function getState(): AuthState { + return { ...state }; + } - cy.get("#cogitoGenerateBtn").click(); + /** + * Registers event listeners + */ + function subscribe(listenersMap: Partial): () => void { + if (listenersMap.onStateChange) { + listeners.onStateChange = listenersMap.onStateChange; + } + if (listenersMap.onError) { + listeners.onError = listenersMap.onError; + } + if (listenersMap.onAuthStep) { + listeners.onAuthStep = listenersMap.onAuthStep; + } - cy.get("#statusBadge").should("contain", "Cogito questions ready"); - cy.get("#cogitoStatus").should("contain", "Questions ready"); - cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); - cy.get("#cogitoQuestionList .cogitoQuestionText") - .eq(0) - .should("contain", "What problem does local-first editing solve here?"); + return () => { + if (listenersMap.onStateChange) listeners.onStateChange = null; + if (listenersMap.onError) listeners.onError = null; + if (listenersMap.onAuthStep) listeners.onAuthStep = null; + }; + } - cy.window().then((win) => { - expect(win.__cogitoCreateEngineCalls).to.deep.equal(["Qwen3-8B-q4f16_1-MLC"]); - expect(win.__cogitoCompletions).to.have.length(1); - expect(win.__cogitoCompletions[0].messages[1].content).to.contain(markdown); - }); + return { + startAuthFlow, + handleCallback, + restoreSession, + logout, + getToken, + isAuthenticated, + getState, + subscribe, + }; +} - cy.contains("#cogitoQuestionList .cogitoInsertBtn", "Insert").first().click(); - cy.get("#statusBadge").should("contain", "Inserted AI question"); - cy.get("#editor").should("have.value", `${markdown}> ### AI\nWhat problem does local-first editing solve here?\n`); - }); -}); +export type GitHubAuthManager = ReturnType; - -function createFakeFileHandle(name, initialContent = "") { - let content = initialContent; - let lastModified = Date.now(); + +/** + * GitHub User Info Module + * Fetches and caches user profile from GitHub API + * User data is cached in memory only - NOT persisted to localStorage + * @module auth/user + */ - return { - kind: "file", - name, - async queryPermission() { - return "granted"; - }, - async requestPermission() { - return "granted"; - }, - async getFile() { - return new File([content], name, { type: "text/markdown", lastModified }); - }, - async createWritable() { - return { - async write(nextContent) { - content = String(nextContent); - lastModified = Date.now(); - }, - async close() {}, - }; - }, - }; -} +import type { GitHubUser } from "../app/types"; -function createFakeDirectoryHandle(name) { - const entries = new Map(); +const GITHUB_API_USER_URL = "https://api.github.com/user"; - return { - kind: "directory", - name, - async queryPermission() { - return "granted"; - }, - async requestPermission() { - return "granted"; - }, - async *entries() { - for (const entry of entries.entries()) { - yield entry; - } - }, - async getFileHandle(fileName, options = {}) { - const existing = entries.get(fileName); - if (existing) { - return existing; - } - if (!options.create) { - throw new Error(`File not found: ${fileName}`); - } - const handle = createFakeFileHandle(fileName); - entries.set(fileName, handle); - return handle; - }, - async getDirectoryHandle(dirName, options = {}) { - const existing = entries.get(dirName); - if (existing) { - return existing; - } - if (!options.create) { - throw new Error(`Directory not found: ${dirName}`); - } - const handle = createFakeDirectoryHandle(dirName); - entries.set(dirName, handle); - return handle; - }, - }; +interface GitHubApiUserResponse { + login: string; + id: number; + avatar_url: string; + name: string | null; } -describe("editor view mode toggle", () => { - const workspaceName = "view-mode-workspace"; - const noteName = "view-mode-note"; - - beforeEach(() => { - cy.visit("/ink-app.html", { - onBeforeLoad(win) { - const root = createFakeDirectoryHandle(workspaceName); +/** + * Creates a user info manager with in-memory caching + */ +export function createUserManager() { + let cachedUser: GitHubUser | null = null; - win.prompt = (message) => { - if (message.includes("New note name")) { - return noteName; - } - return null; - }; + /** + * Fetches user profile from GitHub API + * Caches result in memory for subsequent calls + */ + async function fetchUser(token: string): Promise { + if (cachedUser) { + return cachedUser; + } - win.confirm = () => true; - win.FileSystemHandle = function FileSystemHandle() {}; - win.showDirectoryPicker = async () => root; + const response = await fetch(GITHUB_API_USER_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", }, }); - }); - it("switches between split, source, and preview layouts", () => { - cy.get("[data-action=\"open-workspace\"]").click({ force: true }); - cy.get("[data-action=\"new-note\"]").click({ force: true }); + if (!response.ok) { + throw new Error(`Failed to fetch user profile: ${response.status}`); + } - cy.get("#editorSplit").should("have.class", "view-split"); - cy.get("#editorViewSplitBtn").should("have.class", "active"); + const data: GitHubApiUserResponse = await response.json(); - cy.get("#editorViewSourceBtn").click(); - cy.get("#editorSplit").should("have.class", "view-source"); - cy.get("#editorPane").should("be.visible"); - cy.get("#previewPane").should("not.be.visible"); - cy.get("#editorViewSourceBtn").should("have.class", "active"); + cachedUser = { + login: data.login, + id: data.id, + avatarUrl: data.avatar_url, + name: data.name, + }; - cy.get("#editorViewPreviewBtn").click(); - cy.get("#editorSplit").should("have.class", "view-preview"); - cy.get("#editorPane").should("not.be.visible"); - cy.get("#previewPane").should("be.visible"); - cy.get("#editorViewPreviewBtn").should("have.class", "active"); + return cachedUser; + } - cy.get("#editorViewSplitBtn").click(); - cy.get("#editorSplit").should("have.class", "view-split"); - cy.get("#editorPane").should("be.visible"); - cy.get("#previewPane").should("be.visible"); - cy.get("#editorViewSplitBtn").should("have.class", "active"); - }); -}); - + /** + * Clears cached user data + * Called on logout + */ + function clearCache(): void { + cachedUser = null; + } - -import "./commands"; -import "@cypress/code-coverage/support"; + /** + * Gets cached user without fetching + */ + function getCachedUser(): GitHubUser | null { + return cachedUser; + } -Cypress.on("uncaught:exception", () => false); + return { + fetchUser, + clearCache, + getCachedUser, + }; +} + +export type GitHubUserManager = ReturnType; - -## ADDED Requirements + +export interface KeyValueStorage { + readonly length: number; + key(index: number): string | null; + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} -### Requirement: Cogito Mode Menu Bar Button -The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. +const STORAGE_PREFIX = "ink.workspace."; -#### Scenario: User sees Cogito entrypoint in the menu bar -- **WHEN** the editor UI is visible -- **THEN** a Cogito Mode button SHALL appear in the top-right menu bar actions -- **AND** the button SHALL use a thinking-man icon from the project's glyphicon library -- **AND** the button SHALL expose an accessible text label or tooltip identifying it as Cogito Mode +function workspaceKey(name: string): string { + return `${STORAGE_PREFIX}${name}`; +} -#### Scenario: User toggles Cogito Mode from the button -- **WHEN** the user clicks the Cogito Mode top-right button -- **THEN** the system SHALL open or close the Cogito Mode side panel -- **AND** the toggle action SHALL not interrupt normal editing flow +function normalizeWorkspaceName(name: string): string { + return name.trim(); +} -### Requirement: Cogito Mode Side Panel -The system SHALL provide a Cogito Mode side panel where users can generate and view writing-coach questions while editing markdown. +function parseWorkspace(value: string | null): Record { + if (!value) { + return {}; + } -#### Scenario: User opens Cogito Mode panel -- **WHEN** the user enables Cogito Mode -- **THEN** the editor SHALL show a side panel dedicated to AI questions -- **AND** the panel SHALL not prevent normal markdown editing + try { + const parsed = JSON.parse(value) as unknown; + if (!parsed || typeof parsed !== "object") { + return {}; + } -### Requirement: Fixed Coaching Prompt Contract -The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. + const files: Record = {}; + for (const [key, content] of Object.entries(parsed as Record)) { + if (typeof key === "string" && typeof content === "string") { + files[key] = content; + } + } -#### Scenario: Prompt is sent for generation -- **WHEN** Cogito Mode requests questions -- **THEN** the system SHALL use the following prompt contract: - - You are a writing coach. - - Do NOT write prose. - - Do NOT suggest sentences. - - Ask exactly 3 questions. - - Questions must be grounded in the user's last sentence. - - Output JSON only as `{ "questions": ["...", "...", "..."] }` -- **AND** no additional non-JSON content SHALL be accepted as valid output + return files; + } catch { + return {}; + } +} -### Requirement: Last-Sentence Grounding -The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. +function writeWorkspace(storage: KeyValueStorage, workspace: string, files: Record): void { + storage.setItem(workspaceKey(workspace), JSON.stringify(files)); +} -#### Scenario: User has at least one sentence -- **WHEN** the user triggers question generation -- **THEN** the system SHALL extract the latest sentence from the current document -- **AND** the generated questions SHALL be based on that latest sentence context +export function listWorkspaces(storage: KeyValueStorage): string[] { + const workspaces: string[] = []; -### Requirement: Exactly Three Rendered Questions -The system SHALL display exactly three generated questions in the Cogito Mode side panel. + for (let index = 0; index < storage.length; index += 1) { + const key = storage.key(index); + if (!key || !key.startsWith(STORAGE_PREFIX)) { + continue; + } -#### Scenario: Valid model output is returned -- **WHEN** the model returns a valid JSON payload containing three questions -- **THEN** the panel SHALL render exactly three question entries -- **AND** each entry SHALL expose an insert action for the user + workspaces.push(key.slice(STORAGE_PREFIX.length)); + } -#### Scenario: Invalid model output is returned -- **WHEN** model output is missing JSON or does not contain exactly three questions -- **THEN** the system SHALL show a recoverable error state -- **AND** the editor SHALL remain usable + return workspaces.sort((a, b) => a.localeCompare(b)); +} -### Requirement: AI Question Markdown Insertion Format -The system SHALL insert selected questions into the markdown document using a standardized AI block format. +export function ensureWorkspace(storage: KeyValueStorage, workspaceName: string): string { + const normalizedName = normalizeWorkspaceName(workspaceName); + if (!normalizedName) { + throw new Error("Workspace name cannot be empty."); + } -#### Scenario: User inserts a generated question -- **WHEN** the user chooses insert on a generated question -- **THEN** the document SHALL receive the exact block structure: - - `> ### AI` - - `` -- **AND** inserted content SHALL be plain markdown text in the active document + const key = workspaceKey(normalizedName); + if (storage.getItem(key) === null) { + writeWorkspace(storage, normalizedName, {}); + } -### Requirement: Web-LLM Runtime Integration -The system SHALL use `https://esm.run/@mlc-ai/web-llm` for in-browser question generation. + return normalizedName; +} -#### Scenario: Runtime is available -- **WHEN** Cogito Mode initializes successfully -- **THEN** question generation SHALL execute through the web-llm runtime in the browser +export function listFiles(storage: KeyValueStorage, workspaceName: string): string[] { + const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); + return Object.keys(files).sort((a, b) => a.localeCompare(b)); +} -#### Scenario: Runtime fails or is unsupported -- **WHEN** web-llm cannot initialize or run inference -- **THEN** the system SHALL present a clear non-blocking error/unsupported state -- **AND** users SHALL continue editing markdown without Cogito Mode assistance - +export function createFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { + const normalizedFileName = fileName.trim(); + if (!normalizedFileName) { + throw new Error("File name cannot be empty."); + } - -## Context -Cogito Mode introduces local AI-assisted questioning into the markdown editor. The feature must preserve user authorship by asking questions only, never drafting prose. The output contract is strict and machine-validated to keep behavior deterministic. + const workspace = ensureWorkspace(storage, workspaceName); + const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); + if (!(normalizedFileName in files)) { + files[normalizedFileName] = ""; + writeWorkspace(storage, workspace, files); + } -## Goals / Non-Goals -- Goals: - - Provide a discoverable Cogito Mode entrypoint as a top-right menu bar button with a thinking-man glyphicon. - - Generate exactly three coaching questions grounded in the user's latest sentence. - - Keep generation non-blocking and local to the browser runtime via web-llm. - - Insert selected questions in a recognizable markdown AI block. -- Non-Goals: - - Generating full paragraphs, rewrites, or sentence suggestions. - - Automatic insertion of generated questions without user action. - - Server-side AI inference. + return normalizedFileName; +} -## Decisions -- Decision: Add Cogito Mode activation to the menu bar's top-right button cluster and use the thinking-man glyphicon from the existing glyphicon set. - - Rationale: keeps feature discovery obvious while aligning with current icon system. -- Decision: Integrate `https://esm.run/@mlc-ai/web-llm` behind a dedicated client adapter module. - - Rationale: isolates fast-changing AI runtime concerns from editor core logic. -- Decision: Keep prompt text fixed and versioned in code. - - Rationale: preserves predictable behavior and testability. -- Decision: Validate model responses as JSON and require exactly three non-empty question strings. - - Rationale: prevents malformed or verbose model outputs from corrupting UX. -- Decision: Insert question blocks with a canonical two-line structure: - - `> ### AI` - - `` - - Rationale: gives users clear provenance markers for AI-generated prompts. +export function readFile(storage: KeyValueStorage, workspaceName: string, fileName: string): string { + const files = parseWorkspace(storage.getItem(workspaceKey(workspaceName))); + return files[fileName] ?? ""; +} -## Risks / Trade-offs -- Runtime/model load latency may be noticeable on first use. - - Mitigation: explicit loading state and deferred model initialization when Cogito Mode opens. -- JSON-only output may still be violated by model drift. - - Mitigation: schema validation plus retry/fail-soft messaging. -- Browser/device limitations may prevent reliable local inference. - - Mitigation: graceful disable state with explanatory copy and no editor disruption. -- Icon semantics may be interpreted differently across users. - - Mitigation: add accessible label/tooltip text such as "Cogito Mode" to the icon button. +export function saveFile( + storage: KeyValueStorage, + workspaceName: string, + fileName: string, + content: string, +): void { + const workspace = ensureWorkspace(storage, workspaceName); + const files = parseWorkspace(storage.getItem(workspaceKey(workspace))); + files[fileName] = content; + writeWorkspace(storage, workspace, files); +} + -## Migration Plan -1. Add top-right menu bar button scaffolding and wire it to Cogito Mode panel visibility. -2. Add feature scaffolding and side panel hidden by default. -3. Land LLM adapter with mocked tests for parser/validator behavior. -4. Wire generation and insertion actions behind toggle-controlled UI. -5. Add docs and e2e checks; keep feature opt-in until validated. + +export function normalizeTag(value: string): string { + return (value || "") + .trim() + .replace(/^#+/, "") + .replace(/[^\w\-/]+/g, "") + .toLowerCase(); +} -## Open Questions -- Which specific glyphicon class best matches the "thinking man" expectation in this project? -- Which local model profile should be the default for quality vs startup time? -- Should the three generated questions refresh automatically after every sentence boundary, or only on explicit user request? - +export function extractFrontMatter(text: string): string { + if (!text.startsWith("---")) { + return ""; + } - -# Change: Add Cogito Mode Question Assistant + const end = text.indexOf("\n---", 3); + if (end === -1) { + return ""; + } -## Why -Writers can lose momentum when they need critical prompts to challenge or deepen what they just wrote. A local side-panel coach that asks targeted questions based on the latest sentence can improve reflection without auto-writing content for the user. + return text.slice(3, end).trim(); +} -## What Changes -- Add a new **Cogito Mode** in the editor that runs an in-browser LLM using `https://esm.run/@mlc-ai/web-llm`. -- Add a dedicated Cogito Mode button in the menu bar's top-right action area, using a thinking-man glyphicon as the button icon. -- Generate exactly three coaching questions from the user's most recent sentence using a fixed JSON-only prompt contract. -- Display generated questions in a right-side panel, with per-question insertion into the active markdown document. -- Insert selected questions into the document using a standardized AI block format: - - `> ### AI` - - `Question here` -- Add robust validation, fallback handling, and non-blocking UI status for model loading/inference failures. +export function parseFrontmatterTags(frontMatter: string): Set { + const tags = new Set(); + const lines = frontMatter.split("\n"); -## Impact -- Affected specs: `cogito-mode` (new capability) -- Affected code: menu bar actions UI, editor layout/panel UI, markdown insertion flows, client LLM integration module, and app controller wiring -- Runtime dependency: web-llm loaded from ESM URL at runtime -- Breaking changes: none (feature is additive and opt-in) - + for (const line of lines) { + const inlineListMatch = line.match(/^\s*tags\s*:\s*\[(.*)\]\s*$/i); + if (!inlineListMatch) { + continue; + } - -## 1. Implementation -- [ ] 1.1 Add a Cogito Mode menu bar button in the top-right action area using a thinking-man glyphicon. -- [ ] 1.2 Add Cogito Mode feature toggle behavior from the menu bar button and side-panel shell in the editor layout. -- [ ] 1.3 Implement a web-llm client module that loads `@mlc-ai/web-llm` and exposes `generateQuestionsFromLastSentence(text)`. -- [ ] 1.4 Enforce strict prompt and output contract: exactly 3 questions via JSON `{ "questions": ["...", "...", "..."] }`. -- [ ] 1.5 Add extraction logic for the user's latest sentence and pass only that context to the question generator. -- [ ] 1.6 Render three generated questions in the side panel with per-question insert actions. -- [ ] 1.7 Implement markdown insertion with the required AI block format: - - `> ### AI` - - `` -- [ ] 1.8 Add loading, ready, and error UI states for model init/inference failures without blocking editing. -- [ ] 1.9 Add unit tests for sentence extraction, JSON validation, and insertion formatting. -- [ ] 1.10 Add end-to-end coverage for opening Cogito Mode from the top-right button, generating questions, and inserting each question into the document. + const parts = inlineListMatch[1] + .split(",") + .map((part) => normalizeTag(part.replace(/["']/g, ""))); -## 2. Documentation -- [ ] 2.1 Document where to find the top-right Cogito Mode button and what the thinking-man icon represents. -- [ ] 2.2 Document Cogito Mode behavior and AI block format in user-facing docs. -- [ ] 2.3 Document model/runtime constraints and graceful degradation behavior for unsupported environments. - + for (const part of parts) { + if (part) { + tags.add(part); + } + } + } - -## ADDED Requirements -### Requirement: User-Controlled Left Menu Visibility -The application SHALL provide a user-facing control to collapse and expand the left menu during an editing session. + let inTagsBlock = false; + for (const line of lines) { + if (/^\s*tags\s*:\s*$/i.test(line)) { + inTagsBlock = true; + continue; + } -#### Scenario: User collapses left menu -- **WHEN** the user activates the menu toggle while the menu is expanded -- **THEN** the left menu transitions to a collapsed state -- **AND** the main editor area gains additional horizontal space + if (!inTagsBlock) { + continue; + } -#### Scenario: User expands left menu -- **WHEN** the user activates the menu toggle while the menu is collapsed -- **THEN** the left menu returns to an expanded state -- **AND** primary menu content becomes visible again + const item = line.match(/^\s*-\s*(.+)\s*$/); + if (item) { + const tag = normalizeTag(item[1].replace(/["']/g, "")); + if (tag) { + tags.add(tag); + } + continue; + } -### Requirement: Left Menu Toggle Accessibility -The left menu toggle SHALL be operable via keyboard and SHALL expose its current expanded/collapsed state to assistive technologies. + if (line.trim() !== "" && !/^\s+/.test(line)) { + inTagsBlock = false; + } + } -#### Scenario: Keyboard operation -- **WHEN** a keyboard-only user focuses the menu toggle and activates it -- **THEN** the menu state changes between expanded and collapsed -- **AND** focus remains in a predictable location for continued interaction + return tags; +} -#### Scenario: Assistive state exposure -- **WHEN** the menu state changes through the toggle control -- **THEN** the control's accessibility state reflects whether the menu is expanded or collapsed +export function parseTags(text: string): Set { + const tags = new Set(); -### Requirement: Deterministic Initial Menu State -The application SHALL define a deterministic initial left menu state when a new session loads. + const frontMatter = extractFrontMatter(text); + if (frontMatter) { + const frontMatterTags = parseFrontmatterTags(frontMatter); + for (const tag of frontMatterTags) { + tags.add(tag); + } + } -#### Scenario: Initial layout state -- **WHEN** a user opens the application in a new session -- **THEN** the left menu starts in the documented default state -- **AND** the editor layout reflects that state without requiring user interaction + const inlineMatches = text.match(/\B#([a-zA-Z0-9][\w\-/]{1,50})\b/g); + if (inlineMatches) { + for (const match of inlineMatches) { + const tag = normalizeTag(match); + if (tag) { + tags.add(tag); + } + } + } + + return tags; +} - -# Change: Add User-Controlled Collapsible Left Menu + +import QUnit from "qunit"; -## Why -The current left menu is fixed, which reduces usable editor space on smaller screens and during focused writing. Allowing users to collapse and expand the menu improves layout flexibility without removing existing navigation. +QUnit.module("mobile fallback - JSON export format"); -## What Changes -- Add support for collapsing and expanding the left menu from within the UI. -- Add a persistent toggle control so users can switch menu state on demand. -- Update layout behavior so editor content reflows to use freed horizontal space when the menu is collapsed. -- Preserve keyboard and pointer accessibility for the menu toggle interaction. -- Define expected default menu behavior at app start. +QUnit.test("JSON export structure is correct", (assert) => { + const notes = [ + { + name: "note1.md", + relPath: "note1.md", + content: "# Note 1\n\nContent here", + lastModified: 1700000000000, + tags: new Set(["tag1"]), + }, + { + name: "note2.md", + relPath: "note2.md", + content: "# Note 2\n\nMore content", + lastModified: 1700000001000, + tags: new Set(["tag2"]), + }, + ]; -## Impact -- Affected specs: `editor-layout` -- Affected code: - - `ink.template.html` - - `src/app.ts` - - `src/styles.scss` - - `dist/app.min.js` (rebuilt) - - `dist/styles.min.css` (rebuilt) - + const exportData = { + exportedAt: new Date().toISOString(), + notes: notes.map((note) => ({ + name: note.name, + path: note.relPath, + content: note.content, + lastModified: new Date(note.lastModified).toISOString(), + })), + }; - -## 1. UI Controls and State -- [x] 1.1 Add a left menu toggle control that supports collapse and expand actions. -- [x] 1.2 Implement menu state management in the app so toggle actions update layout consistently. -- [x] 1.3 Define and implement the default menu state on initial load. + const json = JSON.stringify(exportData, null, 2); + const parsed = JSON.parse(json); -## 2. Layout and Styling -- [x] 2.1 Update layout styles so collapsing the menu increases main editor horizontal space. -- [x] 2.2 Ensure expanded state preserves existing navigation usability and visual hierarchy. -- [x] 2.3 Ensure collapsed state keeps the toggle discoverable and usable. + assert.ok(parsed.exportedAt, "Should have exportedAt timestamp"); + assert.strictEqual(parsed.notes.length, 2, "Should have 2 notes"); + assert.strictEqual(parsed.notes[0].name, "note1.md", "First note should have correct name"); + assert.strictEqual(parsed.notes[0].path, "note1.md", "First note should have correct path"); + assert.ok(parsed.notes[0].content.includes("Note 1"), "First note should have content"); +}); -## 3. Accessibility and Interaction -- [x] 3.1 Ensure the toggle is keyboard-focusable and operable. -- [x] 3.2 Ensure toggle semantics expose menu state changes to assistive technologies. +QUnit.module("mobile fallback - in-memory note management"); -## 4. Verification -- [x] 4.1 Verify users can collapse and re-expand the menu in a modern browser. -- [x] 4.2 Verify editor content area width increases when the menu is collapsed. -- [x] 4.3 Verify keyboard-only users can operate the toggle and retain navigation control. -- [x] 4.4 Add a Cypress end-to-end test that validates left menu collapse/expand behavior and resulting layout changes. - +QUnit.test("in-memory notes can be created and found by relPath", (assert) => { + const inMemoryNotes = []; - -## ADDED Requirements + const note1 = { + name: "note1.md", + relPath: "note1.md", + content: "# Note 1", + lastModified: Date.now(), + tags: new Set(["tag1"]), + }; -### Requirement: CSS Custom Property Theme System -The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. + const note2 = { + name: "note2.md", + relPath: "note2.md", + content: "# Note 2", + lastModified: Date.now(), + tags: new Set(["tag2"]), + }; -#### Scenario: Default theme at initial load -- **WHEN** the application loads with no saved theme preference -- **THEN** no `data-theme` attribute is present on `` -- **AND** the default dark teal palette is applied via `:root` base variables + inMemoryNotes.push(note1); + inMemoryNotes.push(note2); -#### Scenario: Named theme activation -- **WHEN** a named theme (e.g. `monokai`) is applied -- **THEN** `data-theme="monokai"` is set on `` -- **AND** the corresponding CSS variable overrides take effect immediately across all elements -- **AND** no page reload is required + const found = inMemoryNotes.find((n) => n.relPath === "note1.md"); + assert.ok(found, "Should find note by relPath"); + assert.strictEqual(found?.name, "note1.md", "Found note should have correct name"); +}); -#### Scenario: Returning to default -- **WHEN** the user selects Default (Dark) after a named theme is active -- **THEN** the `data-theme` attribute is removed from `` -- **AND** the base `:root` variable definitions are restored +QUnit.test("in-memory note content can be updated", (assert) => { + const note = { + name: "note.md", + relPath: "note.md", + content: "# Original", + lastModified: Date.now(), + tags: new Set(), + }; -### Requirement: Theme Persistence -The application SHALL persist the user's theme choice across sessions using `localStorage`. + note.content = "# Updated"; + note.lastModified = Date.now(); -#### Scenario: Theme saved on selection -- **WHEN** the user selects a theme from the View menu -- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` + assert.strictEqual(note.content, "# Updated", "Content should be updated"); + assert.ok(note.lastModified > 0, "Last modified should be updated"); +}); -#### Scenario: Theme restored on load -- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` -- **THEN** that theme is applied before the first render -- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme +QUnit.test("in-memory notes are correctly counted", (assert) => { + const inMemoryNotes = [ + { name: "note1.md", relPath: "note1.md", content: "", lastModified: 0, tags: new Set() }, + { name: "note2.md", relPath: "note2.md", content: "", lastModified: 0, tags: new Set() }, + { name: "note3.md", relPath: "note3.md", content: "", lastModified: 0, tags: new Set() }, + ]; -#### Scenario: Invalid or missing stored value -- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value -- **THEN** the default dark theme is applied -- **AND** no error is shown to the user + const count = inMemoryNotes.length; + assert.strictEqual(count, 3, "Should have 3 notes"); +}); -### Requirement: Available Colour Styles -The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. +QUnit.test("in-memory notes can be filtered by tag", (assert) => { + const inMemoryNotes = [ + { name: "note1.md", relPath: "note1.md", content: "", lastModified: 0, tags: new Set(["work", "docs"]) }, + { name: "note2.md", relPath: "note2.md", content: "", lastModified: 0, tags: new Set(["personal"]) }, + { name: "note3.md", relPath: "note3.md", content: "", lastModified: 0, tags: new Set(["work"]) }, + ]; -| Theme | Character | -|-------|-----------| -| Default (Dark) | Dark teal, original ink palette | -| Classic | Light neutral white/grey, blue accent | -| Cobalt | Dark ocean blue, gold accent | -| Monokai | Dark charcoal, green/pink highlights | -| Office | Clean professional light, Microsoft blue | -| Twilight | Warm dark grey, amber accent | -| Xcode | Crisp Apple-style light, Xcode blue | + const workNotes = inMemoryNotes.filter((n) => n.tags.has("work")); + assert.strictEqual(workNotes.length, 2, "Should have 2 work notes"); +}); -#### Scenario: Each theme covers all required variables -- **WHEN** any named theme is active -- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined -- **AND** no variable falls back to an unintended value from another theme +QUnit.test("markdown filename extraction from full path", (assert) => { + const relPath = "folder/subfolder/note.md"; + const fileName = relPath.split("/").pop(); + + assert.strictEqual(fileName, "note.md", "Should extract correct filename"); + assert.ok(fileName.endsWith(".md"), "Should be markdown file"); +}); - -## ADDED Requirements + +import QUnit from "qunit"; +import { + createFile, + ensureWorkspace, + listFiles, + listWorkspaces, + readFile, + saveFile, +} from "../../dist/test/storage.js"; -### Requirement: View Top-Level Menu -The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. +class MemoryStorage { + constructor() { + this.values = new Map(); + } -#### Scenario: View menu is visible in the menu bar -- **WHEN** the application loads -- **THEN** a "View" menu item is present in the menu bar -- **AND** it appears between "Edit" and "Import/Export" + get length() { + return this.values.size; + } -#### Scenario: View menu opens a dropdown -- **WHEN** the user clicks the View menu item -- **THEN** a dropdown appears listing all available colour styles -- **AND** the dropdown follows the same interaction model as File and Edit menus + key(index) { + return Array.from(this.values.keys())[index] ?? null; + } -### Requirement: Colour Style Selection -The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. + getItem(key) { + return this.values.has(key) ? this.values.get(key) : null; + } -#### Scenario: All themes listed -- **WHEN** the View dropdown is open -- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode + setItem(key, value) { + this.values.set(key, value); + } -#### Scenario: Selecting a theme -- **WHEN** the user clicks a theme name in the View dropdown -- **THEN** the corresponding theme is applied immediately -- **AND** the dropdown closes -- **AND** the application retains full functionality under the new colour scheme + removeItem(key) { + this.values.delete(key); + } +} -### Requirement: Active Theme Indicator -The View menu SHALL display a checkmark (✓) next to the currently active theme. +QUnit.module("storage"); -#### Scenario: Checkmark on active theme -- **WHEN** the View dropdown is open -- **THEN** a ✓ indicator is visible next to the currently active theme name -- **AND** all other theme entries show no checkmark +QUnit.test("ensureWorkspace trims name and rejects empty names", (assert) => { + const storage = new MemoryStorage(); -#### Scenario: Checkmark updates on selection -- **WHEN** the user selects a different theme -- **THEN** the checkmark moves to the newly selected theme -- **AND** the previous theme entry no longer shows a checkmark + assert.throws( + () => ensureWorkspace(storage, " "), + /Workspace name cannot be empty\./, + ); -### Requirement: Backward Compatibility -Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. + const name = ensureWorkspace(storage, " project-a "); + assert.strictEqual(name, "project-a"); + assert.deepEqual(listWorkspaces(storage), ["project-a"]); +}); -#### Scenario: Existing menus unaffected -- **WHEN** the View menu is present -- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour +QUnit.test("createFile rejects empty names and creates file once", (assert) => { + const storage = new MemoryStorage(); -#### Scenario: Existing buttons unaffected -- **WHEN** a colour theme is active -- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly -- **AND** their visual style adapts to the active theme via CSS custom properties - + ensureWorkspace(storage, "notes"); - -# Change: Add Color Style Themes + assert.throws( + () => createFile(storage, "notes", " \n "), + /File name cannot be empty\./, + ); -## Why + createFile(storage, "notes", "daily.md"); + createFile(storage, "notes", "daily.md"); -Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. + assert.deepEqual(listFiles(storage, "notes"), ["daily.md"]); + assert.strictEqual(readFile(storage, "notes", "daily.md"), ""); +}); -## What Changes +QUnit.test("listWorkspaces and listFiles are sorted", (assert) => { + const storage = new MemoryStorage(); -- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. -- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. -- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. -- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. -- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. -- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. -- The body background gradient is theme-aware via a `--body-bg` CSS custom property. + ensureWorkspace(storage, "zeta"); + ensureWorkspace(storage, "alpha"); + ensureWorkspace(storage, "beta"); -## Impact + createFile(storage, "alpha", "z.md"); + createFile(storage, "alpha", "a.md"); + createFile(storage, "alpha", "m.md"); -- Affected specs: `theming`, `view-menu` -- Affected code: - - `ink.template.html` — View menu added - - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced - - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` - - `dist/app.min.js` (rebuilt) - - `dist/styles.min.css` (rebuilt) - - `ink-app.html` (rebuilt) - + assert.deepEqual(listWorkspaces(storage), ["alpha", "beta", "zeta"]); + assert.deepEqual(listFiles(storage, "alpha"), ["a.md", "m.md", "z.md"]); +}); - -## 1. CSS Theme System +QUnit.test("readFile returns empty string when file does not exist", (assert) => { + const storage = new MemoryStorage(); + ensureWorkspace(storage, "empty"); -- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. -- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). -- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). -- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. -- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. -- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). -- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). -- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). -- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. + assert.strictEqual(readFile(storage, "empty", "missing.md"), ""); + assert.strictEqual(readFile(storage, "missing-workspace", "missing.md"), ""); +}); -## 2. View Menu (HTML Template) +QUnit.test("saveFile creates missing workspace and persists content", (assert) => { + const storage = new MemoryStorage(); -- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. -- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. -- [x] 2.3 Add `` to each theme item for active-state indication. -- [x] 2.4 Include a separator between Default (Dark) and the six named themes. + saveFile(storage, "new-workspace", "created.md", "# Hello"); -## 3. Theme Logic (TypeScript) + assert.deepEqual(listWorkspaces(storage), ["new-workspace"]); + assert.deepEqual(listFiles(storage, "new-workspace"), ["created.md"]); + assert.strictEqual(readFile(storage, "new-workspace", "created.md"), "# Hello"); +}); -- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. -- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. -- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). -- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. -- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. +QUnit.test("integration flow: workspace -> create file -> save -> read", (assert) => { + const storage = new MemoryStorage(); -## 4. Build and Verification + ensureWorkspace(storage, "workspace-a"); + createFile(storage, "workspace-a", "notes.md"); + saveFile(storage, "workspace-a", "notes.md", "# Ink\n\nSaved content"); -- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. -- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. -- [x] 4.3 Verify each theme applies immediately on selection with correct colours. -- [x] 4.4 Verify the checkmark moves to the newly selected theme. -- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. + assert.strictEqual( + readFile(storage, "workspace-a", "notes.md"), + "# Ink\n\nSaved content", + ); +}); -## 5. Documentation +QUnit.test("integration flow: data is isolated between workspaces", (assert) => { + const storage = new MemoryStorage(); -- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. -- [x] 5.2 Create this openspec change request. + createFile(storage, "workspace-a", "shared.md"); + createFile(storage, "workspace-b", "shared.md"); + + saveFile(storage, "workspace-a", "shared.md", "A"); + saveFile(storage, "workspace-b", "shared.md", "B"); + + assert.strictEqual(readFile(storage, "workspace-a", "shared.md"), "A"); + assert.strictEqual(readFile(storage, "workspace-b", "shared.md"), "B"); +}); - -## ADDED Requirements -### Requirement: Document Linter -The system SHALL provide a linting and review tool for markdown documents that analyzes prose and structure to provide actionable writing suggestions. + +import QUnit from "qunit"; +import { createUserManager } from "../../dist/test/user.js"; -#### Scenario: Analyze current document -- **WHEN** the user activates the Document Linter -- **THEN** the system parses the currently open document and returns suggestions grouped by clarity, flow, scannability, and engagement +QUnit.module("auth/user", (hooks) => { + let mockFetch; + let originalFetch; -#### Scenario: Ignore code chunks during analysis -- **WHEN** the user enables the "ignore code cells" toggle -- **THEN** the system downweights or excludes code chunks from the analysis and focuses only on human-facing text + hooks.beforeEach(function () { + originalFetch = globalThis.fetch; + mockFetch = async () => ({ + ok: true, + status: 200, + async json() { + return { + login: "testuser", + id: 12345, + avatar_url: "https://avatars.githubusercontent.com/u/12345", + name: "Test User", + }; + }, + }); + globalThis.fetch = mockFetch; + }); -#### Scenario: Generate section-specific suggestions -- **WHEN** the system analyzes a document -- **THEN** it returns actionable suggestions tied to exact lines or sections, such as "opening is vague" or "paragraphs are too dense" + hooks.afterEach(function () { + globalThis.fetch = originalFetch; + }); -#### Scenario: Provide readability metrics -- **WHEN** the system analyzes prose content -- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level + QUnit.test("fetchUser returns user data", async function (assert) { + const userManager = createUserManager(); + const user = await userManager.fetchUser("test_token"); -#### Scenario: Assess skimmability -- **WHEN** the system analyzes document structure -- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure + assert.strictEqual(user.login, "testuser"); + assert.strictEqual(user.id, 12345); + assert.strictEqual(user.avatarUrl, "https://avatars.githubusercontent.com/u/12345"); + assert.strictEqual(user.name, "Test User"); + }); -#### Scenario: Score engagement proxies -- **WHEN** the system analyzes content for engagement -- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance + QUnit.test("fetchUser caches result", async function (assert) { + const userManager = createUserManager(); + let fetchCalls = 0; -#### Scenario: Check style quality -- **WHEN** the system analyzes prose -- **THEN** it identifies spelling errors and style consistency issues + globalThis.fetch = async () => { + fetchCalls++; + return { + ok: true, + status: 200, + async json() { + return { + login: "testuser", + id: 12345, + avatar_url: "https://avatars.githubusercontent.com/u/12345", + name: "Test User", + }; + }, + }; + }; -#### Scenario: Export suggestions as markdown -- **WHEN** the user requests export -- **THEN** the system outputs the suggestions in markdown format - + await userManager.fetchUser("test_token"); + await userManager.fetchUser("test_token"); - -## ADDED Requirements -### Requirement: Document Linter -The system SHALL provide a linting and review tool for markdown documents that analyzes prose and structure to provide actionable writing suggestions. + assert.strictEqual(fetchCalls, 1, "Should only make one fetch call due to caching"); + }); -#### Scenario: Analyze current document -- **WHEN** the user activates the Document Linter -- **THEN** the system parses the currently open document and returns suggestions grouped by clarity, flow, scannability, and engagement + QUnit.test("clearCache removes cached user", async function (assert) { + const userManager = createUserManager(); + await userManager.fetchUser("test_token"); -#### Scenario: Ignore code chunks during analysis -- **WHEN** the user enables the "ignore code cells" toggle -- **THEN** the system downweights or excludes code chunks from the analysis and focuses only on human-facing text + assert.ok(userManager.getCachedUser() !== null); -#### Scenario: Generate section-specific suggestions -- **WHEN** the system analyzes a document -- **THEN** it returns actionable suggestions tied to exact lines or sections, such as "opening is vague" or "paragraphs are too dense" + userManager.clearCache(); -#### Scenario: Provide readability metrics -- **WHEN** the system analyzes prose content -- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level + assert.strictEqual(userManager.getCachedUser(), null); + }); -#### Scenario: Assess skimmability -- **WHEN** the system analyzes document structure -- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure + QUnit.test("getCachedUser returns null before fetch", function (assert) { + const userManager = createUserManager(); + assert.strictEqual(userManager.getCachedUser(), null); + }); -#### Scenario: Score engagement proxies -- **WHEN** the system analyzes content for engagement -- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance + QUnit.test("fetchUser throws on API error", async function (assert) { + const userManager = createUserManager(); -#### Scenario: Check style quality -- **WHEN** the system analyzes prose -- **THEN** it identifies spelling errors and style consistency issues + globalThis.fetch = async () => ({ + ok: false, + status: 401, + async json() { + return {}; + }, + }); -#### Scenario: Export suggestions as markdown -- **WHEN** the user requests export -- **THEN** the system outputs the suggestions in markdown format - + try { + await userManager.fetchUser("invalid_token"); + assert.ok(false, "Should have thrown"); + } catch (error) { + assert.ok(error.message.includes("Failed to fetch user profile")); + } + }); - -# Change: Add Document Linter + QUnit.test("fetchUser uses Authorization header", async function (assert) { + const userManager = createUserManager(); + let capturedHeaders = null; -## Why -Writers need editorial feedback on their documents to improve clarity, flow, scannability, and engagement. While Ink provides markdown editing functionality, there's no built-in tool for analyzing prose structure and providing actionable writing suggestions. This change adds a linting + review app that analyzes the currently open document to provide writing suggestions. + globalThis.fetch = async (url, options) => { + capturedHeaders = new Headers(options?.headers); + return { + ok: true, + status: 200, + async json() { + return { + login: "testuser", + id: 12345, + avatar_url: "https://avatars.githubusercontent.com/u/12345", + name: "Test User", + }; + }, + }; + }; -## What Changes -- Add a new Document Linter feature that analyzes the currently open markdown document -- Parse front matter (if present), headings, prose, code chunks, callouts, lists, and links -- Ignore or downweight code chunks to focus on human-facing text -- Return actionable suggestions grouped into clarity, flow, scannability, and engagement -- Implement rule-based tests plus a scoring layer for analysis -- Generate suggestions tied to exact lines or sections like a code review -- Build as a small web app with three stages: parse, analyze, generate suggestions -- Include a practical rules engine with checks like readability.max_sentence_length, structure.heading_depth_jump, etc. -- Create a simple single-page app with score panel and inline suggestions in the editor -- Implement markdown-aware parsing to isolate prose from code -- Use rule-based checks first, with optional LLM suggestions second -- Output section-by-section report plus optional "improved draft" mode + await userManager.fetchUser("test_token_xyz"); -## Impact -- Affected specs: document-linter (new capability) -- Affected code: New frontend interface, parsing logic, rule engine, suggestion generator -- Runtime dependency: None (client-side only) -- Breaking changes: none (feature is additive and opt-in) + assert.strictEqual(capturedHeaders?.get("Authorization"), "Bearer test_token_xyz"); + assert.strictEqual(capturedHeaders?.get("Accept"), "application/vnd.github+json"); + }); +}); - -## 1. Implementation -- [ ] 1.1 Create Document Linter UI component with score panel and inline suggestions -- [ ] 1.2 Implement markdown-aware parser to isolate prose from code chunks -- [ ] 1.3 Build rule-based engine for readability, skimmability, engagement, and style checks -- [ ] 1.4 Create scoring layer that generates actionable suggestions -- [ ] 1.5 Implement section-by-section reporting with inline suggestions in the editor -- [ ] 1.6 Add toggle to ignore/downweight code cells -- [ ] 1.7 Implement export functionality for suggestions as markdown -- [ ] 1.8 Integrate with existing ink.html build process -- [ ] 1.9 Test with various markdown documents to ensure accuracy -- [ ] 1.10 Validate output matches expected suggestion categories + +workflow: + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + - when: never + +stages: + - verify + +default: + interruptible: true + +variables: + GIT_DEPTH: "0" + NPM_CONFIG_CACHE: "$CI_PROJECT_DIR/.npm" + +.docs_only_check: &docs_only_check | + git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME" + changed_files="$(git diff --name-only "origin/$CI_MERGE_REQUEST_TARGET_BRANCH_NAME...$CI_COMMIT_SHA")" + + printf '%s\n' "$changed_files" + + if [ -z "$changed_files" ]; then + echo "No changed files detected; continuing with full validation." + elif echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then + echo "Non-documentation changes detected." + else + echo "Documentation-only merge request detected; skipping job." + exit 0 + fi + +.node_job: + stage: verify + image: node:20 + cache: + key: + files: + - package-lock.json + paths: + - .npm/ + before_script: + - *docs_only_check + - npm ci --prefer-offline + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + +build: + extends: .node_job + script: + - npm run build + artifacts: + paths: + - ink-app.html + expire_in: 1 week + +qunit: + extends: .node_job + script: + - npm run test:qunit + +cypress: + stage: verify + image: cypress/included:15.12.0 + variables: + CYPRESS_INSTALL_BINARY: "0" + cache: + key: + files: + - package-lock.json + paths: + - .npm/ + before_script: + - *docs_only_check + - npm ci --prefer-offline + script: + - npm run build + - npm run test:cypress + artifacts: + when: on_failure + paths: + - cypress/screenshots/ + expire_in: 1 week + rules: + - if: '$CI_PIPELINE_SOURCE == "merge_request_event"' + + + +{ + "folders": [ + { + "path": "." + } + ], + "settings": {} +} - -## ADDED Requirements -### Requirement: ESLint Code Quality -The project MUST use ESLint to enforce code quality standards on JavaScript files. + +MIT License -#### Scenario: ESLint runs successfully -- **WHEN** `npm run lint` is executed -- **THEN** ESLint analyzes all JavaScript files and reports any violations +Copyright (c) 2026 Federico Viscioletti -#### Scenario: Build includes lint check -- **WHEN** the build process runs -- **THEN** lint check is performed and build fails if errors exist +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. - -# Change: Add ESLint to the project + +{ + "$schema": "https://opencode.ai/config.json", + "model": "mistral-devstral:codestral-latest", + "provider": { + "mistral-devstral": { + "npm": "@ai-sdk/openai-compatible", + "options": { + "baseURL": "https://codestral.mistral.ai/v1", + "apiKey": "{env:MISTRAL_API_KEY}" + } + } + } +} + -## Why -The project lacks consistent code quality enforcement. Adding ESLint will help catch common errors, enforce coding standards, and improve maintainability across the JavaScript codebase. + + + + + + + Declarative WebMCP Demo + + + +

Declarative WebMCP Demo

+

This page exposes a simple note-creation tool using only HTML form attributes.

-## What Changes -- Add ESLint as a dev dependency -- Configure ESLint with appropriate rules for vanilla ES6+ JavaScript -- Integrate lint check into the build process -- Add npm script for running lint +
+ -## Impact -- Affected specs: code-quality (new capability) -- Affected code: All JavaScript files in `src/`, `build/`, `tests/` -- Build system: Add lint step to build/compile-and-assemble.js - + - -## 1. Implementation -- [x] 1.1 Install ESLint and initialize config -- [x] 1.2 Configure ESLint for ES6+ vanilla JavaScript -- [x] 1.3 Add npm lint script to package.json -- [x] 1.4 Run ESLint and fix any errors -- [x] 1.5 Integrate lint check into build process + + + + + +
- -## ADDED Requirements + +#!/bin/sh +# Pre-commit hook to run lint and update repomix output -### Requirement: Mobile Browser Detection -The system SHALL detect when the browser does not support the File System Access API. +echo "Running ESLint..." +npm run lint || exit 1 -#### Scenario: Browser lacks File System Access API -- **WHEN** the user opens ink on a browser without `showDirectoryPicker` and `FileSystemHandle` -- **THEN** the system SHALL detect this and enable in-memory workspace mode -- **AND** the system SHALL NOT throw an error blocking app usage +echo "Running repomix..." +npx repomix@latest -#### Scenario: Browser supports File System Access API -- **WHEN** the user opens ink on a browser with `showDirectoryPicker` and `FileSystemHandle` -- **THEN** the system SHALL allow opening a real folder as usual -- **AND** no in-memory mode SHALL be activated +# Add the repomix output +git add repomix-output.xml + -### Requirement: In-Memory Workspace Mode -The system SHALL provide a temporary in-memory workspace when FS API is unavailable. + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -#### Scenario: User creates note in temporary session -- **WHEN** the user clicks "New Note" in temporary session mode -- **AND** enters a note name -- **THEN** a new note SHALL be created in memory -- **AND** the user CAN edit the note content -- **AND** the user CAN save (persist to memory only) + return { + kind: "file", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + __read() { + return content; + }, + }; +} -#### Scenario: User edits note in temporary session -- **WHEN** the user modifies the editor content -- **AND** the note has unsaved changes -- **THEN** the dirty indicator SHALL show unsaved status -- **AND** the user CAN click Save to persist changes to memory +function createFakeDirectoryHandle(name) { + const entries = new Map(); -### Requirement: Export as JSON -The system SHALL provide a way to download all notes as a JSON file. + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + __entries: entries, + }; +} -#### Scenario: User exports all notes as JSON -- **WHEN** the user clicks "Export JSON" button -- **THEN** the browser SHALL download a file named `ink-export-YYYY-MM-DD.json` -- **AND** the file SHALL contain a JSON object with notes array -- **AND** each note SHALL have `name`, `path`, and `content` fields +describe("cogito mode", () => { + const workspaceName = "workspace-cogito"; + const fileStem = "cogito-note"; + const markdown = "The project should prioritize local-first writing workflows."; -#### Scenario: JSON export contains multiple notes -- **WHEN** the user has created multiple notes in temporary session -- **AND** clicks Export JSON -- **THEN** the exported JSON SHALL include all notes -- **AND** the order SHALL be preserved + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -### Requirement: Export as Markdown -The system SHALL provide a way to download individual notes as .md files. + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; -#### Scenario: User exports current note as Markdown -- **WHEN** the user clicks "Export Markdown" button while a note is open -- **THEN** the browser SHALL download a file with the note's filename -- **AND** the file SHALL contain the note's raw markdown content -- **AND** the file SHALL have `.md` extension + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + win.__cogitoCreateEngineCalls = []; + win.__cogitoCompletions = []; + win.__INK_TEST_WEBLLM__ = { + async CreateMLCEngine(modelId, options = {}) { + win.__cogitoCreateEngineCalls.push(modelId); + options.initProgressCallback?.({ text: `Stub model ready: ${modelId}` }); -### Requirement: Temporary Session UI Indicator -The system SHALL display a clear indicator when operating in temporary session mode. + return { + chat: { + completions: { + async create(payload) { + win.__cogitoCompletions.push(payload); + return { + choices: [ + { + message: { + content: JSON.stringify({ + questions: [ + "What problem does local-first editing solve here?", + "Which user evidence supports this workflow choice?", + "How will you measure whether local-first is working?", + ], + }), + }, + }, + ], + }; + }, + }, + }, + }; + }, + }; + }, + }); + }); -#### Scenario: Temporary session is active -- **WHEN** the app is running in temporary session mode -- **THEN** a visual indicator SHALL be displayed showing "Temporary Session" -- **AND** the indicator SHALL inform users their data is not persisted -- **AND** export buttons SHALL be prominently visible + it("opens the panel, switches model, generates questions, and inserts one into the editor", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#editor").clear().type(markdown); -#### Scenario: No indicator shown in normal mode -- **WHEN** the app is running with real file system access -- **THEN** no temporary session indicator SHALL be displayed -- **AND** export buttons MAY be hidden or disabled - + cy.get("#cogitoToggleBtn") + .should("have.attr", "aria-expanded", "false") + .click() + .should("have.attr", "aria-expanded", "true"); + cy.get("#cogitoPanel").should("be.visible"); + cy.get(".split").should("have.class", "with-cogito"); - -# Change: Add Mobile Fallback Support for Browsers Without File System Access API + cy.get("#cogitoDeepBtn").click().should("have.class", "active"); + cy.get("#cogitoLiteBtn").should("not.have.class", "active"); -## Why -Mobile browsers (e.g., Safari on iOS) do not support the File System Access API. Currently, ink shows an error message and prevents users from using the app. This excludes mobile users entirely. Instead, we should allow temporary in-memory work and provide export capabilities so users can download their notes. + cy.get("#cogitoGenerateBtn").click(); -## What Changes -- Detect when File System Access API is unavailable -- Enable in-memory workspace mode for temporary editing -- Add "Export as JSON" button to download all notes as a JSON file -- Add "Export as Markdown" button to download individual notes as .md files -- Show informative UI about temporary nature of the session -- Preserve existing functionality for desktop browsers with FS API support + cy.get("#statusBadge").should("contain", "Cogito questions ready"); + cy.get("#cogitoStatus").should("contain", "Questions ready"); + cy.get("#cogitoQuestionList .cogitoQuestionItem").should("have.length", 3); + cy.get("#cogitoQuestionList .cogitoQuestionText") + .eq(0) + .should("contain", "What problem does local-first editing solve here?"); -## Impact -- Affected specs: mobile-support (new capability) -- Affected code: src/app/bootstrap.ts, src/app/fs-api.ts, ink-app.html, styles -- No breaking changes to existing desktop functionality + cy.window().then((win) => { + expect(win.__cogitoCreateEngineCalls).to.deep.equal(["Qwen3-8B-q4f16_1-MLC"]); + expect(win.__cogitoCompletions).to.have.length(1); + expect(win.__cogitoCompletions[0].messages[1].content).to.contain(markdown); + }); + + cy.contains("#cogitoQuestionList .cogitoInsertBtn", "Insert").first().click(); + cy.get("#statusBadge").should("contain", "Inserted AI question"); + cy.get("#editor").should("have.value", `${markdown}> ### AI\nWhat problem does local-first editing solve here?\n`); + }); +}); - -## ADDED Requirements + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -### Requirement: ARIA Support for Menu Bar -The menu bar SHALL implement proper ARIA attributes to ensure accessibility for screen readers and assistive technologies. + return { + kind: "file", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + }; +} -#### Scenario: Menu bar has proper ARIA role -- **WHEN** a screen reader encounters the menu bar -- **THEN** the menu bar has role="menubar" attribute -- **AND** screen readers announce it as a menu bar +function createFakeDirectoryHandle(name) { + const entries = new Map(); -#### Scenario: Menu items have proper ARIA attributes -- **WHEN** a screen reader encounters menu items -- **THEN** each menu item has role="menuitem" attribute -- **AND** menu items with dropdowns have aria-haspopup="true" -- **AND** aria-expanded indicates dropdown state + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; + } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + }; +} -#### Scenario: Dropdown menus have proper ARIA attributes -- **WHEN** a dropdown menu is opened -- **THEN** the dropdown has role="menu" attribute -- **AND** each dropdown item has role="menuitem" attribute -- **AND** aria-expanded="true" on the parent menu item +describe("editor view mode toggle", () => { + const workspaceName = "view-mode-workspace"; + const noteName = "view-mode-note"; -### Requirement: Keyboard Accessibility -The menu bar SHALL support full keyboard navigation for users who cannot use a mouse. + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); -#### Scenario: Tab navigation support -- **WHEN** a user navigates using Tab key -- **THEN** focus moves to menu bar items in logical order -- **AND** focused items are visually indicated + win.prompt = (message) => { + if (message.includes("New note name")) { + return noteName; + } + return null; + }; -#### Scenario: Arrow key navigation -- **WHEN** a user navigates using arrow keys -- **THEN** Left/Right arrows move between top-level menu items -- **AND** Up/Down arrows move between dropdown menu items -- **AND** Home/End keys move to first/last items + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + }, + }); + }); -#### Scenario: Enter and Space key activation -- **WHEN** a user presses Enter or Space on a focused menu item -- **THEN** the menu item is activated -- **AND** dropdowns open or actions are executed as appropriate + it("switches between split, source, and preview layouts", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); + + cy.get("#editorSplit").should("have.class", "view-split"); + cy.get("#editorViewSplitBtn").should("have.class", "active"); -#### Scenario: Escape key handling -- **WHEN** a user presses Escape key -- **THEN** open dropdowns are closed -- **AND** focus returns to the parent menu item -- **AND** no other application functionality is affected + cy.get("#editorViewSourceBtn").click(); + cy.get("#editorSplit").should("have.class", "view-source"); + cy.get("#editorPane").should("be.visible"); + cy.get("#previewPane").should("not.be.visible"); + cy.get("#editorViewSourceBtn").should("have.class", "active"); -### Requirement: Visual Focus Indicators -The menu bar SHALL provide clear visual indicators for keyboard focus and active states. + cy.get("#editorViewPreviewBtn").click(); + cy.get("#editorSplit").should("have.class", "view-preview"); + cy.get("#editorPane").should("not.be.visible"); + cy.get("#previewPane").should("be.visible"); + cy.get("#editorViewPreviewBtn").should("have.class", "active"); -#### Scenario: Focus indicators for menu items -- **WHEN** a menu item receives keyboard focus -- **THEN** a clear visual focus indicator is displayed -- **AND** the focus indicator meets WCAG contrast requirements + cy.get("#editorViewSplitBtn").click(); + cy.get("#editorSplit").should("have.class", "view-split"); + cy.get("#editorPane").should("be.visible"); + cy.get("#previewPane").should("be.visible"); + cy.get("#editorViewSplitBtn").should("have.class", "active"); + }); +}); + -#### Scenario: Active state indicators -- **WHEN** a menu item is activated or a dropdown is open -- **THEN** clear visual indicators show the active state -- **AND** the indicators are distinguishable from focus indicators + +import "./commands"; +import "@cypress/code-coverage/support"; -#### Scenario: Hover state indicators -- **WHEN** a user hovers over menu items with a mouse -- **THEN** visual indicators show hover state -- **AND** hover indicators are consistent with focus indicators +Cypress.on("uncaught:exception", () => false); + -### Requirement: Screen Reader Announcements -The menu bar SHALL provide appropriate announcements for screen reader users. + +## ADDED Requirements -#### Scenario: Menu item descriptions -- **WHEN** a screen reader focuses on a menu item -- **THEN** the item's purpose is clearly announced -- **AND** any associated keyboard shortcuts are announced +### Requirement: Cogito Mode Menu Bar Button +The system SHALL provide a Cogito Mode button in the menu bar's top-right action area. -#### Scenario: Dropdown state announcements -- **WHEN** a dropdown menu state changes -- **THEN** screen readers announce the state change -- **AND** aria-expanded attribute is updated appropriately +#### Scenario: User sees Cogito entrypoint in the menu bar +- **WHEN** the editor UI is visible +- **THEN** a Cogito Mode button SHALL appear in the top-right menu bar actions +- **AND** the button SHALL use a thinking-man icon from the project's glyphicon library +- **AND** the button SHALL expose an accessible text label or tooltip identifying it as Cogito Mode -#### Scenario: Menu navigation announcements -- **WHEN** a user navigates between menu items -- **THEN** screen readers announce the current position -- **AND** the total number of items is announced when appropriate +#### Scenario: User toggles Cogito Mode from the button +- **WHEN** the user clicks the Cogito Mode top-right button +- **THEN** the system SHALL open or close the Cogito Mode side panel +- **AND** the toggle action SHALL not interrupt normal editing flow -### Requirement: High Contrast and Zoom Support -The menu bar SHALL support high contrast modes and browser zoom functionality. +### Requirement: Cogito Mode Side Panel +The system SHALL provide a Cogito Mode side panel where users can generate and view writing-coach questions while editing markdown. -#### Scenario: High contrast mode support -- **WHEN** high contrast mode is enabled -- **THEN** menu bar maintains proper contrast ratios -- **AND** all interactive elements remain visible and usable +#### Scenario: User opens Cogito Mode panel +- **WHEN** the user enables Cogito Mode +- **THEN** the editor SHALL show a side panel dedicated to AI questions +- **AND** the panel SHALL not prevent normal markdown editing -#### Scenario: Browser zoom support -- **WHEN** browser zoom is applied -- **THEN** menu bar layout adjusts appropriately -- **AND** no content is clipped or overlapping -- **AND** all functionality remains accessible +### Requirement: Fixed Coaching Prompt Contract +The system SHALL generate questions using a fixed writing-coach instruction that outputs JSON only with exactly three questions. -### Requirement: Error Handling and Feedback -The menu bar SHALL provide appropriate feedback for accessibility-related errors. +#### Scenario: Prompt is sent for generation +- **WHEN** Cogito Mode requests questions +- **THEN** the system SHALL use the following prompt contract: + - You are a writing coach. + - Do NOT write prose. + - Do NOT suggest sentences. + - Ask exactly 3 questions. + - Questions must be grounded in the user's last sentence. + - Output JSON only as `{ "questions": ["...", "...", "..."] }` +- **AND** no additional non-JSON content SHALL be accepted as valid output -#### Scenario: Disabled menu item feedback -- **WHEN** a user attempts to activate a disabled menu item -- **THEN** appropriate feedback is provided -- **AND** screen readers announce the item as disabled +### Requirement: Last-Sentence Grounding +The system SHALL ground generated questions in the user's most recent sentence from the active markdown content. -#### Scenario: Invalid keyboard input handling -- **WHEN** a user provides invalid keyboard input -- **THEN** the application handles it gracefully -- **AND** no unexpected behavior occurs - +#### Scenario: User has at least one sentence +- **WHEN** the user triggers question generation +- **THEN** the system SHALL extract the latest sentence from the current document +- **AND** the generated questions SHALL be based on that latest sentence context - -## ADDED Requirements +### Requirement: Exactly Three Rendered Questions +The system SHALL display exactly three generated questions in the Cogito Mode side panel. -### Requirement: Keyboard Shortcuts for Common Operations -The application SHALL provide standard keyboard shortcuts for frequently used operations accessible through the menu bar. +#### Scenario: Valid model output is returned +- **WHEN** the model returns a valid JSON payload containing three questions +- **THEN** the panel SHALL render exactly three question entries +- **AND** each entry SHALL expose an insert action for the user -#### Scenario: New Note shortcut -- **WHEN** a user presses Ctrl+E (Windows/Linux) or Cmd+E (Mac) -- **THEN** the createNewNote functionality is triggered -- **AND** the same behavior as clicking "New Note" menu item occurs +#### Scenario: Invalid model output is returned +- **WHEN** model output is missing JSON or does not contain exactly three questions +- **THEN** the system SHALL show a recoverable error state +- **AND** the editor SHALL remain usable -#### Scenario: Open Workspace shortcut -- **WHEN** a user presses Ctrl+Shift+O (Windows/Linux) or Cmd+Shift+O (Mac) -- **THEN** the openWorkspace functionality is triggered -- **AND** the same behavior as clicking "Open Workspace" menu item occurs +### Requirement: AI Question Markdown Insertion Format +The system SHALL insert selected questions into the markdown document using a standardized AI block format. -#### Scenario: Save shortcut -- **WHEN** a user presses Ctrl+S (Windows/Linux) or Cmd+S (Mac) -- **THEN** the saveCurrentNote functionality is triggered -- **AND** the same behavior as clicking "Save" menu item occurs +#### Scenario: User inserts a generated question +- **WHEN** the user chooses insert on a generated question +- **THEN** the document SHALL receive the exact block structure: + - `> ### AI` + - `` +- **AND** inserted content SHALL be plain markdown text in the active document -#### Scenario: Refresh shortcut -- **WHEN** a user presses Ctrl+L (Windows/Linux) or Cmd+L (Mac) -- **THEN** the rescanWorkspace functionality is triggered -- **AND** the same behavior as clicking "Refresh" menu item occurs +### Requirement: Web-LLM Runtime Integration +The system SHALL use `https://esm.run/@mlc-ai/web-llm` for in-browser question generation. -#### Scenario: Export JSON shortcut -- **WHEN** a user presses Ctrl+Shift+S (Windows/Linux) or Cmd+Shift+S (Mac) -- **THEN** the exportAsJson functionality is triggered -- **AND** the same behavior as clicking "Export JSON" menu item occurs +#### Scenario: Runtime is available +- **WHEN** Cogito Mode initializes successfully +- **THEN** question generation SHALL execute through the web-llm runtime in the browser -#### Scenario: Export Markdown shortcut -- **WHEN** a user presses Ctrl+Shift+M (Windows/Linux) or Cmd+Shift+M (Mac) -- **THEN** the exportAsMarkdown functionality is triggered -- **AND** the same behavior as clicking "Export Markdown" menu item occurs +#### Scenario: Runtime fails or is unsupported +- **WHEN** web-llm cannot initialize or run inference +- **THEN** the system SHALL present a clear non-blocking error/unsupported state +- **AND** users SHALL continue editing markdown without Cogito Mode assistance + -### Requirement: Keyboard Navigation of Menu Bar -The menu bar SHALL support full keyboard navigation using standard accessibility patterns. + +## Context +Cogito Mode introduces local AI-assisted questioning into the markdown editor. The feature must preserve user authorship by asking questions only, never drafting prose. The output contract is strict and machine-validated to keep behavior deterministic. -#### Scenario: Tab navigation to menu bar -- **WHEN** a user presses Tab to navigate to the menu bar -- **THEN** focus moves to the first menu item -- **AND** the menu item is visually indicated as focused +## Goals / Non-Goals +- Goals: + - Provide a discoverable Cogito Mode entrypoint as a top-right menu bar button with a thinking-man glyphicon. + - Generate exactly three coaching questions grounded in the user's latest sentence. + - Keep generation non-blocking and local to the browser runtime via web-llm. + - Insert selected questions in a recognizable markdown AI block. +- Non-Goals: + - Generating full paragraphs, rewrites, or sentence suggestions. + - Automatic insertion of generated questions without user action. + - Server-side AI inference. -#### Scenario: Arrow key navigation between menus -- **WHEN** a user presses Left/Right arrow keys while focused on menu bar -- **THEN** focus moves between menu items (File, Edit, Import/Export) -- **AND** the focused menu item is visually indicated +## Decisions +- Decision: Add Cogito Mode activation to the menu bar's top-right button cluster and use the thinking-man glyphicon from the existing glyphicon set. + - Rationale: keeps feature discovery obvious while aligning with current icon system. +- Decision: Integrate `https://esm.run/@mlc-ai/web-llm` behind a dedicated client adapter module. + - Rationale: isolates fast-changing AI runtime concerns from editor core logic. +- Decision: Keep prompt text fixed and versioned in code. + - Rationale: preserves predictable behavior and testability. +- Decision: Validate model responses as JSON and require exactly three non-empty question strings. + - Rationale: prevents malformed or verbose model outputs from corrupting UX. +- Decision: Insert question blocks with a canonical two-line structure: + - `> ### AI` + - `` + - Rationale: gives users clear provenance markers for AI-generated prompts. -#### Scenario: Enter key to open dropdown -- **WHEN** a user presses Enter while focused on a menu item -- **THEN** the dropdown menu opens -- **AND** focus moves to the first menu item in the dropdown +## Risks / Trade-offs +- Runtime/model load latency may be noticeable on first use. + - Mitigation: explicit loading state and deferred model initialization when Cogito Mode opens. +- JSON-only output may still be violated by model drift. + - Mitigation: schema validation plus retry/fail-soft messaging. +- Browser/device limitations may prevent reliable local inference. + - Mitigation: graceful disable state with explanatory copy and no editor disruption. +- Icon semantics may be interpreted differently across users. + - Mitigation: add accessible label/tooltip text such as "Cogito Mode" to the icon button. -#### Scenario: Escape key to close dropdowns -- **WHEN** a user presses Escape while a dropdown is open -- **THEN** the dropdown closes -- **AND** focus returns to the parent menu item +## Migration Plan +1. Add top-right menu bar button scaffolding and wire it to Cogito Mode panel visibility. +2. Add feature scaffolding and side panel hidden by default. +3. Land LLM adapter with mocked tests for parser/validator behavior. +4. Wire generation and insertion actions behind toggle-controlled UI. +5. Add docs and e2e checks; keep feature opt-in until validated. -#### Scenario: Up/Down arrow navigation in dropdowns -- **WHEN** a user presses Up/Down arrow keys while focused on a dropdown menu -- **THEN** focus moves between menu items in the dropdown -- **AND** the focused item is visually indicated +## Open Questions +- Which specific glyphicon class best matches the "thinking man" expectation in this project? +- Which local model profile should be the default for quality vs startup time? +- Should the three generated questions refresh automatically after every sentence boundary, or only on explicit user request? + -### Requirement: Shortcut Display in Menu Items -Menu items with keyboard shortcuts SHALL display the shortcut keys for discoverability. + +# Change: Add Cogito Mode Question Assistant -#### Scenario: Shortcut key display -- **WHEN** a user views the menu bar -- **THEN** menu items that have keyboard shortcuts display the shortcut keys -- **AND** shortcut keys are displayed in a consistent format (e.g., "Ctrl+N") +## Why +Writers can lose momentum when they need critical prompts to challenge or deepen what they just wrote. A local side-panel coach that asks targeted questions based on the latest sentence can improve reflection without auto-writing content for the user. -#### Scenario: Platform-appropriate shortcut display -- **WHEN** the application runs on different platforms -- **THEN** shortcut keys are displayed using platform-appropriate modifiers -- **AND** Windows/Linux shows "Ctrl", Mac shows "Cmd" +## What Changes +- Add a new **Cogito Mode** in the editor that runs an in-browser LLM using `https://esm.run/@mlc-ai/web-llm`. +- Add a dedicated Cogito Mode button in the menu bar's top-right action area, using a thinking-man glyphicon as the button icon. +- Generate exactly three coaching questions from the user's most recent sentence using a fixed JSON-only prompt contract. +- Display generated questions in a right-side panel, with per-question insertion into the active markdown document. +- Insert selected questions into the document using a standardized AI block format: + - `> ### AI` + - `Question here` +- Add robust validation, fallback handling, and non-blocking UI status for model loading/inference failures. -### Requirement: Shortcut Key Conflict Resolution -The application SHALL handle keyboard shortcut conflicts appropriately. +## Impact +- Affected specs: `cogito-mode` (new capability) +- Affected code: menu bar actions UI, editor layout/panel UI, markdown insertion flows, client LLM integration module, and app controller wiring +- Runtime dependency: web-llm loaded from ESM URL at runtime +- Breaking changes: none (feature is additive and opt-in) + -#### Scenario: Browser shortcut precedence -- **WHEN** a user presses a keyboard shortcut that conflicts with browser functionality -- **THEN** the application prevents the default browser behavior -- **AND** the application's shortcut functionality is executed instead + +## 1. Implementation +- [ ] 1.1 Add a Cogito Mode menu bar button in the top-right action area using a thinking-man glyphicon. +- [ ] 1.2 Add Cogito Mode feature toggle behavior from the menu bar button and side-panel shell in the editor layout. +- [ ] 1.3 Implement a web-llm client module that loads `@mlc-ai/web-llm` and exposes `generateQuestionsFromLastSentence(text)`. +- [ ] 1.4 Enforce strict prompt and output contract: exactly 3 questions via JSON `{ "questions": ["...", "...", "..."] }`. +- [ ] 1.5 Add extraction logic for the user's latest sentence and pass only that context to the question generator. +- [ ] 1.6 Render three generated questions in the side panel with per-question insert actions. +- [ ] 1.7 Implement markdown insertion with the required AI block format: + - `> ### AI` + - `` +- [ ] 1.8 Add loading, ready, and error UI states for model init/inference failures without blocking editing. +- [ ] 1.9 Add unit tests for sentence extraction, JSON validation, and insertion formatting. +- [ ] 1.10 Add end-to-end coverage for opening Cogito Mode from the top-right button, generating questions, and inserting each question into the document. -#### Scenario: Focus-based shortcut activation -- **WHEN** keyboard shortcuts are pressed -- **THEN** shortcuts are only active when the application has focus -- **AND** shortcuts do not interfere with other applications +## 2. Documentation +- [ ] 2.1 Document where to find the top-right Cogito Mode button and what the thinking-man icon represents. +- [ ] 2.2 Document Cogito Mode behavior and AI block format in user-facing docs. +- [ ] 2.3 Document model/runtime constraints and graceful degradation behavior for unsupported environments. - + ## ADDED Requirements -### Requirement: Menu Bar Structure -The application SHALL provide a horizontal menu bar at the top of the application window containing File, Edit, and Import/Export menu items. +### Requirement: CSS Custom Property Theme System +The application SHALL implement color themes using CSS custom properties, with each theme defined as a set of variable overrides on `:root[data-theme=""]`. -#### Scenario: Menu bar is visible and accessible -- **WHEN** the application loads -- **THEN** a horizontal menu bar is displayed at the top of the application window -- **AND** the menu bar contains File, Edit, and Import/Export menu items +#### Scenario: Default theme at initial load +- **WHEN** the application loads with no saved theme preference +- **THEN** no `data-theme` attribute is present on `` +- **AND** the default dark teal palette is applied via `:root` base variables -#### Scenario: Menu items are properly labeled -- **WHEN** a user views the menu bar -- **THEN** each menu item has clear, descriptive text labels -- **AND** menu items follow standard desktop application conventions +#### Scenario: Named theme activation +- **WHEN** a named theme (e.g. `monokai`) is applied +- **THEN** `data-theme="monokai"` is set on `` +- **AND** the corresponding CSS variable overrides take effect immediately across all elements +- **AND** no page reload is required -### Requirement: File Menu Functionality -The File menu SHALL provide access to workspace and file management operations. +#### Scenario: Returning to default +- **WHEN** the user selects Default (Dark) after a named theme is active +- **THEN** the `data-theme` attribute is removed from `` +- **AND** the base `:root` variable definitions are restored -#### Scenario: New Note menu item -- **WHEN** a user clicks "New Note" in the File menu -- **THEN** the existing createNewNote functionality is triggered -- **AND** the same behavior as clicking the "New Note" button occurs +### Requirement: Theme Persistence +The application SHALL persist the user's theme choice across sessions using `localStorage`. -#### Scenario: New Folder menu item -- **WHEN** a user clicks "New Folder" in the File menu -- **THEN** the existing createNewFolder functionality is triggered -- **AND** the same behavior as clicking the "New Folder" button occurs +#### Scenario: Theme saved on selection +- **WHEN** the user selects a theme from the View menu +- **THEN** the theme identifier is written to `localStorage` under the key `ink-theme` -#### Scenario: Open Workspace menu item -- **WHEN** a user clicks "Open Workspace" in the File menu -- **THEN** the existing openWorkspace functionality is triggered -- **AND** the same behavior as clicking the "Open Workspace" button occurs +#### Scenario: Theme restored on load +- **WHEN** the application initialises and a valid theme identifier exists in `localStorage` +- **THEN** that theme is applied before the first render +- **AND** the user sees their previously chosen colours immediately, without a flash of the default theme -#### Scenario: Close Workspace menu item -- **WHEN** a user clicks "Close Workspace" in the File menu -- **THEN** the workspace is closed and UI returns to initial state -- **AND** all workspace-specific UI elements are reset +#### Scenario: Invalid or missing stored value +- **WHEN** the application initialises and `localStorage` contains no `ink-theme` key or an unrecognised value +- **THEN** the default dark theme is applied +- **AND** no error is shown to the user -### Requirement: Edit Menu Functionality -The Edit menu SHALL provide access to document editing and view operations. +### Requirement: Available Colour Styles +The application SHALL provide the following seven colour styles, modeled on RStudio's built-in themes. -#### Scenario: Save menu item -- **WHEN** a user clicks "Save" in the Edit menu -- **THEN** the existing saveCurrentNote functionality is triggered -- **AND** the same behavior as clicking the "Save" button occurs +| Theme | Character | +|-------|-----------| +| Default (Dark) | Dark teal, original ink palette | +| Classic | Light neutral white/grey, blue accent | +| Cobalt | Dark ocean blue, gold accent | +| Monokai | Dark charcoal, green/pink highlights | +| Office | Clean professional light, Microsoft blue | +| Twilight | Warm dark grey, amber accent | +| Xcode | Crisp Apple-style light, Xcode blue | -#### Scenario: Refresh menu item -- **WHEN** a user clicks "Refresh" in the Edit menu -- **THEN** the existing rescanWorkspace functionality is triggered -- **AND** the same behavior as clicking the "Refresh" button occurs +#### Scenario: Each theme covers all required variables +- **WHEN** any named theme is active +- **THEN** all CSS custom properties (`--bg`, `--panel`, `--panel2`, `--border`, `--muted`, `--text`, `--accent`, `--danger`, `--ok`, `--warn`, `--shadow`, `--body-bg`) are defined +- **AND** no variable falls back to an unintended value from another theme + -#### Scenario: Sort toggle menu item -- **WHEN** a user clicks "Sort: Name/Modified" in the Edit menu -- **THEN** the existing sort functionality is triggered -- **AND** the same behavior as clicking the "Sort" button occurs + +## ADDED Requirements -#### Scenario: Collapse Sidebar menu item -- **WHEN** a user clicks "Collapse Sidebar" in the Edit menu -- **THEN** the existing setSidebarCollapsed functionality is triggered -- **AND** the same behavior as clicking the sidebar toggle button occurs +### Requirement: View Top-Level Menu +The application menu bar SHALL include a View top-level menu positioned between the Edit menu and the Import/Export menu. -### Requirement: Import/Export Menu Functionality -The Import/Export menu SHALL provide access to data export operations. +#### Scenario: View menu is visible in the menu bar +- **WHEN** the application loads +- **THEN** a "View" menu item is present in the menu bar +- **AND** it appears between "Edit" and "Import/Export" -#### Scenario: Export JSON menu item -- **WHEN** a user clicks "Export JSON" in the Import/Export menu -- **THEN** the existing exportAsJson functionality is triggered -- **AND** the same behavior as clicking the "Export JSON" button occurs +#### Scenario: View menu opens a dropdown +- **WHEN** the user clicks the View menu item +- **THEN** a dropdown appears listing all available colour styles +- **AND** the dropdown follows the same interaction model as File and Edit menus -#### Scenario: Export Markdown menu item -- **WHEN** a user clicks "Export Markdown" in the Import/Export menu -- **THEN** the existing exportAsMarkdown functionality is triggered -- **AND** the same behavior as clicking the "Export MD" button occurs +### Requirement: Colour Style Selection +The View menu dropdown SHALL list all available colour styles and allow the user to switch between them with a single click. -### Requirement: Menu Dropdown Behavior -Menu dropdowns SHALL open and close appropriately with proper keyboard and mouse interaction. +#### Scenario: All themes listed +- **WHEN** the View dropdown is open +- **THEN** the following items are present in order: Default (Dark), [separator], Classic, Cobalt, Monokai, Office, Twilight, Xcode -#### Scenario: Mouse interaction with dropdowns -- **WHEN** a user hovers over or clicks a menu item -- **THEN** the dropdown menu opens -- **AND** clicking outside the menu closes it +#### Scenario: Selecting a theme +- **WHEN** the user clicks a theme name in the View dropdown +- **THEN** the corresponding theme is applied immediately +- **AND** the dropdown closes +- **AND** the application retains full functionality under the new colour scheme -#### Scenario: Keyboard navigation of menus -- **WHEN** a user navigates using arrow keys -- **THEN** focus moves between menu items -- **AND** pressing Enter activates the focused menu item -- **AND** pressing Escape closes open dropdowns +### Requirement: Active Theme Indicator +The View menu SHALL display a checkmark (✓) next to the currently active theme. + +#### Scenario: Checkmark on active theme +- **WHEN** the View dropdown is open +- **THEN** a ✓ indicator is visible next to the currently active theme name +- **AND** all other theme entries show no checkmark + +#### Scenario: Checkmark updates on selection +- **WHEN** the user selects a different theme +- **THEN** the checkmark moves to the newly selected theme +- **AND** the previous theme entry no longer shows a checkmark ### Requirement: Backward Compatibility -The menu bar SHALL not interfere with existing button functionality. +Adding the View menu SHALL not affect any existing menu, button, or keyboard shortcut behaviour. -#### Scenario: Buttons continue to work -- **WHEN** a user clicks existing buttons -- **THEN** the same functionality is triggered as before -- **AND** menu items provide alternative access to the same functions +#### Scenario: Existing menus unaffected +- **WHEN** the View menu is present +- **THEN** File, Edit, and Import/Export menus continue to operate identically to their prior behaviour -#### Scenario: Menu and button state synchronization -- **WHEN** a menu item is clicked -- **THEN** any related button states are updated appropriately -- **AND** the application maintains consistent state across all interfaces +#### Scenario: Existing buttons unaffected +- **WHEN** a colour theme is active +- **THEN** all sidebar buttons, Save, JSON, and MD buttons continue to function correctly +- **AND** their visual style adapts to the active theme via CSS custom properties - -# Design: Office-Style Menu Bar Implementation + +# Change: Add Color Style Themes -## Context +## Why -The ink markdown note-taking application needs a proper office-style menu bar to improve user experience and provide familiar desktop application patterns. The application currently has a functional but button-heavy interface. The menu bar should integrate seamlessly with existing functionality while adding discoverability and keyboard shortcuts. +Ink currently uses a single fixed dark color scheme. Users have different preferences and work in different lighting environments, so a fixed style limits comfort and accessibility. Providing a set of well-known color themes — modeled on those available in RStudio — gives users immediate visual choices without requiring any configuration beyond a single menu click. -## Goals / Non-Goals +## What Changes -### Goals -- Provide familiar File, Edit, and Import/Export menu structure -- Add keyboard shortcuts for common operations -- Maintain full backward compatibility with existing buttons -- Ensure accessibility with proper ARIA attributes -- Keep implementation simple and maintainable -- Follow existing code patterns and architecture +- Add a **View** top-level menu to the existing office-style menu bar, placed between Edit and Import/Export. +- The View menu lists seven color styles: Default (Dark), Classic, Cobalt, Monokai, Office, Twilight, and Xcode. +- Selecting a style applies it immediately by setting a `data-theme` attribute on the `` element. +- Each theme is defined as a set of CSS custom property overrides in `src/styles.scss` using `:root[data-theme=""]` selectors. +- The active theme is indicated by a checkmark (✓) next to the selected item in the menu. +- The selected theme is persisted to `localStorage` under the key `ink-theme` and restored on next load. +- The body background gradient is theme-aware via a `--body-bg` CSS custom property. -### Non-Goals -- Replace existing button interface (menus complement, don't replace) -- Add complex menu features like toolbars or ribbons -- Implement undo/redo functionality (not currently in scope) -- Add theming or customization options -- Support for nested submenus beyond basic dropdowns +## Impact -## Decisions +- Affected specs: `theming`, `view-menu` +- Affected code: + - `ink.template.html` — View menu added + - `src/styles.scss` — theme CSS custom properties added, `--body-bg` variable introduced + - `src/app/bootstrap.ts` — `applyTheme()` and `loadTheme()` methods added; new `theme-*` action cases in `handleMenuAction()` + - `dist/app.min.js` (rebuilt) + - `dist/styles.min.css` (rebuilt) + - `ink-app.html` (rebuilt) + + + +## 1. CSS Theme System + +- [x] 1.1 Introduce `--body-bg` CSS custom property to allow per-theme control of the body background gradient. +- [x] 1.2 Define `Default (Dark)` base theme variables in `:root` (existing dark teal palette, refactored to use `--body-bg`). +- [x] 1.3 Define `Classic` theme — light neutral white/grey, blue accent (`#2c6fad`). +- [x] 1.4 Define `Cobalt` theme — dark ocean blue (`#001f3d`) with gold accent (`#ffd700`) and a blue radial gradient. +- [x] 1.5 Define `Monokai` theme — dark (`#272822`) with green accent (`#a6e22e`) and pink/green radial gradients. +- [x] 1.6 Define `Office` theme — clean professional light, Microsoft blue accent (`#0078d4`). +- [x] 1.7 Define `Twilight` theme — warm dark grey (`#141414`), amber accent (`#d4a96a`). +- [x] 1.8 Define `Xcode` theme — crisp light (`#f9f9f9`), Apple blue accent (`#0070c1`). +- [x] 1.9 Add `.menu-theme-check` CSS class for the active-theme checkmark indicator in the dropdown. -### Menu Structure Decision -**Decision**: Use a horizontal menu bar with dropdown menus for File, Edit, and Import/Export sections. +## 2. View Menu (HTML Template) -**Rationale**: This follows standard desktop application patterns and provides clear organization of functionality. The horizontal layout works well with the existing sidebar layout. +- [x] 2.1 Add a `View` menu item to `ink.template.html` between Edit and Import/Export. +- [x] 2.2 Add dropdown list items for all seven themes, each with `data-action="theme-"`. +- [x] 2.3 Add `` to each theme item for active-state indication. +- [x] 2.4 Include a separator between Default (Dark) and the six named themes. -**Alternatives considered**: -- Contextual menus only: Rejected because it reduces discoverability -- Vertical sidebar menu: Rejected because it conflicts with existing workspace sidebar -- Hybrid approach: Rejected for complexity +## 3. Theme Logic (TypeScript) -### Keyboard Shortcuts Decision -**Decision**: Implement standard desktop application shortcuts: -- Ctrl/Cmd+N: New Note -- Ctrl/Cmd+O: Open Workspace -- Ctrl/Cmd+S: Save -- F5: Refresh -- Ctrl/Cmd+Shift+S: Export JSON -- Ctrl/Cmd+Shift+M: Export Markdown +- [x] 3.1 Add `VALID_THEMES` constant array listing all accepted theme identifiers. +- [x] 3.2 Implement `applyTheme(theme: string)` method that sets/removes the `data-theme` attribute on `document.documentElement`, saves to `localStorage`, and updates the checkmark indicator. +- [x] 3.3 Implement `loadTheme()` method that reads `localStorage` on startup and calls `applyTheme()` with the saved value (defaulting to `"default"` if absent or invalid). +- [x] 3.4 Call `loadTheme()` from `initialize()` before other UI setup. +- [x] 3.5 Add `theme-default`, `theme-classic`, `theme-cobalt`, `theme-monokai`, `theme-office`, `theme-twilight`, and `theme-xcode` cases to `handleMenuAction()`. -**Rationale**: These follow established conventions from applications like VS Code, Notepad++, and other desktop editors. +## 4. Build and Verification -**Alternatives considered**: -- Custom shortcuts: Rejected for discoverability -- No shortcuts: Rejected because power users expect them +- [x] 4.1 Run `npm run build` to recompile TypeScript, SCSS, and assemble `ink-app.html`. +- [x] 4.2 Verify the View menu appears in the menu bar between Edit and Import/Export. +- [x] 4.3 Verify each theme applies immediately on selection with correct colours. +- [x] 4.4 Verify the checkmark moves to the newly selected theme. +- [x] 4.5 Verify the selected theme persists across page reloads via `localStorage`. -### Integration Approach Decision -**Decision**: Extend existing InkApp class methods rather than creating new menu-specific functions. +## 5. Documentation -**Rationale**: This maintains code reuse and ensures consistent behavior between button clicks and menu selections. The existing methods already handle edge cases and error conditions properly. +- [x] 5.1 Update `replit.md` with a Color Themes section describing each theme and the persistence mechanism. +- [x] 5.2 Create this openspec change request. + -**Alternatives considered**: -- Separate menu handlers: Rejected because it would duplicate logic -- Event delegation: Rejected because direct method calls are simpler + +## ADDED Requirements +### Requirement: Document Linter +The system SHALL provide a linting and review tool for markdown documents that analyzes prose and structure to provide actionable writing suggestions. -### Accessibility Decision -**Decision**: Implement full ARIA support with proper roles, labels, and keyboard navigation. +#### Scenario: Analyze current document +- **WHEN** the user activates the Document Linter +- **THEN** the system parses the currently open document and returns suggestions grouped by clarity, flow, scannability, and engagement -**Rationale**: Essential for screen reader users and keyboard-only navigation. Follows WCAG guidelines for menu widgets. +#### Scenario: Ignore code chunks during analysis +- **WHEN** the user enables the "ignore code cells" toggle +- **THEN** the system downweights or excludes code chunks from the analysis and focuses only on human-facing text -**Alternatives considered**: -- Basic accessibility: Rejected because it doesn't meet modern standards -- No accessibility: Rejected as unacceptable +#### Scenario: Generate section-specific suggestions +- **WHEN** the system analyzes a document +- **THEN** it returns actionable suggestions tied to exact lines or sections, such as "opening is vague" or "paragraphs are too dense" -## Risks / Trade-offs +#### Scenario: Provide readability metrics +- **WHEN** the system analyzes prose content +- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level -### Risk: Layout Complexity -**Risk**: Adding menu bar could complicate the existing layout and cause responsive design issues. +#### Scenario: Assess skimmability +- **WHEN** the system analyzes document structure +- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -**Mitigation**: -- Use CSS Grid/Flexbox for flexible layout -- Test thoroughly on different screen sizes -- Maintain existing responsive behavior +#### Scenario: Score engagement proxies +- **WHEN** the system analyzes content for engagement +- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -### Risk: Keyboard Shortcut Conflicts -**Risk**: New shortcuts might conflict with browser or OS shortcuts. +#### Scenario: Check style quality +- **WHEN** the system analyzes prose +- **THEN** it identifies spelling errors and style consistency issues -**Mitigation**: -- Use standard shortcuts that don't conflict -- Add proper event.preventDefault() handling -- Test on different platforms +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export +- **THEN** the system outputs the suggestions in markdown format + -### Risk: Performance Impact -**Risk**: Additional DOM elements and event listeners could impact performance. + +## ADDED Requirements +### Requirement: Document Linter +The system SHALL provide a linting and review tool for markdown documents that analyzes prose and structure to provide actionable writing suggestions. -**Mitigation**: -- Use event delegation where possible -- Minimize DOM manipulation -- Follow existing performance patterns +#### Scenario: Analyze current document +- **WHEN** the user activates the Document Linter +- **THEN** the system parses the currently open document and returns suggestions grouped by clarity, flow, scannability, and engagement -## Migration Plan +#### Scenario: Ignore code chunks during analysis +- **WHEN** the user enables the "ignore code cells" toggle +- **THEN** the system downweights or excludes code chunks from the analysis and focuses only on human-facing text -### Phase 1: Core Menu Structure -1. Add HTML structure and basic CSS -2. Implement menu toggle functionality -3. Add basic keyboard navigation +#### Scenario: Generate section-specific suggestions +- **WHEN** the system analyzes a document +- **THEN** it returns actionable suggestions tied to exact lines or sections, such as "opening is vague" or "paragraphs are too dense" -### Phase 2: Menu Item Implementation -1. Implement File menu items -2. Implement Edit menu items -3. Implement Import/Export menu items +#### Scenario: Provide readability metrics +- **WHEN** the system analyzes prose content +- **THEN** it calculates readability scores based on sentence length, word complexity, and reading level -### Phase 3: Polish and Testing -1. Add comprehensive tests -2. Implement accessibility features -3. Add keyboard shortcuts -4. Performance optimization +#### Scenario: Assess skimmability +- **WHEN** the system analyzes document structure +- **THEN** it evaluates headings, bullets, paragraph size, and scan-friendly structure -### Rollback Plan -If issues arise, the menu bar can be easily disabled by: -1. Removing menu HTML structure -2. Removing menu-specific CSS -3. Removing menu event handlers -4. All existing functionality remains intact +#### Scenario: Score engagement proxies +- **WHEN** the system analyzes content for engagement +- **THEN** it considers title strength, quotes, links, vocabulary richness, and length balance -## Open Questions +#### Scenario: Check style quality +- **WHEN** the system analyzes prose +- **THEN** it identifies spelling errors and style consistency issues -1. **Should we add a Help menu?** - Currently not planned, but could be added later -2. **Should menu items be disabled when not applicable?** - Yes, following existing button patterns -3. **Should we support right-click context menus?** - Not in initial implementation, could be added later -4. **Should we add menu animations?** - No, keeping it simple and fast +#### Scenario: Export suggestions as markdown +- **WHEN** the user requests export +- **THEN** the system outputs the suggestions in markdown format - -# Change: Add Office-Style Menu Bar + +# Change: Add Document Linter ## Why - -The ink markdown note-taking application currently lacks a proper office-style menu bar (File, Edit, Import/Export) that users expect from desktop applications. This makes common operations like opening workspaces, creating files/folders, and exporting content less discoverable and accessible. Adding a menu bar will improve user experience by providing familiar navigation patterns and keyboard shortcuts. +Writers need editorial feedback on their documents to improve clarity, flow, scannability, and engagement. While Ink provides markdown editing functionality, there's no built-in tool for analyzing prose structure and providing actionable writing suggestions. This change adds a linting + review app that analyzes the currently open document to provide writing suggestions. ## What Changes - -- **BREAKING**: Add a new menu bar component with File, Edit, and Import/Export sections -- Integrate existing functionality (open workspace, create files/folders, export) into menu items -- Add keyboard shortcuts for common operations (Ctrl/Cmd+N, Ctrl/Cmd+O, Ctrl/Cmd+S, etc.) -- Maintain existing button functionality while adding menu alternatives -- Add accessibility attributes and proper ARIA labeling -- Update UI layout to accommodate menu bar +- Add a new Document Linter feature that analyzes the currently open markdown document +- Parse front matter (if present), headings, prose, code chunks, callouts, lists, and links +- Ignore or downweight code chunks to focus on human-facing text +- Return actionable suggestions grouped into clarity, flow, scannability, and engagement +- Implement rule-based tests plus a scoring layer for analysis +- Generate suggestions tied to exact lines or sections like a code review +- Build as a small web app with three stages: parse, analyze, generate suggestions +- Include a practical rules engine with checks like readability.max_sentence_length, structure.heading_depth_jump, etc. +- Create a simple single-page app with score panel and inline suggestions in the editor +- Implement markdown-aware parsing to isolate prose from code +- Use rule-based checks first, with optional LLM suggestions second +- Output section-by-section report plus optional "improved draft" mode ## Impact - -- Affected specs: `menu-bar`, `keyboard-shortcuts`, `accessibility` -- Affected code: `src/app/bootstrap.ts`, `src/app/dom.ts`, `src/app/types.ts`, `ink-app.html` -- New files: Menu component implementation, CSS styles, comprehensive tests -- User experience: Improved discoverability of features, familiar desktop application patterns -- Backward compatibility: All existing functionality preserved, menu adds new access points +- Affected specs: document-linter (new capability) +- Affected code: New frontend interface, parsing logic, rule engine, suggestion generator +- Runtime dependency: None (client-side only) +- Breaking changes: none (feature is additive and opt-in) - -# 1. Implementation + +## 1. Implementation +- [ ] 1.1 Create Document Linter UI component with score panel and inline suggestions +- [ ] 1.2 Implement markdown-aware parser to isolate prose from code chunks +- [ ] 1.3 Build rule-based engine for readability, skimmability, engagement, and style checks +- [ ] 1.4 Create scoring layer that generates actionable suggestions +- [ ] 1.5 Implement section-by-section reporting with inline suggestions in the editor +- [ ] 1.6 Add toggle to ignore/downweight code cells +- [ ] 1.7 Implement export functionality for suggestions as markdown +- [ ] 1.8 Integrate with existing ink.html build process +- [ ] 1.9 Test with various markdown documents to ensure accuracy +- [ ] 1.10 Validate output matches expected suggestion categories + -## 1.1 Menu Bar Structure -- [x] 1.1.1 Add menu bar HTML structure to ink-app.html -- [x] 1.1.2 Create CSS styles for menu bar layout and dropdowns -- [x] 1.1.3 Add ARIA attributes for accessibility (role="menubar", aria-haspopup, etc.) + +## ADDED Requirements +### Requirement: ESLint Code Quality +The project MUST use ESLint to enforce code quality standards on JavaScript files. -## 1.2 File Menu Implementation -- [x] 1.2.1 Implement "New Note" menu item (Ctrl/Cmd+N) -- [x] 1.2.2 Implement "New Folder" menu item -- [x] 1.2.3 Implement "Open Workspace" menu item (Ctrl/Cmd+O) -- [x] 1.2.4 Implement "Close Workspace" menu item -- [x] 1.2.5 Implement "Exit" menu item +#### Scenario: ESLint runs successfully +- **WHEN** `npm run lint` is executed +- **THEN** ESLint analyzes all JavaScript files and reports any violations -## 1.3 Edit Menu Implementation -- [x] 1.3.1 Implement "Save" menu item (Ctrl/Cmd+S) -- [x] 1.3.2 Implement "Save As" menu item -- [x] 1.3.3 Implement "Refresh" menu item (F5) -- [x] 1.3.4 Implement "Sort" toggle menu item -- [x] 1.3.5 Implement "Collapse Sidebar" menu item +#### Scenario: Build includes lint check +- **WHEN** the build process runs +- **THEN** lint check is performed and build fails if errors exist + -## 1.4 Import/Export Menu Implementation -- [x] 1.4.1 Implement "Export JSON" menu item -- [x] 1.4.2 Implement "Export Markdown" menu item -- [ ] 1.4.3 Implement "Import JSON" menu item (if needed) + +# Change: Add ESLint to the project -## 1.5 Keyboard Shortcuts -- [x] 1.5.1 Add global keyboard event listener for menu shortcuts -- [x] 1.5.2 Implement Ctrl/Cmd+E for new note -- [x] 1.5.3 Implement Ctrl/Cmd+Shift+O for open workspace -- [x] 1.5.4 Implement Ctrl/Cmd+S for save -- [x] 1.5.5 Implement Ctrl/Cmd+L for refresh -- [x] 1.5.6 Add visual indicators for keyboard shortcuts in menu items +## Why +The project lacks consistent code quality enforcement. Adding ESLint will help catch common errors, enforce coding standards, and improve maintainability across the JavaScript codebase. -## 1.6 Integration with Existing Code -- [x] 1.6.1 Update InkApp class to handle menu events -- [x] 1.6.2 Update DOM references in dom.ts to include menu elements -- [x] 1.6.3 Update types.ts with new menu-related types -- [x] 1.6.4 Ensure menu items call existing InkApp methods -- [x] 1.6.5 Maintain backward compatibility with existing buttons +## What Changes +- Add ESLint as a dev dependency +- Configure ESLint with appropriate rules for vanilla ES6+ JavaScript +- Integrate lint check into the build process +- Add npm script for running lint -## 1.7 Testing -- [ ] 1.7.1 Add Cypress tests for menu bar functionality -- [ ] 1.7.2 Add QUnit tests for menu component logic -- [ ] 1.7.3 Test keyboard shortcuts with Cypress -- [ ] 1.7.4 Test accessibility with screen reader simulation -- [ ] 1.7.5 Test menu dropdown behavior and state management +## Impact +- Affected specs: code-quality (new capability) +- Affected code: All JavaScript files in `src/`, `build/`, `tests/` +- Build system: Add lint step to build/compile-and-assemble.js + -## 1.8 Documentation and Polish -- [ ] 1.8.1 Update README.md with new menu features -- [ ] 1.8.2 Add keyboard shortcut documentation -- [ ] 1.8.3 Test responsive design for mobile/tablet -- [x] 1.8.4 Ensure proper error handling and user feedback -- [x] 1.8.5 Code review and cleanup + +## 1. Implementation +- [x] 1.1 Install ESLint and initialize config +- [x] 1.2 Configure ESLint for ES6+ vanilla JavaScript +- [x] 1.3 Add npm lint script to package.json +- [x] 1.4 Run ESLint and fix any errors +- [x] 1.5 Integrate lint check into build process @@ -34195,6 +34807,80 @@ OWASP MASVS‑RESILIENCE * [CWE-756 Missing Custom Error Page](https://cwe.mitre.org/data/definitions/756.html) + +# Project Context + +## Purpose + +Ink is a web application for writing and editing markdown documents with export capabilities. It provides a clean, focused interface for markdown editing and document generation. + +## Tech Stack + +- **Frontend**: Vanilla JavaScript (ES6+) +- **Markdown Processing**: Marked.js v15.0.12 (embedded) +- **Build System**: Custom Node.js build script (build/compile-and-assemble.js) +- **HTML Template**: Custom template system with inlined CSS/JS +- **Target**: Single-page web application (ink.html) + +## Project Conventions + +### Code Style + +- ES6+ JavaScript modules +- Minimal dependencies - prefers vanilla JavaScript +- Single-file build output (ink.html) with inlined assets +- Functional programming patterns preferred + +### Architecture Patterns + +- **Single File Architecture**: Final build is a self-contained HTML file +- **Template Injection**: CSS and JS are injected into HTML template during build +- **No Build Framework**: Custom build process without webpack/rollup/etc. +- **Client-side Only**: Pure frontend application with no server requirements + +### Testing Strategy + +- No formal testing framework currently implemented +- Manual testing via browser +- Build verification through file generation + +### Git Workflow + +- Simple trunk-based development +- Commits should be descriptive of changes +- Main branch contains production-ready code + +## Domain Context + +- **Markdown Editing**: Core focus on markdown document creation and editing +- **Document Export**: Ability to export markdown to various formats +- **Offline Usage**: Application should work without internet connectivity +- **Single User**: Designed for individual document creation + +## Important Constraints + +- **Single File Output**: Must build to a single HTML file (ink.html) +- **No External Dependencies**: Runtime should work without CDN/internet +- **Cross-browser**: Must work in modern browsers +- **Lightweight**: Keep file size minimal for fast loading +- **No Server**: Pure client-side application + +## Keyboard Shortcuts + +- **Ctrl/Cmd + E**: Create a new note +- **Ctrl/Cmd + Shift + O**: Open a workspace +- **Ctrl/Cmd + S**: Save the current note +- **Ctrl/Cmd + L**: Refresh the workspace +- **Ctrl/Cmd + Shift + S**: Export all notes as JSON +- **Ctrl/Cmd + Shift + M**: Export the current note as Markdown + +## External Dependencies + +- **Marked.js**: Markdown parser library (embedded in build) +- **No External APIs**: Application works completely offline +- **No Backend**: No server-side components required + + export const DOCUMENT_LINTER_FALLBACK_STRENGTH = "No clear strengths stand out yet; the draft needs more signal before the linter can praise specific choices."; @@ -34406,40 +35092,6 @@ export function createAutoRefresh({ } - -import type { PermissionCapableHandle } from "./types"; - -export function isFileSystemApiAvailable(): boolean { - return Boolean(window.showDirectoryPicker && window.FileSystemHandle); -} - -export function isInMemoryMode(): boolean { - return !isFileSystemApiAvailable(); -} - -export async function ensurePermission( - handle: PermissionCapableHandle | null, - mode: "read" | "readwrite" = "read", -): Promise { - if (!handle) { - return false; - } - - if (!handle.queryPermission || !handle.requestPermission) { - return true; - } - - const descriptor = { mode }; - const current = await handle.queryPermission(descriptor); - if (current === "granted") { - return true; - } - - const requested = await handle.requestPermission(descriptor); - return requested === "granted"; -} - - export const icon = { folder: () => @@ -35643,125 +36295,6 @@ QUnit.module("fs-api", (hooks) => { }); - -import QUnit from "qunit"; - -QUnit.module("mobile fallback - JSON export format"); - -QUnit.test("JSON export structure is correct", (assert) => { - const notes = [ - { - name: "note1.md", - relPath: "note1.md", - content: "# Note 1\n\nContent here", - lastModified: 1700000000000, - tags: new Set(["tag1"]), - }, - { - name: "note2.md", - relPath: "note2.md", - content: "# Note 2\n\nMore content", - lastModified: 1700000001000, - tags: new Set(["tag2"]), - }, - ]; - - const exportData = { - exportedAt: new Date().toISOString(), - notes: notes.map((note) => ({ - name: note.name, - path: note.relPath, - content: note.content, - lastModified: new Date(note.lastModified).toISOString(), - })), - }; - - const json = JSON.stringify(exportData, null, 2); - const parsed = JSON.parse(json); - - assert.ok(parsed.exportedAt, "Should have exportedAt timestamp"); - assert.strictEqual(parsed.notes.length, 2, "Should have 2 notes"); - assert.strictEqual(parsed.notes[0].name, "note1.md", "First note should have correct name"); - assert.strictEqual(parsed.notes[0].path, "note1.md", "First note should have correct path"); - assert.ok(parsed.notes[0].content.includes("Note 1"), "First note should have content"); -}); - -QUnit.module("mobile fallback - in-memory note management"); - -QUnit.test("in-memory notes can be created and found by relPath", (assert) => { - const inMemoryNotes = []; - - const note1 = { - name: "note1.md", - relPath: "note1.md", - content: "# Note 1", - lastModified: Date.now(), - tags: new Set(["tag1"]), - }; - - const note2 = { - name: "note2.md", - relPath: "note2.md", - content: "# Note 2", - lastModified: Date.now(), - tags: new Set(["tag2"]), - }; - - inMemoryNotes.push(note1); - inMemoryNotes.push(note2); - - const found = inMemoryNotes.find((n) => n.relPath === "note1.md"); - assert.ok(found, "Should find note by relPath"); - assert.strictEqual(found?.name, "note1.md", "Found note should have correct name"); -}); - -QUnit.test("in-memory note content can be updated", (assert) => { - const note = { - name: "note.md", - relPath: "note.md", - content: "# Original", - lastModified: Date.now(), - tags: new Set(), - }; - - note.content = "# Updated"; - note.lastModified = Date.now(); - - assert.strictEqual(note.content, "# Updated", "Content should be updated"); - assert.ok(note.lastModified > 0, "Last modified should be updated"); -}); - -QUnit.test("in-memory notes are correctly counted", (assert) => { - const inMemoryNotes = [ - { name: "note1.md", relPath: "note1.md", content: "", lastModified: 0, tags: new Set() }, - { name: "note2.md", relPath: "note2.md", content: "", lastModified: 0, tags: new Set() }, - { name: "note3.md", relPath: "note3.md", content: "", lastModified: 0, tags: new Set() }, - ]; - - const count = inMemoryNotes.length; - assert.strictEqual(count, 3, "Should have 3 notes"); -}); - -QUnit.test("in-memory notes can be filtered by tag", (assert) => { - const inMemoryNotes = [ - { name: "note1.md", relPath: "note1.md", content: "", lastModified: 0, tags: new Set(["work", "docs"]) }, - { name: "note2.md", relPath: "note2.md", content: "", lastModified: 0, tags: new Set(["personal"]) }, - { name: "note3.md", relPath: "note3.md", content: "", lastModified: 0, tags: new Set(["work"]) }, - ]; - - const workNotes = inMemoryNotes.filter((n) => n.tags.has("work")); - assert.strictEqual(workNotes.length, 2, "Should have 2 work notes"); -}); - -QUnit.test("markdown filename extraction from full path", (assert) => { - const relPath = "folder/subfolder/note.md"; - const fileName = relPath.split("/").pop(); - - assert.strictEqual(fileName, "note.md", "Should extract correct filename"); - assert.ok(fileName.endsWith(".md"), "Should be markdown file"); -}); - - import QUnit from "qunit"; import { @@ -35879,6 +36412,103 @@ coverage/ } + + +# OpenSpec Instructions + +These instructions are for AI assistants working in this project. + +Always open `@/openspec/AGENTS.md` when the request: +- Mentions planning or proposals (words like proposal, spec, change, plan) +- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work +- Sounds ambiguous and you need the authoritative spec before coding + +Use `@/openspec/AGENTS.md` to learn: +- How to create and apply change proposals +- Spec format and conventions +- Project structure and guidelines + +Keep this managed block so 'openspec update' can refresh the instructions. + + + +## Agent Instructions + +You are an AI-first software engineer. Assume all code will be written and maintained by LLMs, not humans. Optimize for model reasoning, regeneration, and debugging — not human aesthetics. + +Your goal: produce code that is predictable, debuggable, and easy for future LLMs to rewrite or extend. + +ALWAYS work in small, verfiable steps. Keep each step discrete: read relevant repo files → implement a small coherent change → validate (run available checks / manual smoke test) → record learnings back into OpenSpec artifacts or repo guidance. Prefer updating the repo's instruction chain (e.g., `AGENTS.md` and OpenSpec specs/notes) over relying on chat history. + +ALWAYS ground work in the repo's sourche of truth. Before coding (and whenever using a language/library/tool), consult OpenSpec specs/steering docs and any repo guidance (`AGENTS.md` `README.md`, etc.), then check primary/official documentation as needed. Don't rely on training-memory for fast-moving APIs; treat docs + repo specs as authoritative. + +Each time you complete a task or learn important information about the project, you should update the `.github/copilot-instructions.md` or any `agent.md` file that might be in the project to reflect any new information that you've learned or changes that require updates to these instructions files. + +ALWAYS check your work before returning control to the user. Run tests if available, verify builds, etc. Never return incomplete or unverified work to the user. + +Be a good steward of terminal instances. Try and reuse existing terminals where possible and use the VS Code API to close terminals that are no longer needed each time you open a new terminal. + +## Mandatory Coding Principles + +These coding principles are mandatory: + +1. Structure + - Use a consistent, predictable project layout. + - Group code by feature/screen; keep shared utilities minimal. + - Create simple, obvious entry points. + - Before scaffolding multiple files, identify shared structure first. Use framework-native composition patterns (layouts, base templates, providers, shared components) for elements that appear across pages. Duplication that requires the same fix in multiple places is a code smell, not a pattern to preserve. + +2. Architecture + - Prefer flat, explicit code over abstractions or deep hierarchies. + - Avoid clever patterns, metaprogramming, and unnecessary indirection. + - Minimize coupling so files can be safely regenerated. + +3. Functions and Modules + - Keep control flow linear and simple. + - Use small-to-medium functions; avoid deeply nested logic. + - Pass state explicitly; avoid globals. + +4. Naming and Comments + - Use descriptive-but-simple names. + - Comment only to note invariants, assumptions, or external requirements. + +5. Logging and Errors + - Emit detailed, structured logs at key boundaries. + - Make errors explicit and informative. + +6. Regenerability + - Write code so any file/module can be rewritten from scratch without breaking the system. + - Prefer clear, declarative configuration (JSON/YAML/etc.). + +7. Platform Use + - Use platform conventions directly and simply (e.g., WinUI/WPF) without over-abstracting. + +8. Modifications + - When extending/refactoring, follow existing patterns. + - Prefer full-file rewrites over micro-edits unless told otherwise. + +9. Quality + - Favor deterministic, testable behavior. + - Keep tests simple and focused on verifying observable behavior. + +## Pre-commit Hook Setup + +To enforce repomix updates locally, set up the pre-commit hook: + +```bash +# Copy the pre-commit hook into place +cp .git/hooks/pre-commit.sample .git/hooks/pre-commit 2>/dev/null || true +# Or create it manually +cat > .git/hooks/pre-commit << 'EOF' +#!/bin/sh +echo "Running repomix..." +npx repomix@latest +git add repomix-output.xml +EOF +chmod +x .git/hooks/pre-commit +``` + + { "presets": ["@babel/preset-typescript"], @@ -35954,20 +36584,64 @@ github: feddernico ko_fi: feddernico - -{ - "$schema": "https://opencode.ai/config.json", - "model": "mistral-devstral:codestral-latest", - "provider": { - "mistral-devstral": { - "npm": "@ai-sdk/openai-compatible", - "options": { - "baseURL": "https://codestral.mistral.ai/v1", - "apiKey": "{env:MISTRAL_API_KEY}" - } - } - } -} + +# Ink - Markdown Editor Web App + +## Overview +Ink is a functional and minimalistic single-page web application for writing documents in markdown and exporting them. + +## Architecture +- **Type**: Static frontend-only SPA (no backend) +- **Language**: TypeScript (compiled via esbuild) +- **Styles**: SCSS (compiled via sass) +- **Output**: Single HTML file (`ink-app.html`) with all JS and CSS inlined + +## Project Structure +- `src/app.ts` - App entrypoint +- `src/app/` - Feature modules (bootstrap, dom, fs-api, types) +- `src/tags.ts` - Tag/frontmatter parsing utilities +- `src/styles.scss` - SCSS styles +- `ink.template.html` - HTML template source +- `ink-app.html` - Final built single-page app (served directly) +- `build/` - Build scripts (esbuild + sass) +- `dist/` - Intermediate compiled output +- `assets/branding/` - Logo and favicon SVGs + +## Build +```bash +npm install +npm run build # One-time build +npm run watch # Auto-rebuild on changes +``` + +## Serving (Development) +Workflow "Start application" runs: +``` +npx http-server . -p 5000 -s +``` +App is accessible at: `http://localhost:5000/ink-app.html` + +## Deployment +- Target: Static site +- Build command: `npm run build` +- Public directory: `.` (root, serves `ink-app.html`) + +## Color Themes +Six RStudio-style color themes available under the **View** menu: +- **Default (Dark)** — original dark teal theme +- **Classic** — light, neutral grey/white +- **Cobalt** — dark ocean blue with gold accents +- **Monokai** — iconic dark theme with green/pink highlights +- **Office** — clean professional light theme (Microsoft Office palette) +- **Twilight** — warm dark grey with amber accents +- **Xcode** — light theme inspired by Apple Xcode + +The selected theme is persisted in `localStorage` under the key `ink-theme`. Themes are applied via a `data-theme` attribute on ``, driven by CSS custom property overrides in `src/styles.scss`. + +## Testing +- QUnit unit tests: `npm run test:qunit` +- Cypress e2e tests: `npm run test:cypress` +- Full suite: `npm test` @@ -46569,146 +47243,6 @@ In conclusion, this document tests various aspects of the linter including reada Some might say that the passive voice was used in this sentence, which could be flagged by the style checker. - -# For most projects, this workflow file will not need changing; you simply need -# to commit it to your repository. -# -# You may wish to alter this file to override the set of languages analyzed, -# or to provide custom queries or build logic. -# -# ******** NOTE ******** -# We have attempted to detect the languages in your repository. Please check -# the `language` matrix defined below to confirm you have the correct set of -# supported CodeQL languages. -# -name: "CodeQL Advanced" - -on: - pull_request: - branches: [ "main" ] - schedule: - - cron: '30 16 * * 2' - -jobs: - changes: - name: Detect changed files - runs-on: ubuntu-latest - outputs: - docs_only: ${{ steps.filter.outputs.docs_only }} - steps: - - name: Checkout repository - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Classify event changes - id: filter - shell: bash - run: | - if [ "${{ github.event_name }}" != "pull_request" ]; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - base_sha="${{ github.event.pull_request.base.sha }}" - head_sha="${{ github.event.pull_request.head.sha }}" - changed_files="$(git diff --name-only "$base_sha" "$head_sha")" - - printf '%s\n' "$changed_files" - - if [ -z "$changed_files" ]; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then - echo "docs_only=false" >> "$GITHUB_OUTPUT" - exit 0 - fi - - echo "docs_only=true" >> "$GITHUB_OUTPUT" - - analyze: - name: Analyze (${{ matrix.language }}) - needs: changes - if: needs.changes.outputs.docs_only != 'true' - # Runner size impacts CodeQL analysis time. To learn more, please see: - # - https://gh.io/recommended-hardware-resources-for-running-codeql - # - https://gh.io/supported-runners-and-hardware-resources - # - https://gh.io/using-larger-runners (GitHub.com only) - # Consider using larger runners or machines with greater resources for possible analysis time improvements. - runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} - permissions: - # required for all workflows - security-events: write - - # required to fetch internal or private CodeQL packs - packages: read - - # only required for workflows in private repositories - actions: read - contents: read - - strategy: - fail-fast: false - matrix: - include: - - language: javascript-typescript - build-mode: none - # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' - # Use `c-cpp` to analyze code written in C, C++ or both - # Use 'java-kotlin' to analyze code written in Java, Kotlin or both - # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both - # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, - # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. - # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how - # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages - steps: - - name: Checkout repository - uses: actions/checkout@v4 - - # Add any setup steps before running the `github/codeql-action/init` action. - # This includes steps like installing compilers or runtimes (`actions/setup-node` - # or others). This is typically only required for manual builds. - # - name: Setup runtime (example) - # uses: actions/setup-example@v1 - - # Initializes the CodeQL tools for scanning. - - name: Initialize CodeQL - uses: github/codeql-action/init@v4 - with: - languages: ${{ matrix.language }} - build-mode: ${{ matrix.build-mode }} - # If you wish to specify custom queries, you can do so here or in a config file. - # By default, queries listed here will override any specified in a config file. - # Prefix the list here with "+" to use these queries and those in the config file. - - # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs - # queries: security-extended,security-and-quality - - # If the analyze step fails for one of the languages you are analyzing with - # "We were unable to automatically build your code", modify the matrix above - # to set the build mode to "manual" for that language. Then modify this step - # to build your code. - # ℹ️ Command-line programs to run using the OS shell. - # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun - - name: Run manual build steps - if: matrix.build-mode == 'manual' - shell: bash - run: | - echo 'If you are using a "manual" build mode for one or more of the' \ - 'languages you are analyzing, replace this with the commands to build' \ - 'your code, for example:' - echo ' make bootstrap' - echo ' make release' - exit 1 - - - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v4 - with: - category: "/language:${{matrix.language}}" - - function createFakeFileHandle(name, initialContent = "") { let content = initialContent; @@ -47483,35 +48017,6 @@ describe("workspace actions regression", () => { }); - -## 1. Implementation -- [x] 1.1 Update `src/app/fs-api.ts` - Add function to check if FS API is available -- [x] 1.2 Update `src/app/bootstrap.ts` - Modify `openWorkspace()` to enter in-memory mode when FS API unavailable -- [x] 1.3 Add in-memory workspace state management (notes stored in memory, not on disk) -- [x] 1.4 Add "Export as JSON" button to UI (download all notes) -- [x] 1.5 Add "Export as Markdown" button to UI (download single note) -- [x] 1.6 Add UI indicator showing "Temporary Session" mode -- [x] 1.7 Update `ink-app.html` with new export buttons -- [x] 1.8 Update `src/styles.scss` with temporary session styles -- [x] 1.9 Test in-memory mode works without errors -- [x] 1.10 Test export JSON downloads correctly -- [x] 1.11 Test export Markdown downloads correctly - -## 2. Cypress Tests -- [x] 2.1 Create `cypress/e2e/mobile-fallback.cy.js` -- [x] 2.2 Test: When FS API unavailable, app shows temporary session mode -- [x] 2.3 Test: User can create and edit notes in memory -- [x] 2.4 Test: Export JSON button downloads valid JSON file -- [x] 2.5 Test: Export Markdown button downloads valid .md file - -## 3. QUnit Tests -- [x] 3.1 Create `tests/qunit/mobile-fallback.test.js` -- [x] 3.2 Test: isFileSystemApiAvailable() returns correct values based on browser -- [x] 3.3 Test: In-memory notes can be created and retrieved -- [x] 3.4 Test: JSON export formats notes correctly -- [x] 3.5 Test: Markdown export includes frontmatter and content - - ## 1. Dependency Setup - [x] 1.1 Add `marked` as an npm dependency via `npm install marked`. @@ -49855,101 +50360,47 @@ QUnit.test("leaves plain text unchanged", (assert) => { }); - - -# OpenSpec Instructions - -These instructions are for AI assistants working in this project. - -Always open `@/openspec/AGENTS.md` when the request: -- Mentions planning or proposals (words like proposal, spec, change, plan) -- Introduces new capabilities, breaking changes, architecture shifts, or big performance/security work -- Sounds ambiguous and you need the authoritative spec before coding - -Use `@/openspec/AGENTS.md` to learn: -- How to create and apply change proposals -- Spec format and conventions -- Project structure and guidelines - -Keep this managed block so 'openspec update' can refresh the instructions. - - - -## Agent Instructions - -You are an AI-first software engineer. Assume all code will be written and maintained by LLMs, not humans. Optimize for model reasoning, regeneration, and debugging — not human aesthetics. - -Your goal: produce code that is predictable, debuggable, and easy for future LLMs to rewrite or extend. - -ALWAYS work in small, verfiable steps. Keep each step discrete: read relevant repo files → implement a small coherent change → validate (run available checks / manual smoke test) → record learnings back into OpenSpec artifacts or repo guidance. Prefer updating the repo's instruction chain (e.g., `AGENTS.md` and OpenSpec specs/notes) over relying on chat history. - -ALWAYS ground work in the repo's sourche of truth. Before coding (and whenever using a language/library/tool), consult OpenSpec specs/steering docs and any repo guidance (`AGENTS.md` `README.md`, etc.), then check primary/official documentation as needed. Don't rely on training-memory for fast-moving APIs; treat docs + repo specs as authoritative. - -Each time you complete a task or learn important information about the project, you should update the `.github/copilot-instructions.md` or any `agent.md` file that might be in the project to reflect any new information that you've learned or changes that require updates to these instructions files. - -ALWAYS check your work before returning control to the user. Run tests if available, verify builds, etc. Never return incomplete or unverified work to the user. - -Be a good steward of terminal instances. Try and reuse existing terminals where possible and use the VS Code API to close terminals that are no longer needed each time you open a new terminal. - -## Mandatory Coding Principles - -These coding principles are mandatory: - -1. Structure - - Use a consistent, predictable project layout. - - Group code by feature/screen; keep shared utilities minimal. - - Create simple, obvious entry points. - - Before scaffolding multiple files, identify shared structure first. Use framework-native composition patterns (layouts, base templates, providers, shared components) for elements that appear across pages. Duplication that requires the same fix in multiple places is a code smell, not a pattern to preserve. - -2. Architecture - - Prefer flat, explicit code over abstractions or deep hierarchies. - - Avoid clever patterns, metaprogramming, and unnecessary indirection. - - Minimize coupling so files can be safely regenerated. - -3. Functions and Modules - - Keep control flow linear and simple. - - Use small-to-medium functions; avoid deeply nested logic. - - Pass state explicitly; avoid globals. + +modules = ["web", "nodejs-20"] +[agent] +expertMode = true -4. Naming and Comments - - Use descriptive-but-simple names. - - Comment only to note invariants, assumptions, or external requirements. +[nix] +channel = "stable-25_05" +packages = ["gh"] -5. Logging and Errors - - Emit detailed, structured logs at key boundaries. - - Make errors explicit and informative. +[workflows] +runButton = "Project" -6. Regenerability - - Write code so any file/module can be rewritten from scratch without breaking the system. - - Prefer clear, declarative configuration (JSON/YAML/etc.). +[[workflows.workflow]] +name = "Project" +mode = "parallel" +author = "agent" -7. Platform Use - - Use platform conventions directly and simply (e.g., WinUI/WPF) without over-abstracting. +[[workflows.workflow.tasks]] +task = "workflow.run" +args = "Start application" -8. Modifications - - When extending/refactoring, follow existing patterns. - - Prefer full-file rewrites over micro-edits unless told otherwise. +[[workflows.workflow]] +name = "Start application" +author = "agent" -9. Quality - - Favor deterministic, testable behavior. - - Keep tests simple and focused on verifying observable behavior. +[[workflows.workflow.tasks]] +task = "shell.exec" +args = "npx http-server . -p 5000 --proxy http://localhost:5000? -s" +waitForPort = 5000 -## Pre-commit Hook Setup +[workflows.workflow.metadata] +outputType = "webview" -To enforce repomix updates locally, set up the pre-commit hook: +[deployment] +deploymentTarget = "static" +build = ["npm", "run", "build"] +publicDir = "." -```bash -# Copy the pre-commit hook into place -cp .git/hooks/pre-commit.sample .git/hooks/pre-commit 2>/dev/null || true -# Or create it manually -cat > .git/hooks/pre-commit << 'EOF' -#!/bin/sh -echo "Running repomix..." -npx repomix@latest -git add repomix-output.xml -EOF -chmod +x .git/hooks/pre-commit -``` +[[ports]] +localPort = 5000 +externalPort = 80 @@ -49971,6 +50422,46 @@ export default defineConfig({ }); + +.PHONY: help lint build watch test test-qunit test-cypress update-repomix + +help: + @echo "Available targets:" + @echo " lint - Run ESLint" + @echo " build - Build the project" + @echo " watch - Watch for changes and rebuild" + @echo " test - Run all tests (QUnit and Cypress)" + @echo " test-qunit - Run QUnit tests" + @echo " test-cypress - Run Cypress tests" + @echo " repomix - Update repomix to the latest version" + +lint: + @echo "Running ESLint..." + npm run lint + +build: + @echo "Building the project..." + npm run build + +watch: + @echo "Watching for changes..." + npm run watch + +test: test-qunit test-cypress + +test-qunit: + @echo "Running QUnit tests..." + npm run test:qunit + +test-cypress: + @echo "Running Cypress tests..." + npm run test:cypress + +repomix: + @echo "Updating repomix to the latest version..." + npx repomix@latest + + { "$schema": "https://raw.githubusercontent.com/googleapis/release-please/main/schemas/config.json", @@ -49983,64 +50474,144 @@ export default defineConfig({ } - -# Ink - Markdown Editor Web App + +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" -## Overview -Ink is a functional and minimalistic single-page web application for writing documents in markdown and exporting them. +on: + pull_request: + branches: [ "main" ] + schedule: + - cron: '30 16 * * 2' -## Architecture -- **Type**: Static frontend-only SPA (no backend) -- **Language**: TypeScript (compiled via esbuild) -- **Styles**: SCSS (compiled via sass) -- **Output**: Single HTML file (`ink-app.html`) with all JS and CSS inlined +jobs: + changes: + name: Detect changed files + runs-on: ubuntu-latest + outputs: + docs_only: ${{ steps.filter.outputs.docs_only }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 -## Project Structure -- `src/app.ts` - App entrypoint -- `src/app/` - Feature modules (bootstrap, dom, fs-api, types) -- `src/tags.ts` - Tag/frontmatter parsing utilities -- `src/styles.scss` - SCSS styles -- `ink.template.html` - HTML template source -- `ink-app.html` - Final built single-page app (served directly) -- `build/` - Build scripts (esbuild + sass) -- `dist/` - Intermediate compiled output -- `assets/branding/` - Logo and favicon SVGs + - name: Classify event changes + id: filter + shell: bash + run: | + if [ "${{ github.event_name }}" != "pull_request" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -## Build -```bash -npm install -npm run build # One-time build -npm run watch # Auto-rebuild on changes -``` + base_sha="${{ github.event.pull_request.base.sha }}" + head_sha="${{ github.event.pull_request.head.sha }}" + changed_files="$(git diff --name-only "$base_sha" "$head_sha")" -## Serving (Development) -Workflow "Start application" runs: -``` -npx http-server . -p 5000 -s -``` -App is accessible at: `http://localhost:5000/ink-app.html` + printf '%s\n' "$changed_files" -## Deployment -- Target: Static site -- Build command: `npm run build` -- Public directory: `.` (root, serves `ink-app.html`) + if [ -z "$changed_files" ]; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -## Color Themes -Six RStudio-style color themes available under the **View** menu: -- **Default (Dark)** — original dark teal theme -- **Classic** — light, neutral grey/white -- **Cobalt** — dark ocean blue with gold accents -- **Monokai** — iconic dark theme with green/pink highlights -- **Office** — clean professional light theme (Microsoft Office palette) -- **Twilight** — warm dark grey with amber accents -- **Xcode** — light theme inspired by Apple Xcode + if echo "$changed_files" | grep -qvE '(^|/)[^/]+\.md$|^LICENSE$|^FUNDING\.yml$'; then + echo "docs_only=false" >> "$GITHUB_OUTPUT" + exit 0 + fi -The selected theme is persisted in `localStorage` under the key `ink-theme`. Themes are applied via a `data-theme` attribute on ``, driven by CSS custom property overrides in `src/styles.scss`. + echo "docs_only=true" >> "$GITHUB_OUTPUT" -## Testing -- QUnit unit tests: `npm run test:qunit` -- Cypress e2e tests: `npm run test:cypress` -- Full suite: `npm test` + analyze: + name: Analyze (${{ matrix.language }}) + needs: changes + if: needs.changes.outputs.docs_only != 'true' + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: javascript-typescript + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" @@ -50126,2003 +50697,1605 @@ jobs: path: cypress/screenshots - -# Project Context - -## Purpose - -Ink is a web application for writing and editing markdown documents with export capabilities. It provides a clean, focused interface for markdown editing and document generation. - -## Tech Stack - -- **Frontend**: Vanilla JavaScript (ES6+) -- **Markdown Processing**: Marked.js v15.0.12 (embedded) -- **Build System**: Custom Node.js build script (build/compile-and-assemble.js) -- **HTML Template**: Custom template system with inlined CSS/JS -- **Target**: Single-page web application (ink.html) - -## Project Conventions - -### Code Style - -- ES6+ JavaScript modules -- Minimal dependencies - prefers vanilla JavaScript -- Single-file build output (ink.html) with inlined assets -- Functional programming patterns preferred - -### Architecture Patterns - -- **Single File Architecture**: Final build is a self-contained HTML file -- **Template Injection**: CSS and JS are injected into HTML template during build -- **No Build Framework**: Custom build process without webpack/rollup/etc. -- **Client-side Only**: Pure frontend application with no server requirements - -### Testing Strategy - -- No formal testing framework currently implemented -- Manual testing via browser -- Build verification through file generation - -### Git Workflow - -- Simple trunk-based development -- Commits should be descriptive of changes -- Main branch contains production-ready code - -## Domain Context - -- **Markdown Editing**: Core focus on markdown document creation and editing -- **Document Export**: Ability to export markdown to various formats -- **Offline Usage**: Application should work without internet connectivity -- **Single User**: Designed for individual document creation - -## Important Constraints - -- **Single File Output**: Must build to a single HTML file (ink.html) -- **No External Dependencies**: Runtime should work without CDN/internet -- **Cross-browser**: Must work in modern browsers -- **Lightweight**: Keep file size minimal for fast loading -- **No Server**: Pure client-side application - -## Keyboard Shortcuts - -- **Ctrl/Cmd + E**: Create a new note -- **Ctrl/Cmd + Shift + O**: Open a workspace -- **Ctrl/Cmd + S**: Save the current note -- **Ctrl/Cmd + L**: Refresh the workspace -- **Ctrl/Cmd + Shift + S**: Export all notes as JSON -- **Ctrl/Cmd + Shift + M**: Export the current note as Markdown - -## External Dependencies - -- **Marked.js**: Markdown parser library (embedded in build) -- **No External APIs**: Application works completely offline -- **No Backend**: No server-side components required - - - -modules = ["web", "nodejs-20"] -[agent] -expertMode = true - -[nix] -channel = "stable-25_05" -packages = ["gh"] - -[workflows] -runButton = "Project" - -[[workflows.workflow]] -name = "Project" -mode = "parallel" -author = "agent" - -[[workflows.workflow.tasks]] -task = "workflow.run" -args = "Start application" - -[[workflows.workflow]] -name = "Start application" -author = "agent" - -[[workflows.workflow.tasks]] -task = "shell.exec" -args = "npx http-server . -p 5000 --proxy http://localhost:5000? -s" -waitForPort = 5000 - -[workflows.workflow.metadata] -outputType = "webview" - -[deployment] -deploymentTarget = "static" -build = ["npm", "run", "build"] -publicDir = "." - -[[ports]] -localPort = 5000 -externalPort = 80 - - - -## 1. Sidebar Toggle Visibility on Mobile - -- [x] 1.1 Remove `display: none` from `.sidebarToggle` inside the `@media (max-width: 980px)` block in `src/styles.scss`. -- [x] 1.2 Remove `display: none` from `.sidebar.collapsed .sidebarToggle` inside the same media block. -- [x] 1.3 Inside the media block render the toggle as a full-width horizontal button: set `width: calc(100% - 20px)`, reset `writing-mode: horizontal-tb`, `text-orientation: mixed`, `letter-spacing: normal`. -- [x] 1.4 Set `display: flex` on `.sidebar.collapsed .sidebarToggle` in the media block so it is explicitly visible when collapsed. -- [x] 1.5 Verify the toggle button is visible and tappable on a ≤ 980 px viewport both when the sidebar is expanded and when it is collapsed. - -## 2. Sidebar Collapsed State Collapses Vertically on Mobile - -- [x] 2.1 Inside the `@media (max-width: 980px)` block add `height: auto; min-height: 0` to `.sidebar.collapsed` so the sidebar row shrinks to just the toggle strip when collapsed. -- [x] 2.2 Add `.app.sidebar-collapsed { grid-template-columns: 1fr; }` inside the media block to override the base `64px 1fr` rule and prevent a two-column layout appearing on mobile when the sidebar is collapsed. -- [x] 2.3 Confirm `.sidebar.collapsed .sidebarPanel { display: none; }` remains active and is not overridden. -- [x] 2.4 Verify that tapping the toggle on a collapsed sidebar expands it and shows the workspace panel; tapping again collapses it back to the strip. - -## 3. App Grid Fills Viewport Height on Mobile - -- [x] 3.1 Inside the `@media (max-width: 980px)` block replace `grid-auto-rows: max-content` and `align-content: start` with `grid-template-rows: auto auto 1fr` and `height: 100%` on `.app`. -- [x] 3.2 Confirm that `html, body { height: 100%; }` is already declared at the base level in `src/styles.scss` (confirmed at line 126). -- [x] 3.3 Verify the main editor row fills all remaining viewport height below the menu bar and sidebar. - -## 4. Editor and Preview Fill Available Vertical Space - -- [x] 4.1 Add `min-height: 40vh` to `.editorPane` inside the mobile media block so the editor pane is usably tall when stacked below the preview. -- [x] 4.2 Add `min-height: 40vh` to `.previewPane` inside the mobile media block. -- [x] 4.3 Confirm `textarea { height: 100%; }` is set at the base level and not overridden in the media block. -- [x] 4.4 Verify on a 390 × 844 px viewport that the textarea fills its pane and scrolling (if any) occurs inside the pane, not on the outer page. - -## 5. Build and Verification - -- [x] 5.1 Run `npm run build` and confirm zero errors. -- [x] 5.2 Run `npm run test:qunit` and confirm all 32 tests pass. -- [x] 5.3 Verify on a ≤ 980 px viewport: - - Sidebar toggle is visible in both expanded and collapsed states. - - Collapsing the sidebar shrinks it to a horizontal strip at the top; editor expands to fill the freed vertical space. - - Expanding restores the full sidebar panel. - - Textarea and preview panes fill the viewport height without outer-page scrolling. -- [x] 5.4 Verify on a > 980 px viewport that the desktop two-column layout is unaffected. - - - -## 1. HTML Template - -- [x] 1.1 Remove the `#newNoteBtn` button element (`📄`) from the sidebar header icon row in `ink.template.html`. -- [x] 1.2 Remove the `#newFolderBtn` button element (`📁+`) from the sidebar header icon row in `ink.template.html`. -- [x] 1.3 Remove the `#openFolderBtn` button element (`🗂️`) from the sidebar header icon row in `ink.template.html`. -- [x] 1.4 Remove the enclosing `
` wrapper for those three icon buttons if it becomes empty. -- [x] 1.5 Remove the `#saveBtn` button element from the editor header status area in `ink.template.html`. -- [x] 1.6 Remove the `#exportJsonBtn` button element (`JSON`) from the editor header status area in `ink.template.html`. -- [x] 1.7 Remove the `#exportMdBtn` button element (`MD`) from the editor header status area in `ink.template.html`. - -## 2. TypeScript — Types - -- [x] 2.1 Remove `newNoteBtn`, `newFolderBtn`, `openFolderBtn`, `saveBtn`, `exportJsonBtn`, and `exportMdBtn` fields from the `DomRefs` interface in `src/app/types.ts`. - -## 3. TypeScript — DOM References - -- [x] 3.1 Remove `newNoteBtn`, `newFolderBtn`, `openFolderBtn`, `saveBtn`, `exportJsonBtn`, and `exportMdBtn` from the `getDomRefs()` return object in `src/app/dom.ts`. - -## 4. TypeScript — Bootstrap / Event Listeners and State Management - -- [x] 4.1 Remove the `this.els.newNoteBtn.addEventListener(...)` block from `attachEventListeners()` in `src/app/bootstrap.ts`. -- [x] 4.2 Remove the `this.els.newFolderBtn.addEventListener(...)` block from `attachEventListeners()`. -- [x] 4.3 Remove the `this.els.openFolderBtn.addEventListener(...)` block from `attachEventListeners()`. -- [x] 4.4 Remove the `this.els.saveBtn.addEventListener(...)` block from `attachEventListeners()`. -- [x] 4.5 Remove the `this.els.exportJsonBtn.addEventListener(...)` block from `attachEventListeners()`. -- [x] 4.6 Remove the `this.els.exportMdBtn.addEventListener(...)` block from `attachEventListeners()`. -- [x] 4.7 Remove all `this.els.saveBtn.disabled = ...` assignments throughout `bootstrap.ts` (including in `updateDirtyUi()`, `openWorkspace()`, and `closeWorkspace()`). -- [x] 4.8 Remove all `this.els.exportJsonBtn.disabled = ...` assignments throughout `bootstrap.ts`. -- [x] 4.9 Remove all `this.els.exportMdBtn.disabled = ...` assignments throughout `bootstrap.ts`. -- [x] 4.10 Remove the `enableExportButtons()` private method and its call site. - -## 5. Tests — New QUnit Tests - -- [x] 5.1 Add a QUnit test asserting that `#saveBtn` is not present in `ink-app.html`. -- [x] 5.2 Add a QUnit test asserting that `#exportJsonBtn` is not present in `ink-app.html`. -- [x] 5.3 Add a QUnit test asserting that `#exportMdBtn` is not present in `ink-app.html`. -- [x] 5.4 Add a QUnit test asserting that `#newNoteBtn`, `#newFolderBtn`, and `#openFolderBtn` are not present in `ink-app.html`. -- [x] 5.5 Add a QUnit test asserting that the dirty-dot indicator (`#dirtyDot`) is still present in `ink-app.html` after button removal. -- [x] 5.6 Add a QUnit test asserting that the status badge (`#statusBadge`) is still present in `ink-app.html` after button removal. - -## 6. Tests — New Cypress Tests - -- [x] 6.1 Add a Cypress test confirming the Save button is absent from the editor header. -- [x] 6.2 Add a Cypress test confirming that Cmd/Ctrl+S still saves the current note successfully. -- [x] 6.3 Add a Cypress test confirming the dirty-dot indicator appears after editing and disappears after saving via keyboard shortcut. -- [x] 6.4 Add a Cypress test confirming that the File menu New Note and New Folder items still function correctly. -- [x] 6.5 Add a Cypress test confirming that Import/Export > Export JSON and Export Markdown items still function correctly. - -## 7. Build and Verification - -- [x] 7.1 Run `npm run build` and confirm zero TypeScript compilation errors. -- [x] 7.2 Run `npm run test:qunit` and confirm all tests pass (baseline: 32 / 32). -- [x] 7.3 Run `npm run test:cypress` and confirm all new Cypress tests pass. Note: The existing tests (editor-flow.cy.js and mobile-fallback.cy.js) use the removed buttons and need to be updated separately. -- [x] 7.4 Verify visually that the sidebar header no longer shows the three icon buttons. -- [x] 7.5 Verify visually that the editor header status area shows only the dirty-dot indicator and status badge. -- [x] 7.6 Verify the dirty-dot indicator and "Unsaved changes" status badge correctly reflect unsaved state. -- [x] 7.7 Verify all remaining menu and keyboard-shortcut paths for the removed buttons continue to work end-to-end. - - - -import type { DomRefs } from "./types"; - -export const COGITO_PROMPT = `You are a writing coach. - -Rules: -- Do NOT write prose. -- Do NOT suggest sentences. -- Ask exactly 3 questions. -- Questions must be grounded in the user's last sentence. -- Output JSON only in this format: -{ - "questions": ["...", "...", "..."] -}`; - -export const LITE_MODEL = "Llama-3.2-1B-Instruct-q4f32_1-MLC"; -export const DEEP_MODEL = "Qwen3-8B-q4f16_1-MLC"; - -export type CogitoModel = "lite" | "deep"; - -type ChatEngine = { - chat: { - completions: { - create: (payload: { - messages: Array<{ role: "system" | "user"; content: string }>; - temperature?: number; - }) => Promise<{ choices?: Array<{ message?: { content?: unknown } }> }>; - }; - }; -}; + +function createFakeFileHandle(name, initialContent = "") { + let content = initialContent; + let lastModified = Date.now(); -type WebLlmModule = { - CreateMLCEngine: ( - modelId: string, - options?: { - initProgressCallback?: (progress: { text?: string }) => void; + return { + kind: "file", + name, + async queryPermission() { + return "granted"; }, - ) => Promise; -}; - -type ToastFn = (message: string, options?: { persist?: boolean }) => void; - -type SetStatusFn = (message: string, kind?: "ok" | "warn" | "err") => void; - -type CogitoController = { - togglePanel: () => void; - setPanelOpen: (isOpen: boolean) => void; - isPanelOpen: () => boolean; - closePanel: () => void; - selectModel: (model: CogitoModel) => void; - generateQuestions: () => Promise; - insertQuestionAtIndex: (index: number) => void; -}; - -export function extractLastSentence(text: string): string { - const normalized = text.replace(/\s+/g, " ").trim(); - if (!normalized) { - return ""; - } - - const fragments = normalized - .split(/(?<=[.!?])\s+/) - .map((fragment) => fragment.trim()) - .filter(Boolean); - - if (fragments.length === 0) { - return ""; - } - - return fragments[fragments.length - 1]; -} - -export function parseCogitoQuestionPayload(raw: string): string[] { - const jsonText = raw - .replace(/[\s\S]*?<\/think>/gi, "") - .replace(/^```(?:json)?\s*/i, "") - .replace(/```\s*$/, "") - .trim(); - const parsed = JSON.parse(jsonText) as { questions?: unknown }; - - if (!parsed || !Array.isArray(parsed.questions)) { - throw new Error("Cogito response did not include a questions array."); - } - - const sanitized = parsed.questions - .filter((q): q is string => typeof q === "string" && q.trim().length > 0) - .map((q) => q.trim()); - - if (sanitized.length === 0) { - throw new Error("Cogito response contained no valid questions."); - } - - if (sanitized.length !== 3) { - throw new Error("Cogito response must contain exactly 3 questions."); - } - - return sanitized; -} - -export function formatCogitoQuestionBlock(question: string): string { - return `> ### AI\n${question.trim()}\n`; -} - -export function insertTextAtCursor(textarea: HTMLTextAreaElement, text: string): void { - const { selectionStart, selectionEnd, value } = textarea; - const before = value.slice(0, selectionStart); - const after = value.slice(selectionEnd); - textarea.value = `${before}${text}${after}`; - const nextCursor = before.length + text.length; - textarea.setSelectionRange(nextCursor, nextCursor); + async requestPermission() { + return "granted"; + }, + async getFile() { + return new File([content], name, { type: "text/markdown", lastModified }); + }, + async createWritable() { + return { + async write(nextContent) { + content = String(nextContent); + lastModified = Date.now(); + }, + async close() {}, + }; + }, + __read() { + return content; + }, + }; } -export function createCogitoController({ - els, - getEditorText, - onEditorContentReplaced, - showToast, - setStatus, -}: { - els: DomRefs; - getEditorText: () => string; - onEditorContentReplaced: (text: string) => void; - showToast: ToastFn; - setStatus: SetStatusFn; -}): CogitoController { - let isPanelOpen = false; - let generatedQuestions: string[] = []; - let selectedModel: CogitoModel = "lite"; - const engineCache: Partial>> = {}; +function createFakeDirectoryHandle(name) { + const entries = new Map(); - async function loadWebLlmModule(): Promise { - const testModule = ( - globalThis as typeof globalThis & { - __INK_TEST_WEBLLM__?: WebLlmModule; + return { + kind: "directory", + name, + async queryPermission() { + return "granted"; + }, + async requestPermission() { + return "granted"; + }, + async *entries() { + for (const entry of entries.entries()) { + yield entry; } - ).__INK_TEST_WEBLLM__; - - if (testModule) { - return testModule; - } - - return import("https://esm.run/@mlc-ai/web-llm") as Promise; - } - - function selectModel(model: CogitoModel): void { - selectedModel = model; - els.cogitoLiteBtn.classList.toggle("active", model === "lite"); - els.cogitoDeepBtn.classList.toggle("active", model === "deep"); - } - - function setPanelVisibility(isOpen: boolean): void { - isPanelOpen = isOpen; - els.cogitoPanel.hidden = !isOpen; - els.cogitoToggleBtn.setAttribute("aria-expanded", String(isOpen)); - const split = els.cogitoPanel.closest(".split"); - if (split) { - split.classList.toggle("with-cogito", isOpen); - } - } + }, + async getFileHandle(fileName, options = {}) { + const existing = entries.get(fileName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`File not found: ${fileName}`); + } + const handle = createFakeFileHandle(fileName); + entries.set(fileName, handle); + return handle; + }, + async getDirectoryHandle(dirName, options = {}) { + const existing = entries.get(dirName); + if (existing) { + return existing; + } + if (!options.create) { + throw new Error(`Directory not found: ${dirName}`); + } + const handle = createFakeDirectoryHandle(dirName); + entries.set(dirName, handle); + return handle; + }, + __entries: entries, + }; +} - function updateQuestionList(questions: string[]): void { - els.cogitoQuestionList.innerHTML = ""; - questions.forEach((question, index) => { - const item = document.createElement("li"); - item.className = "cogitoQuestionItem"; +describe("ink authoring flow", () => { + const workspaceName = "workspace-a"; + const fileStem = "notes"; + const fileName = `${fileStem}.md`; + const markdown = "# Ink flow\n\nThis is markdown content."; - const text = document.createElement("p"); - text.className = "cogitoQuestionText"; - text.textContent = question; + beforeEach(() => { + cy.visit("/ink-app.html", { + onBeforeLoad(win) { + const root = createFakeDirectoryHandle(workspaceName); - const button = document.createElement("button"); - button.type = "button"; - button.className = "ghost cogitoInsertBtn"; - button.dataset.questionIndex = String(index); - button.textContent = "Insert"; - button.title = "Insert question into markdown"; + win.prompt = (message) => { + if (message.includes("New note name")) { + return fileStem; + } + return null; + }; - item.append(text, button); - els.cogitoQuestionList.appendChild(item); + win.confirm = () => true; + win.FileSystemHandle = function FileSystemHandle() {}; + win.showDirectoryPicker = async () => root; + win.__fakeWorkspace = root; + }, }); - } - - function setCogitoStatus(text: string): void { - els.cogitoStatus.textContent = text; - } + }); - async function getOrCreateEngine(): Promise { - const modelId = selectedModel === "deep" ? DEEP_MODEL : LITE_MODEL; + it("selects workspace, creates a new file, edits markdown, and saves", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("#workspaceName").should("contain", workspaceName); - if (engineCache[selectedModel]) { - return engineCache[selectedModel]!; - } + cy.get("[data-action=\"new-note\"]").click({ force: true }); + cy.get("#currentFilename").should("contain", fileName); - engineCache[selectedModel] = (async () => { - setCogitoStatus(`Loading ${selectedModel === "deep" ? "Deep (Qwen3 8B)" : "Lite (Llama 1B)"} model...`); - const webllm = await loadWebLlmModule(); - const engine = await webllm.CreateMLCEngine(modelId, { - initProgressCallback: (progress: { text?: string }) => { - if (progress?.text) { - setCogitoStatus(progress.text); - } - }, - }); - return engine as ChatEngine; - })().catch((error: unknown) => { - delete engineCache[selectedModel]; - throw error; - }); + cy.get("#editor").clear().type(markdown); + cy.get("[data-action=\"save\"]").click({ force: true }); + cy.get("#statusBadge").should("contain", "Saved"); - return engineCache[selectedModel]!; - } + cy.window().then(async (win) => { + const handle = await win.__fakeWorkspace.getFileHandle(fileName); + expect(handle.__read()).to.eq(markdown); + }); + }); - async function generateQuestions(): Promise { - const lastSentence = extractLastSentence(getEditorText()); - if (!lastSentence) { - setCogitoStatus("Write at least one sentence first, then generate Cogito questions."); - setStatus("Cogito needs a sentence", "warn"); - return; - } + it("renders markdown preview when editing a note", () => { + cy.get("[data-action=\"open-workspace\"]").click({ force: true }); + cy.get("[data-action=\"new-note\"]").click({ force: true }); - try { - els.cogitoGenerateBtn.disabled = true; - setCogitoStatus("Generating 3 questions..."); + cy.get("#editor").clear().type("# Hello World\n\nThis is a paragraph."); - const engine = await getOrCreateEngine(); - const completion = await engine.chat.completions.create({ - messages: [ - { role: "system", content: COGITO_PROMPT }, - { role: "user", content: `Last sentence: ${lastSentence}` }, - ], - temperature: 0.2, - }); + cy.get("#preview").find("h1").should("contain", "Hello World"); + cy.get("#preview").find("p").should("contain", "This is a paragraph."); + }); - const rawContent = completion.choices?.[0]?.message?.content; - const textContent = Array.isArray(rawContent) - ? rawContent - .map((chunk) => (typeof chunk === "string" ? chunk : "")) - .join("") - .trim() - : typeof rawContent === "string" - ? rawContent.trim() - : ""; + it("collapses and expands the left menu, updating accessibility state and editor space", () => { + let expandedEditorWidth = 0; + let collapsedEditorWidth = 0; - if (!textContent) { - throw new Error("Cogito returned an empty response."); - } + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "true") + .and("contain", "▶ Collapse") + .and("be.visible"); + cy.get(".app").should("not.have.class", "sidebar-collapsed"); - generatedQuestions = parseCogitoQuestionPayload(textContent); - updateQuestionList(generatedQuestions); - setCogitoStatus("Questions ready. Insert one into your markdown when useful."); - setStatus("Cogito questions ready", "ok"); - } catch (error: unknown) { - generatedQuestions = []; - updateQuestionList(generatedQuestions); - const message = error instanceof Error ? error.message : String(error); - setCogitoStatus(`Cogito error: ${message}`); - setStatus("Cogito unavailable", "warn"); - showToast(`Cogito failed: ${message}`, { persist: true }); - } finally { - els.cogitoGenerateBtn.disabled = false; - } - } + cy.get("#editor").then(($editor) => { + expandedEditorWidth = $editor[0].getBoundingClientRect().width; + }); - function insertQuestionAtIndex(index: number): void { - const question = generatedQuestions[index]; - if (!question) { - showToast("Cogito question not found.", { persist: true }); - return; - } + cy.get("#sidebarToggleBtn").click(); + cy.get(".app").should("have.class", "sidebar-collapsed"); + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "false") + .and("contain", "▼ Expand"); - const block = formatCogitoQuestionBlock(question); - insertTextAtCursor(els.editor, block); - onEditorContentReplaced(els.editor.value); - setStatus("Inserted AI question", "ok"); - } + cy.get("#editor").then(($editor) => { + collapsedEditorWidth = $editor[0].getBoundingClientRect().width; + expect(collapsedEditorWidth).to.be.greaterThan(expandedEditorWidth); + }); - setPanelVisibility(false); + cy.get("#sidebarToggleBtn").focus().type("{enter}"); + cy.get(".app").should("not.have.class", "sidebar-collapsed"); + cy.get("#sidebarToggleBtn") + .should("have.attr", "aria-expanded", "true") + .and("contain", "Collapse"); - return { - togglePanel: () => { - setPanelVisibility(!isPanelOpen); - if (isPanelOpen) { - setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); - } - }, - setPanelOpen: (nextIsOpen: boolean) => { - setPanelVisibility(nextIsOpen); - if (nextIsOpen) { - setCogitoStatus("Cogito Mode enabled. Generate questions from your last sentence."); - } - }, - isPanelOpen: () => isPanelOpen, - closePanel: () => { - setPanelVisibility(false); - }, - selectModel, - generateQuestions, - insertQuestionAtIndex, - }; -} + cy.get("#editor").then(($editor) => { + const finalEditorWidth = $editor[0].getBoundingClientRect().width; + expect(finalEditorWidth).to.be.lessThan(collapsedEditorWidth); + }); + }); +}); - -import { icon } from "./icons"; -import type { - AppState, - DeclarativeNoteInput, - DeclarativeNoteResult, - DirectoryHandleLike, - DirectoryNode, - DomRefs, - FileHandleLike, - FileNode, - InMemoryNoteRecord, - NoteRecord, - TreeNode, -} from "./types"; + +# ink +

+ Ink logo +

-type ToastFn = (message: string, options?: { persist?: boolean }) => void; -type StatusFn = (message: string | null, kind?: "neutral" | "ok" | "warn" | "err") => void; +Ink is a functional and minimalistic webapp to write documents in markdown and export them. -type RenderPreviewFn = (els: DomRefs, text: string) => void; -type UpdateDirtyFn = (els: DomRefs, state: AppState, setStatus: StatusFn) => void; +![TypeScript](https://img.shields.io/badge/TypeScript-5.x-3178C6?logo=typescript&logoColor=white) +![Sass](https://img.shields.io/badge/Sass-SCSS-CC6699?logo=sass&logoColor=white) +![Cypress](https://img.shields.io/badge/Tested%20with-Cypress-17202C?logo=cypress&logoColor=white) +![GitHub Pages](https://img.shields.io/badge/Deployed%20on-GitHub%20Pages-222222?logo=github&logoColor=white) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) -type TreeRenderFns = { - renderTree: () => Promise; - renderInMemoryTree: () => void; - renderTags: () => void; - updateCountsPill: () => void; -}; +## Quick Demo -type FsApi = { - ensurePermission: (handle: DirectoryHandleLike, mode: "read" | "readwrite") => Promise; - isFileSystemApiAvailable: () => boolean; -}; +![](assets/demo/ink-demo.gif) -type AutoRefreshFns = { - startAutoRefresh: () => void; - stopAutoRefresh: () => void; -}; +## Repo Structure -type TagParser = (text: string) => Set; -type TagNormalizer = (value: string) => string; +The structure of the repo is as follows: -export function createWorkspaceActions({ - state, - els, - showToast, - setStatus, - renderPreview, - updateDirtyUi, - renderTree, - renderInMemoryTree, - renderTags, - updateCountsPill, - fsApi, - parseTags, - normalizeTag, - autoRefresh, -}: { - state: AppState; - els: DomRefs; - showToast: ToastFn; - setStatus: StatusFn; - renderPreview: RenderPreviewFn; - updateDirtyUi: UpdateDirtyFn; - renderTree: TreeRenderFns["renderTree"]; - renderInMemoryTree: TreeRenderFns["renderInMemoryTree"]; - renderTags: TreeRenderFns["renderTags"]; - updateCountsPill: TreeRenderFns["updateCountsPill"]; - fsApi: FsApi; - parseTags: TagParser; - normalizeTag: TagNormalizer; - autoRefresh: AutoRefreshFns; -}) { - function activateTemporarySession(): void { - const wasTemporarySession = state.isTemporarySession; +``` +src/ + app.ts (Thin app entrypoint) + app/ (Feature modules: app-controller, ui-events, workspace-io, tree-render, dom, fs-api, types) + tags.ts (Tag/frontmatter parsing utilities) + test-support/ + storage-fixture.ts (Test-only storage helpers) + styles.scss (The app styles, in SCSS) +dist/ + app.min.js + styles.min.css +ink.template.html (The HTML template source) +ink-app.html (The final single-page app, with inline